Merge branch 'develop' into feat/erp-154-upload
This commit is contained in:
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.122'
|
app.version: '0.1.123'
|
||||||
|
|||||||
@@ -157,12 +157,16 @@
|
|||||||
<!-- Onglet Contact -->
|
<!-- Onglet Contact -->
|
||||||
<template #contact>
|
<template #contact>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<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. -->
|
||||||
<ClientContactBlock
|
<ClientContactBlock
|
||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="contact.id ?? `new-${index}`"
|
:key="contact.id ?? `new-${index}`"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||||
:removable="contacts.length > 1"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -199,7 +203,7 @@
|
|||||||
:site-options="siteOptions"
|
:site-options="siteOptions"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="addresses.length > 1"
|
:removable="isRowRemovable(addresses, index)"
|
||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:errors="addressErrors[index]"
|
:errors="addressErrors[index]"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
@@ -304,7 +308,7 @@
|
|||||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
>
|
>
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
icon="mdi:delete-outline"
|
icon="mdi:delete-outline"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
button-class="absolute top-3 right-3"
|
button-class="absolute top-3 right-3"
|
||||||
@@ -440,6 +444,7 @@ import {
|
|||||||
type RibFormDraft,
|
type RibFormDraft,
|
||||||
} from '~/modules/commercial/types/clientForm'
|
} from '~/modules/commercial/types/clientForm'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
|
||||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||||
|
|
||||||
// Masques de saisie (la normalisation finale reste serveur).
|
// Masques de saisie (la normalisation finale reste serveur).
|
||||||
@@ -490,10 +495,6 @@ const contacts = ref<ContactFormDraft[]>([])
|
|||||||
const addresses = ref<AddressFormDraft[]>([])
|
const addresses = ref<AddressFormDraft[]>([])
|
||||||
const ribs = ref<RibFormDraft[]>([])
|
const ribs = ref<RibFormDraft[]>([])
|
||||||
|
|
||||||
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
|
|
||||||
const removedContactIds = ref<number[]>([])
|
|
||||||
const removedAddressIds = ref<number[]>([])
|
|
||||||
const removedRibIds = ref<number[]>([])
|
|
||||||
|
|
||||||
const mainSubmitting = ref(false)
|
const mainSubmitting = ref(false)
|
||||||
const tabSubmitting = ref(false)
|
const tabSubmitting = ref(false)
|
||||||
@@ -754,32 +755,31 @@ function addContact(): void {
|
|||||||
if (canAddContact.value) contacts.value.push(emptyContact())
|
if (canAddContact.value) contacts.value.push(emptyContact())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ERP-172 : DELETE immediat de la sous-ressource a la confirmation de la modale
|
||||||
|
// (et non plus differe au « Enregistrer »). Bloc jamais persiste (id null) : retrait
|
||||||
|
// local. Echec serveur : bloc conserve + erreur remontee.
|
||||||
function askRemoveContact(index: number): void {
|
function askRemoveContact(index: number): void {
|
||||||
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => {
|
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => removeCollectionRow({
|
||||||
const removed = contacts.value[index]
|
rows: contacts.value,
|
||||||
if (removed?.id != null) removedContactIds.value.push(removed.id)
|
errors: contactErrors.value,
|
||||||
contacts.value.splice(index, 1)
|
index,
|
||||||
contactErrors.value.splice(index, 1)
|
endpoint: '/client_contacts',
|
||||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (contacts.value.length === 0) contacts.value.push(emptyContact())
|
makeEmpty: emptyContact,
|
||||||
})
|
onError: showError,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Valide l'onglet Contact : DELETE des contacts retires (existants), puis
|
* Valide l'onglet Contact : POST/PATCH des blocs restants sur la sous-ressource.
|
||||||
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
|
* Strictement scope a la collection contacts (endpoints client_contact dedies). La
|
||||||
* collection contacts (endpoints client_contact dedies).
|
* suppression est traitee a part, en DELETE immediat (askRemoveContact, ERP-172).
|
||||||
*/
|
*/
|
||||||
async function submitContacts(): Promise<void> {
|
async function submitContacts(): Promise<void> {
|
||||||
if (businessReadonly.value || tabSubmitting.value) return
|
if (businessReadonly.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
contactErrors.value = []
|
contactErrors.value = []
|
||||||
try {
|
try {
|
||||||
for (const id of removedContactIds.value) {
|
|
||||||
await api.delete(`/client_contacts/${id}`, {}, { toast: false })
|
|
||||||
}
|
|
||||||
removedContactIds.value = []
|
|
||||||
|
|
||||||
// RG-1.14 : au moins un contact requis. Si l'onglet ne contient QUE des
|
// RG-1.14 : au moins un contact requis. Si l'onglet ne contient QUE des
|
||||||
// amorces neuves vides (ex. tous les contacts existants supprimes), on ne
|
// amorces neuves vides (ex. tous les contacts existants supprimes), on ne
|
||||||
// les skippe pas -> le back renvoie la 422 RG-1.05 « prénom ou nom
|
// les skippe pas -> le back renvoie la 422 RG-1.05 « prénom ou nom
|
||||||
@@ -836,14 +836,15 @@ function addAddress(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function askRemoveAddress(index: number): void {
|
function askRemoveAddress(index: number): void {
|
||||||
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => {
|
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => removeCollectionRow({
|
||||||
const removed = addresses.value[index]
|
rows: addresses.value,
|
||||||
if (removed?.id != null) removedAddressIds.value.push(removed.id)
|
errors: addressErrors.value,
|
||||||
addresses.value.splice(index, 1)
|
index,
|
||||||
addressErrors.value.splice(index, 1)
|
endpoint: '/client_addresses',
|
||||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
|
makeEmpty: emptyAddress,
|
||||||
})
|
onError: showError,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAddressDegraded(): void {
|
function onAddressDegraded(): void {
|
||||||
@@ -855,17 +856,12 @@ function onAddressDegraded(): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Valide l'onglet Adresse : DELETE des adresses retirees puis POST/PATCH. */
|
/** Valide l'onglet Adresse : POST/PATCH des blocs restants (suppression en DELETE immediat, ERP-172). */
|
||||||
async function submitAddresses(): Promise<void> {
|
async function submitAddresses(): Promise<void> {
|
||||||
if (businessReadonly.value || tabSubmitting.value) return
|
if (businessReadonly.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
addressErrors.value = []
|
addressErrors.value = []
|
||||||
try {
|
try {
|
||||||
for (const id of removedAddressIds.value) {
|
|
||||||
await api.delete(`/client_addresses/${id}`, {}, { toast: false })
|
|
||||||
}
|
|
||||||
removedAddressIds.value = []
|
|
||||||
|
|
||||||
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110).
|
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110).
|
||||||
const hasError = await submitRows(
|
const hasError = await submitRows(
|
||||||
addresses.value,
|
addresses.value,
|
||||||
@@ -937,29 +933,32 @@ function addRib(): void {
|
|||||||
if (canAddRib.value) ribs.value.push(emptyRib())
|
if (canAddRib.value) ribs.value.push(emptyRib())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ERP-172 : DELETE immediat du RIB. Le back refuse la suppression du dernier RIB
|
||||||
|
// d'une LCR (RG-1.13) -> 409 remonte via showError (message back), bloc conserve.
|
||||||
function askRemoveRib(index: number): void {
|
function askRemoveRib(index: number): void {
|
||||||
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => {
|
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => removeCollectionRow({
|
||||||
const removed = ribs.value[index]
|
rows: ribs.value,
|
||||||
if (removed?.id != null) removedRibIds.value.push(removed.id)
|
errors: ribErrors.value,
|
||||||
ribs.value.splice(index, 1)
|
index,
|
||||||
ribErrors.value.splice(index, 1)
|
endpoint: '/client_ribs',
|
||||||
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
makeEmpty: emptyRib,
|
||||||
})
|
onError: showError,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
|
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
|
||||||
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
|
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
|
||||||
* back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le back
|
* back). Les RIB crees d'abord : le back valide RG-1.13 (LCR => au moins un RIB
|
||||||
* valide RG-1.13 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
|
* persiste) sur le PATCH scalaires.
|
||||||
*
|
*
|
||||||
|
* ERP-172 : la suppression d'un RIB est traitee en DELETE immediat (askRemoveRib),
|
||||||
|
* plus de DELETE differe ici.
|
||||||
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
|
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
|
||||||
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
||||||
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
|
* re-ecrites. Aucun champ main/information dans le payload (mode strict RG-1.28 :
|
||||||
* (corbeille d'un bloc, toujours sous LCR), plus l'auto-suppression au changement
|
* sinon 403 sur tout le payload).
|
||||||
* de type de reglement. Aucun champ main/information dans le payload (mode strict
|
|
||||||
* RG-1.28 : sinon 403 sur tout le payload).
|
|
||||||
*/
|
*/
|
||||||
async function submitAccounting(): Promise<void> {
|
async function submitAccounting(): Promise<void> {
|
||||||
if (accountingReadonly.value || tabSubmitting.value) return
|
if (accountingReadonly.value || tabSubmitting.value) return
|
||||||
@@ -1013,14 +1012,6 @@ async function submitAccounting(): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
|
|
||||||
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
|
|
||||||
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
|
|
||||||
for (const id of removedRibIds.value) {
|
|
||||||
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
|
|
||||||
}
|
|
||||||
removedRibIds.value = []
|
|
||||||
|
|
||||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
|
|||||||
@@ -156,12 +156,16 @@
|
|||||||
<!-- Onglet Contact -->
|
<!-- Onglet Contact -->
|
||||||
<template #contact>
|
<template #contact>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<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. -->
|
||||||
<ClientContactBlock
|
<ClientContactBlock
|
||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="index"
|
:key="index"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
:readonly="isValidated('contact')"
|
:readonly="isValidated('contact')"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -198,7 +202,7 @@
|
|||||||
:site-options="referentials.sites.value"
|
:site-options="referentials.sites.value"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(addresses, index)"
|
||||||
:readonly="isValidated('address')"
|
:readonly="isValidated('address')"
|
||||||
:errors="addressErrors[index]"
|
:errors="addressErrors[index]"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
@@ -303,7 +307,7 @@
|
|||||||
>
|
>
|
||||||
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
icon="mdi:delete-outline"
|
icon="mdi:delete-outline"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
button-class="absolute top-3 right-3"
|
button-class="absolute top-3 right-3"
|
||||||
@@ -417,6 +421,7 @@ import {
|
|||||||
type RibFormDraft,
|
type RibFormDraft,
|
||||||
} from '~/modules/commercial/types/clientForm'
|
} from '~/modules/commercial/types/clientForm'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||||
|
|
||||||
// Masques de saisie (la normalisation finale reste serveur).
|
// Masques de saisie (la normalisation finale reste serveur).
|
||||||
const SIREN_MASK = '#########'
|
const SIREN_MASK = '#########'
|
||||||
|
|||||||
@@ -126,12 +126,16 @@
|
|||||||
<!-- Onglet Contacts -->
|
<!-- Onglet Contacts -->
|
||||||
<template #contacts>
|
<template #contacts>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<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. -->
|
||||||
<SupplierContactBlock
|
<SupplierContactBlock
|
||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="contact.id ?? `new-${index}`"
|
:key="contact.id ?? `new-${index}`"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
||||||
:removable="contacts.length > 1"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -168,7 +172,7 @@
|
|||||||
:site-options="siteOptions"
|
:site-options="siteOptions"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="addresses.length > 1"
|
:removable="isRowRemovable(addresses, index)"
|
||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:errors="addressErrors[index]"
|
:errors="addressErrors[index]"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
@@ -273,7 +277,7 @@
|
|||||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
>
|
>
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
icon="mdi:delete-outline"
|
icon="mdi:delete-outline"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
button-class="absolute top-3 right-3"
|
button-class="absolute top-3 right-3"
|
||||||
@@ -407,6 +411,7 @@ import {
|
|||||||
type SupplierRibFormDraft,
|
type SupplierRibFormDraft,
|
||||||
} from '~/modules/commercial/types/supplierForm'
|
} from '~/modules/commercial/types/supplierForm'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
|
||||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||||
|
|
||||||
// Masques de saisie (la normalisation finale reste serveur).
|
// Masques de saisie (la normalisation finale reste serveur).
|
||||||
@@ -456,10 +461,6 @@ const contacts = ref<SupplierContactFormDraft[]>([])
|
|||||||
const addresses = ref<SupplierAddressFormDraft[]>([])
|
const addresses = ref<SupplierAddressFormDraft[]>([])
|
||||||
const ribs = ref<SupplierRibFormDraft[]>([])
|
const ribs = ref<SupplierRibFormDraft[]>([])
|
||||||
|
|
||||||
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
|
|
||||||
const removedContactIds = ref<number[]>([])
|
|
||||||
const removedAddressIds = ref<number[]>([])
|
|
||||||
const removedRibIds = ref<number[]>([])
|
|
||||||
|
|
||||||
const mainSubmitting = ref(false)
|
const mainSubmitting = ref(false)
|
||||||
const tabSubmitting = ref(false)
|
const tabSubmitting = ref(false)
|
||||||
@@ -653,32 +654,31 @@ function addContact(): void {
|
|||||||
if (canAddContact.value) contacts.value.push(emptyContact())
|
if (canAddContact.value) contacts.value.push(emptyContact())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ERP-172 : DELETE immediat de la sous-ressource a la confirmation de la modale
|
||||||
|
// (et non plus differe au « Enregistrer »). Bloc jamais persiste (id null) : retrait
|
||||||
|
// local. Echec serveur : bloc conserve + erreur remontee.
|
||||||
function askRemoveContact(index: number): void {
|
function askRemoveContact(index: number): void {
|
||||||
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => {
|
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => removeCollectionRow({
|
||||||
const removed = contacts.value[index]
|
rows: contacts.value,
|
||||||
if (removed?.id != null) removedContactIds.value.push(removed.id)
|
errors: contactErrors.value,
|
||||||
contacts.value.splice(index, 1)
|
index,
|
||||||
contactErrors.value.splice(index, 1)
|
endpoint: '/supplier_contacts',
|
||||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (contacts.value.length === 0) contacts.value.push(emptyContact())
|
makeEmpty: emptyContact,
|
||||||
})
|
onError: showError,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Valide l'onglet Contacts : DELETE des contacts retires (existants), puis
|
* Valide l'onglet Contacts : POST/PATCH des blocs restants sur la sous-ressource.
|
||||||
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
|
* Strictement scope a la collection contacts (endpoints supplier_contact dedies).
|
||||||
* collection contacts (endpoints supplier_contact dedies).
|
* La suppression est traitee a part, en DELETE immediat (askRemoveContact, ERP-172).
|
||||||
*/
|
*/
|
||||||
async function submitContacts(): Promise<void> {
|
async function submitContacts(): Promise<void> {
|
||||||
if (businessReadonly.value || tabSubmitting.value) return
|
if (businessReadonly.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
contactErrors.value = []
|
contactErrors.value = []
|
||||||
try {
|
try {
|
||||||
for (const id of removedContactIds.value) {
|
|
||||||
await api.delete(`/supplier_contacts/${id}`, {}, { toast: false })
|
|
||||||
}
|
|
||||||
removedContactIds.value = []
|
|
||||||
|
|
||||||
// RG-2.13 : au moins un contact requis. Si l'onglet ne contient QUE des
|
// RG-2.13 : au moins un contact requis. Si l'onglet ne contient QUE des
|
||||||
// amorces neuves vides, on les soumet -> 422 RG-2.04 inline (nom OU prenom).
|
// amorces neuves vides, on les soumet -> 422 RG-2.04 inline (nom OU prenom).
|
||||||
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
|
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
|
||||||
@@ -726,14 +726,15 @@ function addAddress(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function askRemoveAddress(index: number): void {
|
function askRemoveAddress(index: number): void {
|
||||||
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => {
|
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => removeCollectionRow({
|
||||||
const removed = addresses.value[index]
|
rows: addresses.value,
|
||||||
if (removed?.id != null) removedAddressIds.value.push(removed.id)
|
errors: addressErrors.value,
|
||||||
addresses.value.splice(index, 1)
|
index,
|
||||||
addressErrors.value.splice(index, 1)
|
endpoint: '/supplier_addresses',
|
||||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
|
makeEmpty: emptyAddress,
|
||||||
})
|
onError: showError,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAddressDegraded(): void {
|
function onAddressDegraded(): void {
|
||||||
@@ -745,17 +746,12 @@ function onAddressDegraded(): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Valide l'onglet Adresses : DELETE des adresses retirees puis POST/PATCH. */
|
/** Valide l'onglet Adresses : POST/PATCH des blocs restants (suppression en DELETE immediat, ERP-172). */
|
||||||
async function submitAddresses(): Promise<void> {
|
async function submitAddresses(): Promise<void> {
|
||||||
if (businessReadonly.value || tabSubmitting.value) return
|
if (businessReadonly.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
addressErrors.value = []
|
addressErrors.value = []
|
||||||
try {
|
try {
|
||||||
for (const id of removedAddressIds.value) {
|
|
||||||
await api.delete(`/supplier_addresses/${id}`, {}, { toast: false })
|
|
||||||
}
|
|
||||||
removedAddressIds.value = []
|
|
||||||
|
|
||||||
const hasError = await submitRows(
|
const hasError = await submitRows(
|
||||||
addresses.value,
|
addresses.value,
|
||||||
addressErrors,
|
addressErrors,
|
||||||
@@ -826,15 +822,18 @@ function addRib(): void {
|
|||||||
if (canAddRib.value) ribs.value.push(emptyRib())
|
if (canAddRib.value) ribs.value.push(emptyRib())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ERP-172 : DELETE immediat du RIB. Le back refuse la suppression du dernier RIB
|
||||||
|
// d'une LCR (RG-2.08) -> 409 remonte via showError (message back), bloc conserve.
|
||||||
function askRemoveRib(index: number): void {
|
function askRemoveRib(index: number): void {
|
||||||
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => {
|
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => removeCollectionRow({
|
||||||
const removed = ribs.value[index]
|
rows: ribs.value,
|
||||||
if (removed?.id != null) removedRibIds.value.push(removed.id)
|
errors: ribErrors.value,
|
||||||
ribs.value.splice(index, 1)
|
index,
|
||||||
ribErrors.value.splice(index, 1)
|
endpoint: '/supplier_ribs',
|
||||||
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
makeEmpty: emptyRib,
|
||||||
})
|
onError: showError,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -843,11 +842,12 @@ function askRemoveRib(index: number): void {
|
|||||||
* cote back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le
|
* cote back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le
|
||||||
* back valide RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
|
* back valide RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
|
||||||
*
|
*
|
||||||
|
* ERP-172 : la suppression d'un RIB est traitee en DELETE immediat (askRemoveRib),
|
||||||
|
* plus de DELETE differe ici.
|
||||||
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
|
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
|
||||||
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
||||||
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
|
* re-ecrites. Aucun champ main/information dans le payload (mode strict RG-2.16 :
|
||||||
* (corbeille d'un bloc, toujours sous LCR). Aucun champ main/information dans le
|
* sinon 403 sur tout le payload).
|
||||||
* payload (mode strict RG-2.16 : sinon 403 sur tout le payload).
|
|
||||||
*/
|
*/
|
||||||
async function submitAccounting(): Promise<void> {
|
async function submitAccounting(): Promise<void> {
|
||||||
if (accountingReadonly.value || tabSubmitting.value) return
|
if (accountingReadonly.value || tabSubmitting.value) return
|
||||||
@@ -897,14 +897,6 @@ async function submitAccounting(): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
|
|
||||||
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
|
|
||||||
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
|
|
||||||
for (const id of removedRibIds.value) {
|
|
||||||
await api.delete(`/supplier_ribs/${id}`, {}, { toast: false })
|
|
||||||
}
|
|
||||||
removedRibIds.value = []
|
|
||||||
|
|
||||||
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
|
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
|
|||||||
@@ -121,12 +121,16 @@
|
|||||||
<!-- Onglet Contacts -->
|
<!-- Onglet Contacts -->
|
||||||
<template #contacts>
|
<template #contacts>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<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. -->
|
||||||
<SupplierContactBlock
|
<SupplierContactBlock
|
||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="index"
|
:key="index"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
:readonly="isValidated('contacts')"
|
:readonly="isValidated('contacts')"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -163,7 +167,7 @@
|
|||||||
:site-options="referentials.sites.value"
|
:site-options="referentials.sites.value"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(addresses, index)"
|
||||||
:readonly="isValidated('addresses')"
|
:readonly="isValidated('addresses')"
|
||||||
:errors="addressErrors[index]"
|
:errors="addressErrors[index]"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
@@ -267,7 +271,7 @@
|
|||||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
>
|
>
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
icon="mdi:delete-outline"
|
icon="mdi:delete-outline"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
button-class="absolute top-3 right-3"
|
button-class="absolute top-3 right-3"
|
||||||
@@ -380,6 +384,7 @@ import {
|
|||||||
type SupplierRibFormDraft,
|
type SupplierRibFormDraft,
|
||||||
} from '~/modules/commercial/types/supplierForm'
|
} from '~/modules/commercial/types/supplierForm'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||||
|
|
||||||
// Masques de saisie (la normalisation finale reste serveur).
|
// Masques de saisie (la normalisation finale reste serveur).
|
||||||
const SIREN_MASK = '#########'
|
const SIREN_MASK = '#########'
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { computed, reactive, ref, type Ref } from 'vue'
|
import { computed, reactive, ref, type Ref } from 'vue'
|
||||||
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
||||||
import { mapViolationsToRecord } from '~/shared/utils/api'
|
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
|
||||||
|
import { removeCollectionRow } from '~/shared/utils/collectionRow'
|
||||||
import {
|
import {
|
||||||
emptyProviderAccounting,
|
emptyProviderAccounting,
|
||||||
emptyProviderAddress,
|
emptyProviderAddress,
|
||||||
@@ -73,6 +74,16 @@ export function useProviderForm() {
|
|||||||
// Erreurs de validation par champ (ERP-101) du formulaire principal.
|
// Erreurs de validation par champ (ERP-101) du formulaire principal.
|
||||||
const mainErrors = useFormErrors()
|
const mainErrors = useFormErrors()
|
||||||
|
|
||||||
|
// ERP-172 : remontee d'erreur 409/422 lors d'une suppression immediate de
|
||||||
|
// sous-ressource (message back affiche en toast dedie — pas de mapping inline,
|
||||||
|
// le bloc est en cours de retrait). Ex. dernier RIB d'une LCR -> 409.
|
||||||
|
function notifyRemovalError(error: unknown): void {
|
||||||
|
toast.error({
|
||||||
|
title: t('technique.providers.toast.error'),
|
||||||
|
message: extractApiErrorMessage((error as { data?: unknown })?.data) || t('technique.providers.toast.error'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ── Etat du prestataire cree ────────────────────────────────────────────
|
// ── Etat du prestataire cree ────────────────────────────────────────────
|
||||||
const providerId = ref<number | null>(null)
|
const providerId = ref<number | null>(null)
|
||||||
const mainLocked = ref(false)
|
const mainLocked = ref(false)
|
||||||
@@ -317,9 +328,18 @@ export function useProviderForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeContact(index: number): void {
|
// ERP-172 : DELETE immediat du contact existant (sous-ressource) a la
|
||||||
contacts.value.splice(index, 1)
|
// confirmation de la modale. Bloc jamais persiste (id null) : retrait local.
|
||||||
contactErrors.value.splice(index, 1)
|
async function removeContact(index: number): Promise<void> {
|
||||||
|
await removeCollectionRow({
|
||||||
|
rows: contacts.value,
|
||||||
|
errors: contactErrors.value,
|
||||||
|
index,
|
||||||
|
endpoint: '/provider_contacts',
|
||||||
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
|
makeEmpty: emptyProviderContact,
|
||||||
|
onError: notifyRemovalError,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -387,9 +407,17 @@ export function useProviderForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeAddress(index: number): void {
|
// ERP-172 : DELETE immediat de l'adresse existante (sous-ressource).
|
||||||
addresses.value.splice(index, 1)
|
async function removeAddress(index: number): Promise<void> {
|
||||||
addressErrors.value.splice(index, 1)
|
await removeCollectionRow({
|
||||||
|
rows: addresses.value,
|
||||||
|
errors: addressErrors.value,
|
||||||
|
index,
|
||||||
|
endpoint: '/provider_addresses',
|
||||||
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
|
makeEmpty: emptyProviderAddress,
|
||||||
|
onError: notifyRemovalError,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -479,13 +507,18 @@ export function useProviderForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeRib(index: number): void {
|
// ERP-172 : DELETE immediat du RIB existant. Le back peut refuser la suppression
|
||||||
ribs.value.splice(index, 1)
|
// du dernier RIB d'une LCR -> 409 remonte via notifyRemovalError, bloc conserve.
|
||||||
ribErrors.value.splice(index, 1)
|
async function removeRib(index: number): Promise<void> {
|
||||||
// Garde au moins un bloc RIB visible (sous LCR).
|
await removeCollectionRow({
|
||||||
if (ribs.value.length === 0) {
|
rows: ribs.value,
|
||||||
ribs.value.push(emptyProviderRib())
|
errors: ribErrors.value,
|
||||||
}
|
index,
|
||||||
|
endpoint: '/provider_ribs',
|
||||||
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
|
makeEmpty: emptyProviderRib,
|
||||||
|
onError: notifyRemovalError,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -62,11 +62,15 @@
|
|||||||
<!-- Onglet Contact -->
|
<!-- Onglet Contact -->
|
||||||
<template #contact>
|
<template #contact>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<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
|
<ProviderContactBlock
|
||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="index"
|
:key="index"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -102,7 +106,7 @@
|
|||||||
:site-options="referentials.sites.value"
|
:site-options="referentials.sites.value"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(addresses, index)"
|
||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:errors="addressErrors[index]"
|
:errors="addressErrors[index]"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
@@ -206,7 +210,7 @@
|
|||||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
>
|
>
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
icon="mdi:delete-outline"
|
icon="mdi:delete-outline"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
button-class="absolute top-3 right-3"
|
button-class="absolute top-3 right-3"
|
||||||
@@ -308,6 +312,7 @@ import {
|
|||||||
emptyProviderRib,
|
emptyProviderRib,
|
||||||
} from '~/modules/technique/types/providerForm'
|
} from '~/modules/technique/types/providerForm'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||||
|
|
||||||
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
|
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
|
||||||
const SIREN_MASK = '#########'
|
const SIREN_MASK = '#########'
|
||||||
|
|||||||
@@ -63,11 +63,15 @@
|
|||||||
<!-- Onglet Contact : saisie multi-contacts (blocs ajoutables). -->
|
<!-- Onglet Contact : saisie multi-contacts (blocs ajoutables). -->
|
||||||
<template #contact>
|
<template #contact>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<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
|
<ProviderContactBlock
|
||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="index"
|
:key="index"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
:readonly="isValidated('contact')"
|
:readonly="isValidated('contact')"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -102,7 +106,7 @@
|
|||||||
:site-options="referentials.sites.value"
|
:site-options="referentials.sites.value"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(addresses, index)"
|
||||||
:readonly="isValidated('address')"
|
:readonly="isValidated('address')"
|
||||||
:errors="addressErrors[index]"
|
:errors="addressErrors[index]"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
@@ -206,7 +210,7 @@
|
|||||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
>
|
>
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
icon="mdi:delete-outline"
|
icon="mdi:delete-outline"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
button-class="absolute top-3 right-3"
|
button-class="absolute top-3 right-3"
|
||||||
@@ -292,6 +296,7 @@ import {
|
|||||||
isRibRequiredForPaymentType,
|
isRibRequiredForPaymentType,
|
||||||
} from '~/modules/technique/utils/forms/providerAccounting'
|
} from '~/modules/technique/utils/forms/providerAccounting'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||||
|
|
||||||
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
|
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
|
||||||
const SIREN_MASK = '#########'
|
const SIREN_MASK = '#########'
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { removeCollectionRow, isRowRemovable, type DeletableRow } from '../collectionRow'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests de `removeCollectionRow` — suppression d'une ligne de collection
|
||||||
|
* (contact / adresse / RIB) avec DELETE immediat de la sous-ressource existante
|
||||||
|
* (ERP-172). Coeur de logique mutualise par les 3 modules (Client / Fournisseur /
|
||||||
|
* Prestataire) : un seul comportement teste ici couvre les 9 cas (3 modules x 3
|
||||||
|
* blocs).
|
||||||
|
*/
|
||||||
|
interface Row extends DeletableRow {
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEmpty(): Row {
|
||||||
|
return { id: null, label: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('removeCollectionRow', () => {
|
||||||
|
it('emet un DELETE sur la sous-ressource quand le bloc est existant (id non null)', async () => {
|
||||||
|
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
|
||||||
|
const errors: Record<string, string>[] = [{}, {}]
|
||||||
|
const deleteRow = vi.fn().mockResolvedValue(undefined)
|
||||||
|
const onError = vi.fn()
|
||||||
|
|
||||||
|
const removed = await removeCollectionRow({
|
||||||
|
rows, errors, index: 0,
|
||||||
|
endpoint: '/client_contacts',
|
||||||
|
deleteRow, makeEmpty, onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(deleteRow).toHaveBeenCalledOnce()
|
||||||
|
expect(deleteRow).toHaveBeenCalledWith('/client_contacts/10')
|
||||||
|
expect(removed).toBe(true)
|
||||||
|
expect(rows).toEqual([{ id: 11, label: 'B' }])
|
||||||
|
expect(errors).toHaveLength(1)
|
||||||
|
expect(onError).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ne fait AUCUN appel reseau pour un bloc jamais persiste (id null) — retrait local', async () => {
|
||||||
|
const rows: Row[] = [{ id: 10, label: 'A' }, { id: null, label: 'brouillon' }]
|
||||||
|
const errors: Record<string, string>[] = [{}, {}]
|
||||||
|
const deleteRow = vi.fn().mockResolvedValue(undefined)
|
||||||
|
const onError = vi.fn()
|
||||||
|
|
||||||
|
const removed = await removeCollectionRow({
|
||||||
|
rows, errors, index: 1,
|
||||||
|
endpoint: '/client_contacts',
|
||||||
|
deleteRow, makeEmpty, onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(deleteRow).not.toHaveBeenCalled()
|
||||||
|
expect(removed).toBe(true)
|
||||||
|
expect(rows).toEqual([{ id: 10, label: 'A' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('conserve le bloc et remonte l\'erreur si le DELETE serveur echoue (ex. 409 dernier RIB LCR)', async () => {
|
||||||
|
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
|
||||||
|
const errors: Record<string, string>[] = [{}, {}]
|
||||||
|
const error = { response: { status: 409 } }
|
||||||
|
const deleteRow = vi.fn().mockRejectedValue(error)
|
||||||
|
const onError = vi.fn()
|
||||||
|
|
||||||
|
const removed = await removeCollectionRow({
|
||||||
|
rows, errors, index: 0,
|
||||||
|
endpoint: '/client_ribs',
|
||||||
|
deleteRow, makeEmpty, onError,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(removed).toBe(false)
|
||||||
|
expect(onError).toHaveBeenCalledWith(error)
|
||||||
|
// Bloc NON retire : la suppression n'a pas ete confirmee par le serveur.
|
||||||
|
expect(rows).toEqual([{ id: 10, label: 'A' }, { id: 11, label: 'B' }])
|
||||||
|
expect(errors).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('garde au moins un bloc visible apres retrait du dernier (amorce vide)', async () => {
|
||||||
|
const rows: Row[] = [{ id: 10, label: 'A' }]
|
||||||
|
const errors: Record<string, string>[] = [{}]
|
||||||
|
const deleteRow = vi.fn().mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
await removeCollectionRow({
|
||||||
|
rows, errors, index: 0,
|
||||||
|
endpoint: '/client_contacts',
|
||||||
|
deleteRow, makeEmpty, onError: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(rows).toEqual([{ id: null, label: '' }])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests de `isRowRemovable` — la poubelle d'un bloc n'apparait que 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 (ERP-172).
|
||||||
|
*/
|
||||||
|
describe('isRowRemovable', () => {
|
||||||
|
it('faux quand aucun autre bloc n\'est enregistre (que des brouillons)', () => {
|
||||||
|
const rows: Row[] = [{ id: null, label: 'brouillon 1' }, { id: null, label: 'brouillon 2' }]
|
||||||
|
expect(isRowRemovable(rows, 0)).toBe(false)
|
||||||
|
expect(isRowRemovable(rows, 1)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('faux pour le seul bloc enregistre (un brouillon a cote ne compte pas)', () => {
|
||||||
|
const rows: Row[] = [{ id: 10, label: 'enregistre' }, { id: null, label: 'brouillon' }]
|
||||||
|
// Le bloc enregistre ne peut pas etre supprime : aucun AUTRE bloc enregistre.
|
||||||
|
expect(isRowRemovable(rows, 0)).toBe(false)
|
||||||
|
// Le brouillon peut etre jete : il reste le bloc enregistre id=10.
|
||||||
|
expect(isRowRemovable(rows, 1)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vrai pour chaque bloc des qu\'au moins deux sont enregistres', () => {
|
||||||
|
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
|
||||||
|
expect(isRowRemovable(rows, 0)).toBe(true)
|
||||||
|
expect(isRowRemovable(rows, 1)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('faux pour un unique bloc', () => {
|
||||||
|
expect(isRowRemovable([{ id: 10, label: 'A' }], 0)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/** Ligne de collection supprimable (contact / adresse / RIB). */
|
||||||
|
export interface DeletableRow {
|
||||||
|
id?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indique si le bloc d'index `index` peut afficher sa poubelle (ERP-172).
|
||||||
|
*
|
||||||
|
* Regle metier : on ne peut supprimer un bloc QUE s'il reste au moins un AUTRE
|
||||||
|
* bloc deja enregistre (`id` non null, donc persiste en base). Consequences :
|
||||||
|
* - tant que rien n'est enregistre -> aucune poubelle (pas de suppression d'un
|
||||||
|
* simple brouillon saisi mais pas valide) ;
|
||||||
|
* - on peut jeter un brouillon non enregistre s'il reste un bloc enregistre ;
|
||||||
|
* - on ne peut jamais supprimer son dernier bloc enregistre.
|
||||||
|
*/
|
||||||
|
export function isRowRemovable<T extends DeletableRow>(rows: T[], index: number): boolean {
|
||||||
|
return rows.some((row, i) => i !== index && row.id != null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options de {@link removeCollectionRow}. */
|
||||||
|
export interface RemoveCollectionRowOptions<T extends DeletableRow> {
|
||||||
|
/** Tableau reactif des brouillons (passer le `.value` de la ref). */
|
||||||
|
rows: T[]
|
||||||
|
/** Tableau reactif des erreurs par ligne, aligne sur l'index (passer le `.value`). */
|
||||||
|
errors: Record<string, string>[]
|
||||||
|
/** Index de la ligne a retirer. */
|
||||||
|
index: number
|
||||||
|
/** Endpoint de la sous-ressource SANS id (ex: '/client_contacts'). */
|
||||||
|
endpoint: string
|
||||||
|
/** Suppression serveur : DOIT rejeter en cas d'echec (ex: url => api.delete(url, {}, { toast: false })). */
|
||||||
|
deleteRow: (url: string) => Promise<unknown>
|
||||||
|
/** Fabrique d'un bloc vide pour garder au moins un bloc visible apres retrait. */
|
||||||
|
makeEmpty: () => T
|
||||||
|
/** Remontee d'erreur 409/422 mappee proprement (message back, pas de toast fourre-tout). */
|
||||||
|
onError: (error: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retire une ligne de collection (contact / adresse / RIB) sur les ecrans de
|
||||||
|
* MODIFICATION, avec DELETE immediat de la sous-ressource (ERP-172). Comportement
|
||||||
|
* aligne sur les 3 modules (Client / Fournisseur / Prestataire) :
|
||||||
|
*
|
||||||
|
* - Bloc jamais persiste (`id` null) : simple retrait local, aucun appel reseau.
|
||||||
|
* - Bloc existant (`id` non null) : DELETE `/endpoint/{id}` AVANT le retrait du
|
||||||
|
* tableau. On ne retire le bloc QUE si le serveur a confirme — sinon le bloc
|
||||||
|
* reste affiche et l'erreur est remontee via `onError` (ex. dernier RIB d'une
|
||||||
|
* LCR -> 409 back, RG-x.08).
|
||||||
|
*
|
||||||
|
* Etat purement local : `rows`/`errors` sont les `.value` des refs (proxies
|
||||||
|
* reactifs), le `splice` declenche donc la reactivite.
|
||||||
|
*
|
||||||
|
* @returns `true` si la ligne a ete retiree (suppression confirmee ou bloc local),
|
||||||
|
* `false` si la suppression serveur a echoue (bloc conserve).
|
||||||
|
*/
|
||||||
|
export async function removeCollectionRow<T extends DeletableRow>(
|
||||||
|
options: RemoveCollectionRowOptions<T>,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { rows, errors, index, endpoint, deleteRow, makeEmpty, onError } = options
|
||||||
|
const removed = rows[index]
|
||||||
|
|
||||||
|
// Bloc existant : suppression serveur d'abord, retrait local seulement si OK.
|
||||||
|
if (removed?.id != null) {
|
||||||
|
try {
|
||||||
|
await deleteRow(`${endpoint}/${removed.id}`)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
onError(error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.splice(index, 1)
|
||||||
|
errors.splice(index, 1)
|
||||||
|
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
||||||
|
if (rows.length === 0) {
|
||||||
|
rows.push(makeEmpty())
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user