b3cc7e4ced
- Relation Distributeur/Courtier : libelles « Depend du distributeur/courtier », select optionnel ; le nom (distributeur ou courtier) devient requis quand la relation correspondante est choisie. - Categorie : au moins 1 obligatoire dans le formulaire principal (aligne sur Assert\Count(min:1) du back). - Bouton « Valider » de l'onglet Information desactive tant que le client n'est pas cree (l'onglet est actif par defaut) : evite tout PATCH premature. - Gestion d'erreur : les toasts d'erreur passent toujours une chaine (corrige un crash izitoast sur message undefined) et remontent le message de validation du serveur (violations 422) sur tous les onglets.
961 lines
42 KiB
Vue
961 lines
42 KiB
Vue
<template>
|
|
<div>
|
|
<!-- En-tete : retour vers le repertoire + titre. -->
|
|
<div class="flex items-center gap-3">
|
|
<MalioButtonIcon
|
|
icon="mdi:arrow-left-bold"
|
|
icon-size="24"
|
|
variant="ghost"
|
|
v-bind="{ ariaLabel: t('commercial.clients.form.back') }"
|
|
@click="goBack"
|
|
/>
|
|
<h1 class="text-[32px] font-bold text-m-primary">{{ t('commercial.clients.form.title') }}</h1>
|
|
</div>
|
|
|
|
<!-- ── Formulaire principal (pre-onglets) ─────────────────────────────
|
|
Sans validation de ce bloc, les onglets restent inaccessibles. Au
|
|
succes du POST, les champs passent en lecture seule et on bascule
|
|
automatiquement sur l'onglet Information. -->
|
|
<div class="mt-[48px] grid grid-cols-3 gap-x-[80px] gap-y-5">
|
|
<MalioInputText
|
|
v-model="main.companyName"
|
|
:label="t('commercial.clients.form.main.companyName')"
|
|
:required="true"
|
|
:readonly="mainLocked"
|
|
/>
|
|
<MalioInputText
|
|
v-model="main.lastName"
|
|
:label="t('commercial.clients.form.main.lastName')"
|
|
:readonly="mainLocked"
|
|
/>
|
|
<MalioInputText
|
|
v-model="main.firstName"
|
|
:label="t('commercial.clients.form.main.firstName')"
|
|
:readonly="mainLocked"
|
|
/>
|
|
<MalioSelectCheckbox
|
|
:model-value="main.categoryIris"
|
|
:options="referentials.categories.value"
|
|
:label="t('commercial.clients.form.main.categories')"
|
|
:display-tag="true"
|
|
:disabled="mainLocked"
|
|
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
|
/>
|
|
<!-- Telephones : 1 par defaut, le bouton « + » revele le 2e (max 2, RG-1.02). -->
|
|
<MalioInputPhone
|
|
v-for="(_, index) in mainPhones"
|
|
:key="index"
|
|
v-model="mainPhones[index]"
|
|
:label="t('commercial.clients.form.main.phonePrimary')"
|
|
:mask="PHONE_MASK"
|
|
:required="index === 0"
|
|
:readonly="mainLocked"
|
|
add-icon-name="mdi:plus"
|
|
:addable="mainPhones.length === 1 && !mainLocked"
|
|
:add-button-label="t('commercial.clients.form.main.addPhone')"
|
|
@add="addMainPhone"
|
|
/>
|
|
<MalioInputEmail
|
|
v-model="main.email"
|
|
:label="t('commercial.clients.form.main.email')"
|
|
:required="true"
|
|
:readonly="mainLocked"
|
|
/>
|
|
<MalioSelect
|
|
:model-value="main.relationType"
|
|
:options="relationOptions"
|
|
:label="t('commercial.clients.form.main.relation')"
|
|
:disabled="mainLocked"
|
|
@update:model-value="onRelationChange"
|
|
/>
|
|
<MalioSelect
|
|
v-if="main.relationType === 'courtier'"
|
|
:model-value="main.brokerIri"
|
|
:options="referentials.brokers.value"
|
|
:label="t('commercial.clients.form.main.brokerName')"
|
|
:disabled="mainLocked"
|
|
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
|
|
/>
|
|
<MalioSelect
|
|
v-if="main.relationType === 'distributeur'"
|
|
:model-value="main.distributorIri"
|
|
:options="referentials.distributors.value"
|
|
:label="t('commercial.clients.form.main.distributorName')"
|
|
:disabled="mainLocked"
|
|
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
|
|
/>
|
|
<MalioCheckbox
|
|
v-model="main.triageService"
|
|
:label="t('commercial.clients.form.main.triageService')"
|
|
group-class="self-center"
|
|
:readonly="mainLocked"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="!mainLocked" class="mt-12 flex justify-center">
|
|
<MalioButton
|
|
variant="primary"
|
|
:label="t('commercial.clients.form.submit')"
|
|
:disabled="!isMainValid || mainSubmitting"
|
|
@click="submitMain"
|
|
/>
|
|
</div>
|
|
|
|
<!-- ── Onglets a validation incrementale ─────────────────────────────-->
|
|
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
|
<!-- Onglet Information -->
|
|
<template #information>
|
|
<div class="mt-12 grid grid-cols-3 gap-x-[80px] gap-y-5 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
|
<!-- pt-1 : aligne le bord superieur du textarea sur celui des
|
|
inputs (centres dans un conteneur h-12, soit ~4px de retrait haut). -->
|
|
<MalioInputTextArea
|
|
v-model="information.description"
|
|
:label="t('commercial.clients.form.information.description')"
|
|
resize="none"
|
|
group-class="row-span-2 pt-1"
|
|
text-input="h-full text-lg"
|
|
:disabled="isValidated('information')"
|
|
/>
|
|
<MalioInputText
|
|
v-model="information.competitors"
|
|
:label="t('commercial.clients.form.information.competitors')"
|
|
:readonly="isValidated('information')"
|
|
/>
|
|
<MalioDate
|
|
v-model="information.foundedAt"
|
|
:label="t('commercial.clients.form.information.foundedAt')"
|
|
:readonly="isValidated('information')"
|
|
/>
|
|
<MalioInputText
|
|
v-model="information.employeesCount"
|
|
:label="t('commercial.clients.form.information.employeesCount')"
|
|
:mask="EMPLOYEES_MASK"
|
|
:readonly="isValidated('information')"
|
|
/>
|
|
<MalioInputAmount
|
|
v-model="information.revenueAmount"
|
|
:label="t('commercial.clients.form.information.revenueAmount')"
|
|
:disabled="isValidated('information')"
|
|
/>
|
|
<MalioInputText
|
|
v-model="information.directorName"
|
|
:label="t('commercial.clients.form.information.directorName')"
|
|
:readonly="isValidated('information')"
|
|
/>
|
|
<MalioInputAmount
|
|
v-model="information.profitAmount"
|
|
:label="t('commercial.clients.form.information.profitAmount')"
|
|
:disabled="isValidated('information')"
|
|
/>
|
|
</div>
|
|
<div v-if="!isValidated('information')" class="mt-12 flex justify-center">
|
|
<!-- Desactive tant que le client n'est pas cree : evite un PATCH
|
|
avant le POST si l'utilisateur clique trop tot (le panneau
|
|
Information est l'onglet actif par defaut). -->
|
|
<MalioButton
|
|
variant="primary"
|
|
:label="t('commercial.clients.form.submit')"
|
|
:disabled="tabSubmitting || clientId === null"
|
|
@click="submitInformation"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Onglet Contact -->
|
|
<template #contact>
|
|
<div class="mt-12 flex flex-col gap-6">
|
|
<ClientContactBlock
|
|
v-for="(contact, index) in contacts"
|
|
:key="index"
|
|
:model-value="contact"
|
|
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
|
:removable="index > 0"
|
|
:readonly="isValidated('contact')"
|
|
@update:model-value="(v) => contacts[index] = v"
|
|
@remove="askRemoveContact(index)"
|
|
/>
|
|
<div v-if="!isValidated('contact')" class="flex justify-center gap-6">
|
|
<MalioButton
|
|
variant="secondary"
|
|
icon-name="mdi:add-bold"
|
|
icon-position="left"
|
|
:label="t('commercial.clients.form.contact.add')"
|
|
:disabled="!canAddContact"
|
|
@click="addContact"
|
|
/>
|
|
<MalioButton
|
|
variant="primary"
|
|
:label="t('commercial.clients.form.submit')"
|
|
:disabled="!canValidateContacts || tabSubmitting"
|
|
@click="submitContacts"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Onglet Adresse -->
|
|
<template #address>
|
|
<div class="mt-12 flex flex-col gap-6">
|
|
<ClientAddressBlock
|
|
v-for="(address, index) in addresses"
|
|
:key="index"
|
|
:model-value="address"
|
|
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
|
|
:category-options="addressCategoryOptions"
|
|
:site-options="referentials.sites.value"
|
|
:contact-options="contactOptions"
|
|
:country-options="countryOptions"
|
|
:removable="index > 0"
|
|
:readonly="isValidated('address')"
|
|
@update:model-value="(v) => addresses[index] = v"
|
|
@remove="askRemoveAddress(index)"
|
|
@degraded="onAddressDegraded"
|
|
/>
|
|
<div v-if="!isValidated('address')" class="flex justify-center gap-6">
|
|
<MalioButton
|
|
variant="secondary"
|
|
icon-name="mdi:add-bold"
|
|
icon-position="left"
|
|
:label="t('commercial.clients.form.address.add')"
|
|
@click="addAddress"
|
|
/>
|
|
<MalioButton
|
|
variant="primary"
|
|
:label="t('commercial.clients.form.submit')"
|
|
:disabled="!canValidateAddresses || tabSubmitting"
|
|
@click="submitAddresses"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Onglet Comptabilite (present uniquement si accounting.view) -->
|
|
<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">
|
|
<MalioInputText
|
|
v-model="accounting.siren"
|
|
:label="t('commercial.clients.form.accounting.siren')"
|
|
:mask="SIREN_MASK"
|
|
:readonly="accountingReadonly"
|
|
/>
|
|
<MalioInputText
|
|
v-model="accounting.accountNumber"
|
|
:label="t('commercial.clients.form.accounting.accountNumber')"
|
|
:readonly="accountingReadonly"
|
|
/>
|
|
<MalioSelect
|
|
:model-value="accounting.tvaModeIri"
|
|
:options="referentials.tvaModes.value"
|
|
:label="t('commercial.clients.form.accounting.tvaMode')"
|
|
:disabled="accountingReadonly"
|
|
empty-option-label=""
|
|
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
|
|
/>
|
|
<MalioInputText
|
|
v-model="accounting.nTva"
|
|
:label="t('commercial.clients.form.accounting.nTva')"
|
|
:readonly="accountingReadonly"
|
|
/>
|
|
<MalioSelect
|
|
:model-value="accounting.paymentDelayIri"
|
|
:options="referentials.paymentDelays.value"
|
|
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
|
:disabled="accountingReadonly"
|
|
empty-option-label=""
|
|
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
|
|
/>
|
|
<MalioSelect
|
|
:model-value="accounting.paymentTypeIri"
|
|
:options="referentials.paymentTypes.value"
|
|
:label="t('commercial.clients.form.accounting.paymentType')"
|
|
:disabled="accountingReadonly"
|
|
empty-option-label=""
|
|
@update:model-value="onPaymentTypeChange"
|
|
/>
|
|
<MalioSelect
|
|
v-if="isBankRequired"
|
|
:model-value="accounting.bankIri"
|
|
:options="referentials.banks.value"
|
|
:label="t('commercial.clients.form.accounting.bank')"
|
|
:disabled="accountingReadonly"
|
|
empty-option-label=""
|
|
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Blocs RIB (0..n) — obligatoires si type de reglement = LCR. -->
|
|
<div
|
|
v-for="(rib, index) in ribs"
|
|
:key="index"
|
|
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
|
>
|
|
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
|
<MalioButtonIcon
|
|
v-if="!accountingReadonly"
|
|
icon="mdi:delete-outline"
|
|
variant="ghost"
|
|
button-class="absolute top-3 right-3"
|
|
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
|
|
@click="askRemoveRib(index)"
|
|
/>
|
|
<div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
|
|
<MalioInputText
|
|
v-model="rib.label"
|
|
:label="t('commercial.clients.form.accounting.ribLabel')"
|
|
:readonly="accountingReadonly"
|
|
/>
|
|
<MalioInputText
|
|
v-model="rib.bic"
|
|
:label="t('commercial.clients.form.accounting.ribBic')"
|
|
:readonly="accountingReadonly"
|
|
/>
|
|
<MalioInputText
|
|
v-model="rib.iban"
|
|
:label="t('commercial.clients.form.accounting.ribIban')"
|
|
:readonly="accountingReadonly"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="!accountingReadonly" class="flex justify-center gap-6">
|
|
<MalioButton
|
|
variant="secondary"
|
|
icon-name="mdi:add-bold"
|
|
icon-position="left"
|
|
:label="t('commercial.clients.form.accounting.addRib')"
|
|
@click="addRib"
|
|
/>
|
|
<MalioButton
|
|
variant="primary"
|
|
:label="t('commercial.clients.form.submit')"
|
|
:disabled="!canValidateAccounting || tabSubmitting"
|
|
@click="submitAccounting"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Onglet non encore implemente : frame vide, passage automatique.
|
|
Statistiques / Rapports / Echanges sont edit-only (absents a la
|
|
creation) — cf. buildClientFormTabKeys. -->
|
|
<template #transport><TabPlaceholderBlank /></template>
|
|
</MalioTabList>
|
|
|
|
<!-- Modal de confirmation generique (suppression contact/adresse/RIB). -->
|
|
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
|
|
<template #header>
|
|
<h2 class="text-[24px] font-bold">{{ t('commercial.clients.form.confirmDelete.title') }}</h2>
|
|
</template>
|
|
<p>{{ confirmModal.message }}</p>
|
|
<template #footer>
|
|
<MalioButton
|
|
variant="secondary"
|
|
button-class="flex-1"
|
|
:label="t('commercial.clients.form.confirmDelete.cancel')"
|
|
@click="confirmModal.open = false"
|
|
/>
|
|
<MalioButton
|
|
variant="danger"
|
|
button-class="flex-1"
|
|
:label="t('commercial.clients.form.confirmDelete.confirm')"
|
|
@click="runConfirm"
|
|
/>
|
|
</template>
|
|
</MalioModal>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
|
import {
|
|
buildClientFormTabKeys,
|
|
CLIENT_FORM_PLACEHOLDER_TABS,
|
|
hasAtLeastOneValidContact,
|
|
isBankRequiredForPaymentType,
|
|
isBillingEmailRequired,
|
|
isContactNamed,
|
|
isRibRequiredForPaymentType,
|
|
} from '~/modules/commercial/utils/clientFormRules'
|
|
import {
|
|
emptyAddress,
|
|
emptyContact,
|
|
emptyRib,
|
|
type AddressFormDraft,
|
|
type ContactFormDraft,
|
|
type RibFormDraft,
|
|
} from '~/modules/commercial/types/clientForm'
|
|
import { formatPhoneFR } from '~/shared/utils/phone'
|
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
|
|
|
// Masques de saisie (la normalisation finale reste serveur).
|
|
const PHONE_MASK = '## ## ## ## ##'
|
|
const SIREN_MASK = '#########'
|
|
// Masque « nombre » du champ Nombre de salaries : chiffres uniquement (max 7).
|
|
const EMPLOYEES_MASK = '#######'
|
|
|
|
// Codes de categorie interdits sur une adresse (RG-1.29, ERP-78).
|
|
const FORBIDDEN_ADDRESS_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER']
|
|
|
|
const { t } = useI18n()
|
|
const api = useApi()
|
|
const toast = useToast()
|
|
const router = useRouter()
|
|
const { can } = usePermissions()
|
|
|
|
/** Retour vers le repertoire clients (fleche d'en-tete). */
|
|
function goBack(): void {
|
|
router.push('/clients')
|
|
}
|
|
|
|
/**
|
|
* Message d'erreur a afficher dans un toast a partir d'une erreur d'API.
|
|
* Retourne TOUJOURS une chaine (le composant de toast plante sur `undefined`) :
|
|
* le message de validation renvoye par le serveur (violations 422 / detail),
|
|
* sinon un libelle generique.
|
|
*/
|
|
function apiErrorMessage(error: unknown): string {
|
|
const data = (error as { data?: unknown })?.data
|
|
return extractApiErrorMessage(data) || t('commercial.clients.toast.error')
|
|
}
|
|
|
|
useHead({ title: t('commercial.clients.form.title') })
|
|
|
|
// Gating de la route : la creation est reservee a `manage`. Compta (accounting
|
|
// seul) et Usine sont rediriges vers le repertoire (cf. §0 du ticket).
|
|
if (!can('commercial.clients.manage')) {
|
|
await navigateTo('/clients')
|
|
}
|
|
|
|
const canAccountingView = computed(() => can('commercial.clients.accounting.view'))
|
|
const canAccountingManage = computed(() => can('commercial.clients.accounting.manage'))
|
|
|
|
const referentials = useClientReferentials()
|
|
|
|
// ── Etat du client cree ────────────────────────────────────────────────────
|
|
const clientId = ref<number | null>(null)
|
|
const mainLocked = ref(false)
|
|
const mainSubmitting = ref(false)
|
|
const tabSubmitting = ref(false)
|
|
|
|
// ── Formulaire principal ────────────────────────────────────────────────────
|
|
const main = reactive({
|
|
companyName: null as string | null,
|
|
firstName: null as string | null,
|
|
lastName: null as string | null,
|
|
email: null as string | null,
|
|
categoryIris: [] as string[],
|
|
relationType: null as 'distributeur' | 'courtier' | null,
|
|
distributorIri: null as string | null,
|
|
brokerIri: null as string | null,
|
|
triageService: false,
|
|
})
|
|
|
|
// Telephones du formulaire principal : 1 par defaut, 2 au maximum (RG-1.02).
|
|
// L'index 0 alimente phonePrimary, l'index 1 phoneSecondary au POST.
|
|
const mainPhones = ref<string[]>([''])
|
|
|
|
/** Revele le 2e numero (le bouton « + » disparait une fois a 2, RG-1.02). */
|
|
function addMainPhone(): void {
|
|
if (mainPhones.value.length === 1) {
|
|
mainPhones.value.push('')
|
|
}
|
|
}
|
|
|
|
// Pas d'option « Aucun » : le select est vide par defaut (relationType = null).
|
|
const relationOptions = computed<RefOption[]>(() => [
|
|
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
|
|
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
|
|
])
|
|
|
|
// Validation du formulaire principal (gate le bouton « Valider ») :
|
|
// - companyName / email / telephone principal / >= 1 categorie obligatoires ;
|
|
// - RG-1.01 : nom OU prenom du contact principal ;
|
|
// - relation Distributeur/Courtier obligatoire (un des deux), ET le nom
|
|
// correspondant obligatoire selon le choix (spec fonctionnelle).
|
|
const isMainValid = computed(() => {
|
|
const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== ''
|
|
// Relation Distributeur/Courtier OPTIONNELLE ; mais si « Depend du
|
|
// distributeur/courtier » est choisi, le nom correspondant devient requis.
|
|
const relationValid
|
|
= main.relationType === null
|
|
|| (main.relationType === 'distributeur' && filled(main.distributorIri))
|
|
|| (main.relationType === 'courtier' && filled(main.brokerIri))
|
|
return filled(main.companyName)
|
|
&& filled(main.email)
|
|
&& filled(mainPhones.value[0])
|
|
&& (filled(main.firstName) || filled(main.lastName))
|
|
&& main.categoryIris.length >= 1
|
|
&& relationValid
|
|
})
|
|
|
|
async function onRelationChange(value: string | number | null): Promise<void> {
|
|
const relation = (value === null || value === '')
|
|
? null
|
|
: (String(value) as 'distributeur' | 'courtier')
|
|
main.relationType = relation
|
|
// Reinitialise la FK non concernee (une seule remplie a la fois, RG-1.03).
|
|
if (relation !== 'distributeur') main.distributorIri = null
|
|
if (relation !== 'courtier') main.brokerIri = null
|
|
|
|
if (relation === 'distributeur') await referentials.loadDistributors()
|
|
if (relation === 'courtier') await referentials.loadBrokers()
|
|
}
|
|
|
|
/** POST /clients (groupe client:write:main). Au succes : verrouille + bascule Information. */
|
|
async function submitMain(): Promise<void> {
|
|
if (!isMainValid.value || mainSubmitting.value) return
|
|
mainSubmitting.value = true
|
|
try {
|
|
const payload: Record<string, unknown> = {
|
|
companyName: main.companyName,
|
|
firstName: main.firstName || null,
|
|
lastName: main.lastName || null,
|
|
email: main.email,
|
|
phonePrimary: mainPhones.value[0] || null,
|
|
phoneSecondary: mainPhones.value[1] || null,
|
|
categories: main.categoryIris,
|
|
distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
|
|
broker: main.relationType === 'courtier' ? main.brokerIri : null,
|
|
triageService: main.triageService,
|
|
}
|
|
const created = await api.post<ClientResponse>('/clients', payload, {
|
|
headers: { Accept: 'application/ld+json' },
|
|
toast: false,
|
|
})
|
|
|
|
clientId.value = created.id
|
|
// Reaffiche les valeurs normalisees renvoyees par le serveur.
|
|
main.companyName = created.companyName ?? main.companyName
|
|
main.firstName = created.firstName ?? null
|
|
main.lastName = created.lastName ?? null
|
|
main.email = created.email ?? main.email
|
|
// Reaffiche les telephones normalises (reformates via formatPhoneFR).
|
|
const normalizedPhones = [formatPhoneFR(created.phonePrimary), formatPhoneFR(created.phoneSecondary)]
|
|
.filter(p => p !== '')
|
|
mainPhones.value = normalizedPhones.length > 0 ? normalizedPhones : ['']
|
|
|
|
// Pre-remplit le 1er contact a partir du formulaire principal (editable).
|
|
prefillFirstContact()
|
|
|
|
mainLocked.value = true
|
|
unlockedIndex.value = 0
|
|
activeTab.value = 'information'
|
|
toast.success({ title: t('commercial.clients.toast.createSuccess') })
|
|
}
|
|
catch (error) {
|
|
// 409 = doublon nom de societe (RG d'unicite) → message explicite ;
|
|
// sinon on remonte le message de validation du serveur (ex: 422).
|
|
const status = (error as { response?: { status?: number } })?.response?.status
|
|
toast.error({
|
|
title: t('commercial.clients.toast.error'),
|
|
message: status === 409
|
|
? t('commercial.clients.form.duplicateCompany')
|
|
: apiErrorMessage(error),
|
|
})
|
|
}
|
|
finally {
|
|
mainSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
// ── Onglets : ordre + gating progressif ─────────────────────────────────────
|
|
const activeTab = ref('information')
|
|
// Index du dernier onglet deverrouille (-1 tant que le client n'est pas cree).
|
|
const unlockedIndex = ref(-1)
|
|
// Onglets valides (passent en lecture seule).
|
|
const validated = reactive<Record<string, boolean>>({})
|
|
|
|
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value))
|
|
|
|
// Icone (Iconify) affichee dans l'onglet, par cle. A ajuster librement.
|
|
const TAB_ICONS: Record<string, string> = {
|
|
information: 'mdi:account-outline',
|
|
contact: 'mdi:account-box-plus-outline',
|
|
address: 'mdi:map-marker-outline',
|
|
transport: 'mdi:truck-delivery-outline',
|
|
accounting: 'mdi:bank-circle-outline',
|
|
statistics: 'mdi:finance',
|
|
reports: 'mdi:file-document-edit-outline',
|
|
exchanges: 'mdi:account-group-outline',
|
|
}
|
|
|
|
const tabs = computed(() => tabKeys.value.map((key, index) => ({
|
|
key,
|
|
label: t(`commercial.clients.tab.${key}`),
|
|
icon: TAB_ICONS[key],
|
|
disabled: index > unlockedIndex.value,
|
|
})))
|
|
|
|
function isValidated(key: string): boolean {
|
|
return validated[key] === true
|
|
}
|
|
|
|
function tabIndex(key: string): number {
|
|
return tabKeys.value.indexOf(key)
|
|
}
|
|
|
|
/** Marque l'onglet valide, deverrouille et avance automatiquement au suivant. */
|
|
function completeTab(key: string): void {
|
|
validated[key] = true
|
|
const next = tabKeys.value[tabIndex(key) + 1]
|
|
unlockedIndex.value = Math.max(unlockedIndex.value, tabIndex(key) + 1)
|
|
if (next) activeTab.value = next
|
|
}
|
|
|
|
// Passage automatique sur les onglets coquille (Transport, Stats, Rapports, Echanges).
|
|
watch(activeTab, (key) => {
|
|
if ((CLIENT_FORM_PLACEHOLDER_TABS as readonly string[]).includes(key)) {
|
|
const next = tabKeys.value[tabIndex(key) + 1]
|
|
unlockedIndex.value = Math.max(unlockedIndex.value, tabIndex(key) + 1)
|
|
if (next) activeTab.value = next
|
|
}
|
|
})
|
|
|
|
// ── Onglet Information ──────────────────────────────────────────────────────
|
|
const information = reactive({
|
|
description: null as string | null,
|
|
competitors: null as string | null,
|
|
foundedAt: null as string | null,
|
|
employeesCount: null as string | null,
|
|
revenueAmount: null as string | null,
|
|
profitAmount: null as string | null,
|
|
directorName: null as string | null,
|
|
})
|
|
|
|
/** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */
|
|
async function submitInformation(): Promise<void> {
|
|
if (clientId.value === null || tabSubmitting.value) return
|
|
tabSubmitting.value = true
|
|
try {
|
|
await api.patch(`/clients/${clientId.value}`, {
|
|
description: information.description || null,
|
|
competitors: information.competitors || null,
|
|
foundedAt: information.foundedAt || null,
|
|
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
|
revenueAmount: information.revenueAmount || null,
|
|
profitAmount: information.profitAmount || null,
|
|
directorName: information.directorName || null,
|
|
}, { toast: false })
|
|
completeTab('information')
|
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
|
}
|
|
catch (error) {
|
|
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
|
|
}
|
|
finally {
|
|
tabSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
// ── Onglet Contact ──────────────────────────────────────────────────────────
|
|
const contacts = ref<ContactFormDraft[]>([emptyContact()])
|
|
|
|
/** Pre-remplit le 1er contact depuis le formulaire principal (apres creation). */
|
|
function prefillFirstContact(): void {
|
|
const first = contacts.value[0]
|
|
if (!first) return
|
|
first.lastName = main.lastName
|
|
first.firstName = main.firstName
|
|
first.email = main.email
|
|
first.phonePrimary = mainPhones.value[0] ?? null
|
|
}
|
|
|
|
// « + Nouveau contact » desactive tant que le dernier bloc n'a ni nom ni prenom.
|
|
const canAddContact = computed(() => {
|
|
const last = contacts.value[contacts.value.length - 1]
|
|
return last !== undefined && isContactNamed(last)
|
|
})
|
|
|
|
// RG-1.14 : au moins un contact nomme pour finaliser l'onglet.
|
|
const canValidateContacts = computed(() => hasAtLeastOneValidContact(contacts.value))
|
|
|
|
function addContact(): void {
|
|
if (canAddContact.value) contacts.value.push(emptyContact())
|
|
}
|
|
|
|
function askRemoveContact(index: number): void {
|
|
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => {
|
|
contacts.value.splice(index, 1)
|
|
})
|
|
}
|
|
|
|
/** POST/PATCH des contacts sur la sous-ressource /clients/{id}/contacts. */
|
|
async function submitContacts(): Promise<void> {
|
|
if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return
|
|
tabSubmitting.value = true
|
|
try {
|
|
for (const contact of contacts.value) {
|
|
// On ignore les blocs totalement vides (ni nom ni prenom).
|
|
if (!isContactNamed(contact)) continue
|
|
|
|
const body = {
|
|
firstName: contact.firstName || null,
|
|
lastName: contact.lastName || null,
|
|
jobTitle: contact.jobTitle || null,
|
|
phonePrimary: contact.phonePrimary || null,
|
|
phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null,
|
|
email: contact.email || null,
|
|
}
|
|
|
|
if (contact.id === null) {
|
|
const created = await api.post<ContactResponse>(
|
|
`/clients/${clientId.value}/contacts`,
|
|
body,
|
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
|
)
|
|
contact.id = created.id
|
|
contact.iri = created['@id'] ?? null
|
|
}
|
|
else {
|
|
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
|
|
}
|
|
}
|
|
completeTab('contact')
|
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
|
}
|
|
catch (error) {
|
|
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
|
|
}
|
|
finally {
|
|
tabSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
// ── Onglet Adresse ──────────────────────────────────────────────────────────
|
|
const addresses = ref<AddressFormDraft[]>([emptyAddress()])
|
|
const addressDegradedNotified = ref(false)
|
|
|
|
// Categories autorisees sur une adresse : toutes SAUF DISTRIBUTEUR/COURTIER (RG-1.29).
|
|
const addressCategoryOptions = computed(() =>
|
|
referentials.categories.value.filter(c => !FORBIDDEN_ADDRESS_CATEGORY_CODES.includes(c.code)),
|
|
)
|
|
|
|
// Contacts deja crees, rattachables a une adresse (M2M, via leur IRI).
|
|
const contactOptions = computed<RefOption[]>(() =>
|
|
contacts.value
|
|
.filter(c => c.iri !== null)
|
|
.map(c => ({
|
|
value: c.iri as string,
|
|
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? ''),
|
|
})),
|
|
)
|
|
|
|
// Pays disponibles (France preselectionnee par defaut sur chaque adresse).
|
|
const countryOptions: RefOption[] = [
|
|
{ value: 'France', label: 'France' },
|
|
{ value: 'Espagne', label: 'Espagne' },
|
|
]
|
|
|
|
// RG-1.10 (>= 1 site) + RG-1.11 (email facturation si Facturation) sur chaque adresse.
|
|
const canValidateAddresses = computed(() =>
|
|
addresses.value.length > 0
|
|
&& addresses.value.every((a) => {
|
|
const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== ''
|
|
return a.siteIris.length >= 1 && (!isBillingEmailRequired(a) || filledBillingEmail)
|
|
}),
|
|
)
|
|
|
|
function addAddress(): void {
|
|
addresses.value.push(emptyAddress())
|
|
}
|
|
|
|
function askRemoveAddress(index: number): void {
|
|
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => {
|
|
addresses.value.splice(index, 1)
|
|
})
|
|
}
|
|
|
|
/** Avertit une seule fois quand l'autocompletion d'adresse bascule en degrade. */
|
|
function onAddressDegraded(): void {
|
|
if (addressDegradedNotified.value) return
|
|
addressDegradedNotified.value = true
|
|
toast.warning({
|
|
title: t('commercial.clients.toast.error'),
|
|
message: t('commercial.clients.form.address.degraded'),
|
|
})
|
|
}
|
|
|
|
/** POST des adresses sur la sous-ressource /clients/{id}/addresses. */
|
|
async function submitAddresses(): Promise<void> {
|
|
if (clientId.value === null || !canValidateAddresses.value || tabSubmitting.value) return
|
|
tabSubmitting.value = true
|
|
try {
|
|
for (const address of addresses.value) {
|
|
const body = {
|
|
isProspect: address.isProspect,
|
|
isDelivery: address.isDelivery,
|
|
isBilling: address.isBilling,
|
|
country: address.country,
|
|
postalCode: address.postalCode || null,
|
|
city: address.city || null,
|
|
street: address.street || null,
|
|
streetComplement: address.streetComplement || null,
|
|
categories: address.categoryIris,
|
|
sites: address.siteIris,
|
|
contacts: address.contactIris,
|
|
billingEmail: isBillingEmailRequired(address) ? (address.billingEmail || null) : null,
|
|
}
|
|
|
|
if (address.id === null) {
|
|
const created = await api.post<{ id: number }>(
|
|
`/clients/${clientId.value}/addresses`,
|
|
body,
|
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
|
)
|
|
address.id = created.id
|
|
}
|
|
else {
|
|
await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
|
|
}
|
|
}
|
|
completeTab('address')
|
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
|
}
|
|
catch (error) {
|
|
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
|
|
}
|
|
finally {
|
|
tabSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
// ── Onglet Comptabilite ─────────────────────────────────────────────────────
|
|
const accounting = reactive({
|
|
siren: null as string | null,
|
|
accountNumber: null as string | null,
|
|
tvaModeIri: null as string | null,
|
|
nTva: null as string | null,
|
|
paymentDelayIri: null as string | null,
|
|
paymentTypeIri: null as string | null,
|
|
bankIri: null as string | null,
|
|
})
|
|
const ribs = ref<RibFormDraft[]>([])
|
|
|
|
// L'onglet est editable seulement avec accounting.manage (sinon lecture seule).
|
|
const accountingReadonly = computed(() => isValidated('accounting') || !canAccountingManage.value)
|
|
|
|
// Code du type de reglement selectionne (pour RG-1.12 / RG-1.13).
|
|
const selectedPaymentTypeCode = computed(() =>
|
|
referentials.paymentTypes.value.find(p => p.value === accounting.paymentTypeIri)?.code ?? null,
|
|
)
|
|
const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value))
|
|
const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value))
|
|
|
|
function onPaymentTypeChange(value: string | number | null): void {
|
|
accounting.paymentTypeIri = value === null ? null : String(value)
|
|
// La banque n'a de sens que pour un virement : on la vide sinon (RG-1.12).
|
|
if (!isBankRequired.value) accounting.bankIri = null
|
|
}
|
|
|
|
function ribIsComplete(rib: RibFormDraft): boolean {
|
|
const filled = (v: string | null) => v !== null && v.trim() !== ''
|
|
return filled(rib.label) && filled(rib.bic) && filled(rib.iban)
|
|
}
|
|
|
|
// RG-1.12 : banque requise si VIREMENT. RG-1.13 : >= 1 RIB complet si LCR.
|
|
const canValidateAccounting = computed(() => {
|
|
if (isBankRequired.value && (accounting.bankIri === null)) return false
|
|
if (isRibRequired.value && !ribs.value.some(ribIsComplete)) return false
|
|
return true
|
|
})
|
|
|
|
function addRib(): void {
|
|
ribs.value.push(emptyRib())
|
|
}
|
|
|
|
function askRemoveRib(index: number): void {
|
|
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => {
|
|
ribs.value.splice(index, 1)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Valide l'onglet Comptabilite : PATCH des scalaires (groupe client:write:accounting)
|
|
* PUIS POST des RIB sur /clients/{id}/ribs. Deux appels distincts (mode strict
|
|
* RG-1.28 : il n'existe pas d'endpoint /accounting, cf. recon back).
|
|
*/
|
|
async function submitAccounting(): Promise<void> {
|
|
if (clientId.value === null || !canValidateAccounting.value || tabSubmitting.value) return
|
|
tabSubmitting.value = true
|
|
try {
|
|
await api.patch(`/clients/${clientId.value}`, {
|
|
siren: accounting.siren || null,
|
|
accountNumber: accounting.accountNumber || null,
|
|
tvaMode: accounting.tvaModeIri,
|
|
nTva: accounting.nTva || null,
|
|
paymentDelay: accounting.paymentDelayIri,
|
|
paymentType: accounting.paymentTypeIri,
|
|
bank: isBankRequired.value ? accounting.bankIri : null,
|
|
}, { toast: false })
|
|
|
|
for (const rib of ribs.value) {
|
|
if (!ribIsComplete(rib)) continue
|
|
if (rib.id === null) {
|
|
const created = await api.post<{ id: number }>(
|
|
`/clients/${clientId.value}/ribs`,
|
|
{ label: rib.label, bic: rib.bic, iban: rib.iban },
|
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
|
)
|
|
rib.id = created.id
|
|
}
|
|
else {
|
|
await api.patch(`/client_ribs/${rib.id}`, { label: rib.label, bic: rib.bic, iban: rib.iban }, { toast: false })
|
|
}
|
|
}
|
|
|
|
completeTab('accounting')
|
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
|
}
|
|
catch (error) {
|
|
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
|
|
}
|
|
finally {
|
|
tabSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
// ── Modal de confirmation generique ─────────────────────────────────────────
|
|
const confirmModal = reactive({
|
|
open: false,
|
|
message: '',
|
|
action: null as null | (() => void),
|
|
})
|
|
|
|
function askConfirm(message: string, action: () => void): void {
|
|
confirmModal.message = message
|
|
confirmModal.action = action
|
|
confirmModal.open = true
|
|
}
|
|
|
|
function runConfirm(): void {
|
|
confirmModal.action?.()
|
|
confirmModal.action = null
|
|
confirmModal.open = false
|
|
}
|
|
|
|
// ── Types de reponse API ────────────────────────────────────────────────────
|
|
interface ClientResponse {
|
|
id: number
|
|
companyName: string | null
|
|
firstName: string | null
|
|
lastName: string | null
|
|
email: string | null
|
|
phonePrimary: string | null
|
|
phoneSecondary: string | null
|
|
}
|
|
|
|
interface ContactResponse {
|
|
'@id'?: string
|
|
id: number
|
|
}
|
|
|
|
onMounted(() => {
|
|
// Echec du chargement des referentiels non bloquant : les selects restent vides.
|
|
referentials.loadCommon().catch(() => {})
|
|
})
|
|
</script>
|