554 lines
13 KiB
Vue
554 lines
13 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>Groupe</th>
|
|
<th>Description</th>
|
|
<th>Date de création</th>
|
|
<th>Statut</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 long"></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 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>Groupe</th>
|
|
<th>Description</th>
|
|
<th>Date de création</th>
|
|
<th>Statut</th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="group in data" :key="group.id">
|
|
<td class="font-weight-bold">
|
|
<div class="d-flex align-items-center">
|
|
<soft-button
|
|
color="info"
|
|
variant="outline"
|
|
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
|
|
>
|
|
<i class="fas fa-layer-group" aria-hidden="true"></i>
|
|
</soft-button>
|
|
<div>
|
|
<div>{{ group.name }}</div>
|
|
<div class="text-xs text-muted">ID #{{ group.id }}</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
|
|
<td class="text-xs font-weight-bold">
|
|
<div class="contact-info">
|
|
<div class="text-xs text-secondary">
|
|
{{ getDescriptionLine(group.description) }}
|
|
</div>
|
|
<div class="text-xs">{{ getDescriptionMeta(group.description) }}</div>
|
|
</div>
|
|
</td>
|
|
|
|
<td class="text-xs font-weight-bold">
|
|
{{ formatDate(group.created_at) }}
|
|
</td>
|
|
|
|
<td class="text-xs font-weight-bold">
|
|
<div class="d-flex flex-column">
|
|
<soft-button
|
|
color="success"
|
|
variant="outline"
|
|
class="btn-sm"
|
|
>
|
|
<i class="fas fa-check me-1"></i>
|
|
Actif
|
|
</soft-button>
|
|
</div>
|
|
</td>
|
|
|
|
<td>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<soft-button
|
|
color="info"
|
|
variant="outline"
|
|
title="Voir le groupe"
|
|
:data-group-id="group.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="warning"
|
|
variant="outline"
|
|
title="Modifier le groupe"
|
|
:data-group-id="group.id"
|
|
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
|
|
>
|
|
<i class="fas fa-edit" aria-hidden="true"></i>
|
|
</soft-button>
|
|
|
|
<soft-button
|
|
color="danger"
|
|
variant="outline"
|
|
title="Supprimer le groupe"
|
|
:data-group-id="group.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 }} groupes
|
|
</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 groupe trouvé</h5>
|
|
<p class="empty-text text-muted">
|
|
Aucun groupe à 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 { defineProps, defineEmits } from "vue";
|
|
|
|
const emit = defineEmits(["view", "edit", "delete", "page-change"]);
|
|
|
|
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 formatDate = (dateString) => {
|
|
if (!dateString) {
|
|
return "-";
|
|
}
|
|
|
|
return new Date(dateString).toLocaleDateString("fr-FR", {
|
|
day: "numeric",
|
|
month: "short",
|
|
year: "numeric",
|
|
});
|
|
};
|
|
|
|
const getDescriptionLine = (description) => {
|
|
if (!description) {
|
|
return "Aucune description";
|
|
}
|
|
|
|
return description;
|
|
};
|
|
|
|
const getDescriptionMeta = (description) => {
|
|
if (!description) {
|
|
return "Description indisponible";
|
|
}
|
|
|
|
return `${description.length} caractere${description.length > 1 ? "s" : ""}`;
|
|
};
|
|
|
|
const handleTableClick = (event) => {
|
|
const button = event.target.closest("button");
|
|
if (!button) return;
|
|
|
|
const groupId = button.getAttribute("data-group-id");
|
|
if (!groupId) return;
|
|
|
|
if (
|
|
button.title === "Supprimer le groupe" ||
|
|
button.querySelector(".fa-trash")
|
|
) {
|
|
emit("delete", groupId);
|
|
} else if (
|
|
button.title === "Modifier le groupe" ||
|
|
button.querySelector(".fa-edit")
|
|
) {
|
|
emit("edit", groupId);
|
|
} else if (
|
|
button.title === "Voir le groupe" ||
|
|
button.querySelector(".fa-eye")
|
|
) {
|
|
emit("view", groupId);
|
|
}
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
.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>
|