dynamic table

This commit is contained in:
Nyavokevin 2025-10-24 10:57:52 +03:00
parent ea2b687533
commit 425d2d510c
8 changed files with 1003 additions and 5 deletions

View File

@ -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,

View File

@ -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();
}
}

View File

@ -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;
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -65,7 +65,7 @@
<sidenav-item
:to="{ name: 'Localisation clients' }"
mini-icon="A"
text="Localisation clients"
text="Gestion des lieux"
/>
</template>
</sidenav-collapse-item>

View File

@ -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>