feat(front) : ameliorations UI onglets client (compta, RIB, blocs, placeholder)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m2s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m16s

- Onglet Comptabilite : grille alignee sur les autres onglets
  (grid-cols-4 gap-x-[44px] gap-y-4) en creation / modification / consultation.
- Bloc RIB toujours visible (au moins un bloc, meme vide) en creation,
  modification et consultation ; un bloc vide n'est jamais persiste.
- Blocs Contact / Adresse / RIB toujours affiches meme vides en consultation
  et modification ; suppression des messages « Aucun ... enregistre ».
- Onglets a venir (Transport, Statistiques, Rapports, Echanges) : nouveau
  composant partage ComingSoonPlaceholder (shared/components/ui) « En cours de
  dev » + gif, reutilisable par tous les modules ; remplace TabPlaceholderBlank.
This commit is contained in:
2026-06-03 15:34:31 +02:00
parent f4313d1f3d
commit 5754d19450
7 changed files with 102 additions and 49 deletions
@@ -1,14 +0,0 @@
<template>
<!--
Placeholder des onglets non encore implementes (Transport, Statistiques,
Rapports, Echanges). Frame vide blanche : aucun champ, aucun bouton,
aucun message « En cours » (decision Tristan 28/05). L'orchestrateur passe
automatiquement a l'onglet suivant ce composant n'est qu'une coquille
visuelle reutilisee par 1.11/1.12.
-->
<div class="min-h-[240px] rounded-md bg-white" />
</template>
<script setup lang="ts">
// Composant purement presentationnel : aucune prop, aucun event.
</script>
@@ -179,9 +179,6 @@
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
/>
<p v-if="contacts.length === 0" class="text-center text-black/60">
{{ t('commercial.clients.edit.emptyContacts') }}
</p>
<div v-if="!businessReadonly" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
@@ -219,9 +216,6 @@
@remove="askRemoveAddress(index)"
@degraded="onAddressDegraded"
/>
<p v-if="addresses.length === 0" class="text-center text-black/60">
{{ t('commercial.clients.edit.emptyAddresses') }}
</p>
<div v-if="!businessReadonly" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
@@ -245,7 +239,7 @@
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')"
@@ -312,7 +306,7 @@
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
<div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')"
@@ -350,10 +344,10 @@
</template>
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
<template #transport><TabPlaceholderBlank /></template>
<template #statistics><TabPlaceholderBlank /></template>
<template #reports><TabPlaceholderBlank /></template>
<template #exchanges><TabPlaceholderBlank /></template>
<template #transport><ComingSoonPlaceholder /></template>
<template #statistics><ComingSoonPlaceholder /></template>
<template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><ComingSoonPlaceholder /></template>
</MalioTabList>
</template>
@@ -495,6 +489,11 @@ function hydrate(detail: ClientDetail): void {
contacts.value = (detail.contacts ?? []).map(mapContactToDraft)
addresses.value = (detail.addresses ?? []).map(mapAddressToDraft)
ribs.value = (detail.ribs ?? []).map(mapRibToDraft)
// Chaque bloc reste visible meme vide : si une collection est vide, on amorce
// un bloc vierge (non persiste tant qu'incomplet — cf. submit*/canValidate*).
if (contacts.value.length === 0) contacts.value.push(emptyContact())
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
if (ribs.value.length === 0) ribs.value.push(emptyRib())
// Charge les listes distributeur / courtier si une relation est deja posee.
if (main.relationType === 'distributeur') referentials.loadDistributors().catch(() => {})
if (main.relationType === 'courtier') referentials.loadBrokers().catch(() => {})
@@ -694,6 +693,8 @@ function askRemoveContact(index: number): void {
const removed = contacts.value[index]
if (removed?.id != null) removedContactIds.value.push(removed.id)
contacts.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (contacts.value.length === 0) contacts.value.push(emptyContact())
})
}
@@ -755,6 +756,8 @@ function askRemoveAddress(index: number): void {
const removed = addresses.value[index]
if (removed?.id != null) removedAddressIds.value.push(removed.id)
addresses.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
})
}
@@ -833,6 +836,8 @@ function askRemoveRib(index: number): void {
const removed = ribs.value[index]
if (removed?.id != null) removedRibIds.value.push(removed.id)
ribs.value.splice(index, 1)
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
if (ribs.value.length === 0) ribs.value.push(emptyRib())
})
}
@@ -159,9 +159,6 @@
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
readonly
/>
<p v-if="contacts.length === 0" class="text-center text-black/60">
{{ t('commercial.clients.consultation.emptyContacts') }}
</p>
</div>
</template>
@@ -179,9 +176,6 @@
:country-options="countryOptions"
readonly
/>
<p v-if="addressViews.length === 0" class="text-center text-black/60">
{{ t('commercial.clients.consultation.emptyAddresses') }}
</p>
</div>
</template>
@@ -189,7 +183,7 @@
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
:model-value="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')"
@@ -244,7 +238,7 @@
:key="rib.id ?? index"
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
:model-value="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')"
@@ -266,10 +260,10 @@
</template>
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
<template #transport><TabPlaceholderBlank /></template>
<template #statistics><TabPlaceholderBlank /></template>
<template #reports><TabPlaceholderBlank /></template>
<template #exchanges><TabPlaceholderBlank /></template>
<template #transport><ComingSoonPlaceholder /></template>
<template #statistics><ComingSoonPlaceholder /></template>
<template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><ComingSoonPlaceholder /></template>
</MalioTabList>
</template>
@@ -320,6 +314,7 @@ import {
type SelectOption,
} from '~/modules/commercial/utils/clientConsultation'
import { formatPhoneFR } from '~/shared/utils/phone'
import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/clientForm'
// Masques d'affichage (purement visuels, la donnee reste celle du serveur).
const PHONE_MASK = '## ## ## ## ##'
@@ -372,10 +367,21 @@ const information = computed(() => ({
directorName: client.value?.directorName ?? null,
}))
const contacts = computed(() => (client.value?.contacts ?? []).map(mapContactToDraft))
// Chaque bloc reste visible meme vide en consultation : si la collection est
// vide, on affiche un bloc vierge en lecture seule (pas de message « Aucun … »).
const contacts = computed(() => {
const list = (client.value?.contacts ?? []).map(mapContactToDraft)
return list.length ? list : [emptyContact()]
})
// Vue par adresse : brouillon + options (sites/categories) propres a l'adresse.
const addressViews = computed(() => (client.value?.addresses ?? []).map(mapAddressView))
const ribs = computed(() => (client.value?.ribs ?? []).map(mapRibToDraft))
const addressViews = computed(() => {
const views = (client.value?.addresses ?? []).map(mapAddressView)
return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }]
})
const ribs = computed(() => {
const list = (client.value?.ribs ?? []).map(mapRibToDraft)
return list.length ? list : [emptyRib()]
})
// Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail)))
@@ -233,7 +233,7 @@
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')"
@@ -301,7 +301,7 @@
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
<div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')"
@@ -341,7 +341,7 @@
<!-- Onglet non encore implemente : frame vide, passage automatique.
Statistiques / Rapports / Echanges sont edit-only (absents a la
creation) — cf. buildClientFormTabKeys. -->
<template #transport><TabPlaceholderBlank /></template>
<template #transport><ComingSoonPlaceholder /></template>
</MalioTabList>
<!-- Modal de confirmation generique (suppression contact/adresse/RIB). -->
@@ -870,6 +870,8 @@ function addRib(): void {
function askRemoveRib(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => {
ribs.value.splice(index, 1)
// Garde au moins un bloc RIB visible (cf. amorce au montage).
if (ribs.value.length === 0) ribs.value.push(emptyRib())
})
}
@@ -956,5 +958,8 @@ interface ContactResponse {
onMounted(() => {
// Echec du chargement des referentiels non bloquant : les selects restent vides.
referentials.loadCommon().catch(() => {})
// Au moins un bloc RIB toujours visible en creation : on amorce un bloc vide
// (non persiste tant qu'incomplet — RG-1.13).
if (ribs.value.length === 0) ribs.value.push(emptyRib())
})
</script>