1ab2eeccca
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).
461 lines
17 KiB
Vue
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>
|