feat(admin) : filtres + pagination serveur sur /admin/users/sites/roles

Ajoute le filtrage par colonne et la pagination negociee via query params
sur les 3 DataTables admin existantes. Tout est cote serveur (API Platform
SearchFilter + BooleanFilter) pour scaler naturellement.

Backend :
- api_platform.yaml : scan du mapping Sites + pagination_client_items_per_page
  (avec borne max 100 pour proteger contre les payloads exagerement grands).
- User : SearchFilter username (partial), rbacRoles.code (exact),
  sites.name (exact) + BooleanFilter isAdmin.
- Site : SearchFilter name/city/postalCode (partial).
- Role : SearchFilter label/code (partial), permissions.code (exact).
  (BooleanFilter isSystem deja present.)

Frontend :
- Composable useDataTableServerState (shared) : singleton de page/perPage/
  filters avec debounce 300ms sur les filters, fetch immediat sur page/
  perPage, reset page=1 au changement filter, token anti-race-condition.
- Pages admin : chaque filtre dans un slot #header-{key} (input text avec
  debounce, select mono-selection pour les relations). Font-size 20px sur
  les inputs de filtre.
- /admin/users : colonne Sites + filtre Sites conditionnes par
  useModules().isModuleActive('sites') — preserve l'invariant "module
  desactivable sans casse".

Tests : 215/215 PHPUnit (14 nouveaux filtres/pagination) + 48/48 Vitest
(8 nouveaux useDataTableServerState).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 17:00:34 +02:00
parent 296befe187
commit cb6d2d72ec
10 changed files with 875 additions and 88 deletions

View File

