feat(directory) : entête Action, hover, modal de conversion et ajustements UI (LST-72)
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 40s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m9s

- Colonne « Action » avec entête (alignée à droite) sur les 3 tableaux
- Feedback hover sur les boutons d'action (poubelle / convertir)
- Conversion prospect → client passe par une modal de confirmation
- ConfirmModal basé sur MalioModal (design Starseed), remplace ConfirmDeleteModal
- Nom (client/prospect/prestataire) en gras dans les modals via <i18n-t>
- Boutons « Ajouter » : label raccourci + taille standard Malio (180px)
- Barres d'outils à hauteur homogène (48px) : le bouton ne saute plus entre onglets

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 15:43:49 +02:00
parent be079cdbe2
commit 6b65839061
4 changed files with 131 additions and 88 deletions
+4
View File
@@ -424,6 +424,7 @@
"edit": "Modifier",
"delete": "Supprimer",
"add": "Ajouter",
"actions": "Action",
"loading": "Chargement...",
"archived": "Archivé",
"noClient": "Aucun client",
@@ -918,6 +919,9 @@
"editProspect": "Modifier un prospect",
"convert": "Convertir en client",
"alreadyConverted": "Déjà converti en client",
"convertConfirmTitle": "Convertir le prospect",
"convertConfirmMessage": "Êtes-vous sûr de vouloir convertir le prospect « {name} » en client ? Le prospect deviendra un client.",
"convertConfirm": "Convertir",
"deleteConfirmTitle": "Supprimer le prospect",
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer le prospect « {name} » ? Cette action est irréversible.",
"fields": {
@@ -1,58 +0,0 @@
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="cancel" />
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">{{ title }}</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ message }}
</p>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
@click="cancel"
/>
<MalioButton
variant="danger"
:label="$t('common.delete')"
button-class="w-auto px-4"
@click="$emit('confirm')"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
title: string
message: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
}>()
function cancel() {
emit('update:modelValue', false)
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,54 @@
<template>
<MalioModal
:model-value="modelValue"
modal-class="max-w-md"
@update:model-value="$emit('update:modelValue', $event)"
>
<template #header>
<h2 class="text-[24px] font-bold">{{ title }}</h2>
</template>
<!-- Corps : slot par défaut pour permettre du texte enrichi (nom en gras
via <i18n-t>) ; sinon repli sur le message texte simple. -->
<slot>
<p>{{ message }}</p>
</slot>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="cancelLabel ?? $t('common.cancel')"
@click="$emit('update:modelValue', false)"
/>
<MalioButton
:variant="confirmVariant"
button-class="flex-1"
:label="confirmLabel ?? $t('common.delete')"
@click="$emit('confirm')"
/>
</template>
</MalioModal>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
modelValue: boolean
title: string
message?: string
confirmLabel?: string
cancelLabel?: string
confirmVariant?: 'primary' | 'secondary' | 'tertiary' | 'danger'
}>(),
{
message: undefined,
confirmLabel: undefined,
cancelLabel: undefined,
confirmVariant: 'danger',
},
)
defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
}>()
</script>
@@ -9,12 +9,11 @@
<!-- Clients -->
<template #clients>
<div class="flex min-h-[30rem] flex-col gap-4 pt-10">
<div class="flex items-center justify-end">
<div class="flex min-h-[48px] items-center justify-end">
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.clients.add')"
:label="$t('common.add')"
@click="openCreateClient"
/>
</div>
@@ -26,6 +25,9 @@
: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>
@@ -37,7 +39,7 @@
<MalioButtonIcon
icon="mdi:trash-can-outline"
:aria-label="$t('common.delete')"
button-class="!bg-red-100 !text-red-700"
button-class="!bg-red-100 !text-red-700 hover:!bg-red-200"
:icon-size="18"
@click="askDeleteClient(item as Client)"
/>
@@ -50,7 +52,7 @@
<!-- Prospects -->
<template #prospects>
<div class="flex min-h-[30rem] flex-col gap-4 pt-10">
<div class="flex flex-wrap items-end justify-between gap-3">
<div class="flex min-h-[48px] flex-wrap items-center justify-between gap-3">
<MalioSelect
v-model="statusFilter"
:label="$t('prospects.fields.status')"
@@ -61,8 +63,7 @@
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.prospects.add')"
:label="$t('common.add')"
@click="openCreateProspect"
/>
</div>
@@ -74,6 +75,9 @@
: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)"
@@ -92,14 +96,14 @@
v-if="!(item as ProspectRow).convertedClient"
icon="mdi:account-convert"
:aria-label="$t('prospects.convert')"
button-class="!bg-green-100 !text-green-700"
button-class="!bg-green-100 !text-green-700 hover:!bg-green-200"
:icon-size="18"
@click="convertProspect(item as ProspectRow)"
@click="askConvertProspect(item as ProspectRow)"
/>
<MalioButtonIcon
icon="mdi:trash-can-outline"
:aria-label="$t('common.delete')"
button-class="!bg-red-100 !text-red-700"
button-class="!bg-red-100 !text-red-700 hover:!bg-red-200"
:icon-size="18"
@click="askDeleteProspect(item as ProspectRow)"
/>
@@ -111,12 +115,11 @@
<!-- Prestataires -->
<template #prestataires>
<div class="flex min-h-[30rem] flex-col gap-4 pt-10">
<div class="flex items-center justify-end">
<div class="flex min-h-[48px] items-center justify-end">
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.prestataires.add')"
:label="$t('common.add')"
@click="openCreatePrestataire"
/>
</div>
@@ -128,6 +131,9 @@
: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>
@@ -139,7 +145,7 @@
<MalioButtonIcon
icon="mdi:trash-can-outline"
:aria-label="$t('common.delete')"
button-class="!bg-red-100 !text-red-700"
button-class="!bg-red-100 !text-red-700 hover:!bg-red-200"
:icon-size="18"
@click="askDeletePrestataire(item as Prestataire)"
/>
@@ -166,12 +172,31 @@
@saved="loadPrestataires"
/>
<ConfirmDeleteModal
<ConfirmModal
v-model="deleteModalOpen"
:title="deleteModalTitle"
:message="deleteModalMessage"
@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>
@@ -220,7 +245,7 @@ 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: '' },
{ key: 'actions', label: t('common.actions') },
]
async function loadClients() {
@@ -255,7 +280,7 @@ const prospectColumns = [
{ key: 'status', label: t('prospects.fields.status') },
{ key: 'email', label: t('prospects.fields.email') },
{ key: 'phone', label: t('prospects.fields.phone') },
{ key: 'actions', label: '' },
{ key: 'actions', label: t('common.actions') },
]
const prospectRows = computed<ProspectRow[]>(() => prospects.value)
@@ -294,10 +319,23 @@ function openEditProspect(item: Record<string, unknown>) {
navigateToDetail(`/directory/prospects/${(item as Prospect).id}`)
}
async function convertProspect(row: ProspectRow) {
// 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
@@ -315,7 +353,7 @@ 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: '' },
{ key: 'actions', label: t('common.actions') },
]
async function loadPrestataires() {
@@ -351,17 +389,22 @@ const deleteModalTitle = computed(() => {
}
})
const deleteModalMessage = computed(() => {
// 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 ''
switch (target.type) {
case 'prospect':
return t('prospects.deleteConfirmMessage', { name: target.item.company })
case 'prestataire':
return t('prestataires.deleteConfirmMessage', { name: target.item.name })
default:
return t('clients.deleteConfirmMessage', { name: target.item.name })
}
return target.type === 'prospect' ? target.item.company : target.item.name
})
function askDeleteClient(item: Client) {