Files
Starseed/frontend/modules/commercial/pages/suppliers/new.vue
T

865 lines
38 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('commercial.suppliers.form.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('commercial.suppliers.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 Information. Pas de contact inline (ERP-106). -->
<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('commercial.suppliers.form.main.companyName')"
:required="true"
:readonly="mainLocked"
:error="mainErrors.errors.companyName"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="referentials.categories.value"
:label="t('commercial.suppliers.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)"
/>
</div>
<div v-if="!mainLocked" class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('commercial.suppliers.form.submit')"
:disabled="mainSubmitting"
@click="submitMain"
/>
</div>
<!-- ── Onglets a validation incrementale ─────────────────────────────-->
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<!-- Onglet Information -->
<template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<MalioInputTextArea
v-model="information.description"
:label="t('commercial.suppliers.form.information.description')"
resize="none"
group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg"
:readonly="isValidated('information')"
:error="informationErrors.errors.description"
/>
<MalioInputText
v-model="information.competitors"
:label="t('commercial.suppliers.form.information.competitors')"
:readonly="isValidated('information')"
:error="informationErrors.errors.competitors"
/>
<MalioDate
v-model="information.foundedAt"
:label="t('commercial.suppliers.form.information.foundedAt')"
:readonly="isValidated('information')"
:editable="true"
:error="informationErrors.errors.foundedAt"
/>
<MalioInputText
v-model="information.employeesCount"
:label="t('commercial.suppliers.form.information.employeesCount')"
:mask="EMPLOYEES_MASK"
:readonly="isValidated('information')"
:error="informationErrors.errors.employeesCount"
/>
<MalioInputAmount
v-model="information.revenueAmount"
:label="t('commercial.suppliers.form.information.revenueAmount')"
:readonly="isValidated('information')"
:error="informationErrors.errors.revenueAmount"
/>
<MalioInputText
v-model="information.directorName"
:label="t('commercial.suppliers.form.information.directorName')"
:readonly="isValidated('information')"
:error="informationErrors.errors.directorName"
/>
<MalioInputAmount
v-model="information.profitAmount"
:label="t('commercial.suppliers.form.information.profitAmount')"
:readonly="isValidated('information')"
:error="informationErrors.errors.profitAmount"
/>
<!-- Volume previsionnel : specifique fournisseur. Champ texte
masque (chiffres uniquement) ; l'entier est resolu au PATCH. -->
<MalioInputText
v-model="information.volumeForecast"
:label="t('commercial.suppliers.form.information.volumeForecast')"
:mask="VOLUME_FORECAST_MASK"
:readonly="isValidated('information')"
:error="informationErrors.errors.volumeForecast"
/>
</div>
<div v-if="!isValidated('information')" class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('commercial.suppliers.form.submit')"
:disabled="tabSubmitting || supplierId === null"
@click="submitInformation"
/>
</div>
</template>
<!-- Onglet Contacts -->
<template #contacts>
<div class="mt-12 flex flex-col gap-6">
<SupplierContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="index > 0"
:readonly="isValidated('contacts')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
/>
<div v-if="!isValidated('contacts')" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('commercial.suppliers.form.contact.add')"
:disabled="!canAddContact"
@click="addContact"
/>
<MalioButton
variant="primary"
:label="t('commercial.suppliers.form.submit')"
:disabled="tabSubmitting"
@click="submitContacts"
/>
</div>
</div>
</template>
<!-- Onglet Adresses -->
<template #addresses>
<div class="mt-12 flex flex-col gap-6">
<SupplierAddressBlock
v-for="(address, index) in addresses"
:key="index"
:model-value="address"
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
:category-options="referentials.categories.value"
:site-options="referentials.sites.value"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="index > 0"
:readonly="isValidated('addresses')"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@degraded="onAddressDegraded"
/>
<div v-if="!isValidated('addresses')" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('commercial.suppliers.form.address.add')"
:disabled="!canAddAddress"
@click="addAddress"
/>
<MalioButton
variant="primary"
:label="t('commercial.suppliers.form.submit')"
:disabled="tabSubmitting"
@click="submitAddresses"
/>
</div>
</div>
</template>
<!-- Onglet Comptabilite (present uniquement si accounting.view) -->
<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('commercial.suppliers.form.accounting.siren')"
:mask="SIREN_MASK"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:label="t('commercial.suppliers.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value"
:label="t('commercial.suppliers.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('commercial.suppliers.form.accounting.nTva')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value"
:label="t('commercial.suppliers.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('commercial.suppliers.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('commercial.suppliers.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-2.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"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.suppliers.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('commercial.suppliers.form.accounting.ribLabel')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:label="t('commercial.suppliers.form.accounting.ribBic')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.bic"
/>
<MalioInputText
v-model="rib.iban"
:label="t('commercial.suppliers.form.accounting.ribIban')"
:readonly="accountingReadonly"
:required="isRibRequired"
: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('commercial.suppliers.form.accounting.addRib')"
:disabled="!canAddRib"
@click="addRib"
/>
<MalioButton
variant="primary"
:label="t('commercial.suppliers.form.submit')"
:disabled="tabSubmitting"
@click="submitAccounting"
/>
</div>
</div>
</template>
<!-- Onglet placeholder : frame vide, passage automatique. -->
<template #transport><ComingSoonPlaceholder /></template>
</MalioTabList>
<!-- 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('commercial.suppliers.form.confirmDelete.title') }}</h2>
</template>
<p>{{ confirmModal.message }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('commercial.suppliers.form.confirmDelete.cancel')"
@click="confirmModal.open = false"
/>
<MalioButton
variant="danger"
button-class="flex-1"
:label="t('commercial.suppliers.form.confirmDelete.confirm')"
@click="runConfirm"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useSupplierReferentials, type RefOption } from '~/modules/commercial/composables/useSupplierReferentials'
import { useSupplierFormErrors } from '~/modules/commercial/composables/useSupplierFormErrors'
import {
buildSupplierFormTabKeys,
SUPPLIER_FORM_PLACEHOLDER_TABS,
isAddressValid,
isBankRequiredForPaymentType,
isContactBlank,
isContactNamed,
isRibBlank,
isRibComplete,
isRibRequiredForPaymentType,
lastFillableTabKey,
} from '~/modules/commercial/utils/supplierFormRules'
import {
buildAccountingPayload,
buildAddressPayload,
buildContactPayload,
buildInformationPayload,
buildMainPayload,
buildRibPayload,
} from '~/modules/commercial/utils/supplierEdit'
import {
emptyAddress,
emptyContact,
emptyRib,
type SupplierAddressFormDraft,
type SupplierContactFormDraft,
type SupplierRibFormDraft,
} from '~/modules/commercial/types/supplierForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
// Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########'
const EMPLOYEES_MASK = '#######'
// Volume previsionnel : champ texte borne aux chiffres (entier >= 0 cote back).
const VOLUME_FORECAST_MASK = '##########'
const { t } = useI18n()
const api = useApi()
const toast = useToast()
const router = useRouter()
const { can } = usePermissions()
/** Retour vers le repertoire fournisseurs (fleche d'en-tete). */
function goBack(): void {
router.push('/suppliers')
}
/**
* 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 { data?: unknown })?.data
return extractApiErrorMessage(data) || t('commercial.suppliers.toast.error')
}
// ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
const {
mainErrors,
informationErrors,
accountingErrors,
contactErrors,
addressErrors,
ribErrors,
submitRows,
} = useSupplierFormErrors()
useHead({ title: t('commercial.suppliers.form.title') })
// Gating de la route : la creation est reservee a `manage`. Compta (accounting
// seul) et Usine sont rediriges vers le repertoire.
if (!can('commercial.suppliers.manage')) {
await navigateTo('/suppliers')
}
const canAccountingView = computed(() => can('commercial.suppliers.accounting.view'))
const canAccountingManage = computed(() => can('commercial.suppliers.accounting.manage'))
const referentials = useSupplierReferentials()
// ── Etat du fournisseur cree ────────────────────────────────────────────────
const supplierId = ref<number | null>(null)
const mainLocked = ref(false)
const mainSubmitting = ref(false)
const tabSubmitting = ref(false)
// ── Formulaire principal ────────────────────────────────────────────────────
const main = reactive({
companyName: null as string | null,
categoryIris: [] as string[],
})
/** POST /suppliers (groupe supplier:write:main). Au succes : verrouille + bascule Information. */
async function submitMain(): Promise<void> {
if (mainSubmitting.value) return
mainSubmitting.value = true
mainErrors.clearErrors()
try {
const created = await api.post<SupplierResponse>('/suppliers', buildMainPayload(main), {
headers: { Accept: 'application/ld+json' },
toast: false,
})
supplierId.value = created.id
// Reaffiche la valeur normalisee renvoyee par le serveur (UPPERCASE, RG-2.12).
main.companyName = created.companyName ?? main.companyName
mainLocked.value = true
// Information est facultatif : on deverrouille jusqu'a Contacts (index 1).
unlockedIndex.value = tabIndex('contacts')
activeTab.value = 'information'
toast.success({ title: t('commercial.suppliers.toast.createSuccess') })
}
catch (error) {
// 409 = doublon nom de societe (RG d'unicite) → erreur inline + toast ;
// 422 → mapping inline par champ ; autre → toast de fallback (ERP-101).
const status = (error as { response?: { status?: number } })?.response?.status
if (status === 409) {
const message = t('commercial.suppliers.form.duplicateCompany')
mainErrors.setError('companyName', message)
toast.error({ title: t('commercial.suppliers.toast.error'), message })
}
else {
mainErrors.handleApiError(error, { fallbackMessage: t('commercial.suppliers.toast.error') })
}
}
finally {
mainSubmitting.value = false
}
}
// ── Onglets : ordre + gating progressif ─────────────────────────────────────
const activeTab = ref('information')
// Index du dernier onglet deverrouille (-1 tant que le fournisseur n'est pas cree).
const unlockedIndex = ref(-1)
// Onglets valides (passent en lecture seule).
const validated = reactive<Record<string, boolean>>({})
const tabKeys = computed(() => buildSupplierFormTabKeys(canAccountingView.value))
// Dernier onglet REMPLISSABLE par le role : sa validation cloture l'ajout.
const lastFillableTab = computed(() => lastFillableTabKey(tabKeys.value))
// Icone (Iconify) affichee dans l'onglet, par cle.
const TAB_ICONS: Record<string, string> = {
information: 'mdi:account-outline',
contacts: 'mdi:account-box-plus-outline',
addresses: 'mdi:map-marker-outline',
transport: 'mdi:truck-delivery-outline',
accounting: 'mdi:bank-circle-outline',
}
const tabs = computed(() => tabKeys.value.map((key, index) => ({
key,
label: t(`commercial.suppliers.tab.${key}`),
icon: TAB_ICONS[key],
disabled: index > unlockedIndex.value,
})))
function isValidated(key: string): boolean {
return validated[key] === true
}
function tabIndex(key: string): number {
return tabKeys.value.indexOf(key)
}
/**
* Marque l'onglet valide. Si c'est le dernier onglet remplissable, l'ajout est
* termine : toast final + redirection vers la liste, et on retourne true. Sinon,
* deverrouille et avance a l'onglet suivant, et retourne false.
*/
function completeTab(key: string): boolean {
validated[key] = true
if (key === lastFillableTab.value) {
toast.success({ title: t('commercial.suppliers.toast.addComplete') })
router.push('/suppliers')
return true
}
const next = tabKeys.value[tabIndex(key) + 1]
unlockedIndex.value = Math.max(unlockedIndex.value, tabIndex(key) + 1)
if (next) activeTab.value = next
return false
}
// Passage automatique sur les onglets coquille (Transport).
watch(activeTab, (key) => {
if ((SUPPLIER_FORM_PLACEHOLDER_TABS as readonly string[]).includes(key)) {
const next = tabKeys.value[tabIndex(key) + 1]
unlockedIndex.value = Math.max(unlockedIndex.value, tabIndex(key) + 1)
if (next) activeTab.value = next
}
})
// ── Onglet Information ──────────────────────────────────────────────────────
const information = reactive({
description: null as string | null,
competitors: null as string | null,
foundedAt: null as string | null,
employeesCount: null as string | null,
revenueAmount: null as string | null,
profitAmount: null as string | null,
directorName: null as string | null,
volumeForecast: null as string | null,
})
/** PATCH /suppliers/{id} — mode strict : uniquement les champs du groupe information. */
async function submitInformation(): Promise<void> {
if (supplierId.value === null || tabSubmitting.value) return
tabSubmitting.value = true
informationErrors.clearErrors()
try {
await api.patch(`/suppliers/${supplierId.value}`, buildInformationPayload(information), { toast: false })
if (completeTab('information')) return
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
}
catch (error) {
informationErrors.handleApiError(error, { fallbackMessage: t('commercial.suppliers.toast.error') })
}
finally {
tabSubmitting.value = false
}
}
// ── Onglet Contacts ─────────────────────────────────────────────────────────
const contacts = ref<SupplierContactFormDraft[]>([emptyContact()])
// « + Nouveau contact » desactive tant que le dernier bloc n'a ni nom ni prenom.
const canAddContact = computed(() => {
const last = contacts.value[contacts.value.length - 1]
return last !== undefined && isContactNamed(last)
})
function addContact(): void {
if (canAddContact.value) contacts.value.push(emptyContact())
}
function askRemoveContact(index: number): void {
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => {
contacts.value.splice(index, 1)
contactErrors.value.splice(index, 1)
})
}
/** POST/PATCH des contacts sur la sous-ressource /suppliers/{id}/contacts. */
async function submitContacts(): Promise<void> {
if (supplierId.value === null || tabSubmitting.value) return
tabSubmitting.value = true
try {
// RG-2.13 : au moins un contact requis. Si l'onglet ne contient QUE des
// amorces vides, on les soumet pour declencher la 422 RG-2.04 inline.
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
const hasError = await submitRows(
contacts.value,
contactErrors,
async (contact) => {
const body = buildContactPayload(contact)
if (contact.id === null) {
const created = await api.post<ContactResponse>(
`/suppliers/${supplierId.value}/contacts`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
contact.id = created.id
contact.iri = created['@id'] ?? null
}
else {
await api.patch(`/supplier_contacts/${contact.id}`, body, { toast: false })
}
},
error => toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(error) }),
contact => hasSubmittableContact && contact.id === null && isContactBlank(contact),
)
if (hasError) return
if (completeTab('contacts')) return
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
}
finally {
tabSubmitting.value = false
}
}
// ── Onglet Adresses ─────────────────────────────────────────────────────────
const addresses = ref<SupplierAddressFormDraft[]>([emptyAddress()])
const addressDegradedNotified = ref(false)
// Contacts deja crees, rattachables a une adresse (M2M, via leur IRI).
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 disponibles (France preselectionnee par defaut sur chaque adresse).
const countryOptions: RefOption[] = [
{ value: 'France', label: 'France' },
{ value: 'Espagne', label: 'Espagne' },
]
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
const canAddAddress = computed(() => {
const last = addresses.value[addresses.value.length - 1]
return last !== undefined && isAddressValid(last)
})
function addAddress(): void {
if (canAddAddress.value) addresses.value.push(emptyAddress())
}
function askRemoveAddress(index: number): void {
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => {
addresses.value.splice(index, 1)
addressErrors.value.splice(index, 1)
})
}
/** Avertit une seule fois quand l'autocompletion d'adresse bascule en degrade. */
function onAddressDegraded(): void {
if (addressDegradedNotified.value) return
addressDegradedNotified.value = true
toast.warning({
title: t('commercial.suppliers.toast.error'),
message: t('commercial.suppliers.form.address.degraded'),
})
}
/** POST/PATCH des adresses sur la sous-ressource /suppliers/{id}/addresses. */
async function submitAddresses(): Promise<void> {
if (supplierId.value === null || tabSubmitting.value) return
tabSubmitting.value = true
try {
const hasError = await submitRows(
addresses.value,
addressErrors,
async (address) => {
const body = buildAddressPayload(address)
if (address.id === null) {
const created = await api.post<{ id: number }>(
`/suppliers/${supplierId.value}/addresses`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
address.id = created.id
}
else {
await api.patch(`/supplier_addresses/${address.id}`, body, { toast: false })
}
},
error => toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(error) }),
)
if (hasError) return
if (completeTab('addresses')) return
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
}
finally {
tabSubmitting.value = false
}
}
// ── Onglet Comptabilite ─────────────────────────────────────────────────────
const accounting = reactive({
siren: null as string | null,
accountNumber: null as string | null,
tvaModeIri: null as string | null,
nTva: null as string | null,
paymentDelayIri: null as string | null,
paymentTypeIri: null as string | null,
bankIri: null as string | null,
})
const ribs = ref<SupplierRibFormDraft[]>([])
// L'onglet est editable seulement avec accounting.manage (sinon lecture seule).
const accountingReadonly = computed(() => isValidated('accounting') || !canAccountingManage.value)
// Code du type de reglement selectionne (pour RG-2.07 / RG-2.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-2.08).
const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
function onPaymentTypeChange(value: string | number | null): void {
accounting.paymentTypeIri = value === null ? null : String(value)
// La banque n'a de sens que pour un virement : on la vide sinon (RG-2.07).
if (!isBankRequired.value) accounting.bankIri = null
// Les RIB n'ont de sens que pour une LCR (RG-2.08) : amorce un bloc vide quand
// LCR est choisi, vide la liste sinon (pas de RIB fantome soumis).
if (isRibRequired.value) {
if (ribs.value.length === 0) ribs.value.push(emptyRib())
}
else {
ribs.value = []
ribErrors.value = []
}
}
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet.
const canAddRib = computed(() => {
const last = ribs.value[ribs.value.length - 1]
return last !== undefined && isRibComplete(last)
})
function addRib(): void {
if (canAddRib.value) ribs.value.push(emptyRib())
}
function askRemoveRib(index: number): void {
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => {
ribs.value.splice(index, 1)
ribErrors.value.splice(index, 1)
// Garde au moins un bloc RIB visible.
if (ribs.value.length === 0) ribs.value.push(emptyRib())
})
}
/**
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur /suppliers/{id}/ribs PUIS
* PATCH des scalaires (groupe supplier:write:accounting). Les RIB d'abord : le back
* valide RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires, les RIB
* doivent donc exister en base AVANT. Deux appels distincts (mode strict).
*/
async function submitAccounting(): Promise<void> {
if (supplierId.value === null || tabSubmitting.value) return
tabSubmitting.value = true
accountingErrors.clearErrors()
try {
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne). Seuls les blocs
// RIB TOTALEMENT vides (amorce neuve) sont ignores.
const ribHasError = await submitRows(
ribs.value,
ribErrors,
async (rib) => {
const body = buildRibPayload(rib)
if (rib.id === null) {
const created = await api.post<{ id: number }>(
`/suppliers/${supplierId.value}/ribs`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
rib.id = created.id
}
else {
await api.patch(`/supplier_ribs/${rib.id}`, body, { toast: false })
}
},
error => toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(error) }),
rib => rib.id === null && isRibBlank(rib),
)
if (ribHasError) return
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try {
await api.patch(
`/suppliers/${supplierId.value}`,
buildAccountingPayload(accounting, isBankRequired.value),
{ toast: false },
)
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.suppliers.toast.error') })
return
}
if (completeTab('accounting')) return
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
}
finally {
tabSubmitting.value = false
}
}
// ── 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
}
// ── Types de reponse API ────────────────────────────────────────────────────
interface SupplierResponse {
id: number
companyName: string | null
}
interface ContactResponse {
'@id'?: string
id: number
}
onMounted(() => {
// Echec du chargement des referentiels non bloquant : les selects restent vides.
referentials.loadCommon().catch(() => {})
})
</script>