Files
Coltura/frontend/modules/core/pages/admin/users.vue
tristan cb6d2d72ec 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>
2026-04-20 17:00:34 +02:00

221 lines
7.5 KiB
Vue

<template>
<div>
<!-- En-tete -->
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
{{ t('admin.users.title') }}
</h1>
</div>
<!-- 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="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"
class="inline-flex items-center rounded-full bg-purple-100 px-2.5 py-0.5 text-xs font-medium text-purple-800"
>
{{ t('admin.users.table.admin') }}
</span>
</template>
</MalioDataTable>
<!-- Drawer RBAC -->
<UserRbacDrawer
v-model="drawerOpen"
:user="selectedUser"
@saved="onUserSaved"
/>
</div>
</template>
<script setup lang="ts">
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'))
// 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>())
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 {
return Number(iri.split('/').pop())
}
const userItems = computed(() =>
items.value.map(user => ({
id: user.id,
username: user.username,
admin: user.isAdmin,
roles: user.roles.length,
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 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))
.join(', '),
})),
)
const drawerOpen = ref(false)
const selectedUser = ref<UserListItem | null>(null)
function getUserById(id: number): UserListItem | undefined {
return items.value.find(u => u.id === id)
}
function openDrawer(user: UserListItem) {
selectedUser.value = user
drawerOpen.value = true
}
function onRowClick(item: Record<string, unknown>) {
const user = getUserById(item.id as number)
if (user) openDrawer(user)
}
function onUserSaved() {
reload()
}
onMounted(() => {
loadFilterOptions()
reload()
})
</script>