2026-04-22 10:17:47 +03:00

624 lines
15 KiB
Vue

<template>
<div class="table-container">
<div v-if="loading" class="loading-container">
<div class="loading-spinner">
<div class="spinner-border text-success loading-spinner-circle" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
</div>
<div class="loading-content">
<div class="table-responsive">
<table class="table table-flush">
<thead class="thead-light">
<tr>
<th>Client</th>
<th>Référence</th>
<th>Catégorie</th>
<th>Commercial</th>
<th>Adresse</th>
<th>Contact</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="i in skeletonRows" :key="i" class="skeleton-row">
<td>
<div class="d-flex align-items-center">
<div class="skeleton-avatar"></div>
<div class="skeleton-text medium ms-2"></div>
</div>
</td>
<td>
<div class="skeleton-text short"></div>
</td>
<td>
<div class="d-flex align-items-center">
<div class="skeleton-icon"></div>
<div class="skeleton-text medium ms-2"></div>
</div>
</td>
<td>
<div class="skeleton-text long"></div>
</td>
<td>
<div class="skeleton-text long"></div>
</td>
<td>
<div class="contact-info">
<div class="skeleton-text long mb-1"></div>
<div class="skeleton-text medium"></div>
</div>
</td>
<td>
<div class="d-flex align-items-center">
<div class="skeleton-icon"></div>
<div class="skeleton-text short ms-2"></div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div v-else class="table-responsive">
<table id="client-list" class="table table-flush">
<thead class="thead-light">
<tr>
<th>Client</th>
<th>Référence</th>
<th>Catégorie</th>
<th>Commercial</th>
<th>Adresse</th>
<th>Contact</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr v-for="client in data" :key="client.id">
<td class="font-weight-bold">
<div class="d-flex align-items-center">
<soft-avatar
:img="client.avatar_url || getRandomAvatar()"
size="xs"
class="me-2"
alt="client image"
circular
/>
<span>{{ client.name }}</span>
</div>
</td>
<td class="text-xs font-weight-bold">
<span class="my-2 text-xs">{{ getClientReference(client) }}</span>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex align-items-center">
<soft-button
:color="getCategoryColor(client.type_label)"
variant="outline"
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
>
<i
:class="getCategoryIcon(client.type_label)"
aria-hidden="true"
></i>
</soft-button>
<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>
</td>
<td class="text-xs font-weight-bold">
<div class="contact-info">
<div class="text-xs text-secondary">{{ client.email || "N/A" }}</div>
<div class="text-xs">{{ client.phone || "N/A" }}</div>
</div>
</td>
<td class="text-xs font-weight-bold">
<div class="d-flex flex-column">
<soft-button
v-if="client.is_active"
color="success"
variant="outline"
class="btn-sm"
>
<i class="fas fa-check me-1"></i>
Actif
</soft-button>
<soft-button
v-else
color="danger"
variant="outline"
class="btn-sm"
>
<i class="fas fa-times me-1"></i>
Inactif
</soft-button>
</div>
</td>
<td>
<div class="d-flex align-items-center gap-2">
<soft-button
color="info"
variant="outline"
title="Voir le client"
:data-client-id="client.id"
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
>
<i class="fas fa-eye" aria-hidden="true"></i>
</soft-button>
<soft-button
color="danger"
variant="outline"
title="Supprimer le client"
:data-client-id="client.id"
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
>
<i class="fas fa-trash" aria-hidden="true"></i>
</soft-button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div
v-if="!loading && data.length > 0 && (pagination?.last_page || 1) > 1"
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">
Affichage de {{ safeFrom }} à {{ safeTo }} sur
{{ pagination.total || data.length }} clients
</div>
<nav aria-label="Pagination clients">
<ul class="pagination pagination-sm pagination-success mb-0">
<li
class="page-item"
:class="{ disabled: (pagination.current_page || 1) === 1 }"
>
<a
class="page-link"
href="#"
aria-label="Previous"
@click.prevent="changePage((pagination.current_page || 1) - 1)"
>
<span aria-hidden="true">
<i class="fa fa-angle-left" aria-hidden="true"></i>
</span>
</a>
</li>
<li
v-for="page in displayedPages"
:key="`page-${page}`"
class="page-item"
:class="{
active: (pagination.current_page || 1) === page,
disabled: page === '...'
}"
>
<a class="page-link" href="#" @click.prevent="changePage(page)">
{{ page }}
</a>
</li>
<li
class="page-item"
:class="{
disabled: (pagination.current_page || 1) === (pagination.last_page || 1)
}"
>
<a
class="page-link"
href="#"
aria-label="Next"
@click.prevent="changePage((pagination.current_page || 1) + 1)"
>
<span aria-hidden="true">
<i class="fa fa-angle-right" aria-hidden="true"></i>
</span>
</a>
</li>
</ul>
</nav>
</div>
<div v-if="!loading && data.length === 0" class="empty-state">
<div class="empty-icon">
<i class="fas fa-users fa-3x text-muted"></i>
</div>
<h5 class="empty-title">Aucun client trouvé</h5>
<p class="empty-text text-muted">
Aucun client à afficher pour le moment.
</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, onUnmounted, computed } from "vue";
import { DataTable } from "simple-datatables";
import SoftButton from "@/components/SoftButton.vue";
import SoftAvatar from "@/components/SoftAvatar.vue";
import { defineProps, defineEmits } from "vue";
const emit = defineEmits(["view", "delete", "page-change", "per-page-change"]);
import img1 from "@/assets/img/team-2.jpg";
import img2 from "@/assets/img/team-1.jpg";
import img3 from "@/assets/img/team-3.jpg";
import img4 from "@/assets/img/team-4.jpg";
import img5 from "@/assets/img/team-5.jpg";
import img6 from "@/assets/img/ivana-squares.jpg";
const avatarImages = [img1, img2, img3, img4, img5, img6];
const dataTableInstance = ref(null);
const props = defineProps({
data: {
type: Array,
default: [],
},
loading: {
type: Boolean,
default: false,
},
skeletonRows: {
type: Number,
default: 5,
},
pagination: {
type: Object,
default: () => ({
current_page: 1,
last_page: 1,
per_page: 10,
total: 0,
from: 0,
to: 0,
}),
},
});
const displayedPages = computed(() => {
const total = Number(props.pagination?.last_page) || 1;
const current = Number(props.pagination?.current_page) || 1;
if (total <= 1) {
return [1];
}
const delta = 2;
const range = [];
for (
let page = Math.max(2, current - delta);
page <= Math.min(total - 1, current + delta);
page++
) {
range.push(page);
}
if (current - delta > 2) {
range.unshift("...");
}
if (current + delta < total - 1) {
range.push("...");
}
range.unshift(1);
if (total > 1) {
range.push(total);
}
return range.filter(
(value, index, self) =>
value !== "..." || (value === "..." && self[index - 1] !== "...")
);
});
const safeFrom = computed(() => {
if (props.pagination?.from) {
return props.pagination.from;
}
if (!props.pagination?.total || props.data.length === 0) {
return 0;
}
return (
((Number(props.pagination.current_page) || 1) - 1) *
(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 randomIndex = Math.floor(Math.random() * avatarImages.length);
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) => {
if (!address) return "N/A";
const parts = [address.postal_code, address.city, address.country_code].filter(
Boolean
);
return parts.length > 0 ? parts.join(" ") : "N/A";
};
const getCategoryColor = (type) => {
const colors = {
Entreprise: "info",
Particulier: "success",
Association: "warning",
};
return colors[type] || "secondary";
};
const getCategoryIcon = (type) => {
const icons = {
Entreprise: "fas fa-building",
Particulier: "fas fa-user",
Association: "fas fa-users",
};
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>
<style scoped>
.table-container {
position: relative;
min-height: 200px;
}
.loading-container {
position: relative;
min-height: 260px;
}
.loading-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10;
}
.loading-spinner-circle {
width: 2.25rem;
height: 2.25rem;
border-width: 0.28em;
}
.loading-content {
opacity: 0.55;
pointer-events: none;
}
.skeleton-row {
animation: none;
}
.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;
}
.address-info,
.contact-info {
line-height: 1.2;
}
.text-xs {
font-size: 0.75rem;
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
@media (max-width: 768px) {
.skeleton-text.long {
width: 80px;
}
.skeleton-text.medium {
width: 60px;
}
}
</style>