feat(front) : page liste des tickets de pesée + export (ERP-188)
This commit is contained in:
@@ -691,6 +691,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"logistique": {
|
||||||
|
"weighingTickets": {
|
||||||
|
"title": "Tickets de pesée",
|
||||||
|
"add": "Ajouter",
|
||||||
|
"export": "Exporter",
|
||||||
|
"empty": "Aucun ticket de pesée pour l'instant.",
|
||||||
|
"column": {
|
||||||
|
"number": "Numéro",
|
||||||
|
"client": "Client",
|
||||||
|
"supplier": "Fournisseur",
|
||||||
|
"other": "Autre",
|
||||||
|
"date": "Date",
|
||||||
|
"weight": "Poids"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"error": "Une erreur est survenue. Réessayez.",
|
||||||
|
"exportError": "L'export des tickets de pesée a échoué. Réessayez."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Connexion",
|
"login": "Connexion",
|
||||||
"logout": "Deconnexion",
|
"logout": "Deconnexion",
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue MINIMALE d'une contrepartie embarquee (Client M1 ou Fournisseur M2) dans la
|
||||||
|
* LISTE des tickets de pesee. Seul `companyName` alimente les colonnes
|
||||||
|
* « Client » / « Fournisseur » ; l'objet sort embarque (`client:read` /
|
||||||
|
* `supplier:read`) ou est carrement absent du JSON quand null (`skip_null_values`,
|
||||||
|
* spec-back § 4.0.bis) — d'ou le `?? null` systematique cote page.
|
||||||
|
*/
|
||||||
|
export interface WeighingTicketParty {
|
||||||
|
id: number
|
||||||
|
companyName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue MINIMALE d'un ticket de pesee pour la datatable (M5, ERP-188). Volontairement
|
||||||
|
* partielle : seuls les champs des colonnes (docx p.3) + l'id (navigation) sont
|
||||||
|
* types. Le detail complet (pesees vide/plein, immatriculation, site, DSD) releve
|
||||||
|
* de l'ecran Modification (ERP-190) — hors perimetre de cet ecran.
|
||||||
|
*
|
||||||
|
* Contrepartie mutuellement exclusive (RG-5.03) : un seul de `client` / `supplier`
|
||||||
|
* / `otherLabel` est renseigne ; les deux autres sont omis du JSON (null).
|
||||||
|
* `displayDate` = getter serveur `fullDate ?? emptyDate` (spec-back § 4.0).
|
||||||
|
* `netWeight` = plein − vide en kg (RG-5.05).
|
||||||
|
*/
|
||||||
|
export interface WeighingTicket {
|
||||||
|
id: number
|
||||||
|
/** Numero metier `{siteCode}-TP-{NNNN}` attribue par site (RG-5.02). */
|
||||||
|
number: string
|
||||||
|
/** Embarque uniquement si contrepartie = Client (RG-5.03), sinon absent. */
|
||||||
|
client: WeighingTicketParty | null
|
||||||
|
/** Embarque uniquement si contrepartie = Fournisseur (RG-5.03), sinon absent. */
|
||||||
|
supplier: WeighingTicketParty | null
|
||||||
|
/** Libelle libre si contrepartie = Autre (RG-5.03), sinon absent. */
|
||||||
|
otherLabel: string | null
|
||||||
|
/** Date ISO du ticket (`fullDate ?? emptyDate`) — colonne « Date ». */
|
||||||
|
displayDate: string | null
|
||||||
|
/** Poids net en kg (= plein − vide, RG-5.05) — colonne « Poids ». */
|
||||||
|
netWeight: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtres de la liste des tickets de pesee, branches sur les query params de
|
||||||
|
* `GET /api/weighing_tickets` (spec-back § 4.1). La liste est par ailleurs
|
||||||
|
* cloisonnee par site courant cote back (`SiteScopedQueryExtension`, § 2.3) — le
|
||||||
|
* front n'a pas a envoyer le site.
|
||||||
|
*/
|
||||||
|
export interface WeighingTicketFilters {
|
||||||
|
search?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste des tickets de pesee (M5, ERP-188) — simple enveloppe de
|
||||||
|
* `usePaginatedList<WeighingTicket>` sur la ressource `/weighing_tickets`
|
||||||
|
* (URL API en snake_case ; la route Nuxt reste `/weighing-tickets`). Pagination
|
||||||
|
* serveur obligatoire (regle ABSOLUE n°13), etat 100 % local (regle ABSOLUE n°6).
|
||||||
|
*
|
||||||
|
* Miroir de `useCarriersRepository` (M4). Volontairement PAR INSTANCE (pas de
|
||||||
|
* singleton) : l'etat tableau est propre a l'ecran et meurt avec lui.
|
||||||
|
*/
|
||||||
|
export function useWeighingTicketsRepository() {
|
||||||
|
return usePaginatedList<WeighingTicket, WeighingTicketFilters>({ url: '/weighing_tickets' })
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
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 specs M1→M4.
|
||||||
|
const mockPush = vi.hoisted(() => vi.fn())
|
||||||
|
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||||
|
const mockCan = 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 les items renvoyes.
|
||||||
|
// Contrepartie CLIENT (RG-5.03) → supplier / otherLabel absents (skip_null_values).
|
||||||
|
vi.stubGlobal('useWeighingTicketsRepository', () => ({
|
||||||
|
items: ref([
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
number: '86-TP-0001',
|
||||||
|
client: { id: 629, companyName: 'NÉGOCE MÉTAUX ATLANTIQUE' },
|
||||||
|
supplier: null,
|
||||||
|
otherLabel: null,
|
||||||
|
displayDate: '2026-06-17T09:12:00+02:00',
|
||||||
|
netWeight: 7150,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
totalItems: ref(1),
|
||||||
|
currentPage: ref(1),
|
||||||
|
itemsPerPage: ref(10),
|
||||||
|
itemsPerPageOptions: ref([10, 25, 50]),
|
||||||
|
fetch: mockFetch,
|
||||||
|
goToPage: vi.fn(),
|
||||||
|
setItemsPerPage: vi.fn(),
|
||||||
|
setFilters: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 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 WeighingTicketsIndex = (await import('../weighing-tickets/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)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Capture les `items` (rows) passes par la page : on rend chaque ligne avec ses
|
||||||
|
// cellules formatees (date / poids) pour pouvoir asserter le mapping des colonnes.
|
||||||
|
const capturedRows = ref<Array<Record<string, unknown>>>([])
|
||||||
|
const DataTableStub = defineComponent({
|
||||||
|
props: { items: { type: Array, default: () => [] } },
|
||||||
|
emits: ['row-click', 'update:page', 'update:per-page'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => {
|
||||||
|
capturedRows.value = props.items as Array<Record<string, unknown>>
|
||||||
|
return h('div', { 'data-testid': 'datatable' },
|
||||||
|
(props.items as Array<Record<string, unknown>>).map(it =>
|
||||||
|
h('tr', { 'data-row-id': it.id as number, onClick: () => emit('row-click', it) }, [
|
||||||
|
h('td', { 'data-cell': 'displayDate' }, it.displayDate as string),
|
||||||
|
h('td', { 'data-cell': 'netWeight' }, it.netWeight as string),
|
||||||
|
h('td', { 'data-cell': 'client' }, it.client as string),
|
||||||
|
h('td', { 'data-cell': 'supplier' }, it.supplier as string),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const PageHeaderStub = defineComponent({
|
||||||
|
setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) },
|
||||||
|
})
|
||||||
|
|
||||||
|
function mountPage() {
|
||||||
|
return mount(WeighingTicketsIndex, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
PageHeader: PageHeaderStub,
|
||||||
|
MalioButton: ButtonStub,
|
||||||
|
MalioDataTable: DataTableStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Liste des tickets de pesée (page /weighing-tickets)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPush.mockReset()
|
||||||
|
mockApiGet.mockReset().mockResolvedValue(new Blob())
|
||||||
|
mockCan.mockReset().mockReturnValue(true)
|
||||||
|
mockFetch.mockReset()
|
||||||
|
mockToastError.mockReset()
|
||||||
|
capturedRows.value = []
|
||||||
|
})
|
||||||
|
|
||||||
|
it('charge la liste au montage', async () => {
|
||||||
|
mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(mockFetch).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formate la date au format JJ-MM-AAAA', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-cell="displayDate"]').text()).toBe('17-06-2026')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formate le poids net en kg avec separateur de milliers', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-cell="netWeight"]').text()).toBe('7 150 Kg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mappe la contrepartie Client (supplier vide car contrepartie ≠ Fournisseur)', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-cell="client"]').text()).toBe('NÉGOCE MÉTAUX ATLANTIQUE')
|
||||||
|
expect(wrapper.find('[data-cell="supplier"]').text()).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche « + Ajouter » uniquement avec la permission manage', async () => {
|
||||||
|
mockCan.mockImplementation((perm: string) => perm === 'logistique.weighing_tickets.manage')
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-label="logistique.weighingTickets.add"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('masque « + Ajouter » sans la permission manage (view seul)', async () => {
|
||||||
|
mockCan.mockImplementation((perm: string) => perm === 'logistique.weighing_tickets.view')
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-label="logistique.weighingTickets.add"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('navigue vers la modification au clic sur une ligne', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.find('tr[data-row-id="9"]').trigger('click')
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/weighing-tickets/9/edit')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appelle l\'export XLSX sur /weighing_tickets/export.xlsx en blob', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.find('[data-label="logistique.weighingTickets.export"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(mockApiGet).toHaveBeenCalledWith(
|
||||||
|
'/weighing_tickets/export.xlsx',
|
||||||
|
expect.any(Object),
|
||||||
|
expect.objectContaining({ responseType: 'blob', toast: false }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PageHeader>
|
||||||
|
{{ t('logistique.weighingTickets.title') }}
|
||||||
|
<template #actions>
|
||||||
|
<MalioButton
|
||||||
|
v-if="canManage"
|
||||||
|
variant="secondary"
|
||||||
|
:label="t('logistique.weighingTickets.add')"
|
||||||
|
icon-name="mdi:add-bold"
|
||||||
|
icon-position="left"
|
||||||
|
@click="goToCreate"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<!-- Datatable branchee sur usePaginatedList via useWeighingTicketsRepository :
|
||||||
|
pagination serveur (defaut 10), tri number DESC par defaut (cote back),
|
||||||
|
liste cloisonnee par site courant (spec-back § 2.3). Etat 100 % local
|
||||||
|
(regle ABSOLUE n°6). -->
|
||||||
|
<MalioDataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="rows"
|
||||||
|
:total-items="totalItems"
|
||||||
|
:page="currentPage"
|
||||||
|
:per-page="itemsPerPage"
|
||||||
|
:per-page-options="itemsPerPageOptions"
|
||||||
|
row-clickable
|
||||||
|
:empty-message="t('logistique.weighingTickets.empty')"
|
||||||
|
@row-click="onRowClick"
|
||||||
|
@update:page="goToPage"
|
||||||
|
@update:per-page="setItemsPerPage"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex justify-center mt-4">
|
||||||
|
<MalioButton
|
||||||
|
v-if="canView"
|
||||||
|
variant="primary"
|
||||||
|
:label="t('logistique.weighingTickets.export')"
|
||||||
|
:disabled="exporting"
|
||||||
|
@click="exportXlsx"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const api = useApi()
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
|
||||||
|
useHead({ title: t('logistique.weighingTickets.title') })
|
||||||
|
|
||||||
|
// Bouton « + Ajouter » reserve a `manage` (Admin / Bureau / Usine). « Exporter »
|
||||||
|
// suit `view`. Compta et Commerciale n'ont aucun acces (item sidebar masque cote
|
||||||
|
// back) — spec-front § Acces.
|
||||||
|
const canManage = computed(() => can('logistique.weighing_tickets.manage'))
|
||||||
|
const canView = computed(() => can('logistique.weighing_tickets.view'))
|
||||||
|
|
||||||
|
const {
|
||||||
|
items: tickets,
|
||||||
|
totalItems,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
itemsPerPageOptions,
|
||||||
|
fetch: loadTickets,
|
||||||
|
goToPage,
|
||||||
|
setItemsPerPage,
|
||||||
|
} = useWeighingTicketsRepository()
|
||||||
|
|
||||||
|
// Mappe les tickets en objets « plats » formates pour MalioDataTable (items typees
|
||||||
|
// Record<string, unknown>[]). La contrepartie est mutuellement exclusive (RG-5.03) :
|
||||||
|
// une seule des colonnes client / supplier / otherLabel est renseignee, les autres
|
||||||
|
// restent vides. Date et poids sont formates ici (cf. helpers ci-dessous).
|
||||||
|
const rows = computed(() => tickets.value.map(ticket => ({
|
||||||
|
id: ticket.id,
|
||||||
|
number: ticket.number,
|
||||||
|
client: ticket.client?.companyName ?? '',
|
||||||
|
supplier: ticket.supplier?.companyName ?? '',
|
||||||
|
otherLabel: ticket.otherLabel ?? '',
|
||||||
|
displayDate: formatDateFr(ticket.displayDate),
|
||||||
|
netWeight: formatWeight(ticket.netWeight),
|
||||||
|
})))
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'number', label: t('logistique.weighingTickets.column.number') },
|
||||||
|
{ key: 'client', label: t('logistique.weighingTickets.column.client') },
|
||||||
|
{ key: 'supplier', label: t('logistique.weighingTickets.column.supplier') },
|
||||||
|
{ key: 'otherLabel', label: t('logistique.weighingTickets.column.other') },
|
||||||
|
{ key: 'displayDate', label: t('logistique.weighingTickets.column.date') },
|
||||||
|
{ key: 'netWeight', label: t('logistique.weighingTickets.column.weight') },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Format court francais JJ-MM-AAAA (spec M5). 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()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poids net affiche en kg avec separateur de milliers (espace) + suffixe « Kg »
|
||||||
|
* (spec-front § formatage : « 7 150 Kg »). Chaine vide si poids absent (ticket
|
||||||
|
* dont la pesee a plein n'est pas encore finalisee). Groupement manuel (espace
|
||||||
|
* ASCII) pour un rendu deterministe, independant de l'ICU de l'environnement.
|
||||||
|
*/
|
||||||
|
function formatWeight(value: number | null | undefined): string {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const grouped = String(Math.round(value)).replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
|
||||||
|
return `${grouped} Kg`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clic sur une ligne → ecran Modification (pas de consultation separee, spec § Navigation). */
|
||||||
|
function onRowClick(item: Record<string, unknown>): void {
|
||||||
|
router.push(`/weighing-tickets/${item.id}/edit`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToCreate(): void {
|
||||||
|
router.push('/weighing-tickets/new')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export XLSX ─────────────────────────────────────────────────────────────
|
||||||
|
// Exporte toute la liste (site courant applique cote back, spec-back § 4.5).
|
||||||
|
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/M4).
|
||||||
|
const blob = await api.get<Blob>('/weighing_tickets/export.xlsx', {}, {
|
||||||
|
responseType: 'blob',
|
||||||
|
toast: false,
|
||||||
|
} as unknown as Parameters<typeof api.get>[2])
|
||||||
|
|
||||||
|
triggerDownload(blob, 'tickets-pesee.xlsx')
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
toast.error({
|
||||||
|
title: t('logistique.weighingTickets.toast.error'),
|
||||||
|
message: t('logistique.weighingTickets.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(loadTickets)
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user