Feat: migration client table, modification groupe client, child dans client

This commit is contained in:
kevin 2026-01-08 17:18:49 +03:00
parent 50f79a8040
commit e0ccd5f627
27 changed files with 1076 additions and 66 deletions

View File

@ -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);
}
}
}

View File

@ -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)) {

View File

@ -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.',

View File

@ -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',
];
}

View File

@ -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')),

View File

@ -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);

View File

@ -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();
}
}

View File

@ -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;
}

View File

@ -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) {

View File

@ -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']);
});
}
};

View File

@ -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']);

View File

@ -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) => {

View File

@ -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">&times;</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>

View File

@ -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";

View File

@ -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,

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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"]);

View File

@ -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>

View File

@ -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>

View File

@ -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;

View File

@ -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,

View File

@ -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);
}
}
});

View File

@ -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>