Feat: Ajout Fron sous-traitant

This commit is contained in:
kevin 2026-05-22 15:57:50 +03:00
parent 163d3ff08d
commit a02a753b77
37 changed files with 1677 additions and 377 deletions

View File

@ -8,6 +8,7 @@ use App\Http\Requests\StoreInterventionWithAllDataRequest;
use App\Http\Requests\UpdateInterventionRequest;
use App\Http\Resources\Intervention\InterventionResource;
use App\Http\Resources\Intervention\InterventionCollection;
use App\Models\SousTraitant;
use App\Repositories\InterventionRepositoryInterface;
use App\Repositories\InterventionPractitionerRepositoryInterface;
use App\Repositories\ClientRepositoryInterface;
@ -154,6 +155,9 @@ class InterventionController extends Controller
// Wrap everything in a database transaction
$result = DB::transaction(function () use ($validated) {
$client = null;
$sousTraitant = null;
// Step 1: Handle Deceased (Create or Link)
$deceased = null;
if (!empty($validated['deceased_id'])) {
@ -164,18 +168,21 @@ class InterventionController extends Controller
$deceased = $this->deceasedRepository->create($deceasedData);
}
// Step 2: Link existing client or create a new one
// Step 2: Link existing client, existing sous-traitant, or create a new client
if (!empty($validated['client_id'])) {
$client = $this->clientRepository->find($validated['client_id']);
}
elseif (!empty($validated['sous_traitant_id'])) {
$sousTraitant = SousTraitant::findOrFail($validated['sous_traitant_id']);
}
else {
$clientData = $validated['client'];
$clientData = $validated['client'] ?? [];
$client = $this->clientRepository->create($clientData);
}
// Step 3: Create the contact (if provided)
// Step 3: Create the contact only when a client exists
$contactId = null;
if (!empty($validated['contact'])) {
if (!empty($validated['contact']) && $client) {
$contactData = array_merge($validated['contact'], [
'client_id' => $client->id
]);
@ -188,7 +195,7 @@ class InterventionController extends Controller
$locationId = $validated['location_id'] ?? null;
$locationNotes = '';
if (!$locationId && !empty($locationData)) {
if (!$locationId && !empty($locationData) && $client) {
// Create new location for the client
$locData = array_merge($locationData, [
'client_id' => $client->id,
@ -229,7 +236,8 @@ class InterventionController extends Controller
// Step 5: Create the intervention
$interventionData = array_merge($validated['intervention'], [
'deceased_id' => $deceased->id,
'client_id' => $client->id,
'client_id' => $client?->id,
'sous_traitant_id' => $sousTraitant?->id,
'location_id' => $locationId,
'notes' => ($validated['intervention']['notes'] ?? '') . $locationNotes
]);
@ -288,7 +296,8 @@ class InterventionController extends Controller
$totalTtc = $totalHt + $totalTva;
$quoteData = [
'client_id' => $client->id,
'client_id' => $client?->id,
'sous_traitant_id' => $sousTraitant?->id,
'status' => 'brouillon',
'quote_date' => now()->toDateString(),
'currency' => 'EUR',
@ -345,6 +354,7 @@ class InterventionController extends Controller
'intervention' => $intervention,
'deceased' => $deceased,
'client' => $client,
'sous_traitant' => $sousTraitant,
'contact_id' => $contactId,
'documents_count' => count($documents)
];
@ -353,7 +363,8 @@ class InterventionController extends Controller
Log::info('Intervention with all data created successfully', [
'intervention_id' => $result['intervention']->id,
'deceased_id' => $result['deceased']->id,
'client_id' => $result['client']->id,
'client_id' => $result['client']?->id,
'sous_traitant_id' => $result['sous_traitant']?->id,
'documents_count' => $result['documents_count']
]);
@ -363,6 +374,7 @@ class InterventionController extends Controller
'intervention' => new InterventionResource($result['intervention']),
'deceased' => $result['deceased'],
'client' => $result['client'],
'sous_traitant' => $result['sous_traitant'],
'contact_id' => $result['contact_id'],
'documents_count' => $result['documents_count']
]

View File

@ -7,24 +7,16 @@ use Illuminate\Validation\Rule;
class StoreInterventionRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
// Add authorization logic if needed
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'client_id' => ['required', 'exists:clients,id'],
'client_id' => ['nullable', 'exists:clients,id'],
'sous_traitant_id' => ['nullable', 'exists:sous_traitants,id'],
'deceased_id' => ['nullable', 'exists:deceased,id'],
'order_giver' => ['nullable', 'string', 'max:255'],
'location_id' => ['nullable', 'exists:client_locations,id'],
@ -35,7 +27,7 @@ class StoreInterventionRequest extends FormRequest
'exhumation',
'retrait_pacemaker',
'retrait_bijoux',
'autre'
'autre',
])],
'scheduled_at' => ['nullable', 'date_format:Y-m-d H:i:s'],
'duration_min' => ['nullable', 'integer', 'min:0'],
@ -44,7 +36,7 @@ class StoreInterventionRequest extends FormRequest
'planifie',
'en_cours',
'termine',
'annule'
'annule',
])],
'practitioners' => ['nullable', 'array'],
'practitioners.*' => ['exists:thanatopractitioners,id'],
@ -52,33 +44,44 @@ class StoreInterventionRequest extends FormRequest
'assistant_practitioner_ids' => ['nullable', 'array'],
'assistant_practitioner_ids.*' => ['exists:thanatopractitioners,id'],
'notes' => ['nullable', 'string'],
'created_by' => ['nullable', 'exists:users,id']
'created_by' => ['nullable', 'exists:users,id'],
];
}
/**
* Get custom error messages for validator errors.
*/
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$hasClient = filled($this->input('client_id'));
$hasSousTraitant = filled($this->input('sous_traitant_id'));
if (! $hasClient && ! $hasSousTraitant) {
$message = 'Un client ou un sous-traitant est obligatoire.';
$validator->errors()->add('client_id', $message);
$validator->errors()->add('sous_traitant_id', $message);
}
});
}
public function messages(): array
{
return [
'client_id.required' => 'Le client est obligatoire.',
'client_id.exists' => 'Le client sélectionné est invalide.',
'deceased_id.exists' => 'Le défunt sélectionné est invalide.',
'order_giver.max' => 'Le donneur d\'ordre ne peut pas dépasser 255 caractères.',
'location_id.exists' => 'Le lieu sélectionné est invalide.',
'type.required' => 'Le type d\'intervention est obligatoire.',
'type.in' => 'Le type d\'intervention est invalide.',
'scheduled_at.date_format' => 'Le format de la date programmée est invalide.',
'duration_min.integer' => 'La durée doit être un nombre entier.',
'duration_min.min' => 'La durée ne peut pas être négative.',
'status.in' => 'Le statut de l\'intervention est invalide.',
'practitioners.array' => 'Les praticiens doivent être un tableau.',
'practitioners.*.exists' => 'Un des praticiens sélectionnés est invalide.',
'principal_practitioner_id.exists' => 'Le praticien principal sélectionné est invalide.',
'assistant_practitioner_ids.array' => 'Les praticiens assistants doivent être un tableau.',
'client_id.exists' => 'Le client selectionne est invalide.',
'sous_traitant_id.exists' => 'Le sous-traitant selectionne est invalide.',
'deceased_id.exists' => 'Le defunt selectionne est invalide.',
'order_giver.max' => 'Le donneur d ordre ne peut pas depasser 255 caracteres.',
'location_id.exists' => 'Le lieu selectionne est invalide.',
'type.required' => 'Le type d intervention est obligatoire.',
'type.in' => 'Le type d intervention est invalide.',
'scheduled_at.date_format' => 'Le format de la date programmee est invalide.',
'duration_min.integer' => 'La duree doit etre un nombre entier.',
'duration_min.min' => 'La duree ne peut pas etre negative.',
'status.in' => 'Le statut de l intervention est invalide.',
'practitioners.array' => 'Les praticiens doivent etre un tableau.',
'practitioners.*.exists' => 'Un des praticiens selectionnes est invalide.',
'principal_practitioner_id.exists' => 'Le praticien principal selectionne est invalide.',
'assistant_practitioner_ids.array' => 'Les praticiens assistants doivent etre un tableau.',
'assistant_practitioner_ids.*.exists' => 'Un des praticiens assistants est invalide.',
'created_by.exists' => 'L\'utilisateur créateur est invalide.'
'created_by.exists' => 'L utilisateur createur est invalide.',
];
}
}

View File

@ -33,8 +33,9 @@ class StoreInterventionWithAllDataRequest extends FormRequest
'deceased.notes' => ['nullable', 'string'],
'client_id' => ['nullable', 'exists:clients,id'],
'client' => 'required_without:client_id|array',
'client.name' => ['required', 'string', 'max:255'],
'sous_traitant_id' => ['nullable', 'exists:sous_traitants,id'],
'client' => 'required_without_all:client_id,sous_traitant_id|array',
'client.name' => ['required_without_all:client_id,sous_traitant_id', 'string', 'max:255'],
'client.vat_number' => ['nullable', 'string', 'max:32'],
'client.siret' => ['nullable', 'string', 'max:20'],
'client.email' => ['nullable', 'email', 'max:191'],
@ -103,6 +104,21 @@ class StoreInterventionWithAllDataRequest extends FormRequest
];
}
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$hasClient = filled($this->input('client_id'));
$hasSousTraitant = filled($this->input('sous_traitant_id'));
$hasNewClient = filled($this->input('client.name'));
if (! $hasClient && ! $hasSousTraitant && ! $hasNewClient) {
$message = 'Un client ou un sous-traitant est obligatoire.';
$validator->errors()->add('client_id', $message);
$validator->errors()->add('sous_traitant_id', $message);
}
});
}
/**
* Get custom error messages for validator errors.
*/
@ -117,6 +133,7 @@ class StoreInterventionWithAllDataRequest extends FormRequest
'deceased.place_of_death.max' => 'Le lieu de décès ne peut pas dépasser 255 caractères.',
'client.required' => 'Les informations du client sont obligatoires.',
'sous_traitant_id.exists' => 'Le sous-traitant sélectionné est invalide.',
'client.name.required' => 'Le nom du client est obligatoire.',
'client.name.max' => 'Le nom du client ne peut pas dépasser 255 caractères.',
'client.vat_number.max' => 'Le numéro de TVA ne peut pas dépasser 32 caractères.',

View File

