feat(front) : ameliorations UI onglets client (compta, RIB, blocs, placeholder)
- 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:
@@ -10,7 +10,11 @@
|
||||
"confirm": "Confirmer",
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"actions": "Actions"
|
||||
"actions": "Actions",
|
||||
"comingSoon": {
|
||||
"title": "En cours de dev",
|
||||
"subtitle": "Cette fonctionnalité arrive bientôt."
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"administration": {
|
||||
@@ -95,8 +99,6 @@
|
||||
"back": "Retour au répertoire",
|
||||
"loading": "Chargement du client…",
|
||||
"notFound": "Client introuvable.",
|
||||
"emptyContacts": "Aucun contact enregistré.",
|
||||
"emptyAddresses": "Aucune adresse enregistrée.",
|
||||
"confirmArchive": {
|
||||
"title": "Archiver le client",
|
||||
"message": "Ce client n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?"
|
||||
@@ -111,8 +113,6 @@
|
||||
"back": "Retour au répertoire",
|
||||
"loading": "Chargement du client…",
|
||||
"notFound": "Client introuvable.",
|
||||
"emptyContacts": "Aucun contact enregistré.",
|
||||
"emptyAddresses": "Aucune adresse enregistrée.",
|
||||
"save": "Valider"
|
||||
},
|
||||
"validation": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<!--
|
||||
Placeholder generique « En cours de dev » pour les ecrans / onglets non
|
||||
encore implementes. Composant PARTAGE (shared/components) : auto-importe
|
||||
sans prefixe (`<ComingSoonPlaceholder>`) et reutilisable depuis n'importe
|
||||
quel module. Affiche un gif (asset local par defaut) + un message i18n.
|
||||
-->
|
||||
<div class="flex min-h-[240px] flex-col items-center justify-center gap-4 rounded-md bg-white py-10">
|
||||
<img
|
||||
v-if="!imageFailed"
|
||||
:src="src"
|
||||
:alt="resolvedTitle"
|
||||
class="max-h-[220px] w-auto rounded-md"
|
||||
@error="imageFailed = true"
|
||||
>
|
||||
<!-- Repli si le gif ne charge pas (offline, CSP, asset absent) :
|
||||
illustration emoji, le message reste affiche. -->
|
||||
<div v-else class="text-5xl" aria-hidden="true">🚧 👨💻 🚧</div>
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-xl font-bold text-black">{{ resolvedTitle }}</p>
|
||||
<p class="mt-1 text-black/60">{{ resolvedSubtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Source de l'image/gif affichee. Defaut : asset local `/coming-soon.gif`. */
|
||||
src?: string
|
||||
/** Titre. Defaut : i18n `common.comingSoon.title`. */
|
||||
title?: string
|
||||
/** Sous-titre. Defaut : i18n `common.comingSoon.subtitle`. */
|
||||
subtitle?: string
|
||||
}>(),
|
||||
{
|
||||
src: '/coming-soon.gif',
|
||||
title: '',
|
||||
subtitle: '',
|
||||
},
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const imageFailed = ref(false)
|
||||
|
||||
// Les props priment sur les libelles i18n par defaut (permet a un module
|
||||
// d'override le texte sans toucher au composant).
|
||||
const resolvedTitle = computed(() => props.title || t('common.comingSoon.title'))
|
||||
const resolvedSubtitle = computed(() => props.subtitle || t('common.comingSoon.subtitle'))
|
||||
</script>
|
||||
Reference in New Issue
Block a user