Files
Starseed/frontend/modules/transport/pages/carriers/[id]/index.vue
T
tristan 80b3741f64
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m3s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m32s
style(transport) : tableau prix consultation — colonne « Type de transport » à 170px (ERP-172)
2026-06-17 17:58:06 +02:00

507 lines
24 KiB
Vue

<template>
<div>
<!-- En-tête : retour répertoire + nom + actions. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
v-bind="{ ariaLabel: t('transport.carriers.consultation.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
<div class="ml-auto flex items-center gap-12">
<MalioButton
v-if="canEdit"
variant="secondary"
icon-name="mdi:pencil-outline"
icon-position="left"
:label="t('transport.carriers.action.edit')"
@click="goEdit"
/>
<MalioButton
v-if="showArchive"
variant="secondary"
icon-name="mdi:archive-arrow-down-outline"
icon-position="left"
:label="t('transport.carriers.action.archive')"
@click="askToggleArchive"
/>
<MalioButton
v-if="showRestore"
variant="secondary"
icon-name="mdi:archive-arrow-up-outline"
icon-position="left"
:label="t('transport.carriers.action.restore')"
@click="askToggleArchive"
/>
</div>
</div>
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('transport.carriers.consultation.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('transport.carriers.consultation.notFound') }}</p>
<template v-else-if="carrier">
<!-- Bloc principal (lecture seule) même disposition que l'ajout -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText :model-value="main.name" :label="t('transport.carriers.form.main.name')" readonly />
<!-- Cas LIOT : seul le champ immatriculations. -->
<MalioInputText
v-if="isLiot"
:model-value="main.liotPlates"
:label="t('transport.carriers.form.main.liotPlates')"
readonly
/>
<!-- Cas standard : certification + décharge (col 3 réservée) + affrètement (col 4). -->
<template v-if="!isLiot">
<MalioInputText
:model-value="certificationLabel"
:label="t('transport.carriers.form.main.certificationType')"
readonly
/>
<!-- Colonne 3 réservée à la décharge (si AUTRE), sinon vide (xl). -->
<MalioInputText
v-if="main.certificationType === 'AUTRE'"
:model-value="dischargeLabel"
:label="t('transport.carriers.form.main.discharge')"
readonly
/>
<div v-else class="hidden xl:block"></div>
<!-- Affréter : colonne 4, centré (h-12) comme à l'ajout. -->
<div class="flex h-12 items-center">
<MalioCheckbox
id="carrier-view-chartered"
:label="t('transport.carriers.form.main.isChartered')"
:model-value="main.isChartered"
readonly
:reserve-message-space="false"
/>
</div>
<!-- Champs d'affrètement (ligne 2) si affrété. -->
<template v-if="main.isChartered">
<MalioInputText :model-value="indexationDisplay" :label="t('transport.carriers.form.main.indexationRate')" readonly />
<!-- Contenant : radios désactivés (lecture seule), aligné sur l'ajout / la modif. -->
<div>
<div class="flex h-12 items-center gap-4">
<MalioRadioButton
:model-value="main.containerType"
name="carrier-view-container"
value="BENNE"
:label="t('transport.carriers.containerType.BENNE')"
readonly
group-class="mt-0"
/>
<MalioRadioButton
:model-value="main.containerType"
name="carrier-view-container"
value="FOND_MOUVANT"
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
readonly
group-class="mt-0"
/>
</div>
</div>
<MalioInputText :model-value="main.volumeM3" :label="t('transport.carriers.form.main.volumeM3')" readonly />
</template>
</template>
</div>
<!-- ── Onglets (Adresses · Contacts · Prix) — ouvre sur Adresses ──── -->
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<template #addresses>
<div class="mt-12 flex flex-col gap-6">
<!-- Adresse UNIQUE (ERP-172). -->
<CarrierAddressBlock
:model-value="address"
:country-options="countryOptionsFor(address.country)"
readonly
/>
</div>
</template>
<template #contacts>
<div class="mt-12 flex flex-col gap-6">
<CarrierContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
readonly
/>
</div>
</template>
<!-- Prix : tableau présentationnel regroupé par contenant + export. -->
<template #prices>
<div class="mt-12 flex flex-col gap-6">
<!-- Police / bordures / radius alignés sur MalioDataTable (header
16px, corps 14px). 1re colonne « Contenant » : libellé du
groupe (Fond Mouvant / Benne) fusionné en rowspan ; séparateur
épais entre les deux groupes. -->
<table class="w-full table-fixed border-separate border-spacing-0 overflow-hidden rounded-malio border border-black text-left text-black">
<!-- Répartition (table-fixed) : « Type de transport » un peu plus
large ; Transporteurs et Adresse livraisons larges ; Forfait /
Tonne / Indexation / État réduits. -->
<colgroup>
<col class="w-[170px]" />
<col class="w-[20%]" />
<col class="w-[11%]" />
<col class="w-[24%]" />
<col class="w-[9%]" />
<col class="w-[9%]" />
<col class="w-[9%]" />
<col class="w-[9%]" />
</colgroup>
<thead>
<tr>
<!-- En-tête centré pour matcher les cellules fusionnées Benne / Fond mouvant. -->
<th class="border-b border-r border-black bg-m-surface px-3 py-3 text-center align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.group') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.carrier') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.aproOrSite') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.delivery') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.forfait') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.tonne') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.indexation') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.state') }}</th>
</tr>
</thead>
<tbody>
<template v-for="(group, gi) in priceGroups" :key="group.label">
<tr
v-for="(row, i) in group.rows"
:key="`${gi}-${i}`"
>
<!-- Cellule de groupe fusionnée (rowspan), centrée verticalement ;
séparateur épais en bas entre les groupes (sauf dernier). -->
<td
v-if="i === 0"
:rowspan="group.rows.length"
class="border-r border-black px-3 text-center align-middle text-[14px] font-medium"
:class="groupBorder(gi)"
>
{{ group.label }}
</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ headerTitle }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.apro }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.delivery }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.forfait }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.tonne }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.indexation }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.state }}</td>
</tr>
</template>
<tr v-if="!hasPrices">
<td colspan="8" class="px-3 py-4 text-center text-[14px] text-m-muted">
{{ t('transport.carriers.consultation.price.empty') }}
</td>
</tr>
</tbody>
</table>
<div v-if="hasPrices" class="flex justify-center">
<MalioButton
variant="primary"
:label="t('transport.carriers.consultation.price.export')"
:disabled="exporting"
@click="exportPrices"
/>
</div>
</div>
</template>
</MalioTabList>
</template>
<!-- Modal de confirmation archivage / restauration. -->
<MalioModal v-model="confirmArchive.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ confirmArchive.title }}</h2>
</template>
<p>{{ confirmArchive.message }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('transport.carriers.form.confirmDelete.cancel')"
@click="confirmArchive.open = false"
/>
<MalioButton
variant="danger"
button-class="flex-1"
:label="confirmArchive.confirmLabel"
@click="runToggleArchive"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
import CarrierContactBlock from '~/modules/transport/components/CarrierContactBlock.vue'
import { useCarrier } from '~/modules/transport/composables/useCarrier'
import {
canEditCarrier,
labelOfRelation,
mapAddressToDraft,
mapContactToDraft,
mapMainToDraft,
showArchiveAction,
showRestoreAction,
type CarrierPriceRead,
type Relation,
} from '~/modules/transport/utils/forms/carrierMappers'
import { extractApiErrorMessage } from '~/shared/utils/api'
interface SelectOption {
value: string
label: string
}
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const api = useApi()
const { can } = usePermissions()
const carrierId = route.params.id as string
const { carrier, loading, error, load, archive, restore } = useCarrier(carrierId)
const isArchived = computed(() => carrier.value?.isArchived ?? false)
const canEdit = computed(() => canEditCarrier(can))
const showArchive = computed(() => showArchiveAction(can, isArchived.value))
const showRestore = computed(() => showRestoreAction(can, isArchived.value))
const headerTitle = computed(() => carrier.value?.name || t('transport.carriers.consultation.title'))
useHead({ title: t('transport.carriers.consultation.title') })
// ── Bloc principal mappé (lecture seule) ─────────────────────────────────────
const main = computed(() => mapMainToDraft(carrier.value ?? { id: 0, '@id': '' }))
const isLiot = computed(() => main.value.name.trim().toUpperCase() === 'LIOT')
const certificationLabel = computed(() => main.value.certificationType
? t(`transport.carriers.certification.${main.value.certificationType}`)
: '')
// Indexation affichée avec le « % » (comme l'icône du champ amount de l'ajout).
const indexationDisplay = computed(() => main.value.indexationRate ? `${main.value.indexationRate} %` : '')
// Décharge : nom du fichier embarqué si présent (sinon vide ; la colonne reste réservée).
const dischargeLabel = computed(() => {
const doc = carrier.value?.dischargeDocument
if (doc && typeof doc !== 'string') {
const meta = doc as Record<string, unknown>
return String(meta.originalFilename ?? meta.name ?? '')
}
return ''
})
// ── Onglets : Adresses · Contacts · Prix (ouvre sur Adresses, pas de Qualimat) ──
const activeTab = ref('addresses')
const TAB_ICONS: Record<string, string> = {
addresses: 'mdi:map-marker-outline',
contacts: 'mdi:account-box-plus-outline',
prices: 'mdi:payment',
}
const tabs = computed(() => ['addresses', 'contacts', 'prices'].map(key => ({
key,
label: t(`transport.carriers.tab.${key}`),
icon: TAB_ICONS[key],
})))
// Adresse UNIQUE (ERP-172) : un seul bloc en lecture seule (vide si pas d'adresse).
const address = computed(() => carrier.value?.address
? mapAddressToDraft(carrier.value.address)
: mapAddressToDraft({ id: 0, '@id': '' }))
const contacts = computed(() => {
const list = (carrier.value?.contacts ?? []).map(mapContactToDraft)
return list.length > 0 ? list : [mapContactToDraft({ id: 0, '@id': '' })]
})
/** Pays : une seule option (valeur courante), suffisant pour l'affichage readonly. */
function countryOptionsFor(country: string): SelectOption[] {
return country ? [{ value: country, label: country }] : []
}
// ── Tableau Prix consultation (regroupé par contenant Fond Mouvant / Benne) ───
const PRICE_GROUP_ORDER = ['FOND_MOUVANT', 'BENNE'] as const
interface PriceRowView {
apro: string
delivery: string
forfait: string
tonne: string
indexation: string
state: string
}
/** 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 })} €`
}
/** Code du site = département (2 premiers chiffres du code postal, ex: 86 / 17 / 82). */
function siteCode(relation: Relation): string {
if (!relation || typeof relation === 'string') {
return ''
}
const postalCode = relation.postalCode as string | undefined
return postalCode ? postalCode.slice(0, 2) : ''
}
/**
* Construit une ligne d'affichage depuis un prix embarqué (maquette Prix) :
* - « Adresse sites » = le CODE du site (département, ex: 86 / 17 / 82) ;
* - « Adresse livraisons » = l'adresse (voie) du client/fournisseur ;
* - le prix tombe dans Forfait € OU Tonne € selon `pricingUnit`.
*/
function toPriceRow(price: CarrierPriceRead): PriceRowView {
const isClient = price.direction === 'CLIENT'
return {
apro: isClient ? siteCode(price.departureSite) : siteCode(price.deliverySite),
delivery: isClient ? labelOfRelation(price.clientDeliveryAddress) : labelOfRelation(price.supplierSupplyAddress),
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)}`) : '',
}
}
/** EN_COURS → EnCours, VALIDE → Valide, NON_VALIDE → NonValide (clés i18n existantes). */
function stateSuffix(state: string): string {
const map: Record<string, string> = { EN_COURS: 'EnCours', VALIDE: 'Valide', NON_VALIDE: 'NonValide' }
return map[state] ?? ''
}
// 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 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)
/**
* Bordure basse d'une cellule de données :
* - ligne interne d'un groupe → fine grise ;
* - dernière ligne d'un groupe NON final → épaisse noire (séparateur de groupe) ;
* - dernière ligne du DERNIER groupe → aucune (le cadre du tableau s'en charge,
* évite la double bordure tout en bas).
*/
function dataBorder(group: PriceGroupView, i: number, gi: number): string {
const isLastRow = i === group.rows.length - 1
const isLastGroup = gi === priceGroups.value.length - 1
if (!isLastRow) {
return 'border-b border-m-muted/30'
}
return isLastGroup ? '' : 'border-b-2 border-black'
}
/** Bordure basse de la cellule de groupe fusionnée (séparateur épais sauf dernier groupe). */
function groupBorder(gi: number): string {
return gi === priceGroups.value.length - 1 ? '' : 'border-b-2 border-black'
}
// ── Export XLSX des prix ─────────────────────────────────────────────────────
const exporting = ref(false)
async function exportPrices(): Promise<void> {
if (exporting.value) return
exporting.value = true
try {
const blob = await api.get<Blob>(`/carriers/${carrierId}/prices/export.xlsx`, {}, {
responseType: 'blob',
toast: false,
} as unknown as Parameters<typeof api.get>[2])
triggerDownload(blob, `transporteur-${carrierId}-prix.xlsx`)
}
catch {
toast.error({ title: t('transport.carriers.toast.error'), message: t('transport.carriers.toast.exportError') })
}
finally {
exporting.value = false
}
}
function triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
// ── Navigation / archivage ───────────────────────────────────────────────────
function goBack(): void {
router.push('/carriers')
}
function goEdit(): void {
router.push(`/carriers/${carrierId}/edit`)
}
const confirmArchive = reactive({ open: false, title: '', message: '', confirmLabel: '' })
function askToggleArchive(): void {
const archiving = !isArchived.value
confirmArchive.title = archiving ? t('transport.carriers.action.archive') : t('transport.carriers.action.restore')
confirmArchive.message = archiving
? t('transport.carriers.consultation.confirmArchive.message')
: t('transport.carriers.consultation.confirmRestore.message')
confirmArchive.confirmLabel = archiving ? t('transport.carriers.action.archive') : t('transport.carriers.action.restore')
confirmArchive.open = true
}
async function runToggleArchive(): Promise<void> {
const archiving = !isArchived.value
confirmArchive.open = false
try {
await (archiving ? archive() : restore())
toast.success({
title: archiving
? t('transport.carriers.toast.archiveSuccess')
: t('transport.carriers.toast.restoreSuccess'),
})
}
catch (err) {
// Surface le message back (ex. 409 « homonyme actif » à la restauration),
// propagé exprès par useCarrier ; fallback générique sinon.
const data = (err as { response?: { _data?: unknown } })?.response?._data
toast.error({
title: t('transport.carriers.toast.error'),
message: extractApiErrorMessage(data) || undefined,
})
}
}
onMounted(load)
</script>