refactor(transport) : onglet Qualimat en MalioDataTable paginé, recherche branchée sur le nom (ERP-166)
This commit is contained in:
@@ -557,7 +557,6 @@
|
|||||||
"liotPlatesHint": "Séparées par « ; »"
|
"liotPlatesHint": "Séparées par « ; »"
|
||||||
},
|
},
|
||||||
"qualimat": {
|
"qualimat": {
|
||||||
"search": "Rechercher un transporteur QUALIMAT",
|
|
||||||
"empty": "Aucun transporteur QUALIMAT trouvé.",
|
"empty": "Aucun transporteur QUALIMAT trouvé.",
|
||||||
"continue": "Continuer",
|
"continue": "Continuer",
|
||||||
"columns": {
|
"columns": {
|
||||||
|
|||||||
@@ -1,55 +1,62 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { useQualimatSearch, type QualimatCarrierRow } from '../useQualimatSearch'
|
||||||
|
|
||||||
|
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||||
|
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests de la saisie assistée QUALIMAT (M4 Transport, ERP-166 — RG-4.01).
|
* 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
|
* `useQualimatSearch` est une fine enveloppe de `usePaginatedList<QualimatCarrierRow>`
|
||||||
* CONTRAT (pas le timing du debounce, couvert par `debounce.test.ts`) via `fetchNow` :
|
* sur `/qualimat_carriers`. La pagination générique est couverte par
|
||||||
* - ressource ciblée + paramètre `search` (trimé) + header `Accept: application/ld+json` ;
|
* `usePaginatedList.test.ts` ; on vérifie ici le CONTRAT propre à la recherche :
|
||||||
* - consommation de l'enveloppe Hydra (`member`) ;
|
* - ressource ciblée `/qualimat_carriers` + enveloppe Hydra + `Accept: application/ld+json` ;
|
||||||
* - échec réseau → résultats vidés, pas de throw (recherche non bloquante).
|
* - le filtre `search` (branché sur le nom du transporteur) est transmis et
|
||||||
|
* retombe en page 1.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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', () => {
|
describe('useQualimatSearch', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockGet.mockReset()
|
mockApiGet.mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('fetchNow cible /qualimat_carriers (search trimé, ld+json) et consomme member', async () => {
|
const PAGE: QualimatCarrierRow[] = [
|
||||||
mockGet.mockResolvedValueOnce({
|
{
|
||||||
member: [{ '@id': '/api/qualimat_carriers/1', id: '1', name: 'ACME', validityDate: '2027-01-01' }],
|
'@id': '/api/qualimat_carriers/1',
|
||||||
})
|
id: '1',
|
||||||
const q = useQualimatSearch()
|
name: 'TRANSPORTS ACME',
|
||||||
|
siret: '12345678900012',
|
||||||
|
address: '1 rue du Port',
|
||||||
|
postalCode: '86000',
|
||||||
|
city: 'Poitiers',
|
||||||
|
validityDate: '2027-01-15',
|
||||||
|
status: 'VALIDE',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
await q.fetchNow(' acme ')
|
it('cible /qualimat_carriers, consomme l\'enveloppe Hydra et envoie l\'Accept ld+json', async () => {
|
||||||
|
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||||
|
const repo = useQualimatSearch()
|
||||||
|
|
||||||
expect(mockGet).toHaveBeenCalledWith(
|
await repo.fetch()
|
||||||
'/qualimat_carriers',
|
|
||||||
{ search: 'acme' },
|
const [url, query, opts] = mockApiGet.mock.calls[0]
|
||||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
expect(url).toBe('/qualimat_carriers')
|
||||||
)
|
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
|
||||||
expect(q.results.value).toHaveLength(1)
|
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||||
expect(q.results.value[0]?.name).toBe('ACME')
|
expect(repo.items.value).toEqual(PAGE)
|
||||||
expect(q.loading.value).toBe(false)
|
expect(repo.totalItems.value).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('échec réseau : résultats vidés, pas de throw', async () => {
|
it('transmet le filtre `search` (nom du transporteur) et retombe en page 1', async () => {
|
||||||
mockGet.mockRejectedValueOnce(new Error('network'))
|
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||||
const q = useQualimatSearch()
|
const repo = useQualimatSearch()
|
||||||
|
await repo.fetch()
|
||||||
|
|
||||||
await expect(q.fetchNow('x')).resolves.toBeUndefined()
|
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||||
expect(q.results.value).toEqual([])
|
await repo.setFilters({ search: 'acme' })
|
||||||
expect(q.loading.value).toBe(false)
|
|
||||||
|
expect(repo.currentPage.value).toBe(1)
|
||||||
|
const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||||
|
expect(query.search).toBe('acme')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { ref } from 'vue'
|
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
|
||||||
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
|
* Ligne du référentiel QUALIMAT renvoyée par la saisie assistée (groupe
|
||||||
@@ -20,57 +18,23 @@ export interface QualimatCarrierRow {
|
|||||||
status: string | null
|
status: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Délai de debounce de la recherche (ms) — une requête après la dernière frappe. */
|
/** Filtre de la recherche QUALIMAT (branché sur le nom du transporteur). */
|
||||||
const SEARCH_DEBOUNCE_MS = 300
|
export interface QualimatSearchFilters {
|
||||||
|
search?: string
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saisie assistée QUALIMAT (M4 Transport, ERP-166 — RG-4.01 / spec-back § 4.7).
|
* 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
|
* `GET /api/qualimat_carriers?search=` : référentiel en LECTURE SEULE, lignes
|
||||||
* actives uniquement (filtré côté serveur), recherche fuzzy nom + siret. Alimente
|
* actives uniquement (filtré côté serveur), recherche fuzzy nom + siret. Simple
|
||||||
* le tableau de sélection de l'onglet Qualimat ; la ligne choisie est copiée dans
|
* enveloppe de `usePaginatedList` (règle frontend : toute GetCollection passe par
|
||||||
* le formulaire principal (cf. `useCarrierForm.applyQualimatSelection`).
|
* ce composable — pagination Hydra, état 100 % local) consommée par le
|
||||||
|
* `MalioDataTable` de l'onglet Qualimat. Le filtre `search` est piloté par le nom
|
||||||
|
* saisi dans le formulaire principal (pas de champ de recherche dédié).
|
||||||
*
|
*
|
||||||
* Volontairement PAR INSTANCE (état local à l'écran d'ajout). `search()` est
|
* Volontairement PAR INSTANCE (état local à l'écran d'ajout).
|
||||||
* debouncé (anti-spam réseau) ; `fetchNow()` expose l'appel immédiat (montage /
|
|
||||||
* tests).
|
|
||||||
*/
|
*/
|
||||||
export function useQualimatSearch() {
|
export function useQualimatSearch() {
|
||||||
const api = useApi()
|
return usePaginatedList<QualimatCarrierRow, QualimatSearchFilters>({ url: '/qualimat_carriers' })
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,57 +131,42 @@
|
|||||||
assistee (table de selection) ; Adresses / Contacts / Prix arrivent aux
|
assistee (table de selection) ; Adresses / Contacts / Prix arrivent aux
|
||||||
tickets suivants (placeholders « A venir »). -->
|
tickets suivants (placeholders « A venir »). -->
|
||||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
||||||
<!-- Onglet Qualimat : recherche + table de selection (RG-4.01 / 4.04). -->
|
<!-- Onglet Qualimat : datatable paginé filtré par le NOM du transporteur
|
||||||
|
(pas de champ de recherche dédié — RG-4.01 / 4.04). -->
|
||||||
<template #qualimat>
|
<template #qualimat>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
<MalioInputText
|
<MalioDataTable
|
||||||
v-model="qualimatTerm"
|
:columns="qualimatColumns"
|
||||||
icon-name="mdi:magnify"
|
:items="qualimatRows"
|
||||||
:label="t('transport.carriers.form.qualimat.search')"
|
:total-items="qualimatTotal"
|
||||||
/>
|
:page="qualimatPage"
|
||||||
|
:per-page="qualimatPerPage"
|
||||||
<table class="w-full border-collapse text-left">
|
:per-page-options="qualimatPerPageOptions"
|
||||||
<thead>
|
row-clickable
|
||||||
<tr class="border-b border-black">
|
:empty-message="t('transport.carriers.form.qualimat.empty')"
|
||||||
<th class="w-12 py-2"></th>
|
@row-click="onQualimatRowClick"
|
||||||
<th class="py-2 font-semibold">{{ t('transport.carriers.form.qualimat.columns.name') }}</th>
|
@update:page="qualimatGoToPage"
|
||||||
<th class="py-2 font-semibold">{{ t('transport.carriers.form.qualimat.columns.address') }}</th>
|
@update:per-page="qualimatSetPerPage"
|
||||||
<th class="py-2 font-semibold">{{ t('transport.carriers.form.qualimat.columns.validityDate') }}</th>
|
>
|
||||||
</tr>
|
<!-- Radio reflétant la ligne QUALIMAT intégrée (lecture). -->
|
||||||
</thead>
|
<template #cell-select="{ item }">
|
||||||
<tbody>
|
<MalioRadioButton
|
||||||
<tr
|
:model-value="main.qualimatCarrierIri"
|
||||||
v-for="row in qualimatResults"
|
name="qualimat-row"
|
||||||
:key="row.id"
|
:value="item.iri"
|
||||||
class="cursor-pointer border-b border-m-muted/30 hover:bg-m-muted/10"
|
group-class="mt-0"
|
||||||
@click="askIntegrate(row)"
|
/>
|
||||||
|
</template>
|
||||||
|
<!-- Date de validité : fond rouge si périmée (RG-4.04). -->
|
||||||
|
<template #cell-validityDate="{ item }">
|
||||||
|
<span
|
||||||
|
v-if="item.validityDate"
|
||||||
|
:class="isExpired(item.validityDate as string) ? 'inline-block rounded px-2 py-0.5 bg-m-danger text-white' : ''"
|
||||||
>
|
>
|
||||||
<td class="py-2">
|
{{ formatDateFr(item.validityDate as string) }}
|
||||||
<MalioRadioButton
|
</span>
|
||||||
:model-value="main.qualimatCarrierIri"
|
</template>
|
||||||
name="qualimat-row"
|
</MalioDataTable>
|
||||||
: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">
|
<div v-if="!isValidated('qualimat')" class="flex justify-center">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
@@ -272,18 +257,52 @@ const {
|
|||||||
completeTab,
|
completeTab,
|
||||||
} = useCarrierForm()
|
} = useCarrierForm()
|
||||||
|
|
||||||
const { results: qualimatResults, fetchNow: qualimatFetch, search: qualimatSearchDebounced } = useQualimatSearch()
|
const {
|
||||||
|
items: qualimatItems,
|
||||||
|
totalItems: qualimatTotal,
|
||||||
|
currentPage: qualimatPage,
|
||||||
|
itemsPerPage: qualimatPerPage,
|
||||||
|
itemsPerPageOptions: qualimatPerPageOptions,
|
||||||
|
goToPage: qualimatGoToPage,
|
||||||
|
setItemsPerPage: qualimatSetPerPage,
|
||||||
|
setFilters: qualimatSetFilters,
|
||||||
|
} = useQualimatSearch()
|
||||||
|
|
||||||
// Certifications selectionnables manuellement (spec § Formulaire principal).
|
// Certifications selectionnables manuellement (spec § Formulaire principal) :
|
||||||
// QUALIMAT n'y figure PAS : il est pose par la saisie assistee (onglet Qualimat).
|
// GMP+ / OVOCOM / Compte-propre / Autre. QUALIMAT n'y figure PAS — il est posé par
|
||||||
|
// la saisie assistee (onglet Qualimat). On l'ajoute cependant aux options QUAND il
|
||||||
|
// est deja selectionne (transporteur QUALIMAT integre), uniquement pour AFFICHER
|
||||||
|
// son libelle dans le select en lecture seule.
|
||||||
const SELECTABLE_CERTIFICATIONS = ['GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE'] as const
|
const SELECTABLE_CERTIFICATIONS = ['GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE'] as const
|
||||||
|
|
||||||
const certificationOptions = computed<SelectOption[]>(() =>
|
const certificationOptions = computed<SelectOption[]>(() => {
|
||||||
SELECTABLE_CERTIFICATIONS.map(code => ({
|
const codes: string[] = [...SELECTABLE_CERTIFICATIONS]
|
||||||
|
if (main.certificationType === 'QUALIMAT') {
|
||||||
|
codes.unshift('QUALIMAT')
|
||||||
|
}
|
||||||
|
return codes.map(code => ({
|
||||||
value: code,
|
value: code,
|
||||||
label: t(`transport.carriers.certification.${code}`),
|
label: t(`transport.carriers.certification.${code}`),
|
||||||
})),
|
}))
|
||||||
)
|
})
|
||||||
|
|
||||||
|
// Colonnes du datatable de selection QUALIMAT (radio / Nom / Adresse / Validite).
|
||||||
|
const qualimatColumns = [
|
||||||
|
{ key: 'select', label: '' },
|
||||||
|
{ key: 'name', label: t('transport.carriers.form.qualimat.columns.name') },
|
||||||
|
{ key: 'address', label: t('transport.carriers.form.qualimat.columns.address') },
|
||||||
|
{ key: 'validityDate', label: t('transport.carriers.form.qualimat.columns.validityDate') },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Lignes « plates » pour MalioDataTable (l'IRI sert au radio + à retrouver la ligne
|
||||||
|
// source au clic). Le détail QUALIMAT complet reste dans `qualimatItems`.
|
||||||
|
const qualimatRows = computed(() => qualimatItems.value.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
iri: row['@id'],
|
||||||
|
name: row.name,
|
||||||
|
address: formatQualimatAddress(row),
|
||||||
|
validityDate: row.validityDate,
|
||||||
|
})))
|
||||||
|
|
||||||
// Contenant (RG-4.03) : Benne / Fond mouvant — select simple.
|
// Contenant (RG-4.03) : Benne / Fond mouvant — select simple.
|
||||||
const CONTAINER_TYPES = ['BENNE', 'FOND_MOUVANT'] as const
|
const CONTAINER_TYPES = ['BENNE', 'FOND_MOUVANT'] as const
|
||||||
@@ -316,19 +335,16 @@ const tabs = computed(() => tabKeys.value.map((key, index) => ({
|
|||||||
const placeholderTabs = computed(() => tabKeys.value.filter(key => key !== 'qualimat'))
|
const placeholderTabs = computed(() => tabKeys.value.filter(key => key !== 'qualimat'))
|
||||||
|
|
||||||
// ── Saisie assistee QUALIMAT (onglet Qualimat) ───────────────────────────────
|
// ── Saisie assistee QUALIMAT (onglet Qualimat) ───────────────────────────────
|
||||||
const qualimatTerm = ref('')
|
|
||||||
const qualimatLoaded = ref(false)
|
const qualimatLoaded = ref(false)
|
||||||
const confirmOpen = ref(false)
|
const confirmOpen = ref(false)
|
||||||
const pendingRow = ref<QualimatCarrierRow | null>(null)
|
const pendingRow = ref<QualimatCarrierRow | null>(null)
|
||||||
|
|
||||||
// Recherche debouncee a chaque frappe.
|
// Chargement quand l'onglet Qualimat devient actif : la recherche est branchée sur
|
||||||
watch(qualimatTerm, term => qualimatSearchDebounced(term))
|
// le NOM saisi dans le formulaire principal (RG-4.01) — pas de champ dédié.
|
||||||
|
|
||||||
// Premier chargement (liste active complete) quand l'onglet Qualimat devient actif.
|
|
||||||
watch(activeTab, (tab) => {
|
watch(activeTab, (tab) => {
|
||||||
if (tab === 'qualimat' && !qualimatLoaded.value) {
|
if (tab === 'qualimat' && !qualimatLoaded.value) {
|
||||||
qualimatLoaded.value = true
|
qualimatLoaded.value = true
|
||||||
qualimatFetch('').catch(() => {})
|
void qualimatSetFilters({ search: main.name })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -363,7 +379,15 @@ function formatDateFr(value: string | null | undefined): string {
|
|||||||
return `${day}-${month}-${date.getFullYear()}`
|
return `${day}-${month}-${date.getFullYear()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clic sur une ligne QUALIMAT → modal de confirmation d'integration. */
|
/** Clic sur une ligne du datatable → retrouve la ligne QUALIMAT source + modal. */
|
||||||
|
function onQualimatRowClick(item: Record<string, unknown>): void {
|
||||||
|
const row = qualimatItems.value.find(r => r.id === item.id)
|
||||||
|
if (row) {
|
||||||
|
askIntegrate(row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ouvre la modal de confirmation d'integration pour une ligne QUALIMAT. */
|
||||||
function askIntegrate(row: QualimatCarrierRow): void {
|
function askIntegrate(row: QualimatCarrierRow): void {
|
||||||
pendingRow.value = row
|
pendingRow.value = row
|
||||||
confirmOpen.value = true
|
confirmOpen.value = true
|
||||||
|
|||||||
Reference in New Issue
Block a user