Fix Client Desig

This commit is contained in:
kevin 2026-04-22 10:17:47 +03:00
parent d8d2b68421
commit ce61b79080
7 changed files with 376 additions and 373 deletions

View File

@ -0,0 +1,48 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\Client;
use App\Models\User;
use Faker\Factory as Faker;
class ClientSeeder extends Seeder
{
public function run(): void
{
$faker = Faker::create('fr_FR');
// Récupérer des users existants (optionnel)
$users = User::pluck('id')->toArray();
for ($i = 0; $i < 20; $i++) {
Client::create([
'name' => $faker->company,
'vat_number' => 'FR' . $faker->numberBetween(10000000000, 99999999999),
'siret' => $faker->numberBetween(10000000000000, 99999999999999),
'email' => $faker->unique()->companyEmail,
'phone' => $faker->phoneNumber,
'billing_address_line1' => $faker->streetAddress,
'billing_address_line2' => $faker->optional()->secondaryAddress,
'billing_postal_code' => $faker->postcode,
'billing_city' => $faker->city,
'billing_country_code' => 'FR',
'group_id' => null, // ou random si tu veux
'notes' => $faker->optional()->sentence,
'is_active' => $faker->boolean(90),
'is_parent' => false,
'parent_id' => null,
'client_category_id' => $faker->numberBetween(1, 5),
'user_id' => 1,
'avatar' => null,
]);
}
}
}

View File

@ -24,5 +24,6 @@ class DatabaseSeeder extends Seeder
$this->call(ProductCategorySeeder::class); $this->call(ProductCategorySeeder::class);
$this->call(EmployeeSeeder::class); $this->call(EmployeeSeeder::class);
$this->call(ThanatopractitionerSeeder::class); $this->call(ThanatopractitionerSeeder::class);
$this->call(ClientSeeder::class);
} }
} }

View File

