feat(transport) : saisie assistée QUALIMAT + champs conditionnels (ERP-166)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m8s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m36s

This commit is contained in:
2026-06-16 17:22:25 +02:00
parent f1b18cfbbe
commit f70e701854
7 changed files with 738 additions and 48 deletions
+29 -2
View File
@@ -526,7 +526,12 @@
"toast": {
"error": "Une erreur est survenue. Réessayez.",
"exportError": "L'export du répertoire transporteurs a échoué. Réessayez.",
"createSuccess": "Transporteur créé avec succès"
"createSuccess": "Transporteur créé avec succès",
"integrateSuccess": "Transporteur QUALIMAT intégré"
},
"containerType": {
"BENNE": "Benne",
"FOND_MOUVANT": "Fond mouvant"
},
"tab": {
"qualimat": "Qualimat",
@@ -543,7 +548,29 @@
"main": {
"name": "Nom",
"certificationType": "Certification transport",
"isChartered": "Affréter"
"isChartered": "Affréter",
"indexationRate": "Indexation %",
"containerType": "Benne / Fond mouvant",
"volumeM3": "Volume m³",
"discharge": "Décharge",
"liotPlates": "Immatriculations LIOT",
"liotPlatesHint": "Séparées par « ; »"
},
"qualimat": {
"search": "Rechercher un transporteur QUALIMAT",
"empty": "Aucun transporteur QUALIMAT trouvé.",
"continue": "Continuer",
"columns": {
"name": "Nom",
"address": "Adresse",
"validityDate": "Date de validité"
},
"confirm": {
"title": "Intégration QUALIMAT",
"message": "Êtes-vous sûr de vouloir intégrer ce transporteur ?",
"cancel": "Annuler",
"confirm": "Intégrer"
}
},
"errors": {
"nameRequired": "Le nom du transporteur est obligatoire."
@@ -189,3 +189,153 @@ describe('useCarrierForm', () => {
expect(mockPatch).toHaveBeenCalledWith('/carriers/9', { liotPlates: 'AA-123-BB' }, { toast: false })
})
})
describe('useCarrierForm — champs conditionnels (ERP-166)', () => {
beforeEach(() => {
mockPost.mockReset()
mockPatch.mockReset()
})
it('cas LIOT (insensible à la casse) : masque la certification, payload réduit', () => {
const form = useCarrierForm()
form.main.name = 'liot'
expect(form.isLiot.value).toBe(true)
expect(form.showCertification.value).toBe(false)
form.main.liotPlates = 'AA-123-BB ; CC-456-DD'
expect(form.buildMainPayload()).toEqual({
name: 'liot',
isChartered: false,
liotPlates: 'AA-123-BB ; CC-456-DD',
})
})
it('LIOT masque les champs conditionnels (affrètement / décharge)', () => {
const form = useCarrierForm()
form.main.name = 'LIOT'
form.main.isChartered = true
form.main.certificationType = 'AUTRE'
expect(form.showCharteredFields.value).toBe(false)
expect(form.showDischarge.value).toBe(false)
})
it('RG-4.03 affrété : indexation / contenant / volume visibles et dans le payload', () => {
const form = useCarrierForm()
form.main.name = 'Acme'
form.main.certificationType = 'GMP_PLUS'
form.main.isChartered = true
expect(form.showCharteredFields.value).toBe(true)
form.main.indexationRate = '5'
form.main.containerType = 'BENNE'
form.main.volumeM3 = '30'
expect(form.buildMainPayload()).toEqual({
name: 'Acme',
certificationType: 'GMP_PLUS',
isChartered: true,
indexationRate: '5',
containerType: 'BENNE',
volumeM3: '30',
})
})
it('RG-4.03 affrété mais champs vides : omis du payload (422 NotBlank back)', () => {
const form = useCarrierForm()
form.main.name = 'Acme'
form.main.certificationType = 'GMP_PLUS'
form.main.isChartered = true
expect(form.buildMainPayload()).toEqual({
name: 'Acme',
certificationType: 'GMP_PLUS',
isChartered: true,
})
})
it('RG-4.02 AUTRE : décharge visible + dischargeDocument dans le payload si IRI résolu', () => {
const form = useCarrierForm()
form.main.name = 'Acme'
form.main.certificationType = 'AUTRE'
expect(form.showDischarge.value).toBe(true)
form.main.dischargeDocumentIri = '/api/uploaded_documents/7'
expect(form.buildMainPayload()).toMatchObject({ dischargeDocument: '/api/uploaded_documents/7' })
})
})
describe('useCarrierForm — copie QUALIMAT (ERP-166)', () => {
const QUALIMAT_ROW = {
'@id': '/api/qualimat_carriers/42',
id: '42',
name: 'TRANSPORTS QUALIMAT',
siret: '12345678900012',
address: '1 rue du Port',
postalCode: '86000',
city: 'Poitiers',
validityDate: '2027-01-15',
status: 'VALIDE',
}
beforeEach(() => {
mockPost.mockReset()
mockPatch.mockReset()
})
it('copie name + certificationType=QUALIMAT (readonly) + IRI + adresse, sans PATCH avant création', async () => {
const form = useCarrierForm()
const ok = await form.applyQualimatSelection(QUALIMAT_ROW)
expect(ok).toBe(true)
expect(mockPatch).not.toHaveBeenCalled()
expect(form.main.name).toBe('TRANSPORTS QUALIMAT')
expect(form.main.certificationType).toBe('QUALIMAT')
expect(form.main.qualimatCarrierIri).toBe('/api/qualimat_carriers/42')
expect(form.isQualimat.value).toBe(true)
expect(form.certificationReadonly.value).toBe(true)
expect(form.qualimatAddress.value).toEqual({
country: 'France',
postalCode: '86000',
city: 'Poitiers',
street: '1 rue du Port',
})
})
it('après création : PATCH /carriers/{id} avec qualimatCarrier + name + certification', async () => {
mockPost.mockResolvedValueOnce({ id: 9, name: 'X', certificationType: 'GMP_PLUS' })
mockPatch.mockResolvedValueOnce({})
const form = useCarrierForm()
form.main.name = 'X'
form.main.certificationType = 'GMP_PLUS'
await form.submitMain()
const ok = await form.applyQualimatSelection(QUALIMAT_ROW)
expect(ok).toBe(true)
expect(mockPatch).toHaveBeenCalledWith(
'/carriers/9',
{
qualimatCarrier: '/api/qualimat_carriers/42',
name: 'TRANSPORTS QUALIMAT',
certificationType: 'QUALIMAT',
},
{ toast: false },
)
})
it('buildMainPayload inclut qualimatCarrier + certificationType QUALIMAT après intégration', async () => {
const form = useCarrierForm()
form.main.name = 'Acme'
await form.applyQualimatSelection(QUALIMAT_ROW)
expect(form.buildMainPayload()).toMatchObject({
qualimatCarrier: '/api/qualimat_carriers/42',
certificationType: 'QUALIMAT',
})
})
})
@@ -0,0 +1,55 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
/**
* Tests de la saisie assistée QUALIMAT (M4 Transport, ERP-166 — RG-4.01).
*
* `useQualimatSearch` interroge `GET /api/qualimat_carriers?search=`. On vérifie le
* CONTRAT (pas le timing du debounce, couvert par `debounce.test.ts`) via `fetchNow` :
* - ressource ciblée + paramètre `search` (trimé) + header `Accept: application/ld+json` ;
* - consommation de l'enveloppe Hydra (`member`) ;
* - échec réseau → résultats vidés, pas de throw (recherche non bloquante).
*/
const mockGet = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({
get: mockGet,
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
}))
const { useQualimatSearch } = await import('../useQualimatSearch')
describe('useQualimatSearch', () => {
beforeEach(() => {
mockGet.mockReset()
})
it('fetchNow cible /qualimat_carriers (search trimé, ld+json) et consomme member', async () => {
mockGet.mockResolvedValueOnce({
member: [{ '@id': '/api/qualimat_carriers/1', id: '1', name: 'ACME', validityDate: '2027-01-01' }],
})
const q = useQualimatSearch()
await q.fetchNow(' acme ')
expect(mockGet).toHaveBeenCalledWith(
'/qualimat_carriers',
{ search: 'acme' },
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
expect(q.results.value).toHaveLength(1)
expect(q.results.value[0]?.name).toBe('ACME')
expect(q.loading.value).toBe(false)
})
it('échec réseau : résultats vidés, pas de throw', async () => {
mockGet.mockRejectedValueOnce(new Error('network'))
const q = useQualimatSearch()
await expect(q.fetchNow('x')).resolves.toBeUndefined()
expect(q.results.value).toEqual([])
expect(q.loading.value).toBe(false)
})
})
@@ -1,10 +1,16 @@
import { reactive, ref } from 'vue'
import { computed, reactive, ref } from 'vue'
import { useFormErrors } from '~/shared/composables/useFormErrors'
import {
emptyCarrierAddressCopy,
emptyCarrierMain,
type CarrierAddressCopy,
type CarrierMainDraft,
type CarrierMainResponse,
} from '~/modules/transport/types/carrierForm'
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) —
@@ -50,6 +56,24 @@ export function useCarrierForm() {
// ── 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é (-1 tant que le transporteur n'est pas créé).
@@ -92,15 +116,45 @@ export function useCarrierForm() {
* certification) sur le champ plutôt qu'une erreur de type.
*/
function buildMainPayload(): Record<string, unknown> {
const payload: Record<string, unknown> = {
isChartered: main.isChartered,
// 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
}
if (main.name?.trim()) {
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
}
@@ -162,6 +216,43 @@ export function useCarrierForm() {
await api.patch(`/carriers/${carrierId.value}`, payload, { toast: 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
* la copie via un PATCH partiel `carrier:write:main`. La copie locale a lieu
* dans tous les cas. Retourne true si l'intégration a abouti.
*/
async function applyQualimatSelection(row: QualimatCarrierRow): Promise<boolean> {
main.name = row.name ?? ''
main.certificationType = 'QUALIMAT'
main.qualimatCarrierIri = row['@id']
qualimatAddress.value = {
country: 'France',
postalCode: row.postalCode ?? '',
city: row.city ?? '',
street: row.address ?? '',
}
if (carrierId.value === null) {
return true
}
try {
await patchCarrier({
qualimatCarrier: row['@id'],
name: row.name,
certificationType: 'QUALIMAT',
})
return true
}
catch (error) {
mainErrors.handleApiError(error, { fallbackMessage: t('transport.carriers.toast.error') })
return 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
@@ -186,10 +277,18 @@ export function useCarrierForm() {
return {
// état
main,
qualimatAddress,
carrierId,
mainLocked,
mainSubmitting,
mainErrors,
// affichage conditionnel
isLiot,
isQualimat,
showCertification,
certificationReadonly,
showCharteredFields,
showDischarge,
// onglets
tabKeys,
activeTab,
@@ -202,6 +301,7 @@ export function useCarrierForm() {
buildMainPayload,
submitMain,
patchCarrier,
applyQualimatSelection,
completeTab,
}
}
@@ -0,0 +1,76 @@
import { ref } from 'vue'
import { debounce } from '~/shared/utils/debounce'
import type { HydraCollection } from '~/shared/utils/api'
/**
* Ligne du référentiel QUALIMAT renvoyée par la saisie assistée (groupe
* `qualimat:read`). `@id` est l'IRI conservée comme FK `carrier.qualimatCarrier`
* (RG-4.01 / § 2.5) ; `validityDate` pilote le fond rouge de la colonne « Date de
* validité » (RG-4.04).
*/
export interface QualimatCarrierRow {
'@id': string
id: string
name: string | null
siret: string | null
address: string | null
postalCode: string | null
city: string | null
validityDate: string | null
status: string | null
}
/** Délai de debounce de la recherche (ms) — une requête après la dernière frappe. */
const SEARCH_DEBOUNCE_MS = 300
/**
* Saisie assistée QUALIMAT (M4 Transport, ERP-166 — RG-4.01 / spec-back § 4.7).
*
* `GET /api/qualimat_carriers?search=` : référentiel en LECTURE SEULE, lignes
* actives uniquement (filtré côté serveur), recherche fuzzy nom + siret. Alimente
* le tableau de sélection de l'onglet Qualimat ; la ligne choisie est copiée dans
* le formulaire principal (cf. `useCarrierForm.applyQualimatSelection`).
*
* Volontairement PAR INSTANCE (état local à l'écran d'ajout). `search()` est
* debouncé (anti-spam réseau) ; `fetchNow()` expose l'appel immédiat (montage /
* tests).
*/
export function useQualimatSearch() {
const api = useApi()
const results = ref<QualimatCarrierRow[]>([])
const loading = ref(false)
/** Lance immédiatement la recherche (sans debounce). */
async function fetchNow(term: string): Promise<void> {
loading.value = true
try {
const data = await api.get<HydraCollection<QualimatCarrierRow>>(
'/qualimat_carriers',
{ search: term.trim() },
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
results.value = data.member ?? []
}
catch {
// Échec réseau / 403 : on vide les résultats, pas de toast (la recherche
// assistée est non bloquante — l'utilisateur peut saisir manuellement).
results.value = []
}
finally {
loading.value = false
}
}
// Recherche debouncée branchée sur le champ de recherche de l'onglet Qualimat.
const search = debounce((term: string) => {
void fetchNow(term)
}, SEARCH_DEBOUNCE_MS)
return {
results,
loading,
search,
fetchNow,
}
}
+278 -33
View File
@@ -13,11 +13,10 @@
</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 Qualimat. Les champs conditionnels
(indexation / benne / volume si affrete, decharge si AUTRE, cas LIOT)
et la saisie assistee QUALIMAT arrivent a ERP-166. -->
Champs conditionnels (ERP-166) : cas LIOT (nom == LIOT) seul
« immatriculations » ; certification AUTRE champ Decharge ; Affreter
coche indexation / contenant / volume. La certification est en lecture
seule pour un transporteur QUALIMAT (saisie assistee, onglet Qualimat). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.name"
@@ -26,29 +25,104 @@
:readonly="mainLocked"
:error="mainErrors.errors.name"
/>
<MalioSelect
:model-value="main.certificationType"
:options="certificationOptions"
:label="t('transport.carriers.form.main.certificationType')"
empty-option-label=""
<!-- Cas LIOT : seul le champ immatriculations est pertinent. -->
<MalioInputText
v-if="isLiot"
v-model="main.liotPlates"
:label="t('transport.carriers.form.main.liotPlates')"
:hint="t('transport.carriers.form.main.liotPlatesHint')"
:required="true"
:readonly="mainLocked"
:error="mainErrors.errors.certificationType"
@update:model-value="(v: string | number | null) => main.certificationType = v === null ? null : String(v)"
:error="mainErrors.errors.liotPlates"
/>
<!-- Wrapper h-12 + centrage vertical : aligne la case a cocher sur la
ligne de champ des inputs/selects (qui posent un h-12 items-center
en interne). reserve-message-space=false pour un centrage exact. -->
<div class="flex h-12 items-center">
<MalioCheckbox
id="carrier-is-chartered"
:label="t('transport.carriers.form.main.isChartered')"
:model-value="main.isChartered"
:readonly="mainLocked"
:reserve-message-space="false"
@update:model-value="(val: boolean) => main.isChartered = val"
<!-- Cas standard : certification + affretement + champs conditionnels. -->
<template v-if="!isLiot">
<MalioSelect
:model-value="main.certificationType"
:options="certificationOptions"
:label="t('transport.carriers.form.main.certificationType')"
empty-option-label=""
:required="true"
:readonly="certificationReadonly"
:error="mainErrors.errors.certificationType"
@update:model-value="(v: string | number | null) => main.certificationType = v === null ? null : String(v)"
/>
</div>
<!-- Wrapper h-12 + centrage vertical : aligne la case sur la ligne
de champ des inputs/selects (qui posent un h-12 en interne). -->
<div class="flex h-12 items-center">
<MalioCheckbox
id="carrier-is-chartered"
:label="t('transport.carriers.form.main.isChartered')"
:model-value="main.isChartered"
:readonly="mainLocked"
:reserve-message-space="false"
@update:model-value="(val: boolean) => main.isChartered = val"
/>
</div>
<!-- RG-4.02 : Decharge visible et obligatoire si certification AUTRE.
L'upload reel (File → IRI via useUpload) arrive a ERP-171. -->
<!-- TODO ERP-171 : brancher useUpload pour resoudre le File en IRI
(main.dischargeDocumentIri). Le champ est deja visible/obligatoire. -->
<MalioInputUpload
v-if="showDischarge"
:label="t('transport.carriers.form.main.discharge')"
accept="application/pdf,image/*"
:required="true"
:readonly="mainLocked"
:error="mainErrors.errors.dischargeDocument"
/>
<!-- RG-4.03 : champs d'affretement visibles + obligatoires si « Affreter ». -->
<template v-if="showCharteredFields">
<MalioInputNumber
v-model="main.indexationRate"
:label="t('transport.carriers.form.main.indexationRate')"
:required="true"
:readonly="mainLocked"
:error="mainErrors.errors.indexationRate"
/>
<!-- Contenant : radio Benne / Fond mouvant (RG-4.03). -->
<div class="flex flex-col justify-center">
<span class="mb-1 text-sm font-medium text-m-muted">
{{ t('transport.carriers.form.main.containerType') }}<span class="text-m-danger"> *</span>
</span>
<div class="flex gap-4">
<MalioRadioButton
v-model="main.containerType"
name="container-type"
value="BENNE"
:label="t('transport.carriers.containerType.BENNE')"
:disabled="mainLocked"
group-class="mt-0"
/>
<MalioRadioButton
v-model="main.containerType"
name="container-type"
value="FOND_MOUVANT"
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
:disabled="mainLocked"
group-class="mt-0"
/>
</div>
<p v-if="mainErrors.errors.containerType" class="mt-1 ml-[2px] text-xs text-m-danger">
{{ mainErrors.errors.containerType }}
</p>
</div>
<MalioInputNumber
v-model="main.volumeM3"
:label="t('transport.carriers.form.main.volumeM3')"
:required="true"
:readonly="mainLocked"
:error="mainErrors.errors.volumeM3"
/>
</template>
</template>
</div>
<div v-if="!mainLocked" class="mt-12 flex justify-center">
@@ -61,13 +135,76 @@
</div>
<!-- ── Onglets a validation incrementale ─────────────────────────────
Barre Qualimat · Adresses · Contacts · Prix. Onglets verrouilles tant
que le formulaire principal n'est pas valide (unlockedIndex = -1) puis
deverrouilles progressivement. Le contenu de chaque onglet arrive aux
tickets suivants (ERP-166+) : placeholders « A venir » pour l'instant. -->
Barre Qualimat · Adresses · Contacts · Prix. Onglet Qualimat = saisie
assistee (table de selection) ; Adresses / Contacts / Prix arrivent aux
tickets suivants (placeholders « A venir »). -->
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<!-- Onglet Qualimat : recherche + table de selection (RG-4.01 / 4.04). -->
<template #qualimat>
<div class="mt-12 flex flex-col gap-6">
<MalioInputText
v-model="qualimatTerm"
icon-name="mdi:magnify"
:label="t('transport.carriers.form.qualimat.search')"
/>
<table class="w-full border-collapse text-left">
<thead>
<tr class="border-b border-black">
<th class="w-12 py-2"></th>
<th class="py-2 font-semibold">{{ t('transport.carriers.form.qualimat.columns.name') }}</th>
<th class="py-2 font-semibold">{{ t('transport.carriers.form.qualimat.columns.address') }}</th>
<th class="py-2 font-semibold">{{ t('transport.carriers.form.qualimat.columns.validityDate') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in qualimatResults"
:key="row.id"
class="cursor-pointer border-b border-m-muted/30 hover:bg-m-muted/10"
@click="askIntegrate(row)"
>
<td class="py-2">
<MalioRadioButton
:model-value="main.qualimatCarrierIri"
name="qualimat-row"
:value="row['@id']"
group-class="mt-0"
/>
</td>
<td class="py-2">{{ row.name }}</td>
<td class="py-2">{{ formatQualimatAddress(row) }}</td>
<td class="py-2">
<span
v-if="row.validityDate"
:class="isExpired(row.validityDate) ? 'inline-block rounded px-2 py-0.5 bg-m-danger text-white' : ''"
>
{{ formatDateFr(row.validityDate) }}
</span>
</td>
</tr>
<tr v-if="qualimatResults.length === 0">
<td colspan="4" class="py-4 text-center text-m-muted">
{{ t('transport.carriers.form.qualimat.empty') }}
</td>
</tr>
</tbody>
</table>
<div v-if="!isValidated('qualimat')" class="flex justify-center">
<MalioButton
variant="primary"
:label="t('transport.carriers.form.qualimat.continue')"
:disabled="carrierId === null"
@click="onContinueQualimat"
/>
</div>
</div>
</template>
<!-- Adresses / Contacts / Prix : contenu aux tickets suivants. -->
<template
v-for="key in tabKeys"
v-for="key in placeholderTabs"
:key="key"
#[key]
>
@@ -76,12 +213,35 @@
</div>
</template>
</MalioTabList>
<!-- Modal de confirmation d'integration QUALIMAT (RG-4.01). -->
<MalioModal v-model="confirmOpen" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('transport.carriers.form.qualimat.confirm.title') }}</h2>
</template>
<p>{{ t('transport.carriers.form.qualimat.confirm.message') }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('transport.carriers.form.qualimat.confirm.cancel')"
@click="confirmOpen = false"
/>
<MalioButton
variant="primary"
button-class="flex-1"
:label="t('transport.carriers.form.qualimat.confirm.confirm')"
@click="confirmIntegrate"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
import { useQualimatSearch, type QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
interface SelectOption {
value: string
@@ -90,6 +250,7 @@ interface SelectOption {
const { t } = useI18n()
const router = useRouter()
const toast = useToast()
const { can } = usePermissions()
useHead({ title: t('transport.carriers.form.title') })
@@ -102,18 +263,27 @@ if (!can('transport.carriers.manage')) {
const {
main,
carrierId,
mainLocked,
mainSubmitting,
mainErrors,
isLiot,
certificationReadonly,
showCharteredFields,
showDischarge,
tabKeys,
activeTab,
unlockedIndex,
isValidated,
submitMain,
applyQualimatSelection,
completeTab,
} = useCarrierForm()
const { results: qualimatResults, fetchNow: qualimatFetch, search: qualimatSearchDebounced } = useQualimatSearch()
// Certifications selectionnables manuellement (spec § Formulaire principal).
// QUALIMAT n'est PAS dans cette liste : il est pose par la saisie assistee QUALIMAT
// (ERP-166), pas choisi a la main.
// QUALIMAT n'y figure PAS : il est pose par la saisie assistee (onglet Qualimat).
const SELECTABLE_CERTIFICATIONS = ['GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE'] as const
const certificationOptions = computed<SelectOption[]>(() =>
@@ -140,12 +310,87 @@ const tabs = computed(() => tabKeys.value.map((key, index) => ({
disabled: index > unlockedIndex.value,
})))
// Onglets dont le contenu arrive aux tickets suivants (tout sauf Qualimat).
const placeholderTabs = computed(() => tabKeys.value.filter(key => key !== 'qualimat'))
// ── Saisie assistee QUALIMAT (onglet Qualimat) ───────────────────────────────
const qualimatTerm = ref('')
const qualimatLoaded = ref(false)
const confirmOpen = ref(false)
const pendingRow = ref<QualimatCarrierRow | null>(null)
// Recherche debouncee a chaque frappe.
watch(qualimatTerm, term => qualimatSearchDebounced(term))
// Premier chargement (liste active complete) quand l'onglet Qualimat devient actif.
watch(activeTab, (tab) => {
if (tab === 'qualimat' && !qualimatLoaded.value) {
qualimatLoaded.value = true
qualimatFetch('').catch(() => {})
}
})
/** Adresse QUALIMAT condensee pour la colonne « Adresse » (voie · CP · ville). */
function formatQualimatAddress(row: QualimatCarrierRow): string {
return [row.address, row.postalCode, row.city].filter(Boolean).join(' · ')
}
/** RG-4.04 : un agrement est perime si sa date de validite est < aujourd'hui. */
function isExpired(value: string): boolean {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return false
}
const today = new Date()
today.setHours(0, 0, 0, 0)
date.setHours(0, 0, 0, 0)
return date.getTime() < today.getTime()
}
/** Format court francais JJ-MM-AAAA (chaine vide si date absente / invalide). */
function formatDateFr(value: string | null | undefined): string {
if (!value) {
return ''
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
return `${day}-${month}-${date.getFullYear()}`
}
/** Clic sur une ligne QUALIMAT → modal de confirmation d'integration. */
function askIntegrate(row: QualimatCarrierRow): void {
pendingRow.value = row
confirmOpen.value = true
}
/** Confirme l'integration : copie + PATCH (cf. useCarrierForm.applyQualimatSelection). */
async function confirmIntegrate(): Promise<void> {
const row = pendingRow.value
confirmOpen.value = false
if (row === null) {
return
}
const ok = await applyQualimatSelection(row)
if (ok) {
toast.success({ title: t('transport.carriers.toast.integrateSuccess') })
}
}
/** « Continuer » : valide l'onglet Qualimat et avance a l'onglet Adresses. */
function onContinueQualimat(): void {
completeTab('qualimat')
}
/** Retour vers le repertoire transporteurs (fleche d'en-tete). */
function goBack(): void {
router.push('/carriers')
}
/** Valide le formulaire principal (POST /carriers ; bascule gerée par le composable). */
/** Valide le formulaire principal (POST /carriers ; bascule geree par le composable). */
async function onSubmitMain(): Promise<void> {
await submitMain()
}
@@ -1,22 +1,36 @@
/**
* Types du workflow « Ajouter un transporteur » (M4 Transport, ERP-165).
* Types du workflow « Ajouter un transporteur » (M4 Transport, ERP-165 / ERP-166).
*
* 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.
* Périmètre :
* - ERP-165 : formulaire PRINCIPAL minimal (Nom + Certification + Affréter).
* - ERP-166 : champs CONDITIONNELS du formulaire principal (indexation / benne /
* volume si affrété — RG-4.03 ; décharge si AUTRE — RG-4.02 ; immatriculations
* LIOT — RG-4.01) + saisie assistée QUALIMAT (copie name / certification /
* adresse + FK qualimatCarrier — RG-4.01 / § 2.5).
*
* L'upload réel de la décharge (file → IRI via useUpload) arrive à ERP-171 ; ici
* on porte seulement l'IRI résolu (`dischargeDocumentIri`).
*/
/**
* 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.
* Brouillon du formulaire principal. Les décimales (indexation / volume) sont
* portées en `string` car `MalioInputNumber` émet une chaîne ; le serveur parse.
* `certificationType` est un code enum back (GMP_PLUS | OVOCOM | COMPTE_PROPRE |
* AUTRE | QUALIMAT — ce dernier posé par la saisie assistée) ou `null`.
* `containerType` vaut `BENNE` | `FOND_MOUVANT` (radio) ou `null`.
*/
export interface CarrierMainDraft {
name: string
certificationType: string | null
isChartered: boolean
indexationRate: string
containerType: string | null
volumeM3: string
liotPlates: string
/** IRI du document de décharge (résolu par useUpload — ERP-171). */
dischargeDocumentIri: string | null
/** IRI de la ligne QUALIMAT liée (saisie assistée — null si non QUALIMAT). */
qualimatCarrierIri: string | null
}
/** Brouillon principal vide (état initial du formulaire de création). */
@@ -25,9 +39,32 @@ export function emptyCarrierMain(): CarrierMainDraft {
name: '',
certificationType: null,
isChartered: false,
indexationRate: '',
containerType: null,
volumeM3: '',
liotPlates: '',
dischargeDocumentIri: null,
qualimatCarrierIri: null,
}
}
/**
* Adresse copiée depuis le référentiel QUALIMAT à la sélection (RG-4.01 / § 2.5).
* Stockée dans l'état du formulaire pour alimenter l'onglet Adresses (ticket
* ultérieur) ; pré-remplie « France » côté pays par défaut.
*/
export interface CarrierAddressCopy {
country: string
postalCode: string
city: string
street: string
}
/** Adresse copiée vide. */
export function emptyCarrierAddressCopy(): CarrierAddressCopy {
return { country: 'France', postalCode: '', city: '', street: '' }
}
/**
* 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.