670 lines
28 KiB
TypeScript
670 lines
28 KiB
TypeScript
import { computed, reactive, ref, type Ref } from 'vue'
|
|
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
|
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
|
|
import { removeCollectionRow } from '~/shared/utils/collectionRow'
|
|
import {
|
|
emptyCarrierAddress,
|
|
emptyCarrierAddressCopy,
|
|
emptyCarrierContact,
|
|
emptyCarrierMain,
|
|
emptyCarrierPrice,
|
|
type CarrierAddressCopy,
|
|
type CarrierAddressFormDraft,
|
|
type CarrierContactFormDraft,
|
|
type CarrierMainDraft,
|
|
type CarrierMainResponse,
|
|
type CarrierPriceFormDraft,
|
|
} from '~/modules/transport/types/carrierForm'
|
|
import { buildCarrierAddressPayload, isCarrierAddressValid } from '~/modules/transport/utils/forms/carrierAddress'
|
|
import { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } from '~/modules/transport/utils/forms/carrierContact'
|
|
import { buildCarrierPricePayload, isCarrierPriceValid } from '~/modules/transport/utils/forms/carrierPrice'
|
|
import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
|
|
|
/** Nom du cas spécial « compte-propre » LIOT (comparaison insensible à la casse, RG-4.01). */
|
|
const LIOT_NAME = 'LIOT'
|
|
|
|
/**
|
|
* Workflow de l'écran « Ajouter un transporteur » (M4 Transport, ERP-165) —
|
|
* miroir conceptuel de `useSupplierForm` (M2) / `useProviderForm` (M3).
|
|
*
|
|
* Périmètre ERP-165 : le formulaire PRINCIPAL (pré-onglets) et l'orchestration de
|
|
* la barre d'onglets. La création est INCRÉMENTALE (spec-front § Écran Ajouter) :
|
|
* - on POST d'abord le formulaire principal (`POST /api/carriers`) ;
|
|
* - au succès le bloc principal passe en lecture seule, le 1er onglet (Qualimat)
|
|
* se déverrouille et devient actif ;
|
|
* - chaque onglet validé déverrouille le suivant (PATCH partiels par groupe de
|
|
* sérialisation) et passe en lecture seule.
|
|
*
|
|
* Les champs conditionnels du formulaire principal (indexation / benne / volume
|
|
* si affrété, décharge si AUTRE, cas LIOT) et la saisie assistée QUALIMAT arrivent
|
|
* à ERP-166 ; le contenu des onglets Adresses / Contacts / Prix aux tickets
|
|
* suivants. Ce composable pose le POST principal, le PATCH partiel et le gating
|
|
* des onglets.
|
|
*
|
|
* État 100 % local à l'instance (refs / reactive) — aucune persistance URL.
|
|
*/
|
|
|
|
/**
|
|
* Clés des onglets du flux de création, dans l'ordre de la barre (spec-front
|
|
* § Écran Ajouter). Toujours les 4 (pas de gating par permission au M4, ≠ l'onglet
|
|
* Comptabilité du M3).
|
|
*/
|
|
export const CARRIER_TAB_KEYS = ['qualimat', 'addresses', 'contacts', 'prices'] as const
|
|
|
|
export function useCarrierForm() {
|
|
const api = useApi()
|
|
const { t } = useI18n()
|
|
const toast = useToast()
|
|
|
|
// Erreurs de validation par champ (ERP-101) du formulaire principal.
|
|
const mainErrors = useFormErrors()
|
|
|
|
// ── État du transporteur créé ─────────────────────────────────────────────
|
|
const carrierId = ref<number | null>(null)
|
|
const mainLocked = ref(false)
|
|
const mainSubmitting = ref(false)
|
|
const tabSubmitting = ref(false)
|
|
|
|
// ── Formulaire principal ──────────────────────────────────────────────────
|
|
const main = reactive<CarrierMainDraft>(emptyCarrierMain())
|
|
|
|
// Adresse copiée depuis QUALIMAT à la sélection (alimente l'onglet Adresses,
|
|
// ticket ultérieur). Vide tant qu'aucun transporteur QUALIMAT n'est intégré.
|
|
const qualimatAddress = ref<CarrierAddressCopy>(emptyCarrierAddressCopy())
|
|
|
|
// ── Affichage conditionnel du formulaire principal (RG-4.01 / 4.02 / 4.03) ──
|
|
// Cas LIOT : nom == « LIOT » → seul `liotPlates` est pertinent, le reste masqué.
|
|
const isLiot = computed(() => main.name.trim().toUpperCase() === LIOT_NAME)
|
|
// Transporteur QUALIMAT : la FK est posée → certification figée à « QUALIMAT ».
|
|
const isQualimat = computed(() => main.qualimatCarrierIri !== null)
|
|
// Certification masquée en cas LIOT ; lecture seule si QUALIMAT (ou bloc verrouillé).
|
|
const showCertification = computed(() => !isLiot.value)
|
|
const certificationReadonly = computed(() => isQualimat.value || mainLocked.value)
|
|
// RG-4.03 : champs d'affrètement (indexation / contenant / volume) visibles et
|
|
// obligatoires si « Affréter » coché — masqués en cas LIOT.
|
|
const showCharteredFields = computed(() => main.isChartered && !isLiot.value)
|
|
// RG-4.02 : décharge visible et obligatoire si certification == AUTRE (hors LIOT).
|
|
const showDischarge = computed(() => main.certificationType === 'AUTRE' && !isLiot.value)
|
|
|
|
// ── Onglets : ordre + gating progressif ───────────────────────────────────
|
|
const tabKeys = ref<string[]>([...CARRIER_TAB_KEYS])
|
|
// Index du dernier onglet déverrouillé. L'onglet Qualimat (index 0) est la saisie
|
|
// assistée du formulaire principal : accessible DÈS LE DÉPART (≠ Adresses /
|
|
// Contacts / Prix, déverrouillés seulement après le POST principal).
|
|
const unlockedIndex = ref(0)
|
|
const activeTab = ref<string>(CARRIER_TAB_KEYS[0])
|
|
// Onglets validés (passent en lecture seule).
|
|
const validated = reactive<Record<string, boolean>>({})
|
|
// Mode MODIFICATION (ticket ultérieur) : navigation libre, pas de verrouillage
|
|
// ni de bascule automatique d'onglet à la validation (cf. completeTab).
|
|
const editMode = ref(false)
|
|
|
|
function isValidated(key: string): boolean {
|
|
return validated[key] === true
|
|
}
|
|
|
|
function tabIndex(key: string): number {
|
|
return tabKeys.value.indexOf(key)
|
|
}
|
|
|
|
/**
|
|
* Validation FRONT du formulaire principal : seul le nom est requis côté front
|
|
* (ERP-101) : feedback immédiat sur tous les champs obligatoires (y compris
|
|
* conditionnels), alignés sur les RG du back (qui reste autoritaire) :
|
|
* - RG-4.01 : nom requis ; certification requise hors cas LIOT (où tout est masqué) ;
|
|
* - RG-4.02 : décharge requise si certification AUTRE ;
|
|
* - RG-4.03 : indexation + contenant + volume requis si « Affréter ».
|
|
*/
|
|
function validateMainFront(): boolean {
|
|
let valid = true
|
|
if (!main.name?.trim()) {
|
|
mainErrors.setError('name', t('transport.carriers.form.errors.nameRequired'))
|
|
valid = false
|
|
}
|
|
|
|
// Cas LIOT : seul le nom compte, les autres champs sont masqués (RG-4.01).
|
|
if (isLiot.value) {
|
|
return valid
|
|
}
|
|
|
|
// RG-4.01 : certification obligatoire hors LIOT.
|
|
if (!main.certificationType) {
|
|
mainErrors.setError('certificationType', t('transport.carriers.form.errors.certificationRequired'))
|
|
valid = false
|
|
}
|
|
|
|
// RG-4.02 : décharge obligatoire si certification AUTRE.
|
|
if (main.certificationType === 'AUTRE' && !main.dischargeDocumentIri) {
|
|
mainErrors.setError('dischargeDocument', t('transport.carriers.form.errors.dischargeRequired'))
|
|
valid = false
|
|
}
|
|
|
|
// RG-4.03 : indexation / contenant / volume obligatoires si affrété.
|
|
if (main.isChartered) {
|
|
if (!main.indexationRate.trim()) {
|
|
mainErrors.setError('indexationRate', t('transport.carriers.form.errors.indexationRequired'))
|
|
valid = false
|
|
}
|
|
if (!main.containerType) {
|
|
mainErrors.setError('containerType', t('transport.carriers.form.errors.containerTypeRequired'))
|
|
valid = false
|
|
}
|
|
if (!main.volumeM3.trim()) {
|
|
mainErrors.setError('volumeM3', t('transport.carriers.form.errors.volumeRequired'))
|
|
valid = false
|
|
}
|
|
}
|
|
|
|
return valid
|
|
}
|
|
|
|
/**
|
|
* Payload du POST principal (groupe `carrier:write:main`). `name` et
|
|
* `certificationType` sont omis s'ils sont vides afin que la 422 porte la
|
|
* violation métier (NotBlank sur le nom, « certification obligatoire » sur la
|
|
* certification) sur le champ plutôt qu'une erreur de type.
|
|
*/
|
|
function buildMainPayload(): Record<string, unknown> {
|
|
// Cas LIOT (RG-4.01) : seul `liotPlates` est pertinent ; les autres champs
|
|
// sont masqués côté front et non envoyés (le back stocke ce qu'il reçoit).
|
|
if (isLiot.value) {
|
|
const payload: Record<string, unknown> = { name: main.name, isChartered: false }
|
|
if (main.liotPlates.trim()) {
|
|
payload.liotPlates = main.liotPlates
|
|
}
|
|
return payload
|
|
}
|
|
|
|
const payload: Record<string, unknown> = { isChartered: main.isChartered }
|
|
if (main.name.trim()) {
|
|
payload.name = main.name
|
|
}
|
|
if (main.certificationType) {
|
|
payload.certificationType = main.certificationType
|
|
}
|
|
// FK QUALIMAT (saisie assistée, § 2.5) envoyée si une ligne a été intégrée.
|
|
if (main.qualimatCarrierIri) {
|
|
payload.qualimatCarrier = main.qualimatCarrierIri
|
|
}
|
|
// RG-4.02 : décharge envoyée seulement en certification AUTRE ; omise quand
|
|
// absente pour que la 422 « obligatoire » porte sur le champ.
|
|
if (main.certificationType === 'AUTRE' && main.dischargeDocumentIri) {
|
|
payload.dischargeDocument = main.dischargeDocumentIri
|
|
}
|
|
// RG-4.03 : indexation / contenant / volume envoyés seulement si affrété ;
|
|
// omis quand vides pour déclencher la 422 NotBlank inline sur le champ.
|
|
if (main.isChartered) {
|
|
if (main.indexationRate.trim()) {
|
|
payload.indexationRate = main.indexationRate
|
|
}
|
|
if (main.containerType) {
|
|
payload.containerType = main.containerType
|
|
}
|
|
if (main.volumeM3.trim()) {
|
|
payload.volumeM3 = main.volumeM3
|
|
}
|
|
}
|
|
return payload
|
|
}
|
|
|
|
/**
|
|
* POST /carriers (groupe `carrier:write:main`). Pré-check front, puis création.
|
|
* Au succès : verrouille le bloc principal, déverrouille l'onglet Adresses et
|
|
* bascule dessus (l'onglet Qualimat, saisie assistée, était déjà accessible).
|
|
* Retourne true si créé, false sinon.
|
|
*/
|
|
async function submitMain(): Promise<boolean> {
|
|
if (mainSubmitting.value) return false
|
|
mainErrors.clearErrors()
|
|
if (!validateMainFront()) return false
|
|
|
|
mainSubmitting.value = true
|
|
try {
|
|
const created = await api.post<CarrierMainResponse>('/carriers', buildMainPayload(), {
|
|
headers: { Accept: 'application/ld+json' },
|
|
toast: false,
|
|
})
|
|
|
|
carrierId.value = created.id
|
|
// Réaffiche les valeurs normalisées renvoyées par le serveur (nom en
|
|
// UPPERCASE — RG-4.13 ; certification éventuellement forcée).
|
|
main.name = created.name ?? main.name
|
|
main.certificationType = created.certificationType ?? main.certificationType
|
|
|
|
mainLocked.value = true
|
|
// Déverrouille l'onglet suivant (Adresses, index 1) et bascule dessus.
|
|
unlockedIndex.value = Math.max(unlockedIndex.value, 1)
|
|
activeTab.value = tabKeys.value[1] ?? CARRIER_TAB_KEYS[1]
|
|
toast.success({ title: t('transport.carriers.toast.createSuccess') })
|
|
return true
|
|
}
|
|
catch (error) {
|
|
// 409 = doublon de nom (RG-4.12) → erreur inline dédiée + 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('transport.carriers.form.duplicateName')
|
|
mainErrors.setError('name', message)
|
|
toast.error({ title: t('transport.carriers.toast.error'), message })
|
|
}
|
|
else {
|
|
mainErrors.handleApiError(error, { fallbackMessage: t('transport.carriers.toast.error') })
|
|
}
|
|
return false
|
|
}
|
|
finally {
|
|
mainSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* PATCH partiel du transporteur (mode strict : un seul groupe de sérialisation
|
|
* par appel — spec-back § 2.9). Servira les onglets à champs scalaires des
|
|
* tickets suivants. No-op tant que le transporteur n'existe pas.
|
|
*/
|
|
async function patchCarrier(payload: Record<string, unknown>): Promise<void> {
|
|
if (carrierId.value === null) return
|
|
await api.patch(`/carriers/${carrierId.value}`, payload, { toast: false })
|
|
}
|
|
|
|
/** Remontée d'erreur de suppression immédiate d'une sous-ressource (toast dédié). */
|
|
function notifyRemovalError(error: unknown): void {
|
|
toast.error({
|
|
title: t('transport.carriers.toast.error'),
|
|
message: extractApiErrorMessage((error as { data?: unknown })?.data) || t('transport.carriers.toast.error'),
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Soumet TOUS les blocs d'une collection en collectant les erreurs PAR INDEX :
|
|
* on n'arrête pas au premier bloc en échec (décision ERP-101). Réinitialise la
|
|
* cible, tente chaque ligne via `saveRow`, mappe les 422 inline ou délègue le
|
|
* fallback à `onUnmappedError`. `shouldSkip` ignore les amorces vides. Retourne
|
|
* true si au moins un bloc a échoué. Miroir de `useProviderForm.submitRows`.
|
|
*/
|
|
async function submitRows<T>(
|
|
rows: T[],
|
|
target: Ref<Record<string, string>[]>,
|
|
saveRow: (row: T, index: number) => Promise<void>,
|
|
onUnmappedError: (error: unknown, index: number) => void,
|
|
shouldSkip?: (row: T, index: number) => boolean,
|
|
): Promise<boolean> {
|
|
target.value = []
|
|
let hasError = false
|
|
for (let index = 0; index < rows.length; index++) {
|
|
const row = rows[index] as T
|
|
if (shouldSkip?.(row, index)) {
|
|
continue
|
|
}
|
|
try {
|
|
await saveRow(row, index)
|
|
}
|
|
catch (error) {
|
|
const response = (error as { response?: { status?: number, _data?: unknown } })?.response
|
|
const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {}
|
|
if (Object.keys(mapped).length > 0) {
|
|
target.value[index] = mapped
|
|
}
|
|
else {
|
|
onUnmappedError(error, index)
|
|
}
|
|
hasError = true
|
|
}
|
|
}
|
|
return hasError
|
|
}
|
|
|
|
// ── Onglet Adresses (ERP-167) ─────────────────────────────────────────────
|
|
const addresses = ref<CarrierAddressFormDraft[]>([emptyCarrierAddress()])
|
|
// Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows.
|
|
const addressErrors = ref<Record<string, string>[]>([])
|
|
|
|
// « + Nouvelle adresse » désactivé tant que la dernière adresse n'est pas
|
|
// complète (CP + ville + rue — RG-4.05, gate d'ajout).
|
|
const canAddAddress = computed(() => {
|
|
const last = addresses.value[addresses.value.length - 1]
|
|
return last !== undefined && isCarrierAddressValid(last)
|
|
})
|
|
|
|
function addAddress(): void {
|
|
if (canAddAddress.value) {
|
|
addresses.value.push(emptyCarrierAddress())
|
|
}
|
|
}
|
|
|
|
/** Suppression immédiate d'une adresse existante (DELETE /carrier_addresses/{id}). */
|
|
async function removeAddress(index: number): Promise<void> {
|
|
await removeCollectionRow({
|
|
rows: addresses.value,
|
|
errors: addressErrors.value,
|
|
index,
|
|
endpoint: '/carrier_addresses',
|
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
|
makeEmpty: emptyCarrierAddress,
|
|
onError: notifyRemovalError,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Valide l'onglet Adresses : POST des nouvelles adresses sur
|
|
* /carriers/{id}/addresses, PATCH des existantes sur /carrier_addresses/{id}
|
|
* (groupe carrier:write:addresses). Erreurs 422 collectées par ligne (RG-4.05
|
|
* « obligatoire si affrété » re-validée back). Retourne true si l'onglet a été
|
|
* validé (avancé/terminé).
|
|
*/
|
|
async function submitAddresses(onError: (error: unknown) => void): Promise<boolean> {
|
|
if (carrierId.value === null || tabSubmitting.value) {
|
|
return false
|
|
}
|
|
tabSubmitting.value = true
|
|
try {
|
|
const hasError = await submitRows(
|
|
addresses.value,
|
|
addressErrors,
|
|
async (address) => {
|
|
const body = buildCarrierAddressPayload(address)
|
|
if (address.id === null) {
|
|
const created = await api.post<{ id: number }>(
|
|
`/carriers/${carrierId.value}/addresses`,
|
|
body,
|
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
|
)
|
|
address.id = created.id
|
|
}
|
|
else {
|
|
await api.patch(`/carrier_addresses/${address.id}`, body, { toast: false })
|
|
}
|
|
},
|
|
onError,
|
|
)
|
|
if (hasError) {
|
|
return false
|
|
}
|
|
completeTab('addresses')
|
|
return true
|
|
}
|
|
finally {
|
|
tabSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
// ── Onglet Contacts (ERP-168) ─────────────────────────────────────────────
|
|
const contacts = ref<CarrierContactFormDraft[]>([emptyCarrierContact()])
|
|
// Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows.
|
|
const contactErrors = ref<Record<string, string>[]>([])
|
|
|
|
// « + Nouveau contact » désactivé tant que le DERNIER bloc n'est pas « nommé »
|
|
// (prénom OU nom) — aligné sur M1/M2/M3 (fonction / téléphone / email seuls ne
|
|
// suffisent pas à ajouter un nouveau bloc).
|
|
const canAddContact = computed(() => {
|
|
const last = contacts.value[contacts.value.length - 1]
|
|
return last !== undefined && isCarrierContactNamed(last)
|
|
})
|
|
|
|
function addContact(): void {
|
|
if (canAddContact.value) {
|
|
contacts.value.push(emptyCarrierContact())
|
|
}
|
|
}
|
|
|
|
/** Suppression immédiate d'un contact existant (DELETE /carrier_contacts/{id}). */
|
|
async function removeContact(index: number): Promise<void> {
|
|
await removeCollectionRow({
|
|
rows: contacts.value,
|
|
errors: contactErrors.value,
|
|
index,
|
|
endpoint: '/carrier_contacts',
|
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
|
makeEmpty: emptyCarrierContact,
|
|
onError: notifyRemovalError,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Valide l'onglet Contacts : POST des nouveaux contacts sur
|
|
* /carriers/{id}/contacts, PATCH des existants sur /carrier_contacts/{id}
|
|
* (groupe carrier:write:contacts). RG-4.08 (≥ 1 champ rempli, max 2 téléphones)
|
|
* re-validée back → 422 par ligne. Si l'onglet ne contient QUE des amorces
|
|
* vides, on soumet la 1re pour déclencher la 422 RG-4.08 inline plutôt que de
|
|
* finaliser un onglet vide. Retourne true si l'onglet a été validé.
|
|
*/
|
|
async function submitContacts(onError: (error: unknown) => void): Promise<boolean> {
|
|
if (carrierId.value === null || tabSubmitting.value) {
|
|
return false
|
|
}
|
|
tabSubmitting.value = true
|
|
try {
|
|
const hasSubmittable = contacts.value.some(c => c.id !== null || !isCarrierContactBlank(c))
|
|
const hasError = await submitRows(
|
|
contacts.value,
|
|
contactErrors,
|
|
async (contact) => {
|
|
const body = buildCarrierContactPayload(contact)
|
|
if (contact.id === null) {
|
|
const created = await api.post<{ id: number }>(
|
|
`/carriers/${carrierId.value}/contacts`,
|
|
body,
|
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
|
)
|
|
contact.id = created.id
|
|
}
|
|
else {
|
|
await api.patch(`/carrier_contacts/${contact.id}`, body, { toast: false })
|
|
}
|
|
},
|
|
onError,
|
|
// Amorce vide neuve ignorée s'il reste un autre bloc soumettable ;
|
|
// sinon on la soumet pour déclencher la 422 RG-4.08 (sur firstName).
|
|
contact => hasSubmittable && contact.id === null && isCarrierContactBlank(contact),
|
|
)
|
|
if (hasError) {
|
|
return false
|
|
}
|
|
completeTab('contacts')
|
|
return true
|
|
}
|
|
finally {
|
|
tabSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
// ── Onglet Prix (ERP-169) ─────────────────────────────────────────────────
|
|
// Un bloc présent par défaut (sens CLIENT pré-sélectionné). L'utilisateur ajoute
|
|
// les suivants via « + Nouveau prix ».
|
|
const prices = ref<CarrierPriceFormDraft[]>([emptyCarrierPrice()])
|
|
// Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows.
|
|
const priceErrors = ref<Record<string, string>[]>([])
|
|
|
|
// « + Nouveau prix » : autorisé si la liste est vide, sinon le dernier bloc doit
|
|
// être valide (branche complète + prix — RG-4.09→4.11, pré-check léger).
|
|
const canAddPrice = computed(() => {
|
|
const last = prices.value[prices.value.length - 1]
|
|
return last === undefined || isCarrierPriceValid(last)
|
|
})
|
|
|
|
function addPrice(): void {
|
|
if (canAddPrice.value) {
|
|
prices.value.push(emptyCarrierPrice())
|
|
}
|
|
}
|
|
|
|
/** Suppression immédiate d'un prix existant (DELETE /carrier_prices/{id}). */
|
|
async function removePrice(index: number): Promise<void> {
|
|
await removeCollectionRow({
|
|
rows: prices.value,
|
|
errors: priceErrors.value,
|
|
index,
|
|
endpoint: '/carrier_prices',
|
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
|
makeEmpty: emptyCarrierPrice,
|
|
onError: notifyRemovalError,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Valide l'onglet Prix : POST des nouveaux prix sur /carriers/{id}/prices, PATCH
|
|
* des existants sur /carrier_prices/{id} (groupe carrier:write:prices). La
|
|
* cohérence de branche CLIENT/FOURNISSEUR (RG-4.09→4.11) est re-validée back →
|
|
* 422 par ligne. Onglet Prix optionnel : une liste vide finalise sans appel.
|
|
* Retourne true si l'onglet a été validé (création terminée).
|
|
*/
|
|
async function submitPrices(onError: (error: unknown) => void): Promise<boolean> {
|
|
if (carrierId.value === null || tabSubmitting.value) {
|
|
return false
|
|
}
|
|
tabSubmitting.value = true
|
|
try {
|
|
const hasError = await submitRows(
|
|
prices.value,
|
|
priceErrors,
|
|
async (price) => {
|
|
const body = buildCarrierPricePayload(price)
|
|
if (price.id === null) {
|
|
const created = await api.post<{ id: number }>(
|
|
`/carriers/${carrierId.value}/prices`,
|
|
body,
|
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
|
)
|
|
price.id = created.id
|
|
}
|
|
else {
|
|
await api.patch(`/carrier_prices/${price.id}`, body, { toast: false })
|
|
}
|
|
},
|
|
onError,
|
|
)
|
|
if (hasError) {
|
|
return false
|
|
}
|
|
completeTab('prices')
|
|
return true
|
|
}
|
|
finally {
|
|
tabSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Intègre une ligne QUALIMAT sélectionnée dans l'onglet Qualimat (RG-4.01 /
|
|
* § 2.5) : copie le nom, force la certification à « QUALIMAT » (lecture seule),
|
|
* pose la FK `qualimatCarrier` (IRI) et copie l'adresse (pour l'onglet Adresses).
|
|
* Si le transporteur existe déjà (post-POST, cas nominal de l'onglet), persiste
|
|
* d'abord la copie via un PATCH partiel `carrier:write:main` : la copie locale
|
|
* (nom, certification figée « QUALIMAT », FK, adresse) n'est appliquée qu'en cas
|
|
* de succès, pour ne pas laisser l'UI dans un état QUALIMAT non sauvegardé si le
|
|
* PATCH échoue. Retourne true si l'intégration a abouti.
|
|
*/
|
|
async function applyQualimatSelection(row: QualimatCarrierRow): Promise<boolean> {
|
|
// Transporteur déjà créé : on persiste avant de refléter localement.
|
|
if (carrierId.value !== null) {
|
|
try {
|
|
await patchCarrier({
|
|
qualimatCarrier: row['@id'],
|
|
name: row.name,
|
|
certificationType: 'QUALIMAT',
|
|
})
|
|
}
|
|
catch (error) {
|
|
mainErrors.handleApiError(error, { fallbackMessage: t('transport.carriers.toast.error') })
|
|
return false
|
|
}
|
|
}
|
|
|
|
main.name = row.name ?? ''
|
|
main.certificationType = 'QUALIMAT'
|
|
main.qualimatCarrierIri = row['@id']
|
|
qualimatAddress.value = {
|
|
country: 'France',
|
|
postalCode: row.postalCode ?? '',
|
|
city: row.city ?? '',
|
|
street: row.address ?? '',
|
|
}
|
|
// RG-4.05 : pré-remplit le 1er bloc de l'onglet Adresses par copie (la FK
|
|
// QUALIMAT survit, les champs restent éditables — § 2.5).
|
|
addresses.value = [{
|
|
id: null,
|
|
country: 'France',
|
|
postalCode: row.postalCode || null,
|
|
city: row.city || null,
|
|
street: row.address || null,
|
|
streetComplement: null,
|
|
}]
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Marque un onglet validé (passe en lecture seule), déverrouille et avance à
|
|
* l'onglet suivant. Retourne true si c'était le dernier onglet du flux (création
|
|
* terminée), false sinon.
|
|
*/
|
|
function completeTab(key: string): boolean {
|
|
// En modification : navigation libre, l'onglet reste éditable après validation.
|
|
if (editMode.value) {
|
|
return false
|
|
}
|
|
validated[key] = true
|
|
const index = tabIndex(key)
|
|
const next = tabKeys.value[index + 1]
|
|
if (next === undefined) {
|
|
return true
|
|
}
|
|
unlockedIndex.value = Math.max(unlockedIndex.value, index + 1)
|
|
activeTab.value = next
|
|
return false
|
|
}
|
|
|
|
return {
|
|
// état
|
|
main,
|
|
qualimatAddress,
|
|
carrierId,
|
|
mainLocked,
|
|
mainSubmitting,
|
|
tabSubmitting,
|
|
mainErrors,
|
|
// affichage conditionnel
|
|
isLiot,
|
|
isQualimat,
|
|
showCertification,
|
|
certificationReadonly,
|
|
showCharteredFields,
|
|
showDischarge,
|
|
// onglets
|
|
tabKeys,
|
|
activeTab,
|
|
unlockedIndex,
|
|
validated,
|
|
editMode,
|
|
isValidated,
|
|
// adresses
|
|
addresses,
|
|
addressErrors,
|
|
canAddAddress,
|
|
addAddress,
|
|
removeAddress,
|
|
submitAddresses,
|
|
// contacts
|
|
contacts,
|
|
contactErrors,
|
|
canAddContact,
|
|
addContact,
|
|
removeContact,
|
|
submitContacts,
|
|
// prix
|
|
prices,
|
|
priceErrors,
|
|
canAddPrice,
|
|
addPrice,
|
|
removePrice,
|
|
submitPrices,
|
|
// actions
|
|
validateMainFront,
|
|
buildMainPayload,
|
|
submitMain,
|
|
patchCarrier,
|
|
applyQualimatSelection,
|
|
completeTab,
|
|
submitRows,
|
|
}
|
|
}
|