Files
Starseed/frontend/modules/technique/pages/providers/[id]/edit.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

544 lines
24 KiB
Vue

<template>
<div>
<!-- En-tete : retour consultation + nom du prestataire. -->
<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.edit.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
</div>
<!-- Etats de chargement / introuvable. -->
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('technique.providers.edit.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('technique.providers.edit.notFound') }}</p>
<template v-else-if="provider">
<!-- Bloc principal (pre-rempli, editable si `manage`) -->
<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="businessReadonly"
:error="mainErrors.errors.companyName"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="referentials.categories.value"
:label="t('technique.providers.form.main.categories')"
:display-tag="true"
:readonly="businessReadonly"
: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="businessReadonly"
:required="true"
:error="mainErrors.errors.sites"
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
/>
</div>
<div v-if="!businessReadonly" class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('technique.providers.edit.save')"
:disabled="mainSubmitting"
@click="onUpdateMain"
/>
</div>
<!-- ── Onglets : navigation LIBRE, edition independante par onglet ──── -->
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Contact -->
<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="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
/>
<div v-if="!businessReadonly" 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.edit.save')"
:disabled="tabSubmitting"
@click="onSubmitContacts"
/>
</div>
</div>
</template>
<!-- Onglet Adresse -->
<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="businessReadonly"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@degraded="onAddressDegraded"
/>
<div v-if="!businessReadonly" 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.edit.save')"
:disabled="tabSubmitting"
@click="onSubmitAddresses"
/>
</div>
</div>
</template>
<!-- Onglet Comptabilite (present 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"
/>
<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.edit.save')"
:disabled="tabSubmitting"
@click="onSubmitAccounting"
/>
</div>
</div>
</template>
</MalioTabList>
</template>
<!-- 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('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 { useProvider } from '~/modules/technique/composables/useProvider'
import { useProviderReferentials, type RefOption } from '~/modules/technique/composables/useProviderReferentials'
import { useProviderForm } from '~/modules/technique/composables/useProviderForm'
import {
canEditProvider,
irisOf,
mapAccountingDraft,
mapAddressToDraft,
mapContactToDraft,
mapRibToDraft,
paymentTypeCodeOf,
} from '~/modules/technique/utils/forms/providerDetail'
import {
isBankRequiredForPaymentType,
isRibRequiredForPaymentType,
} from '~/modules/technique/utils/forms/providerAccounting'
import {
emptyProviderAddress,
emptyProviderContact,
emptyProviderRib,
} from '~/modules/technique/types/providerForm'
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 route = useRoute()
const router = useRouter()
const toast = useToast()
const { can, canAny } = usePermissions()
const providerId = route.params.id as string
// Acces : l'edition exige `manage` OU `accounting.manage` (le role Compta edite
// son onglet). Sinon retour consultation.
if (!canEditProvider(canAny)) {
await navigateTo(`/providers/${providerId}`)
}
const businessReadonly = computed(() => !can('technique.providers.manage'))
const referentials = useProviderReferentials()
const { provider, loading, error, load } = useProvider(providerId)
const {
main,
providerId: formProviderId,
mainErrors,
mainSubmitting,
tabSubmitting,
editMode,
canAccountingView,
tabKeys,
activeTab,
contacts,
contactErrors,
canAddContact,
addContact,
removeContact,
submitContacts,
addresses,
addressErrors,
canAddAddress,
addAddress,
removeAddress,
submitAddresses,
accounting,
ribs,
accountingErrors,
ribErrors,
accountingReadonly,
setPaymentType,
canAddRib,
addRib,
removeRib,
submitAccounting,
updateMain,
} = useProviderForm()
// Modification : navigation libre + pas de verrouillage a la validation.
editMode.value = true
activeTab.value = 'contact'
const headerTitle = computed(() => provider.value?.companyName || t('technique.providers.edit.title'))
useHead({ title: t('technique.providers.edit.title') })
// ── Onglets (navigation libre ; Comptabilite si accounting.view) ───────────────
const TAB_ICONS: Record<string, string> = {
contact: 'mdi:account-box-plus-outline',
address: 'mdi:map-marker-outline',
accounting: 'mdi:bank-circle-outline',
}
const tabs = computed(() => tabKeys.value.map(key => ({
key,
label: t(`technique.providers.tab.${key}`),
icon: TAB_ICONS[key],
})))
/** Pre-remplit les brouillons depuis la SEULE reponse detail. */
function prefill(): void {
const d = provider.value
if (!d) return
// Indispensable : pilote les URLs des PATCH/POST par onglet (sinon les submits no-op).
formProviderId.value = d.id
main.companyName = d.companyName ?? null
main.categoryIris = irisOf(d.categories)
main.siteIris = irisOf(d.sites)
const mappedContacts = (d.contacts ?? []).map(mapContactToDraft)
contacts.value = mappedContacts.length > 0 ? mappedContacts : [emptyProviderContact()]
const mappedAddresses = (d.addresses ?? []).map(mapAddressToDraft)
addresses.value = mappedAddresses.length > 0 ? mappedAddresses : [emptyProviderAddress()]
if (canAccountingView.value) {
Object.assign(accounting, mapAccountingDraft(d))
ribs.value = (d.ribs ?? []).map(mapRibToDraft)
// Garantit un bloc RIB visible si le type de reglement est LCR.
if (isRibRequiredForPaymentType(paymentTypeCodeOf(d.paymentType)) && ribs.value.length === 0) {
ribs.value.push(emptyProviderRib())
}
}
}
// ── Comptabilite : RG-3.07 / RG-3.08 pilotees par le code du type de reglement ──
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))
const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
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))
}
// ── Options adresses ──────────────────────────────────────────────────────────
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 ?? ''),
})),
)
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)
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'),
})
}
// ── Navigation + helpers ──────────────────────────────────────────────────────
function goBack(): void {
router.push(`/providers/${providerId}`)
}
function apiErrorMessage(err: unknown): string {
const data = (err as { response?: { _data?: unknown } })?.response?._data
return extractApiErrorMessage(data) || t('technique.providers.toast.error')
}
/** PATCH du bloc principal (groupe provider:write:main). */
async function onUpdateMain(): Promise<void> {
if (await updateMain()) {
toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
}
async function onSubmitContacts(): Promise<void> {
const ok = await submitContacts(err => toast.error({
title: t('technique.providers.toast.error'),
message: apiErrorMessage(err),
}))
if (ok) toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
async function onSubmitAddresses(): Promise<void> {
const ok = await submitAddresses(err => toast.error({
title: t('technique.providers.toast.error'),
message: apiErrorMessage(err),
}))
if (ok) toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
async function onSubmitAccounting(): Promise<void> {
const ok = await submitAccounting(
isBankRequired.value,
isRibRequired.value,
err => toast.error({ title: t('technique.providers.toast.error'), message: apiErrorMessage(err) }),
)
if (ok) toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
// ── 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
}
function askRemoveContact(index: number): void {
askConfirm(t('technique.providers.form.confirmDelete.contact'), () => removeContact(index))
}
function askRemoveAddress(index: number): void {
askConfirm(t('technique.providers.form.confirmDelete.address'), () => removeAddress(index))
}
function askRemoveRib(index: number): void {
askConfirm(t('technique.providers.form.confirmDelete.rib'), () => removeRib(index))
}
onMounted(async () => {
referentials.loadMain().catch(() => {})
if (canAccountingView.value) {
referentials.loadAccounting().catch(() => {})
}
await load()
prefill()
})
</script>