Files
Lesstime/frontend/modules/directory/pages/directory/index.vue
T
Matthieu 1ab2eeccca
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 42s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m1s
fix(rbac) : rattache le rôle de base « user » et gate le frontend par permission
Un user avec des permissions sur le rôle RBAC « user » ne voyait rien : le
ROLE_USER legacy n'a aucun lien avec le RBAC et getEffectivePermissions() ne lit
que rbacRoles + permissions directes, alors qu'aucun user n'était rattaché au
rôle « user » (table user_role vide, jamais backfillée).

Backend
- DefaultUserRoleAssigner + UserDefaultRoleListener (prePersist) : tout nouvel
  utilisateur est rattaché au rôle « user » sur tous les chemins de persistance.
- Commande app:assign-default-roles (backfill idempotent) + ajout au deploy.sh.
- AppFixtures : seed des rôles système avant la création des users.

Frontend (gating par permission au lieu de ROLE_ADMIN legacy)
- Nouveau middleware « permission » + augmentation PageMeta : definePageMeta
  ({ permission }) (string = requise, array = any), ROLE_ADMIN bypasse.
- Pages directory/reporting/admin gatées par permission ; SidebarFilter accepte
  une liste de permissions (any) ; section admin sans gate de rôle.
- team-absences reste en ROLE_ADMIN (module Absence non RBAC-isé côté backend).
2026-06-29 10:15:46 +02:00

461 lines
17 KiB
Vue

