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>