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);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 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\ContactRepositoryInterface;
|
||||
use App\Repositories\DeceasedRepositoryInterface;
|
||||
use App\Repositories\QuoteRepositoryInterface;
|
||||
use App\Repositories\ProductRepositoryInterface;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@ -46,6 +48,16 @@ class InterventionController extends Controller
|
||||
*/
|
||||
protected $deceasedRepository;
|
||||
|
||||
/**
|
||||
* @var QuoteRepositoryInterface
|
||||
*/
|
||||
protected $quoteRepository;
|
||||
|
||||
/**
|
||||
* @var ProductRepositoryInterface
|
||||
*/
|
||||
protected $productRepository;
|
||||
|
||||
/**
|
||||
* InterventionController constructor.
|
||||
*
|
||||
@ -54,19 +66,25 @@ class InterventionController extends Controller
|
||||
* @param ClientRepositoryInterface $clientRepository
|
||||
* @param ContactRepositoryInterface $contactRepository
|
||||
* @param DeceasedRepositoryInterface $deceasedRepository
|
||||
* @param QuoteRepositoryInterface $quoteRepository
|
||||
* @param ProductRepositoryInterface $productRepository
|
||||
*/
|
||||
public function __construct(
|
||||
InterventionRepositoryInterface $interventionRepository,
|
||||
InterventionPractitionerRepositoryInterface $interventionPractitionerRepository,
|
||||
ClientRepositoryInterface $clientRepository,
|
||||
ContactRepositoryInterface $contactRepository,
|
||||
DeceasedRepositoryInterface $deceasedRepository
|
||||
DeceasedRepositoryInterface $deceasedRepository,
|
||||
QuoteRepositoryInterface $quoteRepository,
|
||||
ProductRepositoryInterface $productRepository
|
||||
) {
|
||||
$this->interventionRepository = $interventionRepository;
|
||||
$this->interventionPractitionerRepository = $interventionPractitionerRepository;
|
||||
$this->clientRepository = $clientRepository;
|
||||
$this->contactRepository = $contactRepository;
|
||||
$this->deceasedRepository = $deceasedRepository;
|
||||
$this->quoteRepository = $quoteRepository;
|
||||
$this->productRepository = $productRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -182,6 +200,56 @@ class InterventionController extends Controller
|
||||
]);
|
||||
$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)
|
||||
$documents = $validated['documents'] ?? [];
|
||||
if (!empty($documents)) {
|
||||
|
||||
@ -28,7 +28,7 @@ class StoreClientRequest extends FormRequest
|
||||
'siret' => 'nullable|string|max:20',
|
||||
'email' => 'nullable|email|max:191',
|
||||
'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_postal_code' => 'nullable|string|max:20',
|
||||
'billing_city' => 'nullable|string|max:191',
|
||||
@ -36,6 +36,8 @@ class StoreClientRequest extends FormRequest
|
||||
'group_id' => 'nullable|exists:client_groups,id',
|
||||
'notes' => 'nullable|string',
|
||||
'is_active' => 'boolean',
|
||||
'is_parent' => 'boolean|nullable',
|
||||
'parent_id' => 'nullable|exists:clients,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.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.',
|
||||
'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_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.',
|
||||
|
||||
@ -36,6 +36,8 @@ class UpdateClientRequest extends FormRequest
|
||||
'group_id' => 'nullable|exists:client_groups,id',
|
||||
'notes' => 'nullable|string',
|
||||
'is_active' => 'boolean',
|
||||
'is_parent' => 'boolean|nullable',
|
||||
'parent_id' => 'nullable|exists:clients,id',
|
||||
'default_tva_rate_id' => 'nullable|exists:tva_rates,id',
|
||||
];
|
||||
}
|
||||
|
||||
@ -36,6 +36,8 @@ class ClientResource extends JsonResource
|
||||
'group_id' => $this->group_id,
|
||||
'notes' => $this->notes,
|
||||
'is_active' => $this->is_active,
|
||||
'is_parent' => $this->is_parent,
|
||||
'parent_id' => $this->parent_id,
|
||||
// 'default_tva_rate_id' => $this->default_tva_rate_id,
|
||||
'created_at' => $this->created_at?->format('Y-m-d H:i:s'),
|
||||
'updated_at' => $this->updated_at?->format('Y-m-d H:i:s'),
|
||||
@ -50,6 +52,7 @@ class ClientResource extends JsonResource
|
||||
// Relations
|
||||
// 'company' => new CompanyResource($this->whenLoaded('company')),
|
||||
'group' => new ClientGroupResource($this->whenLoaded('group')),
|
||||
'parent' => new ClientResource($this->whenLoaded('parent')),
|
||||
// 'default_tva_rate' => new TvaRateResource($this->whenLoaded('defaultTvaRate')),
|
||||
'contacts' => ContactResource::collection($this->whenLoaded('contacts')),
|
||||
'locations' => ClientLocationResource::collection($this->whenLoaded('locations')),
|
||||
|
||||
@ -21,6 +21,8 @@ class Client extends Model
|
||||
'group_id',
|
||||
'notes',
|
||||
'is_active',
|
||||
'is_parent',
|
||||
'parent_id',
|
||||
// 'default_tva_rate_id',
|
||||
'client_category_id',
|
||||
'user_id',
|
||||
@ -28,8 +30,19 @@ class Client extends Model
|
||||
|
||||
protected $casts = [
|
||||
'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
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
|
||||
@ -140,4 +140,15 @@ class ProductRepository extends BaseRepository implements ProductRepositoryInter
|
||||
'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 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
|
||||
{
|
||||
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('client-groups', ClientGroupController::class);
|
||||
|
||||
Route::apiResource('client-locations', ClientLocationController::class);
|
||||
Route::apiResource('client-locations', ClientLocationController::class);
|
||||
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
|
||||
Route::apiResource('contacts', ContactController::class);
|
||||
Route::get('clients/{clientId}/contacts', [ContactController::class, 'getContactsByClient']);
|
||||
|
||||
@ -25,10 +25,12 @@
|
||||
:client-type="client.type_label || 'Client'"
|
||||
:contacts-count="contacts.length"
|
||||
:locations-count="locations.length"
|
||||
:children-count="children ? children.length : 0"
|
||||
:is-active="client.is_active"
|
||||
:is-parent="client.is_parent"
|
||||
:active-tab="activeTab"
|
||||
@edit-avatar="triggerFileInput"
|
||||
@change-tab="activeTab = $event"
|
||||
@change-tab="$emit('change-tab', $event)"
|
||||
/>
|
||||
</template>
|
||||
<template #file-input>
|
||||
@ -50,7 +52,7 @@
|
||||
:client-id="client.id"
|
||||
:contact-is-loading="contactLoading"
|
||||
:location-is-loading="locationLoading"
|
||||
@change-tab="activeTab = $event"
|
||||
@change-tab="$emit('change-tab', $event)"
|
||||
@updating-client="handleUpdateClient"
|
||||
@create-contact="handleAddContact"
|
||||
@updating-contact="handleModifiedContact"
|
||||
@ -83,6 +85,11 @@ const props = defineProps({
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
children: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@ -118,7 +125,9 @@ const emit = defineEmits([
|
||||
"updating-contact",
|
||||
"add-new-location",
|
||||
"modify-location",
|
||||
"modify-location",
|
||||
"remove-location",
|
||||
"change-tab",
|
||||
]);
|
||||
|
||||
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'">
|
||||
<ClientNotesTab :notes="client.notes" />
|
||||
</div>
|
||||
|
||||
<!-- Children Tab -->
|
||||
<div v-show="activeTab === 'children'">
|
||||
<ClientChildrenTab :client="client" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -62,6 +67,7 @@ import ClientContactsTab from "@/components/molecules/client/ClientContactsTab.v
|
||||
import ClientAddressTab from "@/components/molecules/client/ClientAddressTab.vue";
|
||||
import ClientLocationsTab from "@/components/molecules/client/ClientLocationsTab.vue";
|
||||
import ClientNotesTab from "@/components/molecules/client/ClientNotesTab.vue";
|
||||
import ClientChildrenTab from "@/components/molecules/client/ClientChildrenTab.vue";
|
||||
import { defineProps, defineEmits } from "vue";
|
||||
import ClientActivityTab from "@/components/molecules/client/ClientActivityTab.vue";
|
||||
|
||||
|
||||
@ -19,6 +19,8 @@
|
||||
:active-tab="activeTab"
|
||||
:contacts-count="contactsCount"
|
||||
:locations-count="locationsCount"
|
||||
:children-count="childrenCount"
|
||||
:is-parent="isParent"
|
||||
@change-tab="$emit('change-tab', $event)"
|
||||
/>
|
||||
</div>
|
||||
@ -58,6 +60,14 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
isParent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
childrenCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
activeTab: {
|
||||
type: String,
|
||||
required: true,
|
||||
|
||||
@ -1,13 +1,56 @@
|
||||
<template>
|
||||
<interventions-template>
|
||||
<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 #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 #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 #intervention-table>
|
||||
<!-- Loading state -->
|
||||
@ -27,22 +70,25 @@
|
||||
</div>
|
||||
|
||||
<!-- Data table -->
|
||||
<interventions-list v-else :intervention-data="interventions" />
|
||||
<template v-else>
|
||||
<interventions-list :intervention-data="interventions" />
|
||||
</template>
|
||||
</template>
|
||||
</interventions-template>
|
||||
</template>
|
||||
<script setup>
|
||||
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 { 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";
|
||||
const router = useRouter();
|
||||
|
||||
// Props
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
interventions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
@ -55,12 +101,92 @@ defineProps({
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
pagination: {
|
||||
type: Object,
|
||||
default: null,
|
||||
}
|
||||
});
|
||||
|
||||
// Emits
|
||||
defineEmits(["retry"]);
|
||||
const emit = defineEmits(["retry", "page-change", "export-csv"]);
|
||||
|
||||
const go = () => {
|
||||
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>
|
||||
|
||||
@ -92,9 +92,12 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const formatCurrency = (value) => {
|
||||
const numberValue = typeof value === 'string' ? parseFloat(value) : value;
|
||||
if (isNaN(numberValue)) return '0,00 €';
|
||||
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(value);
|
||||
}).format(numberValue);
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -26,15 +26,18 @@
|
||||
import { defineProps } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
ht: Number,
|
||||
tva: Number,
|
||||
ttc: Number,
|
||||
ht: [Number, String],
|
||||
tva: [Number, String],
|
||||
ttc: [Number, String],
|
||||
});
|
||||
|
||||
const formatCurrency = (value) => {
|
||||
const numberValue = typeof value === 'string' ? parseFloat(value) : value;
|
||||
if (isNaN(numberValue)) return '0,00 €';
|
||||
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(value);
|
||||
}).format(numberValue);
|
||||
};
|
||||
</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'"
|
||||
@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>
|
||||
</template>
|
||||
|
||||
@ -65,6 +72,14 @@ defineProps({
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
childrenCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
isParent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(["change-tab"]);
|
||||
|
||||
@ -9,9 +9,9 @@
|
||||
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
||||
<label class="form-label">Catégorie de client</label>
|
||||
<select
|
||||
:value="form.client_category_id"
|
||||
v-model="form.client_category_id"
|
||||
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
|
||||
@ -22,6 +22,12 @@
|
||||
{{ category.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div
|
||||
v-if="fieldErrors.client_category_id"
|
||||
class="invalid-feedback"
|
||||
>
|
||||
{{ errorMessage("client_category_id") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -32,15 +38,14 @@
|
||||
>Nom du client <span class="text-danger">*</span></label
|
||||
>
|
||||
<soft-input
|
||||
:value="form.name"
|
||||
v-model="form.name"
|
||||
class="multisteps-form__input"
|
||||
:class="{ 'is-invalid': fieldErrors.name }"
|
||||
:error="!!fieldErrors.name"
|
||||
type="text"
|
||||
placeholder="ex. Nom de l'entreprise ou Nom du particulier"
|
||||
@input="form.name = $event.target.value"
|
||||
/>
|
||||
<div v-if="fieldErrors.name" class="invalid-feedback">
|
||||
{{ fieldErrors.name }}
|
||||
{{ errorMessage("name") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -50,31 +55,29 @@
|
||||
<div class="col-12 col-sm-6">
|
||||
<label class="form-label">Numéro de TVA</label>
|
||||
<soft-input
|
||||
:value="form.vat_number"
|
||||
v-model="form.vat_number"
|
||||
class="multisteps-form__input"
|
||||
:class="{ 'is-invalid': fieldErrors.vat_number }"
|
||||
:error="!!fieldErrors.vat_number"
|
||||
type="text"
|
||||
placeholder="ex. FR12345678901"
|
||||
maxlength="32"
|
||||
@input="form.vat_number = $event.target.value"
|
||||
/>
|
||||
<div v-if="fieldErrors.vat_number" class="invalid-feedback">
|
||||
{{ fieldErrors.vat_number }}
|
||||
{{ errorMessage("vat_number") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
||||
<label class="form-label">SIRET</label>
|
||||
<soft-input
|
||||
:value="form.siret"
|
||||
v-model="form.siret"
|
||||
class="multisteps-form__input"
|
||||
:class="{ 'is-invalid': fieldErrors.siret }"
|
||||
:error="!!fieldErrors.siret"
|
||||
type="text"
|
||||
placeholder="ex. 12345678901234"
|
||||
maxlength="20"
|
||||
@input="form.siret = $event.target.value"
|
||||
/>
|
||||
<div v-if="fieldErrors.siret" class="invalid-feedback">
|
||||
{{ fieldErrors.siret }}
|
||||
{{ errorMessage("siret") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -84,30 +87,28 @@
|
||||
<div class="col-12 col-sm-6">
|
||||
<label class="form-label">Email</label>
|
||||
<soft-input
|
||||
:value="form.email"
|
||||
v-model="form.email"
|
||||
class="multisteps-form__input"
|
||||
:class="{ 'is-invalid': fieldErrors.email }"
|
||||
:error="!!fieldErrors.email"
|
||||
type="email"
|
||||
placeholder="ex. contact@entreprise.com"
|
||||
@input="form.email = $event.target.value"
|
||||
/>
|
||||
<div v-if="fieldErrors.email" class="invalid-feedback">
|
||||
{{ fieldErrors.email }}
|
||||
{{ errorMessage("email") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 mt-3 mt-sm-0">
|
||||
<label class="form-label">Téléphone</label>
|
||||
<soft-input
|
||||
:value="form.phone"
|
||||
v-model="form.phone"
|
||||
class="multisteps-form__input"
|
||||
:class="{ 'is-invalid': fieldErrors.phone }"
|
||||
:error="!!fieldErrors.phone"
|
||||
type="text"
|
||||
placeholder="ex. +33 1 23 45 67 89"
|
||||
maxlength="50"
|
||||
@input="form.phone = $event.target.value"
|
||||
/>
|
||||
<div v-if="fieldErrors.phone" class="invalid-feedback">
|
||||
{{ fieldErrors.phone }}
|
||||
{{ errorMessage("phone") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -117,19 +118,18 @@
|
||||
<div class="col-12">
|
||||
<label class="form-label">Adresse ligne 1</label>
|
||||
<soft-input
|
||||
:value="form.billing_address_line1"
|
||||
v-model="form.billing_address_line1"
|
||||
class="multisteps-form__input"
|
||||
:class="{ 'is-invalid': fieldErrors.billing_address_line1 }"
|
||||
:error="!!fieldErrors.billing_address_line1"
|
||||
type="text"
|
||||
placeholder="ex. 123 Rue Principale"
|
||||
maxlength="255"
|
||||
@input="form.billing_address_line1 = $event.target.value"
|
||||
/>
|
||||
<div
|
||||
v-if="fieldErrors.billing_address_line1"
|
||||
class="invalid-feedback"
|
||||
>
|
||||
{{ fieldErrors.billing_address_line1 }}
|
||||
{{ errorMessage("billing_address_line1") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -138,19 +138,18 @@
|
||||
<div class="col-12">
|
||||
<label class="form-label">Adresse ligne 2</label>
|
||||
<soft-input
|
||||
:value="form.billing_address_line2"
|
||||
v-model="form.billing_address_line2"
|
||||
class="multisteps-form__input"
|
||||
:class="{ 'is-invalid': fieldErrors.billing_address_line2 }"
|
||||
:error="!!fieldErrors.billing_address_line2"
|
||||
type="text"
|
||||
placeholder="ex. Appartement, Suite, etc."
|
||||
maxlength="255"
|
||||
@input="form.billing_address_line2 = $event.target.value"
|
||||
/>
|
||||
<div
|
||||
v-if="fieldErrors.billing_address_line2"
|
||||
class="invalid-feedback"
|
||||
>
|
||||
{{ fieldErrors.billing_address_line2 }}
|
||||
{{ errorMessage("billing_address_line2") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -159,40 +158,37 @@
|
||||
<div class="col-12 col-sm-4">
|
||||
<label class="form-label">Code postal</label>
|
||||
<soft-input
|
||||
:value="form.billing_postal_code"
|
||||
v-model="form.billing_postal_code"
|
||||
class="multisteps-form__input"
|
||||
:class="{ 'is-invalid': fieldErrors.billing_postal_code }"
|
||||
:error="!!fieldErrors.billing_postal_code"
|
||||
type="text"
|
||||
placeholder="ex. 75001"
|
||||
maxlength="20"
|
||||
@input="form.billing_postal_code = $event.target.value"
|
||||
/>
|
||||
<div v-if="fieldErrors.billing_postal_code" class="invalid-feedback">
|
||||
{{ fieldErrors.billing_postal_code }}
|
||||
{{ errorMessage("billing_postal_code") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-4 mt-3 mt-sm-0">
|
||||
<label class="form-label">Ville</label>
|
||||
<soft-input
|
||||
:value="form.billing_city"
|
||||
v-model="form.billing_city"
|
||||
class="multisteps-form__input"
|
||||
:class="{ 'is-invalid': fieldErrors.billing_city }"
|
||||
:error="!!fieldErrors.billing_city"
|
||||
type="text"
|
||||
placeholder="ex. Paris"
|
||||
maxlength="191"
|
||||
@input="form.billing_city = $event.target.value"
|
||||
/>
|
||||
<div v-if="fieldErrors.billing_city" class="invalid-feedback">
|
||||
{{ fieldErrors.billing_city }}
|
||||
{{ errorMessage("billing_city") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-4 mt-3 mt-sm-0">
|
||||
<label class="form-label">Code pays</label>
|
||||
<select
|
||||
:value="form.billing_country_code"
|
||||
v-model="form.billing_country_code"
|
||||
class="form-control multisteps-form__input"
|
||||
:class="{ 'is-invalid': fieldErrors.billing_country_code }"
|
||||
@input="form.billing_country_code = $event.target.value"
|
||||
>
|
||||
<option value="">Sélectionner un pays</option>
|
||||
<option value="FR">France</option>
|
||||
@ -205,7 +201,7 @@
|
||||
<option value="GB">Royaume-Uni</option>
|
||||
</select>
|
||||
<div v-if="fieldErrors.billing_country_code" class="invalid-feedback">
|
||||
{{ fieldErrors.billing_country_code }}
|
||||
{{ errorMessage("billing_country_code") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -215,13 +211,16 @@
|
||||
<div class="col-12">
|
||||
<label class="form-label">Notes</label>
|
||||
<textarea
|
||||
:value="form.notes"
|
||||
v-model="form.notes"
|
||||
class="form-control multisteps-form__input"
|
||||
:class="{ 'is-invalid': fieldErrors.notes }"
|
||||
rows="3"
|
||||
placeholder="Notes supplémentaires sur le client..."
|
||||
maxlength="1000"
|
||||
@input="form.notes = $event.target.value"
|
||||
></textarea>
|
||||
<div v-if="fieldErrors.notes" class="invalid-feedback">
|
||||
{{ errorMessage("notes") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -401,6 +400,13 @@ const clearErrors = () => {
|
||||
errors.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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
<template>
|
||||
<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>
|
||||
<slot name="intervention-new-action"></slot>
|
||||
</div>
|
||||
<div>
|
||||
<slot name="header-pagination"></slot>
|
||||
</div>
|
||||
<div class="d-flex">
|
||||
<div class="dropdown d-inline">
|
||||
<slot name="select-filter"></slot>
|
||||
|
||||
@ -223,6 +223,48 @@ export const ClientService = {
|
||||
|
||||
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;
|
||||
|
||||
@ -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
|
||||
*/
|
||||
@ -332,6 +401,9 @@ export const useClientStore = defineStore("client", () => {
|
||||
deleteClient,
|
||||
searchClients,
|
||||
toggleClientStatus,
|
||||
addChildClient,
|
||||
removeChildClient,
|
||||
fetchChildClients,
|
||||
clearCurrentClient,
|
||||
clearStore,
|
||||
clearError,
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
:client="clientStore.currentClient"
|
||||
:contacts="contacts_client"
|
||||
:locations="locations_client"
|
||||
:children="children_client"
|
||||
:is-loading="clientStore.isLoading"
|
||||
:client-avatar="clientAvatar"
|
||||
:active-tab="activeTab"
|
||||
@ -16,6 +17,7 @@
|
||||
@add-new-location="createNewLocation"
|
||||
@modify-location="modifyLocation"
|
||||
@remove-location="removeLocation"
|
||||
@change-tab="activeTab = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@ -38,6 +40,7 @@ const notificationStore = useNotificationStore();
|
||||
const client_id = Number(route.params.id);
|
||||
const contacts_client = ref([]);
|
||||
const locations_client = ref([]);
|
||||
const children_client = ref([]);
|
||||
const activeTab = ref("overview");
|
||||
const clientAvatar = ref(null);
|
||||
const fileInput = ref(null);
|
||||
@ -50,6 +53,10 @@ onMounted(async () => {
|
||||
client_id
|
||||
);
|
||||
locations_client.value = locationsResponse || [];
|
||||
|
||||
if (clientStore.currentClient.is_parent) {
|
||||
children_client.value = await clientStore.fetchChildClients(client_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -3,7 +3,10 @@
|
||||
:interventions="transformedInterventions"
|
||||
:loading="interventionStore.isLoading"
|
||||
:error="interventionStore.getError"
|
||||
:pagination="interventionStore.getPagination"
|
||||
@retry="loadInterventions"
|
||||
@page-change="onChangePage"
|
||||
@export-csv="exportCSV"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
@ -14,14 +17,28 @@ import { useInterventionStore } from "@/stores/interventionStore";
|
||||
const interventionStore = useInterventionStore();
|
||||
|
||||
// Load interventions on component mount
|
||||
const loadInterventions = async () => {
|
||||
const loadInterventions = async (page = 1) => {
|
||||
try {
|
||||
await interventionStore.fetchInterventions();
|
||||
await interventionStore.fetchInterventions({
|
||||
page: page,
|
||||
per_page: 6,
|
||||
});
|
||||
} catch (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
|
||||
const transformedInterventions = computed(() => {
|
||||
return interventionStore.interventions.map((intervention) => ({
|
||||
@ -81,6 +98,6 @@ const formatDate = (dateString) => {
|
||||
|
||||
// Load data on component mount
|
||||
onMounted(() => {
|
||||
loadInterventions();
|
||||
loadInterventions(1);
|
||||
});
|
||||
</script>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user