Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 79d389834b |
@@ -49,6 +49,32 @@
|
|||||||
"commercial": {
|
"commercial": {
|
||||||
"title": "Commercial",
|
"title": "Commercial",
|
||||||
"welcome": "Module Commercial",
|
"welcome": "Module Commercial",
|
||||||
|
"suppliers": {
|
||||||
|
"title": "Répertoire fournisseurs",
|
||||||
|
"add": "Ajouter",
|
||||||
|
"export": "Exporter",
|
||||||
|
"empty": "Aucun fournisseur pour l'instant.",
|
||||||
|
"column": {
|
||||||
|
"companyName": "Nom",
|
||||||
|
"categories": "Catégories",
|
||||||
|
"sites": "Site",
|
||||||
|
"lastActivity": "Dernière activité"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"title": "Filtres",
|
||||||
|
"search": "Recherche",
|
||||||
|
"categories": "Catégories",
|
||||||
|
"sites": "Sites",
|
||||||
|
"status": "Statut",
|
||||||
|
"includeArchived": "Inclure les archivés",
|
||||||
|
"apply": "Voir les résultats",
|
||||||
|
"reset": "Réinitialiser"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"error": "Une erreur est survenue. Réessayez.",
|
||||||
|
"exportError": "L'export du répertoire fournisseurs a échoué. Réessayez."
|
||||||
|
}
|
||||||
|
},
|
||||||
"clients": {
|
"clients": {
|
||||||
"title": "Répertoire clients",
|
"title": "Répertoire clients",
|
||||||
"add": "Ajouter",
|
"add": "Ajouter",
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import type { HydraCollection } from '~/shared/utils/api'
|
||||||
|
import type { Supplier } from '../useSuppliersRepository'
|
||||||
|
|
||||||
|
// `useApi` est un auto-import Nuxt : on le stubbe globalement pour intercepter
|
||||||
|
// les appels declenches par usePaginatedList (que useSuppliersRepository enveloppe)
|
||||||
|
// et controler les reponses. Meme pattern que useClientsRepository.spec.ts.
|
||||||
|
const mockGet = vi.hoisted(() => vi.fn())
|
||||||
|
vi.stubGlobal('useApi', () => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
patch: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Import APRES le stub pour que useApi soit bien resolu au top-level du module.
|
||||||
|
const { useSuppliersRepository } = await import('../useSuppliersRepository')
|
||||||
|
|
||||||
|
/** Envelope Hydra minimale (la liste reelle des membres importe peu ici). */
|
||||||
|
function makeHydra(total: number): HydraCollection<Supplier> {
|
||||||
|
return { totalItems: total, member: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useSuppliersRepository', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGet.mockReset()
|
||||||
|
// 25 items → 3 pages a 10/page : permet de tester la navigation page 2.
|
||||||
|
mockGet.mockResolvedValue(makeHydra(25))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cible la ressource /suppliers en page 1 par defaut', async () => {
|
||||||
|
const repo = useSuppliersRepository()
|
||||||
|
await repo.fetch()
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenLastCalledWith(
|
||||||
|
'/suppliers',
|
||||||
|
{ page: 1, itemsPerPage: 10 },
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pousse les filtres du drawer (categories multi, sites, archives inclus) et retombe en page 1', async () => {
|
||||||
|
const repo = useSuppliersRepository()
|
||||||
|
await repo.fetch()
|
||||||
|
await repo.goToPage(2)
|
||||||
|
expect(repo.currentPage.value).toBe(2)
|
||||||
|
|
||||||
|
await repo.setFilters(
|
||||||
|
{
|
||||||
|
search: 'acme',
|
||||||
|
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
|
||||||
|
'siteId[]': ['86', '17'],
|
||||||
|
includeArchived: true,
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(repo.currentPage.value).toBe(1)
|
||||||
|
expect(mockGet).toHaveBeenLastCalledWith(
|
||||||
|
'/suppliers',
|
||||||
|
{
|
||||||
|
search: 'acme',
|
||||||
|
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
|
||||||
|
'siteId[]': ['86', '17'],
|
||||||
|
includeArchived: true,
|
||||||
|
page: 1,
|
||||||
|
itemsPerPage: 10,
|
||||||
|
},
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('repasse a une query propre apres reinitialisation des filtres', async () => {
|
||||||
|
const repo = useSuppliersRepository()
|
||||||
|
await repo.setFilters({ search: 'acme', includeArchived: true }, { replace: true })
|
||||||
|
await repo.setFilters({}, { replace: true })
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenLastCalledWith(
|
||||||
|
'/suppliers',
|
||||||
|
{ page: 1, itemsPerPage: 10 },
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Site Starseed rattache a une adresse du fournisseur, tel qu'embarque en LISTE
|
||||||
|
* (groupe site:read) pour la colonne « Site » du Repertoire (badges colores).
|
||||||
|
* Agrege des adresses cote back via Supplier::getSites() (cf. spec-back M2).
|
||||||
|
*/
|
||||||
|
export interface SupplierSite {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categorie (type FOURNISSEUR) rattachee au fournisseur, embarquee en LISTE
|
||||||
|
* (groupe category:read). La colonne « Catégories » affiche le `name` (et non le
|
||||||
|
* `code` comme au M1 clients — decision spec-front M2 § Datatable).
|
||||||
|
*/
|
||||||
|
export interface SupplierCategory {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue MINIMALE d'un fournisseur pour le Repertoire (datatable). Volontairement
|
||||||
|
* partielle : seuls les champs des colonnes + l'id (navigation) sont types ici.
|
||||||
|
* Le detail complet (onglets) est hors perimetre de cet ecran (ERP-93).
|
||||||
|
*/
|
||||||
|
export interface Supplier {
|
||||||
|
id: number
|
||||||
|
companyName: string
|
||||||
|
categories: SupplierCategory[]
|
||||||
|
sites: SupplierSite[]
|
||||||
|
/** Date ISO de derniere modification (default:read) — colonne « Dernière activité ». */
|
||||||
|
updatedAt: string | null
|
||||||
|
isArchived: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repertoire fournisseurs (ERP-93) — simple enveloppe de `usePaginatedList<Supplier>`
|
||||||
|
* sur la ressource `/suppliers` (RG-13 : pagination serveur obligatoire ; jamais
|
||||||
|
* de chargement integral en memoire). Miroir de `useClientsRepository` (M1).
|
||||||
|
*
|
||||||
|
* Les filtres (recherche, categories, sites, inclusion des archives) sont pilotes
|
||||||
|
* par la page via `setFilters` du composable partage — la remise en page 1 est
|
||||||
|
* garantie.
|
||||||
|
*
|
||||||
|
* Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau
|
||||||
|
* est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de
|
||||||
|
* `usePaginatedList`. Aucun reset au logout a gerer.
|
||||||
|
*/
|
||||||
|
export function useSuppliersRepository() {
|
||||||
|
return usePaginatedList<Supplier>({ url: '/suppliers' })
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { defineComponent, h, ref } from 'vue'
|
||||||
|
|
||||||
|
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
|
||||||
|
// La page ne les importe pas (auto-import) : on les expose en globals pour le
|
||||||
|
// runtime de test (happy-dom). Meme philosophie que les autres specs commercial.
|
||||||
|
const mockPush = vi.hoisted(() => vi.fn())
|
||||||
|
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||||
|
const mockCan = vi.hoisted(() => vi.fn())
|
||||||
|
const mockSetFilters = vi.hoisted(() => vi.fn())
|
||||||
|
const mockFetch = vi.hoisted(() => vi.fn())
|
||||||
|
const mockToastError = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||||
|
vi.stubGlobal('useHead', () => undefined)
|
||||||
|
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||||
|
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||||
|
vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() }))
|
||||||
|
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
|
||||||
|
|
||||||
|
// Le repository est lui aussi un auto-import : on controle items + setFilters.
|
||||||
|
vi.stubGlobal('useSuppliersRepository', () => ({
|
||||||
|
items: ref([
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
companyName: 'ACME',
|
||||||
|
categories: [{ code: 'NEG', name: 'Négociant' }],
|
||||||
|
sites: [{ id: 86, name: '86', color: '#123456' }],
|
||||||
|
updatedAt: '2026-01-15T10:00:00+00:00',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
totalItems: ref(1),
|
||||||
|
currentPage: ref(1),
|
||||||
|
itemsPerPage: ref(10),
|
||||||
|
itemsPerPageOptions: ref([10, 25, 50]),
|
||||||
|
fetch: mockFetch,
|
||||||
|
goToPage: vi.fn(),
|
||||||
|
setItemsPerPage: vi.fn(),
|
||||||
|
setFilters: mockSetFilters,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques
|
||||||
|
// sur la classe URL existante (sans la remplacer — sinon `new URL()` casse).
|
||||||
|
globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake')
|
||||||
|
globalThis.URL.revokeObjectURL = vi.fn()
|
||||||
|
|
||||||
|
// Import APRES les stubs (la page resout les auto-imports au top-level du module).
|
||||||
|
const SuppliersIndex = (await import('../suppliers/index.vue')).default
|
||||||
|
|
||||||
|
// ── Stubs de composants ──────────────────────────────────────────────────────
|
||||||
|
const ButtonStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
|
||||||
|
emits: ['click'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const DataTableStub = defineComponent({
|
||||||
|
props: { items: { type: Array, default: () => [] } },
|
||||||
|
emits: ['row-click', 'update:page', 'update:per-page'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => h('div', { 'data-testid': 'datatable' },
|
||||||
|
(props.items as Array<{ id: number }>).map(it =>
|
||||||
|
h('tr', { 'data-row-id': it.id, onClick: () => emit('row-click', it) }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const DrawerStub = defineComponent({
|
||||||
|
props: { modelValue: { type: Boolean, default: false } },
|
||||||
|
setup(_, { slots }) {
|
||||||
|
return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const SlotStub = defineComponent({ setup(_, { slots }) { return () => h('div', {}, slots.default?.()) } })
|
||||||
|
|
||||||
|
const PageHeaderStub = defineComponent({
|
||||||
|
setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) },
|
||||||
|
})
|
||||||
|
|
||||||
|
const CheckboxStub = defineComponent({
|
||||||
|
props: { id: { type: String, default: '' }, modelValue: { type: Boolean, default: false } },
|
||||||
|
emits: ['update:model-value'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => h('input', {
|
||||||
|
'type': 'checkbox',
|
||||||
|
'data-id': props.id,
|
||||||
|
'onChange': (e: Event) => emit('update:model-value', (e.target as HTMLInputElement).checked),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const InputTextStub = defineComponent({ setup() { return () => h('input') } })
|
||||||
|
|
||||||
|
function mountPage() {
|
||||||
|
return mount(SuppliersIndex, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
PageHeader: PageHeaderStub,
|
||||||
|
MalioButton: ButtonStub,
|
||||||
|
MalioDataTable: DataTableStub,
|
||||||
|
MalioDrawer: DrawerStub,
|
||||||
|
MalioAccordion: SlotStub,
|
||||||
|
MalioAccordionItem: SlotStub,
|
||||||
|
MalioInputText: InputTextStub,
|
||||||
|
MalioCheckbox: CheckboxStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Répertoire fournisseurs (page /suppliers)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPush.mockReset()
|
||||||
|
mockApiGet.mockReset().mockResolvedValue({ member: [] })
|
||||||
|
mockCan.mockReset().mockReturnValue(true)
|
||||||
|
mockSetFilters.mockReset()
|
||||||
|
mockFetch.mockReset()
|
||||||
|
mockToastError.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('charge la liste au montage', async () => {
|
||||||
|
mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(mockFetch).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche « + Ajouter » uniquement avec la permission manage', async () => {
|
||||||
|
mockCan.mockImplementation((perm: string) => perm === 'commercial.suppliers.manage')
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-label="commercial.suppliers.add"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('masque « + Ajouter » sans la permission manage (view seul)', async () => {
|
||||||
|
mockCan.mockImplementation((perm: string) => perm === 'commercial.suppliers.view')
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-label="commercial.suppliers.add"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('navigue vers la consultation au clic sur une ligne', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.find('tr[data-row-id="7"]').trigger('click')
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/suppliers/7')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('charge les categories de type FOURNISSEUR pour le filtre', async () => {
|
||||||
|
mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(mockApiGet).toHaveBeenCalledWith(
|
||||||
|
'/categories',
|
||||||
|
expect.objectContaining({ pagination: 'false', typeCode: 'FOURNISSEUR' }),
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appelle l\'export XLSX sur /suppliers/export.xlsx en blob', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.find('[data-label="commercial.suppliers.export"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(mockApiGet).toHaveBeenCalledWith(
|
||||||
|
'/suppliers/export.xlsx',
|
||||||
|
expect.any(Object),
|
||||||
|
expect.objectContaining({ responseType: 'blob', toast: false }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('repercute le filtre « Inclure les archivés » dans setFilters sans toucher l\'URL', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// Coche « Inclure les archivés » puis applique les filtres.
|
||||||
|
await wrapper.find('input[data-id="filter-include-archived"]').setValue(true)
|
||||||
|
await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click')
|
||||||
|
|
||||||
|
expect(mockSetFilters).toHaveBeenLastCalledWith(
|
||||||
|
{ includeArchived: true },
|
||||||
|
{ replace: true },
|
||||||
|
)
|
||||||
|
// Etat 100 % local (regle n°6) : aucune navigation/query string declenchee.
|
||||||
|
expect(mockPush).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('badge filtres actifs + Réinitialiser vide l\'etat applique', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('input[data-id="filter-include-archived"]').setValue(true)
|
||||||
|
await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click')
|
||||||
|
|
||||||
|
// Le libelle du bouton Filtrer porte le compteur (1 filtre actif).
|
||||||
|
expect(wrapper.find('[data-label="commercial.suppliers.filters.title (1)"]').exists()).toBe(true)
|
||||||
|
|
||||||
|
// Réinitialiser → query propre (setFilters avec objet vide).
|
||||||
|
await wrapper.find('[data-label="commercial.suppliers.filters.reset"]').trigger('click')
|
||||||
|
expect(mockSetFilters).toHaveBeenLastCalledWith({}, { replace: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PageHeader>
|
||||||
|
{{ t('commercial.suppliers.title') }}
|
||||||
|
<template #actions>
|
||||||
|
<!-- gap-8 = 32px d'espacement entre Filtres et Ajouter. -->
|
||||||
|
<div class="flex items-center gap-8">
|
||||||
|
<!-- Bouton Filtres 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('commercial.suppliers.add')"
|
||||||
|
icon-name="mdi:add-bold"
|
||||||
|
icon-position="left"
|
||||||
|
@click="goToCreate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<!-- Datatable branchee sur usePaginatedList via useSuppliersRepository :
|
||||||
|
pagination serveur, tri companyName ASC par defaut (cote back). -->
|
||||||
|
<MalioDataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="rows"
|
||||||
|
:total-items="totalItems"
|
||||||
|
:page="currentPage"
|
||||||
|
:per-page="itemsPerPage"
|
||||||
|
:per-page-options="itemsPerPageOptions"
|
||||||
|
row-clickable
|
||||||
|
table-class="table-fixed suppliers-table"
|
||||||
|
:empty-message="t('commercial.suppliers.empty')"
|
||||||
|
@row-click="onRowClick"
|
||||||
|
@update:page="goToPage"
|
||||||
|
@update:per-page="setItemsPerPage"
|
||||||
|
>
|
||||||
|
<!-- Categories : libelles (name) separes par une virgule (spec M2). -->
|
||||||
|
<template #cell-categories="{ item }">
|
||||||
|
{{ formatCategories(item) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Sites : badges colores (name + color), agreges des adresses. -->
|
||||||
|
<template #cell-sites="{ item }">
|
||||||
|
<span class="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="site in (item.sites as SupplierSite[])"
|
||||||
|
:key="site.id"
|
||||||
|
class="inline-flex items-center rounded-full px-2 py-0.5 font-medium text-white"
|
||||||
|
:style="{ backgroundColor: site.color }"
|
||||||
|
>
|
||||||
|
{{ site.name }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Derniere activite : date de derniere modification (updatedAt). -->
|
||||||
|
<template #cell-lastActivity="{ item }">
|
||||||
|
{{ formatLastActivity(item) }}
|
||||||
|
</template>
|
||||||
|
</MalioDataTable>
|
||||||
|
|
||||||
|
<div class="flex justify-center mt-4">
|
||||||
|
<MalioButton
|
||||||
|
v-if="canView"
|
||||||
|
variant="primary"
|
||||||
|
:label="t('commercial.suppliers.export')"
|
||||||
|
:disabled="exporting"
|
||||||
|
@click="exportXlsx"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
|
||||||
|
« Appliquer ». Meme pattern que le repertoire clients. 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('commercial.suppliers.filters.title') }}</h2>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<MalioAccordion>
|
||||||
|
<!-- Recherche : nom societe + contact + email (param `search`, decision D1). -->
|
||||||
|
<MalioAccordionItem :title="t('commercial.suppliers.filters.search')" value="search">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="draftSearch"
|
||||||
|
icon-name="mdi:magnify"
|
||||||
|
/>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<!-- Categories : cases a cocher (multi). Valeur = code stable. -->
|
||||||
|
<MalioAccordionItem :title="t('commercial.suppliers.filters.categories')" value="categories">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<MalioCheckbox
|
||||||
|
v-for="opt in categoryOptions"
|
||||||
|
:id="`filter-category-${opt.value}`"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:model-value="draftCategoryCodes.includes(opt.value)"
|
||||||
|
@update:model-value="(val: boolean) => toggleCategory(opt.value, val)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<!-- Sites : cases a cocher (multi). Valeur = id du site. -->
|
||||||
|
<MalioAccordionItem :title="t('commercial.suppliers.filters.sites')" value="sites">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<MalioCheckbox
|
||||||
|
v-for="opt in siteOptions"
|
||||||
|
:id="`filter-site-${opt.value}`"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:model-value="draftSiteIds.includes(opt.value)"
|
||||||
|
@update:model-value="(val: boolean) => toggleSite(opt.value, val)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<!-- Statut : bool unique. Coche = inclut aussi les archives (sinon actifs seuls). -->
|
||||||
|
<MalioAccordionItem :title="t('commercial.suppliers.filters.status')" value="status">
|
||||||
|
<MalioCheckbox
|
||||||
|
id="filter-include-archived"
|
||||||
|
:label="t('commercial.suppliers.filters.includeArchived')"
|
||||||
|
:model-value="draftIncludeArchived"
|
||||||
|
@update:model-value="(val: boolean) => draftIncludeArchived = val"
|
||||||
|
/>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
</MalioAccordion>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
variant="tertiary"
|
||||||
|
:label="t('commercial.suppliers.filters.reset')"
|
||||||
|
button-class="w-m-btn-action"
|
||||||
|
@click="resetFilters"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('commercial.suppliers.filters.apply')"
|
||||||
|
button-class="w-[170px]"
|
||||||
|
@click="applyFilters"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioDrawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import type { Supplier, SupplierSite } from '~/modules/commercial/composables/useSuppliersRepository'
|
||||||
|
|
||||||
|
interface FilterOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const api = useApi()
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
|
||||||
|
useHead({ title: t('commercial.suppliers.title') })
|
||||||
|
|
||||||
|
// Bouton « Ajouter » reserve a `manage` (POST /suppliers garde manage seul →
|
||||||
|
// Compta / Usine ne creent pas). « Exporter » et « Filtres » suivent `view`.
|
||||||
|
const canManage = computed(() => can('commercial.suppliers.manage'))
|
||||||
|
const canView = computed(() => can('commercial.suppliers.view'))
|
||||||
|
|
||||||
|
const {
|
||||||
|
items: suppliers,
|
||||||
|
totalItems,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
itemsPerPageOptions,
|
||||||
|
fetch: loadSuppliers,
|
||||||
|
goToPage,
|
||||||
|
setItemsPerPage,
|
||||||
|
setFilters,
|
||||||
|
} = useSuppliersRepository()
|
||||||
|
|
||||||
|
// Mappe les fournisseurs en objets « plats » pour MalioDataTable (items typees
|
||||||
|
// Record<string, unknown>[]) : un objet litteral porte une signature d'index
|
||||||
|
// implicite, contrairement a l'interface Supplier. Meme pattern que clients.
|
||||||
|
const rows = computed(() => suppliers.value.map(supplier => ({
|
||||||
|
id: supplier.id,
|
||||||
|
companyName: supplier.companyName,
|
||||||
|
categories: supplier.categories,
|
||||||
|
sites: supplier.sites,
|
||||||
|
updatedAt: supplier.updatedAt,
|
||||||
|
})))
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'companyName', label: t('commercial.suppliers.column.companyName') },
|
||||||
|
{ key: 'categories', label: t('commercial.suppliers.column.categories') },
|
||||||
|
{ key: 'sites', label: t('commercial.suppliers.column.sites') },
|
||||||
|
{ key: 'lastActivity', label: t('commercial.suppliers.column.lastActivity') },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Libelles des categories du fournisseur, separes par une virgule (spec M2 : name). */
|
||||||
|
function formatCategories(item: Record<string, unknown>): string {
|
||||||
|
const categories = (item.categories as Supplier['categories']) ?? []
|
||||||
|
return categories.map(c => c.name).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derniere activite : faute de suivi d'activite metier au M2, on affiche la
|
||||||
|
* date de derniere modification de la fiche (updatedAt, expose en liste via
|
||||||
|
* default:read). Format court francais jj/mm/aaaa.
|
||||||
|
*/
|
||||||
|
function formatLastActivity(item: Record<string, unknown>): string {
|
||||||
|
const value = item.updatedAt as string | null | undefined
|
||||||
|
if (!value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garde-fou date invalide : un updatedAt mal forme donnerait « Invalid Date ».
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleDateString('fr-FR')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clic sur une ligne → ecran Consultation (route a plat /suppliers/{id}). */
|
||||||
|
function onRowClick(item: Record<string, unknown>): void {
|
||||||
|
router.push(`/suppliers/${item.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToCreate(): void {
|
||||||
|
router.push('/suppliers/new')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filtres (drawer) ────────────────────────────────────────────────────────
|
||||||
|
// Deux niveaux d'etat (pattern repertoire clients) :
|
||||||
|
// - APPLIED : pilote la liste/l'export + le compteur du bouton. Modifie
|
||||||
|
// uniquement au clic « Appliquer » / « Réinitialiser ».
|
||||||
|
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
|
||||||
|
const filterDrawerOpen = ref(false)
|
||||||
|
|
||||||
|
const draftSearch = ref('')
|
||||||
|
const draftCategoryCodes = ref<string[]>([])
|
||||||
|
const draftSiteIds = ref<string[]>([])
|
||||||
|
const draftIncludeArchived = ref(false)
|
||||||
|
|
||||||
|
const appliedSearch = ref('')
|
||||||
|
const appliedCategoryCodes = ref<string[]>([])
|
||||||
|
const appliedSiteIds = ref<string[]>([])
|
||||||
|
const appliedIncludeArchived = ref(false)
|
||||||
|
|
||||||
|
// Options des selects multi, chargees une fois (referentiels courts).
|
||||||
|
const categoryOptions = ref<FilterOption[]>([])
|
||||||
|
const siteOptions = ref<FilterOption[]>([])
|
||||||
|
|
||||||
|
const activeFilterCount = computed(() => {
|
||||||
|
let count = 0
|
||||||
|
if (appliedSearch.value.trim() !== '') count++
|
||||||
|
if (appliedCategoryCodes.value.length > 0) count++
|
||||||
|
if (appliedSiteIds.value.length > 0) count++
|
||||||
|
if (appliedIncludeArchived.value) count++
|
||||||
|
return count
|
||||||
|
})
|
||||||
|
|
||||||
|
const filterButtonLabel = computed(() => {
|
||||||
|
const base = t('commercial.suppliers.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
|
||||||
|
draftCategoryCodes.value = [...appliedCategoryCodes.value]
|
||||||
|
draftSiteIds.value = [...appliedSiteIds.value]
|
||||||
|
draftIncludeArchived.value = appliedIncludeArchived.value
|
||||||
|
filterDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCategory(code: string, selected: boolean): void {
|
||||||
|
draftCategoryCodes.value = selected
|
||||||
|
? [...draftCategoryCodes.value, code]
|
||||||
|
: draftCategoryCodes.value.filter(c => c !== code)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSite(id: string, selected: boolean): void {
|
||||||
|
draftSiteIds.value = selected
|
||||||
|
? [...draftSiteIds.value, id]
|
||||||
|
: draftSiteIds.value.filter(s => s !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit le payload de filtres serveur a partir de l'etat applique. Cles
|
||||||
|
* `categoryCode[]` / `siteId[]` pour que PHP les parse en tableaux (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 (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
|
||||||
|
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
|
||||||
|
if (appliedIncludeArchived.value) payload.includeArchived = true
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
// « Appliquer » : recopie brouillon → applied, pousse les filtres (retombe en
|
||||||
|
// page 1 via usePaginatedList) et ferme le drawer.
|
||||||
|
function applyFilters(): void {
|
||||||
|
appliedSearch.value = draftSearch.value.trim()
|
||||||
|
appliedCategoryCodes.value = [...draftCategoryCodes.value]
|
||||||
|
appliedSiteIds.value = [...draftSiteIds.value]
|
||||||
|
appliedIncludeArchived.value = draftIncludeArchived.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 = ''
|
||||||
|
draftCategoryCodes.value = []
|
||||||
|
draftSiteIds.value = []
|
||||||
|
draftIncludeArchived.value = false
|
||||||
|
|
||||||
|
appliedSearch.value = ''
|
||||||
|
appliedCategoryCodes.value = []
|
||||||
|
appliedSiteIds.value = []
|
||||||
|
appliedIncludeArchived.value = false
|
||||||
|
|
||||||
|
setFilters({}, { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Charge les referentiels du drawer (categories FOURNISSEUR + sites) via ?pagination=false. */
|
||||||
|
async function loadFilterOptions(): Promise<void> {
|
||||||
|
const [cats, sites] = await Promise.all([
|
||||||
|
api.get<{ member?: Array<{ code: string, name: string }> }>(
|
||||||
|
'/categories',
|
||||||
|
// Taxonomie multi-types (ERP-84) : le filtre du repertoire fournisseurs
|
||||||
|
// ne propose que les categories de type FOURNISSEUR (pas les CLIENT).
|
||||||
|
{ pagination: 'false', typeCode: 'FOURNISSEUR' },
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
),
|
||||||
|
api.get<{ member?: Array<{ id: number, name: string }> }>(
|
||||||
|
'/sites',
|
||||||
|
{ pagination: 'false' },
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
categoryOptions.value = (cats.member ?? []).map(c => ({ value: c.code, label: c.name }))
|
||||||
|
siteOptions.value = (sites.member ?? []).map(s => ({ value: String(s.id), label: s.name }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export XLSX ─────────────────────────────────────────────────────────────
|
||||||
|
// Memes filtres que la vue. La colonne SIREN n'est dans le fichier que si
|
||||||
|
// l'utilisateur a accounting.view (gere cote back).
|
||||||
|
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 — a generaliser via
|
||||||
|
// un ticket dedie si d'autres exports binaires arrivent.
|
||||||
|
const blob = await api.get<Blob>('/suppliers/export.xlsx', buildFilterPayload(), {
|
||||||
|
responseType: 'blob',
|
||||||
|
toast: false,
|
||||||
|
} as unknown as Parameters<typeof api.get>[2])
|
||||||
|
|
||||||
|
triggerDownload(blob, 'repertoire-fournisseurs.xlsx')
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
toast.error({
|
||||||
|
title: t('commercial.suppliers.toast.error'),
|
||||||
|
message: t('commercial.suppliers.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(() => {
|
||||||
|
loadSuppliers()
|
||||||
|
// Echec du chargement des referentiels non bloquant : la liste s'affiche,
|
||||||
|
// l'utilisateur perd juste les options de filtre.
|
||||||
|
loadFilterOptions().catch(() => {
|
||||||
|
categoryOptions.value = []
|
||||||
|
siteOptions.value = []
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/*
|
||||||
|
* Colonne Sites uniquement (3e colonne : companyName, categories, SITES,
|
||||||
|
* lastActivity) : ses badges rendent la cellule trop haute. On reduit le padding
|
||||||
|
* vertical de SON td (16px Malio -> 8px) sans toucher les autres colonnes ni les
|
||||||
|
* couleurs/tailles (qui restent sur les defauts Malio).
|
||||||
|
*/
|
||||||
|
:deep(.suppliers-table tbody td:nth-child(3)) {
|
||||||
|
padding-top: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user