Files
Starseed/frontend/modules/technique/pages/providers/new.vue
T
tristan e3421ac4c8
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m37s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m25s
fix(front) : suppression immediate des sous-ressources + regle poubelle (ERP-172)
- 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).
2026-06-15 16:52:49 +02:00

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>