Feat: migration client table, modification groupe client, child dans client
This commit is contained in:
parent
50f79a8040
commit
e0ccd5f627
@ -207,4 +207,112 @@ class ClientController extends Controller
|
|||||||
], 500);
|
], 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Change client status (active/inactive).
|
||||||
|
*/
|
||||||
|
public function changeStatus(Request $request, string $id): ClientResource|JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$isActive = $request->input('is_active');
|
||||||
|
$updated = $this->clientRepository->update($id, ['is_active' => $isActive]);
|
||||||
|
|
||||||
|
if (!$updated) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Client non trouvé ou échec de la mise à jour.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$client = $this->clientRepository->find($id);
|
||||||
|
return new ClientResource($client);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error changing client status: ' . $e->getMessage(), [
|
||||||
|
'exception' => $e,
|
||||||
|
'client_id' => $id,
|
||||||
|
]);
|
||||||
|
return response()->json(['message' => 'Erreur serveur'], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get children clients.
|
||||||
|
*/
|
||||||
|
public function getChildren(string $id): AnonymousResourceCollection|JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$client = $this->clientRepository->find($id);
|
||||||
|
if (!$client) {
|
||||||
|
return response()->json(['message' => 'Client not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assuming the relationship is defined in the model as 'children'
|
||||||
|
$children = $client->children;
|
||||||
|
return ClientResource::collection($children);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error fetching children: ' . $e->getMessage(), [
|
||||||
|
'exception' => $e,
|
||||||
|
'client_id' => $id,
|
||||||
|
]);
|
||||||
|
return response()->json(['message' => 'Erreur serveur'], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a child client.
|
||||||
|
*/
|
||||||
|
public function addChild(string $id, string $childId): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$parent = $this->clientRepository->find($id);
|
||||||
|
$child = $this->clientRepository->find($childId);
|
||||||
|
|
||||||
|
if (!$parent || !$child) {
|
||||||
|
return response()->json(['message' => 'Parent or Child not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update child's parent_id
|
||||||
|
$this->clientRepository->update($childId, ['parent_id' => $id]);
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Child added successfully'], 200);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error adding child: ' . $e->getMessage(), [
|
||||||
|
'exception' => $e,
|
||||||
|
'parent_id' => $id,
|
||||||
|
'child_id' => $childId
|
||||||
|
]);
|
||||||
|
return response()->json(['message' => 'Erreur serveur'], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a child client.
|
||||||
|
*/
|
||||||
|
public function removeChild(string $id, string $childId): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$child = $this->clientRepository->find($childId);
|
||||||
|
|
||||||
|
if (!$child) {
|
||||||
|
return response()->json(['message' => 'Child not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($child->parent_id != $id) {
|
||||||
|
return response()->json(['message' => 'Client is not a child of this parent'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove parent_id
|
||||||
|
$this->clientRepository->update($childId, ['parent_id' => null]);
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Child removed successfully'], 200);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error removing child: ' . $e->getMessage(), [
|
||||||
|
'exception' => $e,
|
||||||
|
'parent_id' => $id,
|
||||||
|
'child_id' => $childId
|
||||||
|
]);
|
||||||
|
return response()->json(['message' => 'Erreur serveur'], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,8 @@ use App\Repositories\InterventionPractitionerRepositoryInterface;
|
|||||||
use App\Repositories\ClientRepositoryInterface;
|
use App\Repositories\ClientRepositoryInterface;
|
||||||
use App\Repositories\ContactRepositoryInterface;
|
use App\Repositories\ContactRepositoryInterface;
|
||||||
use App\Repositories\DeceasedRepositoryInterface;
|
use App\Repositories\DeceasedRepositoryInterface;
|
||||||
|
use App\Repositories\QuoteRepositoryInterface;
|
||||||
|
use App\Repositories\ProductRepositoryInterface;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@ -46,6 +48,16 @@ class InterventionController extends Controller
|
|||||||
*/
|
*/
|
||||||
protected $deceasedRepository;
|
protected $deceasedRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var QuoteRepositoryInterface
|
||||||
|
*/
|
||||||
|
protected $quoteRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ProductRepositoryInterface
|
||||||
|
*/
|
||||||
|
protected $productRepository;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* InterventionController constructor.
|
* InterventionController constructor.
|
||||||
*
|
*
|
||||||
@ -54,19 +66,25 @@ class InterventionController extends Controller
|
|||||||
* @param ClientRepositoryInterface $clientRepository
|
* @param ClientRepositoryInterface $clientRepository
|
||||||
* @param ContactRepositoryInterface $contactRepository
|
* @param ContactRepositoryInterface $contactRepository
|
||||||
* @param DeceasedRepositoryInterface $deceasedRepository
|
* @param DeceasedRepositoryInterface $deceasedRepository
|
||||||
|
* @param QuoteRepositoryInterface $quoteRepository
|
||||||
|
* @param ProductRepositoryInterface $productRepository
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
InterventionRepositoryInterface $interventionRepository,
|
InterventionRepositoryInterface $interventionRepository,
|
||||||
InterventionPractitionerRepositoryInterface $interventionPractitionerRepository,
|
InterventionPractitionerRepositoryInterface $interventionPractitionerRepository,
|
||||||
ClientRepositoryInterface $clientRepository,
|
ClientRepositoryInterface $clientRepository,
|
||||||
ContactRepositoryInterface $contactRepository,
|
ContactRepositoryInterface $contactRepository,
|
||||||
DeceasedRepositoryInterface $deceasedRepository
|
DeceasedRepositoryInterface $deceasedRepository,
|
||||||
|
QuoteRepositoryInterface $quoteRepository,
|
||||||
|
ProductRepositoryInterface $productRepository
|
||||||
) {
|
) {
|
||||||
$this->interventionRepository = $interventionRepository;
|
$this->interventionRepository = $interventionRepository;
|
||||||
$this->interventionPractitionerRepository = $interventionPractitionerRepository;
|
$this->interventionPractitionerRepository = $interventionPractitionerRepository;
|
||||||
$this->clientRepository = $clientRepository;
|
$this->clientRepository = $clientRepository;
|
||||||
$this->contactRepository = $contactRepository;
|
$this->contactRepository = $contactRepository;
|
||||||
$this->deceasedRepository = $deceasedRepository;
|
$this->deceasedRepository = $deceasedRepository;
|
||||||
|
$this->quoteRepository = $quoteRepository;
|
||||||
|
$this->productRepository = $productRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -182,6 +200,56 @@ class InterventionController extends Controller
|
|||||||
]);
|
]);
|
||||||
$intervention = $this->interventionRepository->create($interventionData);
|
$intervention = $this->interventionRepository->create($interventionData);
|
||||||
|
|
||||||
|
// Step 5b: Create a Quote for this intervention
|
||||||
|
try {
|
||||||
|
$interventionProduct = $this->productRepository->findInterventionProduct();
|
||||||
|
|
||||||
|
if ($interventionProduct) {
|
||||||
|
// Calculate totals
|
||||||
|
$quantity = 1;
|
||||||
|
// Ideally fetch TVA rate from product, default to 20% if not set
|
||||||
|
// Assuming product has tva_rate relationship or simple logic
|
||||||
|
$tvaRateValue = 20;
|
||||||
|
|
||||||
|
$unitPrice = $interventionProduct->prix_unitaire;
|
||||||
|
$totalHt = $unitPrice * $quantity;
|
||||||
|
$totalTva = $totalHt * ($tvaRateValue / 100);
|
||||||
|
$totalTtc = $totalHt + $totalTva;
|
||||||
|
|
||||||
|
$quoteData = [
|
||||||
|
'client_id' => $client->id,
|
||||||
|
'status' => 'brouillon',
|
||||||
|
'quote_date' => now()->toDateString(),
|
||||||
|
'currency' => 'EUR',
|
||||||
|
'valid_until' => now()->addDays(30)->toDateString(),
|
||||||
|
'total_ht' => $totalHt,
|
||||||
|
'total_tva' => $totalTva,
|
||||||
|
'total_ttc' => $totalTtc,
|
||||||
|
'lines' => [
|
||||||
|
[
|
||||||
|
'product_id' => $interventionProduct->id,
|
||||||
|
'description' => 'Intervention: ' . ($intervention->type ?? 'Standard'),
|
||||||
|
'units_qty' => $quantity,
|
||||||
|
'unit_price' => $unitPrice,
|
||||||
|
'discount_pct' => 0,
|
||||||
|
'total_ht' => $totalHt,
|
||||||
|
// 'tva_rate_id' => ... if needed
|
||||||
|
]
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->quoteRepository->create($quoteData);
|
||||||
|
Log::info('Quote auto-created for intervention', ['intervention_id' => $intervention->id]);
|
||||||
|
} else {
|
||||||
|
Log::warning('No intervention product found, skipping auto-quote creation', ['intervention_id' => $intervention->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Failed to auto-create quote for intervention: ' . $e->getMessage());
|
||||||
|
// Silently fail for the quote part to not block intervention creation
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Step 6: Handle document uploads (if any)
|
// Step 6: Handle document uploads (if any)
|
||||||
$documents = $validated['documents'] ?? [];
|
$documents = $validated['documents'] ?? [];
|
||||||
if (!empty($documents)) {
|
if (!empty($documents)) {
|
||||||
|
|||||||
@ -28,7 +28,7 @@ class StoreClientRequest extends FormRequest
|
|||||||
'siret' => 'nullable|string|max:20',
|
'siret' => 'nullable|string|max:20',
|
||||||
'email' => 'nullable|email|max:191',
|
'email' => 'nullable|email|max:191',
|
||||||
'phone' => 'nullable|string|max:50',
|
'phone' => 'nullable|string|max:50',
|
||||||
'billing_address_line1' => 'nullable|string|max:255',
|
'billing_address_line1' => 'required|string|max:255',
|
||||||
'billing_address_line2' => 'nullable|string|max:255',
|
'billing_address_line2' => 'nullable|string|max:255',
|
||||||
'billing_postal_code' => 'nullable|string|max:20',
|
'billing_postal_code' => 'nullable|string|max:20',
|
||||||
'billing_city' => 'nullable|string|max:191',
|
'billing_city' => 'nullable|string|max:191',
|
||||||
@ -36,6 +36,8 @@ class StoreClientRequest extends FormRequest
|
|||||||
'group_id' => 'nullable|exists:client_groups,id',
|
'group_id' => 'nullable|exists:client_groups,id',
|
||||||
'notes' => 'nullable|string',
|
'notes' => 'nullable|string',
|
||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
|
'is_parent' => 'boolean|nullable',
|
||||||
|
'parent_id' => 'nullable|exists:clients,id',
|
||||||
'default_tva_rate_id' => 'nullable|exists:tva_rates,id',
|
'default_tva_rate_id' => 'nullable|exists:tva_rates,id',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -56,6 +58,7 @@ class StoreClientRequest extends FormRequest
|
|||||||
'email.email' => 'L\'adresse email doit être valide.',
|
'email.email' => 'L\'adresse email doit être valide.',
|
||||||
'email.max' => 'L\'adresse email ne peut pas dépasser 191 caractères.',
|
'email.max' => 'L\'adresse email ne peut pas dépasser 191 caractères.',
|
||||||
'phone.max' => 'Le téléphone ne peut pas dépasser 50 caractères.',
|
'phone.max' => 'Le téléphone ne peut pas dépasser 50 caractères.',
|
||||||
|
'billing_address_line1.required' => 'L\'adresse facturation est obligatoire.',
|
||||||
'billing_address_line1.max' => 'L\'adresse ne peut pas dépasser 255 caractères.',
|
'billing_address_line1.max' => 'L\'adresse ne peut pas dépasser 255 caractères.',
|
||||||
'billing_address_line2.max' => 'Le complément d\'adresse ne peut pas dépasser 255 caractères.',
|
'billing_address_line2.max' => 'Le complément d\'adresse ne peut pas dépasser 255 caractères.',
|
||||||
'billing_postal_code.max' => 'Le code postal ne peut pas dépasser 20 caractères.',
|
'billing_postal_code.max' => 'Le code postal ne peut pas dépasser 20 caractères.',
|
||||||
|
|||||||
@ -36,6 +36,8 @@ class UpdateClientRequest extends FormRequest
|
|||||||
'group_id' => 'nullable|exists:client_groups,id',
|
'group_id' => 'nullable|exists:client_groups,id',
|
||||||
'notes' => 'nullable|string',
|
'notes' => 'nullable|string',
|
||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
|
'is_parent' => 'boolean|nullable',
|
||||||
|
'parent_id' => 'nullable|exists:clients,id',
|
||||||
'default_tva_rate_id' => 'nullable|exists:tva_rates,id',
|
'default_tva_rate_id' => 'nullable|exists:tva_rates,id',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,6 +36,8 @@ class ClientResource extends JsonResource
|
|||||||
'group_id' => $this->group_id,
|
'group_id' => $this->group_id,
|
||||||
'notes' => $this->notes,
|
'notes' => $this->notes,
|
||||||
'is_active' => $this->is_active,
|
'is_active' => $this->is_active,
|
||||||
|
'is_parent' => $this->is_parent,
|
||||||
|
'parent_id' => $this->parent_id,
|
||||||
// 'default_tva_rate_id' => $this->default_tva_rate_id,
|
// 'default_tva_rate_id' => $this->default_tva_rate_id,
|
||||||
'created_at' => $this->created_at?->format('Y-m-d H:i:s'),
|
'created_at' => $this->created_at?->format('Y-m-d H:i:s'),
|
||||||
'updated_at' => $this->updated_at?->format('Y-m-d H:i:s'),
|
'updated_at' => $this->updated_at?->format('Y-m-d H:i:s'),
|
||||||
@ -50,6 +52,7 @@ class ClientResource extends JsonResource
|
|||||||
// Relations
|
// Relations
|
||||||
// 'company' => new CompanyResource($this->whenLoaded('company')),
|
// 'company' => new CompanyResource($this->whenLoaded('company')),
|
||||||
'group' => new ClientGroupResource($this->whenLoaded('group')),
|
'group' => new ClientGroupResource($this->whenLoaded('group')),
|
||||||
|
'parent' => new ClientResource($this->whenLoaded('parent')),
|
||||||
// 'default_tva_rate' => new TvaRateResource($this->whenLoaded('defaultTvaRate')),
|
// 'default_tva_rate' => new TvaRateResource($this->whenLoaded('defaultTvaRate')),
|
||||||
'contacts' => ContactResource::collection($this->whenLoaded('contacts')),
|
'contacts' => ContactResource::collection($this->whenLoaded('contacts')),
|
||||||
'locations' => ClientLocationResource::collection($this->whenLoaded('locations')),
|
'locations' => ClientLocationResource::collection($this->whenLoaded('locations')),
|
||||||
|
|||||||
@ -21,6 +21,8 @@ class Client extends Model
|
|||||||
'group_id',
|
'group_id',
|
||||||
'notes',
|
'notes',
|
||||||
'is_active',
|
'is_active',
|
||||||
|
'is_parent',
|
||||||
|
'parent_id',
|
||||||
// 'default_tva_rate_id',
|
// 'default_tva_rate_id',
|
||||||
'client_category_id',
|
'client_category_id',
|
||||||
'user_id',
|
'user_id',
|
||||||
@ -28,8 +30,19 @@ class Client extends Model
|
|||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
|
'is_parent' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public function parent(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Client::class, 'parent_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function children()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Client::class, 'parent_id');
|
||||||
|
}
|
||||||
|
|
||||||
public function user(): BelongsTo
|
public function user(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
|
|||||||
@ -140,4 +140,15 @@ class ProductRepository extends BaseRepository implements ProductRepositoryInter
|
|||||||
'total_value' => $totalValue,
|
'total_value' => $totalValue,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Find a default intervention product (where category has intervention=true)
|
||||||
|
*/
|
||||||
|
public function findInterventionProduct(): ?Product
|
||||||
|
{
|
||||||
|
return $this->model->newQuery()
|
||||||
|
->whereHas('category', function ($query) {
|
||||||
|
$query->where('intervention', true);
|
||||||
|
})
|
||||||
|
->first();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,4 +17,6 @@ interface ProductRepositoryInterface extends BaseRepositoryInterface
|
|||||||
public function getByCategory(int $categoryId, int $perPage = 15): LengthAwarePaginator;
|
public function getByCategory(int $categoryId, int $perPage = 15): LengthAwarePaginator;
|
||||||
|
|
||||||
public function getProductsByFournisseur(int $fournisseurId): LengthAwarePaginator;
|
public function getProductsByFournisseur(int $fournisseurId): LengthAwarePaginator;
|
||||||
|
|
||||||
|
public function findInterventionProduct(): ?\App\Models\Product;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,11 @@ class QuoteRepository extends BaseRepository implements QuoteRepositoryInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function all(array $columns = ['*']): \Illuminate\Support\Collection
|
||||||
|
{
|
||||||
|
return $this->model->with(['client', 'lines.product'])->get($columns);
|
||||||
|
}
|
||||||
|
|
||||||
public function create(array $data): Quote
|
public function create(array $data): Quote
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($data) {
|
return DB::transaction(function () use ($data) {
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
<?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->boolean('is_parent')->nullable()->default(false);
|
||||||
|
$table->foreignId('parent_id')->nullable()->constrained('clients')->onDelete('set null');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('clients', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['parent_id']);
|
||||||
|
$table->dropColumn(['is_parent', 'parent_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -53,9 +53,16 @@ Route::middleware('auth:sanctum')->group(function () {
|
|||||||
Route::apiResource('clients', ClientController::class);
|
Route::apiResource('clients', ClientController::class);
|
||||||
Route::apiResource('client-groups', ClientGroupController::class);
|
Route::apiResource('client-groups', ClientGroupController::class);
|
||||||
|
|
||||||
|
Route::apiResource('client-locations', ClientLocationController::class);
|
||||||
Route::apiResource('client-locations', ClientLocationController::class);
|
Route::apiResource('client-locations', ClientLocationController::class);
|
||||||
Route::get('clients/{clientId}/locations', [ClientLocationController::class, 'getLocationsByClient']);
|
Route::get('clients/{clientId}/locations', [ClientLocationController::class, 'getLocationsByClient']);
|
||||||
|
|
||||||
|
// Client Parent/Child routes
|
||||||
|
Route::get('clients/{id}/children', [ClientController::class, 'getChildren']);
|
||||||
|
Route::post('clients/{id}/children/{childId}', [ClientController::class, 'addChild']);
|
||||||
|
Route::delete('clients/{id}/children/{childId}', [ClientController::class, 'removeChild']);
|
||||||
|
Route::patch('clients/{id}/status', [ClientController::class, 'changeStatus']);
|
||||||
|
|
||||||
// Contact management
|
// Contact management
|
||||||
Route::apiResource('contacts', ContactController::class);
|
Route::apiResource('contacts', ContactController::class);
|
||||||
Route::get('clients/{clientId}/contacts', [ContactController::class, 'getContactsByClient']);
|
Route::get('clients/{clientId}/contacts', [ContactController::class, 'getContactsByClient']);
|
||||||
|
|||||||
@ -25,10 +25,12 @@
|
|||||||
:client-type="client.type_label || 'Client'"
|
:client-type="client.type_label || 'Client'"
|
||||||
:contacts-count="contacts.length"
|
:contacts-count="contacts.length"
|
||||||
:locations-count="locations.length"
|
:locations-count="locations.length"
|
||||||
|
:children-count="children ? children.length : 0"
|
||||||
:is-active="client.is_active"
|
:is-active="client.is_active"
|
||||||
|
:is-parent="client.is_parent"
|
||||||
:active-tab="activeTab"
|
:active-tab="activeTab"
|
||||||
@edit-avatar="triggerFileInput"
|
@edit-avatar="triggerFileInput"
|
||||||
@change-tab="activeTab = $event"
|
@change-tab="$emit('change-tab', $event)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #file-input>
|
<template #file-input>
|
||||||
@ -50,7 +52,7 @@
|
|||||||
:client-id="client.id"
|
:client-id="client.id"
|
||||||
:contact-is-loading="contactLoading"
|
:contact-is-loading="contactLoading"
|
||||||
:location-is-loading="locationLoading"
|
:location-is-loading="locationLoading"
|
||||||
@change-tab="activeTab = $event"
|
@change-tab="$emit('change-tab', $event)"
|
||||||
@updating-client="handleUpdateClient"
|
@updating-client="handleUpdateClient"
|
||||||
@create-contact="handleAddContact"
|
@create-contact="handleAddContact"
|
||||||
@updating-contact="handleModifiedContact"
|
@updating-contact="handleModifiedContact"
|
||||||
@ -83,6 +85,11 @@ const props = defineProps({
|
|||||||
required: false,
|
required: false,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
|
children: {
|
||||||
|
type: Array,
|
||||||
|
required: false,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
isLoading: {
|
isLoading: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@ -118,7 +125,9 @@ const emit = defineEmits([
|
|||||||
"updating-contact",
|
"updating-contact",
|
||||||
"add-new-location",
|
"add-new-location",
|
||||||
"modify-location",
|
"modify-location",
|
||||||
|
"modify-location",
|
||||||
"remove-location",
|
"remove-location",
|
||||||
|
"change-tab",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleAvatarUpload = (event) => {
|
const handleAvatarUpload = (event) => {
|
||||||
|
|||||||
@ -0,0 +1,110 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="modal fade"
|
||||||
|
:class="{ show: show, 'd-block': show }"
|
||||||
|
:style="{ display: show ? 'block' : 'none' }"
|
||||||
|
tabindex="-1"
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="addChildModalLabel"
|
||||||
|
:aria-hidden="!show"
|
||||||
|
>
|
||||||
|
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="addChildModalLabel">Ajouter un sous-client</h5>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-close text-dark"
|
||||||
|
@click="closeModal"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Rechercher un client</label>
|
||||||
|
<div v-if="selectedClient" class="d-flex align-items-center justify-content-between p-2 border rounded mb-3 bg-light">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<soft-avatar
|
||||||
|
size="sm"
|
||||||
|
border-radius="md"
|
||||||
|
class="me-3"
|
||||||
|
alt="selected client"
|
||||||
|
/>
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<span class="font-weight-bold">{{ selectedClient.name }}</span>
|
||||||
|
<span class="text-xs text-muted">{{ selectedClient.email }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-link text-danger mb-0" @click="selectedClient = null">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ClientSearchInput
|
||||||
|
v-else
|
||||||
|
:exclude-ids="excludeIds"
|
||||||
|
@select="handleSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" @click="closeModal">Annuler</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-success btn-sm"
|
||||||
|
:disabled="!selectedClient || loading"
|
||||||
|
@click="confirmAdd"
|
||||||
|
>
|
||||||
|
<span v-if="loading" class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="show" class="modal-backdrop fade show"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, defineProps, defineEmits } from "vue";
|
||||||
|
import ClientSearchInput from "@/components/molecules/client/ClientSearchInput.vue";
|
||||||
|
import SoftAvatar from "@/components/SoftAvatar.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
excludeIds: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["close", "add"]);
|
||||||
|
|
||||||
|
const selectedClient = ref(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const handleSelect = (client) => {
|
||||||
|
selectedClient.value = client;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
selectedClient.value = null;
|
||||||
|
emit("close");
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmAdd = async () => {
|
||||||
|
if (!selectedClient.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
emit("add", selectedClient.value);
|
||||||
|
// Loading state is handled by parent usually, but here we emit and wait?
|
||||||
|
// Ideally parent handles the async and closes modal.
|
||||||
|
// For now simple emit.
|
||||||
|
loading.value = false;
|
||||||
|
closeModal();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@ -52,6 +52,11 @@
|
|||||||
<div v-show="activeTab === 'notes'">
|
<div v-show="activeTab === 'notes'">
|
||||||
<ClientNotesTab :notes="client.notes" />
|
<ClientNotesTab :notes="client.notes" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Children Tab -->
|
||||||
|
<div v-show="activeTab === 'children'">
|
||||||
|
<ClientChildrenTab :client="client" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -62,6 +67,7 @@ import ClientContactsTab from "@/components/molecules/client/ClientContactsTab.v
|
|||||||
import ClientAddressTab from "@/components/molecules/client/ClientAddressTab.vue";
|
import ClientAddressTab from "@/components/molecules/client/ClientAddressTab.vue";
|
||||||
import ClientLocationsTab from "@/components/molecules/client/ClientLocationsTab.vue";
|
import ClientLocationsTab from "@/components/molecules/client/ClientLocationsTab.vue";
|
||||||
import ClientNotesTab from "@/components/molecules/client/ClientNotesTab.vue";
|
import ClientNotesTab from "@/components/molecules/client/ClientNotesTab.vue";
|
||||||
|
import ClientChildrenTab from "@/components/molecules/client/ClientChildrenTab.vue";
|
||||||
import { defineProps, defineEmits } from "vue";
|
import { defineProps, defineEmits } from "vue";
|
||||||
import ClientActivityTab from "@/components/molecules/client/ClientActivityTab.vue";
|
import ClientActivityTab from "@/components/molecules/client/ClientActivityTab.vue";
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,8 @@
|
|||||||
:active-tab="activeTab"
|
:active-tab="activeTab"
|
||||||
:contacts-count="contactsCount"
|
:contacts-count="contactsCount"
|
||||||
:locations-count="locationsCount"
|
:locations-count="locationsCount"
|
||||||
|
:children-count="childrenCount"
|
||||||
|
:is-parent="isParent"
|
||||||
@change-tab="$emit('change-tab', $event)"
|
@change-tab="$emit('change-tab', $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -58,6 +60,14 @@ defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
isParent: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
childrenCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
activeTab: {
|
activeTab: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
|
|||||||
@ -1,13 +1,56 @@
|
|||||||
<template>
|
<template>
|
||||||
<interventions-template>
|
<interventions-template>
|
||||||
<template #intervention-new-action>
|
<template #intervention-new-action>
|
||||||
<add-button text="Ajouter" @click="go" />
|
<soft-button color="success" variant="gradient" @click="go">
|
||||||
|
<i class="fas fa-plus me-2"></i> Ajouter
|
||||||
|
</soft-button>
|
||||||
|
</template>
|
||||||
|
<template #header-pagination>
|
||||||
|
<div class="d-flex justify-content-center" v-if="pagination && pagination.last_page > 1">
|
||||||
|
<soft-pagination color="success" size="sm">
|
||||||
|
<soft-pagination-item
|
||||||
|
prev
|
||||||
|
:disabled="pagination.current_page <= 1"
|
||||||
|
@click="changePage(pagination.current_page - 1)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<soft-pagination-item
|
||||||
|
v-for="page in visiblePages"
|
||||||
|
:key="page"
|
||||||
|
:label="page.toString()"
|
||||||
|
:active="pagination.current_page === page"
|
||||||
|
@click="changePage(page)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<soft-pagination-item
|
||||||
|
next
|
||||||
|
:disabled="pagination.current_page >= pagination.last_page"
|
||||||
|
@click="changePage(pagination.current_page + 1)"
|
||||||
|
/>
|
||||||
|
</soft-pagination>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #select-filter>
|
<template #select-filter>
|
||||||
<filter-table />
|
<soft-button color="dark" variant="outline" class="dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="fas fa-filter me-2"></i> Filtrer
|
||||||
|
</soft-button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-lg-start px-2 py-3">
|
||||||
|
<li><a class="dropdown-item border-radius-md" href="javascript:;">Par date</a></li>
|
||||||
|
<li><a class="dropdown-item border-radius-md" href="javascript:;">Par statut</a></li>
|
||||||
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
<template #intervention-other-action>
|
<template #intervention-other-action>
|
||||||
<table-action />
|
<soft-button
|
||||||
|
class="btn-icon ms-2 export"
|
||||||
|
color="dark"
|
||||||
|
variant="outline"
|
||||||
|
@click="$emit('export-csv')"
|
||||||
|
>
|
||||||
|
<span class="btn-inner--icon">
|
||||||
|
<i class="ni ni-archive-2"></i>
|
||||||
|
</span>
|
||||||
|
<span class="btn-inner--text">Export CSV</span>
|
||||||
|
</soft-button>
|
||||||
</template>
|
</template>
|
||||||
<template #intervention-table>
|
<template #intervention-table>
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
@ -27,22 +70,25 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Data table -->
|
<!-- Data table -->
|
||||||
<interventions-list v-else :intervention-data="interventions" />
|
<template v-else>
|
||||||
|
<interventions-list :intervention-data="interventions" />
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</interventions-template>
|
</interventions-template>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import InterventionsTemplate from "@/components/templates/Interventions/InterventionsTemplate.vue";
|
import InterventionsTemplate from "@/components/templates/Interventions/InterventionsTemplate.vue";
|
||||||
import addButton from "@/components/molecules/new-button/addButton.vue";
|
|
||||||
import FilterTable from "@/components/molecules/Tables/FilterTable.vue";
|
|
||||||
import TableAction from "@/components/molecules/Tables/TableAction.vue";
|
|
||||||
import interventionsList from "@/components/molecules/Interventions/interventionsList.vue";
|
import interventionsList from "@/components/molecules/Interventions/interventionsList.vue";
|
||||||
import { defineProps, defineEmits } from "vue";
|
import SoftButton from "@/components/SoftButton.vue";
|
||||||
|
import SoftPagination from "@/components/SoftPagination.vue";
|
||||||
|
import SoftPaginationItem from "@/components/SoftPaginationItem.vue";
|
||||||
|
import { defineProps, defineEmits, computed } from "vue";
|
||||||
|
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
interventions: {
|
interventions: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
@ -55,12 +101,92 @@ defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
pagination: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Emits
|
// Emits
|
||||||
defineEmits(["retry"]);
|
const emit = defineEmits(["retry", "page-change", "export-csv"]);
|
||||||
|
|
||||||
const go = () => {
|
const go = () => {
|
||||||
router.push({ name: "Add Intervention" });
|
router.push({ name: "Add Intervention" });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const changePage = (page) => {
|
||||||
|
if (typeof page !== 'number') return;
|
||||||
|
if (page < 1 || (props.pagination && page > props.pagination.last_page)) return;
|
||||||
|
if (page === props.pagination.current_page) return;
|
||||||
|
emit("page-change", page);
|
||||||
|
};
|
||||||
|
|
||||||
|
const visiblePages = computed(() => {
|
||||||
|
if (!props.pagination) return [];
|
||||||
|
const { current_page, last_page } = props.pagination;
|
||||||
|
const delta = 2;
|
||||||
|
const range = [];
|
||||||
|
for (let i = Math.max(2, current_page - delta); i <= Math.min(last_page - 1, current_page + delta); i++) {
|
||||||
|
range.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current_page - delta > 2) {
|
||||||
|
range.unshift("...");
|
||||||
|
}
|
||||||
|
if (current_page + delta < last_page - 1) {
|
||||||
|
range.push("...");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always show first and last
|
||||||
|
let finalRange = [];
|
||||||
|
if (last_page > 0) finalRange.push(1);
|
||||||
|
// Simplified logic for now: just range around current if total pages is large, else all
|
||||||
|
if (last_page <= 7) {
|
||||||
|
return Array.from({length: last_page}, (_, i) => i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complex logic if needed, but for now let's do simple sliding window
|
||||||
|
// [1] ... [current-1] [current] [current+1] ... [last]
|
||||||
|
|
||||||
|
let pages = [1];
|
||||||
|
|
||||||
|
let start = Math.max(2, current_page - 1);
|
||||||
|
let end = Math.min(last_page - 1, current_page + 1);
|
||||||
|
|
||||||
|
if (current_page <= 3) {
|
||||||
|
end = 4; // Show 1, 2, 3, 4 ... Last
|
||||||
|
}
|
||||||
|
if (current_page >= last_page - 2) {
|
||||||
|
start = last_page - 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start > 2) {
|
||||||
|
// no dots, just gap logic handled by UI usually, but here we return numbers
|
||||||
|
// SoftPaginationItem expects label, maybe I handle dots logic elsewhere?
|
||||||
|
// Let's stick to simple window:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-implement simplified:
|
||||||
|
const p = [];
|
||||||
|
const total = last_page;
|
||||||
|
if (total <= 7) {
|
||||||
|
for(let i=1; i<=total; i++) p.push(i);
|
||||||
|
} else {
|
||||||
|
p.push(1);
|
||||||
|
if (current_page > 3) p.push('...');
|
||||||
|
|
||||||
|
let midStart = Math.max(2, current_page - 1);
|
||||||
|
let midEnd = Math.min(total - 1, current_page + 1);
|
||||||
|
|
||||||
|
// pinned logic adjustment
|
||||||
|
if (current_page < 4) { midStart = 2; midEnd = 4; }
|
||||||
|
if (current_page > total - 3) { midStart = total - 3; midEnd = total - 1; }
|
||||||
|
|
||||||
|
for(let i=midStart; i<=midEnd; i++) p.push(i);
|
||||||
|
|
||||||
|
if (current_page < total - 2) p.push('...');
|
||||||
|
p.push(total);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -92,9 +92,12 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const formatCurrency = (value) => {
|
const formatCurrency = (value) => {
|
||||||
|
const numberValue = typeof value === 'string' ? parseFloat(value) : value;
|
||||||
|
if (isNaN(numberValue)) return '0,00 €';
|
||||||
|
|
||||||
return new Intl.NumberFormat("fr-FR", {
|
return new Intl.NumberFormat("fr-FR", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "EUR",
|
currency: "EUR",
|
||||||
}).format(value);
|
}).format(numberValue);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -26,15 +26,18 @@
|
|||||||
import { defineProps } from "vue";
|
import { defineProps } from "vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
ht: Number,
|
ht: [Number, String],
|
||||||
tva: Number,
|
tva: [Number, String],
|
||||||
ttc: Number,
|
ttc: [Number, String],
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatCurrency = (value) => {
|
const formatCurrency = (value) => {
|
||||||
|
const numberValue = typeof value === 'string' ? parseFloat(value) : value;
|
||||||
|
if (isNaN(numberValue)) return '0,00 €';
|
||||||
|
|
||||||
return new Intl.NumberFormat("fr-FR", {
|
return new Intl.NumberFormat("fr-FR", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "EUR",
|
currency: "EUR",
|
||||||
}).format(value);
|
}).format(numberValue);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -0,0 +1,198 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header pb-0 p-3">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8 d-flex align-items-center">
|
||||||
|
<h6 class="mb-0">Gestion des sous-comptes</h6>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 text-end">
|
||||||
|
<!-- Toggle Is Parent -->
|
||||||
|
<div class="form-check form-switch d-inline-block ms-auto">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="isParentToggle"
|
||||||
|
:checked="client.is_parent"
|
||||||
|
@change="toggleParentStatus"
|
||||||
|
/>
|
||||||
|
<label class="form-check-label" for="isParentToggle">Compte Parent</label>
|
||||||
|
</div>
|
||||||
|
<!-- Add Child Button -->
|
||||||
|
<soft-button
|
||||||
|
v-if="client.is_parent"
|
||||||
|
size="sm"
|
||||||
|
variant="gradient"
|
||||||
|
color="success"
|
||||||
|
class="ms-3"
|
||||||
|
@click="showAddModal = true"
|
||||||
|
>
|
||||||
|
<i class="fas fa-plus me-2"></i>Ajouter un sous-client
|
||||||
|
</soft-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<div v-if="!client.is_parent" class="text-center py-4">
|
||||||
|
<p class="text-muted">
|
||||||
|
Ce client n'est pas défini comme compte parent. Activez l'option ci-dessus pour gérer des sous-comptes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<div v-if="loading" class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Chargement...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="children.length === 0" class="text-center py-4">
|
||||||
|
<p class="text-muted">Aucun sous-compte associé.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-else class="list-group">
|
||||||
|
<li v-for="child in children" :key="child.id" class="list-group-item border-0 d-flex justify-content-between ps-0 mb-2 border-radius-lg">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<soft-avatar
|
||||||
|
:img="getAvatar(child.name)"
|
||||||
|
size="sm"
|
||||||
|
border-radius="md"
|
||||||
|
class="me-3"
|
||||||
|
alt="child client"
|
||||||
|
/>
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<h6 class="mb-1 text-dark text-sm">{{ child.name }}</h6>
|
||||||
|
<span class="text-xs">{{ child.email }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center text-sm">
|
||||||
|
<button class="btn btn-link text-dark text-sm mb-0 px-0 ms-4" @click="goToClient(child.id)">
|
||||||
|
<i class="fas fa-eye text-lg me-1"></i> Voir
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-link text-danger text-gradient px-3 mb-0" @click="confirmRemoveChild(child)">
|
||||||
|
<i class="far fa-trash-alt me-2"></i> Détacher
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AddChildClientModal
|
||||||
|
:show="showAddModal"
|
||||||
|
:exclude-ids="[client.id, client.parent_id, ...children.map(c => c.id)]"
|
||||||
|
@close="showAddModal = false"
|
||||||
|
@add="handleAddChild"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, onMounted, watch, defineProps, defineEmits } from "vue";
|
||||||
|
import SoftButton from "@/components/SoftButton.vue";
|
||||||
|
import SoftAvatar from "@/components/SoftAvatar.vue";
|
||||||
|
import AddChildClientModal from "@/components/Organism/CRM/client/AddChildClientModal.vue";
|
||||||
|
import { useClientStore } from "@/stores/clientStore";
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import Swal from 'sweetalert2';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
client: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update-client']);
|
||||||
|
const clientStore = useClientStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const children = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const showAddModal = ref(false);
|
||||||
|
|
||||||
|
const fetchChildren = async () => {
|
||||||
|
if (!props.client.is_parent) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await clientStore.fetchChildClients(props.client.id);
|
||||||
|
children.value = res.data || res; // handle potential array vs response structure
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch children", e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.client.is_parent, (newVal) => {
|
||||||
|
if (newVal) fetchChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/* eslint-disable require-atomic-updates */
|
||||||
|
const toggleParentStatus = async (e) => {
|
||||||
|
const isChecked = e.target.checked;
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
// emit('update-client', { ...props.client, is_parent: isChecked });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// We'll update the client via store, passing just the id and field to update
|
||||||
|
await clientStore.updateClient({
|
||||||
|
id: props.client.id,
|
||||||
|
name: props.client.name,
|
||||||
|
is_parent: isChecked
|
||||||
|
});
|
||||||
|
// The parent component should react to store changes if it watches it, or we emit updated
|
||||||
|
} catch (err) {
|
||||||
|
// Revert on error
|
||||||
|
e.target.checked = !isChecked;
|
||||||
|
Swal.fire('Erreur', 'Impossible de mettre à jour le statut parent.', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddChild = async (selectedClient) => {
|
||||||
|
try {
|
||||||
|
await clientStore.addChildClient(props.client.id, selectedClient.id);
|
||||||
|
Swal.fire('Succès', 'Le client a été ajouté comme sous-compte.', 'success');
|
||||||
|
fetchChildren();
|
||||||
|
} catch (e) {
|
||||||
|
Swal.fire('Erreur', "Impossible d'ajouter le sous-compte.", 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const confirmRemoveChild = async (child) => {
|
||||||
|
const result = await Swal.fire({
|
||||||
|
title: 'Confirmer le détachement',
|
||||||
|
text: `Voulez-vous vraiment détacher ${child.name} ?`,
|
||||||
|
icon: 'warning',
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonText: 'Oui, détacher',
|
||||||
|
cancelButtonText: 'Annuler'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
try {
|
||||||
|
await clientStore.removeChildClient(props.client.id, child.id);
|
||||||
|
Swal.fire('Détaché!', 'Le client a été détaché.', 'success');
|
||||||
|
children.value = children.value.filter(c => c.id !== child.id);
|
||||||
|
} catch (e) {
|
||||||
|
Swal.fire('Erreur', "Impossible de détacher le client.", 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToClient = (id) => {
|
||||||
|
router.push(`/crm/clients/${id}`); // Adjust route as needed
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper for initials/avatar
|
||||||
|
const getAvatar = (name) => {
|
||||||
|
// placeholder logic, replace with actual avatar logic or component usage
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
</script>
|
||||||
@ -0,0 +1,131 @@
|
|||||||
|
<template>
|
||||||
|
<div class="client-search-input position-relative">
|
||||||
|
<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"
|
||||||
|
placeholder="Rechercher un client (nom, email...)"
|
||||||
|
@input="handleInput"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="searchQuery"
|
||||||
|
class="btn btn-outline-secondary mb-0"
|
||||||
|
type="button"
|
||||||
|
@click="clearSearch"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="loading" class="text-center py-2 position-absolute w-100 bg-white border rounded shadow-sm" style="z-index: 1000; top: 100%;">
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Chargement...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results Dropdown -->
|
||||||
|
<div
|
||||||
|
v-else-if="results.length > 0 && showResults"
|
||||||
|
class="list-group position-absolute w-100 mt-1 shadow-lg"
|
||||||
|
style="z-index: 1000; max-height: 300px; overflow-y: auto;"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="client in results"
|
||||||
|
:key="client.id"
|
||||||
|
type="button"
|
||||||
|
class="list-group-item list-group-item-action d-flex align-items-center p-2"
|
||||||
|
@click="handleSelect(client)"
|
||||||
|
>
|
||||||
|
<soft-avatar
|
||||||
|
:img="getAvatar(client)"
|
||||||
|
size="sm"
|
||||||
|
border-radius="md"
|
||||||
|
class="me-3"
|
||||||
|
:alt="client.name"
|
||||||
|
/>
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<span class="font-weight-bold text-sm">{{ client.name }}</span>
|
||||||
|
<span class="text-xs text-muted">{{ client.email || 'Pas d\'email' }}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No Results -->
|
||||||
|
<div
|
||||||
|
v-else-if="searchQuery && !loading && showResults"
|
||||||
|
class="position-absolute w-100 mt-1 p-3 bg-white border rounded shadow-sm text-center text-sm text-muted"
|
||||||
|
style="z-index: 1000;"
|
||||||
|
>
|
||||||
|
Aucun client trouvé.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, defineProps, defineEmits } from "vue";
|
||||||
|
import SoftAvatar from "@/components/SoftAvatar.vue";
|
||||||
|
import { useClientStore } from "@/stores/clientStore";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
excludeIds: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["select"]);
|
||||||
|
const clientStore = useClientStore();
|
||||||
|
|
||||||
|
const searchQuery = ref("");
|
||||||
|
const results = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const showResults = ref(false);
|
||||||
|
let debounceTimeout = null;
|
||||||
|
|
||||||
|
const handleInput = () => {
|
||||||
|
showResults.value = true;
|
||||||
|
if (debounceTimeout) clearTimeout(debounceTimeout);
|
||||||
|
|
||||||
|
if (!searchQuery.value.trim()) {
|
||||||
|
results.value = [];
|
||||||
|
showResults.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
debounceTimeout = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await clientStore.searchClients(searchQuery.value);
|
||||||
|
// Filter out excluded IDs (e.g. self, parent)
|
||||||
|
results.value = res.filter(c => !props.excludeIds.includes(c.id));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
results.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (client) => {
|
||||||
|
emit("select", client);
|
||||||
|
searchQuery.value = "";
|
||||||
|
results.value = [];
|
||||||
|
showResults.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSearch = () => {
|
||||||
|
searchQuery.value = "";
|
||||||
|
results.value = [];
|
||||||
|
showResults.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper for avatar (placeholder logic)
|
||||||
|
const getAvatar = (client) => {
|
||||||
|
// If client has an avatar property use it, else return null/placeholder logic handled by SoftAvatar or similar
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@ -45,6 +45,13 @@
|
|||||||
:is-active="activeTab === 'notes'"
|
:is-active="activeTab === 'notes'"
|
||||||
@click="$emit('change-tab', 'notes')"
|
@click="$emit('change-tab', 'notes')"
|
||||||
/>
|
/>
|
||||||
|
<TabNavigationItem
|
||||||
|
icon="fas fa-sitemap"
|
||||||
|
label="Sous-comptes"
|
||||||
|
:is-active="activeTab === 'children'"
|
||||||
|
:badge="childrenCount > 0 ? childrenCount : null"
|
||||||
|
@click="$emit('change-tab', 'children')"
|
||||||
|
/>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -65,6 +72,14 @@ defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 0,
|
default: 0,
|
||||||
},
|
},
|
||||||
|
childrenCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
isParent: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
defineEmits(["change-tab"]);
|
defineEmits(["change-tab"]);
|
||||||
|
|||||||
@ -9,9 +9,9 @@
|
|||||||
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
||||||
<label class="form-label">Catégorie de client</label>
|
<label class="form-label">Catégorie de client</label>
|
||||||
<select
|
<select
|
||||||
:value="form.client_category_id"
|
v-model="form.client_category_id"
|
||||||
class="form-control multisteps-form__input"
|
class="form-control multisteps-form__input"
|
||||||
@input="form.client_category_id = $event.target.value"
|
:class="{ 'is-invalid': fieldErrors.client_category_id }"
|
||||||
>
|
>
|
||||||
<option value="">Sélectionner une catégorie</option>
|
<option value="">Sélectionner une catégorie</option>
|
||||||
<option
|
<option
|
||||||
@ -22,6 +22,12 @@
|
|||||||
{{ category.name }}
|
{{ category.name }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
<div
|
||||||
|
v-if="fieldErrors.client_category_id"
|
||||||
|
class="invalid-feedback"
|
||||||
|
>
|
||||||
|
{{ errorMessage("client_category_id") }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -32,15 +38,14 @@
|
|||||||
>Nom du client <span class="text-danger">*</span></label
|
>Nom du client <span class="text-danger">*</span></label
|
||||||
>
|
>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.name"
|
v-model="form.name"
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.name }"
|
:error="!!fieldErrors.name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. Nom de l'entreprise ou Nom du particulier"
|
placeholder="ex. Nom de l'entreprise ou Nom du particulier"
|
||||||
@input="form.name = $event.target.value"
|
|
||||||
/>
|
/>
|
||||||
<div v-if="fieldErrors.name" class="invalid-feedback">
|
<div v-if="fieldErrors.name" class="invalid-feedback">
|
||||||
{{ fieldErrors.name }}
|
{{ errorMessage("name") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -50,31 +55,29 @@
|
|||||||
<div class="col-12 col-sm-6">
|
<div class="col-12 col-sm-6">
|
||||||
<label class="form-label">Numéro de TVA</label>
|
<label class="form-label">Numéro de TVA</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.vat_number"
|
v-model="form.vat_number"
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.vat_number }"
|
:error="!!fieldErrors.vat_number"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. FR12345678901"
|
placeholder="ex. FR12345678901"
|
||||||
maxlength="32"
|
maxlength="32"
|
||||||
@input="form.vat_number = $event.target.value"
|
|
||||||
/>
|
/>
|
||||||
<div v-if="fieldErrors.vat_number" class="invalid-feedback">
|
<div v-if="fieldErrors.vat_number" class="invalid-feedback">
|
||||||
{{ fieldErrors.vat_number }}
|
{{ errorMessage("vat_number") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
||||||
<label class="form-label">SIRET</label>
|
<label class="form-label">SIRET</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.siret"
|
v-model="form.siret"
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.siret }"
|
:error="!!fieldErrors.siret"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. 12345678901234"
|
placeholder="ex. 12345678901234"
|
||||||
maxlength="20"
|
maxlength="20"
|
||||||
@input="form.siret = $event.target.value"
|
|
||||||
/>
|
/>
|
||||||
<div v-if="fieldErrors.siret" class="invalid-feedback">
|
<div v-if="fieldErrors.siret" class="invalid-feedback">
|
||||||
{{ fieldErrors.siret }}
|
{{ errorMessage("siret") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -84,30 +87,28 @@
|
|||||||
<div class="col-12 col-sm-6">
|
<div class="col-12 col-sm-6">
|
||||||
<label class="form-label">Email</label>
|
<label class="form-label">Email</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.email"
|
v-model="form.email"
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.email }"
|
:error="!!fieldErrors.email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="ex. contact@entreprise.com"
|
placeholder="ex. contact@entreprise.com"
|
||||||
@input="form.email = $event.target.value"
|
|
||||||
/>
|
/>
|
||||||
<div v-if="fieldErrors.email" class="invalid-feedback">
|
<div v-if="fieldErrors.email" class="invalid-feedback">
|
||||||
{{ fieldErrors.email }}
|
{{ errorMessage("email") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
||||||
<label class="form-label">Téléphone</label>
|
<label class="form-label">Téléphone</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.phone"
|
v-model="form.phone"
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.phone }"
|
:error="!!fieldErrors.phone"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. +33 1 23 45 67 89"
|
placeholder="ex. +33 1 23 45 67 89"
|
||||||
maxlength="50"
|
maxlength="50"
|
||||||
@input="form.phone = $event.target.value"
|
|
||||||
/>
|
/>
|
||||||
<div v-if="fieldErrors.phone" class="invalid-feedback">
|
<div v-if="fieldErrors.phone" class="invalid-feedback">
|
||||||
{{ fieldErrors.phone }}
|
{{ errorMessage("phone") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -117,19 +118,18 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label">Adresse ligne 1</label>
|
<label class="form-label">Adresse ligne 1</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.billing_address_line1"
|
v-model="form.billing_address_line1"
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.billing_address_line1 }"
|
:error="!!fieldErrors.billing_address_line1"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. 123 Rue Principale"
|
placeholder="ex. 123 Rue Principale"
|
||||||
maxlength="255"
|
maxlength="255"
|
||||||
@input="form.billing_address_line1 = $event.target.value"
|
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="fieldErrors.billing_address_line1"
|
v-if="fieldErrors.billing_address_line1"
|
||||||
class="invalid-feedback"
|
class="invalid-feedback"
|
||||||
>
|
>
|
||||||
{{ fieldErrors.billing_address_line1 }}
|
{{ errorMessage("billing_address_line1") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -138,19 +138,18 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label">Adresse ligne 2</label>
|
<label class="form-label">Adresse ligne 2</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.billing_address_line2"
|
v-model="form.billing_address_line2"
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.billing_address_line2 }"
|
:error="!!fieldErrors.billing_address_line2"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. Appartement, Suite, etc."
|
placeholder="ex. Appartement, Suite, etc."
|
||||||
maxlength="255"
|
maxlength="255"
|
||||||
@input="form.billing_address_line2 = $event.target.value"
|
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="fieldErrors.billing_address_line2"
|
v-if="fieldErrors.billing_address_line2"
|
||||||
class="invalid-feedback"
|
class="invalid-feedback"
|
||||||
>
|
>
|
||||||
{{ fieldErrors.billing_address_line2 }}
|
{{ errorMessage("billing_address_line2") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -159,40 +158,37 @@
|
|||||||
<div class="col-12 col-sm-4">
|
<div class="col-12 col-sm-4">
|
||||||
<label class="form-label">Code postal</label>
|
<label class="form-label">Code postal</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.billing_postal_code"
|
v-model="form.billing_postal_code"
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.billing_postal_code }"
|
:error="!!fieldErrors.billing_postal_code"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. 75001"
|
placeholder="ex. 75001"
|
||||||
maxlength="20"
|
maxlength="20"
|
||||||
@input="form.billing_postal_code = $event.target.value"
|
|
||||||
/>
|
/>
|
||||||
<div v-if="fieldErrors.billing_postal_code" class="invalid-feedback">
|
<div v-if="fieldErrors.billing_postal_code" class="invalid-feedback">
|
||||||
{{ fieldErrors.billing_postal_code }}
|
{{ errorMessage("billing_postal_code") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-sm-4 mt-3 mt-sm-0">
|
<div class="col-12 col-sm-4 mt-3 mt-sm-0">
|
||||||
<label class="form-label">Ville</label>
|
<label class="form-label">Ville</label>
|
||||||
<soft-input
|
<soft-input
|
||||||
:value="form.billing_city"
|
v-model="form.billing_city"
|
||||||
class="multisteps-form__input"
|
class="multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.billing_city }"
|
:error="!!fieldErrors.billing_city"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ex. Paris"
|
placeholder="ex. Paris"
|
||||||
maxlength="191"
|
maxlength="191"
|
||||||
@input="form.billing_city = $event.target.value"
|
|
||||||
/>
|
/>
|
||||||
<div v-if="fieldErrors.billing_city" class="invalid-feedback">
|
<div v-if="fieldErrors.billing_city" class="invalid-feedback">
|
||||||
{{ fieldErrors.billing_city }}
|
{{ errorMessage("billing_city") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-sm-4 mt-3 mt-sm-0">
|
<div class="col-12 col-sm-4 mt-3 mt-sm-0">
|
||||||
<label class="form-label">Code pays</label>
|
<label class="form-label">Code pays</label>
|
||||||
<select
|
<select
|
||||||
:value="form.billing_country_code"
|
v-model="form.billing_country_code"
|
||||||
class="form-control multisteps-form__input"
|
class="form-control multisteps-form__input"
|
||||||
:class="{ 'is-invalid': fieldErrors.billing_country_code }"
|
:class="{ 'is-invalid': fieldErrors.billing_country_code }"
|
||||||
@input="form.billing_country_code = $event.target.value"
|
|
||||||
>
|
>
|
||||||
<option value="">Sélectionner un pays</option>
|
<option value="">Sélectionner un pays</option>
|
||||||
<option value="FR">France</option>
|
<option value="FR">France</option>
|
||||||
@ -205,7 +201,7 @@
|
|||||||
<option value="GB">Royaume-Uni</option>
|
<option value="GB">Royaume-Uni</option>
|
||||||
</select>
|
</select>
|
||||||
<div v-if="fieldErrors.billing_country_code" class="invalid-feedback">
|
<div v-if="fieldErrors.billing_country_code" class="invalid-feedback">
|
||||||
{{ fieldErrors.billing_country_code }}
|
{{ errorMessage("billing_country_code") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -215,13 +211,16 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label">Notes</label>
|
<label class="form-label">Notes</label>
|
||||||
<textarea
|
<textarea
|
||||||
:value="form.notes"
|
v-model="form.notes"
|
||||||
class="form-control multisteps-form__input"
|
class="form-control multisteps-form__input"
|
||||||
|
:class="{ 'is-invalid': fieldErrors.notes }"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder="Notes supplémentaires sur le client..."
|
placeholder="Notes supplémentaires sur le client..."
|
||||||
maxlength="1000"
|
maxlength="1000"
|
||||||
@input="form.notes = $event.target.value"
|
|
||||||
></textarea>
|
></textarea>
|
||||||
|
<div v-if="fieldErrors.notes" class="invalid-feedback">
|
||||||
|
{{ errorMessage("notes") }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -401,6 +400,13 @@ const clearErrors = () => {
|
|||||||
errors.value = [];
|
errors.value = [];
|
||||||
fieldErrors.value = {};
|
fieldErrors.value = {};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const errorMessage = (field) => {
|
||||||
|
if (fieldErrors.value[field] && Array.isArray(fieldErrors.value[field])) {
|
||||||
|
return fieldErrors.value[field][0];
|
||||||
|
}
|
||||||
|
return fieldErrors.value[field];
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container-fluid py-4">
|
<div class="container-fluid py-4">
|
||||||
<div class="d-sm-flex justify-content-between">
|
<div class="d-sm-flex justify-content-between align-items-center">
|
||||||
<div>
|
<div>
|
||||||
<slot name="intervention-new-action"></slot>
|
<slot name="intervention-new-action"></slot>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<slot name="header-pagination"></slot>
|
||||||
|
</div>
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
<div class="dropdown d-inline">
|
<div class="dropdown d-inline">
|
||||||
<slot name="select-filter"></slot>
|
<slot name="select-filter"></slot>
|
||||||
|
|||||||
@ -223,6 +223,48 @@ export const ClientService = {
|
|||||||
|
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a child client to a parent client
|
||||||
|
*/
|
||||||
|
async addChildClient(
|
||||||
|
parentId: number,
|
||||||
|
childId: number
|
||||||
|
): Promise<ClientResponse> {
|
||||||
|
const response = await request<ClientResponse>({
|
||||||
|
url: `/api/clients/${parentId}/children/${childId}`,
|
||||||
|
method: "post",
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a child client from a parent client
|
||||||
|
*/
|
||||||
|
async removeChildClient(
|
||||||
|
parentId: number,
|
||||||
|
childId: number
|
||||||
|
): Promise<ClientResponse> {
|
||||||
|
const response = await request<ClientResponse>({
|
||||||
|
url: `/api/clients/${parentId}/children/${childId}`,
|
||||||
|
method: "delete",
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get child clients for a parent
|
||||||
|
*/
|
||||||
|
async getChildClients(parentId: number): Promise<ClientListResponse> {
|
||||||
|
const response = await request<ClientListResponse>({
|
||||||
|
url: `/api/clients/${parentId}/children`,
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ClientService;
|
export default ClientService;
|
||||||
|
|||||||
@ -285,6 +285,75 @@ export const useClientStore = defineStore("client", () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add child client
|
||||||
|
*/
|
||||||
|
const addChildClient = async (parentId: number, childId: number) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await ClientService.addChildClient(parentId, childId);
|
||||||
|
// Determine what to return or update
|
||||||
|
// Ideally backend returns the updated parent or child list.
|
||||||
|
// Assuming we need to refresh the current client if it is the parent
|
||||||
|
if (currentClient.value && currentClient.value.id === parentId) {
|
||||||
|
// Optionally refetch or update manually if we knew the structure
|
||||||
|
await fetchClient(parentId);
|
||||||
|
}
|
||||||
|
return response.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage =
|
||||||
|
err.response?.data?.message || err.message || "Failed to add child client";
|
||||||
|
setError(errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove child client
|
||||||
|
*/
|
||||||
|
const removeChildClient = async (parentId: number, childId: number) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ClientService.removeChildClient(parentId, childId);
|
||||||
|
if (currentClient.value && currentClient.value.id === parentId) {
|
||||||
|
await fetchClient(parentId);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage =
|
||||||
|
err.response?.data?.message || err.message || "Failed to remove child client";
|
||||||
|
setError(errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get children
|
||||||
|
*/
|
||||||
|
const fetchChildClients = async (parentId: number) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await ClientService.getChildClients(parentId);
|
||||||
|
return response.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage =
|
||||||
|
err.response?.data?.message || err.message || "Failed to fetch child clients";
|
||||||
|
setError(errorMessage);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear current client
|
* Clear current client
|
||||||
*/
|
*/
|
||||||
@ -332,6 +401,9 @@ export const useClientStore = defineStore("client", () => {
|
|||||||
deleteClient,
|
deleteClient,
|
||||||
searchClients,
|
searchClients,
|
||||||
toggleClientStatus,
|
toggleClientStatus,
|
||||||
|
addChildClient,
|
||||||
|
removeChildClient,
|
||||||
|
fetchChildClients,
|
||||||
clearCurrentClient,
|
clearCurrentClient,
|
||||||
clearStore,
|
clearStore,
|
||||||
clearError,
|
clearError,
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
:client="clientStore.currentClient"
|
:client="clientStore.currentClient"
|
||||||
:contacts="contacts_client"
|
:contacts="contacts_client"
|
||||||
:locations="locations_client"
|
:locations="locations_client"
|
||||||
|
:children="children_client"
|
||||||
:is-loading="clientStore.isLoading"
|
:is-loading="clientStore.isLoading"
|
||||||
:client-avatar="clientAvatar"
|
:client-avatar="clientAvatar"
|
||||||
:active-tab="activeTab"
|
:active-tab="activeTab"
|
||||||
@ -16,6 +17,7 @@
|
|||||||
@add-new-location="createNewLocation"
|
@add-new-location="createNewLocation"
|
||||||
@modify-location="modifyLocation"
|
@modify-location="modifyLocation"
|
||||||
@remove-location="removeLocation"
|
@remove-location="removeLocation"
|
||||||
|
@change-tab="activeTab = $event"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -38,6 +40,7 @@ const notificationStore = useNotificationStore();
|
|||||||
const client_id = Number(route.params.id);
|
const client_id = Number(route.params.id);
|
||||||
const contacts_client = ref([]);
|
const contacts_client = ref([]);
|
||||||
const locations_client = ref([]);
|
const locations_client = ref([]);
|
||||||
|
const children_client = ref([]);
|
||||||
const activeTab = ref("overview");
|
const activeTab = ref("overview");
|
||||||
const clientAvatar = ref(null);
|
const clientAvatar = ref(null);
|
||||||
const fileInput = ref(null);
|
const fileInput = ref(null);
|
||||||
@ -50,6 +53,10 @@ onMounted(async () => {
|
|||||||
client_id
|
client_id
|
||||||
);
|
);
|
||||||
locations_client.value = locationsResponse || [];
|
locations_client.value = locationsResponse || [];
|
||||||
|
|
||||||
|
if (clientStore.currentClient.is_parent) {
|
||||||
|
children_client.value = await clientStore.fetchChildClients(client_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,10 @@
|
|||||||
:interventions="transformedInterventions"
|
:interventions="transformedInterventions"
|
||||||
:loading="interventionStore.isLoading"
|
:loading="interventionStore.isLoading"
|
||||||
:error="interventionStore.getError"
|
:error="interventionStore.getError"
|
||||||
|
:pagination="interventionStore.getPagination"
|
||||||
@retry="loadInterventions"
|
@retry="loadInterventions"
|
||||||
|
@page-change="onChangePage"
|
||||||
|
@export-csv="exportCSV"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
@ -14,14 +17,28 @@ import { useInterventionStore } from "@/stores/interventionStore";
|
|||||||
const interventionStore = useInterventionStore();
|
const interventionStore = useInterventionStore();
|
||||||
|
|
||||||
// Load interventions on component mount
|
// Load interventions on component mount
|
||||||
const loadInterventions = async () => {
|
const loadInterventions = async (page = 1) => {
|
||||||
try {
|
try {
|
||||||
await interventionStore.fetchInterventions();
|
await interventionStore.fetchInterventions({
|
||||||
|
page: page,
|
||||||
|
per_page: 6,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load interventions:", error);
|
console.error("Failed to load interventions:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onChangePage = (page) => {
|
||||||
|
loadInterventions(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportCSV = () => {
|
||||||
|
// Logic to export CSV - checking if store has action or just trigger download
|
||||||
|
// For now just logging, can be implemented via service
|
||||||
|
console.log("Export CSV triggered");
|
||||||
|
// Ideally: window.open('/api/interventions/export?format=csv', '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
// Transform store data to match component expectations
|
// Transform store data to match component expectations
|
||||||
const transformedInterventions = computed(() => {
|
const transformedInterventions = computed(() => {
|
||||||
return interventionStore.interventions.map((intervention) => ({
|
return interventionStore.interventions.map((intervention) => ({
|
||||||
@ -81,6 +98,6 @@ const formatDate = (dateString) => {
|
|||||||
|
|
||||||
// Load data on component mount
|
// Load data on component mount
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadInterventions();
|
loadInterventions(1);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user