feat(front) : page répertoire clients + datatable
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m47s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m15s

- Page /clients (route à plat) : MalioDataTable 6 colonnes (contact, téléphone
  formaté, codes catégories, badges sites), toggle « Voir les archivés » (état
  local), boutons Ajouter (manage) / Exporter (view, download xlsx), clic ligne
  vers le détail, empty state.
- Composable useClientsRepository (wrapper de usePaginatedList) + util
  formatPhoneFR + clé i18n showArchived.
- Contrat back : la liste client:read expose désormais les codes catégories
  (category:read) et les sites agrégés des adresses (site:read + Client::getSites) ;
  jointures anti N+1 dans createListQueryBuilder. Tests back + front.
This commit is contained in:
2026-06-02 11:17:22 +02:00
parent a5af1e6108
commit 9ca9cb1d42
9 changed files with 519 additions and 5 deletions
@@ -0,0 +1,211 @@
<template>
<div>
<PageHeader>
{{ t('commercial.clients.title') }}
<template #actions>
<MalioButton
v-if="canView"
variant="secondary"
:label="t('commercial.clients.export')"
icon-name="mdi:file-export-outline"
icon-position="left"
:disabled="exporting"
@click="exportXlsx"
/>
<MalioButton
v-if="canManage"
:label="t('commercial.clients.add')"
icon-name="mdi:add-bold"
icon-position="left"
@click="goToCreate"
/>
</template>
</PageHeader>
<!-- Toggle « Voir les archivés » : etat 100 % local (regle ABSOLUE n°6),
desactive par defaut (clients actifs uniquement). -->
<div class="mb-4">
<MalioCheckbox
id="clients-include-archived"
:label="t('commercial.clients.showArchived')"
:model-value="includeArchived"
@update:model-value="onToggleArchived"
/>
</div>
<!-- Datatable branchee sur usePaginatedList via useClientsRepository :
pagination serveur, tri companyName ASC par defaut (cote back). -->
<MalioDataTable
:columns="columns"
:items="rows"
:total-items="totalItems"
:page="currentPage"
:per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions"
row-clickable
:empty-message="t('commercial.clients.empty')"
@row-click="onRowClick"
@update:page="goToPage"
@update:per-page="setItemsPerPage"
>
<!-- Contact principal : prenom + nom (l'un des deux peut etre vide). -->
<template #cell-contact="{ item }">
{{ formatContact(item) }}
</template>
<!-- Telephone principal formate XX XX XX XX XX (ERP-66). -->
<template #cell-phone="{ item }">
{{ formatPhoneFR(item.phonePrimary as string | null) }}
</template>
<!-- Categories : codes stables separes par une virgule (ERP-78). -->
<template #cell-categories="{ item }">
{{ formatCategories(item) }}
</template>
<!-- Sites : badges colores (name + color), agreges des adresses. -->
<template #cell-sites="{ item }">
<span class="flex flex-wrap gap-1">
<span
v-for="site in (item.sites as ClientSite[])"
:key="site.id"
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium text-white"
:style="{ backgroundColor: site.color }"
>
{{ site.name }}
</span>
</span>
</template>
</MalioDataTable>
</div>
</template>
<script setup lang="ts">
import type { Client, ClientSite } from '~/modules/commercial/composables/useClientsRepository'
const { t } = useI18n()
const api = useApi()
const router = useRouter()
const toast = useToast()
const { can } = usePermissions()
useHead({ title: t('commercial.clients.title') })
// Bouton « + Ajouter » reserve a `manage` (POST /clients garde manage seul →
// Compta / Usine ne creent pas). « Exporter » suit `view`.
const canManage = computed(() => can('commercial.clients.manage'))
const canView = computed(() => can('commercial.clients.view'))
const {
items: clients,
totalItems,
currentPage,
itemsPerPage,
itemsPerPageOptions,
includeArchived,
fetch: loadClients,
goToPage,
setItemsPerPage,
setIncludeArchived,
} = useClientsRepository()
// Mappe les clients en objets « plats » pour MalioDataTable (items typees
// Record<string, unknown>[]) : un objet litteral porte une signature d'index
// implicite, contrairement a l'interface Client. Meme pattern que sites.vue.
const rows = computed(() => clients.value.map(client => ({
id: client.id,
companyName: client.companyName,
firstName: client.firstName,
lastName: client.lastName,
phonePrimary: client.phonePrimary,
email: client.email,
categories: client.categories,
sites: client.sites,
})))
const columns = [
{ key: 'companyName', label: t('commercial.clients.column.companyName') },
{ key: 'contact', label: t('commercial.clients.column.contact') },
{ key: 'phone', label: t('commercial.clients.column.phone') },
{ key: 'email', label: t('commercial.clients.column.email') },
{ key: 'categories', label: t('commercial.clients.column.categories') },
{ key: 'sites', label: t('commercial.clients.column.sites') },
]
/** Contact principal : « Prenom Nom » en ignorant les parties vides. */
function formatContact(item: Record<string, unknown>): string {
return [item.firstName, item.lastName].filter(Boolean).join(' ')
}
/** Codes des categories du client, separes par une virgule (ERP-78). */
function formatCategories(item: Record<string, unknown>): string {
const categories = (item.categories as Client['categories']) ?? []
return categories.map(c => c.code).join(', ')
}
/** Clic sur une ligne → ecran Consultation (route a plat /clients/{id}). */
function onRowClick(item: Record<string, unknown>): void {
router.push(`/clients/${item.id}`)
}
function goToCreate(): void {
router.push('/clients/new')
}
function onToggleArchived(value: boolean): void {
setIncludeArchived(value)
}
// Export XLSX : memes filtres que la vue (includeArchived). La colonne SIREN
// n'est dans le fichier que si l'utilisateur a accounting.view (gere cote back).
const exporting = ref(false)
async function exportXlsx(): Promise<void> {
if (exporting.value) {
return
}
exporting.value = true
try {
const query: Record<string, unknown> = {}
if (includeArchived.value) {
query.includeArchived = true
}
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
// contenu faute d'overload blob sur le client partage — a generaliser via
// un ticket dedie si d'autres exports binaires arrivent.
const blob = await api.get<Blob>('/clients/export.xlsx', query, {
responseType: 'blob',
toast: false,
} as unknown as Parameters<typeof api.get>[2])
triggerDownload(blob, 'repertoire-clients.xlsx')
}
catch {
toast.error({
title: t('commercial.clients.toast.error'),
message: t('commercial.clients.toast.error'),
})
}
finally {
exporting.value = false
}
}
/** Declenche le telechargement d'un blob via un lien temporaire. */
function triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
onMounted(() => {
loadClients()
})
</script>