Files
Starseed/frontend/modules/transport/composables/useCarrierForm.ts
T
tristan 5734aaef54
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m11s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled
feat(transport) : écran ajout transporteur — layout + formulaire principal (ERP-165)
2026-06-16 17:05:21 +02:00

208 lines
8.2 KiB
TypeScript

import { reactive, ref } from 'vue'
import { useFormErrors } from '~/shared/composables/useFormErrors'
import {
emptyCarrierMain,
type CarrierMainDraft,
type CarrierMainResponse,
} from '~/modules/transport/types/carrierForm'
/**
* 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)
// ── Formulaire principal ──────────────────────────────────────────────────
const main = reactive<CarrierMainDraft>(emptyCarrierMain())
// ── Onglets : ordre + gating progressif ───────────────────────────────────
const tabKeys = ref<string[]>([...CARRIER_TAB_KEYS])
// Index du dernier onglet déverrouillé (-1 tant que le transporteur n'est pas créé).
const unlockedIndex = ref(-1)
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
* (RG-4.01). Le back reste la couche autoritaire (ERP-101) — la certification
* obligatoire (sauf LIOT) et les RG conditionnelles sont re-validées serveur et
* remontées en 422 inline, sans pré-check front (qui devrait connaître le cas
* LIOT, hors périmètre ERP-165).
*/
function validateMainFront(): boolean {
let valid = true
if (!main.name?.trim()) {
mainErrors.setError('name', t('transport.carriers.form.errors.nameRequired'))
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> {
const payload: Record<string, unknown> = {
isChartered: main.isChartered,
}
if (main.name?.trim()) {
payload.name = main.name
}
if (main.certificationType) {
payload.certificationType = main.certificationType
}
return payload
}
/**
* POST /carriers (groupe `carrier:write:main`). Pré-check front (nom), puis
* création. Au succès : verrouille le bloc principal, déverrouille le 1er onglet
* et bascule sur « Qualimat ». 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
unlockedIndex.value = 0
activeTab.value = tabKeys.value[0] ?? CARRIER_TAB_KEYS[0]
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 })
}
/**
* 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,
carrierId,
mainLocked,
mainSubmitting,
mainErrors,
// onglets
tabKeys,
activeTab,
unlockedIndex,
validated,
editMode,
isValidated,
// actions
validateMainFront,
buildMainPayload,
submitMain,
patchCarrier,
completeTab,
}
}