diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 90d4636..440175b 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -621,7 +621,8 @@ "dischargeRequired": "La décharge est obligatoire pour une certification « Autre ».", "indexationRequired": "Le taux d'indexation est obligatoire pour un transporteur affrété.", "containerTypeRequired": "Le type de contenant est obligatoire pour un transporteur affrété.", - "volumeRequired": "Le volume est obligatoire pour un transporteur affrété." + "volumeRequired": "Le volume est obligatoire pour un transporteur affrété.", + "uploadFailed": "Le téléversement de la décharge a échoué." }, "address": { "country": "Pays", diff --git a/frontend/modules/transport/composables/useCarrierForm.ts b/frontend/modules/transport/composables/useCarrierForm.ts index 6db00a7..f1b26cf 100644 --- a/frontend/modules/transport/composables/useCarrierForm.ts +++ b/frontend/modules/transport/composables/useCarrierForm.ts @@ -1,5 +1,6 @@ import { computed, reactive, ref, type Ref } from 'vue' import { useFormErrors } from '~/shared/composables/useFormErrors' +import { useUpload } from '~/shared/composables/useUpload' import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api' import { removeCollectionRow } from '~/shared/utils/collectionRow' import { @@ -66,6 +67,9 @@ export function useCarrierForm() { // Erreurs de validation par champ (ERP-101) du formulaire principal. const mainErrors = useFormErrors() + // Upload de la décharge (RG-4.02) — infra partagée /api/uploaded_documents. + const { uploading: dischargeUploading, upload: uploadFile } = useUpload() + // ── État du transporteur créé ───────────────────────────────────────────── const carrierId = ref(null) const mainLocked = ref(false) @@ -165,6 +169,24 @@ export function useCarrierForm() { return valid } + /** + * Upload de la décharge (RG-4.02) déclenché par `@file-selected` du champ + * Décharge : envoie le fichier, pose l'IRI résultant sur le brouillon. Au 422 + * (MIME hors whitelist / taille), le message back s'affiche sous le champ + * (pas de toast) ; l'IRI est remis à null pour bloquer la validation. + */ + async function uploadDischarge(file: File): Promise { + mainErrors.clearError('dischargeDocument') + try { + main.dischargeDocumentIri = await uploadFile(file) + } catch (error) { + main.dischargeDocumentIri = null + const message = extractApiErrorMessage((error as { data?: unknown })?.data) + || t('transport.carriers.form.errors.uploadFailed') + mainErrors.setError('dischargeDocument', message) + } + } + /** * Payload du POST principal (groupe `carrier:write:main`). `name` et * `certificationType` sont omis s'ils sont vides afin que la 422 porte la @@ -732,6 +754,7 @@ export function useCarrierForm() { mainSubmitting, tabSubmitting, mainErrors, + dischargeUploading, // affichage conditionnel isLiot, isQualimat, @@ -768,6 +791,7 @@ export function useCarrierForm() { removePrice, submitPrices, // actions + uploadDischarge, validateMainFront, buildMainPayload, submitMain, diff --git a/frontend/modules/transport/pages/carriers/[id]/edit.vue b/frontend/modules/transport/pages/carriers/[id]/edit.vue index 11b1e97..29a47ea 100644 --- a/frontend/modules/transport/pages/carriers/[id]/edit.vue +++ b/frontend/modules/transport/pages/carriers/[id]/edit.vue @@ -45,12 +45,16 @@ />
@@ -231,6 +235,8 @@ const { mainSubmitting, tabSubmitting, mainErrors, + dischargeUploading, + uploadDischarge, isLiot, certificationReadonly, showCharteredFields, @@ -307,6 +313,12 @@ onMounted(async () => { await load() if (carrier.value) { prefillFrom(carrier.value) + // Pré-affiche le nom du fichier de décharge déjà rattaché (s'il existe). + const doc = carrier.value.dischargeDocument + if (doc && typeof doc !== 'string') { + const meta = doc as Record + dischargeFileName.value = String(meta.originalFilename ?? meta.name ?? '') + } } loadCountries().catch(() => {}) void loadOptions('/clients', clientOptions, m => String(m.companyName ?? m['@id'])) @@ -319,6 +331,16 @@ function apiErrorMessage(err: unknown): string { return extractApiErrorMessage(data) || t('transport.carriers.toast.error') } +// Nom de fichier affiché dans le champ Décharge (alimenté à la sélection ou au +// chargement d'un transporteur ayant déjà une décharge). +const dischargeFileName = ref('') + +/** Vidage du champ Décharge : retire l'IRI et le nom affiché. */ +function onClearDischarge(): void { + main.dischargeDocumentIri = null + dischargeFileName.value = '' +} + // Indexation plafonnée à 100 % : la clé force le ré-affichage du MalioInputAmount // (contrôlé) quand le plafonnement laisse le modelValue inchangé. const indexationKey = ref(0) diff --git a/frontend/modules/transport/pages/carriers/new.vue b/frontend/modules/transport/pages/carriers/new.vue index 1958abe..8f898ae 100644 --- a/frontend/modules/transport/pages/carriers/new.vue +++ b/frontend/modules/transport/pages/carriers/new.vue @@ -53,18 +53,20 @@ - + L'upload réel (File → IRI via useUpload, ERP-171) résout le + fichier en IRI posé sur main.dischargeDocumentIri. --> @@ -398,6 +400,8 @@ const { mainSubmitting, tabSubmitting, mainErrors, + dischargeUploading, + uploadDischarge, isLiot, isQualimat, certificationReadonly, @@ -429,6 +433,15 @@ const { applyQualimatSelection, } = useCarrierForm() +// Nom de fichier affiché dans le champ Décharge (alimenté à la sélection). +const dischargeFileName = ref('') + +/** Vidage du champ Décharge : retire l'IRI et le nom affiché. */ +function onClearDischarge(): void { + main.dischargeDocumentIri = null + dischargeFileName.value = '' +} + const { items: qualimatItems, totalItems: qualimatTotal, diff --git a/frontend/shared/composables/__tests__/useUpload.test.ts b/frontend/shared/composables/__tests__/useUpload.test.ts new file mode 100644 index 0000000..bde5178 --- /dev/null +++ b/frontend/shared/composables/__tests__/useUpload.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * Tests du composable d'upload générique (ERP-171) : + * - succès : POST multipart /uploaded_documents (champ « file »), toast désactivé, + * renvoie l'IRI (@id) du document créé, `uploading` retombe à false ; + * - erreur MIME hors whitelist → 422 : l'erreur est RELAYÉE à l'appelant (pour un + * affichage inline sous le champ), `uploading` ré-armé via le finally. + */ + +const mockPost = vi.hoisted(() => vi.fn()) + +vi.stubGlobal('useApi', () => ({ + get: vi.fn(), + post: mockPost, + put: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), +})) + +const { useUpload } = await import('../useUpload') + +describe('useUpload', () => { + beforeEach(() => { + mockPost.mockReset() + }) + + it('succès : POST multipart champ « file » + toast:false → renvoie l\'IRI', async () => { + mockPost.mockResolvedValue({ '@id': '/api/uploaded_documents/9', originalFilename: 'decharge.pdf' }) + const { upload, uploading } = useUpload() + const file = new File(['contenu'], 'decharge.pdf', { type: 'application/pdf' }) + + const iri = await upload(file) + + expect(iri).toBe('/api/uploaded_documents/9') + + const [url, body, options] = mockPost.mock.calls[0] + expect(url).toBe('/uploaded_documents') + expect(body).toBeInstanceOf(FormData) + const stored = (body as FormData).get('file') + expect(stored).toBeInstanceOf(File) + expect((stored as File).name).toBe('decharge.pdf') + expect(options).toMatchObject({ toast: false }) + + expect(uploading.value).toBe(false) + }) + + it('erreur MIME → 422 : l\'erreur est remontée à l\'appelant', async () => { + const error = Object.assign(new Error('422'), { + data: { 'hydra:description': 'Type de fichier non autorisé.' }, + }) + mockPost.mockRejectedValue(error) + const { upload, uploading } = useUpload() + const file = new File(['x'], 'malware.exe', { type: 'application/x-msdownload' }) + + await expect(upload(file)).rejects.toBe(error) + expect(uploading.value).toBe(false) + }) +}) diff --git a/frontend/shared/composables/useUpload.ts b/frontend/shared/composables/useUpload.ts new file mode 100644 index 0000000..1ed6f03 --- /dev/null +++ b/frontend/shared/composables/useUpload.ts @@ -0,0 +1,53 @@ +import { ref } from 'vue' +import type { AnyObject } from '~/shared/composables/useApi' + +/** + * Réponse JSON-LD de POST /api/uploaded_documents (groupe `uploaded_document:read`). + * Seul l'IRI (`@id`) est exploité pour le poser sur la relation cible. + */ +export interface UploadedDocumentResponse { + '@id': string + originalFilename?: string + mimeType?: string +} + +/** + * Upload d'un document générique vers l'infra partagée (ERP-154) : + * POST /api/uploaded_documents en multipart/form-data, champ « file », via + * `useApi()` (cookie JWT, parsing Hydra/erreurs). Renvoie l'IRI du document créé, + * à poser sur la relation cible (ex: `carrier.dischargeDocument` — RG-4.02). + * + * Les erreurs (MIME hors whitelist / fichier trop volumineux → 422) sont relayées + * (rethrow) à l'appelant pour un affichage inline sous le champ. `toast: false` par + * défaut : pas de toast fourre-tout, le formulaire mappe le message au bon champ. + */ +export function useUpload() { + // Indicateur d'upload en cours (désactivation UI / spinner éventuel). + const uploading = ref(false) + + /** + * Envoie `file` et renvoie l'IRI du `UploadedDocument` créé. + * @throws relaie l'erreur réseau / 422 (MIME, taille) à l'appelant. + */ + async function upload(file: File, options: { toast?: boolean } = {}): Promise { + const formData = new FormData() + formData.append('file', file) + + uploading.value = true + try { + // useApi() détecte le FormData et n'impose pas de Content-Type JSON : + // le navigateur pose lui-même la frontière multipart. + const doc = await useApi().post( + '/uploaded_documents', + formData as unknown as AnyObject, + { toast: options.toast ?? false }, + ) + + return doc['@id'] + } finally { + uploading.value = false + } + } + + return { uploading, upload } +}