feat(commercial) : onglet adresse — Select « Type d'adresse » + Sites en multiselect, ligne 1 réagencée (ERP-110)
- Sites Starseed : 3 cases -> multiselect a tags « Sites » (required). - Usage adresse : 3 cases Prospect/Livraison/Facturation -> Select unique « Type d'adresse » (Prospect / Livraison / Facturation / Adresse + Facturation), obligatoire sans option vide, conditionnant le bouton « Valider ». Pur sucre front : le back recoit toujours isProspect/isDelivery/isBilling (aucune RG modifiee), exclusivite Prospect devenue structurelle. - Email de facturation conditionnel (Facturation / Adresse + Facturation) deplace en ligne 1. - Ligne 1 : Type d'adresse | Sites | Contact rattache | Email ; le reste (Categorie, Pays, CP, Ville, Adresse...) en lignes suivantes. - Email : MalioInputText -> MalioInputEmail (lowercase, ERP-101/RG-1.21) sur facturation ET contact. - Helpers front testables addressFlagsFromType / addressTypeFromFlags + gating canValidateAddresses (type obligatoire) dans new.vue / edit.vue.
This commit is contained in:
@@ -168,13 +168,18 @@
|
|||||||
"prospect": "Prospect",
|
"prospect": "Prospect",
|
||||||
"delivery": "Adresse de livraison",
|
"delivery": "Adresse de livraison",
|
||||||
"billing": "Facturation",
|
"billing": "Facturation",
|
||||||
|
"addressType": "Type d'adresse",
|
||||||
|
"addressTypeProspect": "Prospect",
|
||||||
|
"addressTypeDelivery": "Livraison",
|
||||||
|
"addressTypeBilling": "Facturation",
|
||||||
|
"addressTypeDeliveryBilling": "Adresse + Facturation",
|
||||||
"categories": "Catégorie",
|
"categories": "Catégorie",
|
||||||
"country": "Pays",
|
"country": "Pays",
|
||||||
"postalCode": "Code postal",
|
"postalCode": "Code postal",
|
||||||
"city": "Ville",
|
"city": "Ville",
|
||||||
"street": "Adresse",
|
"street": "Adresse",
|
||||||
"streetComplement": "Adresse complémentaire",
|
"streetComplement": "Adresse complémentaire",
|
||||||
"sites": "Sites Starseed",
|
"sites": "Sites",
|
||||||
"contacts": "Contact(s) rattaché(s)",
|
"contacts": "Contact(s) rattaché(s)",
|
||||||
"billingEmail": "Email de facturation",
|
"billingEmail": "Email de facturation",
|
||||||
"remove": "Supprimer l'adresse",
|
"remove": "Supprimer l'adresse",
|
||||||
|
|||||||
@@ -10,34 +10,53 @@
|
|||||||
@click="$emit('remove')"
|
@click="$emit('remove')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Usage de l'adresse : Prospect exclusif de Livraison/Facturation
|
<!-- Usage de l'adresse : Select unique (plus simple pour l'utilisateur)
|
||||||
(RG-1.06/07/08). L'exclusivite est appliquee au toggle (cocher l'un
|
remplacant les 3 cases. Les options encodent les combinaisons valides
|
||||||
decoche l'autre) plutot qu'en masquant les options. -->
|
(exclusivite Prospect, RG-1.06/07/08) ; le back recoit toujours les
|
||||||
<MalioCheckbox
|
drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). -->
|
||||||
:model-value="model.isProspect"
|
<MalioSelect
|
||||||
:label="t('commercial.clients.form.address.prospect')"
|
:model-value="addressType"
|
||||||
group-class="self-center"
|
:options="addressTypeOptions"
|
||||||
|
:label="t('commercial.clients.form.address.addressType')"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
@update:model-value="(v: boolean) => toggleFlag('isProspect', v)"
|
:required="true"
|
||||||
/>
|
@update:model-value="onAddressTypeChange"
|
||||||
<MalioCheckbox
|
|
||||||
:model-value="model.isDelivery"
|
|
||||||
:label="t('commercial.clients.form.address.delivery')"
|
|
||||||
group-class="self-center"
|
|
||||||
:readonly="readonly"
|
|
||||||
@update:model-value="(v: boolean) => toggleFlag('isDelivery', v)"
|
|
||||||
/>
|
|
||||||
<MalioCheckbox
|
|
||||||
:model-value="model.isBilling"
|
|
||||||
:label="t('commercial.clients.form.address.billing')"
|
|
||||||
group-class="self-center"
|
|
||||||
:readonly="readonly"
|
|
||||||
@update:model-value="(v: boolean) => toggleFlag('isBilling', v)"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Cellule vide : laisse un trou en position 4 (ligne 1) pour que
|
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-1.10). -->
|
||||||
Categorie reparte au debut de la ligne suivante. -->
|
<MalioSelectCheckbox
|
||||||
<div aria-hidden="true" />
|
:model-value="model.siteIris"
|
||||||
|
:options="siteOptions"
|
||||||
|
:label="t('commercial.clients.form.address.sites')"
|
||||||
|
:display-tag="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
:required="true"
|
||||||
|
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="model.contactIris"
|
||||||
|
:options="contactOptions"
|
||||||
|
:label="t('commercial.clients.form.address.contacts')"
|
||||||
|
:display-tag="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Email de facturation : ligne 1 colonne 4, visible/obligatoire
|
||||||
|
seulement si Facturation (RG-1.11). Sinon un filler comble la
|
||||||
|
colonne pour que Categorie reparte au debut de la ligne 2. -->
|
||||||
|
<MalioInputEmail
|
||||||
|
v-if="isBillingEmailRequired(model)"
|
||||||
|
:model-value="model.billingEmail"
|
||||||
|
:label="t('commercial.clients.form.address.billingEmail')"
|
||||||
|
:required="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
:lowercase="true"
|
||||||
|
:error="errors?.billingEmail"
|
||||||
|
@update:model-value="(v: string) => update('billingEmail', v)"
|
||||||
|
/>
|
||||||
|
<div v-else aria-hidden="true" />
|
||||||
|
|
||||||
<MalioSelectCheckbox
|
<MalioSelectCheckbox
|
||||||
:model-value="model.categoryIris"
|
:model-value="model.categoryIris"
|
||||||
@@ -134,47 +153,15 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sites Starseed : cases a cocher inline (>= 1 obligatoire, RG-1.10). -->
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<MalioCheckbox
|
|
||||||
v-for="site in siteOptions"
|
|
||||||
:key="site.value"
|
|
||||||
:model-value="model.siteIris.includes(site.value)"
|
|
||||||
:label="site.label"
|
|
||||||
group-class="w-auto self-center"
|
|
||||||
:readonly="readonly"
|
|
||||||
@update:model-value="(v: boolean) => toggleSite(site.value, v)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MalioSelectCheckbox
|
|
||||||
:model-value="model.contactIris"
|
|
||||||
:options="contactOptions"
|
|
||||||
:label="t('commercial.clients.form.address.contacts')"
|
|
||||||
:display-tag="true"
|
|
||||||
:readonly="readonly"
|
|
||||||
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Email de facturation : visible/obligatoire seulement si Facturation
|
|
||||||
est coche (RG-1.11). -->
|
|
||||||
<MalioInputText
|
|
||||||
v-if="isBillingEmailRequired(model)"
|
|
||||||
:model-value="model.billingEmail"
|
|
||||||
:label="t('commercial.clients.form.address.billingEmail')"
|
|
||||||
:required="true"
|
|
||||||
:readonly="readonly"
|
|
||||||
:error="errors?.billingEmail"
|
|
||||||
@update:model-value="(v: string) => update('billingEmail', v)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
applyProspectExclusivity,
|
addressFlagsFromType,
|
||||||
|
addressTypeFromFlags,
|
||||||
isBillingEmailRequired,
|
isBillingEmailRequired,
|
||||||
type AddressFlagsDraft,
|
type AddressType,
|
||||||
} from '~/modules/commercial/utils/clientFormRules'
|
} from '~/modules/commercial/utils/clientFormRules'
|
||||||
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
||||||
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
||||||
@@ -213,6 +200,23 @@ const autocomplete = useAddressAutocomplete()
|
|||||||
|
|
||||||
const model = computed(() => props.modelValue)
|
const model = computed(() => props.modelValue)
|
||||||
|
|
||||||
|
// Type d'adresse (Select unique) derive des drapeaux back. null tant qu'aucun
|
||||||
|
// drapeau n'est pose -> champ vide + bouton « Valider » bloque (cf. parent).
|
||||||
|
const addressType = computed<AddressType | null>(() => addressTypeFromFlags(model.value))
|
||||||
|
|
||||||
|
const addressTypeOptions = computed<RefOption[]>(() => [
|
||||||
|
{ value: 'prospect', label: t('commercial.clients.form.address.addressTypeProspect') },
|
||||||
|
{ value: 'delivery', label: t('commercial.clients.form.address.addressTypeDelivery') },
|
||||||
|
{ value: 'billing', label: t('commercial.clients.form.address.addressTypeBilling') },
|
||||||
|
{ value: 'delivery_billing', label: t('commercial.clients.form.address.addressTypeDeliveryBilling') },
|
||||||
|
])
|
||||||
|
|
||||||
|
/** Applique le type choisi en repercutant les 3 drapeaux back (immutabilite). */
|
||||||
|
function onAddressTypeChange(value: string | number | null): void {
|
||||||
|
if (value === null) return
|
||||||
|
emit('update:modelValue', { ...props.modelValue, ...addressFlagsFromType(value as AddressType) })
|
||||||
|
}
|
||||||
|
|
||||||
// Mode degrade : service BAN indisponible → Ville/Adresse en saisie libre.
|
// Mode degrade : service BAN indisponible → Ville/Adresse en saisie libre.
|
||||||
const degraded = ref(false)
|
const degraded = ref(false)
|
||||||
// Villes proposees par la BAN (alimentees a la saisie du code postal).
|
// Villes proposees par la BAN (alimentees a la saisie du code postal).
|
||||||
@@ -254,25 +258,6 @@ function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDr
|
|||||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Coche/decoche un site Starseed rattache a l'adresse (M2M par IRI, RG-1.10). */
|
|
||||||
function toggleSite(siteIri: string, selected: boolean): void {
|
|
||||||
const current = props.modelValue.siteIris
|
|
||||||
const next = selected
|
|
||||||
? [...current, siteIri]
|
|
||||||
: current.filter(iri => iri !== siteIri)
|
|
||||||
update('siteIris', next)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Applique l'exclusivite Prospect / (Livraison|Facturation) au changement. */
|
|
||||||
function toggleFlag(field: keyof AddressFlagsDraft, value: boolean): void {
|
|
||||||
const flags = applyProspectExclusivity(
|
|
||||||
{ isProspect: model.value.isProspect, isDelivery: model.value.isDelivery, isBilling: model.value.isBilling },
|
|
||||||
field,
|
|
||||||
value,
|
|
||||||
)
|
|
||||||
emit('update:modelValue', { ...props.modelValue, ...flags })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bascule définitivement en mode degrade et previent le parent (toast unique). */
|
/** Bascule définitivement en mode degrade et previent le parent (toast unique). */
|
||||||
function enterDegraded(): void {
|
function enterDegraded(): void {
|
||||||
if (!degraded.value) {
|
if (!degraded.value) {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
:model-value="model.email"
|
:model-value="model.email"
|
||||||
:label="t('commercial.clients.form.contact.email')"
|
:label="t('commercial.clients.form.contact.email')"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
|
:lowercase="true"
|
||||||
:error="errors?.email"
|
:error="errors?.email"
|
||||||
@update:model-value="(v: string) => update('email', v)"
|
@update:model-value="(v: string) => update('email', v)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -410,6 +410,7 @@ import {
|
|||||||
type MainFormDraft,
|
type MainFormDraft,
|
||||||
} from '~/modules/commercial/utils/clientEdit'
|
} from '~/modules/commercial/utils/clientEdit'
|
||||||
import {
|
import {
|
||||||
|
addressTypeFromFlags,
|
||||||
buildClientFormTabKeys,
|
buildClientFormTabKeys,
|
||||||
hasAllRequiredAccountingFields,
|
hasAllRequiredAccountingFields,
|
||||||
hasAtLeastOneValidContact,
|
hasAtLeastOneValidContact,
|
||||||
@@ -789,7 +790,8 @@ const canValidateAddresses = computed(() =>
|
|||||||
addresses.value.length > 0
|
addresses.value.length > 0
|
||||||
&& addresses.value.every((a) => {
|
&& addresses.value.every((a) => {
|
||||||
const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== ''
|
const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== ''
|
||||||
return a.siteIris.length >= 1
|
return addressTypeFromFlags(a) !== null
|
||||||
|
&& a.siteIris.length >= 1
|
||||||
&& a.categoryIris.length >= 1
|
&& a.categoryIris.length >= 1
|
||||||
&& (!isBillingEmailRequired(a) || filledBillingEmail)
|
&& (!isBillingEmailRequired(a) || filledBillingEmail)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -380,6 +380,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|||||||
import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
||||||
import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
|
import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
|
||||||
import {
|
import {
|
||||||
|
addressTypeFromFlags,
|
||||||
buildClientFormTabKeys,
|
buildClientFormTabKeys,
|
||||||
CLIENT_FORM_PLACEHOLDER_TABS,
|
CLIENT_FORM_PLACEHOLDER_TABS,
|
||||||
hasAllRequiredAccountingFields,
|
hasAllRequiredAccountingFields,
|
||||||
@@ -749,12 +750,14 @@ const countryOptions: RefOption[] = [
|
|||||||
{ value: 'Espagne', label: 'Espagne' },
|
{ value: 'Espagne', label: 'Espagne' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// RG-1.10 (>= 1 site) + RG-1.11 (email facturation si Facturation) sur chaque adresse.
|
// Type d'adresse (Select) obligatoire + RG-1.10 (>= 1 site) + RG-1.11 (email
|
||||||
|
// facturation si Facturation) sur chaque adresse.
|
||||||
const canValidateAddresses = computed(() =>
|
const canValidateAddresses = computed(() =>
|
||||||
addresses.value.length > 0
|
addresses.value.length > 0
|
||||||
&& addresses.value.every((a) => {
|
&& addresses.value.every((a) => {
|
||||||
const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== ''
|
const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== ''
|
||||||
return a.siteIris.length >= 1
|
return addressTypeFromFlags(a) !== null
|
||||||
|
&& a.siteIris.length >= 1
|
||||||
&& a.categoryIris.length >= 1
|
&& a.categoryIris.length >= 1
|
||||||
&& (!isBillingEmailRequired(a) || filledBillingEmail)
|
&& (!isBillingEmailRequired(a) || filledBillingEmail)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import {
|
import {
|
||||||
|
addressFlagsFromType,
|
||||||
|
addressTypeFromFlags,
|
||||||
applyProspectExclusivity,
|
applyProspectExclusivity,
|
||||||
buildClientFormTabKeys,
|
buildClientFormTabKeys,
|
||||||
canSelectDeliveryOrBilling,
|
canSelectDeliveryOrBilling,
|
||||||
@@ -197,6 +199,32 @@ describe('isBillingEmailRequired (RG-1.11)', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('type d\'adresse (Select front) <-> drapeaux back', () => {
|
||||||
|
it('addressFlagsFromType mappe chaque type vers les bons drapeaux', () => {
|
||||||
|
expect(addressFlagsFromType('prospect')).toEqual({ isProspect: true, isDelivery: false, isBilling: false })
|
||||||
|
expect(addressFlagsFromType('delivery')).toEqual({ isProspect: false, isDelivery: true, isBilling: false })
|
||||||
|
expect(addressFlagsFromType('billing')).toEqual({ isProspect: false, isDelivery: false, isBilling: true })
|
||||||
|
expect(addressFlagsFromType('delivery_billing')).toEqual({ isProspect: false, isDelivery: true, isBilling: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addressTypeFromFlags reconstruit le type (Prospect prioritaire, livraison+facturation groupes)', () => {
|
||||||
|
expect(addressTypeFromFlags({ isProspect: true, isDelivery: false, isBilling: false })).toBe('prospect')
|
||||||
|
expect(addressTypeFromFlags({ isProspect: false, isDelivery: true, isBilling: false })).toBe('delivery')
|
||||||
|
expect(addressTypeFromFlags({ isProspect: false, isDelivery: false, isBilling: true })).toBe('billing')
|
||||||
|
expect(addressTypeFromFlags({ isProspect: false, isDelivery: true, isBilling: true })).toBe('delivery_billing')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addressTypeFromFlags retourne null quand aucun drapeau (amorce vierge -> bouton bloque)', () => {
|
||||||
|
expect(addressTypeFromFlags({ isProspect: false, isDelivery: false, isBilling: false })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aller-retour type -> drapeaux -> type stable pour les 4 types', () => {
|
||||||
|
for (const type of ['prospect', 'delivery', 'billing', 'delivery_billing'] as const) {
|
||||||
|
expect(addressTypeFromFlags(addressFlagsFromType(type))).toBe(type)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('regles type de reglement (RG-1.12 / RG-1.13)', () => {
|
describe('regles type de reglement (RG-1.12 / RG-1.13)', () => {
|
||||||
it('banque obligatoire si VIREMENT', () => {
|
it('banque obligatoire si VIREMENT', () => {
|
||||||
expect(isBankRequiredForPaymentType('VIREMENT')).toBe(true)
|
expect(isBankRequiredForPaymentType('VIREMENT')).toBe(true)
|
||||||
|
|||||||
@@ -187,6 +187,45 @@ export function isBillingEmailRequired(flags: AddressFlagsDraft): boolean {
|
|||||||
return flags.isBilling
|
return flags.isBilling
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type d'adresse expose a l'utilisateur (Select unique remplacant les trois
|
||||||
|
* cases a cocher). Sucre purement front : le back continue de recevoir les
|
||||||
|
* drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). Les seules
|
||||||
|
* combinaisons proposees respectent l'exclusivite Prospect (RG-1.06/07/08).
|
||||||
|
*/
|
||||||
|
export type AddressType = 'prospect' | 'delivery' | 'billing' | 'delivery_billing'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mappe le type d'adresse choisi vers les trois drapeaux back.
|
||||||
|
* « Adresse + Facturation » = livraison ET facturation sur la meme adresse.
|
||||||
|
*/
|
||||||
|
export function addressFlagsFromType(type: AddressType): AddressFlagsDraft {
|
||||||
|
switch (type) {
|
||||||
|
case 'prospect':
|
||||||
|
return { isProspect: true, isDelivery: false, isBilling: false }
|
||||||
|
case 'delivery':
|
||||||
|
return { isProspect: false, isDelivery: true, isBilling: false }
|
||||||
|
case 'billing':
|
||||||
|
return { isProspect: false, isDelivery: false, isBilling: true }
|
||||||
|
case 'delivery_billing':
|
||||||
|
return { isProspect: false, isDelivery: true, isBilling: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstruit le type d'adresse a partir des drapeaux (consultation / edition
|
||||||
|
* d'une adresse persistee, ou amorce vierge). Retourne null si aucun drapeau
|
||||||
|
* n'est positionne — le Select reste alors a saisir (et bloque la validation).
|
||||||
|
*/
|
||||||
|
export function addressTypeFromFlags(flags: AddressFlagsDraft): AddressType | null {
|
||||||
|
if (flags.isProspect) return 'prospect'
|
||||||
|
if (flags.isDelivery && flags.isBilling) return 'delivery_billing'
|
||||||
|
if (flags.isDelivery) return 'delivery'
|
||||||
|
if (flags.isBilling) return 'billing'
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/** Code stable du type de reglement « virement » (cf. PaymentType.code, RG-1.12). */
|
/** Code stable du type de reglement « virement » (cf. PaymentType.code, RG-1.12). */
|
||||||
const PAYMENT_TYPE_TRANSFER = 'VIREMENT'
|
const PAYMENT_TYPE_TRANSFER = 'VIREMENT'
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user