fix(transport) : tableau prix consultation — cellule de groupe fusionnée (Fond Mouvant/Benne) + colonnes maquette (ERP-170)
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Has been cancelled

This commit is contained in:
2026-06-17 11:41:56 +02:00
parent 68236956f1
commit eaaa312af4
@@ -102,43 +102,52 @@
<!-- Prix : tableau présentationnel regroupé par contenant + export. -->
<template #prices>
<div class="mt-12 flex flex-col gap-6">
<table class="w-full border-collapse text-left text-sm">
<table class="w-full border-collapse border border-m-muted/40 text-left text-sm">
<thead>
<tr class="border-b border-black">
<th class="py-2 font-semibold">{{ t('transport.carriers.consultation.price.group') }}</th>
<th class="py-2 font-semibold">{{ t('transport.carriers.consultation.price.carrier') }}</th>
<th class="py-2 font-semibold">{{ t('transport.carriers.consultation.price.aproOrSite') }}</th>
<th class="py-2 font-semibold">{{ t('transport.carriers.consultation.price.delivery') }}</th>
<th class="py-2 font-semibold">{{ t('transport.carriers.consultation.price.forfait') }}</th>
<th class="py-2 font-semibold">{{ t('transport.carriers.consultation.price.tonne') }}</th>
<th class="py-2 font-semibold">{{ t('transport.carriers.consultation.price.indexation') }}</th>
<th class="py-2 font-semibold">{{ t('transport.carriers.consultation.price.state') }}</th>
<tr class="border-b border-black bg-m-muted/10">
<th class="px-3 py-2"></th>
<th class="px-3 py-2 font-semibold">{{ t('transport.carriers.consultation.price.carrier') }}</th>
<th class="px-3 py-2 font-semibold">{{ t('transport.carriers.consultation.price.aproOrSite') }}</th>
<th class="px-3 py-2 font-semibold">{{ t('transport.carriers.consultation.price.delivery') }}</th>
<th class="px-3 py-2 font-semibold">{{ t('transport.carriers.consultation.price.forfait') }}</th>
<th class="px-3 py-2 font-semibold">{{ t('transport.carriers.consultation.price.tonne') }}</th>
<th class="px-3 py-2 font-semibold">{{ t('transport.carriers.consultation.price.indexation') }}</th>
<th class="px-3 py-2 font-semibold">{{ t('transport.carriers.consultation.price.state') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, index) in priceRows"
:key="index"
class="border-b border-m-muted/30"
>
<td class="py-2 font-medium">{{ row.group }}</td>
<td class="py-2">{{ headerTitle }}</td>
<td class="py-2">{{ row.aproOrSite }}</td>
<td class="py-2">{{ row.delivery }}</td>
<td class="py-2">{{ row.forfait }}</td>
<td class="py-2">{{ row.tonne }}</td>
<td class="py-2">{{ row.indexation }}</td>
<td class="py-2">{{ row.state }}</td>
</tr>
<tr v-if="priceRows.length === 0">
<td colspan="8" class="py-4 text-center text-m-muted">
<template v-for="(group, gi) in priceGroups" :key="group.label">
<tr
v-for="(row, i) in group.rows"
:key="`${gi}-${i}`"
:class="i === group.rows.length - 1 ? 'border-b border-black' : 'border-b border-m-muted/30'"
>
<!-- Cellule de groupe fusionnée (rowspan), centrée verticalement. -->
<td
v-if="i === 0"
:rowspan="group.rows.length"
class="border-r border-black px-3 py-2 text-center align-middle font-medium"
>
{{ group.label }}
</td>
<td class="px-3 py-2">{{ headerTitle }}</td>
<td class="px-3 py-2">{{ row.apro }}</td>
<td class="px-3 py-2">{{ row.delivery }}</td>
<td class="px-3 py-2">{{ row.forfait }}</td>
<td class="px-3 py-2">{{ row.tonne }}</td>
<td class="px-3 py-2">{{ row.indexation }}</td>
<td class="px-3 py-2">{{ row.state }}</td>
</tr>
</template>
<tr v-if="!hasPrices">
<td colspan="8" class="px-3 py-4 text-center text-m-muted">
{{ t('transport.carriers.consultation.price.empty') }}
</td>
</tr>
</tbody>
</table>
<div v-if="priceRows.length > 0" class="flex justify-center">
<div v-if="hasPrices" class="flex justify-center">
<MalioButton
variant="primary"
:label="t('transport.carriers.consultation.price.export')"
@@ -256,8 +265,7 @@ function countryOptionsFor(country: string): SelectOption[] {
const PRICE_GROUP_ORDER = ['FOND_MOUVANT', 'BENNE'] as const
interface PriceRowView {
group: string
aproOrSite: string
apro: string
delivery: string
forfait: string
tonne: string
@@ -265,17 +273,41 @@ interface PriceRowView {
state: string
}
/** Construit une ligne d'affichage depuis un prix embarqué. */
/** Groupe de prix d'un même contenant (Fond Mouvant / Benne) — cellule fusionnée. */
interface PriceGroupView {
label: string
rows: PriceRowView[]
}
/** Formate un montant décimal en « 1 000,00 € » (chaîne vide si absent). */
function formatAmount(value: string | null | undefined): string {
if (!value) {
return ''
}
const n = Number(value)
if (Number.isNaN(n)) {
return ''
}
return `${n.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
}
/**
* Construit une ligne d'affichage depuis un prix embarqué (maquette Prix) :
* - « Adresse APRO » = adresse (voie) du client/fournisseur ;
* - « Adresse livraisons » = le site (86 / 17 / 82) ;
* - le prix tombe dans Forfait € OU Tonne € selon `pricingUnit`.
*/
function toPriceRow(price: CarrierPriceRead): PriceRowView {
const isClient = price.direction === 'CLIENT'
return {
group: price.containerType ? t(`transport.carriers.containerType.${price.containerType}`) : '',
// RG : prix Client → site de départ ; prix Fournisseur → adresse d'appro.
aproOrSite: isClient ? labelOfRelation(price.departureSite) : labelOfRelation(price.supplierSupplyAddress),
delivery: isClient ? labelOfRelation(price.clientDeliveryAddress) : labelOfRelation(price.deliverySite),
forfait: price.pricingUnit === 'FORFAIT' ? (price.price ?? '') : '',
tonne: price.pricingUnit === 'TONNE' ? (price.price ?? '') : '',
indexation: main.value.indexationRate || '',
apro: isClient ? labelOfRelation(price.clientDeliveryAddress) : labelOfRelation(price.supplierSupplyAddress),
delivery: isClient ? labelOfRelation(price.departureSite) : labelOfRelation(price.deliverySite),
forfait: price.pricingUnit === 'FORFAIT' ? formatAmount(price.price) : '',
tonne: price.pricingUnit === 'TONNE' ? formatAmount(price.price) : '',
// CarrierPrice n'a pas de taux d'indexation propre → on affiche celui du
// transporteur (formulaire principal). À faire évoluer si un taux par prix
// est requis (gap back).
indexation: main.value.indexationRate ? `${main.value.indexationRate} %` : '',
state: price.priceState ? t(`transport.carriers.form.price.state${stateSuffix(price.priceState)}`) : '',
}
}
@@ -286,15 +318,20 @@ function stateSuffix(state: string): string {
return map[state] ?? ''
}
// Prix triés/regroupés par contenant (Fond Mouvant puis Benne).
const priceRows = computed<PriceRowView[]>(() => {
// Prix regroupés par contenant (Fond Mouvant puis Benne) — une cellule fusionnée
// par groupe (rowspan) à gauche, conformément à la maquette.
const priceGroups = computed<PriceGroupView[]>(() => {
const list = carrier.value?.prices ?? []
return [...list]
.sort((a, b) => PRICE_GROUP_ORDER.indexOf((a.containerType ?? '') as 'FOND_MOUVANT')
- PRICE_GROUP_ORDER.indexOf((b.containerType ?? '') as 'FOND_MOUVANT'))
.map(toPriceRow)
return PRICE_GROUP_ORDER
.map(container => ({
label: t(`transport.carriers.containerType.${container}`),
rows: list.filter(p => p.containerType === container).map(toPriceRow),
}))
.filter(group => group.rows.length > 0)
})
const hasPrices = computed(() => priceGroups.value.length > 0)
// ── Export XLSX des prix ─────────────────────────────────────────────────────
const exporting = ref(false)