425 lines
18 KiB
Vue
425 lines
18 KiB
Vue
<template>
|
|
<div>
|
|
<!-- En-tete : retour vers le repertoire + titre. -->
|
|
<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.form.back') }"
|
|
@click="goBack"
|
|
/>
|
|
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('transport.carriers.form.title') }}</h1>
|
|
</div>
|
|
|
|
<!-- ── Formulaire principal (pre-onglets) ─────────────────────────────
|
|
Champs conditionnels (ERP-166) : cas LIOT (nom == LIOT) → seul
|
|
« immatriculations » ; certification AUTRE → champ Decharge ; Affreter
|
|
coche → indexation / contenant / volume. La certification est en lecture
|
|
seule pour un transporteur QUALIMAT (saisie assistee, onglet Qualimat). -->
|
|
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
|
<MalioInputText
|
|
v-model="main.name"
|
|
:label="t('transport.carriers.form.main.name')"
|
|
:required="true"
|
|
:readonly="mainLocked"
|
|
:error="mainErrors.errors.name"
|
|
/>
|
|
|
|
<!-- Cas LIOT : seul le champ immatriculations est pertinent. -->
|
|
<MalioInputText
|
|
v-if="isLiot"
|
|
v-model="main.liotPlates"
|
|
:label="t('transport.carriers.form.main.liotPlates')"
|
|
:hint="t('transport.carriers.form.main.liotPlatesHint')"
|
|
:required="true"
|
|
:readonly="mainLocked"
|
|
:error="mainErrors.errors.liotPlates"
|
|
/>
|
|
|
|
<!-- Cas standard : certification + affretement + champs conditionnels. -->
|
|
<template v-if="!isLiot">
|
|
<MalioSelect
|
|
:model-value="main.certificationType"
|
|
:options="certificationOptions"
|
|
:label="t('transport.carriers.form.main.certificationType')"
|
|
empty-option-label=""
|
|
:required="true"
|
|
:readonly="certificationReadonly"
|
|
:error="mainErrors.errors.certificationType"
|
|
@update:model-value="(v: string | number | null) => main.certificationType = v === null ? null : String(v)"
|
|
/>
|
|
|
|
<!-- Colonne 3 RÉSERVÉE à la Décharge (RG-4.02 : visible et obligatoire
|
|
si certification AUTRE). Si elle n'apparaît pas, on garde la colonne
|
|
vide (xl) pour qu'« Affréter » reste en colonne 4 de la ligne 1.
|
|
L'upload reel (File → IRI via useUpload) arrive a ERP-171. -->
|
|
<!-- TODO ERP-171 : brancher useUpload pour resoudre le File en IRI
|
|
(main.dischargeDocumentIri). Le champ est deja visible/obligatoire. -->
|
|
<MalioInputUpload
|
|
v-if="showDischarge"
|
|
:label="t('transport.carriers.form.main.discharge')"
|
|
accept="application/pdf,image/*"
|
|
:required="true"
|
|
:readonly="mainLocked"
|
|
:clearable="true"
|
|
:error="mainErrors.errors.dischargeDocument"
|
|
@clear="main.dischargeDocumentIri = null"
|
|
/>
|
|
<div v-else class="hidden xl:block"></div>
|
|
|
|
<!-- « Affréter » : toujours en colonne 4 de la ligne 1 (colonne 3
|
|
réservée à la décharge ci-dessus). Wrapper h-12 + centrage vertical
|
|
pour aligner la case sur la ligne de champ des inputs/selects. -->
|
|
<div class="flex h-12 items-center">
|
|
<MalioCheckbox
|
|
id="carrier-is-chartered"
|
|
:label="t('transport.carriers.form.main.isChartered')"
|
|
:model-value="main.isChartered"
|
|
:readonly="mainLocked"
|
|
:reserve-message-space="false"
|
|
@update:model-value="(val: boolean) => main.isChartered = val"
|
|
/>
|
|
</div>
|
|
|
|
<!-- RG-4.03 : champs d'affretement (ligne 2) visibles + obligatoires si
|
|
« Affreter ». La ligne 1 étant pleine (4 colonnes), ils démarrent
|
|
naturellement en colonne 1 de la ligne 2. -->
|
|
<template v-if="showCharteredFields">
|
|
<MalioInputNumber
|
|
v-model="main.indexationRate"
|
|
:label="t('transport.carriers.form.main.indexationRate')"
|
|
:required="true"
|
|
:readonly="mainLocked"
|
|
:error="mainErrors.errors.indexationRate"
|
|
/>
|
|
|
|
<!-- Contenant : Benne / Fond mouvant (RG-4.03). -->
|
|
<MalioSelect
|
|
:model-value="main.containerType"
|
|
:options="containerOptions"
|
|
:label="t('transport.carriers.form.main.containerType')"
|
|
empty-option-label=""
|
|
:required="true"
|
|
:readonly="mainLocked"
|
|
:error="mainErrors.errors.containerType"
|
|
@update:model-value="(v: string | number | null) => main.containerType = v === null ? null : String(v)"
|
|
/>
|
|
|
|
<MalioInputNumber
|
|
v-model="main.volumeM3"
|
|
:label="t('transport.carriers.form.main.volumeM3')"
|
|
:required="true"
|
|
:readonly="mainLocked"
|
|
:error="mainErrors.errors.volumeM3"
|
|
/>
|
|
</template>
|
|
</template>
|
|
</div>
|
|
|
|
<div v-if="!mainLocked" class="mt-12 flex justify-center">
|
|
<MalioButton
|
|
variant="primary"
|
|
:label="t('transport.carriers.form.submit')"
|
|
:disabled="mainSubmitting"
|
|
@click="onSubmitMain"
|
|
/>
|
|
</div>
|
|
|
|
<!-- ── Onglets a validation incrementale ─────────────────────────────
|
|
Barre Qualimat · Adresses · Contacts · Prix. Onglet Qualimat = saisie
|
|
assistee (table de selection) ; Adresses / Contacts / Prix arrivent aux
|
|
tickets suivants (placeholders « A venir »). -->
|
|
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
|
<!-- Onglet Qualimat : datatable paginé filtré par le NOM du transporteur
|
|
(pas de champ de recherche dédié — RG-4.01 / 4.04). -->
|
|
<template #qualimat>
|
|
<div class="mt-12 flex flex-col gap-6">
|
|
<MalioDataTable
|
|
: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>
|
|
|
|
<!-- Adresses / Contacts / Prix : contenu aux tickets suivants. -->
|
|
<template
|
|
v-for="key in placeholderTabs"
|
|
:key="key"
|
|
#[key]
|
|
>
|
|
<div class="mt-12 flex justify-center text-m-muted">
|
|
{{ t('transport.carriers.form.comingSoon') }}
|
|
</div>
|
|
</template>
|
|
</MalioTabList>
|
|
|
|
<!-- Modal de confirmation d'integration QUALIMAT (RG-4.01). -->
|
|
<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>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref, watch } from 'vue'
|
|
import { debounce } from '~/shared/utils/debounce'
|
|
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
|
|
import { useQualimatSearch, type QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
|
|
|
interface SelectOption {
|
|
value: string
|
|
label: string
|
|
}
|
|
|
|
const { t } = useI18n()
|
|
const router = useRouter()
|
|
const toast = useToast()
|
|
const { can } = usePermissions()
|
|
|
|
useHead({ title: t('transport.carriers.form.title') })
|
|
|
|
// Gating de la route : la creation est reservee a `manage` (Admin / Bureau).
|
|
// Commerciale (consultation seule), Compta et Usine sont rediriges vers le repertoire.
|
|
if (!can('transport.carriers.manage')) {
|
|
await navigateTo('/carriers')
|
|
}
|
|
|
|
const {
|
|
main,
|
|
mainLocked,
|
|
mainSubmitting,
|
|
mainErrors,
|
|
isLiot,
|
|
certificationReadonly,
|
|
showCharteredFields,
|
|
showDischarge,
|
|
tabKeys,
|
|
activeTab,
|
|
unlockedIndex,
|
|
submitMain,
|
|
applyQualimatSelection,
|
|
} = useCarrierForm()
|
|
|
|
const {
|
|
items: qualimatItems,
|
|
totalItems: qualimatTotal,
|
|
currentPage: qualimatPage,
|
|
itemsPerPage: qualimatPerPage,
|
|
itemsPerPageOptions: qualimatPerPageOptions,
|
|
goToPage: qualimatGoToPage,
|
|
setItemsPerPage: qualimatSetPerPage,
|
|
setFilters: qualimatSetFilters,
|
|
} = useQualimatSearch()
|
|
|
|
// Certifications selectionnables manuellement (spec § Formulaire principal) :
|
|
// 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 certificationOptions = computed<SelectOption[]>(() => {
|
|
const codes: string[] = [...SELECTABLE_CERTIFICATIONS]
|
|
if (main.certificationType === 'QUALIMAT') {
|
|
codes.unshift('QUALIMAT')
|
|
}
|
|
return codes.map(code => ({
|
|
value: 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') },
|
|
]
|
|
|
|
// 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'))
|
|
|
|
// Contenant (RG-4.03) : Benne / Fond mouvant — select simple.
|
|
const CONTAINER_TYPES = ['BENNE', 'FOND_MOUVANT'] as const
|
|
|
|
const containerOptions = computed<SelectOption[]>(() =>
|
|
CONTAINER_TYPES.map(code => ({
|
|
value: code,
|
|
label: t(`transport.carriers.containerType.${code}`),
|
|
})),
|
|
)
|
|
|
|
// Icone (Iconify) affichee dans chaque onglet, par cle.
|
|
const TAB_ICONS: Record<string, string> = {
|
|
qualimat: 'mdi:truck-check-outline',
|
|
addresses: 'mdi:map-marker-outline',
|
|
contacts: 'mdi:account-box-plus-outline',
|
|
prices: 'mdi:currency-eur',
|
|
}
|
|
|
|
// 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,
|
|
})))
|
|
|
|
// Onglets dont le contenu arrive aux tickets suivants (tout sauf Qualimat).
|
|
const placeholderTabs = computed(() => tabKeys.value.filter(key => key !== 'qualimat'))
|
|
|
|
// ── Saisie assistee QUALIMAT (onglet Qualimat) ───────────────────────────────
|
|
const confirmOpen = ref(false)
|
|
const pendingRow = ref<QualimatCarrierRow | null>(null)
|
|
|
|
// 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)
|
|
if (ok) {
|
|
toast.success({ title: t('transport.carriers.toast.integrateSuccess') })
|
|
}
|
|
}
|
|
|
|
/** Retour vers le repertoire transporteurs (fleche d'en-tete). */
|
|
function goBack(): void {
|
|
router.push('/carriers')
|
|
}
|
|
|
|
/** Valide le formulaire principal (POST /carriers ; bascule geree par le composable). */
|
|
async function onSubmitMain(): Promise<void> {
|
|
await submitMain()
|
|
}
|
|
</script>
|