390 lines
15 KiB
Vue
390 lines
15 KiB
Vue
<template>
|
|
<div>
|
|
<PageHeader>
|
|
{{ t('transport.carriers.title') }}
|
|
<template #actions>
|
|
<!-- gap-8 = 32px d'espacement entre Filtrer et Ajouter. -->
|
|
<div class="flex items-center gap-8">
|
|
<!-- Bouton Filtrer a GAUCHE d'Ajouter. Le compteur reflete les filtres actifs. -->
|
|
<MalioButton
|
|
v-if="canView"
|
|
variant="tertiary"
|
|
:label="filterButtonLabel"
|
|
icon-name="mdi:tune"
|
|
icon-position="left"
|
|
icon-size="24"
|
|
@click="openFilters"
|
|
/>
|
|
<MalioButton
|
|
v-if="canManage"
|
|
variant="secondary"
|
|
:label="t('transport.carriers.add')"
|
|
icon-name="mdi:add-bold"
|
|
icon-position="left"
|
|
@click="goToCreate"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</PageHeader>
|
|
|
|
<!-- Datatable branchee sur usePaginatedList via useCarriersRepository :
|
|
pagination serveur, tri name ASC par defaut (cote back). -->
|
|
<MalioDataTable
|
|
:columns="columns"
|
|
:items="rows"
|
|
:total-items="totalItems"
|
|
:page="currentPage"
|
|
:per-page="itemsPerPage"
|
|
:per-page-options="itemsPerPageOptions"
|
|
row-clickable
|
|
:empty-message="t('transport.carriers.empty')"
|
|
@row-click="onRowClick"
|
|
@update:page="goToPage"
|
|
@update:per-page="setItemsPerPage"
|
|
>
|
|
<!-- Certification : libelle i18n (le back renvoie le code enum). -->
|
|
<template #cell-certificationType="{ item }">
|
|
{{ formatCertification(item) }}
|
|
</template>
|
|
|
|
<!-- Date de validite QUALIMAT : fond rouge si perimee (< aujourd'hui — RG-4.04). -->
|
|
<template #cell-validityDate="{ item }">
|
|
<span
|
|
v-if="getValidityDate(item)"
|
|
:class="isValidityExpired(item) ? 'inline-block rounded px-2 py-0.5 bg-m-danger text-white' : ''"
|
|
>
|
|
{{ formatDateFr(getValidityDate(item)) }}
|
|
</span>
|
|
</template>
|
|
|
|
<!-- Derniere activite : date de derniere modification (updatedAt). -->
|
|
<template #cell-lastActivity="{ item }">
|
|
{{ formatDateFr(item.updatedAt as string | null) }}
|
|
</template>
|
|
</MalioDataTable>
|
|
|
|
<div class="flex justify-center mt-4">
|
|
<MalioButton
|
|
v-if="canView"
|
|
variant="primary"
|
|
:label="t('transport.carriers.export')"
|
|
:disabled="exporting"
|
|
@click="exportXlsx"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
|
|
« Voir les résultats ». Meme pattern que les repertoires M1/M2/M3.
|
|
Etat 100 % local, jamais dans l'URL (regle ABSOLUE n°6). -->
|
|
<MalioDrawer
|
|
v-model="filterDrawerOpen"
|
|
drawer-class="max-w-[450px]"
|
|
body-class="p-0"
|
|
footer-class="justify-between border-t border-black p-6"
|
|
>
|
|
<template #header>
|
|
<h2 class="text-[24px] font-bold uppercase">{{ t('transport.carriers.filters.title') }}</h2>
|
|
</template>
|
|
|
|
<MalioAccordion>
|
|
<!-- Recherche : nom du transporteur (param `search`). -->
|
|
<MalioAccordionItem :title="t('transport.carriers.filters.search')" value="search">
|
|
<MalioInputText
|
|
v-model="draftSearch"
|
|
icon-name="mdi:magnify"
|
|
/>
|
|
</MalioAccordionItem>
|
|
|
|
<!-- Certification : cases a cocher (multi). Valeur = code enum.
|
|
Meme pattern que le filtre Categories du repertoire clients. -->
|
|
<MalioAccordionItem :title="t('transport.carriers.filters.certification')" value="certification">
|
|
<div class="flex flex-col">
|
|
<MalioCheckbox
|
|
v-for="opt in certificationOptions"
|
|
:id="`filter-certification-${opt.value}`"
|
|
:key="opt.value"
|
|
:label="opt.label"
|
|
:model-value="draftCertificationTypes.includes(opt.value)"
|
|
@update:model-value="(val: boolean) => toggleCertification(opt.value, val)"
|
|
/>
|
|
</div>
|
|
</MalioAccordionItem>
|
|
|
|
<!-- Statut : voir uniquement les archives (sinon actifs uniquement). -->
|
|
<MalioAccordionItem :title="t('transport.carriers.filters.status')" value="status">
|
|
<MalioCheckbox
|
|
id="filter-archived-only"
|
|
:label="t('transport.carriers.filters.archivedOnly')"
|
|
:model-value="draftArchivedOnly"
|
|
@update:model-value="(val: boolean) => draftArchivedOnly = val"
|
|
/>
|
|
</MalioAccordionItem>
|
|
</MalioAccordion>
|
|
|
|
<template #footer>
|
|
<MalioButton
|
|
variant="tertiary"
|
|
:label="t('transport.carriers.filters.reset')"
|
|
button-class="w-m-btn-action"
|
|
@click="resetFilters"
|
|
/>
|
|
<MalioButton
|
|
variant="primary"
|
|
:label="t('transport.carriers.filters.apply')"
|
|
button-class="w-[170px]"
|
|
@click="applyFilters"
|
|
/>
|
|
</template>
|
|
</MalioDrawer>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, ref } from 'vue'
|
|
|
|
interface FilterOption {
|
|
value: string
|
|
label: string
|
|
}
|
|
|
|
const { t } = useI18n()
|
|
const api = useApi()
|
|
const router = useRouter()
|
|
const toast = useToast()
|
|
const { can } = usePermissions()
|
|
|
|
useHead({ title: t('transport.carriers.title') })
|
|
|
|
// Bouton « Ajouter » reserve a `manage` (Admin + Bureau). « Exporter » et
|
|
// « Filtrer » suivent `view` (Admin / Bureau / Commerciale). Compta et Usine
|
|
// n'ont aucun acces (item sidebar masque cote back).
|
|
const canManage = computed(() => can('transport.carriers.manage'))
|
|
const canView = computed(() => can('transport.carriers.view'))
|
|
|
|
const {
|
|
items: carriers,
|
|
totalItems,
|
|
currentPage,
|
|
itemsPerPage,
|
|
itemsPerPageOptions,
|
|
fetch: loadCarriers,
|
|
goToPage,
|
|
setItemsPerPage,
|
|
setFilters,
|
|
} = useCarriersRepository()
|
|
|
|
// Mappe les transporteurs en objets « plats » pour MalioDataTable (items typees
|
|
// Record<string, unknown>[]) : un objet litteral porte une signature d'index
|
|
// implicite, contrairement a l'interface Carrier. Meme pattern que M1/M2/M3.
|
|
const rows = computed(() => carriers.value.map(carrier => ({
|
|
id: carrier.id,
|
|
name: carrier.name,
|
|
certificationType: carrier.certificationType,
|
|
validityDate: carrier.qualimatCarrier?.validityDate ?? null,
|
|
updatedAt: carrier.updatedAt,
|
|
})))
|
|
|
|
const columns = [
|
|
{ key: 'name', label: t('transport.carriers.column.name') },
|
|
{ key: 'certificationType', label: t('transport.carriers.column.certification') },
|
|
{ key: 'validityDate', label: t('transport.carriers.column.validityDate') },
|
|
{ key: 'lastActivity', label: t('transport.carriers.column.lastActivity') },
|
|
]
|
|
|
|
// Codes de certification (miroir de l'enum back) + cas LIOT (null). Le libelle
|
|
// est resolu par i18n ; un code inconnu retombe sur le code brut.
|
|
const CERTIFICATION_CODES = ['QUALIMAT', 'GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE'] as const
|
|
|
|
const certificationOptions = computed<FilterOption[]>(() =>
|
|
CERTIFICATION_CODES.map(code => ({
|
|
value: code,
|
|
label: t(`transport.carriers.certification.${code}`),
|
|
})),
|
|
)
|
|
|
|
/** Libelle i18n de la certification (vide en cas LIOT — certificationType null). */
|
|
function formatCertification(item: Record<string, unknown>): string {
|
|
const code = item.certificationType as string | null | undefined
|
|
if (!code) {
|
|
return ''
|
|
}
|
|
return t(`transport.carriers.certification.${code}`)
|
|
}
|
|
|
|
/** Date de validite QUALIMAT de la ligne (null si transporteur non QUALIMAT). */
|
|
function getValidityDate(item: Record<string, unknown>): string | null {
|
|
return (item.validityDate as string | null | undefined) ?? null
|
|
}
|
|
|
|
/**
|
|
* RG-4.04 : un agrement QUALIMAT est perime si sa date de validite est anterieure
|
|
* a la date du jour (comparaison jour a jour, sans l'heure).
|
|
*/
|
|
function isValidityExpired(item: Record<string, unknown>): boolean {
|
|
const value = getValidityDate(item)
|
|
if (!value) {
|
|
return false
|
|
}
|
|
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 (spec M4). 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 → ecran Consultation (route a plat /carriers/{id}). */
|
|
function onRowClick(item: Record<string, unknown>): void {
|
|
router.push(`/carriers/${item.id}`)
|
|
}
|
|
|
|
function goToCreate(): void {
|
|
router.push('/carriers/new')
|
|
}
|
|
|
|
// ── Filtres (drawer) ────────────────────────────────────────────────────────
|
|
// Deux niveaux d'etat (pattern repertoires M1/M2/M3) :
|
|
// - APPLIED : pilote la liste/l'export + le compteur du bouton. Modifie
|
|
// uniquement au clic « Voir les résultats » / « Réinitialiser ».
|
|
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
|
|
const filterDrawerOpen = ref(false)
|
|
|
|
const draftSearch = ref('')
|
|
const draftCertificationTypes = ref<string[]>([])
|
|
const draftArchivedOnly = ref(false)
|
|
|
|
const appliedSearch = ref('')
|
|
const appliedCertificationTypes = ref<string[]>([])
|
|
const appliedArchivedOnly = ref(false)
|
|
|
|
const activeFilterCount = computed(() => {
|
|
let count = 0
|
|
if (appliedSearch.value.trim() !== '') count++
|
|
if (appliedCertificationTypes.value.length > 0) count++
|
|
if (appliedArchivedOnly.value) count++
|
|
return count
|
|
})
|
|
|
|
const filterButtonLabel = computed(() => {
|
|
const base = t('transport.carriers.filters.title')
|
|
return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base
|
|
})
|
|
|
|
// Recopie l'etat applique vers le brouillon puis ouvre le drawer : la reouverture
|
|
// reflete les filtres actifs.
|
|
function openFilters(): void {
|
|
draftSearch.value = appliedSearch.value
|
|
draftCertificationTypes.value = [...appliedCertificationTypes.value]
|
|
draftArchivedOnly.value = appliedArchivedOnly.value
|
|
filterDrawerOpen.value = true
|
|
}
|
|
|
|
/** Coche / decoche une certification dans le brouillon (filtre multi). */
|
|
function toggleCertification(code: string, selected: boolean): void {
|
|
draftCertificationTypes.value = selected
|
|
? [...draftCertificationTypes.value, code]
|
|
: draftCertificationTypes.value.filter(c => c !== code)
|
|
}
|
|
|
|
/**
|
|
* Construit le payload de filtres serveur a partir de l'etat applique. Cle
|
|
* `certificationType[]` pour que PHP la parse en tableau (OR cote back). Les
|
|
* filtres vides sont omis pour une query propre.
|
|
*/
|
|
function buildFilterPayload(): Record<string, string | string[] | boolean> {
|
|
const payload: Record<string, string | string[] | boolean> = {}
|
|
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
|
|
if (appliedCertificationTypes.value.length > 0) payload['certificationType[]'] = [...appliedCertificationTypes.value]
|
|
if (appliedArchivedOnly.value) payload.archivedOnly = true
|
|
return payload
|
|
}
|
|
|
|
// « Voir les résultats » : recopie brouillon → applied, pousse les filtres
|
|
// (retombe en page 1 via usePaginatedList) et ferme le drawer.
|
|
function applyFilters(): void {
|
|
appliedSearch.value = draftSearch.value.trim()
|
|
appliedCertificationTypes.value = [...draftCertificationTypes.value]
|
|
appliedArchivedOnly.value = draftArchivedOnly.value
|
|
|
|
setFilters(buildFilterPayload(), { replace: true })
|
|
filterDrawerOpen.value = false
|
|
}
|
|
|
|
// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete.
|
|
// Le drawer reste ouvert pour montrer le formulaire vide.
|
|
function resetFilters(): void {
|
|
draftSearch.value = ''
|
|
draftCertificationTypes.value = []
|
|
draftArchivedOnly.value = false
|
|
|
|
appliedSearch.value = ''
|
|
appliedCertificationTypes.value = []
|
|
appliedArchivedOnly.value = false
|
|
|
|
setFilters({}, { replace: true })
|
|
}
|
|
|
|
// ── Export XLSX ─────────────────────────────────────────────────────────────
|
|
// Memes filtres que la vue : l'export reflete exactement ce que l'utilisateur voit.
|
|
const exporting = ref(false)
|
|
|
|
async function exportXlsx(): Promise<void> {
|
|
if (exporting.value) {
|
|
return
|
|
}
|
|
exporting.value = true
|
|
try {
|
|
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
|
|
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
|
|
// contenu faute d'overload blob sur le client partage (meme pattern M2/M3).
|
|
const blob = await api.get<Blob>('/carriers/export.xlsx', buildFilterPayload(), {
|
|
responseType: 'blob',
|
|
toast: false,
|
|
} as unknown as Parameters<typeof api.get>[2])
|
|
|
|
triggerDownload(blob, 'repertoire-transporteurs.xlsx')
|
|
}
|
|
catch {
|
|
toast.error({
|
|
title: t('transport.carriers.toast.error'),
|
|
message: t('transport.carriers.toast.exportError'),
|
|
})
|
|
}
|
|
finally {
|
|
exporting.value = false
|
|
}
|
|
}
|
|
|
|
/** Declenche le telechargement d'un blob via un lien temporaire. */
|
|
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)
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadCarriers()
|
|
})
|
|
</script>
|