<template>
<div>
<PageHeader>
{{ $t('directory.title') }}
</PageHeader>
<div class="flex flex-col gap-6">
<MalioTabList v-model="activeTab" :tabs="tabs">
<!-- Clients -->
<template #clients>
<div class="flex min-h-[30rem] flex-col gap-4 pt-10">
<div class="flex min-h-[48px] items-center justify-end">
<MalioButton
icon-name="mdi:plus"
icon-position="left"
:label="$t('common.add')"
@click="openCreateClient"
/>
</div>
<MalioDataTable
:columns="clientColumns"
:items="clients"
:total-items="clients.length"
:empty-message="$t('directory.clients.empty')"
@row-click="openEditClient"
>
<template #header-actions>
<span class="block text-right font-semibold text-black">{{ $t('common.actions') }}</span>
</template>
<template #cell-email="{ item }">
{{ (item as Client).email ?? '—' }}
</template>
<template #cell-phone="{ item }">
{{ (item as Client).phone ?? '—' }}
</template>
<template #cell-actions="{ item }">
<div class="flex justify-end" @click.stop>
<MalioButtonIcon
icon="mdi:trash-can-outline"
:aria-label="$t('common.delete')"
button-class="!bg-red-100 !text-red-700 hover:!bg-red-200"
:icon-size="18"
@click="askDeleteClient(item as Client)"
/>
</div>
</template>
</MalioDataTable>
</div>
</template>
<!-- Prospects -->
<template #prospects>
<div class="flex min-h-[30rem] flex-col gap-4 pt-10">
<div class="flex min-h-[48px] flex-wrap items-center justify-between gap-3">
<MalioSelect
v-model="statusFilter"
:label="$t('prospects.fields.status')"
:options="statusOptions"
:empty-option-label="$t('directory.prospects.allStatuses')"
group-class="w-48"
/>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
:label="$t('common.add')"
@click="openCreateProspect"
/>
</div>
<MalioDataTable
:columns="prospectColumns"
:items="prospectRows"
:total-items="prospectRows.length"
:empty-message="$t('directory.prospects.empty')"
@row-click="openEditProspect"
>
<template #header-actions>
<span class="block text-right font-semibold text-black">{{ $t('common.actions') }}</span>
</template>
<template #cell-status="{ item }">
<StatusBadge
:label="statusLabel((item as ProspectRow).status)"
:variant="statusVariant((item as ProspectRow).status)"
/>
</template>
<template #cell-email="{ item }">
{{ (item as ProspectRow).email ?? '—' }}
</template>
<template #cell-phone="{ item }">
{{ (item as ProspectRow).phone ?? '—' }}
</template>
<template #cell-actions="{ item }">
<div class="flex justify-end gap-2" @click.stop>
<MalioButtonIcon
v-if="!(item as ProspectRow).convertedClient"
icon="mdi:account-convert"
:aria-label="$t('prospects.convert')"
button-class="!bg-green-100 !text-green-700 hover:!bg-green-200"
:icon-size="18"
@click="askConvertProspect(item as ProspectRow)"
/>
<MalioButtonIcon
icon="mdi:trash-can-outline"
:aria-label="$t('common.delete')"
button-class="!bg-red-100 !text-red-700 hover:!bg-red-200"
:icon-size="18"
@click="askDeleteProspect(item as ProspectRow)"
/>
</div>
</template>
</MalioDataTable>
</div>
</template>
<!-- Prestataires -->
<template #prestataires>
<div class="flex min-h-[30rem] flex-col gap-4 pt-10">
<div class="flex min-h-[48px] items-center justify-end">
<MalioButton
icon-name="mdi:plus"
icon-position="left"
:label="$t('common.add')"
@click="openCreatePrestataire"
/>
</div>
<MalioDataTable
:columns="prestataireColumns"
:items="prestataires"
:total-items="prestataires.length"
:empty-message="$t('directory.prestataires.empty')"
@row-click="openEditPrestataire"
>
<template #header-actions>
<span class="block text-right font-semibold text-black">{{ $t('common.actions') }}</span>
</template>
<template #cell-email="{ item }">
{{ (item as Prestataire).email ?? '—' }}
</template>
<template #cell-phone="{ item }">
{{ (item as Prestataire).phone ?? '—' }}
</template>
<template #cell-actions="{ item }">
<div class="flex justify-end" @click.stop>
<MalioButtonIcon
icon="mdi:trash-can-outline"
:aria-label="$t('common.delete')"
button-class="!bg-red-100 !text-red-700 hover:!bg-red-200"
:icon-size="18"
@click="askDeletePrestataire(item as Prestataire)"
/>
</div>
</template>
</MalioDataTable>
</div>
</template>
</MalioTabList>
<ClientDrawer
v-model="clientDrawerOpen"
:client="selectedClient"
@saved="loadClients"
/>
<ProspectDrawer
v-model="prospectDrawerOpen"
:prospect="selectedProspect"
@saved="onProspectSaved"
/>
<PrestataireDrawer
v-model="prestataireDrawerOpen"
:prestataire="selectedPrestataire"
@saved="loadPrestataires"
/>
<ConfirmModal
v-model="deleteModalOpen"
:title="deleteModalTitle"
@confirm="confirmDelete"
>
<i18n-t :keypath="deleteModalKeypath" tag="p" scope="global">
<template #name>
<strong class="font-semibold">{{ deleteTargetName }}</strong>
</template>
</i18n-t>
</ConfirmModal>
<ConfirmModal
v-model="convertModalOpen"
:title="$t('prospects.convertConfirmTitle')"
:confirm-label="$t('prospects.convertConfirm')"
confirm-variant="primary"
@confirm="confirmConvert"
>
<i18n-t keypath="prospects.convertConfirmMessage" tag="p" scope="global">
<template #name>
<strong class="font-semibold">{{ convertTarget?.company }}</strong>
</template>
</i18n-t>
</ConfirmModal>
</div>
</div>
</template>
<script setup lang="ts">
import type { Client } from '~/modules/directory/services/dto/client'
import { useClientService } from '~/modules/directory/services/clients'
import type { Prospect, ProspectStatus } from '~/modules/directory/services/dto/prospect'
import { useProspectService } from '~/modules/directory/services/prospects'
import type { Prestataire } from '~/modules/directory/services/dto/prestataire'
import { usePrestataireService } from '~/modules/directory/services/prestataires'
import { readHistoryTab, stampHistoryTab } from '~/utils/historyTab'
definePageMeta({ middleware: ['permission'], permission: ['directory.clients.view', 'directory.prospects.view', 'directory.providers.view'] })
type ProspectRow = Prospect
const { t } = useI18n()
useHead({ title: t('directory.title') })
const clientService = useClientService()
const prospectService = useProspectService()
const prestataireService = usePrestataireService()
const activeTab = ref('clients')
const tabs = [
{ key: 'clients', label: t('directory.tabs.clients'), icon: 'mdi:account-tie-outline' },
{ key: 'prospects', label: t('directory.tabs.prospects'), icon: 'mdi:account-search-outline' },
{ key: 'prestataires', label: t('directory.tabs.prestataires'), icon: 'mdi:account-hard-hat-outline' },
]
const tabKeys = tabs.map((tab) => tab.key)
// Avant d'ouvrir une fiche : on estampille l'entrée d'historique courante avec
// l'onglet actif → la flèche « précédent » du navigateur restaure le bon onglet.
function navigateToDetail(path: string): void {
stampHistoryTab(activeTab.value)
navigateTo(path)
}
// --- Clients ---
const clients = ref<Client[]>([])
const clientDrawerOpen = ref(false)
const selectedClient = ref<Client | null>(null)
const clientColumns = [
{ key: 'name', label: t('prospects.fields.company') },
{ key: 'email', label: t('prospects.fields.email') },
{ key: 'phone', label: t('prospects.fields.phone') },
{ key: 'actions', label: t('common.actions') },
]
async function loadClients() {
clients.value = await clientService.getAll()
}
function openCreateClient() {
selectedClient.value = null
clientDrawerOpen.value = true
}
function openEditClient(item: Record<string, unknown>) {
navigateToDetail(`/directory/clients/${(item as Client).id}`)
}
// --- Prospects ---
const prospects = ref<Prospect[]>([])
const prospectDrawerOpen = ref(false)
const selectedProspect = ref<Prospect | null>(null)
const statusFilter = ref<ProspectStatus | null>(null)
const statusOptions = [
{ label: t('prospects.status.new'), value: 'new' },
{ label: t('prospects.status.contacted'), value: 'contacted' },
{ label: t('prospects.status.qualified'), value: 'qualified' },
{ label: t('prospects.status.won'), value: 'won' },
{ label: t('prospects.status.lost'), value: 'lost' },
]
const prospectColumns = [
{ key: 'company', label: t('prospects.fields.company') },
{ key: 'status', label: t('prospects.fields.status') },
{ key: 'email', label: t('prospects.fields.email') },
{ key: 'phone', label: t('prospects.fields.phone') },
{ key: 'actions', label: t('common.actions') },
]
const prospectRows = computed<ProspectRow[]>(() => prospects.value)
function statusLabel(status: ProspectStatus): string {
return t(`prospects.status.${status}`)
}
function statusVariant(status: ProspectStatus): 'neutral' | 'info' | 'success' | 'warning' | 'danger' {
switch (status) {
case 'new':
return 'info'
case 'contacted':
return 'warning'
case 'qualified':
return 'neutral'
case 'won':
return 'success'
case 'lost':
return 'danger'
default:
return 'neutral'
}
}
async function loadProspects() {
prospects.value = await prospectService.getAll(statusFilter.value ?? undefined)
}
function openCreateProspect() {
selectedProspect.value = null
prospectDrawerOpen.value = true
}
function openEditProspect(item: Record<string, unknown>) {
navigateToDetail(`/directory/prospects/${(item as Prospect).id}`)
}
// La conversion passe par une modal de confirmation (le prospect devient client).
const convertModalOpen = ref(false)
const convertTarget = ref<ProspectRow | null>(null)
function askConvertProspect(row: ProspectRow) {
convertTarget.value = row
convertModalOpen.value = true
}
async function confirmConvert() {
const row = convertTarget.value
if (!row) return
await prospectService.convert(row.id)
// La conversion crée un client et retire le prospect : rafraîchir les deux listes.
await Promise.all([loadProspects(), loadClients()])
convertModalOpen.value = false
convertTarget.value = null
}
// Le ProspectDrawer porte aussi le bouton « Convertir » : son event 'saved' peut
// donc être une conversion → toujours rafraîchir les deux listes par sécurité.
async function onProspectSaved() {
await Promise.all([loadProspects(), loadClients()])
}
// --- Prestataires ---
const prestataires = ref<Prestataire[]>([])
const prestataireDrawerOpen = ref(false)
const selectedPrestataire = ref<Prestataire | null>(null)
const prestataireColumns = [
{ key: 'name', label: t('prospects.fields.company') },
{ key: 'email', label: t('prospects.fields.email') },
{ key: 'phone', label: t('prospects.fields.phone') },
{ key: 'actions', label: t('common.actions') },
]
async function loadPrestataires() {
prestataires.value = await prestataireService.getAll()
}
function openCreatePrestataire() {
selectedPrestataire.value = null
prestataireDrawerOpen.value = true
}
function openEditPrestataire(item: Record<string, unknown>) {
navigateToDetail(`/directory/prestataires/${(item as Prestataire).id}`)
}
// --- Suppression (clients, prospects & prestataires) ---
type DeleteTarget =
| { type: 'client'; item: Client }
| { type: 'prospect'; item: Prospect }
| { type: 'prestataire'; item: Prestataire }
const deleteModalOpen = ref(false)
const deleteTarget = ref<DeleteTarget | null>(null)
const deleteModalTitle = computed(() => {
switch (deleteTarget.value?.type) {
case 'prospect':
return t('prospects.deleteConfirmTitle')
case 'prestataire':
return t('prestataires.deleteConfirmTitle')
default:
return t('clients.deleteConfirmTitle')
}
})
// Clé i18n du message (le nom y est injecté en gras via <i18n-t> côté template).
const deleteModalKeypath = computed(() => {
switch (deleteTarget.value?.type) {
case 'prospect':
return 'prospects.deleteConfirmMessage'
case 'prestataire':
return 'prestataires.deleteConfirmMessage'
default:
return 'clients.deleteConfirmMessage'
}
})
const deleteTargetName = computed(() => {
const target = deleteTarget.value
if (!target) return ''
return target.type === 'prospect' ? target.item.company : target.item.name
})
function askDeleteClient(item: Client) {
deleteTarget.value = { type: 'client', item }
deleteModalOpen.value = true
}
function askDeleteProspect(item: Prospect) {
deleteTarget.value = { type: 'prospect', item }
deleteModalOpen.value = true
}
function askDeletePrestataire(item: Prestataire) {
deleteTarget.value = { type: 'prestataire', item }
deleteModalOpen.value = true
}
async function confirmDelete() {
const target = deleteTarget.value
if (!target) return
if (target.type === 'client') {
await clientService.remove(target.item.id)
await loadClients()
} else if (target.type === 'prospect') {
await prospectService.remove(target.item.id)
await loadProspects()
} else {
await prestataireService.remove(target.item.id)
await loadPrestataires()
}
deleteModalOpen.value = false
deleteTarget.value = null
}
watch(statusFilter, loadProspects)
onMounted(async () => {
// Restaure l'onglet quitté lors d'un retour depuis une fiche (flèche app ou
// navigateur). `null` (deep link / reload) → onglet Clients par défaut.
activeTab.value = readHistoryTab(tabKeys) ?? 'clients'
await Promise.all([loadClients(), loadProspects(), loadPrestataires()])
})
</script>
<style scoped>
/* MalioTabList (lib) : aère les onglets verticalement (espace haut/bas du texte) */
:deep([role="tab"]) {
padding-top: 0.9rem;
padding-bottom: 0.25rem;
}
</style>