feat(transport) : modif — onglet Qualimat (actualisation) + certification éditable (déliage Qualimat) (ERP-172)
This commit is contained in:
@@ -0,0 +1,201 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { debounce } from '~/shared/utils/debounce'
|
||||||
|
import { useQualimatSearch, type QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onglet « Qualimat » — saisie assistée par recherche dans le référentiel QUALIMAT
|
||||||
|
* (RG-4.01 / RG-4.04). Datatable paginé filtré par le NOM (`searchName`), sélection
|
||||||
|
* d'une ligne → modal de confirmation → `integrate`. Mutualisé entre l'écran
|
||||||
|
* d'AJOUT (ERP-166) et l'écran de MODIFICATION (ERP-172, « actualiser le
|
||||||
|
* transporteur »). La persistance (copie nom / certification / FK) est portée par
|
||||||
|
* le parent via `useCarrierForm.applyQualimatSelection`.
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Terme de recherche (nom du transporteur saisi dans le formulaire principal). */
|
||||||
|
searchName: string
|
||||||
|
/** IRI QUALIMAT actuellement lié (coche le radio de la ligne correspondante). */
|
||||||
|
selectedIri: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'integrate', row: QualimatCarrierRow): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const {
|
||||||
|
items: qualimatItems,
|
||||||
|
totalItems: qualimatTotal,
|
||||||
|
currentPage: qualimatPage,
|
||||||
|
itemsPerPage: qualimatPerPage,
|
||||||
|
itemsPerPageOptions: qualimatPerPageOptions,
|
||||||
|
goToPage: qualimatGoToPage,
|
||||||
|
setItemsPerPage: qualimatSetPerPage,
|
||||||
|
setFilters: qualimatSetFilters,
|
||||||
|
} = useQualimatSearch()
|
||||||
|
|
||||||
|
// Colonnes du datatable de sélection QUALIMAT (radio / Nom / Adresse / Validité).
|
||||||
|
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') },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Le datatable n'affiche QUE des résultats de recherche : vide tant que le Nom n'est
|
||||||
|
// pas saisi (pas de liste complète par défaut).
|
||||||
|
const hasQualimatSearch = computed(() => props.searchName.trim() !== '')
|
||||||
|
|
||||||
|
const qualimatRows = computed(() => {
|
||||||
|
if (!hasQualimatSearch.value) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return qualimatItems.value.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
iri: row['@id'],
|
||||||
|
name: row.name,
|
||||||
|
address: formatQualimatAddress(row),
|
||||||
|
validityDate: row.validityDate,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const qualimatTotalDisplay = computed(() => (hasQualimatSearch.value ? qualimatTotal.value : 0))
|
||||||
|
const qualimatEmptyMessage = computed(() => hasQualimatSearch.value
|
||||||
|
? t('transport.carriers.form.qualimat.empty')
|
||||||
|
: t('transport.carriers.form.qualimat.searchHint'))
|
||||||
|
|
||||||
|
// Re-filtrage debouncé sur le nom ; aucune recherche tant que le Nom est vide.
|
||||||
|
const filterQualimatByName = debounce((term: string) => {
|
||||||
|
if (term.trim() === '') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void qualimatSetFilters({ search: term })
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
watch(() => props.searchName, term => filterQualimatByName(term), { immediate: true })
|
||||||
|
|
||||||
|
/** Adresse QUALIMAT condensée 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 agrément est périmé si sa date de validité 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 français JJ-MM-AAAA (chaîne 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()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Confirmation d'intégration ───────────────────────────────────────────────
|
||||||
|
const confirmOpen = ref(false)
|
||||||
|
const pendingRow = ref<QualimatCarrierRow | null>(null)
|
||||||
|
|
||||||
|
/** Clic sur une ligne → retrouve la ligne QUALIMAT source + ouvre la modal. */
|
||||||
|
function onQualimatRowClick(item: Record<string, unknown>): void {
|
||||||
|
const row = qualimatItems.value.find(r => r.id === item.id)
|
||||||
|
if (row) {
|
||||||
|
pendingRow.value = row
|
||||||
|
confirmOpen.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirme l'intégration : délègue la persistance au parent via `integrate`. */
|
||||||
|
function confirmIntegrate(): void {
|
||||||
|
const row = pendingRow.value
|
||||||
|
confirmOpen.value = false
|
||||||
|
if (row !== null) {
|
||||||
|
emit('integrate', row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<!-- table-fixed : 1re colonne (radio) étroite, les 3 autres à parts égales. -->
|
||||||
|
<MalioDataTable
|
||||||
|
class="qualimat-table"
|
||||||
|
table-class="table-fixed"
|
||||||
|
:columns="qualimatColumns"
|
||||||
|
:items="qualimatRows"
|
||||||
|
:total-items="qualimatTotalDisplay"
|
||||||
|
:page="qualimatPage"
|
||||||
|
:per-page="qualimatPerPage"
|
||||||
|
:per-page-options="qualimatPerPageOptions"
|
||||||
|
row-clickable
|
||||||
|
:empty-message="qualimatEmptyMessage"
|
||||||
|
@row-click="onQualimatRowClick"
|
||||||
|
@update:page="qualimatGoToPage"
|
||||||
|
@update:per-page="qualimatSetPerPage"
|
||||||
|
>
|
||||||
|
<!-- Radio reflétant la ligne QUALIMAT intégrée (lecture). -->
|
||||||
|
<template #cell-select="{ item }">
|
||||||
|
<MalioRadioButton
|
||||||
|
:model-value="selectedIri"
|
||||||
|
name="qualimat-row"
|
||||||
|
:value="item.iri"
|
||||||
|
group-class="mt-0"
|
||||||
|
/>
|
||||||
|
</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' : ''"
|
||||||
|
>
|
||||||
|
{{ formatDateFr(item.validityDate as string) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</MalioDataTable>
|
||||||
|
|
||||||
|
<!-- Modal de confirmation d'intégration QUALIMAT. -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Datatable QUALIMAT en table-fixed : la colonne radio (1re) reste étroite,
|
||||||
|
les 3 autres (nom / adresse / validité) se partagent l'espace à parts égales. */
|
||||||
|
.qualimat-table :deep(th:first-child),
|
||||||
|
.qualimat-table :deep(td:first-child) {
|
||||||
|
width: 56px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -975,3 +975,73 @@ describe('useCarrierForm — édition (ERP-170)', () => {
|
|||||||
expect(form.main.name).toBe('TRANSPORTS ACME')
|
expect(form.main.name).toBe('TRANSPORTS ACME')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('useCarrierForm — modification : Qualimat + certification (ERP-172)', () => {
|
||||||
|
const QUALIMAT_ROW = {
|
||||||
|
'@id': '/api/qualimat_carriers/42',
|
||||||
|
id: '42',
|
||||||
|
name: 'TRANSPORTS QUALIMAT',
|
||||||
|
address: '1 rue du Port',
|
||||||
|
postalCode: '86000',
|
||||||
|
city: 'Poitiers',
|
||||||
|
validityDate: '2027-01-15',
|
||||||
|
status: 'VALIDE',
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPost.mockReset()
|
||||||
|
mockPatch.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setCertification : quitter QUALIMAT délie la FK qualimatCarrier', () => {
|
||||||
|
const form = useCarrierForm()
|
||||||
|
form.main.qualimatCarrierIri = '/api/qualimat_carriers/42'
|
||||||
|
form.main.certificationType = 'QUALIMAT'
|
||||||
|
|
||||||
|
form.setCertification('GMP_PLUS')
|
||||||
|
|
||||||
|
expect(form.main.certificationType).toBe('GMP_PLUS')
|
||||||
|
expect(form.main.qualimatCarrierIri).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('certificationReadonly : éditable en modification même pour un QUALIMAT', () => {
|
||||||
|
const form = useCarrierForm()
|
||||||
|
form.prefillFrom({
|
||||||
|
'@id': '/api/carriers/7', id: 7, name: 'ACME', certificationType: 'QUALIMAT',
|
||||||
|
qualimatCarrier: { '@id': '/api/qualimat_carriers/42' },
|
||||||
|
})
|
||||||
|
expect(form.isQualimat.value).toBe(true)
|
||||||
|
expect(form.certificationReadonly.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('buildMainPayload : en modification, délie le Qualimat (qualimatCarrier: null) sans lien', () => {
|
||||||
|
const form = useCarrierForm()
|
||||||
|
form.prefillFrom({
|
||||||
|
'@id': '/api/carriers/7', id: 7, name: 'ACME', certificationType: 'QUALIMAT',
|
||||||
|
qualimatCarrier: { '@id': '/api/qualimat_carriers/42' },
|
||||||
|
})
|
||||||
|
form.setCertification('GMP_PLUS')
|
||||||
|
|
||||||
|
expect(form.buildMainPayload()).toMatchObject({ certificationType: 'GMP_PLUS', qualimatCarrier: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applyQualimatSelection : en modification, conserve l\'adresse existante (PATCH nom/certif/FK)', async () => {
|
||||||
|
mockPatch.mockResolvedValueOnce({})
|
||||||
|
const form = useCarrierForm()
|
||||||
|
form.prefillFrom({
|
||||||
|
'@id': '/api/carriers/7', id: 7, name: 'OLD', certificationType: 'GMP_PLUS',
|
||||||
|
address: { '@id': '/api/carrier_addresses/3', id: 3, city: 'Poitiers', street: 'rue A' },
|
||||||
|
})
|
||||||
|
const addressBefore = { ...form.address.value }
|
||||||
|
|
||||||
|
const ok = await form.applyQualimatSelection(QUALIMAT_ROW)
|
||||||
|
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
// Décision « conserver » (ERP-172) : l'adresse n'est pas réécrite en modification.
|
||||||
|
expect(form.address.value).toEqual(addressBefore)
|
||||||
|
// Nom + certification + FK actualisés via PATCH.
|
||||||
|
expect(form.main.name).toBe('TRANSPORTS QUALIMAT')
|
||||||
|
expect(form.main.certificationType).toBe('QUALIMAT')
|
||||||
|
expect(form.main.qualimatCarrierIri).toBe('/api/qualimat_carriers/42')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -91,8 +91,10 @@ export function useCarrierForm() {
|
|||||||
// Transporteur QUALIMAT : la FK est posée → certification figée à « QUALIMAT ».
|
// Transporteur QUALIMAT : la FK est posée → certification figée à « QUALIMAT ».
|
||||||
const isQualimat = computed(() => main.qualimatCarrierIri !== null)
|
const isQualimat = computed(() => main.qualimatCarrierIri !== null)
|
||||||
// Certification masquée en cas LIOT ; lecture seule si QUALIMAT (ou bloc verrouillé).
|
// Certification masquée en cas LIOT ; lecture seule si QUALIMAT (ou bloc verrouillé).
|
||||||
|
// En MODIFICATION (ERP-172) : éditable même pour un QUALIMAT (le métier doit pouvoir
|
||||||
|
// changer la certification) — la sortie de QUALIMAT délie le référentiel.
|
||||||
const showCertification = computed(() => !isLiot.value)
|
const showCertification = computed(() => !isLiot.value)
|
||||||
const certificationReadonly = computed(() => isQualimat.value || mainLocked.value)
|
const certificationReadonly = computed(() => (isQualimat.value && !editMode.value) || mainLocked.value)
|
||||||
// RG-4.03 : champs d'affrètement (indexation / contenant / volume) visibles et
|
// RG-4.03 : champs d'affrètement (indexation / contenant / volume) visibles et
|
||||||
// obligatoires si « Affréter » coché — masqués en cas LIOT.
|
// obligatoires si « Affréter » coché — masqués en cas LIOT.
|
||||||
const showCharteredFields = computed(() => main.isChartered && !isLiot.value)
|
const showCharteredFields = computed(() => main.isChartered && !isLiot.value)
|
||||||
@@ -211,6 +213,19 @@ export function useCarrierForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change la certification (sélecteur). Quitter « QUALIMAT » délie le référentiel
|
||||||
|
* (FK qualimatCarrier vidée — ERP-172) : un transporteur n'est QUALIMAT que tant
|
||||||
|
* que sa certification l'est. La FK null est propagée au back par buildMainPayload
|
||||||
|
* (en modification uniquement).
|
||||||
|
*/
|
||||||
|
function setCertification(value: string | null): void {
|
||||||
|
main.certificationType = value
|
||||||
|
if (value !== 'QUALIMAT') {
|
||||||
|
main.qualimatCarrierIri = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Payload du POST principal (groupe `carrier:write:main`). `name` et
|
* Payload du POST principal (groupe `carrier:write:main`). `name` et
|
||||||
* `certificationType` sont omis s'ils sont vides afin que la 422 porte la
|
* `certificationType` sont omis s'ils sont vides afin que la 422 porte la
|
||||||
@@ -236,9 +251,14 @@ export function useCarrierForm() {
|
|||||||
payload.certificationType = main.certificationType
|
payload.certificationType = main.certificationType
|
||||||
}
|
}
|
||||||
// FK QUALIMAT (saisie assistée, § 2.5) envoyée si une ligne a été intégrée.
|
// FK QUALIMAT (saisie assistée, § 2.5) envoyée si une ligne a été intégrée.
|
||||||
|
// En MODIFICATION, on délie explicitement (null) si plus de lien — ex: la
|
||||||
|
// certification a changé de QUALIMAT vers autre chose (ERP-172).
|
||||||
if (main.qualimatCarrierIri) {
|
if (main.qualimatCarrierIri) {
|
||||||
payload.qualimatCarrier = main.qualimatCarrierIri
|
payload.qualimatCarrier = main.qualimatCarrierIri
|
||||||
}
|
}
|
||||||
|
else if (editMode.value) {
|
||||||
|
payload.qualimatCarrier = null
|
||||||
|
}
|
||||||
// RG-4.02 : décharge envoyée seulement en certification AUTRE ; omise quand
|
// RG-4.02 : décharge envoyée seulement en certification AUTRE ; omise quand
|
||||||
// absente pour que la 422 « obligatoire » porte sur le champ.
|
// absente pour que la 422 « obligatoire » porte sur le champ.
|
||||||
if (main.certificationType === 'AUTRE' && main.dischargeDocumentIri) {
|
if (main.certificationType === 'AUTRE' && main.dischargeDocumentIri) {
|
||||||
@@ -799,6 +819,7 @@ export function useCarrierForm() {
|
|||||||
removePrice,
|
removePrice,
|
||||||
submitPrices,
|
submitPrices,
|
||||||
// actions
|
// actions
|
||||||
|
setCertification,
|
||||||
selectDischarge,
|
selectDischarge,
|
||||||
clearDischarge,
|
clearDischarge,
|
||||||
validateMainFront,
|
validateMainFront,
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
:required="true"
|
:required="true"
|
||||||
:readonly="certificationReadonly"
|
:readonly="certificationReadonly"
|
||||||
:error="mainErrors.errors.certificationType"
|
:error="mainErrors.errors.certificationType"
|
||||||
@update:model-value="(v: string | number | null) => main.certificationType = v === null ? null : String(v)"
|
@update:model-value="(v: string | number | null) => setCertification(v === null ? null : String(v))"
|
||||||
/>
|
/>
|
||||||
<MalioInputUpload
|
<MalioInputUpload
|
||||||
v-if="showDischarge"
|
v-if="showDischarge"
|
||||||
@@ -122,6 +122,15 @@
|
|||||||
|
|
||||||
<!-- ── Onglets éditables (navigation libre, PATCH partiel par onglet) ── -->
|
<!-- ── Onglets éditables (navigation libre, PATCH partiel par onglet) ── -->
|
||||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
||||||
|
<!-- Qualimat : actualiser nom + certification depuis le référentiel (ERP-172). -->
|
||||||
|
<template #qualimat>
|
||||||
|
<CarrierQualimatTab
|
||||||
|
:search-name="main.name"
|
||||||
|
:selected-iri="main.qualimatCarrierIri"
|
||||||
|
@integrate="onIntegrateQualimat"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #addresses>
|
<template #addresses>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
<!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. -->
|
<!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. -->
|
||||||
@@ -201,8 +210,10 @@ import { isRowRemovable } from '~/shared/utils/collectionRow'
|
|||||||
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
|
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
|
||||||
import CarrierContactBlock from '~/modules/transport/components/CarrierContactBlock.vue'
|
import CarrierContactBlock from '~/modules/transport/components/CarrierContactBlock.vue'
|
||||||
import CarrierPriceBlock from '~/modules/transport/components/CarrierPriceBlock.vue'
|
import CarrierPriceBlock from '~/modules/transport/components/CarrierPriceBlock.vue'
|
||||||
|
import CarrierQualimatTab from '~/modules/transport/components/CarrierQualimatTab.vue'
|
||||||
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
|
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
|
||||||
import { useCarrier } from '~/modules/transport/composables/useCarrier'
|
import { useCarrier } from '~/modules/transport/composables/useCarrier'
|
||||||
|
import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
||||||
import { clampPercent, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput'
|
import { clampPercent, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput'
|
||||||
|
|
||||||
interface SelectOption {
|
interface SelectOption {
|
||||||
@@ -235,10 +246,12 @@ const {
|
|||||||
dischargeUploading,
|
dischargeUploading,
|
||||||
selectDischarge,
|
selectDischarge,
|
||||||
clearDischarge,
|
clearDischarge,
|
||||||
|
setCertification,
|
||||||
isLiot,
|
isLiot,
|
||||||
certificationReadonly,
|
certificationReadonly,
|
||||||
showCharteredFields,
|
showCharteredFields,
|
||||||
showDischarge,
|
showDischarge,
|
||||||
|
applyQualimatSelection,
|
||||||
address,
|
address,
|
||||||
addressErrors,
|
addressErrors,
|
||||||
submitAddress,
|
submitAddress,
|
||||||
@@ -267,12 +280,14 @@ const certificationOptions = computed<SelectOption[]>(() => {
|
|||||||
|
|
||||||
|
|
||||||
const TAB_ICONS: Record<string, string> = {
|
const TAB_ICONS: Record<string, string> = {
|
||||||
|
qualimat: 'mdi:truck-fast-outline',
|
||||||
addresses: 'mdi:map-marker-outline',
|
addresses: 'mdi:map-marker-outline',
|
||||||
contacts: 'mdi:account-box-plus-outline',
|
contacts: 'mdi:account-box-plus-outline',
|
||||||
prices: 'mdi:payment',
|
prices: 'mdi:payment',
|
||||||
}
|
}
|
||||||
const activeTab = ref('addresses')
|
const activeTab = ref('addresses')
|
||||||
const tabs = computed(() => ['addresses', 'contacts', 'prices'].map(key => ({
|
// Onglet Qualimat disponible en modif (ERP-172) : « actualiser » nom + certification.
|
||||||
|
const tabs = computed(() => ['qualimat', 'addresses', 'contacts', 'prices'].map(key => ({
|
||||||
key,
|
key,
|
||||||
label: t(`transport.carriers.tab.${key}`),
|
label: t(`transport.carriers.tab.${key}`),
|
||||||
icon: TAB_ICONS[key],
|
icon: TAB_ICONS[key],
|
||||||
@@ -361,6 +376,15 @@ async function onUpdateMain(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Intégration d'une ligne QUALIMAT (onglet Qualimat) : actualise nom + certification
|
||||||
|
* + FK via PATCH (applyQualimatSelection). L'adresse existante n'est pas touchée. */
|
||||||
|
async function onIntegrateQualimat(row: QualimatCarrierRow): Promise<void> {
|
||||||
|
const ok = await applyQualimatSelection(row)
|
||||||
|
if (ok) {
|
||||||
|
toast.success({ title: t('transport.carriers.toast.integrateSuccess') })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onSubmitAddresses(): Promise<void> {
|
async function onSubmitAddresses(): Promise<void> {
|
||||||
const ok = await submitAddress(err => toast.error({ title: t('transport.carriers.toast.error'), message: apiErrorMessage(err) }))
|
const ok = await submitAddress(err => toast.error({ title: t('transport.carriers.toast.error'), message: apiErrorMessage(err) }))
|
||||||
if (ok) toast.success({ title: t('transport.carriers.toast.addressSaved') })
|
if (ok) toast.success({ title: t('transport.carriers.toast.addressSaved') })
|
||||||
|
|||||||
@@ -156,46 +156,14 @@
|
|||||||
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 : datatable paginé filtré par le NOM du transporteur
|
<!-- Onglet Qualimat : saisie assistée (recherche par nom). Composant
|
||||||
(pas de champ de recherche dédié — RG-4.01 / 4.04). -->
|
mutualisé avec l'écran de modification (ERP-172). -->
|
||||||
<template #qualimat>
|
<template #qualimat>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<CarrierQualimatTab
|
||||||
<!-- table-fixed : 1re colonne (radio) étroite, les 3 autres à parts égales. -->
|
:search-name="main.name"
|
||||||
<MalioDataTable
|
:selected-iri="main.qualimatCarrierIri"
|
||||||
class="qualimat-table"
|
@integrate="onIntegrateQualimat"
|
||||||
table-class="table-fixed"
|
/>
|
||||||
:columns="qualimatColumns"
|
|
||||||
:items="qualimatRows"
|
|
||||||
:total-items="qualimatTotalDisplay"
|
|
||||||
:page="qualimatPage"
|
|
||||||
:per-page="qualimatPerPage"
|
|
||||||
:per-page-options="qualimatPerPageOptions"
|
|
||||||
row-clickable
|
|
||||||
:empty-message="qualimatEmptyMessage"
|
|
||||||
@row-click="onQualimatRowClick"
|
|
||||||
@update:page="qualimatGoToPage"
|
|
||||||
@update:per-page="qualimatSetPerPage"
|
|
||||||
>
|
|
||||||
<!-- Radio reflétant la ligne QUALIMAT intégrée (lecture). -->
|
|
||||||
<template #cell-select="{ item }">
|
|
||||||
<MalioRadioButton
|
|
||||||
:model-value="main.qualimatCarrierIri"
|
|
||||||
name="qualimat-row"
|
|
||||||
:value="item.iri"
|
|
||||||
group-class="mt-0"
|
|
||||||
/>
|
|
||||||
</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' : ''"
|
|
||||||
>
|
|
||||||
{{ formatDateFr(item.validityDate as string) }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</MalioDataTable>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Onglet Adresses (ERP-167) : un bloc par adresse + BAN. RG-4.05
|
<!-- Onglet Adresses (ERP-167) : un bloc par adresse + BAN. RG-4.05
|
||||||
@@ -306,29 +274,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</MalioTabList>
|
</MalioTabList>
|
||||||
|
|
||||||
<!-- Modal de confirmation d'integration QUALIMAT (RG-4.01). -->
|
<!-- Modal de confirmation de suppression (bloc contact / prix). -->
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Modal de confirmation de suppression (bloc adresse). -->
|
|
||||||
<MalioModal v-model="deleteConfirm.open" modal-class="max-w-md">
|
<MalioModal v-model="deleteConfirm.open" modal-class="max-w-md">
|
||||||
<template #header>
|
<template #header>
|
||||||
<h2 class="text-[24px] font-bold">{{ t('transport.carriers.form.confirmDelete.title') }}</h2>
|
<h2 class="text-[24px] font-bold">{{ t('transport.carriers.form.confirmDelete.title') }}</h2>
|
||||||
@@ -353,15 +299,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { debounce } from '~/shared/utils/debounce'
|
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||||
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
|
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
|
||||||
import CarrierContactBlock from '~/modules/transport/components/CarrierContactBlock.vue'
|
import CarrierContactBlock from '~/modules/transport/components/CarrierContactBlock.vue'
|
||||||
import CarrierPriceBlock from '~/modules/transport/components/CarrierPriceBlock.vue'
|
import CarrierPriceBlock from '~/modules/transport/components/CarrierPriceBlock.vue'
|
||||||
|
import CarrierQualimatTab from '~/modules/transport/components/CarrierQualimatTab.vue'
|
||||||
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
|
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
|
||||||
import { useQualimatSearch, type QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
||||||
import { clampPercent, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput'
|
import { clampPercent, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput'
|
||||||
|
|
||||||
interface SelectOption {
|
interface SelectOption {
|
||||||
@@ -430,17 +376,6 @@ function onClearDischarge(): void {
|
|||||||
dischargeFileName.value = ''
|
dischargeFileName.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
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) :
|
||||||
// GMP+ / OVOCOM / Compte-propre / Autre. QUALIMAT n'y figure PAS — il est posé par
|
// 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
|
// la saisie assistee (onglet Qualimat). On l'ajoute cependant aux options QUAND il
|
||||||
@@ -459,40 +394,6 @@ const certificationOptions = computed<SelectOption[]>(() => {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
// 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') },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Le datatable n'affiche QUE des résultats de recherche : vide tant que le Nom n'est
|
|
||||||
// pas saisi (pas de liste complète par défaut). `main.name` pilote l'affichage.
|
|
||||||
const hasQualimatSearch = computed(() => main.name.trim() !== '')
|
|
||||||
|
|
||||||
// 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(() => {
|
|
||||||
if (!hasQualimatSearch.value) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return qualimatItems.value.map(row => ({
|
|
||||||
id: row.id,
|
|
||||||
iri: row['@id'],
|
|
||||||
name: row.name,
|
|
||||||
address: formatQualimatAddress(row),
|
|
||||||
validityDate: row.validityDate,
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Total / message vide alignés sur « tableau vide tant qu'on n'a pas recherché ».
|
|
||||||
const qualimatTotalDisplay = computed(() => (hasQualimatSearch.value ? qualimatTotal.value : 0))
|
|
||||||
const qualimatEmptyMessage = computed(() => hasQualimatSearch.value
|
|
||||||
? t('transport.carriers.form.qualimat.empty')
|
|
||||||
: t('transport.carriers.form.qualimat.searchHint'))
|
|
||||||
|
|
||||||
|
|
||||||
// Icone (Iconify) affichee dans chaque onglet, par cle.
|
// Icone (Iconify) affichee dans chaque onglet, par cle.
|
||||||
const TAB_ICONS: Record<string, string> = {
|
const TAB_ICONS: Record<string, string> = {
|
||||||
qualimat: 'mdi:truck-fast-outline',
|
qualimat: 'mdi:truck-fast-outline',
|
||||||
@@ -646,74 +547,9 @@ function runDeleteConfirm(): void {
|
|||||||
deleteConfirm.open = false
|
deleteConfirm.open = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Saisie assistee QUALIMAT (onglet Qualimat) ───────────────────────────────
|
/** Intégration d'une ligne QUALIMAT (émise par CarrierQualimatTab) : copie + PATCH
|
||||||
const confirmOpen = ref(false)
|
* (cf. useCarrierForm.applyQualimatSelection). */
|
||||||
const pendingRow = ref<QualimatCarrierRow | null>(null)
|
async function onIntegrateQualimat(row: QualimatCarrierRow): Promise<void> {
|
||||||
|
|
||||||
// Le datatable QUALIMAT est filtré par le NOM saisi dans le formulaire principal
|
|
||||||
// (RG-4.01) — pas de champ de recherche dédié. Aucune recherche tant que le Nom est
|
|
||||||
// vide (tableau vide par défaut) ; sinon re-filtrage debouncé à chaque frappe.
|
|
||||||
const filterQualimatByName = debounce((term: string) => {
|
|
||||||
if (term.trim() === '') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
void qualimatSetFilters({ search: term })
|
|
||||||
}, 300)
|
|
||||||
|
|
||||||
watch(() => main.name, term => filterQualimatByName(term))
|
|
||||||
|
|
||||||
/** 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 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 {
|
|
||||||
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)
|
const ok = await applyQualimatSelection(row)
|
||||||
if (ok) {
|
if (ok) {
|
||||||
toast.success({ title: t('transport.carriers.toast.integrateSuccess') })
|
toast.success({ title: t('transport.carriers.toast.integrateSuccess') })
|
||||||
@@ -753,12 +589,3 @@ async function onSubmitMain(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Datatable QUALIMAT en table-fixed : la colonne radio (1re) reste étroite,
|
|
||||||
les 3 autres (nom / adresse / validité) se partagent l'espace à parts égales. */
|
|
||||||
.qualimat-table :deep(th:first-child),
|
|
||||||
.qualimat-table :deep(td:first-child) {
|
|
||||||
width: 56px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user