dynamic table
This commit is contained in:
parent
ea2b687533
commit
425d2d510c
@ -8,10 +8,12 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreClientLocationRequest;
|
||||
use App\Http\Requests\UpdateClientLocationRequest;
|
||||
use App\Http\Resources\Client\ClientLocationResource;
|
||||
use App\Http\Resources\Client\ClientLocationCollection;
|
||||
use App\Repositories\ClientLocationRepositoryInterface;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ClientLocationController extends Controller
|
||||
{
|
||||
@ -23,11 +25,14 @@ class ClientLocationController extends Controller
|
||||
/**
|
||||
* Display a listing of client locations.
|
||||
*/
|
||||
public function index(): AnonymousResourceCollection|JsonResponse
|
||||
public function index(Request $request)
|
||||
{
|
||||
try {
|
||||
$clientLocations = $this->clientLocationRepository->all();
|
||||
return ClientLocationResource::collection($clientLocations);
|
||||
$filters = $request->only(['client_id', 'is_default', 'search']);
|
||||
$perPage = $request->get('per_page', 10);
|
||||
|
||||
$clientLocations = $this->clientLocationRepository->getPaginated($filters, $perPage);
|
||||
return new ClientLocationCollection($clientLocations);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error fetching client locations: ' . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
|
||||
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Models\ClientLocation;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class ClientLocationRepository extends BaseRepository implements ClientLocationRepositoryInterface
|
||||
{
|
||||
@ -19,4 +20,93 @@ class ClientLocationRepository extends BaseRepository implements ClientLocationR
|
||||
$query->where('client_id', $client_id);
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated client locations with optional filters
|
||||
*/
|
||||
public function getPaginated(array $filters = [], int $perPage = 10)
|
||||
{
|
||||
$query = $this->model->newQuery();
|
||||
|
||||
// Filter by client_id
|
||||
if (isset($filters['client_id'])) {
|
||||
$query->where('client_id', $filters['client_id']);
|
||||
}
|
||||
|
||||
// Filter by is_default
|
||||
if (isset($filters['is_default'])) {
|
||||
$query->where('is_default', $filters['is_default']);
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (!empty($filters['search'])) {
|
||||
$search = $filters['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'LIKE', "%{$search}%")
|
||||
->orWhere('address', 'LIKE', "%{$search}%")
|
||||
->orWhere('city', 'LIKE', "%{$search}%")
|
||||
->orWhere('postal_code', 'LIKE', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Order by
|
||||
$query->orderBy('is_default', 'desc')
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated locations for a specific client
|
||||
*/
|
||||
public function getPaginatedByClientId(int $clientId, array $filters = [], int $perPage = 10): LengthAwarePaginator
|
||||
{
|
||||
$query = $this->model->newQuery()->where('client_id', $clientId);
|
||||
|
||||
if (isset($filters['is_default'])) {
|
||||
$query->where('is_default', $filters['is_default']);
|
||||
}
|
||||
|
||||
if (!empty($filters['search'])) {
|
||||
$search = $filters['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'LIKE', "%{$search}%")
|
||||
->orWhere('address', 'LIKE', "%{$search}%")
|
||||
->orWhere('city', 'LIKE', "%{$search}%")
|
||||
->orWhere('postal_code', 'LIKE', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$query->orderBy('is_default', 'desc')
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default location for a client
|
||||
*/
|
||||
public function getDefaultByClientId(int $clientId): ?ClientLocation
|
||||
{
|
||||
return $this->model->where('client_id', $clientId)
|
||||
->where('is_default', true)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a location as default and update others
|
||||
*/
|
||||
public function setAsDefault(int $locationId): ClientLocation
|
||||
{
|
||||
$location = $this->find($locationId);
|
||||
|
||||
$this->model->where('client_id', $location->client_id)
|
||||
->where('id', '!=', $locationId)
|
||||
->update(['is_default' => false]);
|
||||
|
||||
// Set this location as default
|
||||
$location->update(['is_default' => true]);
|
||||
|
||||
return $location->fresh();
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,8 +3,18 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repositories;
|
||||
use App\Models\ClientLocation;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
interface ClientLocationRepositoryInterface extends BaseRepositoryInterface
|
||||
{
|
||||
function getByClientId(int $client_id);
|
||||
|
||||
function getPaginated(array $filters = [], int $perPage = 10);
|
||||
|
||||
function getPaginatedByClientId(int $clientId, array $filters = [], int $perPage = 10): LengthAwarePaginator;
|
||||
|
||||
function getDefaultByClientId(int $clientId): ?ClientLocation;
|
||||
|
||||
function setAsDefault(int $locationId): ClientLocation;
|
||||
}
|
||||
|
||||
@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<liste-lieux-template>
|
||||
<template #lieux-new-action>
|
||||
<add-button text="Ajouter" />
|
||||
</template>
|
||||
<template #select-filter>
|
||||
<filter-table />
|
||||
</template>
|
||||
<template #lieux-other-action>
|
||||
<table-action />
|
||||
</template>
|
||||
<template #lieux-table>
|
||||
<location-table
|
||||
:data="locationData"
|
||||
:loading="isLoading"
|
||||
:current-page="currentPage"
|
||||
:per-page="perPage"
|
||||
@view="handleViewLocation"
|
||||
@delete="handleDeleteLocation"
|
||||
@page-change="handlePageChange"
|
||||
/>
|
||||
</template>
|
||||
</liste-lieux-template>
|
||||
</template>
|
||||
<script setup>
|
||||
import ListeLieuxTemplate from "@/components/templates/CRM/lieux/ListeLieuxTemplate.vue";
|
||||
import LocationTable from "@/components/molecules/location/LocationTable.vue";
|
||||
import addButton from "@/components/molecules/new-button/addButton.vue";
|
||||
import TableAction from "@/components/molecules/Tables/TableAction.vue";
|
||||
import FilterTable from "@/components/molecules/Tables/FilterTable.vue";
|
||||
import { defineProps, defineEmits } from "vue";
|
||||
defineProps({
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
locationData: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
currentPage: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
perPage: {
|
||||
type: Number,
|
||||
default: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["view-location", "delete-location", "page-change"]);
|
||||
|
||||
const handleViewLocation = (locationId) => {
|
||||
emit("view-location", locationId);
|
||||
};
|
||||
|
||||
const handleDeleteLocation = (locationId) => {
|
||||
emit("delete-location", locationId);
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage) => {
|
||||
emit("page-change", newPage);
|
||||
};
|
||||
</script>
|
||||
@ -0,0 +1,785 @@
|
||||
<template>
|
||||
<div class="table-container">
|
||||
<!-- 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 v-for="column in visibleColumns" :key="column.key">
|
||||
{{ column.label }}
|
||||
</th>
|
||||
<th v-if="showActions">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="i in skeletonRows" :key="i" class="skeleton-row">
|
||||
<!-- Dynamic Skeleton Columns -->
|
||||
<td v-for="column in visibleColumns" :key="column.key">
|
||||
<div class="d-flex align-items-center">
|
||||
<div
|
||||
v-if="column.key === 'name'"
|
||||
class="skeleton-avatar"
|
||||
></div>
|
||||
<div
|
||||
v-if="column.key === 'is_default'"
|
||||
class="skeleton-icon small"
|
||||
></div>
|
||||
<div
|
||||
:class="['skeleton-text', getSkeletonWidth(column.key)]"
|
||||
></div>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Actions Skeleton -->
|
||||
<td v-if="showActions">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="skeleton-icon small"></div>
|
||||
<div class="skeleton-icon small"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data State -->
|
||||
<div v-else-if="data.length > 0" class="data-container">
|
||||
<!-- Table Controls -->
|
||||
<div class="table-controls mb-3">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<!-- Column Visibility Toggle -->
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn btn-outline-secondary btn-sm dropdown-toggle"
|
||||
type="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<i class="fas fa-columns me-2"></i>
|
||||
Colonnes
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li v-for="column in allColumns" :key="column.key">
|
||||
<div class="dropdown-item">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
:id="`col-${column.key}`"
|
||||
v-model="column.visible"
|
||||
/>
|
||||
<label
|
||||
class="form-check-label"
|
||||
:for="`col-${column.key}`"
|
||||
>
|
||||
{{ column.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="search-box">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text">
|
||||
<i class="fas fa-search"></i>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Rechercher..."
|
||||
v-model="searchQuery"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 text-end">
|
||||
<div class="d-flex align-items-center justify-content-end gap-2">
|
||||
<!-- Items per page -->
|
||||
<div class="items-per-page">
|
||||
<select
|
||||
class="form-select form-select-sm"
|
||||
v-model="perPage"
|
||||
@change="handlePerPageChange"
|
||||
style="width: auto"
|
||||
>
|
||||
<option value="5">5 par page</option>
|
||||
<option value="10">10 par page</option>
|
||||
<option value="15">15 par page</option>
|
||||
<option value="20">20 par page</option>
|
||||
<option value="50">50 par page</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="table-responsive">
|
||||
<table class="table table-flush table-hover">
|
||||
<thead class="thead-light">
|
||||
<tr>
|
||||
<th v-for="column in visibleColumns" :key="column.key">
|
||||
<div
|
||||
class="column-header"
|
||||
@click="() => sortBy(column.key)"
|
||||
:class="{ sortable: column.sortable }"
|
||||
>
|
||||
{{ column.label }}
|
||||
<span v-if="sortColumn === column.key" class="sort-icon">
|
||||
<i
|
||||
:class="
|
||||
sortDirection === 'asc'
|
||||
? 'fas fa-sort-up'
|
||||
: 'fas fa-sort-down'
|
||||
"
|
||||
></i>
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="showActions">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="location in paginatedData" :key="location.id">
|
||||
<!-- Dynamic Columns -->
|
||||
<td v-for="column in visibleColumns" :key="column.key">
|
||||
<!-- <component
|
||||
:is="getColumnComponent(column.key)"
|
||||
:location="location"
|
||||
:column="column"
|
||||
/> -->
|
||||
{{ location[column.key] }}
|
||||
</td>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<td v-if="showActions">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<!-- View Button -->
|
||||
<soft-button
|
||||
color="info"
|
||||
variant="outline"
|
||||
title="View Location"
|
||||
:data-location-id="location.id"
|
||||
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
|
||||
@click="emit('view', location.id)"
|
||||
>
|
||||
<i class="fas fa-eye" aria-hidden="true"></i>
|
||||
</soft-button>
|
||||
|
||||
<!-- Delete Button -->
|
||||
<soft-button
|
||||
color="danger"
|
||||
variant="outline"
|
||||
title="Delete Location"
|
||||
:data-location-id="location.id"
|
||||
class="btn-icon-only btn-rounded mb-0 btn-sm d-flex align-items-center justify-content-center"
|
||||
@click="emit('delete', location.id)"
|
||||
>
|
||||
<i class="fas fa-trash" aria-hidden="true"></i>
|
||||
</soft-button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="table-pagination mt-4">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-6">
|
||||
<div class="pagination-info text-muted">
|
||||
Affichage de {{ pagination.from }} à {{ pagination.to }} sur
|
||||
{{ pagination.total }} éléments
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<nav aria-label="Table pagination">
|
||||
<ul class="pagination justify-content-end mb-0">
|
||||
<!-- Previous Page -->
|
||||
<li
|
||||
class="page-item"
|
||||
:class="{ disabled: pagination.currentPage === 1 }"
|
||||
>
|
||||
<button
|
||||
class="page-link"
|
||||
@click="changePage(pagination.currentPage - 1)"
|
||||
:disabled="pagination.currentPage === 1"
|
||||
>
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<!-- Page Numbers -->
|
||||
<li
|
||||
v-for="page in pagination.pages"
|
||||
:key="page"
|
||||
class="page-item"
|
||||
:class="{ active: page === pagination.currentPage }"
|
||||
>
|
||||
<button class="page-link" @click="changePage(page)">
|
||||
{{ page }}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<!-- Next Page -->
|
||||
<li
|
||||
class="page-item"
|
||||
:class="{
|
||||
disabled: pagination.currentPage === pagination.lastPage,
|
||||
}"
|
||||
>
|
||||
<button
|
||||
class="page-link"
|
||||
@click="changePage(pagination.currentPage + 1)"
|
||||
:disabled="pagination.currentPage === pagination.lastPage"
|
||||
>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<i class="fas fa-map-marker-alt fa-3x text-muted"></i>
|
||||
</div>
|
||||
<h5 class="empty-title">Aucun emplacement trouvé</h5>
|
||||
<p class="empty-text text-muted">
|
||||
Aucun emplacement à afficher pour le moment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import SoftButton from "@/components/SoftButton.vue";
|
||||
import SoftAvatar from "@/components/SoftAvatar.vue";
|
||||
import { defineProps, defineEmits } from "vue";
|
||||
|
||||
// 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 emit = defineEmits(["view", "delete", "page-change"]);
|
||||
|
||||
// Accept both `data` and `location-data` from parents
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
// support alternative prop name used elsewhere: `location-data` / `locationData`
|
||||
locationData: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
skeletonRows: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
},
|
||||
showActions: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
currentPage: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
perPage: {
|
||||
type: Number,
|
||||
default: 10,
|
||||
},
|
||||
});
|
||||
|
||||
// Use the provided data prop or fallback to locationData
|
||||
const tableData = computed(() => {
|
||||
// Prefer explicit `data` when provided; otherwise use `locationData`
|
||||
return (props.data && props.data.length) ||
|
||||
!props.data ||
|
||||
props.data.length === 0
|
||||
? props.data.length
|
||||
? props.data
|
||||
: props.locationData
|
||||
: props.locationData;
|
||||
});
|
||||
|
||||
// Reactive data
|
||||
const searchQuery = ref("");
|
||||
const sortColumn = ref("name");
|
||||
const sortDirection = ref("asc");
|
||||
const perPage = ref(props.perPage);
|
||||
const currentPage = ref(props.currentPage);
|
||||
|
||||
// Column configuration
|
||||
const allColumns = ref([
|
||||
{ key: "name", label: "Nom", visible: true, sortable: true },
|
||||
{ key: "city", label: "Ville", visible: true, sortable: true },
|
||||
{ key: "address_line1", label: "Adresse", visible: true, sortable: true },
|
||||
{ key: "gps_lat", label: "Latitude GPS", visible: true, sortable: true },
|
||||
{ key: "gps_lng", label: "Longitude GPS", visible: true, sortable: true },
|
||||
{ key: "is_default", label: "Par défaut", visible: true, sortable: true },
|
||||
]);
|
||||
|
||||
// Computed
|
||||
const visibleColumns = computed(() =>
|
||||
allColumns.value.filter((col) => col.visible)
|
||||
);
|
||||
|
||||
const getNestedValue = (obj, path) => {
|
||||
return path.split(".").reduce((acc, part) => acc && acc[part], obj);
|
||||
};
|
||||
|
||||
const filteredAndSortedData = computed(() => {
|
||||
let filtered = [...tableData.value];
|
||||
|
||||
// Apply search filter
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(location) =>
|
||||
location.name?.toLowerCase().includes(query) ||
|
||||
location.address?.city?.toLowerCase().includes(query) ||
|
||||
location.address?.line1?.toLowerCase().includes(query) ||
|
||||
location.address?.postal_code?.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
if (sortColumn.value) {
|
||||
filtered.sort((a, b) => {
|
||||
let aValue = getNestedValue(a, sortColumn.value);
|
||||
let bValue = getNestedValue(b, sortColumn.value);
|
||||
|
||||
if (aValue == null) aValue = "";
|
||||
if (bValue == null) bValue = "";
|
||||
|
||||
if (aValue < bValue) return sortDirection.value === "asc" ? -1 : 1;
|
||||
if (aValue > bValue) return sortDirection.value === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
const paginatedData = computed(() => {
|
||||
const start = (currentPage.value - 1) * perPage.value;
|
||||
const end = start + perPage.value;
|
||||
return filteredAndSortedData.value.slice(start, end);
|
||||
});
|
||||
|
||||
const pagination = computed(() => {
|
||||
const total = filteredAndSortedData.value.length;
|
||||
const lastPage = Math.ceil(total / perPage.value);
|
||||
const from = total === 0 ? 0 : (currentPage.value - 1) * perPage.value + 1;
|
||||
const to = Math.min(currentPage.value * perPage.value, total);
|
||||
|
||||
// Generate page numbers
|
||||
const pages = [];
|
||||
const maxVisiblePages = 5;
|
||||
let startPage = Math.max(
|
||||
1,
|
||||
currentPage.value - Math.floor(maxVisiblePages / 2)
|
||||
);
|
||||
let endPage = Math.min(lastPage, startPage + maxVisiblePages - 1);
|
||||
|
||||
if (endPage - startPage + 1 < maxVisiblePages) {
|
||||
startPage = Math.max(1, endPage - maxVisiblePages + 1);
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
return {
|
||||
currentPage: currentPage.value,
|
||||
lastPage,
|
||||
total,
|
||||
from,
|
||||
to,
|
||||
pages,
|
||||
};
|
||||
});
|
||||
|
||||
// Methods
|
||||
const getRandomAvatar = () => {
|
||||
const randomIndex = Math.floor(Math.random() * avatarImages.length);
|
||||
return avatarImages[randomIndex];
|
||||
};
|
||||
|
||||
const getSkeletonWidth = (columnKey) => {
|
||||
const widths = {
|
||||
name: "medium",
|
||||
client_name: "medium",
|
||||
gps_lat: "short",
|
||||
gps_lng: "short",
|
||||
code_portail: "medium",
|
||||
code_alarm: "medium",
|
||||
code_funeraire: "medium",
|
||||
is_default: "short",
|
||||
};
|
||||
return widths[columnKey] || "medium";
|
||||
};
|
||||
|
||||
const getColumnComponent = (columnKey) => {
|
||||
return {
|
||||
name: NameColumn,
|
||||
city: CityColumn,
|
||||
address_line1: AddressColumn,
|
||||
gps_lat: GpsLatColumn,
|
||||
gps_lng: GpsLngColumn,
|
||||
is_default: DefaultColumn,
|
||||
}[columnKey];
|
||||
};
|
||||
|
||||
const sortBy = (columnKey) => {
|
||||
const column = allColumns.value.find((col) => col.key === columnKey);
|
||||
if (!column?.sortable) return;
|
||||
|
||||
if (sortColumn.value === columnKey) {
|
||||
sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
sortColumn.value = columnKey;
|
||||
sortDirection.value = "asc";
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
currentPage.value = 1;
|
||||
};
|
||||
|
||||
const handlePerPageChange = () => {
|
||||
currentPage.value = 1;
|
||||
};
|
||||
|
||||
const changePage = (page) => {
|
||||
if (page >= 1 && page <= pagination.value.lastPage) {
|
||||
currentPage.value = page;
|
||||
emit("page-change", page);
|
||||
}
|
||||
};
|
||||
|
||||
// Column Components
|
||||
const NameColumn = {
|
||||
props: ["location"],
|
||||
template: `
|
||||
<div class="d-flex align-items-center">
|
||||
<soft-avatar
|
||||
:img="getRandomAvatar()"
|
||||
size="xs"
|
||||
class="me-2"
|
||||
alt="location image"
|
||||
circular
|
||||
/>
|
||||
<span class="font-weight-bold">{{ location.name || 'N/A' }}</span>
|
||||
</div>
|
||||
`,
|
||||
components: { SoftAvatar },
|
||||
methods: { getRandomAvatar },
|
||||
};
|
||||
|
||||
const CityColumn = {
|
||||
props: ["location"],
|
||||
template: `
|
||||
<span class="text-xs font-weight-bold">
|
||||
{{ location.address?.city || 'N/A' }}
|
||||
</span>
|
||||
`,
|
||||
};
|
||||
|
||||
const AddressColumn = {
|
||||
props: ["location"],
|
||||
template: `
|
||||
<span class="text-xs font-weight-bold">
|
||||
{{ location.address?.line1 || 'N/A' }}
|
||||
</span>
|
||||
`,
|
||||
};
|
||||
|
||||
const GpsLatColumn = {
|
||||
props: ["location"],
|
||||
template: `
|
||||
<span class="text-xs font-weight-bold">
|
||||
{{ location.gps_lat ? location.gps_lat + '°' : 'N/A' }}
|
||||
</span>
|
||||
`,
|
||||
};
|
||||
|
||||
const GpsLngColumn = {
|
||||
props: ["location"],
|
||||
template: `
|
||||
<span class="text-xs font-weight-bold">
|
||||
{{ location.gps_lng ? location.gps_lng + '°' : 'N/A' }}
|
||||
</span>
|
||||
`,
|
||||
};
|
||||
|
||||
const DefaultColumn = {
|
||||
props: ["location"],
|
||||
template: `
|
||||
<div class="d-flex align-items-center">
|
||||
<soft-button
|
||||
:color="location.is_default ? 'success' : 'secondary'"
|
||||
variant="outline"
|
||||
class="btn-icon-only btn-rounded mb-0 me-2 btn-sm d-flex align-items-center justify-content-center"
|
||||
>
|
||||
<i :class="location.is_default ? 'fas fa-check' : 'fas fa-times'" aria-hidden="true"></i>
|
||||
</soft-button>
|
||||
<span class="text-xs font-weight-bold">{{ location.is_default ? "Oui" : "Non" }}</span>
|
||||
</div>
|
||||
`,
|
||||
components: { SoftButton },
|
||||
};
|
||||
|
||||
// Watch for data changes (both prop names)
|
||||
watch(
|
||||
() => props.data,
|
||||
() => {
|
||||
currentPage.value = 1;
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.locationData,
|
||||
() => {
|
||||
currentPage.value = 1;
|
||||
}
|
||||
);
|
||||
|
||||
// Watch for prop changes
|
||||
watch(
|
||||
() => props.currentPage,
|
||||
(newPage) => {
|
||||
currentPage.value = newPage;
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.perPage,
|
||||
(newPerPage) => {
|
||||
perPage.value = newPerPage;
|
||||
}
|
||||
);
|
||||
</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-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;
|
||||
}
|
||||
|
||||
.skeleton-icon.small {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Table Controls */
|
||||
.table-controls {
|
||||
background: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.column-header {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.column-header.sortable:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.sort-icon {
|
||||
margin-left: 0.25rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.table-pagination {
|
||||
background: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.pagination .page-item.active .page-link {
|
||||
background-color: #007bff;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.pagination .page-link {
|
||||
color: #007bff;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.pagination .page-link:hover {
|
||||
color: #0056b3;
|
||||
background-color: #e9ecef;
|
||||
border-color: #dee2e6;
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
@media (max-width: 768px) {
|
||||
.table-controls .row > div {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.table-pagination .row > div {
|
||||
text-align: center !important;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
justify-content: center !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-sm-flex justify-content-between">
|
||||
<div>
|
||||
<slot name="lieux-new-action"></slot>
|
||||
</div>
|
||||
<div class="d-flex">
|
||||
<div class="dropdown d-inline">
|
||||
<slot name="select-filter"></slot>
|
||||
</div>
|
||||
<slot name="lieux-other-action"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card mt-4">
|
||||
<slot name="lieux-table"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script></script>
|
||||
@ -65,7 +65,7 @@
|
||||
<sidenav-item
|
||||
:to="{ name: 'Localisation clients' }"
|
||||
mini-icon="A"
|
||||
text="Localisation clients"
|
||||
text="Gestion des lieux"
|
||||
/>
|
||||
</template>
|
||||
</sidenav-collapse-item>
|
||||
|
||||
@ -1,3 +1,24 @@
|
||||
<template>
|
||||
<h1>GECTION localisation clients</h1>
|
||||
<liste-lieux-presentation
|
||||
:is-loading="clientLocationStore.isLoading"
|
||||
:location-data="clientLocationStore.clientLocations"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import ListeLieuxPresentation from "@/components/Organism/CRM/lieux/ListeLieuxPresentation.vue";
|
||||
import { useClientLocationStore } from "@/stores/clientLocation";
|
||||
import { onMounted, ref } from "vue";
|
||||
|
||||
const clientLocationStore = useClientLocationStore();
|
||||
|
||||
const filters = ref({
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
search: "",
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await clientLocationStore.fetchClientLocations();
|
||||
console.log(clientLocationStore.clientLocations);
|
||||
});
|
||||
</script>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user