e3421ac4c8
- DELETE immediat des sous-ressources (contacts / adresses / RIB) a la confirmation de la modale sur les ecrans de modification M1 / M2 / M3, au lieu d'un DELETE differe qui ne partait jamais sans re-validation de l'onglet. Helper partage removeCollectionRow (+ tests) ; le mecanisme differe (removed*Ids + boucles dans submit*) devenu mort est supprime. - Affichage de la poubelle des blocs de collection unifie sur les 3 modules via isRowRemovable : visible seulement s'il reste un AUTRE bloc deja enregistre (id en base). Empeche de supprimer un bloc tant que rien n'est sauvegarde, et de supprimer son dernier bloc enregistre. Applique aux ecrans new + edit (contacts / adresses / RIB).
536 lines
24 KiB
Vue
536 lines
24 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('technique.providers.form.back') }"
|
|
@click="goBack"
|
|
/>
|
|
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('technique.providers.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 Contact (PAS d'onglet Information au M3).
|
|
Selecteur de site present ici (RG-3.03, relation directe). -->
|
|
<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('technique.providers.form.main.companyName')"
|
|
:required="true"
|
|
:readonly="mainLocked"
|
|
:error="mainErrors.errors.companyName"
|
|
/>
|
|
<MalioSelectCheckbox
|
|
:model-value="main.categoryIris"
|
|
:options="referentials.categories.value"
|
|
:label="t('technique.providers.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)"
|
|
/>
|
|
<MalioSelectCheckbox
|
|
:model-value="main.siteIris"
|
|
:options="referentials.sites.value"
|
|
:label="t('technique.providers.form.main.sites')"
|
|
:display-tag="true"
|
|
:readonly="mainLocked"
|
|
:required="true"
|
|
:error="mainErrors.errors.sites"
|
|
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="!mainLocked" class="mt-12 flex justify-center">
|
|
<MalioButton
|
|
variant="primary"
|
|
:label="t('technique.providers.form.submit')"
|
|
:disabled="mainSubmitting"
|
|
@click="submitMain"
|
|
/>
|
|
</div>
|
|
|
|
<!-- ── Onglets a validation incrementale ─────────────────────────────
|
|
Onglet Contact actif (ERP-142) ; Adresse / Comptabilite arrivent aux
|
|
tickets ERP-143 / 144 : placeholders « A venir » pour l'instant. -->
|
|
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
|
<!-- Onglet Contact : saisie multi-contacts (blocs ajoutables). -->
|
|
<template #contact>
|
|
<div class="mt-12 flex flex-col gap-6">
|
|
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
|
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
|
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
|
bloc enregistre. -->
|
|
<ProviderContactBlock
|
|
v-for="(contact, index) in contacts"
|
|
:key="index"
|
|
:model-value="contact"
|
|
:removable="isRowRemovable(contacts, index)"
|
|
: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('technique.providers.form.contact.add')"
|
|
:disabled="!canAddContact"
|
|
@click="addContact"
|
|
/>
|
|
<MalioButton
|
|
variant="primary"
|
|
:label="t('technique.providers.form.submit')"
|
|
:disabled="tabSubmitting || providerId === null"
|
|
@click="onSubmitContacts"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<!-- Onglet Adresse : saisie multi-adresses (blocs ajoutables). -->
|
|
<template #address>
|
|
<div class="mt-12 flex flex-col gap-6">
|
|
<ProviderAddressBlock
|
|
v-for="(address, index) in addresses"
|
|
:key="index"
|
|
:model-value="address"
|
|
:category-options="referentials.categories.value"
|
|
:site-options="referentials.sites.value"
|
|
:contact-options="contactOptions"
|
|
:country-options="countryOptions"
|
|
:removable="isRowRemovable(addresses, index)"
|
|
: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('technique.providers.form.address.add')"
|
|
:disabled="!canAddAddress"
|
|
@click="addAddress"
|
|
/>
|
|
<MalioButton
|
|
variant="primary"
|
|
:label="t('technique.providers.form.submit')"
|
|
:disabled="tabSubmitting || providerId === null"
|
|
@click="onSubmitAddresses"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<!-- Onglet Comptabilite (present uniquement si accounting.view ; editable si manage). -->
|
|
<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('technique.providers.form.accounting.siren')"
|
|
:mask="SIREN_MASK"
|
|
:readonly="accountingReadonly"
|
|
:required="true"
|
|
:error="accountingErrors.errors.siren"
|
|
/>
|
|
<MalioInputText
|
|
v-model="accounting.accountNumber"
|
|
:label="t('technique.providers.form.accounting.accountNumber')"
|
|
:readonly="accountingReadonly"
|
|
:required="true"
|
|
:error="accountingErrors.errors.accountNumber"
|
|
/>
|
|
<MalioSelect
|
|
:model-value="accounting.tvaModeIri"
|
|
:options="referentials.tvaModes.value"
|
|
:label="t('technique.providers.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('technique.providers.form.accounting.nTva')"
|
|
:readonly="accountingReadonly"
|
|
:required="true"
|
|
:error="accountingErrors.errors.nTva"
|
|
/>
|
|
<MalioSelect
|
|
:model-value="accounting.paymentDelayIri"
|
|
:options="referentials.paymentDelays.value"
|
|
:label="t('technique.providers.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('technique.providers.form.accounting.paymentType')"
|
|
:readonly="accountingReadonly"
|
|
empty-option-label=""
|
|
:required="true"
|
|
:error="accountingErrors.errors.paymentType"
|
|
@update:model-value="onPaymentTypeChange"
|
|
/>
|
|
<!-- Banque : visible et obligatoire seulement si VIREMENT (RG-3.07). -->
|
|
<MalioSelect
|
|
v-if="isBankRequired"
|
|
:model-value="accounting.bankIri"
|
|
:options="referentials.banks.value"
|
|
:label="t('technique.providers.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-3.08). -->
|
|
<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)]"
|
|
>
|
|
<MalioButtonIcon
|
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
|
icon="mdi:delete-outline"
|
|
variant="ghost"
|
|
button-class="absolute top-3 right-3"
|
|
v-bind="{ ariaLabel: t('technique.providers.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('technique.providers.form.accounting.ribLabel')"
|
|
:readonly="accountingReadonly"
|
|
:required="true"
|
|
:error="ribErrors[index]?.label"
|
|
/>
|
|
<MalioInputText
|
|
v-model="rib.bic"
|
|
:label="t('technique.providers.form.accounting.ribBic')"
|
|
:readonly="accountingReadonly"
|
|
:required="true"
|
|
:error="ribErrors[index]?.bic"
|
|
/>
|
|
<MalioInputText
|
|
v-model="rib.iban"
|
|
:label="t('technique.providers.form.accounting.ribIban')"
|
|
:readonly="accountingReadonly"
|
|
:required="true"
|
|
: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('technique.providers.form.accounting.addRib')"
|
|
:disabled="!canAddRib"
|
|
@click="addRib"
|
|
/>
|
|
<MalioButton
|
|
variant="primary"
|
|
:label="t('technique.providers.form.submit')"
|
|
:disabled="tabSubmitting || providerId === null"
|
|
@click="onSubmitAccounting"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</MalioTabList>
|
|
|
|
<!-- Modal de confirmation generique (suppression d'un bloc contact). -->
|
|
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
|
|
<template #header>
|
|
<h2 class="text-[24px] font-bold">{{ t('technique.providers.form.confirmDelete.title') }}</h2>
|
|
</template>
|
|
<p>{{ confirmModal.message }}</p>
|
|
<template #footer>
|
|
<MalioButton
|
|
variant="secondary"
|
|
button-class="flex-1"
|
|
:label="t('technique.providers.form.confirmDelete.cancel')"
|
|
@click="confirmModal.open = false"
|
|
/>
|
|
<MalioButton
|
|
variant="danger"
|
|
button-class="flex-1"
|
|
:label="t('technique.providers.form.confirmDelete.confirm')"
|
|
@click="runConfirm"
|
|
/>
|
|
</template>
|
|
</MalioModal>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, reactive, ref } from 'vue'
|
|
import { useProviderReferentials, type RefOption } from '~/modules/technique/composables/useProviderReferentials'
|
|
import { useProviderForm } from '~/modules/technique/composables/useProviderForm'
|
|
import {
|
|
isBankRequiredForPaymentType,
|
|
isRibRequiredForPaymentType,
|
|
} from '~/modules/technique/utils/forms/providerAccounting'
|
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
|
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
|
|
|
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
|
|
const SIREN_MASK = '#########'
|
|
|
|
const { t } = useI18n()
|
|
const router = useRouter()
|
|
const toast = useToast()
|
|
const { can } = usePermissions()
|
|
|
|
useHead({ title: t('technique.providers.form.title') })
|
|
|
|
// Gating de la route : la creation est reservee a `manage` (POST /providers garde
|
|
// manage seul — Compta ne cree pas). Compta (accounting seul) et Usine sont
|
|
// rediriges vers le repertoire.
|
|
if (!can('technique.providers.manage')) {
|
|
await navigateTo('/providers')
|
|
}
|
|
|
|
const referentials = useProviderReferentials()
|
|
|
|
const {
|
|
main,
|
|
providerId,
|
|
mainLocked,
|
|
mainSubmitting,
|
|
mainErrors,
|
|
canAccountingView,
|
|
tabKeys,
|
|
activeTab,
|
|
unlockedIndex,
|
|
submitMain,
|
|
tabSubmitting,
|
|
isValidated,
|
|
contacts,
|
|
contactErrors,
|
|
canAddContact,
|
|
addContact,
|
|
removeContact,
|
|
submitContacts,
|
|
addresses,
|
|
addressErrors,
|
|
canAddAddress,
|
|
addAddress,
|
|
removeAddress,
|
|
submitAddresses,
|
|
accounting,
|
|
ribs,
|
|
accountingErrors,
|
|
ribErrors,
|
|
accountingReadonly,
|
|
setPaymentType,
|
|
canAddRib,
|
|
addRib,
|
|
removeRib,
|
|
submitAccounting,
|
|
} = useProviderForm()
|
|
|
|
/** Retour vers le repertoire prestataires (fleche d'en-tete). */
|
|
function goBack(): void {
|
|
router.push('/providers')
|
|
}
|
|
|
|
/**
|
|
* 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`).
|
|
*/
|
|
function apiErrorMessage(error: unknown): string {
|
|
const data = (error as { response?: { _data?: unknown } })?.response?._data
|
|
return extractApiErrorMessage(data) || t('technique.providers.toast.error')
|
|
}
|
|
|
|
// Dernier onglet REMPLISSABLE par le role : tabKeys exclut deja la Comptabilite
|
|
// si l'user n'a pas accounting.view. Sa validation cloture l'ajout (redirection).
|
|
const lastFillableTab = computed(() => tabKeys.value[tabKeys.value.length - 1])
|
|
|
|
/**
|
|
* Apres validation d'un onglet (creation) : si c'est le dernier onglet du role,
|
|
* l'ajout est termine -> toast final + retour au repertoire (miroir M1/M2) ; sinon
|
|
* toast de mise a jour (l'onglet suivant a deja ete deverrouille par completeTab).
|
|
*/
|
|
function onTabSaved(key: string): void {
|
|
if (key === lastFillableTab.value) {
|
|
toast.success({ title: t('technique.providers.toast.addComplete') })
|
|
router.push('/providers')
|
|
return
|
|
}
|
|
toast.success({ title: t('technique.providers.toast.updateSuccess') })
|
|
}
|
|
|
|
// ── Onglet Contact ──────────────────────────────────────────────────────────
|
|
/** Valide l'onglet Contact ; redirige si c'est le dernier onglet du role. */
|
|
async function onSubmitContacts(): Promise<void> {
|
|
const ok = await submitContacts(error => toast.error({
|
|
title: t('technique.providers.toast.error'),
|
|
message: apiErrorMessage(error),
|
|
}))
|
|
if (ok) {
|
|
onTabSaved('contact')
|
|
}
|
|
}
|
|
|
|
function askRemoveContact(index: number): void {
|
|
askConfirm(t('technique.providers.form.confirmDelete.contact'), () => removeContact(index))
|
|
}
|
|
|
|
// ── Onglet Adresse ────────────────────────────────────────────────────────────
|
|
// Contacts deja persistes (IRI non nul), rattachables a une adresse (M2M). Le
|
|
// libelle reprend le nom complet, a defaut l'email.
|
|
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 : France garantie en tete meme si /countries echoue (resilience ERP-102),
|
|
// pour rester preselectionnable par defaut sur chaque adresse.
|
|
const countryOptions = computed<RefOption[]>(() => {
|
|
const list = referentials.countries.value
|
|
return list.some(c => c.value === 'France')
|
|
? list
|
|
: [{ value: 'France', label: 'France' }, ...list]
|
|
})
|
|
|
|
const addressDegradedNotified = ref(false)
|
|
|
|
/** Avertit une seule fois quand l'autocompletion d'adresse bascule en degrade (RG-3.06). */
|
|
function onAddressDegraded(): void {
|
|
if (addressDegradedNotified.value) {
|
|
return
|
|
}
|
|
addressDegradedNotified.value = true
|
|
toast.warning({
|
|
title: t('technique.providers.toast.error'),
|
|
message: t('technique.providers.form.address.degraded'),
|
|
})
|
|
}
|
|
|
|
/** Valide l'onglet Adresse ; redirige si c'est le dernier onglet du role. */
|
|
async function onSubmitAddresses(): Promise<void> {
|
|
const ok = await submitAddresses(error => toast.error({
|
|
title: t('technique.providers.toast.error'),
|
|
message: apiErrorMessage(error),
|
|
}))
|
|
if (ok) {
|
|
onTabSaved('address')
|
|
}
|
|
}
|
|
|
|
function askRemoveAddress(index: number): void {
|
|
askConfirm(t('technique.providers.form.confirmDelete.address'), () => removeAddress(index))
|
|
}
|
|
|
|
// ── Onglet Comptabilite ───────────────────────────────────────────────────────
|
|
// Code stable du type de reglement selectionne (pour RG-3.07 / RG-3.08).
|
|
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-3.08).
|
|
const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
|
|
|
|
/** Changement de type de reglement : propage les RG inter-champs (banque / RIB). */
|
|
function onPaymentTypeChange(value: string | number | null): void {
|
|
const iri = value === null ? null : String(value)
|
|
const code = referentials.paymentTypes.value.find(p => p.value === iri)?.code ?? null
|
|
setPaymentType(iri, isBankRequiredForPaymentType(code), isRibRequiredForPaymentType(code))
|
|
}
|
|
|
|
function askRemoveRib(index: number): void {
|
|
askConfirm(t('technique.providers.form.confirmDelete.rib'), () => removeRib(index))
|
|
}
|
|
|
|
/** Valide l'onglet Comptabilite ; redirige si c'est le dernier onglet du role. */
|
|
async function onSubmitAccounting(): Promise<void> {
|
|
const ok = await submitAccounting(
|
|
isBankRequired.value,
|
|
isRibRequired.value,
|
|
error => toast.error({
|
|
title: t('technique.providers.toast.error'),
|
|
message: apiErrorMessage(error),
|
|
}),
|
|
)
|
|
if (ok) {
|
|
onTabSaved('accounting')
|
|
}
|
|
}
|
|
|
|
// ── 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
|
|
}
|
|
|
|
// Icone (Iconify) affichee dans l'onglet, par cle.
|
|
const TAB_ICONS: Record<string, string> = {
|
|
contact: 'mdi:account-box-plus-outline',
|
|
address: 'mdi:map-marker-outline',
|
|
accounting: 'mdi:bank-circle-outline',
|
|
}
|
|
|
|
// Onglets desactives tant que le formulaire principal n'est pas valide
|
|
// (unlockedIndex = -1 au depart) ; deverrouillage progressif ensuite.
|
|
const tabs = computed(() => tabKeys.value.map((key, index) => ({
|
|
key,
|
|
label: t(`technique.providers.tab.${key}`),
|
|
icon: TAB_ICONS[key],
|
|
disabled: index > unlockedIndex.value,
|
|
})))
|
|
|
|
onMounted(() => {
|
|
// Echec du chargement des referentiels non bloquant : les selects restent vides.
|
|
referentials.loadMain().catch(() => {})
|
|
// Referentiels comptables charges uniquement si l'onglet est accessible.
|
|
if (canAccountingView.value) {
|
|
referentials.loadAccounting().catch(() => {})
|
|
}
|
|
})
|
|
</script>
|