@@ -14,16 +14,68 @@
/>
</div>
<!-- Table des roles -->
<!-- Table des roles avec filtres + pagination -->
<MalioDataTable
v-model:page="page"
v-model:per-page="perPage"
class="mt-6"
:columns="columns"
:items="roleItems"
:total-items="roles.length"
:total-items="totalItems"
:row-clickable="canManage"
:empty-message="t('admin.roles.noRoles')"
@row-click="onRowClick"
>
<template #header-label>
<input
v-model="filters.label"
type="text"
:placeholder="t('admin.roles.table.label')"
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
>
</template>
<template #header-code>
<input
v-model="filters.code"
type="text"
:placeholder="t('admin.roles.table.code')"
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
>
</template>
<template #header-permissions>
<select
v-model="filters['permissions.code']"
class="w-full appearance-none border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
>
<option value="">
{{ t('admin.roles.table.permissions') }}
</option>
<option
v-for="perm in allPermissions"
:key="perm.id"
:value="perm.code"
>
{{ perm.code }}
</option>
</select>
</template>
<template #header-system>
<select
v-model="filters.isSystem"
class="w-full appearance-none border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
>
<option value="">
{{ t('admin.roles.table.system') }}
</option>
<option value="true">
{{ t('common.yes') }}
</option>
<option value="false">
{{ t('common.no') }}
</option>
</select>
</template>
<template #cell-code="{ item }">
<span class="font-mono text-xs">{{ item.code }}</span>
</template>
@@ -59,7 +111,7 @@
</template>
<script setup lang="ts">
import type { Role } from '~/shared/types/rbac'
import type { Permission, Role } from '~/shared/types/rbac'
const { t } = useI18n()
const api = useApi()
@@ -68,8 +120,39 @@ const canManage = computed(() => can('core.roles.manage'))
useHead({ title: t('admin.roles.title') })
const roles = ref<Role[]>([])
const loading = ref(false)
// Etat DataTable centralise : pagination serveur + filtres debounces.
// `isSystem` est une string ('true'/'false'/'') plutot qu'un bool : les
// <select> HTML travaillent en string et API Platform BooleanFilter
// accepte les strings 'true'/'false' telles quelles.
const {
items,
totalItems,
page,
perPage,
filters,
reload,
} = useDataTableServerState<Role>('/roles', {
label: '',
code: '',
isSystem: '',
'permissions.code': '',
})
// Chargement one-shot des permissions pour alimenter le select filter.
// Independant du composable de table : cette liste ne bouge pas pendant
// la session admin.
const allPermissions = ref<Permission[]>([])
async function loadPermissions(): Promise<void> {
const data = await api.get<{ member: Permission[] }>(
'/permissions',
{ itemsPerPage: 999, orphan: false },
{ toast: false },
)
allPermissions.value = (data.member ?? []).sort(
(a, b) => a.code.localeCompare(b.code),
)
}
const columns = [
{ key: 'label', label: t('admin.roles.table.label') },
@@ -80,45 +163,31 @@ const columns = [
// Transformer les roles en items compatibles MalioDataTable
const roleItems = computed(() =>
roles.value.map(role => ({
items.value.map(role => ({
id: role.id,
label: role.label,
code: role.code,
permissions: role.permissions.length,
isSystem: role.isSystem,
system: '', // colonne geree par le slot
}))
})),
)
function getRoleById(id: number): Role | undefined {
return roles.value.find(r => r.id === id)
return items.value.find(r => r.id === id)
}
function onRowClick(item: Record<string, unknown>) {
const role = getRoleById(item.id as number)
if (role) openEditDrawer(role)
}
const drawerOpen = ref(false)
const selectedRole = ref<Role | null>(null)
const deleteModalOpen = ref(false)
const roleToDelete = ref<Role | null>(null)
const deleting = ref(false)
// Charger la liste des roles
async function loadRoles() {
loading.value = true
try {
const data = await api.get<{ member: Role[] }>(
'/roles',
{},
{ toast: false },
)
roles.value = data.member
} finally {
loading.value = false
}
}
function openCreateDrawer() {
selectedRole.value = null
drawerOpen.value = true
@@ -145,17 +214,18 @@ async function handleDelete() {
deleteModalOpen.value = false
roleToDelete.value = null
drawerOpen.value = false
await loadRoles()
reload()
} finally {
deleting.value = false
}
}
function onRoleSaved() {
loadRoles()
reload()
}
onMounted(() => {
loadRoles()
loadPermissions()
reload()
})
</script>

View File

@@ -7,16 +7,77 @@
</h1>
</div>
<!-- Table des utilisateurs -->
<!-- Table des utilisateurs avec filtres + pagination -->
<MalioDataTable
v-model:page="page"
v-model:per-page="perPage"
class="mt-6"
:columns="columns"
:items="userItems"
:total-items="users.length"
:total-items="totalItems"
:row-clickable="canManage"
:empty-message="t('admin.users.noUsers')"
@row-click="onRowClick"
>
<template #header-username>
<input
v-model="filters.username"
type="text"
:placeholder="t('admin.users.table.username')"
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
>
</template>
<template #header-admin>
<select
v-model="filters.isAdmin"
class="w-full appearance-none border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
>
<option value="">
{{ t('admin.users.table.admin') }}
</option>
<option value="true">
{{ t('common.yes') }}
</option>
<option value="false">
{{ t('common.no') }}
</option>
</select>
</template>
<template #header-roles>
<select
v-model="filters['rbacRoles.code']"
class="w-full appearance-none border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
>
<option value="">
{{ t('admin.users.table.roles') }}
</option>
<option
v-for="role in allRoles"
:key="role.id"
:value="role.code"
>
{{ role.label }}
</option>
</select>
</template>
<template v-if="sitesModuleActive" #header-sites>
<select
v-model="filters['sites.name']"
class="w-full appearance-none border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
>
<option value="">
{{ t('admin.users.table.sites') }}
</option>
<option
v-for="site in allSites"
:key="site.id"
:value="site.name"
>
{{ site.name }}
</option>
</select>
</template>
<template #cell-admin="{ item }">
<span
v-if="item.admin"
@@ -37,30 +98,77 @@
</template>
<script setup lang="ts">
import type { UserListItem } from '~/shared/types/rbac'
import type { Role, UserListItem } from '~/shared/types/rbac'
import type { Site } from '~/shared/types/sites'
const { t } = useI18n()
const api = useApi()
const { can } = usePermissions()
const { isModuleActive } = useModules()
useHead({ title: t('admin.users.title') })
const canManage = computed(() => can('core.users.manage'))
// Conditionne la colonne Sites + le filtre Sites : si le module Sites
// est desactive, inutile de charger /api/sites ni d'afficher ces elements.
// L'invariant "module inactif = app fonctionnelle" est preserve.
const sitesModuleActive = computed(() => isModuleActive('sites'))
const users = ref<UserListItem[]>([])
// Etat DataTable centralise. On declare le filtre sites.name meme si le
// module Sites est inactif : le composable omet les filtres a valeur
// vide donc ca ne produit aucun impact cote API, et ca evite de casser
// la forme du state si le module est reactive sans reloader la page.
const {
items,
totalItems,
page,
perPage,
filters,
reload,
} = useDataTableServerState<UserListItem>('/users', {
username: '',
isAdmin: '',
'rbacRoles.code': '',
'sites.name': '',
})
const allRoles = ref<Role[]>([])
const allSites = ref<Site[]>([])
const sitesById = ref(new Map<number, Site>())
const loading = ref(false)
const drawerOpen = ref(false)
const selectedUser = ref<UserListItem | null>(null)
const columns = [
{ key: 'username', label: t('admin.users.table.username') },
{ key: 'admin', label: t('admin.users.table.admin') },
{ key: 'roles', label: t('admin.users.table.roles') },
{ key: 'directPermissions', label: t('admin.users.table.directPermissions') },
{ key: 'sites', label: t('admin.users.table.sites') },
]
async function loadFilterOptions(): Promise<void> {
const rolesPromise = api.get<{ member: Role[] }>(
'/roles',
{ itemsPerPage: 999 },
{ toast: false },
)
// /api/sites est protege par `sites.view`. On skip si module off pour
// eviter un 403 inutile dans la console devtools — la UI ne consomme
// pas le resultat dans ce cas.
const sitesPromise = sitesModuleActive.value
? api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: false })
: Promise.resolve({ member: [] as Site[] })
const [rolesData, sitesData] = await Promise.all([rolesPromise, sitesPromise])
allRoles.value = rolesData.member ?? []
allSites.value = sitesData.member ?? []
sitesById.value = new Map(allSites.value.map(s => [s.id, s]))
}
// Colonnes dynamiques : on omet la colonne Sites si le module est off.
const columns = computed(() => {
const base = [
{ key: 'username', label: t('admin.users.table.username') },
{ key: 'admin', label: t('admin.users.table.admin') },
{ key: 'roles', label: t('admin.users.table.roles') },
{ key: 'directPermissions', label: t('admin.users.table.directPermissions') },
]
if (sitesModuleActive.value) {
base.push({ key: 'sites', label: t('admin.users.table.sites') })
}
return base
})
// Extraire l'id numerique depuis une IRI API Platform type `/api/sites/3`.
function iriToId(iri: string): number {
@@ -68,7 +176,7 @@ function iriToId(iri: string): number {
}
const userItems = computed(() =>
users.value.map(user => ({
items.value.map(user => ({
id: user.id,
username: user.username,
admin: user.isAdmin,
@@ -76,7 +184,7 @@ const userItems = computed(() =>
directPermissions: user.directPermissions.length,
// Affichage : liste des noms de sites separes par virgule. Les IRIs
// du payload /api/users (groupe user:list) sont resolues via la Map
// construite en parallele depuis /api/sites.
// construite par loadFilterOptions. Vide si module Sites off.
sites: (user.sites ?? [])
.map(iri => sitesById.value.get(iriToId(iri))?.name)
.filter((name): name is string => Boolean(name))
@@ -84,24 +192,11 @@ const userItems = computed(() =>
})),
)
async function loadUsers() {
loading.value = true
try {
// Chargement parallele : les sites alimentent la Map de resolution
// IRI→name pour la colonne "Sites" de la table.
const [usersData, sitesData] = await Promise.all([
api.get<{ member: UserListItem[] }>('/users', {}, { toast: false }),
api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: false }),
])
users.value = usersData.member
sitesById.value = new Map(sitesData.member.map(s => [s.id, s]))
} finally {
loading.value = false
}
}
const drawerOpen = ref(false)
const selectedUser = ref<UserListItem | null>(null)
function getUserById(id: number): UserListItem | undefined {
return users.value.find(u => u.id === id)
return items.value.find(u => u.id === id)
}
function openDrawer(user: UserListItem) {
@@ -115,10 +210,11 @@ function onRowClick(item: Record<string, unknown>) {
}
function onUserSaved() {
loadUsers()
reload()
}
onMounted(() => {
loadUsers()
loadFilterOptions()
reload()
})
</script>

View File

@@ -14,16 +14,43 @@
/>
</div>
<!-- Table des sites -->
<!-- Table des sites avec filtres + pagination -->
<MalioDataTable
v-model:page="page"
v-model:per-page="perPage"
class="mt-6"
:columns="columns"
:items="siteItems"
:total-items="sites.length"
:total-items="totalItems"
:row-clickable="canManage"
:empty-message="t('admin.sites.noSites')"
@row-click="onRowClick"
>
<template #header-name>
<input
v-model="filters.name"
type="text"
:placeholder="t('admin.sites.table.name')"
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
>
</template>
<template #header-city>
<input
v-model="filters.city"
type="text"
:placeholder="t('admin.sites.table.city')"
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
>
</template>
<template #header-postalCode>
<input
v-model="filters.postalCode"
type="text"
:placeholder="t('admin.sites.table.postalCode')"
class="w-full border-0 border-b border-black bg-transparent px-0 py-1 text-xl outline-none"
>
</template>
<template #cell-color="{ item }">
<span class="inline-flex items-center gap-2">
<span
@@ -68,8 +95,20 @@ const canManage = computed(() => can('sites.manage'))
useHead({ title: t('admin.sites.title') })
const sites = ref<Site[]>([])
const loading = ref(false)
// Etat DataTable centralise : pagination serveur + filtres debounces.
// Les filtres name/city/postalCode sont des partiels SearchFilter cote API.
const {
items,
totalItems,
page,
perPage,
filters,
reload,
} = useDataTableServerState<Site>('/sites', {
name: '',
city: '',
postalCode: '',
})
const columns = [
{ key: 'name', label: t('admin.sites.table.name') },
@@ -83,7 +122,7 @@ const columns = [
// `fullAddress` provient du getter computed cote backend (Site::getFullAddress)
// au format multi-lignes — on l'aplatit en virgules pour l'affichage table.
const siteItems = computed(() =>
sites.value.map(site => ({
items.value.map(site => ({
id: site.id,
name: site.name,
city: site.city,
@@ -94,7 +133,7 @@ const siteItems = computed(() =>
)
function getSiteById(id: number): Site | undefined {
return sites.value.find(s => s.id === id)
return items.value.find(s => s.id === id)
}
function onRowClick(item: Record<string, unknown>) {
@@ -108,20 +147,6 @@ const deleteModalOpen = ref(false)
const siteToDelete = ref<Site | null>(null)
const deleting = ref(false)
async function loadSites() {
loading.value = true
try {
const data = await api.get<{ member: Site[] }>(
'/sites',
{ itemsPerPage: 999 },
{ toast: false },
)
sites.value = data.member
} finally {
loading.value = false
}
}
function openCreateDrawer() {
selectedSite.value = null
drawerOpen.value = true
@@ -148,17 +173,17 @@ async function handleDelete() {
deleteModalOpen.value = false
siteToDelete.value = null
drawerOpen.value = false
await loadSites()
reload()
} finally {
deleting.value = false
}
}
function onSiteSaved() {
loadSites()
reload()
}
onMounted(() => {
loadSites()
reload()
})
</script>