feat(directory) : entête Action, hover, modal de conversion et ajustements UI (LST-72)
- 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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user