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\StoreClientLocationRequest;
|
||||||
use App\Http\Requests\UpdateClientLocationRequest;
|
use App\Http\Requests\UpdateClientLocationRequest;
|
||||||
use App\Http\Resources\Client\ClientLocationResource;
|
use App\Http\Resources\Client\ClientLocationResource;
|
||||||
|
use App\Http\Resources\Client\ClientLocationCollection;
|
||||||
use App\Repositories\ClientLocationRepositoryInterface;
|
use App\Repositories\ClientLocationRepositoryInterface;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class ClientLocationController extends Controller
|
class ClientLocationController extends Controller
|
||||||
{
|
{
|
||||||
@ -23,11 +25,14 @@ class ClientLocationController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Display a listing of client locations.
|
* Display a listing of client locations.
|
||||||
*/
|
*/
|
||||||
public function index(): AnonymousResourceCollection|JsonResponse
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$clientLocations = $this->clientLocationRepository->all();
|
$filters = $request->only(['client_id', 'is_default', 'search']);
|
||||||
return ClientLocationResource::collection($clientLocations);
|
$perPage = $request->get('per_page', 10);
|
||||||
|
|
||||||
|
$clientLocations = $this->clientLocationRepository->getPaginated($filters, $perPage);
|
||||||
|
return new ClientLocationCollection($clientLocations);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('Error fetching client locations: ' . $e->getMessage(), [
|
Log::error('Error fetching client locations: ' . $e->getMessage(), [
|
||||||
'exception' => $e,
|
'exception' => $e,
|
||||||
|
|||||||
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Repositories;
|
namespace App\Repositories;
|
||||||
|
|
||||||
use App\Models\ClientLocation;
|
use App\Models\ClientLocation;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
class ClientLocationRepository extends BaseRepository implements ClientLocationRepositoryInterface
|
class ClientLocationRepository extends BaseRepository implements ClientLocationRepositoryInterface
|
||||||
{
|
{
|
||||||
@ -19,4 +20,93 @@ class ClientLocationRepository extends BaseRepository implements ClientLocationR
|
|||||||
$query->where('client_id', $client_id);
|
$query->where('client_id', $client_id);
|
||||||
return $query->get();
|
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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Repositories;
|
namespace App\Repositories;
|
||||||
|
use App\Models\ClientLocation;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
interface ClientLocationRepositoryInterface extends BaseRepositoryInterface
|
interface ClientLocationRepositoryInterface extends BaseRepositoryInterface
|
||||||
{
|
{
|
||||||
function getByClientId(int $client_id);
|
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
|
<sidenav-item
|
||||||
:to="{ name: 'Localisation clients' }"
|
:to="{ name: 'Localisation clients' }"
|
||||||
mini-icon="A"
|
mini-icon="A"
|
||||||
text="Localisation clients"
|
text="Gestion des lieux"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</sidenav-collapse-item>
|
</sidenav-collapse-item>
|
||||||
|
|||||||
@ -1,3 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<h1>GECTION localisation clients</h1>
|
<liste-lieux-presentation
|
||||||
|
:is-loading="clientLocationStore.isLoading"
|
||||||
|
:location-data="clientLocationStore.clientLocations"
|
||||||
|
/>
|
||||||
</template>
|
</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