From 5734aaef5424edd8339a5a6fe6e503f31549a273 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 16 Jun 2026 17:05:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(transport)=20:=20=C3=A9cran=20ajout=20tran?= =?UTF-8?q?sporteur=20=E2=80=94=20layout=20+=20formulaire=20principal=20(E?= =?UTF-8?q?RP-165)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/i18n/locales/fr.json | 24 +- .../__tests__/useCarrierForm.test.ts | 191 ++++++++++++++++ .../transport/composables/useCarrierForm.ts | 207 ++++++++++++++++++ .../modules/transport/pages/carriers/new.vue | 146 ++++++++++++ .../modules/transport/types/carrierForm.ts | 40 ++++ 5 files changed, 607 insertions(+), 1 deletion(-) create mode 100644 frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts create mode 100644 frontend/modules/transport/composables/useCarrierForm.ts create mode 100644 frontend/modules/transport/pages/carriers/new.vue create mode 100644 frontend/modules/transport/types/carrierForm.ts diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 686b1a7..3bbee0f 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -525,7 +525,29 @@ }, "toast": { "error": "Une erreur est survenue. Réessayez.", - "exportError": "L'export du répertoire transporteurs a échoué. Réessayez." + "exportError": "L'export du répertoire transporteurs a échoué. Réessayez.", + "createSuccess": "Transporteur créé avec succès" + }, + "tab": { + "qualimat": "Qualimat", + "addresses": "Adresses", + "contacts": "Contacts", + "prices": "Prix" + }, + "form": { + "title": "Ajouter un transporteur", + "back": "Retour au répertoire", + "submit": "Valider", + "comingSoon": "À venir", + "duplicateName": "Un transporteur actif portant ce nom existe déjà.", + "main": { + "name": "Nom", + "certificationType": "Certification transport", + "isChartered": "Affréter" + }, + "errors": { + "nameRequired": "Le nom du transporteur est obligatoire." + } } } }, diff --git a/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts new file mode 100644 index 0000000..729c1cb --- /dev/null +++ b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts @@ -0,0 +1,191 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * Tests du workflow « Ajouter un transporteur » (M4 Transport, ERP-165). + * + * `useCarrierForm` porte le formulaire principal (Nom + Certification + Affréter) + * et l'orchestration des onglets de création. On vérifie ici le CONTRAT propre à + * la création : + * - pré-check front : nom requis → POST bloqué, erreur inline, aucun appel réseau ; + * - POST /carriers (groupe carrier:write:main) : payload + Accept ld+json + + * toast:false ; au succès, verrouillage + bascule sur l'onglet Qualimat + + * réaffichage du nom normalisé ; + * - 409 doublon (RG-4.12) → erreur inline dédiée sur `name` ; + * - 422 → mapping inline par champ (propertyPath) ; + * - onglets : 4 onglets (Qualimat/Adresses/Contacts/Prix), completeTab + * déverrouille/avance et signale le dernier onglet ; + * - patchCarrier : PATCH partiel, no-op avant création. + */ + +const mockPost = vi.hoisted(() => vi.fn()) +const mockPatch = vi.hoisted(() => vi.fn()) + +vi.stubGlobal('useApi', () => ({ + get: vi.fn(), + post: mockPost, + put: vi.fn(), + patch: mockPatch, + delete: vi.fn(), +})) +vi.stubGlobal('useI18n', () => ({ t: (key: string) => key })) +vi.stubGlobal('useToast', () => ({ + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), +})) + +const { useCarrierForm, CARRIER_TAB_KEYS } = await import('../useCarrierForm') + +describe('useCarrierForm', () => { + beforeEach(() => { + mockPost.mockReset() + mockPatch.mockReset() + }) + + it('front : nom vide → erreur inline sur name, pas de POST', async () => { + const form = useCarrierForm() + + const created = await form.submitMain() + + expect(created).toBe(false) + expect(mockPost).not.toHaveBeenCalled() + expect(form.mainErrors.errors.name).toBe('transport.carriers.form.errors.nameRequired') + expect(form.mainLocked.value).toBe(false) + }) + + it('front : nom en espaces uniquement → erreur inline, pas de POST', async () => { + const form = useCarrierForm() + form.main.name = ' ' + + const created = await form.submitMain() + + expect(created).toBe(false) + expect(mockPost).not.toHaveBeenCalled() + expect(form.mainErrors.errors.name).toBe('transport.carriers.form.errors.nameRequired') + }) + + it('POST /carriers avec Accept ld+json, verrouille et bascule sur Qualimat', async () => { + mockPost.mockResolvedValueOnce({ id: 42, name: 'TRANSPORTS ACME', certificationType: 'GMP_PLUS' }) + const form = useCarrierForm() + form.main.name = 'Transports Acme' + form.main.certificationType = 'GMP_PLUS' + form.main.isChartered = true + + const created = await form.submitMain() + + expect(created).toBe(true) + expect(mockPost).toHaveBeenCalledTimes(1) + const [url, body, opts] = mockPost.mock.calls[0] ?? [] + expect(url).toBe('/carriers') + expect(body).toEqual({ + name: 'Transports Acme', + certificationType: 'GMP_PLUS', + isChartered: true, + }) + expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } }) + + expect(form.carrierId.value).toBe(42) + // RG-4.13 : réaffiche le nom normalisé (UPPERCASE) renvoyé par le serveur. + expect(form.main.name).toBe('TRANSPORTS ACME') + expect(form.mainLocked.value).toBe(true) + expect(form.activeTab.value).toBe('qualimat') + expect(form.unlockedIndex.value).toBe(0) + }) + + it('payload : omet name et certificationType vides, garde isChartered', async () => { + mockPost.mockRejectedValueOnce({ response: { status: 422, _data: { violations: [] } } }) + const form = useCarrierForm() + form.main.name = 'X' // nom présent pour passer le pré-check front + // certificationType laissé null → omis pour que la 422 « obligatoire » porte. + + await form.submitMain() + + const body = mockPost.mock.calls[0]?.[1] as Record + expect(body).toEqual({ name: 'X', isChartered: false }) + expect('certificationType' in body).toBe(false) + }) + + it('409 doublon (RG-4.12) : erreur inline dédiée sur name, pas de verrouillage', async () => { + mockPost.mockRejectedValueOnce({ response: { status: 409 } }) + const form = useCarrierForm() + form.main.name = 'Doublon' + form.main.certificationType = 'AUTRE' + + const created = await form.submitMain() + + expect(created).toBe(false) + expect(form.mainErrors.errors.name).toBe('transport.carriers.form.duplicateName') + expect(form.mainLocked.value).toBe(false) + }) + + it('422 : mappe les violations serveur inline par champ', async () => { + mockPost.mockRejectedValueOnce({ + response: { + status: 422, + _data: { violations: [{ propertyPath: 'certificationType', message: 'Le type de certification est obligatoire.' }] }, + }, + }) + const form = useCarrierForm() + form.main.name = 'Sans Certif' + + const created = await form.submitMain() + + expect(created).toBe(false) + expect(form.mainErrors.errors.certificationType).toBe('Le type de certification est obligatoire.') + expect(form.mainLocked.value).toBe(false) + }) + + it('onglets : 4 clés Qualimat/Adresses/Contacts/Prix', () => { + expect(CARRIER_TAB_KEYS).toEqual(['qualimat', 'addresses', 'contacts', 'prices']) + const form = useCarrierForm() + expect(form.tabKeys.value).toEqual(['qualimat', 'addresses', 'contacts', 'prices']) + // Tous verrouillés tant que le formulaire principal n'est pas validé. + expect(form.unlockedIndex.value).toBe(-1) + }) + + it('completeTab : déverrouille/avance, et signale le dernier onglet du flux', () => { + const form = useCarrierForm() + + // Qualimat → Adresses (pas le dernier). + expect(form.completeTab('qualimat')).toBe(false) + expect(form.isValidated('qualimat')).toBe(true) + expect(form.activeTab.value).toBe('addresses') + expect(form.unlockedIndex.value).toBe(1) + + expect(form.completeTab('addresses')).toBe(false) + expect(form.activeTab.value).toBe('contacts') + + expect(form.completeTab('contacts')).toBe(false) + expect(form.activeTab.value).toBe('prices') + + // Prix = dernier onglet → true (création terminée). + expect(form.completeTab('prices')).toBe(true) + expect(form.isValidated('prices')).toBe(true) + }) + + it('editMode : completeTab ne verrouille pas et ne bascule pas d\'onglet', () => { + const form = useCarrierForm() + form.editMode.value = true + form.activeTab.value = 'qualimat' + + expect(form.completeTab('qualimat')).toBe(false) + expect(form.isValidated('qualimat')).toBe(false) + expect(form.activeTab.value).toBe('qualimat') + }) + + it('patchCarrier : PATCH /carriers/{id} en mode strict, no-op avant création', async () => { + const form = useCarrierForm() + + await form.patchCarrier({ liotPlates: 'AA-123-BB' }) + expect(mockPatch).not.toHaveBeenCalled() + + mockPost.mockResolvedValueOnce({ id: 9, name: 'ACME', certificationType: 'OVOCOM' }) + form.main.name = 'Acme' + form.main.certificationType = 'OVOCOM' + await form.submitMain() + + await form.patchCarrier({ liotPlates: 'AA-123-BB' }) + expect(mockPatch).toHaveBeenCalledWith('/carriers/9', { liotPlates: 'AA-123-BB' }, { toast: false }) + }) +}) diff --git a/frontend/modules/transport/composables/useCarrierForm.ts b/frontend/modules/transport/composables/useCarrierForm.ts new file mode 100644 index 0000000..ef6326e --- /dev/null +++ b/frontend/modules/transport/composables/useCarrierForm.ts @@ -0,0 +1,207 @@ +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(null) + const mainLocked = ref(false) + const mainSubmitting = ref(false) + + // ── Formulaire principal ────────────────────────────────────────────────── + const main = reactive(emptyCarrierMain()) + + // ── Onglets : ordre + gating progressif ─────────────────────────────────── + const tabKeys = ref([...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(CARRIER_TAB_KEYS[0]) + // Onglets validés (passent en lecture seule). + const validated = reactive>({}) + // 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 { + const payload: Record = { + 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 { + if (mainSubmitting.value) return false + mainErrors.clearErrors() + if (!validateMainFront()) return false + + mainSubmitting.value = true + try { + const created = await api.post('/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): Promise { + 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, + } +} diff --git a/frontend/modules/transport/pages/carriers/new.vue b/frontend/modules/transport/pages/carriers/new.vue new file mode 100644 index 0000000..21f0cd5 --- /dev/null +++ b/frontend/modules/transport/pages/carriers/new.vue @@ -0,0 +1,146 @@ + + + diff --git a/frontend/modules/transport/types/carrierForm.ts b/frontend/modules/transport/types/carrierForm.ts new file mode 100644 index 0000000..5fe7c1f --- /dev/null +++ b/frontend/modules/transport/types/carrierForm.ts @@ -0,0 +1,40 @@ +/** + * Types du workflow « Ajouter un transporteur » (M4 Transport, ERP-165). + * + * Périmètre ERP-165 = formulaire PRINCIPAL (pré-onglets) uniquement : Nom + + * Certification + Affréter. Les champs conditionnels (indexation / benne / volume + * si affrété, décharge si AUTRE, immatriculations LIOT) et la saisie assistée + * QUALIMAT arrivent à ERP-166 ; les onglets Adresses / Contacts / Prix aux tickets + * suivants. On garde donc volontairement ce draft minimal — il s'étendra. + */ + +/** + * Brouillon du formulaire principal. `certificationType` est un code enum back + * (GMP_PLUS | OVOCOM | COMPTE_PROPRE | AUTRE ; QUALIMAT sera posé par la saisie + * assistée à ERP-166) ou `null` tant que rien n'est choisi. + */ +export interface CarrierMainDraft { + name: string + certificationType: string | null + isChartered: boolean +} + +/** Brouillon principal vide (état initial du formulaire de création). */ +export function emptyCarrierMain(): CarrierMainDraft { + return { + name: '', + certificationType: null, + isChartered: false, + } +} + +/** + * Réponse du POST / PATCH principal (groupe `carrier:read`). Le serveur renvoie + * le nom normalisé (UPPERCASE, RG-4.13) que l'UI réaffiche tel quel. + */ +export interface CarrierMainResponse { + id: number + name: string | null + certificationType: string | null + '@id'?: string +}