c73111121f
L'onglet Comptabilite envoyait le PATCH des scalaires (paymentType=LCR) AVANT le POST des RIB. Le back valide RG-1.13 (LCR => au moins un RIB persiste) sur ce PATCH en lisant les RIB en base : vides a ce stade -> 422 « Au moins un RIB est obligatoire pour le type de reglement LCR », et le return empechait la creation des RIB. Premier passage en LCR impossible. Ordre inverse : POST/PATCH des RIB d'abord, puis PATCH des scalaires. Sur l'ecran edition, ordre universel sur CREATE/UPDATE RIB -> PATCH scalaires -> DELETE RIB retires (les suppressions restent apres le PATCH : le guard back n'autorise la suppression du dernier RIB qu'une fois quitte LCR). Corrige au passage un 409 latent sur le swap du dernier RIB en LCR.
1045 lines
47 KiB
Vue
1045 lines
47 KiB
Vue
<template>
|
|
<div>
|
|
<!-- En-tete : retour vers le repertoire + titre. -->
|
|
<div class="flex items-center gap-3 pt-11">
|
|
<MalioButtonIcon
|
|
icon="mdi:arrow-left-bold"
|
|
icon-size="24"
|
|
variant="ghost"
|
|
v-bind="{ ariaLabel: t('commercial.clients.form.back') }"
|
|
@click="goBack"
|
|
/>
|
|
<h1 class="text-[30px] font-semibold 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 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
|
<MalioInputText
|
|
v-model="main.companyName"
|
|
:label="t('commercial.clients.form.main.companyName')"
|
|
:required="true"
|
|
:readonly="mainLocked"
|
|
:error="mainErrors.errors.companyName"
|
|
/>
|
|
<MalioSelectCheckbox
|
|
:model-value="main.categoryIris"
|
|
:options="referentials.categories.value"
|
|
:label="t('commercial.clients.form.main.categories')"
|
|
:display-tag="true"
|
|
:readonly="mainLocked"
|
|
:required="true"
|
|
:error="mainErrors.errors.categories"
|
|
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
|
/>
|
|
<MalioSelect
|
|
v-if="showRelationAndTriage"
|
|
:model-value="main.relationType"
|
|
:options="relationOptions"
|
|
:label="t('commercial.clients.form.main.relation')"
|
|
:empty-option-label="t('commercial.clients.form.main.relationNone')"
|
|
:readonly="mainLocked"
|
|
@update:model-value="onRelationChange"
|
|
/>
|
|
<MalioSelect
|
|
v-if="showRelationAndTriage && main.relationType === 'courtier'"
|
|
:model-value="main.brokerIri"
|
|
:options="referentials.brokers.value"
|
|
:label="t('commercial.clients.form.main.brokerName')"
|
|
:readonly="mainLocked"
|
|
:required="true"
|
|
:error="mainErrors.errors.broker"
|
|
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
|
|
/>
|
|
<MalioSelect
|
|
v-if="showRelationAndTriage && main.relationType === 'distributeur'"
|
|
:model-value="main.distributorIri"
|
|
:options="referentials.distributors.value"
|
|
:label="t('commercial.clients.form.main.distributorName')"
|
|
:readonly="mainLocked"
|
|
:required="true"
|
|
:error="mainErrors.errors.distributor"
|
|
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
|
|
/>
|
|
<MalioCheckbox
|
|
v-if="showRelationAndTriage"
|
|
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-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
|
<!-- pt-1/pb-1 alignent le textarea (h-full) sur les inputs, dont
|
|
le champ de 40px est centre dans un conteneur h-12 (~4px de
|
|
coussin en HAUT et en BAS). Sans pb-1, le textarea descend ~4px
|
|
plus bas que les champs voisins. -->
|
|
<MalioInputTextArea
|
|
v-model="information.description"
|
|
:label="t('commercial.clients.form.information.description')"
|
|
resize="none"
|
|
group-class="row-span-2 pt-1 pb-1"
|
|
text-input="h-full text-lg"
|
|
:readonly="isValidated('information')"
|
|
:error="informationErrors.errors.description"
|
|
/>
|
|
<MalioInputText
|
|
v-model="information.competitors"
|
|
:label="t('commercial.clients.form.information.competitors')"
|
|
:readonly="isValidated('information')"
|
|
:error="informationErrors.errors.competitors"
|
|
/>
|
|
<MalioDate
|
|
v-model="information.foundedAt"
|
|
:label="t('commercial.clients.form.information.foundedAt')"
|
|
:readonly="isValidated('information')"
|
|
:error="informationErrors.errors.foundedAt"
|
|
/>
|
|
<MalioInputText
|
|
v-model="information.employeesCount"
|
|
:label="t('commercial.clients.form.information.employeesCount')"
|
|
:mask="EMPLOYEES_MASK"
|
|
:readonly="isValidated('information')"
|
|
:error="informationErrors.errors.employeesCount"
|
|
/>
|
|
<MalioInputAmount
|
|
v-model="information.revenueAmount"
|
|
:label="t('commercial.clients.form.information.revenueAmount')"
|
|
:readonly="isValidated('information')"
|
|
:error="informationErrors.errors.revenueAmount"
|
|
/>
|
|
<MalioInputText
|
|
v-model="information.directorName"
|
|
:label="t('commercial.clients.form.information.directorName')"
|
|
:readonly="isValidated('information')"
|
|
:error="informationErrors.errors.directorName"
|
|
/>
|
|
<MalioInputAmount
|
|
v-model="information.profitAmount"
|
|
:label="t('commercial.clients.form.information.profitAmount')"
|
|
:readonly="isValidated('information')"
|
|
:error="informationErrors.errors.profitAmount"
|
|
/>
|
|
</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 clic trop tot, Information etant l'onglet
|
|
actif par defaut) OU si aucun champ n'est rempli : onglet
|
|
facultatif, mais pas de validation a vide (on passe alors
|
|
directement a Contact). -->
|
|
<MalioButton
|
|
variant="primary"
|
|
:label="t('commercial.clients.form.submit')"
|
|
:disabled="tabSubmitting || clientId === null || !canValidateInformation"
|
|
@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')"
|
|
:errors="contactErrors[index]"
|
|
@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')"
|
|
:errors="addressErrors[index]"
|
|
@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')"
|
|
:disabled="!canAddAddress"
|
|
@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-4 gap-x-[44px] gap-y-4">
|
|
<MalioInputText
|
|
v-model="accounting.siren"
|
|
:label="t('commercial.clients.form.accounting.siren')"
|
|
:mask="SIREN_MASK"
|
|
:readonly="accountingReadonly"
|
|
:required="true"
|
|
:error="accountingErrors.errors.siren"
|
|
/>
|
|
<MalioInputText
|
|
v-model="accounting.accountNumber"
|
|
:label="t('commercial.clients.form.accounting.accountNumber')"
|
|
:readonly="accountingReadonly"
|
|
:required="true"
|
|
:error="accountingErrors.errors.accountNumber"
|
|
/>
|
|
<MalioSelect
|
|
:model-value="accounting.tvaModeIri"
|
|
:options="referentials.tvaModes.value"
|
|
:label="t('commercial.clients.form.accounting.tvaMode')"
|
|
:readonly="accountingReadonly"
|
|
empty-option-label=""
|
|
:required="true"
|
|
:error="accountingErrors.errors.tvaMode"
|
|
@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"
|
|
:required="true"
|
|
:error="accountingErrors.errors.nTva"
|
|
/>
|
|
<MalioSelect
|
|
:model-value="accounting.paymentDelayIri"
|
|
:options="referentials.paymentDelays.value"
|
|
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
|
:readonly="accountingReadonly"
|
|
empty-option-label=""
|
|
:required="true"
|
|
:error="accountingErrors.errors.paymentDelay"
|
|
@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')"
|
|
:readonly="accountingReadonly"
|
|
empty-option-label=""
|
|
:required="true"
|
|
:error="accountingErrors.errors.paymentType"
|
|
@update:model-value="onPaymentTypeChange"
|
|
/>
|
|
<MalioSelect
|
|
v-if="isBankRequired"
|
|
:model-value="accounting.bankIri"
|
|
:options="referentials.banks.value"
|
|
:label="t('commercial.clients.form.accounting.bank')"
|
|
:readonly="accountingReadonly"
|
|
empty-option-label=""
|
|
:required="true"
|
|
:error="accountingErrors.errors.bank"
|
|
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13). -->
|
|
<div
|
|
v-for="(rib, index) in visibleRibs"
|
|
: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-4 gap-x-[44px] gap-y-4">
|
|
<MalioInputText
|
|
v-model="rib.label"
|
|
:label="t('commercial.clients.form.accounting.ribLabel')"
|
|
:readonly="accountingReadonly"
|
|
:required="isRibRequired"
|
|
:error="ribErrors[index]?.label"
|
|
/>
|
|
<MalioInputText
|
|
v-model="rib.bic"
|
|
:label="t('commercial.clients.form.accounting.ribBic')"
|
|
:readonly="accountingReadonly"
|
|
:required="isRibRequired"
|
|
:error="ribErrors[index]?.bic"
|
|
/>
|
|
<MalioInputText
|
|
v-model="rib.iban"
|
|
:label="t('commercial.clients.form.accounting.ribIban')"
|
|
:readonly="accountingReadonly"
|
|
:required="isRibRequired"
|
|
:error="ribErrors[index]?.iban"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="!accountingReadonly" class="flex justify-center gap-6">
|
|
<MalioButton
|
|
v-if="isRibRequired"
|
|
variant="secondary"
|
|
icon-name="mdi:add-bold"
|
|
icon-position="left"
|
|
:label="t('commercial.clients.form.accounting.addRib')"
|
|
:disabled="!canAddRib"
|
|
@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><ComingSoonPlaceholder /></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 { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
|
|
import {
|
|
buildClientFormTabKeys,
|
|
CLIENT_FORM_PLACEHOLDER_TABS,
|
|
hasAllRequiredAccountingFields,
|
|
hasAtLeastOneInformationField,
|
|
hasAtLeastOneValidContact,
|
|
isAddressValid,
|
|
isBankRequiredForPaymentType,
|
|
isBillingEmailRequired,
|
|
isContactBlank,
|
|
isContactNamed,
|
|
isRibBlank,
|
|
isRibComplete,
|
|
isRibRequiredForPaymentType,
|
|
showsRelationAndTriageFields,
|
|
} from '~/modules/commercial/utils/clientFormRules'
|
|
import {
|
|
emptyAddress,
|
|
emptyContact,
|
|
emptyRib,
|
|
type AddressFormDraft,
|
|
type ContactFormDraft,
|
|
type RibFormDraft,
|
|
} from '~/modules/commercial/types/clientForm'
|
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
|
|
|
// Masques de saisie (la normalisation finale reste serveur).
|
|
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')
|
|
}
|
|
|
|
// ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
|
|
// Etat d'erreurs factorise entre creation et edition (cf. useClientFormErrors) :
|
|
// un `useFormErrors` par groupe scalaire (Principal / Information / Comptabilite)
|
|
// + un tableau d'erreurs par ligne pour chaque collection (contacts/adresses/RIB).
|
|
// `mapRowError` mappe une 422 inline et retourne true ; il ne toaste pas, le
|
|
// fallback reste local a la creation (cf. catch des submits de collection).
|
|
const {
|
|
mainErrors,
|
|
informationErrors,
|
|
accountingErrors,
|
|
contactErrors,
|
|
addressErrors,
|
|
ribErrors,
|
|
submitRows,
|
|
} = useClientFormErrors()
|
|
|
|
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,
|
|
categoryIris: [] as string[],
|
|
relationType: null as 'distributeur' | 'courtier' | null,
|
|
distributorIri: null as string | null,
|
|
brokerIri: null as string | null,
|
|
triageService: false,
|
|
})
|
|
|
|
// 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') },
|
|
])
|
|
|
|
// Codes des categories selectionnees (resolus depuis les IRI du brouillon).
|
|
const selectedCategoryCodes = computed(() =>
|
|
main.categoryIris
|
|
.map(iri => referentials.categories.value.find(c => c.value === iri)?.code)
|
|
.filter((code): code is string => code !== undefined),
|
|
)
|
|
|
|
// « Relation » + « Prestation de triage » masques par defaut, reveles des qu'une
|
|
// categorie ordinaire (autre que Distributeur/Courtier) est selectionnee.
|
|
const showRelationAndTriage = computed(() => showsRelationAndTriageFields(selectedCategoryCodes.value))
|
|
|
|
// Masquer ces champs reinitialise leurs valeurs : pas de relation/triage fantome
|
|
// soumis pour un client Distributeur/Courtier.
|
|
watch(showRelationAndTriage, (visible) => {
|
|
if (!visible) {
|
|
main.relationType = null
|
|
main.distributorIri = null
|
|
main.brokerIri = null
|
|
main.triageService = false
|
|
}
|
|
})
|
|
|
|
// Validation du formulaire principal (gate le bouton « Valider ») :
|
|
// - companyName / >= 1 categorie obligatoires ;
|
|
// - relation Distributeur/Courtier optionnelle, mais le nom correspondant
|
|
// devient requis si l'un des deux est choisi (spec fonctionnelle).
|
|
// Les coordonnees de contact ne sont plus saisies ici : elles vivent dans
|
|
// l'onglet Contacts (RG-1.05/1.14 garantissent >= 1 contact valide).
|
|
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)
|
|
&& 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
|
|
mainErrors.clearErrors()
|
|
try {
|
|
const payload: Record<string, unknown> = {
|
|
companyName: main.companyName,
|
|
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 la valeur normalisee renvoyee par le serveur.
|
|
main.companyName = created.companyName ?? main.companyName
|
|
|
|
mainLocked.value = true
|
|
// Information est facultatif : on deverrouille jusqu'a Contact (index 1)
|
|
// pour que l'utilisateur puisse y aller directement sans valider Information.
|
|
unlockedIndex.value = tabIndex('contact')
|
|
activeTab.value = 'information'
|
|
toast.success({ title: t('commercial.clients.toast.createSuccess') })
|
|
}
|
|
catch (error) {
|
|
// 409 = doublon nom de societe (RG d'unicite) → erreur inline sur le
|
|
// champ + toast explicite ; 422 → mapping inline par champ (pas de
|
|
// toast) ; autre → toast de fallback. Cf. ERP-101.
|
|
const status = (error as { response?: { status?: number } })?.response?.status
|
|
if (status === 409) {
|
|
const message = t('commercial.clients.form.duplicateCompany')
|
|
mainErrors.setError('companyName', message)
|
|
toast.error({ title: t('commercial.clients.toast.error'), message })
|
|
}
|
|
else {
|
|
mainErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.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,
|
|
})
|
|
|
|
// Onglet facultatif, mais pas de validation « a vide » : au moins un champ rempli.
|
|
const canValidateInformation = computed(() => hasAtLeastOneInformationField(information))
|
|
|
|
/** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */
|
|
async function submitInformation(): Promise<void> {
|
|
if (clientId.value === null || tabSubmitting.value || !canValidateInformation.value) return
|
|
tabSubmitting.value = true
|
|
informationErrors.clearErrors()
|
|
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) {
|
|
informationErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
|
|
}
|
|
finally {
|
|
tabSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
// ── Onglet Contact ──────────────────────────────────────────────────────────
|
|
// Au moins un bloc Contact vide au depart : c'est desormais le seul point de
|
|
// saisie des coordonnees (le bloc principal ne porte plus de contact inline).
|
|
const contacts = ref<ContactFormDraft[]>([emptyContact()])
|
|
|
|
// « + 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)
|
|
contactErrors.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 {
|
|
// On tente TOUS les blocs (collecte des erreurs par index, ERP-110). Seuls
|
|
// les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli
|
|
// sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc.
|
|
const hasError = await submitRows(
|
|
contacts.value,
|
|
contactErrors,
|
|
async (contact) => {
|
|
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 })
|
|
}
|
|
},
|
|
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
|
|
// On ne saute QUE les amorces neuves (id null) totalement vides. Un
|
|
// bloc existant vide est soumis -> 422 RG-1.05 inline (sinon la modif
|
|
// serait perdue en silence avec un faux toast de succes).
|
|
contact => contact.id === null && isContactBlank(contact),
|
|
)
|
|
// Tant qu'un bloc reste en erreur : pas de validation d'onglet ni de toast succes.
|
|
if (hasError) return
|
|
completeTab('contact')
|
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
|
}
|
|
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' },
|
|
]
|
|
|
|
// Type d'adresse (Select) obligatoire + 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(isAddressValid),
|
|
)
|
|
|
|
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
|
|
const canAddAddress = computed(() => {
|
|
const last = addresses.value[addresses.value.length - 1]
|
|
return last !== undefined && isAddressValid(last)
|
|
})
|
|
|
|
function addAddress(): void {
|
|
if (canAddAddress.value) addresses.value.push(emptyAddress())
|
|
}
|
|
|
|
function askRemoveAddress(index: number): void {
|
|
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => {
|
|
addresses.value.splice(index, 1)
|
|
addressErrors.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 {
|
|
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110).
|
|
const hasError = await submitRows(
|
|
addresses.value,
|
|
addressErrors,
|
|
async (address) => {
|
|
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 })
|
|
}
|
|
},
|
|
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
|
|
)
|
|
if (hasError) return
|
|
completeTab('address')
|
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
|
}
|
|
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))
|
|
|
|
// Les blocs RIB ne sont affiches que pour une LCR (RG-1.13).
|
|
const visibleRibs = computed(() => isRibRequired.value ? ribs.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
|
|
// Les RIB n'ont de sens que pour une LCR (RG-1.13) : on amorce un bloc vide
|
|
// quand LCR est choisi, on vide la liste sinon (pas de RIB fantome soumis).
|
|
if (isRibRequired.value) {
|
|
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
|
}
|
|
else {
|
|
ribs.value = []
|
|
ribErrors.value = []
|
|
}
|
|
}
|
|
|
|
// RG-1.30 : les 6 champs scalaires obligatoires (comme les onglets Contact /
|
|
// Adresse, le bouton reste desactive tant que l'onglet n'est pas complet).
|
|
// RG-1.12 : banque requise si VIREMENT. RG-1.13 : >= 1 RIB complet si LCR.
|
|
const canValidateAccounting = computed(() => {
|
|
if (!hasAllRequiredAccountingFields(accounting)) return false
|
|
if (isBankRequired.value && (accounting.bankIri === null)) return false
|
|
if (isRibRequired.value && !ribs.value.some(isRibComplete)) return false
|
|
return true
|
|
})
|
|
|
|
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet.
|
|
const canAddRib = computed(() => {
|
|
const last = ribs.value[ribs.value.length - 1]
|
|
return last !== undefined && isRibComplete(last)
|
|
})
|
|
|
|
function addRib(): void {
|
|
if (canAddRib.value) ribs.value.push(emptyRib())
|
|
}
|
|
|
|
function askRemoveRib(index: number): void {
|
|
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => {
|
|
ribs.value.splice(index, 1)
|
|
ribErrors.value.splice(index, 1)
|
|
// Garde au moins un bloc RIB visible (cf. amorce au montage).
|
|
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur /clients/{id}/ribs PUIS
|
|
* PATCH des scalaires (groupe client:write:accounting). Les RIB d'abord : le back
|
|
* valide RG-1.13 (LCR => au moins un RIB persiste) sur le PATCH scalaires, les RIB
|
|
* doivent donc exister en base AVANT (sinon 422 « Au moins un RIB est obligatoire
|
|
* pour le type de reglement LCR »). 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
|
|
accountingErrors.clearErrors()
|
|
try {
|
|
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs
|
|
// tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2.
|
|
// Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex.
|
|
// IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline.
|
|
const ribHasError = await submitRows(
|
|
ribs.value,
|
|
ribErrors,
|
|
async (rib) => {
|
|
const body = { label: rib.label, bic: rib.bic, iban: rib.iban }
|
|
if (rib.id === null) {
|
|
const created = await api.post<{ id: number }>(
|
|
`/clients/${clientId.value}/ribs`,
|
|
body,
|
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
|
)
|
|
rib.id = created.id
|
|
}
|
|
else {
|
|
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
|
|
}
|
|
},
|
|
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
|
|
// On ne saute QUE les amorces neuves (id null) totalement vides. Un
|
|
// RIB existant vide est soumis -> 422 NotBlank inline (sinon la modif
|
|
// serait perdue en silence avec un faux toast de succes).
|
|
rib => rib.id === null && isRibBlank(rib),
|
|
)
|
|
if (ribHasError) return
|
|
|
|
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
|
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 })
|
|
}
|
|
catch (error) {
|
|
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
|
|
return
|
|
}
|
|
|
|
completeTab('accounting')
|
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
|
}
|
|
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
|
|
}
|
|
|
|
interface ContactResponse {
|
|
'@id'?: string
|
|
id: number
|
|
}
|
|
|
|
onMounted(() => {
|
|
// Echec du chargement des referentiels non bloquant : les selects restent vides.
|
|
referentials.loadCommon().catch(() => {})
|
|
// Pas d'amorce de RIB ici : un bloc vide n'apparait que si LCR est choisi
|
|
// (cf. onPaymentTypeChange).
|
|
})
|
|
</script>
|