@ -36,12 +36,7 @@ import { storeToRefs } from "pinia";
const router = useRouter(); const router = useRouter();
const clientStore = useClientStore(); const clientStore = useClientStore();
// Use storeToRefs to keep reactivity for pagination if it was passed as a prop, const emit = defineEmits(["pushDetails", "deleteClient"]);
// but since we are using the store directly for actions, we can also extract it here if needed.
// However, the common pattern is that the parent view passes the data.
// Let's check where clientData comes from. It comes from props.
const emit = defineEmits(["pushDetails"]);
const props = defineProps({ const props = defineProps({
clientData: { clientData: {
@ -74,7 +69,10 @@ const deleteClient = (client) => {
}; };
const onPageChange = (page) => { const onPageChange = (page) => {
clientStore.fetchClients({ page: page, per_page: props.pagination.per_page }); clientStore.fetchClients({
page,
per_page: props.pagination.per_page,
});
}; };
const onPerPageChange = (perPage) => { const onPerPageChange = (perPage) => {
@ -84,7 +82,6 @@ const onPerPageChange = (perPage) => {
const onSearch = (query) => { const onSearch = (query) => {
clientStore.fetchClients({ clientStore.fetchClients({
page: 1, page: 1,
per_page: props.pagination.per_page,
search: query, search: query,
}); });
}; };

View File

@ -1,101 +1,54 @@
<template> <template>
<div class="table-container"> <div class="table-container">
<!-- Top Controls (Search & Per Page) -->
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center">
<select
class="form-select form-select-sm me-2"
style="width: 80px"
:value="pagination.per_page"
@change="onPerPageChange"
>
<option :value="5">5</option>
<option :value="10">10</option>
<option :value="15">15</option>
<option :value="20">20</option>
<option :value="50">50</option>
</select>
<span class="text-secondary text-xs font-weight-bold"
>éléments par page</span
>
</div>
<div class="d-flex align-items-center">
<div class="input-group">
<span class="input-group-text text-body"
><i class="fas fa-search" aria-hidden="true"></i
></span>
<input
type="text"
class="form-control form-control-sm"
placeholder="Rechercher..."
@input="onSearch"
/>
</div>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="loading-container"> <div v-if="loading" class="loading-container">
<div class="loading-spinner"> <div class="loading-spinner">
<div class="spinner-border text-primary" role="status"> <div class="spinner-border text-success loading-spinner-circle" role="status">
<span class="visually-hidden">Chargement...</span> <span class="visually-hidden">Chargement...</span>
</div> </div>
</div> </div>
<div class="loading-content"> <div class="loading-content">
<!-- Skeleton Rows -->
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-flush"> <table class="table table-flush">
<thead class="thead-light"> <thead class="thead-light">
<tr> <tr>
<th>Commercial</th>
<th>Client</th> <th>Client</th>
<th>Address</th> <th>Référence</th>
<th>Categories</th> <th>Catégorie</th>
<th>Commercial</th>
<th>Adresse</th>
<th>Contact</th> <th>Contact</th>
<th>Status</th> <th>Status</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="i in skeletonRows" :key="i" class="skeleton-row"> <tr v-for="i in skeletonRows" :key="i" class="skeleton-row">
<!-- Commercial Column Skeleton -->
<td>
<div class="d-flex align-items-center">
<div class="skeleton-checkbox"></div>
<div class="skeleton-text short ms-2"></div>
</div>
</td>
<!-- Client Name Column Skeleton -->
<td> <td>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="skeleton-avatar"></div> <div class="skeleton-avatar"></div>
<div class="skeleton-text medium ms-2"></div> <div class="skeleton-text medium ms-2"></div>
</div> </div>
</td> </td>
<!-- Address Column Skeleton -->
<td> <td>
<div class="skeleton-text long"></div> <div class="skeleton-text short"></div>
</td> </td>
<!-- Categories Column Skeleton -->
<td> <td>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="skeleton-icon"></div> <div class="skeleton-icon"></div>
<div class="skeleton-text medium ms-2"></div> <div class="skeleton-text medium ms-2"></div>
</div> </div>
</td> </td>
<td>
<!-- Contact Column Skeleton --> <div class="skeleton-text long"></div>
</td>
<td>
<div class="skeleton-text long"></div>
</td>
<td> <td>
<div class="contact-info"> <div class="contact-info">
<div class="skeleton-text long mb-1"></div> <div class="skeleton-text long mb-1"></div>
<div class="skeleton-text medium"></div> <div class="skeleton-text medium"></div>
</div> </div>
</td> </td>
<!-- Status Column Skeleton -->
<td> <td>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="skeleton-icon"></div> <div class="skeleton-icon"></div>
@ -109,15 +62,15 @@
</div> </div>
</div> </div>
<!-- Data State -->
<div v-else class="table-responsive"> <div v-else class="table-responsive">
<table id="contact-list" class="table table-flush"> <table id="client-list" class="table table-flush">
<thead class="thead-light"> <thead class="thead-light">
<tr> <tr>
<th>Commercial</th>
<th>Client</th> <th>Client</th>
<th>Address</th> <th>Référence</th>
<th>Categories</th> <th>Catégorie</th>
<th>Commercial</th>
<th>Adresse</th>
<th>Contact</th> <th>Contact</th>
<th>Status</th> <th>Status</th>
<th>Action</th> <th>Action</th>
@ -125,38 +78,23 @@
</thead> </thead>
<tbody> <tbody>
<tr v-for="client in data" :key="client.id"> <tr v-for="client in data" :key="client.id">
<!-- Commercial Column -->
<td>
<div class="d-flex align-items-center">
<soft-checkbox />
<p class="text-xs font-weight-bold ms-2 mb-0">
{{ client.commercial }}
</p>
</div>
</td>
<!-- Client Name Column -->
<td class="font-weight-bold"> <td class="font-weight-bold">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<soft-avatar <soft-avatar
:img="getRandomAvatar()" :img="client.avatar_url || getRandomAvatar()"
size="xs" size="xs"
class="me-2" class="me-2"
alt="user image" alt="client image"
circular circular
/> />
<span>{{ client.name }}</span> <span>{{ client.name }}</span>
</div> </div>
</td> </td>
<!-- Address Column (Shortened) -->
<td class="text-xs font-weight-bold"> <td class="text-xs font-weight-bold">
<span class="my-2 text-xs">{{ <span class="my-2 text-xs">{{ getClientReference(client) }}</span>
getShortAddress(client.billing_address)
}}</span>
</td> </td>
<!-- Categories Column -->
<td class="text-xs font-weight-bold"> <td class="text-xs font-weight-bold">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<soft-button <soft-button
@ -169,58 +107,71 @@
aria-hidden="true" aria-hidden="true"
></i> ></i>
</soft-button> </soft-button>
<span>{{ client.type_label }}</span> <span>{{ client.type_label || "Non renseigné" }}</span>
</div>
</td>
<td class="text-xs font-weight-bold">
{{ client.commercial || "N/A" }}
</td>
<td class="text-xs font-weight-bold">
<div class="address-info">
<div>{{ getAddressLine(client.billing_address) }}</div>
<div class="text-xs text-muted">
{{ getShortAddress(client.billing_address) }}
</div>
</div> </div>
</td> </td>
<!-- Contact Column -->
<td class="text-xs font-weight-bold"> <td class="text-xs font-weight-bold">
<div class="contact-info"> <div class="contact-info">
<div class="text-xs text-secondary">{{ client.email }}</div> <div class="text-xs text-secondary">{{ client.email || "N/A" }}</div>
<div class="text-xs">{{ client.phone }}</div> <div class="text-xs">{{ client.phone || "N/A" }}</div>
</div> </div>
</td> </td>
<!-- Status Column -->
<td class="text-xs font-weight-bold"> <td class="text-xs font-weight-bold">
<div class="d-flex align-items-center"> <div class="d-flex flex-column">
<soft-button <soft-button
:color="client.is_active ? 'success' : 'danger'" v-if="client.is_active"
color="success"
variant="outline" variant="outline"
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center" class="btn-sm"
> >
<i <i class="fas fa-check me-1"></i>
:class="client.is_active ? 'fas fa-check' : 'fas fa-times'" Actif
aria-hidden="true" </soft-button>
></i> <soft-button
v-else
color="danger"
variant="outline"
class="btn-sm"
>
<i class="fas fa-times me-1"></i>
Inactif
</soft-button> </soft-button>
<span>{{ client.is_active ? "Active" : "Inactive" }}</span>
</div> </div>
</td> </td>
<td> <td>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<!-- View Button -->
<soft-button <soft-button
color="info" color="info"
variant="outline" variant="outline"
title="View Client" title="Voir le client"
:data-client-id="client.id" :data-client-id="client.id"
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center" class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
@click="emit('view', client.id)"
> >
<i class="fas fa-eye" aria-hidden="true"></i> <i class="fas fa-eye" aria-hidden="true"></i>
</soft-button> </soft-button>
<!-- Delete Button -->
<soft-button <soft-button
color="danger" color="danger"
variant="outline" variant="outline"
title="Delete Client" title="Supprimer le client"
:data-client-id="client.id" :data-client-id="client.id"
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center" class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
@click="emit('delete', client.id)"
> >
<i class="fas fa-trash" aria-hidden="true"></i> <i class="fas fa-trash" aria-hidden="true"></i>
</soft-button> </soft-button>
@ -231,72 +182,72 @@
</table> </table>
</div> </div>
<!-- Pagination Footer -->
<div <div
v-if="!loading && data.length > 0" v-if="!loading && data.length > 0 && (pagination?.last_page || 1) > 1"
class="d-flex justify-content-between align-items-center mt-3 px-3" class="d-flex justify-content-between align-items-center mt-3 px-3 flex-wrap gap-3"
> >
<div class="text-xs text-secondary font-weight-bold"> <div class="text-xs text-secondary font-weight-bold">
Affichage de {{ pagination.from }} à {{ pagination.to }} sur Affichage de {{ safeFrom }} à {{ safeTo }} sur
{{ pagination.total }} clients {{ pagination.total || data.length }} clients
</div> </div>
<nav aria-label="Page navigation"> <nav aria-label="Pagination clients">
<ul class="pagination pagination-sm pagination-success mb-0"> <ul class="pagination pagination-sm pagination-success mb-0">
<li <li
class="page-item" class="page-item"
:class="{ disabled: pagination.current_page === 1 }" :class="{ disabled: (pagination.current_page || 1) === 1 }"
> >
<a <a
class="page-link" class="page-link"
href="#" href="#"
aria-label="Previous" aria-label="Previous"
@click.prevent="changePage(pagination.current_page - 1)" @click.prevent="changePage((pagination.current_page || 1) - 1)"
> >
<span aria-hidden="true" <span aria-hidden="true">
><i class="fa fa-angle-left" aria-hidden="true"></i <i class="fa fa-angle-left" aria-hidden="true"></i>
></span> </span>
</a> </a>
</li> </li>
<li <li
v-for="page in displayedPages" v-for="page in displayedPages"
:key="page" :key="`page-${page}`"
class="page-item" class="page-item"
:class="{ active: pagination.current_page === page }" :class="{
active: (pagination.current_page || 1) === page,
disabled: page === '...'
}"
> >
<a class="page-link" href="#" @click.prevent="changePage(page)">{{ <a class="page-link" href="#" @click.prevent="changePage(page)">
page {{ page }}
}}</a> </a>
</li> </li>
<li <li
class="page-item" class="page-item"
:class="{ :class="{
disabled: pagination.current_page === pagination.last_page, disabled: (pagination.current_page || 1) === (pagination.last_page || 1)
}" }"
> >
<a <a
class="page-link" class="page-link"
href="#" href="#"
aria-label="Next" aria-label="Next"
@click.prevent="changePage(pagination.current_page + 1)" @click.prevent="changePage((pagination.current_page || 1) + 1)"
> >
<span aria-hidden="true" <span aria-hidden="true">
><i class="fa fa-angle-right" aria-hidden="true"></i <i class="fa fa-angle-right" aria-hidden="true"></i>
></span> </span>
</a> </a>
</li> </li>
</ul> </ul>
</nav> </nav>
</div> </div>
<!-- Empty State -->
<div v-if="!loading && data.length === 0" class="empty-state"> <div v-if="!loading && data.length === 0" class="empty-state">
<div class="empty-icon"> <div class="empty-icon">
<i class="fas fa-users fa-3x text-muted"></i> <i class="fas fa-users fa-3x text-muted"></i>
</div> </div>
"
<h5 class="empty-title">Aucun client trouvé</h5> <h5 class="empty-title">Aucun client trouvé</h5>
<p class="empty-text text-muted"> <p class="empty-text text-muted">
Aucun client à afficher pour le moment. Aucun client à afficher pour le moment.
@ -306,22 +257,14 @@
</template> </template>
<script setup> <script setup>
import { computed } from "vue"; import { ref, onMounted, watch, onUnmounted, computed } from "vue";
import SoftCheckbox from "@/components/SoftCheckbox.vue"; import { DataTable } from "simple-datatables";
import SoftButton from "@/components/SoftButton.vue"; import SoftButton from "@/components/SoftButton.vue";
import SoftAvatar from "@/components/SoftAvatar.vue"; import SoftAvatar from "@/components/SoftAvatar.vue";
import { defineProps, defineEmits } from "vue"; import { defineProps, defineEmits } from "vue";
import debounce from "lodash/debounce";
const emit = defineEmits([ const emit = defineEmits(["view", "delete", "page-change", "per-page-change"]);
"view",
"delete",
"page-change",
"per-page-change",
"search-change",
]);
// Sample avatar images
import img1 from "@/assets/img/team-2.jpg"; import img1 from "@/assets/img/team-2.jpg";
import img2 from "@/assets/img/team-1.jpg"; import img2 from "@/assets/img/team-1.jpg";
import img3 from "@/assets/img/team-3.jpg"; import img3 from "@/assets/img/team-3.jpg";
@ -331,6 +274,8 @@ import img6 from "@/assets/img/ivana-squares.jpg";
const avatarImages = [img1, img2, img3, img4, img5, img6]; const avatarImages = [img1, img2, img3, img4, img5, img6];
const dataTableInstance = ref(null);
const props = defineProps({ const props = defineProps({
data: { data: {
type: Array, type: Array,
@ -357,64 +302,104 @@ const props = defineProps({
}, },
}); });
// Calculate displayed page numbers
const displayedPages = computed(() => { const displayedPages = computed(() => {
const total = props.pagination.last_page; const total = Number(props.pagination?.last_page) || 1;
const current = props.pagination.current_page; const current = Number(props.pagination?.current_page) || 1;
if (total <= 1) {
return [1];
}
const delta = 2; const delta = 2;
const range = []; const range = [];
for ( for (
let i = Math.max(2, current - delta); let page = Math.max(2, current - delta);
i <= Math.min(total - 1, current + delta); page <= Math.min(total - 1, current + delta);
i++ page++
) { ) {
range.push(i); range.push(page);
} }
if (current - delta > 2) { if (current - delta > 2) {
range.unshift("..."); range.unshift("...");
} }
if (current + delta < total - 1) { if (current + delta < total - 1) {
range.push("..."); range.push("...");
} }
range.unshift(1); range.unshift(1);
if (total > 1) { if (total > 1) {
range.push(total); range.push(total);
} }
return range.filter( return range.filter(
(val, index, self) => (value, index, self) =>
val !== "..." || (val === "..." && self[index - 1] !== "...") value !== "..." || (value === "..." && self[index - 1] !== "...")
); );
}); });
// Methods const safeFrom = computed(() => {
const changePage = (page) => { if (props.pagination?.from) {
if (page !== "..." && page >= 1 && page <= props.pagination.last_page) { return props.pagination.from;
emit("page-change", page);
} }
};
const onPerPageChange = (event) => { if (!props.pagination?.total || props.data.length === 0) {
const newPerPage = parseInt(event.target.value); return 0;
emit("per-page-change", newPerPage); }
};
const onSearch = debounce((event) => { return (
emit("search-change", event.target.value); ((Number(props.pagination.current_page) || 1) - 1) *
}, 300); (Number(props.pagination.per_page) || 10) +
1
);
});
const safeTo = computed(() => {
if (props.pagination?.to) {
return props.pagination.to;
}
if (!props.pagination?.total || props.data.length === 0) {
return 0;
}
return Math.min(
(Number(props.pagination.current_page) || 1) *
(Number(props.pagination.per_page) || 10),
Number(props.pagination.total) || 0
);
});
const getRandomAvatar = () => { const getRandomAvatar = () => {
const randomIndex = Math.floor(Math.random() * avatarImages.length); const randomIndex = Math.floor(Math.random() * avatarImages.length);
return avatarImages[randomIndex]; return avatarImages[randomIndex];
}; };
const getClientReference = (client) => {
return client.vat_number || client.siret || "N/A";
};
const getAddressLine = (address) => {
if (!address) return "Adresse indisponible";
return (
address.line1 ||
address.line2 ||
address.full_address ||
"Adresse indisponible"
);
};
const getShortAddress = (address) => { const getShortAddress = (address) => {
if (!address) return "N/A"; if (!address) return "N/A";
// Return just city and postal code for brevity
return `${address.postal_code} ${address.city}`; const parts = [address.postal_code, address.city, address.country_code].filter(
Boolean
);
return parts.length > 0 ? parts.join(" ") : "N/A";
}; };
const getCategoryColor = (type) => { const getCategoryColor = (type) => {
@ -432,8 +417,88 @@ const getCategoryIcon = (type) => {
Particulier: "fas fa-user", Particulier: "fas fa-user",
Association: "fas fa-users", Association: "fas fa-users",
}; };
return icons[type] || "fas fa-circle"; return icons[type] || "fas fa-tag";
}; };
const handleTableClick = (event) => {
const button = event.target.closest("button");
if (!button) return;
const clientId = button.getAttribute("data-client-id");
if (!clientId) return;
if (
button.title === "Supprimer le client" ||
button.querySelector(".fa-trash")
) {
emit("delete", clientId);
} else if (
button.title === "Voir le client" ||
button.querySelector(".fa-eye")
) {
emit("view", clientId);
}
};
const initializeDataTable = () => {
if (dataTableInstance.value) {
dataTableInstance.value.destroy();
dataTableInstance.value = null;
}
const dataTableEl = document.getElementById("client-list");
if (dataTableEl) {
dataTableInstance.value = new DataTable(dataTableEl, {
searchable: true,
fixedHeight: true,
paging: false,
perPage: Number(props.pagination?.per_page) || 10,
perPageSelect: false,
});
dataTableEl.addEventListener("click", handleTableClick);
}
};
const changePage = (page) => {
if (
page !== "..." &&
page >= 1 &&
page <= (Number(props.pagination?.last_page) || 1) &&
page !== Number(props.pagination?.current_page)
) {
emit("page-change", page);
}
};
watch(
() => props.data,
() => {
if (!props.loading) {
setTimeout(() => {
initializeDataTable();
}, 100);
}
},
{ deep: true }
);
onUnmounted(() => {
const dataTableEl = document.getElementById("client-list");
if (dataTableEl) {
dataTableEl.removeEventListener("click", handleTableClick);
}
if (dataTableInstance.value) {
dataTableInstance.value.destroy();
}
});
onMounted(() => {
if (!props.loading && props.data.length > 0) {
initializeDataTable();
}
});
</script> </script>
<style scoped> <style scoped>
@ -444,31 +509,30 @@ const getCategoryIcon = (type) => {
.loading-container { .loading-container {
position: relative; position: relative;
min-height: 260px;
} }
.loading-spinner { .loading-spinner {
position: absolute; position: absolute;
top: 20px; top: 50%;
right: 20px; left: 50%;
transform: translate(-50%, -50%);
z-index: 10; z-index: 10;
} }
.loading-spinner-circle {
width: 2.25rem;
height: 2.25rem;
border-width: 0.28em;
}
.loading-content { .loading-content {
opacity: 0.7; opacity: 0.55;
pointer-events: none; pointer-events: none;
} }
.skeleton-row { .skeleton-row {
animation: pulse 1.5s ease-in-out infinite; animation: none;
}
.skeleton-checkbox {
width: 18px;
height: 18px;
border-radius: 3px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
} }
.skeleton-avatar { .skeleton-avatar {
@ -529,6 +593,7 @@ const getCategoryIcon = (type) => {
margin: 0 auto; margin: 0 auto;
} }
.address-info,
.contact-info { .contact-info {
line-height: 1.2; line-height: 1.2;
} }
@ -537,19 +602,6 @@ const getCategoryIcon = (type) => {
font-size: 0.75rem; font-size: 0.75rem;
} }
/* Animations */
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.7;
}
100% {
opacity: 1;
}
}
@keyframes shimmer { @keyframes shimmer {
0% { 0% {
background-position: -200% 0; background-position: -200% 0;
@ -559,13 +611,7 @@ const getCategoryIcon = (type) => {
} }
} }
/* Responsive adjustments */
@media (max-width: 768px) { @media (max-width: 768px) {
.loading-spinner {
top: 10px;
right: 10px;
}
.skeleton-text.long { .skeleton-text.long {
width: 80px; width: 80px;
} }
@ -574,162 +620,4 @@ const getCategoryIcon = (type) => {
width: 60px; width: 60px;
} }
} }
.skeleton-icon.small {
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
</style>
<style scoped>
.table-container {
position: relative;
min-height: 200px;
}
.loading-container {
position: relative;
}
.loading-spinner {
position: absolute;
top: 20px;
right: 20px;
z-index: 10;
}
.loading-content {
opacity: 0.7;
pointer-events: none;
}
.skeleton-row {
animation: pulse 1.5s ease-in-out infinite;
}
.skeleton-checkbox {
width: 18px;
height: 18px;
border-radius: 3px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
.skeleton-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
.skeleton-icon {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
.skeleton-text {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
border-radius: 4px;
height: 12px;
}
.skeleton-text.short {
width: 40px;
}
.skeleton-text.medium {
width: 80px;
}
.skeleton-text.long {
width: 120px;
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
}
.empty-icon {
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-title {
margin-bottom: 0.5rem;
color: #6c757d;
}
.empty-text {
max-width: 300px;
margin: 0 auto;
}
.contact-info {
line-height: 1.2;
}
.text-xs {
font-size: 0.75rem;
}
/* Animations */
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.7;
}
100% {
opacity: 1;
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* Responsive adjustments */
@media (max-width: 768px) {
.loading-spinner {
top: 10px;
right: 10px;
}
.skeleton-text.long {
width: 80px;
}
.skeleton-text.medium {
width: 60px;
}
}
.skeleton-icon.small {
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
</style> </style>

View File

@ -244,7 +244,7 @@
maxlength="255" maxlength="255"
/> />
<p v-else class="form-control-static text-sm"> <p v-else class="form-control-static text-sm">
{{ client.billing_address_line1 || "-" }} {{ client.billing_address?.line1 || "-" }}
</p> </p>
<div <div
v-if="errors.billing_address_line1" v-if="errors.billing_address_line1"
@ -265,7 +265,7 @@
maxlength="255" maxlength="255"
/> />
<p v-else class="form-control-static text-sm"> <p v-else class="form-control-static text-sm">
{{ client.billing_address_line2 || "-" }} {{ client.billing_address?.line2 || "-" }}
</p> </p>
<div <div
v-if="errors.billing_address_line2" v-if="errors.billing_address_line2"
@ -286,7 +286,7 @@
maxlength="20" maxlength="20"
/> />
<p v-else class="form-control-static text-sm"> <p v-else class="form-control-static text-sm">
{{ client.billing_postal_code || "-" }} {{ client.billing_address?.postal_code || "-" }}
</p> </p>
<div <div
v-if="errors.billing_postal_code" v-if="errors.billing_postal_code"
@ -307,7 +307,7 @@
maxlength="191" maxlength="191"
/> />
<p v-else class="form-control-static text-sm"> <p v-else class="form-control-static text-sm">
{{ client.billing_city || "-" }} {{ client.billing_address?.city || "-" }}
</p> </p>
<div v-if="errors.billing_city" class="invalid-feedback d-block"> <div v-if="errors.billing_city" class="invalid-feedback d-block">
{{ errors.billing_city }} {{ errors.billing_city }}
@ -325,7 +325,7 @@
maxlength="2" maxlength="2"
/> />
<p v-else class="form-control-static text-sm"> <p v-else class="form-control-static text-sm">
{{ client.billing_country_code || "-" }} {{ client.billing_address?.country_code || "-" }}
</p> </p>
<div <div
v-if="errors.billing_country_code" v-if="errors.billing_country_code"
@ -412,6 +412,8 @@ const formData = reactive({
}); });
const startEdit = () => { const startEdit = () => {
const billingAddress = props.client.billing_address || {};
isEditing.value = true; isEditing.value = true;
Object.assign(formData, { Object.assign(formData, {
name: props.client.name || "", name: props.client.name || "",
@ -419,11 +421,11 @@ const startEdit = () => {
siret: props.client.siret || "", siret: props.client.siret || "",
email: props.client.email || "", email: props.client.email || "",
phone: props.client.phone || "", phone: props.client.phone || "",
billing_address_line1: props.client.billing_address_line1 || "", billing_address_line1: billingAddress.line1 || "",
billing_address_line2: props.client.billing_address_line2 || "", billing_address_line2: billingAddress.line2 || "",
billing_postal_code: props.client.billing_postal_code || "", billing_postal_code: billingAddress.postal_code || "",
billing_city: props.client.billing_city || "", billing_city: billingAddress.city || "",
billing_country_code: props.client.billing_country_code || "FR", // Valeur par défaut billing_country_code: billingAddress.country_code || "FR",
group_id: props.client.group_id || null, group_id: props.client.group_id || null,
notes: props.client.notes || "", notes: props.client.notes || "",
is_active: is_active:
@ -606,7 +608,7 @@ const saveChanges = async () => {
try { try {
isEditing.value = false; isEditing.value = false;
emit("client-updated", formData); emit("client-updated", prepareFormData());
} catch (error) { } catch (error) {
console.error("Erreur lors de la mise à jour:", error); console.error("Erreur lors de la mise à jour:", error);
if (error.response && error.response.data && error.response.data.errors) { if (error.response && error.response.data && error.response.data.errors) {

View File

@ -6,7 +6,6 @@ import type {
Client, Client,
CreateClientPayload, CreateClientPayload,
UpdateClientPayload, UpdateClientPayload,
ClientListResponse,
} from "@/services/client"; } from "@/services/client";
import { Contact } from "@/services/contact"; import { Contact } from "@/services/contact";
@ -18,6 +17,16 @@ export const useClientStore = defineStore("client", () => {
const error = ref<string | null>(null); const error = ref<string | null>(null);
const searchResults = ref<Client[]>([]); const searchResults = ref<Client[]>([]);
const contacts_client = ref<Contact[]>([]); const contacts_client = ref<Contact[]>([]);
const filters = ref<{
page: number;
per_page: number;
search?: string;
is_active?: boolean;
group_id?: number;
}>({
page: 1,
per_page: 10,
});
// Pagination state // Pagination state
const pagination = ref({ const pagination = ref({
@ -25,6 +34,8 @@ export const useClientStore = defineStore("client", () => {
last_page: 1, last_page: 1,
per_page: 10, per_page: 10,
total: 0, total: 0,
from: 0,
to: 0,
}); });
// Getters // Getters
@ -80,10 +91,33 @@ export const useClientStore = defineStore("client", () => {
last_page: Number(getValue(meta.last_page)) || 1, last_page: Number(getValue(meta.last_page)) || 1,
per_page: Number(getValue(meta.per_page)) || 10, per_page: Number(getValue(meta.per_page)) || 10,
total: Number(getValue(meta.total)) || 0, total: Number(getValue(meta.total)) || 0,
from: Number(getValue(meta.from)) || 0,
to: Number(getValue(meta.to)) || 0,
};
filters.value = {
...filters.value,
page: pagination.value.current_page,
per_page: pagination.value.per_page,
}; };
} }
}; };
const setFilters = (params?: {
page?: number;
per_page?: number;
search?: string;
is_active?: boolean;
group_id?: number;
}) => {
filters.value = {
...filters.value,
...params,
page: params?.page ?? filters.value.page ?? 1,
per_page: params?.per_page ?? filters.value.per_page ?? 10,
};
};
/** /**
* Fetch all clients with optional pagination and filters * Fetch all clients with optional pagination and filters
*/ */
@ -98,7 +132,21 @@ export const useClientStore = defineStore("client", () => {
setError(null); setError(null);
try { try {
const response = await ClientService.getAllClients(params); setFilters(params);
const requestParams = Object.fromEntries(
Object.entries(filters.value).filter(
([, value]) => value !== undefined && value !== null && value !== ""
)
) as {
page?: number;
per_page?: number;
search?: string;
is_active?: boolean;
group_id?: number;
};
const response = await ClientService.getAllClients(requestParams);
setClients(response.data); setClients(response.data);
if (response.meta) { if (response.meta) {
setPagination(response.meta); setPagination(response.meta);
@ -166,7 +214,6 @@ export const useClientStore = defineStore("client", () => {
setError(null); setError(null);
try { try {
const response = await ClientService.updateClient(payload); const response = await ClientService.updateClient(payload);
const updatedClient = response.data; const updatedClient = response.data;
@ -227,7 +274,6 @@ export const useClientStore = defineStore("client", () => {
* Search clients * Search clients
*/ */
const searchClients = async (query: string, exactMatch: boolean = false) => { const searchClients = async (query: string, exactMatch: boolean = false) => {
setLoading(true); setLoading(true);
error.value = null; error.value = null;
@ -375,6 +421,12 @@ export const useClientStore = defineStore("client", () => {
last_page: 1, last_page: 1,
per_page: 10, per_page: 10,
total: 0, total: 0,
from: 0,
to: 0,
};
filters.value = {
page: 1,
per_page: 10,
}; };
}; };
@ -394,6 +446,7 @@ export const useClientStore = defineStore("client", () => {
getError, getError,
getClientById, getClientById,
getPagination, getPagination,
filters,
// Actions // Actions
fetchClients, fetchClients,

View File

@ -4,16 +4,19 @@
:loading-data="clientStore.loading" :loading-data="clientStore.loading"
:pagination="clientStore.getPagination" :pagination="clientStore.getPagination"
@push-details="goDetails" @push-details="goDetails"
@delete-client="handleDeleteClient"
/> />
</template> </template>
<script setup> <script setup>
import ClientPresentation from "@/components/Organism/CRM/ClientPresentation.vue"; import ClientPresentation from "@/components/Organism/CRM/ClientPresentation.vue";
import { useClientStore } from "@/stores/clientStore"; import { useClientStore } from "@/stores/clientStore";
import { useNotificationStore } from "@/stores/notification";
import { onMounted } from "vue"; import { onMounted } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
const clientStore = useClientStore(); const clientStore = useClientStore();
const router = useRouter(); const router = useRouter();
const notificationStore = useNotificationStore();
onMounted(async () => { onMounted(async () => {
await clientStore.fetchClients(); await clientStore.fetchClients();
@ -27,4 +30,15 @@ const goDetails = (id) => {
}, },
}); });
}; };
const handleDeleteClient = async (clientId) => {
try {
await clientStore.deleteClient(Number(clientId));
await clientStore.fetchClients();
notificationStore.deleted("Client");
} catch (error) {
console.error("Error deleting client:", error);
notificationStore.error("Erreur", "Impossible de supprimer le client");
}
};
</script> </script>