feat(transport) : onglet Prix affiche uniquement si transporteur affrete
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 50s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m42s

This commit is contained in:
2026-06-29 09:48:30 +02:00
parent 9cd1e096f7
commit 38ff809f9c
4 changed files with 76 additions and 14 deletions
@@ -216,7 +216,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
@@ -304,11 +304,30 @@ const TAB_KEYS = ['qualimat', 'addresses', 'contacts', 'prices']
// consultation) pour retomber sur le meme onglet ; defaut « addresses ».
const requestedTab = typeof route.query.tab === 'string' ? route.query.tab : ''
const activeTab = ref(TAB_KEYS.includes(requestedTab) ? requestedTab : 'addresses')
const tabs = computed(() => TAB_KEYS.map(key => ({
key,
label: t(`transport.carriers.tab.${key}`),
icon: TAB_ICONS[key],
})))
// État affrété SAUVEGARDÉ (≠ brouillon `main.isChartered`) : pilote la visibilité
// de l'onglet « Prix ». On ne se base PAS sur la checkbox, mais sur le dernier
// PATCH principal réussi — sinon, en cas d'erreur back, l'onglet apparaîtrait
// alors que l'affrètement n'est pas persisté. Initialisé au chargement, remis à
// jour uniquement après un `updateMain()` réussi.
const savedIsChartered = ref(false)
// L'onglet « Prix » n'est visible que si le transporteur est affrété ET validé.
// Les prix existants restent en base même après retrait du statut affrété (jamais
// supprimés) : on masque seulement l'onglet tant que le transporteur n'est pas affrété.
const tabs = computed(() => TAB_KEYS
.filter(key => key !== 'prices' || savedIsChartered.value)
.map(key => ({
key,
label: t(`transport.carriers.tab.${key}`),
icon: TAB_ICONS[key],
})))
// Si l'affrètement validé est retiré alors que l'onglet Prix (qui disparait) est
// actif, on bascule sur un onglet visible pour éviter un contenu d'onglet vide.
watch(savedIsChartered, (chartered) => {
if (!chartered && activeTab.value === 'prices') {
activeTab.value = 'addresses'
}
})
// ── Référentiels (pays + clients / fournisseurs / sites pour l'onglet Prix) ───
const countryOptions = ref<SelectOption[]>([{ value: 'France', label: 'France' }])
@@ -340,12 +359,19 @@ onMounted(async () => {
await load()
if (carrier.value) {
prefillFrom(carrier.value)
// État affrété persisté à l'ouverture (pilote la visibilité de l'onglet Prix).
savedIsChartered.value = main.isChartered
// Pré-affiche le nom du fichier de décharge déjà rattaché (s'il existe).
const doc = carrier.value.dischargeDocument
if (doc && typeof doc !== 'string') {
const meta = doc as Record<string, unknown>
dischargeFileName.value = String(meta.originalFilename ?? meta.name ?? '')
}
// L'onglet « Prix » est masqué si le transporteur n'est pas affrété : si on
// arrivait dessus via ?tab=prices, on retombe sur un onglet visible.
if (activeTab.value === 'prices' && !savedIsChartered.value) {
activeTab.value = 'addresses'
}
}
loadCountries().catch(() => {})
// Exclut les courtiers (catégorie COURTIER) du select clients du module Transport.
@@ -391,6 +417,10 @@ function goBack(): void {
async function onUpdateMain(): Promise<void> {
const ok = await updateMain()
if (ok) {
// L'onglet « Prix » ne (ré)apparaît qu'ici, après PATCH réussi — jamais au
// simple clic sur la checkbox (un échec back laisserait l'onglet visible
// alors que l'affrètement n'est pas persisté).
savedIsChartered.value = main.isChartered
toast.success({ title: t('transport.carriers.toast.updateSuccess') })
}
}
@@ -417,12 +417,17 @@ const TAB_ICONS: Record<string, string> = {
// Onglets desactives tant que le formulaire principal n'est pas valide
// (unlockedIndex = -1 au depart) ; deverrouillage progressif ensuite.
const tabs = computed(() => tabKeys.value.map((key, index) => ({
key,
label: t(`transport.carriers.tab.${key}`),
icon: TAB_ICONS[key],
disabled: index > unlockedIndex.value,
})))
// L'onglet « Prix » n'apparait que si le transporteur est affrete (isChartered) :
// il est en derniere position, le filtrer ne decale pas les index des autres
// onglets (donc la logique de deverrouillage progressif reste correcte).
const tabs = computed(() => tabKeys.value
.filter(key => key !== 'prices' || main.isChartered)
.map((key, index) => ({
key,
label: t(`transport.carriers.tab.${key}`),
icon: TAB_ICONS[key],
disabled: index > unlockedIndex.value,
})))
// Tous les onglets ont désormais leur contenu (qualimat / addresses / contacts / prices).
const placeholderTabs = computed(() => tabKeys.value.filter(
@@ -148,9 +148,10 @@ describe('carrierConsultationVisibleTabs', () => {
expect(carrierConsultationVisibleTabs({ '@id': '/api/carriers/1', id: 1, name: 'LIOT' })).toEqual([])
})
it('affiche addresses/contacts/prices dans l\'ordre quand renseignés', () => {
it('affiche addresses/contacts/prices dans l\'ordre quand renseignés (affrété)', () => {
const carrier: CarrierDetail = {
'@id': '/api/carriers/1', id: 1,
isChartered: true,
address: { '@id': '/api/carrier_addresses/1', id: 1, city: 'Poitiers' },
contacts: [{ '@id': '/api/carrier_contacts/1', id: 1 }],
prices: [{ '@id': '/api/carrier_prices/1', id: 1 }],
@@ -167,4 +168,25 @@ describe('carrierConsultationVisibleTabs', () => {
}
expect(carrierConsultationVisibleTabs(carrier)).toEqual(['contacts'])
})
it('affiche l\'onglet Prix dès que le transporteur est affrété, même sans prix', () => {
const carrier: CarrierDetail = {
'@id': '/api/carriers/1', id: 1,
isChartered: true,
prices: [],
}
expect(carrierConsultationVisibleTabs(carrier)).toEqual(['prices'])
})
it('masque l\'onglet Prix d\'un transporteur non affrété même avec des prix historiques', () => {
// Retour métier : les prix d'un ancien affrété ne sont jamais supprimés,
// mais l'onglet reste masqué tant que le transporteur n'est pas réaffrété.
const carrier: CarrierDetail = {
'@id': '/api/carriers/1', id: 1,
isChartered: false,
contacts: [{ '@id': '/api/carrier_contacts/1', id: 1 }],
prices: [{ '@id': '/api/carrier_prices/1', id: 1 }],
}
expect(carrierConsultationVisibleTabs(carrier)).toEqual(['contacts'])
})
})
@@ -216,6 +216,11 @@ export function hasAddressData(address: CarrierAddressRead | null | undefined):
* onglet de données vide. Le transporteur n'a pas de coquille « à venir ».
* Ordre : Adresses · Contacts · Prix. Retourne `[]` tant que le transporteur
* n'est pas chargé.
*
* Exception « Prix » : l'onglet n'est visible QUE si le transporteur est
* affrété (`isChartered`), indépendamment de la présence de prix. Un ancien
* affrété repassé non affrété conserve ses prix en base (jamais supprimés) mais
* l'onglet reste masqué tant qu'il n'est pas réaffrété — décision métier.
*/
export function carrierConsultationVisibleTabs(
carrier: CarrierDetail | null | undefined,
@@ -230,7 +235,7 @@ export function carrierConsultationVisibleTabs(
if ((carrier.contacts ?? []).length > 0) {
visible.push('contacts')
}
if ((carrier.prices ?? []).length > 0) {
if (carrier.isChartered) {
visible.push('prices')
}
return visible