@ -6,23 +6,16 @@ use Illuminate\Foundation\Http\FormRequest;
class StoreInvoiceRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'client_id' => 'nullable|exists:clients,id',
'sous_traitant_id' => 'nullable|exists:sous_traitants,id',
'group_id' => 'nullable|exists:client_groups,id',
'source_quote_id' => 'nullable|exists:quotes,id',
'status' => 'required|in:brouillon,emise,envoyee,partiellement_payee,payee,echue,annulee,avoir',
@ -53,19 +46,20 @@ class StoreInvoiceRequest extends FormRequest
{
$validator->after(function ($validator) {
$hasClient = filled($this->input('client_id'));
$hasSousTraitant = filled($this->input('sous_traitant_id'));
$hasGroup = filled($this->input('group_id'));
if (! $hasClient && ! $hasGroup) {
$message = 'Un client ou un groupe de clients est obligatoire.';
if (! $hasClient && ! $hasSousTraitant && ! $hasGroup) {
$message = 'Un client, un sous-traitant ou un groupe de clients est obligatoire.';
$validator->errors()->add('client_id', $message);
$validator->errors()->add('sous_traitant_id', $message);
$validator->errors()->add('group_id', $message);
}
if ($hasClient && $hasGroup) {
$message = 'Selectionnez soit un client, soit un groupe de clients.';
if ((int) $hasClient + (int) $hasSousTraitant + (int) $hasGroup > 1) {
$message = 'Selectionnez soit un client, soit un sous-traitant, soit un groupe de clients.';
$validator->errors()->add('client_id', $message);
$validator->errors()->add('sous_traitant_id', $message);
$validator->errors()->add('group_id', $message);
}
});
@ -74,26 +68,27 @@ class StoreInvoiceRequest extends FormRequest
public function messages(): array
{
return [
'client_id.exists' => 'Le client sélectionné est invalide.',
'group_id.exists' => 'Le groupe sélectionné est invalide.',
'client_id.exists' => 'Le client selectionne est invalide.',
'sous_traitant_id.exists' => 'Le sous-traitant selectionne est invalide.',
'group_id.exists' => 'Le groupe selectionne est invalide.',
'status.required' => 'Le statut est obligatoire.',
'status.in' => 'Le statut sélectionné est invalide.',
'status.in' => 'Le statut selectionne est invalide.',
'invoice_date.required' => 'La date de la facture est obligatoire.',
'invoice_date.date' => 'La date de la facture n\'est pas valide.',
'due_date.date' => 'La date d\'échéance n\'est pas valide.',
'due_date.after_or_equal' => 'La date d\'échéance doit être postérieure ou égale à la date de la facture.',
'invoice_date.date' => 'La date de la facture n est pas valide.',
'due_date.date' => 'La date d echeance n est pas valide.',
'due_date.after_or_equal' => 'La date d echeance doit etre posterieure ou egale a la date de la facture.',
'currency.required' => 'La devise est obligatoire.',
'currency.size' => 'La devise doit comporter 3 caractères.',
'currency.size' => 'La devise doit comporter 3 caracteres.',
'total_ht.required' => 'Le total HT est obligatoire.',
'total_ht.numeric' => 'Le total HT doit être un nombre.',
'total_ht.min' => 'Le total HT ne peut pas être négatif.',
'total_ht.numeric' => 'Le total HT doit etre un nombre.',
'total_ht.min' => 'Le total HT ne peut pas etre negatif.',
'total_tva.required' => 'Le total TVA est obligatoire.',
'total_tva.numeric' => 'Le total TVA doit être un nombre.',
'total_tva.min' => 'Le total TVA ne peut pas être négatif.',
'total_tva.numeric' => 'Le total TVA doit etre un nombre.',
'total_tva.min' => 'Le total TVA ne peut pas etre negatif.',
'total_ttc.required' => 'Le total TTC est obligatoire.',
'total_ttc.numeric' => 'Le total TTC doit être un nombre.',
'total_ttc.min' => 'Le total TTC ne peut pas être négatif.',
'lines.required' => 'Veuillez ajouter au moins une ligne à la facture.',
'total_ttc.numeric' => 'Le total TTC doit etre un nombre.',
'total_ttc.min' => 'Le total TTC ne peut pas etre negatif.',
'lines.required' => 'Veuillez ajouter au moins une ligne a la facture.',
];
}
}

View File

@ -6,23 +6,16 @@ use Illuminate\Foundation\Http\FormRequest;
class StoreQuoteRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'client_id' => 'nullable|exists:clients,id',
'sous_traitant_id' => 'nullable|exists:sous_traitants,id',
'group_id' => 'nullable|exists:client_groups,id',
'status' => 'required|in:brouillon,envoye,accepte,refuse,expire,annule',
'quote_date' => 'required|date',
@ -50,19 +43,20 @@ class StoreQuoteRequest extends FormRequest
{
$validator->after(function ($validator) {
$hasClient = filled($this->input('client_id'));
$hasSousTraitant = filled($this->input('sous_traitant_id'));
$hasGroup = filled($this->input('group_id'));
if (! $hasClient && ! $hasGroup) {
$message = 'Un client ou un groupe de clients est obligatoire.';
if (! $hasClient && ! $hasSousTraitant && ! $hasGroup) {
$message = 'Un client, un sous-traitant ou un groupe de clients est obligatoire.';
$validator->errors()->add('client_id', $message);
$validator->errors()->add('sous_traitant_id', $message);
$validator->errors()->add('group_id', $message);
}
if ($hasClient && $hasGroup) {
$message = 'Sélectionnez soit un client, soit un groupe de clients.';
if ((int) $hasClient + (int) $hasSousTraitant + (int) $hasGroup > 1) {
$message = 'Selectionnez soit un client, soit un sous-traitant, soit un groupe de clients.';
$validator->errors()->add('client_id', $message);
$validator->errors()->add('sous_traitant_id', $message);
$validator->errors()->add('group_id', $message);
}
});
@ -71,26 +65,26 @@ class StoreQuoteRequest extends FormRequest
public function messages(): array
{
return [
'client_id.nullable' => 'Le client sélectionné est invalide.',
'client_id.exists' => 'Le client sélectionné est invalide.',
'group_id.exists' => 'Le groupe sélectionné est invalide.',
'client_id.exists' => 'Le client selectionne est invalide.',
'sous_traitant_id.exists' => 'Le sous-traitant selectionne est invalide.',
'group_id.exists' => 'Le groupe selectionne est invalide.',
'status.required' => 'Le statut est obligatoire.',
'status.in' => 'Le statut sélectionné est invalide.',
'status.in' => 'Le statut selectionne est invalide.',
'quote_date.required' => 'La date du devis est obligatoire.',
'quote_date.date' => 'La date du devis n\'est pas valide.',
'valid_until.date' => 'La date de validité n\'est pas valide.',
'valid_until.after_or_equal' => 'La date de validité doit être postérieure ou égale à la date du devis.',
'quote_date.date' => 'La date du devis n est pas valide.',
'valid_until.date' => 'La date de validite n est pas valide.',
'valid_until.after_or_equal' => 'La date de validite doit etre posterieure ou egale a la date du devis.',
'currency.required' => 'La devise est obligatoire.',
'currency.size' => 'La devise doit comporter 3 caractères.',
'currency.size' => 'La devise doit comporter 3 caracteres.',
'total_ht.required' => 'Le total HT est obligatoire.',
'total_ht.numeric' => 'Le total HT doit être un nombre.',
'total_ht.min' => 'Le total HT ne peut pas être négatif.',
'total_ht.numeric' => 'Le total HT doit etre un nombre.',
'total_ht.min' => 'Le total HT ne peut pas etre negatif.',
'total_tva.required' => 'Le total TVA est obligatoire.',
'total_tva.numeric' => 'Le total TVA doit être un nombre.',
'total_tva.min' => 'Le total TVA ne peut pas être négatif.',
'total_tva.numeric' => 'Le total TVA doit etre un nombre.',
'total_tva.min' => 'Le total TVA ne peut pas etre negatif.',
'total_ttc.required' => 'Le total TTC est obligatoire.',
'total_ttc.numeric' => 'Le total TTC doit être un nombre.',
'total_ttc.min' => 'Le total TTC ne peut pas être négatif.',
'total_ttc.numeric' => 'Le total TTC doit etre un nombre.',
'total_ttc.min' => 'Le total TTC ne peut pas etre negatif.',
];
}
}

View File

@ -7,24 +7,16 @@ use Illuminate\Validation\Rule;
class UpdateInterventionRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
// Add authorization logic if needed
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'client_id' => ['sometimes', 'required', 'exists:clients,id'],
'client_id' => ['sometimes', 'nullable', 'exists:clients,id'],
'sous_traitant_id' => ['sometimes', 'nullable', 'exists:sous_traitants,id'],
'deceased_id' => ['nullable', 'exists:deceased,id'],
'order_giver' => ['nullable', 'string', 'max:255'],
'location_id' => ['nullable', 'exists:client_locations,id'],
@ -35,7 +27,7 @@ class UpdateInterventionRequest extends FormRequest
'exhumation',
'retrait_pacemaker',
'retrait_bijoux',
'autre'
'autre',
])],
'scheduled_at' => ['nullable', 'date_format:Y-m-d H:i:s'],
'duration_min' => ['nullable', 'integer', 'min:0'],
@ -44,7 +36,7 @@ class UpdateInterventionRequest extends FormRequest
'planifie',
'en_cours',
'termine',
'annule'
'annule',
])],
'practitioners' => ['nullable', 'array'],
'practitioners.*' => ['exists:thanatopractitioners,id'],
@ -52,33 +44,48 @@ class UpdateInterventionRequest extends FormRequest
'assistant_practitioner_ids' => ['nullable', 'array'],
'assistant_practitioner_ids.*' => ['exists:thanatopractitioners,id'],
'notes' => ['nullable', 'string'],
'created_by' => ['nullable', 'exists:users,id']
'created_by' => ['nullable', 'exists:users,id'],
];
}
/**
* Get custom error messages for validator errors.
*/
public function withValidator($validator): void
{
$validator->after(function ($validator) {
if (! $this->hasAny(['client_id', 'sous_traitant_id'])) {
return;
}
$hasClient = filled($this->input('client_id'));
$hasSousTraitant = filled($this->input('sous_traitant_id'));
if (! $hasClient && ! $hasSousTraitant) {
$message = 'Un client ou un sous-traitant est obligatoire.';
$validator->errors()->add('client_id', $message);
$validator->errors()->add('sous_traitant_id', $message);
}
});
}
public function messages(): array
{
return [
'client_id.required' => 'Le client est obligatoire.',
'client_id.exists' => 'Le client sélectionné est invalide.',
'deceased_id.exists' => 'Le défunt sélectionné est invalide.',
'order_giver.max' => 'Le donneur d\'ordre ne peut pas dépasser 255 caractères.',
'location_id.exists' => 'Le lieu sélectionné est invalide.',
'type.required' => 'Le type d\'intervention est obligatoire.',
'type.in' => 'Le type d\'intervention est invalide.',
'scheduled_at.date_format' => 'Le format de la date programmée est invalide.',
'duration_min.integer' => 'La durée doit être un nombre entier.',
'duration_min.min' => 'La durée ne peut pas être négative.',
'status.in' => 'Le statut de l\'intervention est invalide.',
'practitioners.array' => 'Les praticiens doivent être un tableau.',
'practitioners.*.exists' => 'Un des praticiens sélectionnés est invalide.',
'principal_practitioner_id.exists' => 'Le praticien principal sélectionné est invalide.',
'assistant_practitioner_ids.array' => 'Les praticiens assistants doivent être un tableau.',
'client_id.exists' => 'Le client selectionne est invalide.',
'sous_traitant_id.exists' => 'Le sous-traitant selectionne est invalide.',
'deceased_id.exists' => 'Le defunt selectionne est invalide.',
'order_giver.max' => 'Le donneur d ordre ne peut pas depasser 255 caracteres.',
'location_id.exists' => 'Le lieu selectionne est invalide.',
'type.required' => 'Le type d intervention est obligatoire.',
'type.in' => 'Le type d intervention est invalide.',
'scheduled_at.date_format' => 'Le format de la date programmee est invalide.',
'duration_min.integer' => 'La duree doit etre un nombre entier.',
'duration_min.min' => 'La duree ne peut pas etre negative.',
'status.in' => 'Le statut de l intervention est invalide.',
'practitioners.array' => 'Les praticiens doivent etre un tableau.',
'practitioners.*.exists' => 'Un des praticiens selectionnes est invalide.',
'principal_practitioner_id.exists' => 'Le praticien principal selectionne est invalide.',
'assistant_practitioner_ids.array' => 'Les praticiens assistants doivent etre un tableau.',
'assistant_practitioner_ids.*.exists' => 'Un des praticiens assistants est invalide.',
'created_by.exists' => 'L\'utilisateur créateur est invalide.'
'created_by.exists' => 'L utilisateur createur est invalide.',
];
}
}

