736 lines
17 KiB
Vue
736 lines
17 KiB
Vue
<template>
|
|
<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 class="loading-spinner">
|
|
<div class="spinner-border text-primary" role="status">
|
|
<span class="visually-hidden">Chargement...</span>
|
|
</div>
|
|
</div>
|
|
<div class="loading-content">
|
|
<!-- Skeleton Rows -->
|
|
<div class="table-responsive">
|
|
<table class="table table-flush">
|
|
<thead class="thead-light">
|
|
<tr>
|
|
<th>Commercial</th>
|
|
<th>Client</th>
|
|
<th>Address</th>
|
|
<th>Categories</th>
|
|
<th>Contact</th>
|
|
<th>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<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>
|
|
<div class="d-flex align-items-center">
|
|
<div class="skeleton-avatar"></div>
|
|
<div class="skeleton-text medium ms-2"></div>
|
|
</div>
|
|
</td>
|
|
|
|
<!-- Address Column Skeleton -->
|
|
<td>
|
|
<div class="skeleton-text long"></div>
|
|
</td>
|
|
|
|
<!-- Categories Column Skeleton -->
|
|
<td>
|
|
<div class="d-flex align-items-center">
|
|
<div class="skeleton-icon"></div>
|
|
<div class="skeleton-text medium ms-2"></div>
|
|
</div>
|
|
</td>
|
|
|
|
<!-- Contact Column Skeleton -->
|
|
<td>
|
|
<div class="contact-info">
|
|
<div class="skeleton-text long mb-1"></div>
|
|
<div class="skeleton-text medium"></div>
|
|
</div>
|
|
</td>
|
|
|
|
<!-- Status Column Skeleton -->
|
|
<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>
|
|
|
|
<!-- Data State -->
|
|
<div v-else class="table-responsive">
|
|
<table id="contact-list" class="table table-flush">
|
|
<thead class="thead-light">
|
|
<tr>
|
|
<th>Commercial</th>
|
|
<th>Client</th>
|
|
<th>Address</th>
|
|
<th>Categories</th>
|
|
<th>Contact</th>
|
|
<th>Status</th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<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">
|
|
<div class="d-flex align-items-center">
|
|
<soft-avatar
|
|
:img="getRandomAvatar()"
|
|
size="xs"
|
|
class="me-2"
|
|
alt="user image"
|
|
circular
|
|
/>
|
|
<span>{{ client.name }}</span>
|
|
</div>
|
|
</td>
|
|
|
|
<!-- Address Column (Shortened) -->
|
|
<td class="text-xs font-weight-bold">
|
|
<span class="my-2 text-xs">{{
|
|
getShortAddress(client.billing_address)
|
|
}}</span>
|
|
</td>
|
|
|
|
<!-- Categories Column -->
|
|
<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 }}</span>
|
|
</div>
|
|
</td>
|
|
|
|
<!-- Contact Column -->
|
|
<td class="text-xs font-weight-bold">
|
|
<div class="contact-info">
|
|
<div class="text-xs text-secondary">{{ client.email }}</div>
|
|
<div class="text-xs">{{ client.phone }}</div>
|
|
</div>
|
|
</td>
|
|
|
|
<!-- Status Column -->
|
|
<td class="text-xs font-weight-bold">
|
|
<div class="d-flex align-items-center">
|
|
<soft-button
|
|
:color="client.is_active ? 'success' : 'danger'"
|
|
variant="outline"
|
|
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
|
|
>
|
|
<i
|
|
:class="client.is_active ? 'fas fa-check' : 'fas fa-times'"
|
|
aria-hidden="true"
|
|
></i>
|
|
</soft-button>
|
|
<span>{{ client.is_active ? "Active" : "Inactive" }}</span>
|
|
</div>
|
|
</td>
|
|
|
|
<td>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<!-- View Button -->
|
|
|
|
<soft-button
|
|
color="info"
|
|
variant="outline"
|
|
title="View Client"
|
|
:data-client-id="client.id"
|
|
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>
|
|
</soft-button>
|
|
|
|
<!-- Delete Button -->
|
|
<soft-button
|
|
color="danger"
|
|
variant="outline"
|
|
title="Delete Client"
|
|
:data-client-id="client.id"
|
|
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>
|
|
</soft-button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Pagination Footer -->
|
|
<div
|
|
v-if="!loading && data.length > 0"
|
|
class="d-flex justify-content-between align-items-center mt-3 px-3"
|
|
>
|
|
<div class="text-xs text-secondary font-weight-bold">
|
|
Affichage de {{ pagination.from }} à {{ pagination.to }} sur
|
|
{{ pagination.total }} clients
|
|
</div>
|
|
|
|
<nav aria-label="Page navigation">
|
|
<ul class="pagination pagination-sm pagination-success mb-0">
|
|
<li
|
|
class="page-item"
|
|
:class="{ disabled: pagination.current_page === 1 }"
|
|
>
|
|
<a
|
|
class="page-link"
|
|
href="#"
|
|
aria-label="Previous"
|
|
@click.prevent="changePage(pagination.current_page - 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"
|
|
class="page-item"
|
|
:class="{ active: pagination.current_page === page }"
|
|
>
|
|
<a class="page-link" href="#" @click.prevent="changePage(page)">{{
|
|
page
|
|
}}</a>
|
|
</li>
|
|
|
|
<li
|
|
class="page-item"
|
|
:class="{
|
|
disabled: pagination.current_page === pagination.last_page,
|
|
}"
|
|
>
|
|
<a
|
|
class="page-link"
|
|
href="#"
|
|
aria-label="Next"
|
|
@click.prevent="changePage(pagination.current_page + 1)"
|
|
>
|
|
<span aria-hidden="true"
|
|
><i class="fa fa-angle-right" aria-hidden="true"></i
|
|
></span>
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</nav>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<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 { computed } from "vue";
|
|
import SoftCheckbox from "@/components/SoftCheckbox.vue";
|
|
import SoftButton from "@/components/SoftButton.vue";
|
|
import SoftAvatar from "@/components/SoftAvatar.vue";
|
|
import { defineProps, defineEmits } from "vue";
|
|
import debounce from "lodash/debounce";
|
|
|
|
const emit = defineEmits([
|
|
"view",
|
|
"delete",
|
|
"page-change",
|
|
"per-page-change",
|
|
"search-change",
|
|
]);
|
|
|
|
// Sample avatar images
|
|
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 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,
|
|
}),
|
|
},
|
|
});
|
|
|
|
// Calculate displayed page numbers
|
|
const displayedPages = computed(() => {
|
|
const total = props.pagination.last_page;
|
|
const current = props.pagination.current_page;
|
|
const delta = 2;
|
|
const range = [];
|
|
|
|
for (
|
|
let i = Math.max(2, current - delta);
|
|
i <= Math.min(total - 1, current + delta);
|
|
i++
|
|
) {
|
|
range.push(i);
|
|
}
|
|
|
|
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(
|
|
(val, index, self) =>
|
|
val !== "..." || (val === "..." && self[index - 1] !== "...")
|
|
);
|
|
});
|
|
|
|
// Methods
|
|
const changePage = (page) => {
|
|
if (page !== "..." && page >= 1 && page <= props.pagination.last_page) {
|
|
emit("page-change", page);
|
|
}
|
|
};
|
|
|
|
const onPerPageChange = (event) => {
|
|
const newPerPage = parseInt(event.target.value);
|
|
emit("per-page-change", newPerPage);
|
|
};
|
|
|
|
const onSearch = debounce((event) => {
|
|
emit("search-change", event.target.value);
|
|
}, 300);
|
|
|
|
const getRandomAvatar = () => {
|
|
const randomIndex = Math.floor(Math.random() * avatarImages.length);
|
|
return avatarImages[randomIndex];
|
|
};
|
|
|
|
const getShortAddress = (address) => {
|
|
if (!address) return "N/A";
|
|
// Return just city and postal code for brevity
|
|
return `${address.postal_code} ${address.city}`;
|
|
};
|
|
|
|
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-circle";
|
|
};
|
|
</script>
|
|
|
|
<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 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>
|