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).
544 lines
24 KiB
Vue
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>
|