View File

@ -6,25 +6,18 @@ use Illuminate\Foundation\Http\FormRequest;
class UpdateInvoiceRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$invoiceId = $this->route('invoice');
return [
'client_id' => 'nullable|exists:clients,id',
'sous_traitant_id' => 'nullable|exists:sous_traitants,id',
'group_id' => 'nullable|exists:client_groups,id',
'source_quote_id' => 'nullable|exists:quotes,id',
'invoice_number' => 'sometimes|string|max:191|unique:invoices,invoice_number,' . $invoiceId,
@ -43,49 +36,51 @@ class UpdateInvoiceRequest extends FormRequest
public function messages(): array
{
return [
'client_id.exists' => 'Le client sélectionné est invalide.',
'group_id.exists' => 'Le groupe sélectionné est invalide.',
'client_id.exists' => 'Le client selectionne est invalide.',
'sous_traitant_id.exists' => 'Le sous-traitant selectionne est invalide.',
'group_id.exists' => 'Le groupe selectionne est invalide.',
'source_quote_id.exists' => 'Le devis source est invalide.',
'invoice_number.string' => 'Le numéro de facture doit être une chaîne de caractères.',
'invoice_number.max' => 'Le numéro de facture ne doit pas dépasser 191 caractères.',
'invoice_number.unique' => 'Ce numéro de facture existe déjà.',
'status.in' => 'Le statut sélectionné est invalide.',
'invoice_date.date' => 'La date de la facture n\'est pas valide.',
'due_date.date' => 'La date d\'échéance n\'est pas valide.',
'due_date.after_or_equal' => 'La date d\'échéance doit être postérieure ou égale à la date de la facture.',
'currency.size' => 'La devise doit comporter 3 caractères.',
'total_ht.numeric' => 'Le total HT doit être un nombre.',
'total_ht.min' => 'Le total HT ne peut pas être négatif.',
'total_tva.numeric' => 'Le total TVA doit être un nombre.',
'total_tva.min' => 'Le total TVA ne peut pas être négatif.',
'total_ttc.numeric' => 'Le total TTC doit être un nombre.',
'total_ttc.min' => 'Le total TTC ne peut pas être négatif.',
'e_invoicing_channel_id.exists' => 'Le canal de facturation électronique est invalide.',
'e_invoice_status.in' => 'Le statut de facturation électronique est invalide.',
'invoice_number.string' => 'Le numero de facture doit etre une chaine de caracteres.',
'invoice_number.max' => 'Le numero de facture ne doit pas depasser 191 caracteres.',
'invoice_number.unique' => 'Ce numero de facture existe deja.',
'status.in' => 'Le statut selectionne est invalide.',
'invoice_date.date' => 'La date de la facture n est pas valide.',
'due_date.date' => 'La date d echeance n est pas valide.',
'due_date.after_or_equal' => 'La date d echeance doit etre posterieure ou egale a la date de la facture.',
'currency.size' => 'La devise doit comporter 3 caracteres.',
'total_ht.numeric' => 'Le total HT doit etre un nombre.',
'total_ht.min' => 'Le total HT ne peut pas etre negatif.',
'total_tva.numeric' => 'Le total TVA doit etre un nombre.',
'total_tva.min' => 'Le total TVA ne peut pas etre negatif.',
'total_ttc.numeric' => 'Le total TTC doit etre un nombre.',
'total_ttc.min' => 'Le total TTC ne peut pas etre negatif.',
'e_invoicing_channel_id.exists' => 'Le canal de facturation electronique est invalide.',
'e_invoice_status.in' => 'Le statut de facturation electronique est invalide.',
];
}
public function withValidator($validator): void
{
$validator->after(function ($validator) {
if (! $this->hasAny(['client_id', 'group_id'])) {
if (! $this->hasAny(['client_id', 'sous_traitant_id', 'group_id'])) {
return;
}
$hasClient = filled($this->input('client_id'));
$hasSousTraitant = filled($this->input('sous_traitant_id'));
$hasGroup = filled($this->input('group_id'));
if (! $hasClient && ! $hasGroup) {
$message = 'Un client ou un groupe de clients est obligatoire.';
if (! $hasClient && ! $hasSousTraitant && ! $hasGroup) {
$message = 'Un client, un sous-traitant ou un groupe de clients est obligatoire.';
$validator->errors()->add('client_id', $message);
$validator->errors()->add('sous_traitant_id', $message);
$validator->errors()->add('group_id', $message);
}
if ($hasClient && $hasGroup) {
$message = 'Selectionnez soit un client, soit un groupe de clients.';
if ((int) $hasClient + (int) $hasSousTraitant + (int) $hasGroup > 1) {
$message = 'Selectionnez soit un client, soit un sous-traitant, soit un groupe de clients.';
$validator->errors()->add('client_id', $message);
$validator->errors()->add('sous_traitant_id', $message);
$validator->errors()->add('group_id', $message);
}
});

View File

@ -6,25 +6,18 @@ use Illuminate\Foundation\Http\FormRequest;
class UpdateQuoteRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$quoteId = $this->route('quote');
return [
'client_id' => 'sometimes|exists:clients,id',
'client_id' => 'sometimes|nullable|exists:clients,id',
'sous_traitant_id' => 'sometimes|nullable|exists:sous_traitants,id',
'group_id' => 'nullable|exists:client_groups,id',
'reference' => 'sometimes|string|max:191|unique:quotes,reference,' . $quoteId,
'status' => 'sometimes|in:brouillon,envoye,accepte,refuse,expire,annule',
@ -37,25 +30,53 @@ class UpdateQuoteRequest extends FormRequest
];
}
public function withValidator($validator): void
{
$validator->after(function ($validator) {
if (! $this->hasAny(['client_id', 'sous_traitant_id', 'group_id'])) {
return;
}
$hasClient = filled($this->input('client_id'));
$hasSousTraitant = filled($this->input('sous_traitant_id'));
$hasGroup = filled($this->input('group_id'));
if (! $hasClient && ! $hasSousTraitant && ! $hasGroup) {
$message = 'Un client, un sous-traitant ou un groupe de clients est obligatoire.';
$validator->errors()->add('client_id', $message);
$validator->errors()->add('sous_traitant_id', $message);
$validator->errors()->add('group_id', $message);
}
if ((int) $hasClient + (int) $hasSousTraitant + (int) $hasGroup > 1) {
$message = 'Selectionnez soit un client, soit un sous-traitant, soit un groupe de clients.';
$validator->errors()->add('client_id', $message);
$validator->errors()->add('sous_traitant_id', $message);
$validator->errors()->add('group_id', $message);
}
});
}
public function messages(): array
{
return [
'client_id.exists' => 'Le client sélectionné est invalide.',
'group_id.exists' => 'Le groupe sélectionné est invalide.',
'reference.string' => 'Le numéro de devis doit être une chaîne de caractères.',
'reference.max' => 'Le numéro de devis ne doit pas dépasser 191 caractères.',
'reference.unique' => 'Ce numéro de devis existe déjà.',
'status.in' => 'Le statut sélectionné est invalide.',
'quote_date.date' => 'La date du devis n\'est pas valide.',
'valid_until.date' => 'La date de validité n\'est pas valide.',
'valid_until.after_or_equal' => 'La date de validité doit être postérieure ou égale à la date du devis.',
'currency.size' => 'La devise doit comporter 3 caractères.',
'total_ht.numeric' => 'Le total HT doit être un nombre.',
'total_ht.min' => 'Le total HT ne peut pas être négatif.',
'total_tva.numeric' => 'Le total TVA doit être un nombre.',
'total_tva.min' => 'Le total TVA ne peut pas être négatif.',
'total_ttc.numeric' => 'Le total TTC doit être un nombre.',
'total_ttc.min' => 'Le total TTC ne peut pas être négatif.',
'client_id.exists' => 'Le client selectionne est invalide.',
'sous_traitant_id.exists' => 'Le sous-traitant selectionne est invalide.',
'group_id.exists' => 'Le groupe selectionne est invalide.',
'reference.string' => 'Le numero de devis doit etre une chaine de caracteres.',
'reference.max' => 'Le numero de devis ne doit pas depasser 191 caracteres.',
'reference.unique' => 'Ce numero de devis existe deja.',
'status.in' => 'Le statut selectionne est invalide.',
'quote_date.date' => 'La date du devis n est pas valide.',
'valid_until.date' => 'La date de validite n est pas valide.',
'valid_until.after_or_equal' => 'La date de validite doit etre posterieure ou egale a la date du devis.',
'currency.size' => 'La devise doit comporter 3 caracteres.',
'total_ht.numeric' => 'Le total HT doit etre un nombre.',
'total_ht.min' => 'Le total HT ne peut pas etre negatif.',
'total_tva.numeric' => 'Le total TVA doit etre un nombre.',
'total_tva.min' => 'Le total TVA ne peut pas etre negatif.',
'total_ttc.numeric' => 'Le total TTC doit etre un nombre.',
'total_ttc.min' => 'Le total TTC ne peut pas etre negatif.',
];
}
}

View File

@ -19,6 +19,8 @@ class InterventionResource extends JsonResource
{
return [
'id' => $this->id,
'client_id' => $this->client_id,
'sous_traitant_id' => $this->sous_traitant_id,
'quote_id' => $this->quote_id,
'invoice_id' => $this->invoice_id,
'quote' => $this->whenLoaded('quote', function () {
@ -27,6 +29,9 @@ class InterventionResource extends JsonResource
'client' => $this->whenLoaded('client', function () {
return new ClientResource($this->client);
}),
'sous_traitant' => $this->whenLoaded('sousTraitant', function () {
return new \App\Http\Resources\SousTraitant\SousTraitantResource($this->sousTraitant);
}),
'deceased' => $this->whenLoaded('deceased', function () {
return new DeceasedResource($this->deceased);
}),

View File

@ -17,6 +17,7 @@ class InvoiceResource extends JsonResource
return [
'id' => $this->id,
'client_id' => $this->client_id,
'sous_traitant_id' => $this->sous_traitant_id,
'group_id' => $this->group_id,
'source_quote_id' => $this->source_quote_id,
'invoice_number' => $this->invoice_number,
@ -32,6 +33,7 @@ class InvoiceResource extends JsonResource
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
'client' => $this->whenLoaded('client'),
'sous_traitant' => $this->whenLoaded('sousTraitant'),
'group' => $this->whenLoaded('group'),
'sourceQuote' => $this->whenLoaded('sourceQuote'),
'lines' => InvoiceLineResource::collection($this->whenLoaded('lines')),

View File

@ -17,6 +17,7 @@ class QuoteResource extends JsonResource
return [
'id' => $this->id,
'client_id' => $this->client_id,
'sous_traitant_id' => $this->sous_traitant_id,
'group_id' => $this->group_id,
'reference' => $this->reference,
'status' => $this->status,
@ -29,6 +30,7 @@ class QuoteResource extends JsonResource
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
'client' => $this->whenLoaded('client'),
'sous_traitant' => $this->whenLoaded('sousTraitant'),
'group' => $this->whenLoaded('group'),
'lines' => QuoteLineResource::collection($this->whenLoaded('lines')),
'history' => DocumentStatusHistoryResource::collection($this->whenLoaded('history')),

View File

@ -19,6 +19,7 @@ class Intervention extends Model
*/
protected $fillable = [
'client_id',
'sous_traitant_id',
'deceased_id',
'order_giver',
'location_id',
@ -60,6 +61,11 @@ class Intervention extends Model
return $this->belongsTo(Client::class);
}
public function sousTraitant(): BelongsTo
{
return $this->belongsTo(SousTraitant::class, 'sous_traitant_id');
}
/**
* Get the deceased associated with the intervention.
*/

View File

@ -11,6 +11,7 @@ class Invoice extends Model
protected $fillable = [
'client_id',
'sous_traitant_id',
'group_id',
'source_quote_id',
'invoice_number',
@ -60,6 +61,11 @@ class Invoice extends Model
return $this->belongsTo(Client::class);
}
public function sousTraitant()
{
return $this->belongsTo(SousTraitant::class, 'sous_traitant_id');
}
public function group()
{
return $this->belongsTo(ClientGroup::class, 'group_id');

View File

@ -11,6 +11,7 @@ class Quote extends Model
protected $fillable = [
'client_id',
'sous_traitant_id',
'group_id',
'reference',
'status',
@ -54,6 +55,11 @@ class Quote extends Model
return $this->belongsTo(Client::class);
}
public function sousTraitant()
{
return $this->belongsTo(SousTraitant::class, 'sous_traitant_id');
}
public function group()
{
return $this->belongsTo(ClientGroup::class);

View File

@ -30,6 +30,10 @@ class InterventionRepository implements InterventionRepositoryInterface
$query->where('client_id', $filters['client_id']);
}
if (!empty($filters['sous_traitant_id'])) {
$query->where('sous_traitant_id', $filters['sous_traitant_id']);
}
if (!empty($filters['deceased_id'])) {
$query->where('deceased_id', $filters['deceased_id']);
}
@ -59,6 +63,7 @@ class InterventionRepository implements InterventionRepositoryInterface
// Eager load related models
$query->with([
'client',
'sousTraitant',
'deceased',
'location',
'practitioners'
@ -77,6 +82,7 @@ class InterventionRepository implements InterventionRepositoryInterface
{
return Intervention::with([
'client',
'sousTraitant',
'deceased',
'location',
'practitioners',

View File

@ -39,6 +39,7 @@ class InvoiceRepository extends BaseRepository implements InvoiceRepositoryInter
// Create Invoice
$invoiceData = [
'client_id' => $quote->client_id,
'sous_traitant_id' => $quote->sous_traitant_id,
'group_id' => $quote->group_id,
'source_quote_id' => $quote->id,
'status' => 'brouillon', // Start as draft
@ -98,7 +99,7 @@ class InvoiceRepository extends BaseRepository implements InvoiceRepositoryInter
public function all(array $columns = ['*']): \Illuminate\Support\Collection
{
return $this->model->with(['client', 'group', 'lines.product'])->get($columns);
return $this->model->with(['client', 'sousTraitant', 'group', 'lines.product'])->get($columns);
}
public function create(array $data): Invoice
@ -185,7 +186,7 @@ class InvoiceRepository extends BaseRepository implements InvoiceRepositoryInter
public function find(int|string $id, array $columns = ['*']): ?Invoice
{
return $this->model->with(['client', 'group', 'lines.product', 'history.user'])->find($id, $columns);
return $this->model->with(['client', 'sousTraitant', 'group', 'lines.product', 'history.user'])->find($id, $columns);
}
private function recordHistory(int $invoiceId, ?string $oldStatus, string $newStatus, ?string $comment = null): void

View File

@ -24,7 +24,7 @@ class QuoteRepository extends BaseRepository implements QuoteRepositoryInterface
public function all(array $columns = ['*']): \Illuminate\Support\Collection
{
return $this->model->with(['client', 'group', 'lines.product'])->get($columns);
return $this->model->with(['client', 'sousTraitant', 'group', 'lines.product'])->get($columns);
}
public function create(array $data): Quote
@ -115,7 +115,7 @@ class QuoteRepository extends BaseRepository implements QuoteRepositoryInterface
public function find(int|string $id, array $columns = ['*']): ?Quote
{
return $this->model->with(['client', 'group', 'lines.product', 'history.user'])->find($id, $columns);
return $this->model->with(['client', 'sousTraitant', 'group', 'lines.product', 'history.user'])->find($id, $columns);
}
private function recordHistory(int $quoteId, ?string $oldStatus, string $newStatus, ?string $comment = null): void

View File

@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('interventions', function (Blueprint $table) {
$table->dropForeign(['client_id']);
$table->unsignedBigInteger('client_id')->nullable()->change();
$table->foreign('client_id')->references('id')->on('clients')->nullOnDelete();
$table->foreignId('sous_traitant_id')
->nullable()
->after('client_id')
->constrained('sous_traitants')
->nullOnDelete();
});
Schema::table('quotes', function (Blueprint $table) {
$table->foreignId('sous_traitant_id')
->nullable()
->after('client_id')
->constrained('sous_traitants')
->nullOnDelete();
});
Schema::table('invoices', function (Blueprint $table) {
$table->foreignId('sous_traitant_id')
->nullable()
->after('client_id')
->constrained('sous_traitants')
->nullOnDelete();
});
}
public function down(): void
{
Schema::table('invoices', function (Blueprint $table) {
$table->dropConstrainedForeignId('sous_traitant_id');
});
Schema::table('quotes', function (Blueprint $table) {
$table->dropConstrainedForeignId('sous_traitant_id');
});
Schema::table('interventions', function (Blueprint $table) {
$table->dropConstrainedForeignId('sous_traitant_id');
$table->dropForeign(['client_id']);
$table->unsignedBigInteger('client_id')->nullable(false)->change();
$table->foreign('client_id')->references('id')->on('clients');
});
}
};

View File

@ -177,7 +177,7 @@
type="text"
class="form-control"
:class="{ 'is-invalid': hasError('client') }"
placeholder="Nom, email..."
placeholder="Nom du client ou du sous-traitant..."
@input="handleClientSearch"
@focus="handleClientFocus"
/>
@ -196,6 +196,28 @@
>
{{ getFieldError("client") }}
</small>
<div
v-if="selectedClient"
class="d-inline-flex align-items-center gap-2 mt-2 px-3 py-2 rounded-3 bg-gray-100"
>
<span
class="badge"
:class="getOrderGiverBadgeClass(selectedClient)"
>
{{ selectedClient.badge_label }}
</span>
<div class="text-sm">
<div class="fw-semibold">
{{ selectedClient.name }}
</div>
<div
v-if="selectedClient.subtitle"
class="text-secondary"
>
{{ selectedClient.subtitle }}
</div>
</div>
</div>
<div
v-if="showClientResults"
class="position-absolute start-0 end-0 mt-2 card border-0 shadow-sm"
@ -208,13 +230,29 @@
<div class="list-group list-group-flush">
<button
v-for="c in clientSearchResults"
:key="c.id"
:key="`${c.entity_type}-${c.id}`"
type="button"
class="list-group-item list-group-item-action text-start"
@click="selectClient(c)"
>
<div class="fw-semibold text-sm">{{ c.name }}</div>
<small class="text-secondary">{{ c.email }}</small>
<div
class="d-flex align-items-start justify-content-between gap-2"
>
<div>
<div class="fw-semibold text-sm">
{{ c.name }}
</div>
<small class="text-secondary">
{{ c.subtitle || "Aucune information" }}
</small>
</div>
<span
class="badge"
:class="getOrderGiverBadgeClass(c)"
>
{{ c.badge_label }}
</span>
</div>
</button>
<div
v-if="!clientSearchResults.length"
@ -645,7 +683,7 @@ import { Modal } from "bootstrap";
import DeceasedService from "@/services/deceased";
import ProductService from "@/services/product";
import ClientLocationService from "@/services/client_location";
import { ClientService } from "@/services/client";
import OrderGiverSearchService from "@/services/orderGiverSearch";
import ThanatopractitionerService from "@/services/thanatopractitioner";
import SoftButton from "@/components/SoftButton.vue";
@ -793,8 +831,19 @@ const clearDeceasedSelection = () => {
);
};
const getOrderGiverBadgeClass = (orderGiver) =>
orderGiver?.entity_type === "sous_traitant"
? "bg-gradient-warning"
: "bg-gradient-info";
const handleClientSearch = () => {
showClientResults.value = true;
if (
selectedClient.value &&
clientSearchQuery.value !== selectedClient.value.name
) {
selectedClient.value = null;
}
if (clientSearchTimeout) clearTimeout(clientSearchTimeout);
if (clientSearchQuery.value.length < 2) {
clientSearchResults.value = recentClients.value;
@ -802,29 +851,21 @@ const handleClientSearch = () => {
}
clientSearchTimeout = setTimeout(async () => {
try {
clientSearchResults.value = await ClientService.searchClients(
clientSearchResults.value = await OrderGiverSearchService.search(
clientSearchQuery.value
);
} catch (e) {
console.error(e);
clientSearchResults.value = [];
}
}, 300);
};
const loadRecentClients = async () => {
try {
const response = await ClientService.getAllClients({
page: 1,
per_page: 5,
});
const clients = Array.isArray(response?.data)
? response.data
: Array.isArray(response?.data?.data)
? response.data.data
: [];
recentClients.value = clients;
recentClients.value = await OrderGiverSearchService.getRecent();
} catch (e) {
console.error("Failed to load recent clients", e);
console.error("Failed to load recent order givers", e);
recentClients.value = [];
}
};
@ -841,7 +882,7 @@ const handleClientFocus = async () => {
const selectClient = (c) => {
selectedClient.value = c;
clientSearchQuery.value = c.name + (c.email ? ` (${c.email})` : "");
clientSearchQuery.value = c.name;
clientSearchResults.value = [];
showClientResults.value = false;
errors.value = errors.value.filter((e) => e.field !== "client");
@ -1098,6 +1139,9 @@ const handleSubmit = async () => {
formData.append("deceased[death_date]", deceasedForm.value.death_date);
}
if (selectedClient.value) {
if (selectedClient.value.entity_type === "sous_traitant") {
formData.append("sous_traitant_id", selectedClient.value.id);
} else {
formData.append("client_id", selectedClient.value.id);
if (selectedClient.value.name)
formData.append("client[name]", selectedClient.value.name);
@ -1118,10 +1162,14 @@ const handleSubmit = async () => {
formData.append("client[billing_country_code]", ba.country_code);
}
if (selectedClient.value.vat_number)
formData.append("client[vat_number]", selectedClient.value.vat_number);
formData.append(
"client[vat_number]",
selectedClient.value.vat_number
);
if (selectedClient.value.siret)
formData.append("client[siret]", selectedClient.value.siret);
}
}
if (locationForm.value.is_existing && locationForm.value.id) {
formData.append("location_id", locationForm.value.id);
} else {

View File

@ -0,0 +1,98 @@
<template>
<sous-traitant-commande-template>
<template #controls>
<sous-traitant-commande-list-controls
@filter="handleFilter"
@export="handleExport"
/>
</template>
<template #table>
<sous-traitant-commande-table
:data="filteredQuotes"
:loading="loading"
@view="handleView"
@delete="handleDelete"
/>
</template>
</sous-traitant-commande-template>
</template>
<script setup>
import { computed, onMounted, ref } from "vue";
import { storeToRefs } from "pinia";
import { useRouter } from "vue-router";
import { useQuoteStore } from "@/stores/quoteStore";
import SousTraitantCommandeTemplate from "@/components/templates/CRM/SousTraitantCommandeTemplate.vue";
import SousTraitantCommandeListControls from "@/components/molecules/SousTraitants/SousTraitantCommandeListControls.vue";
import SousTraitantCommandeTable from "@/components/molecules/Tables/CRM/SousTraitantCommandeTable.vue";
const router = useRouter();
const quoteStore = useQuoteStore();
const { quotes, loading } = storeToRefs(quoteStore);
const activeFilter = ref(null);
const filteredQuotes = computed(() =>
quotes.value.filter((quote) => {
const hasSousTraitant = Boolean(quote.sous_traitant_id);
const matchesStatus = activeFilter.value
? quote.status === activeFilter.value
: true;
return hasSousTraitant && matchesStatus;
})
);
const handleFilter = (status) => {
activeFilter.value = status;
};
const handleView = (id) => {
router.push(`/ventes/devis/${id}`);
};
const handleDelete = async (id) => {
if (!confirm("Etes-vous sur de vouloir supprimer cette commande ?")) {
return;
}
await quoteStore.deleteQuote(id);
};
const handleExport = () => {
const headers = ["Reference", "Date", "Statut", "Sous-traitant", "Total TTC"];
const csvContent = [
headers.join(","),
...filteredQuotes.value.map((quote) =>
[
quote.reference,
quote.quote_date,
quote.status,
quote.sous_traitant?.nom_entreprise || "",
quote.total_ttc,
].join(",")
),
].join("\n");
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.setAttribute("href", url);
link.setAttribute(
"download",
`commandes-sous-traitants-${new Date().toISOString().split("T")[0]}.csv`
);
link.style.visibility = "hidden";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
onMounted(() => {
quoteStore.fetchQuotes();
});
</script>

View File

@ -0,0 +1,98 @@
<template>
<sous-traitant-commande-template>
<template #controls>
<sous-traitant-facture-list-controls
@filter="handleFilter"
@export="handleExport"
/>
</template>
<template #table>
<sous-traitant-facture-table
:data="filteredInvoices"
:loading="loading"
@view="handleView"
@delete="handleDelete"
/>
</template>
</sous-traitant-commande-template>
</template>
<script setup>
import { computed, onMounted, ref } from "vue";
import { storeToRefs } from "pinia";
import { useRouter } from "vue-router";
import { useInvoiceStore } from "@/stores/invoiceStore";
import SousTraitantCommandeTemplate from "@/components/templates/CRM/SousTraitantCommandeTemplate.vue";
import SousTraitantFactureListControls from "@/components/molecules/SousTraitants/SousTraitantFactureListControls.vue";
import SousTraitantFactureTable from "@/components/molecules/Tables/CRM/SousTraitantFactureTable.vue";
const router = useRouter();
const invoiceStore = useInvoiceStore();
const { invoices, loading } = storeToRefs(invoiceStore);
const activeFilter = ref(null);
const filteredInvoices = computed(() =>
invoices.value.filter((invoice) => {
const hasSousTraitant = Boolean(invoice.sous_traitant_id);
const matchesStatus = activeFilter.value
? invoice.status === activeFilter.value
: true;
return hasSousTraitant && matchesStatus;
})
);
const handleFilter = (status) => {
activeFilter.value = status;
};
const handleView = (id) => {
router.push(`/ventes/factures/${id}`);
};
const handleDelete = async (id) => {
if (!confirm("Etes-vous sur de vouloir supprimer cette facture ?")) {
return;
}
await invoiceStore.deleteInvoice(id);
};
const handleExport = () => {
const headers = ["Numero", "Date", "Statut", "Sous-traitant", "Total TTC"];
const csvContent = [
headers.join(","),
...filteredInvoices.value.map((invoice) =>
[
invoice.invoice_number,
invoice.invoice_date,
invoice.status,
invoice.sous_traitant?.nom_entreprise || "",
invoice.total_ttc,
].join(",")
),
].join("\n");
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.setAttribute("href", url);
link.setAttribute(
"download",
`factures-sous-traitants-${new Date().toISOString().split("T")[0]}.csv`
);
link.style.visibility = "hidden";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
onMounted(() => {
invoiceStore.fetchInvoices();
});
</script>

View File

@ -28,8 +28,7 @@
<h5 class="mb-1">Planifier une intervention</h5>
<p class="text-sm text-muted mb-0">
Préparez une fiche claire, liée au client et au défunt, dans le
style du dashboard.
Préparez une fiche claire, liée à un client ou à un sous-traitant.
</p>
</div>
</div>
@ -44,7 +43,7 @@
<div class="card-body pt-3">
<div class="check-item">
<span class="check-dot bg-gradient-success"></span>
<span class="text-sm">Associer le bon client</span>
<span class="text-sm">Choisir client ou sous-traitant</span>
</div>
<div class="check-item">
<span class="check-dot bg-gradient-info"></span>
@ -79,7 +78,8 @@
<div class="hero-pill-group">
<span class="hero-pill">
<i class="fas fa-user me-2 text-success"></i>Client
<i class="fas fa-user-tag me-2 text-success"></i>Client ou
sous-traitant
</span>
<span class="hero-pill">
<i class="fas fa-cross me-2 text-info"></i>Défunt
@ -102,6 +102,8 @@
:client-loading="clientLoading"
:search-clients="searchClients"
:on-client-select="onClientSelect"
:search-sous-traitants="searchSousTraitants"
:on-sous-traitant-select="onSousTraitantSelect"
:search-deceased="searchDeceased"
:on-deceased-select="onDeceasedSelect"
@create-intervention="handleCreateIntervention"
@ -109,12 +111,13 @@
</template>
</client-detail-template>
</template>
<script setup>
import { defineProps, defineEmits } from "vue";
import { RouterLink } from "vue-router";
import ClientDetailTemplate from "@/components/templates/CRM/ClientDetailTemplate.vue";
import InterventionForm from "@/components/molecules/Interventions/InterventionForm.vue";
import SoftBadge from "@/components/SoftBadge.vue";
import { defineProps, defineEmits } from "vue";
import { RouterLink } from "vue-router";
defineProps({
loading: {
@ -153,6 +156,14 @@ defineProps({
type: Function,
required: true,
},
searchSousTraitants: {
type: Function,
required: true,
},
onSousTraitantSelect: {
type: Function,
required: true,
},
searchDeceased: {
type: Function,
required: true,

View File

@ -108,25 +108,25 @@
<h6 class="mb-3">Quote lines</h6>
<quote-lines-table :lines="quote.lines" />
<h6 class="mb-3 mt-4">Billing Information</h6>
<h6 class="mb-3 mt-4">Informations destinataire</h6>
<ul class="list-group">
<li
class="list-group-item border-0 d-flex p-4 mb-2 bg-gray-100 border-radius-lg"
>
<div class="d-flex flex-column">
<h6 class="mb-3 text-sm">
{{ recipientName }}
{{ recipientTypeLabel }} : {{ recipientName }}
</h6>
<span class="mb-2 text-xs">
Email Address:
<span class="text-dark ms-2 font-weight-bold">{{
quote.client?.email || groupDetailsFallback
recipientEmail || groupDetailsFallback
}}</span>
</span>
<span class="mb-2 text-xs">
Phone:
<span class="text-dark ms-2 font-weight-bold">{{
quote.client?.phone || groupDetailsFallback
recipientPhone || groupDetailsFallback
}}</span>
</span>
<span class="text-xs">
@ -191,8 +191,32 @@ const selectedStatus = ref("brouillon");
const recipientName = computed(() => {
if (quote.value?.client?.name) return quote.value.client.name;
if (quote.value?.sous_traitant?.nom_entreprise) {
return quote.value.sous_traitant.nom_entreprise;
}
if (quote.value?.group?.name) return quote.value.group.name;
return "Client inconnu";
return "Destinataire inconnu";
});
const recipientTypeLabel = computed(() => {
if (quote.value?.client?.name) return "Client";
if (quote.value?.sous_traitant?.nom_entreprise) return "Sous-traitant";
if (quote.value?.group?.name) return "Groupe";
return "Destinataire";
});
const recipientEmail = computed(() => {
if (quote.value?.client?.email) return quote.value.client.email;
if (quote.value?.sous_traitant?.email) return quote.value.sous_traitant.email;
return null;
});
const recipientPhone = computed(() => {
if (quote.value?.client?.phone) return quote.value.client.phone;
if (quote.value?.sous_traitant?.telephone) {
return quote.value.sous_traitant.telephone;
}
return null;
});
const groupDetailsFallback = computed(() => {

View File

@ -3,7 +3,7 @@
<!-- Client Selection -->
<div class="mb-3">
<label class="form-label">
Client <span class="text-danger">*</span>
Client (Donneur d'ordre) <span class="text-danger">*</span>
</label>
<modal-search
:search-action="searchClients"

View File

@ -12,8 +12,8 @@
Informations principales
</h5>
<p class="mb-0 text-sm text-muted">
Identifiez le client, le défunt concerné et le type
d'intervention.
Identifiez le client ou le sous-traitant, le défunt concerné
et le type d'intervention.
</p>
</div>
<soft-badge color="info" variant="gradient" size="sm">
@ -25,31 +25,58 @@
<div class="card-body pt-3 p-4">
<div class="mb-4">
<label class="form-label"
>Client <span class="text-danger">*</span></label
>Intervenant <span class="text-danger">*</span></label
>
<div class="entity-switch mb-3">
<button
type="button"
class="entity-switch-btn"
:class="{ active: form.intervenant_type === 'client' }"
@click="setIntervenantType('client')"
>
<i class="fas fa-user me-2"></i>Client
</button>
<button
type="button"
class="entity-switch-btn"
:class="{ active: form.intervenant_type === 'sous_traitant' }"
@click="setIntervenantType('sous_traitant')"
>
<i class="fas fa-people-carry me-2"></i>Sous-traitant
</button>
</div>
<search-input
v-model="selectedItem"
:search-action="props.searchClients"
v-model="selectedIntervenant"
:search-action="currentSearchAction"
:min-chars="0"
item-key="id"
item-label="name"
:item-label="getIntervenantLabel"
@search="handleSearch"
@select="handleSelect"
/>
<div v-if="selectedItem" class="selection-chip mt-2">
<i class="fas fa-user me-2 text-success"></i>
<div v-if="selectedIntervenant" class="selection-chip mt-2">
<i
:class="
form.intervenant_type === 'client'
? 'fas fa-user me-2 text-success'
: 'fas fa-people-carry me-2 text-warning'
"
></i>
<span>
{{ selectedItem.name }}
{{ getIntervenantLabel(selectedIntervenant) }}
<small class="text-muted ms-1">{{
selectedItem.email || "Pas d'email"
selectedIntervenant.email || "Pas d'email"
}}</small>
</span>
</div>
<div
v-if="fieldErrors.client_id"
v-if="fieldErrors.client_id || fieldErrors.sous_traitant_id"
class="invalid-feedback small-error"
>
{{ fieldErrors.client_id }}
{{ fieldErrors.client_id || fieldErrors.sous_traitant_id }}
</div>
</div>
@ -257,10 +284,8 @@
<div class="summary-panel">
<p class="summary-title mb-3">Résumé rapide</p>
<div class="summary-line">
<span>Client</span>
<strong>{{
selectedItem?.name || "Non sélectionné"
}}</strong>
<span>Intervenant</span>
<strong>{{ summaryIntervenant }}</strong>
</div>
<div class="summary-line">
<span>Défunt</span>
@ -319,13 +344,12 @@
</template>
<script setup>
import { ref, defineProps, defineEmits, watch } from "vue";
import { computed, defineEmits, defineProps, ref, watch } from "vue";
import SoftInput from "@/components/SoftInput.vue";
import SoftButton from "@/components/SoftButton.vue";
import SoftBadge from "@/components/SoftBadge.vue";
import SearchInput from "@/components/atoms/input/SearchInput.vue";
// Props
const props = defineProps({
loading: {
type: Boolean,
@ -363,6 +387,14 @@ const props = defineProps({
type: Function,
required: true,
},
searchSousTraitants: {
type: Function,
required: true,
},
onSousTraitantSelect: {
type: Function,
required: true,
},
searchDeceased: {
type: Function,
required: true,
@ -373,60 +405,17 @@ const props = defineProps({
},
});
// Emits
const emit = defineEmits(["createIntervention"]);
// Reactive data
const errors = ref([]);
// Search input data
const selectedItem = ref(null);
const selectedIntervenant = ref(null);
const selectedDeceased = ref(null);
// Handle client search event
const handleSearch = (query) => {
console.log("Searching for client:", query);
};
// Handle client select event
const handleSelect = (item) => {
console.log("Selected client:", item);
// Call the parent callback for client selection if provided
if (props.onClientSelect) {
props.onClientSelect(item);
}
// Set the client_id in the form
if (item && item.id) {
form.value.client_id = item.id.toString();
}
};
// Handle deceased search event
const handleSearchDeceased = (query) => {
console.log("Searching for deceased:", query);
};
const getDeceasedFullName = (deceased) => {
const parts = [deceased.last_name, deceased.first_name].filter(Boolean);
return parts.join(" ").trim();
};
// Handle deceased select event
const handleSelectDeceased = (item) => {
console.log("Selected deceased:", item);
// Call the parent callback for deceased selection if provided
if (props.onDeceasedSelect) {
props.onDeceasedSelect(item);
}
// Set the deceased_id in the form
if (item && item.id) {
form.value.deceased_id = item.id.toString();
}
};
const fieldErrors = ref({});
const form = ref({
intervenant_type: "client",
client_id: "",
sous_traitant_id: "",
deceased_id: "",
type: "",
scheduled_date: "",
@ -437,7 +426,73 @@ const form = ref({
notes: "",
});
// Watch for validation errors from parent
const currentSearchAction = computed(() =>
form.value.intervenant_type === "client"
? props.searchClients
: props.searchSousTraitants
);
const summaryIntervenant = computed(() => {
if (!selectedIntervenant.value) {
return "Non sélectionné";
}
return getIntervenantLabel(selectedIntervenant.value);
});
const getIntervenantLabel = (item) => {
if (!item) return "";
return form.value.intervenant_type === "client"
? item.name
: item.nom_entreprise;
};
const setIntervenantType = (type) => {
form.value.intervenant_type = type;
form.value.client_id = "";
form.value.sous_traitant_id = "";
selectedIntervenant.value = null;
delete fieldErrors.value.client_id;
delete fieldErrors.value.sous_traitant_id;
};
const handleSearch = (query) => {
console.log("Searching partner:", query);
};
const handleSelect = (item) => {
console.log("Selected partner:", item);
selectedIntervenant.value = item;
if (form.value.intervenant_type === "client") {
props.onClientSelect?.(item);
form.value.client_id = item?.id ? item.id.toString() : "";
form.value.sous_traitant_id = "";
} else {
props.onSousTraitantSelect?.(item);
form.value.sous_traitant_id = item?.id ? item.id.toString() : "";
form.value.client_id = "";
}
};
const handleSearchDeceased = (query) => {
console.log("Searching for deceased:", query);
};
const getDeceasedFullName = (deceased) => {
const parts = [deceased.last_name, deceased.first_name].filter(Boolean);
return parts.join(" ").trim();
};
const handleSelectDeceased = (item) => {
console.log("Selected deceased:", item);
selectedDeceased.value = item;
props.onDeceasedSelect?.(item);
if (item?.id) {
form.value.deceased_id = item.id.toString();
}
};
watch(
() => props.validationErrors,
(newErrors) => {
@ -446,7 +501,6 @@ watch(
{ deep: true }
);
// Watch for success from parent
watch(
() => props.success,
(newSuccess) => {
@ -456,20 +510,30 @@ watch(
}
);
// Watch for client_id changes to update selectedItem
watch(
() => form.value.client_id,
(newClientId) => {
if (newClientId && fieldErrors.value.client_id) {
delete fieldErrors.value.client_id;
}
if (!newClientId) {
selectedItem.value = null;
if (!newClientId && form.value.intervenant_type === "client") {
selectedIntervenant.value = null;
}
}
);
watch(
() => form.value.sous_traitant_id,
(newSousTraitantId) => {
if (newSousTraitantId && fieldErrors.value.sous_traitant_id) {
delete fieldErrors.value.sous_traitant_id;
}
if (!newSousTraitantId && form.value.intervenant_type === "sous_traitant") {
selectedIntervenant.value = null;
}
}
);
// Watch for deceased_id changes to update selectedDeceased
watch(
() => form.value.deceased_id,
(newDeceasedId) => {
@ -492,19 +556,22 @@ watch(
);
const submitForm = async () => {
// Clear errors before submitting
fieldErrors.value = {};
errors.value = [];
// Validate required fields
let hasErrors = false;
if (!form.value.client_id || form.value.client_id === "") {
if (form.value.intervenant_type === "client") {
if (!form.value.client_id) {
fieldErrors.value.client_id = "Le client est obligatoire";
hasErrors = true;
}
} else if (!form.value.sous_traitant_id) {
fieldErrors.value.sous_traitant_id = "Le sous-traitant est obligatoire";
hasErrors = true;
}
if (!form.value.type || form.value.type === "") {
if (!form.value.type) {
fieldErrors.value.type = "Le type d'intervention est obligatoire";
hasErrors = true;
}
@ -513,7 +580,6 @@ const submitForm = async () => {
return;
}
// Clean up form data: convert empty strings to null
const cleanedForm = {};
const formData = form.value;
@ -525,20 +591,20 @@ const submitForm = async () => {
}
}
// Combine date and time into scheduled_at (backend expects Y-m-d H:i:s format)
if (cleanedForm.scheduled_date && cleanedForm.scheduled_time) {
// Format: Y-m-d H:i:s (e.g., "2024-12-15 14:30:00")
const formattedDate = cleanedForm.scheduled_date;
const formattedTime = cleanedForm.scheduled_time;
cleanedForm.scheduled_at = `${formattedDate} ${formattedTime}:00`;
delete cleanedForm.scheduled_date;
delete cleanedForm.scheduled_time;
cleanedForm.scheduled_at = `${cleanedForm.scheduled_date} ${cleanedForm.scheduled_time}:00`;
}
// Convert string numbers to integers
delete cleanedForm.scheduled_date;
delete cleanedForm.scheduled_time;
delete cleanedForm.intervenant_type;
if (cleanedForm.client_id) {
cleanedForm.client_id = parseInt(cleanedForm.client_id);
}
if (cleanedForm.sous_traitant_id) {
cleanedForm.sous_traitant_id = parseInt(cleanedForm.sous_traitant_id);
}
if (cleanedForm.deceased_id) {
cleanedForm.deceased_id = parseInt(cleanedForm.deceased_id);
}
@ -547,14 +613,14 @@ const submitForm = async () => {
}
console.log("Intervention form data being emitted:", cleanedForm);
// Emit the cleaned form data to parent
emit("createIntervention", cleanedForm);
};
const resetForm = () => {
form.value = {
intervenant_type: "client",
client_id: "",
sous_traitant_id: "",
deceased_id: "",
type: "",
scheduled_date: "",
@ -564,8 +630,7 @@ const resetForm = () => {
order_giver: "",
notes: "",
};
// Clear the selected items
selectedItem.value = null;
selectedIntervenant.value = null;
selectedDeceased.value = null;
clearErrors();
};
@ -601,6 +666,31 @@ const clearErrors = () => {
margin-top: 0.25rem;
}
.entity-switch {
display: inline-flex;
gap: 0.5rem;
padding: 0.35rem;
border-radius: 0.9rem;
background: #f8f9fa;
border: 1px solid rgba(52, 71, 103, 0.08);
}
.entity-switch-btn {
border: 0;
background: transparent;
color: #67748e;
font-weight: 600;
padding: 0.65rem 0.9rem;
border-radius: 0.75rem;
transition: all 0.2s ease;
}
.entity-switch-btn.active {
background: white;
color: #344767;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
}
.selection-chip {
display: inline-flex;
align-items: center;
@ -621,20 +711,6 @@ const clearErrors = () => {
height: 1rem;
}
.alert {
border-radius: 0.75rem;
}
.alert-warning {
background-color: #fff3cd;
border: 1px solid #ffeaa7;
color: #856404;
}
.alert i {
font-size: 0.8rem;
}
.soft-select,
.soft-textarea {
background-color: white;

View File

@ -0,0 +1,68 @@
<template>
<div class="d-sm-flex justify-content-between align-items-center gap-3">
<div>
<h5 class="mb-1">Commandes sous-traitants</h5>
<p class="text-sm text-secondary mb-0">
Liste des devis associés à un sous-traitant.
</p>
</div>
<div class="d-flex">
<div class="dropdown d-inline">
<soft-button
id="sousTraitantCommandeFilter"
color="dark"
variant="outline"
class="dropdown-toggle"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Filtrer
</soft-button>
<ul
class="dropdown-menu dropdown-menu-lg-start px-2 py-3"
aria-labelledby="sousTraitantCommandeFilter"
>
<li v-for="option in filterOptions" :key="option.value ?? 'all'">
<a
class="dropdown-item border-radius-md"
:class="{ 'text-danger': option.value === null }"
href="javascript:;"
@click="$emit('filter', option.value)"
>
{{ option.label }}
</a>
</li>
</ul>
</div>
<soft-button
class="btn-icon ms-2 export"
color="dark"
variant="outline"
data-type="csv"
@click="$emit('export')"
>
<span class="btn-inner--icon">
<i class="ni ni-archive-2"></i>
</span>
<span class="btn-inner--text">Export CSV</span>
</soft-button>
</div>
</div>
</template>
<script setup>
import { defineEmits } from "vue";
import SoftButton from "@/components/SoftButton.vue";
defineEmits(["filter", "export"]);
const filterOptions = [
{ label: "Statut: Envoye", value: "envoye" },
{ label: "Statut: Accepte", value: "accepte" },
{ label: "Statut: Brouillon", value: "brouillon" },
{ label: "Statut: Refuse", value: "refuse" },
{ label: "Retirer Filtres", value: null },
];
</script>

View File

@ -0,0 +1,69 @@
<template>
<div class="d-sm-flex justify-content-between align-items-center gap-3">
<div>
<h5 class="mb-1">Factures sous-traitants</h5>
<p class="text-sm text-secondary mb-0">
Liste des factures associées à un sous-traitant.
</p>
</div>
<div class="d-flex">
<div class="dropdown d-inline">
<soft-button
id="sousTraitantFactureFilter"
color="dark"
variant="outline"
class="dropdown-toggle"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Filtrer
</soft-button>
<ul
class="dropdown-menu dropdown-menu-lg-start px-2 py-3"
aria-labelledby="sousTraitantFactureFilter"
>
<li v-for="option in filterOptions" :key="option.value ?? 'all'">
<a
class="dropdown-item border-radius-md"
:class="{ 'text-danger': option.value === null }"
href="javascript:;"
@click="$emit('filter', option.value)"
>
{{ option.label }}
</a>
</li>
</ul>
</div>
<soft-button
class="btn-icon ms-2 export"
color="dark"
variant="outline"
data-type="csv"
@click="$emit('export')"
>
<span class="btn-inner--icon">
<i class="ni ni-archive-2"></i>
</span>
<span class="btn-inner--text">Export CSV</span>
</soft-button>
</div>
</div>
</template>
<script setup>
import { defineEmits } from "vue";
import SoftButton from "@/components/SoftButton.vue";
defineEmits(["filter", "export"]);
const filterOptions = [
{ label: "Statut: Brouillon", value: "brouillon" },
{ label: "Statut: Emise", value: "emise" },
{ label: "Statut: Envoyee", value: "envoyee" },
{ label: "Statut: Payee", value: "payee" },
{ label: "Statut: Echue", value: "echue" },
{ label: "Retirer Filtres", value: null },
];
</script>

View File

@ -0,0 +1,278 @@
<template>
<div class="card mt-4">
<div class="table-responsive">
<table id="sous-traitant-commandes-list" class="table table-flush">
<thead class="thead-light">
<tr>
<th>Reference</th>
<th>Date</th>
<th>Statut</th>
<th>Sous-traitant</th>
<th>Produit</th>
<th>Total TTC</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="quote in data" :key="quote.id">
<td>
<div class="d-flex align-items-center">
<soft-checkbox class="me-2" />
<p class="text-xs font-weight-bold ms-2 mb-0">
{{ quote.reference }}
</p>
</div>
</td>
<td class="font-weight-bold">
<span class="my-2 text-xs">
{{ formatDate(quote.quote_date) }}
</span>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<soft-button
:color="getStatusColor(quote.status)"
variant="outline"
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
>
<i
:class="getStatusIcon(quote.status)"
aria-hidden="true"
></i>
</soft-button>
<span>{{ getStatusLabel(quote.status) }}</span>
</div>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<soft-avatar
:img="getRandomAvatar()"
class="me-2"
size="xs"
alt="sous-traitant image"
circular
/>
<span>{{
quote.sous_traitant?.nom_entreprise || "Sous-traitant inconnu"
}}</span>
</div>
</td>
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">
{{ getProductSummary(quote.lines) }}
</span>
</td>
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">{{
formatCurrency(quote.total_ttc)
}}</span>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<button
class="btn btn-link text-secondary mb-0 px-2"
:data-id="quote.id"
data-action="view"
title="Voir la commande"
>
<i class="fas fa-eye text-xs" aria-hidden="true"></i>
</button>
<button
class="btn btn-link text-danger mb-0 px-2"
:data-id="quote.id"
data-action="delete"
title="Supprimer la commande"
>
<i class="fas fa-trash text-xs" aria-hidden="true"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import {
ref,
onMounted,
watch,
onUnmounted,
defineProps,
defineEmits,
} from "vue";
import { DataTable } from "simple-datatables";
import SoftButton from "@/components/SoftButton.vue";
import SoftAvatar from "@/components/SoftAvatar.vue";
import SoftCheckbox from "@/components/SoftCheckbox.vue";
import img1 from "@/assets/img/team-2.jpg";
import img2 from "@/assets/img/team-1.jpg";
import img3 from "@/assets/img/team-3.jpg";
import img4 from "@/assets/img/team-4.jpg";
import img5 from "@/assets/img/team-5.jpg";
import img6 from "@/assets/img/ivana-squares.jpg";
const avatarImages = [img1, img2, img3, img4, img5, img6];
const emit = defineEmits(["view", "delete"]);
const props = defineProps({
data: {
type: Array,
default: () => [],
},
loading: {
type: Boolean,
default: false,
},
});
const dataTableInstance = ref(null);
let tableClickHandler = null;
const getRandomAvatar = () => {
const randomIndex = Math.floor(Math.random() * avatarImages.length);
return avatarImages[randomIndex];
};
const formatDate = (dateString) => {
if (!dateString) return "-";
return new Date(dateString).toLocaleDateString("fr-FR", {
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
};
const formatCurrency = (value) =>
new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(value || 0);
const getStatusColor = (status) => {
const map = {
brouillon: "secondary",
envoye: "info",
accepte: "success",
refuse: "danger",
expire: "warning",
annule: "danger",
};
return map[status] || "secondary";
};
const getStatusIcon = (status) => {
const map = {
brouillon: "fas fa-pen",
envoye: "fas fa-paper-plane",
accepte: "fas fa-check",
refuse: "fas fa-times",
expire: "fas fa-clock",
annule: "fas fa-ban",
};
return map[status] || "fas fa-info";
};
const getStatusLabel = (status) => {
const labels = {
brouillon: "Brouillon",
envoye: "Envoye",
accepte: "Accepte",
refuse: "Refuse",
expire: "Expire",
annule: "Annule",
};
return labels[status] || status;
};
const getProductSummary = (lines) => {
if (!lines || lines.length === 0) return "Aucun produit";
const firstProduct =
lines[0].product_name || lines[0].description || "Produit";
return lines.length > 1
? `${firstProduct} +${lines.length - 1} autre(s)`
: firstProduct;
};
const initializeDataTable = () => {
if (dataTableInstance.value) {
dataTableInstance.value.destroy();
dataTableInstance.value = null;
}
const dataTableEl = document.getElementById("sous-traitant-commandes-list");
if (dataTableEl) {
dataTableInstance.value = new DataTable(dataTableEl, {
searchable: true,
fixedHeight: false,
perPageSelect: false,
});
}
};
onMounted(() => {
if (!props.loading && props.data.length > 0) {
initializeDataTable();
}
const table = document.getElementById("sous-traitant-commandes-list");
if (table) {
tableClickHandler = (event) => {
const btn = event.target.closest("button");
if (!btn) return;
const id = btn.getAttribute("data-id");
const action = btn.getAttribute("data-action");
if (id && action) {
if (action === "view") {
emit("view", parseInt(id, 10));
} else if (action === "delete") {
emit("delete", parseInt(id, 10));
}
}
};
table.addEventListener("click", tableClickHandler);
}
});
watch(
() => props.data,
() => {
setTimeout(() => {
initializeDataTable();
}, 100);
},
{ deep: true }
);
onUnmounted(() => {
if (dataTableInstance.value) {
dataTableInstance.value.destroy();
}
const table = document.getElementById("sous-traitant-commandes-list");
if (table && tableClickHandler) {
table.removeEventListener("click", tableClickHandler);
}
});
</script>

View File

@ -0,0 +1,285 @@
<template>
<div class="card mt-4">
<div class="table-responsive">
<table id="sous-traitant-factures-list" class="table table-flush">
<thead class="thead-light">
<tr>
<th>Numero</th>
<th>Date</th>
<th>Echeance</th>
<th>Statut</th>
<th>Sous-traitant</th>
<th>Total TTC</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="invoice in data" :key="invoice.id">
<td>
<div class="d-flex align-items-center">
<soft-checkbox class="me-2" />
<p class="text-xs font-weight-bold ms-2 mb-0">
{{ invoice.invoice_number }}
</p>
</div>
</td>
<td class="font-weight-bold">
<span class="my-2 text-xs">
{{ formatDate(invoice.invoice_date) }}
</span>
</td>
<td class="font-weight-bold">
<span class="my-2 text-xs" :class="getDueDateClass(invoice)">
{{ formatDate(invoice.due_date) || "-" }}
</span>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<soft-button
:color="getStatusColor(invoice.status)"
variant="outline"
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
>
<i
:class="getStatusIcon(invoice.status)"
aria-hidden="true"
></i>
</soft-button>
<span>{{ getStatusLabel(invoice.status) }}</span>
</div>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<soft-avatar
:img="getRandomAvatar()"
class="me-2"
size="xs"
alt="sous-traitant image"
circular
/>
<span>
{{
invoice.sous_traitant?.nom_entreprise ||
"Sous-traitant inconnu"
}}
</span>
</div>
</td>
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">{{
formatCurrency(invoice.total_ttc)
}}</span>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<button
class="btn btn-link text-secondary mb-0 px-2"
:data-id="invoice.id"
data-action="view"
title="Voir la facture"
>
<i class="fas fa-eye text-xs" aria-hidden="true"></i>
</button>
<button
class="btn btn-link text-danger mb-0 px-2"
:data-id="invoice.id"
data-action="delete"
title="Supprimer la facture"
>
<i class="fas fa-trash text-xs" aria-hidden="true"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import {
ref,
onMounted,
watch,
onUnmounted,
defineProps,
defineEmits,
} from "vue";
import { DataTable } from "simple-datatables";
import SoftButton from "@/components/SoftButton.vue";
import SoftAvatar from "@/components/SoftAvatar.vue";
import SoftCheckbox from "@/components/SoftCheckbox.vue";
import img1 from "@/assets/img/team-2.jpg";
import img2 from "@/assets/img/team-1.jpg";
import img3 from "@/assets/img/team-3.jpg";
import img4 from "@/assets/img/team-4.jpg";
import img5 from "@/assets/img/team-5.jpg";
import img6 from "@/assets/img/ivana-squares.jpg";
const avatarImages = [img1, img2, img3, img4, img5, img6];
const emit = defineEmits(["view", "delete"]);
const props = defineProps({
data: {
type: Array,
default: () => [],
},
loading: {
type: Boolean,
default: false,
},
});
const dataTableInstance = ref(null);
let tableClickHandler = null;
const getRandomAvatar = () => {
const randomIndex = Math.floor(Math.random() * avatarImages.length);
return avatarImages[randomIndex];
};
const formatDate = (dateString) => {
if (!dateString) return null;
return new Date(dateString).toLocaleDateString("fr-FR", {
day: "numeric",
month: "short",
year: "numeric",
});
};
const formatCurrency = (value) =>
new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(value || 0);
const getDueDateClass = (invoice) => {
if (!invoice.due_date) return "";
const dueDate = new Date(invoice.due_date);
const today = new Date();
return dueDate < today && invoice.status !== "payee" ? "text-danger" : "";
};
const getStatusColor = (status) => {
const map = {
brouillon: "secondary",
emise: "info",
envoyee: "primary",
partiellement_payee: "warning",
payee: "success",
echue: "danger",
annulee: "dark",
avoir: "info",
};
return map[status] || "secondary";
};
const getStatusIcon = (status) => {
const map = {
brouillon: "fas fa-pen",
emise: "fas fa-file-invoice",
envoyee: "fas fa-paper-plane",
partiellement_payee: "fas fa-hourglass-half",
payee: "fas fa-check",
echue: "fas fa-exclamation-circle",
annulee: "fas fa-ban",
avoir: "fas fa-undo",
};
return map[status] || "fas fa-info";
};
const getStatusLabel = (status) => {
const labels = {
brouillon: "Brouillon",
emise: "Emise",
envoyee: "Envoyee",
partiellement_payee: "Part. Payee",
payee: "Payee",
echue: "Echue",
annulee: "Annulee",
avoir: "Avoir",
};
return labels[status] || status;
};
const initializeDataTable = () => {
if (dataTableInstance.value) {
dataTableInstance.value.destroy();
dataTableInstance.value = null;
}
const dataTableEl = document.getElementById("sous-traitant-factures-list");
if (dataTableEl) {
dataTableInstance.value = new DataTable(dataTableEl, {
searchable: true,
fixedHeight: false,
perPageSelect: false,
});
}
};
onMounted(() => {
if (!props.loading && props.data.length > 0) {
initializeDataTable();
}
const table = document.getElementById("sous-traitant-factures-list");
if (table) {
tableClickHandler = (event) => {
const btn = event.target.closest("button");
if (!btn) return;
const id = btn.getAttribute("data-id");
const action = btn.getAttribute("data-action");
if (id && action) {
if (action === "view") {
emit("view", parseInt(id, 10));
} else if (action === "delete") {
emit("delete", parseInt(id, 10));
}
}
};
table.addEventListener("click", tableClickHandler);
}
});
watch(
() => props.data,
() => {
if (!props.loading) {
setTimeout(() => {
initializeDataTable();
}, 100);
}
},
{ deep: true }
);
onUnmounted(() => {
if (dataTableInstance.value) {
dataTableInstance.value.destroy();
}
const table = document.getElementById("sous-traitant-factures-list");
if (table && tableClickHandler) {
table.removeEventListener("click", tableClickHandler);
}
});
</script>

View File

@ -0,0 +1,10 @@
<template>
<div class="container-fluid py-4">
<slot name="controls" />
<div class="row mt-4">
<div class="col-12">
<slot name="table" />
</div>
</div>
</div>
</template>

View File

@ -1,4 +1,5 @@
export * from "./http";
export { default as AuthService } from "./auth";
export { default as OrderGiverSearchService } from "./orderGiverSearch";
export { default as WebmailService } from "./webmail";
export { default as SousTraitantService } from "./sousTraitant";

View File

@ -1,10 +1,12 @@
import { request } from "./http";
import { Client } from "./client";
import type { ClientGroup } from "./clientGroup";
import type { SousTraitant } from "./sousTraitant";
export interface Invoice {
id: number;
client_id: number | null;
sous_traitant_id: number | null;
group_id: number | null;
source_quote_id: number | null;
invoice_number: string;
@ -28,6 +30,7 @@ export interface Invoice {
created_at: string;
updated_at: string;
client?: Client;
sous_traitant?: SousTraitant;
group?: ClientGroup;
}

View File

@ -0,0 +1,98 @@
import { ClientService } from "./client";
import SousTraitantService from "./sousTraitant";
export type OrderGiverType = "client" | "sous_traitant";
export interface OrderGiverSearchResult {
id: number;
entity_type: OrderGiverType;
name: string;
email: string | null;
phone: string | null;
subtitle: string | null;
badge_label: string;
billing_address?: {
line1: string | null;
line2: string | null;
postal_code: string | null;
city: string | null;
country_code: string | null;
full_address?: string;
} | null;
vat_number?: string | null;
siret?: string | null;
}
const normalizeClient = (client: any): OrderGiverSearchResult => ({
id: client.id,
entity_type: "client",
name: client.name,
email: client.email || null,
phone: client.phone || null,
subtitle: client.email || client.phone || null,
badge_label: "Client",
billing_address: client.billing_address || null,
vat_number: client.vat_number || null,
siret: client.siret || null,
});
const normalizeSousTraitant = (sousTraitant: any): OrderGiverSearchResult => ({
id: sousTraitant.id,
entity_type: "sous_traitant",
name: sousTraitant.nom_entreprise,
email: sousTraitant.email || null,
phone: sousTraitant.telephone || null,
subtitle:
sousTraitant.contact_principal ||
sousTraitant.email ||
sousTraitant.telephone ||
null,
badge_label: "Sous-traitant",
siret: sousTraitant.siret || null,
});
export const OrderGiverSearchService = {
async search(query: string): Promise<OrderGiverSearchResult[]> {
const [clients, sousTraitants] = await Promise.all([
ClientService.searchClients(query),
SousTraitantService.searchSousTraitants(query),
]);
return [
...clients.map(normalizeClient),
...sousTraitants.map(normalizeSousTraitant),
];
},
async getRecent(): Promise<OrderGiverSearchResult[]> {
const [clientsResponse, sousTraitantsResponse] = await Promise.all([
ClientService.getAllClients({
page: 1,
per_page: 5,
}),
SousTraitantService.getAllSousTraitants({
page: 1,
per_page: 5,
}),
]);
const clients = Array.isArray(clientsResponse?.data)
? clientsResponse.data
: Array.isArray(clientsResponse?.data?.data)
? clientsResponse.data.data
: [];
const sousTraitants = Array.isArray(sousTraitantsResponse?.data)
? sousTraitantsResponse.data
: Array.isArray(sousTraitantsResponse?.data?.data)
? sousTraitantsResponse.data.data
: [];
return [
...clients.map(normalizeClient),
...sousTraitants.map(normalizeSousTraitant),
];
},
};
export default OrderGiverSearchService;

View File

@ -1,9 +1,11 @@
import { http, request } from "./http";
import { Client } from "./client";
import type { SousTraitant } from "./sousTraitant";
export interface Quote {
id: number;
client_id: number | null;
sous_traitant_id: number | null;
group_id: number | null;
reference: string;
status: "brouillon" | "envoye" | "accepte" | "refuse" | "expire" | "annule";
@ -16,6 +18,7 @@ export interface Quote {
created_at: string;
updated_at: string;
client?: Client;
sous_traitant?: SousTraitant;
}
export interface QuoteListResponse {

View File

@ -9,69 +9,73 @@
:client-loading="clientStore.isLoading"
:search-clients="handleSearchClients"
:on-client-select="handleClientSelect"
:search-sous-traitants="handleSearchSousTraitants"
:on-sous-traitant-select="handleSousTraitantSelect"
:search-deceased="handleSearchDeceased"
:on-deceased-select="handleDeceasedSelect"
@create-intervention="handleCreateIntervention"
/>
</template>
<script setup>
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import AddInterventionPresentation from "@/components/Organism/Interventions/AddInterventionPresentation.vue";
import { useInterventionStore } from "@/stores/interventionStore";
import { useDeceasedStore } from "@/stores/deceasedStore";
import { useClientStore } from "@/stores/clientStore";
import { useSousTraitantStore } from "@/stores/sousTraitantStore";
import { useNotificationStore } from "@/stores/notification";
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
const router = useRouter();
const interventionStore = useInterventionStore();
const deceasedStore = useDeceasedStore();
const clientStore = useClientStore();
const sousTraitantStore = useSousTraitantStore();
const notificationStore = useNotificationStore();
const validationErrors = ref({});
const showSuccess = ref(false);
// Client search handler passed down to form
const handleSearchClients = async (query) => {
return await clientStore.searchClients(query);
};
// Client selection handler to pass down to form
const handleClientSelect = (client) => {
return client;
};
// Deceased search handler passed down to form
const handleSearchSousTraitants = async (query) => {
return await sousTraitantStore.searchSousTraitants(query);
};
const handleSousTraitantSelect = (sousTraitant) => {
return sousTraitant;
};
const handleSearchDeceased = async (query) => {
return await deceasedStore.searchDeceased(query);
};
// Deceased selection handler to pass down to form
const handleDeceasedSelect = (deceased) => {
return deceased;
};
const handleCreateIntervention = async (form) => {
try {
// Clear previous errors
validationErrors.value = {};
showSuccess.value = false;
// Call the store to create intervention
const intervention = await interventionStore.createIntervention(form);
await interventionStore.createIntervention(form);
// Show success notification
notificationStore.created("Intervention");
showSuccess.value = true;
// Redirect after 2 seconds
setTimeout(() => {
router.push({ name: "Interventions" });
}, 2000);
} catch (error) {
console.error("Error creating intervention:", error);
// Handle validation errors from Laravel
if (error.response && error.response.status === 422) {
validationErrors.value = error.response.data.errors || {};
notificationStore.error(
@ -79,7 +83,6 @@ const handleCreateIntervention = async (form) => {
"Veuillez corriger les erreurs dans le formulaire"
);
} else if (error.response && error.response.data) {
// Handle other API errors
const errorMessage =
error.response.data.message || "Une erreur est survenue";
notificationStore.error("Erreur", errorMessage);
@ -89,12 +92,12 @@ const handleCreateIntervention = async (form) => {
}
};
// Load deceased and client lists for selection
onMounted(async () => {
try {
await Promise.all([
deceasedStore.fetchDeceased(),
clientStore.fetchClients(),
sousTraitantStore.fetchSousTraitants(),
]);
} catch (error) {
console.error("Error loading data:", error);

View File

@ -1,11 +1,7 @@
<template>
<div>
<h1>Commandes sous-traitants</h1>
</div>
<sous-traitant-commande-list-presentation />
</template>
<script>
export default {
name: "CommandesSousTraitants",
};
<script setup>
import SousTraitantCommandeListPresentation from "@/components/Organism/CRM/SousTraitantCommandeListPresentation.vue";
</script>

View File

@ -1,11 +1,7 @@
<template>
<div>
<h1>Factures sous-traitants</h1>
</div>
<sous-traitant-facture-list-presentation />
</template>
<script>
export default {
name: "FacturesSousTraitants",
};
<script setup>
import SousTraitantFactureListPresentation from "@/components/Organism/CRM/SousTraitantFactureListPresentation.vue";
</script>