refacto(tables) : composant DataTable global + migration de toutes les tables
- Nouveau composant DataTable réutilisable avec tri par en-têtes, pagination, filtres colonnes - Nouveau composable useDataTable (sort/page/search/perPage/columnFilters + persistance URL) - Migration des 9 tables : constructeurs, comments, admin, pieces-catalog, component-catalog, product-catalog, documents, activity-log, ManagementView (catégories) - Filtres "Type de" server-side (ipartial) pour pièces, composants, produits Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
308
app/components/common/DataTable.vue
Normal file
308
app/components/common/DataTable.vue
Normal file
@@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Toolbar + counter row -->
|
||||
<div
|
||||
v-if="$slots.toolbar || showCounter || showPerPage"
|
||||
class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"
|
||||
>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<slot name="toolbar" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div v-if="showPerPage && pagination?.perPageOptions?.length" class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="dt-per-page"
|
||||
>
|
||||
Par page
|
||||
</label>
|
||||
<select
|
||||
id="dt-per-page"
|
||||
:value="pagination.perPage"
|
||||
class="select select-bordered select-sm"
|
||||
@change="emit('update:perPage', Number(($event.target as HTMLSelectElement).value))"
|
||||
>
|
||||
<option v-for="opt in pagination.perPageOptions" :key="opt" :value="opt">
|
||||
{{ opt }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p v-if="showCounter && pagination" class="text-xs text-base-content/50 whitespace-nowrap">
|
||||
{{ pagination.pageItems }} / {{ pagination.totalItems }}
|
||||
résultat{{ pagination.totalItems > 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state (full spinner only when no filterable columns to keep visible) -->
|
||||
<div v-if="loading && !hasFilterableColumns" class="flex justify-center py-8">
|
||||
<slot name="loading">
|
||||
<span class="loading loading-spinner" aria-hidden="true" />
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<!-- Empty state (no data at all, no filterable columns to keep visible) -->
|
||||
<template v-else-if="isEmpty && !hasFilterableColumns">
|
||||
<slot name="empty">
|
||||
<p class="text-sm text-base-content/70 py-8 text-center">
|
||||
{{ emptyMessage }}
|
||||
</p>
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<!-- No results without filterable columns -->
|
||||
<template v-else-if="rows.length === 0 && !hasFilterableColumns">
|
||||
<slot name="no-results">
|
||||
<p class="text-sm text-base-content/70 py-8 text-center">
|
||||
{{ noResultsMessage }}
|
||||
</p>
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<!-- Table (always shown when there are filterable columns, even during loading or with 0 rows) -->
|
||||
<template v-else>
|
||||
<div class="overflow-x-auto relative">
|
||||
<!-- Loading overlay (keeps table & filter inputs visible) -->
|
||||
<div
|
||||
v-if="loading && hasFilterableColumns"
|
||||
class="absolute inset-0 bg-base-100/50 z-10 flex items-center justify-center"
|
||||
>
|
||||
<span class="loading loading-spinner" aria-hidden="true" />
|
||||
</div>
|
||||
<table :class="['table table-sm md:table-md', tableClass]">
|
||||
<thead>
|
||||
<!-- Header labels + sort -->
|
||||
<tr>
|
||||
<th
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
:class="[
|
||||
col.width,
|
||||
col.class,
|
||||
col.headerClass,
|
||||
alignClass(col),
|
||||
{ 'hidden sm:table-cell': col.hiddenMobile },
|
||||
]"
|
||||
>
|
||||
<slot :name="`header-${col.key}`" :column="col">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1',
|
||||
col.sortable ? 'cursor-pointer select-none hover:text-base-content' : '',
|
||||
]"
|
||||
@click="col.sortable && handleHeaderSort(col)"
|
||||
>
|
||||
{{ col.label }}
|
||||
<template v-if="col.sortable">
|
||||
<IconLucideChevronUp
|
||||
v-if="isSortedAsc(col)"
|
||||
class="h-3.5 w-3.5"
|
||||
aria-label="Trié croissant"
|
||||
/>
|
||||
<IconLucideChevronDown
|
||||
v-else-if="isSortedDesc(col)"
|
||||
class="h-3.5 w-3.5"
|
||||
aria-label="Trié décroissant"
|
||||
/>
|
||||
<IconLucideChevronsUpDown
|
||||
v-else
|
||||
class="h-3.5 w-3.5 opacity-30"
|
||||
aria-label="Triable"
|
||||
/>
|
||||
</template>
|
||||
</span>
|
||||
</slot>
|
||||
</th>
|
||||
<th v-if="expandable" class="w-12" />
|
||||
</tr>
|
||||
<!-- Filter inputs row -->
|
||||
<tr v-if="hasFilterableColumns">
|
||||
<th
|
||||
v-for="col in columns"
|
||||
:key="`filter-${col.key}`"
|
||||
class="p-1"
|
||||
:class="{ 'hidden sm:table-cell': col.hiddenMobile }"
|
||||
>
|
||||
<input
|
||||
v-if="col.filterable"
|
||||
type="text"
|
||||
class="input input-bordered input-xs w-full"
|
||||
:placeholder="col.filterPlaceholder || 'Filtrer…'"
|
||||
:value="columnFilters[col.key] ?? ''"
|
||||
@input="handleFilterInput(col.key, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</th>
|
||||
<th v-if="expandable" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- No results message (inside table to keep headers visible) -->
|
||||
<tr v-if="rows.length === 0">
|
||||
<td :colspan="expandable ? columns.length + 1 : columns.length" class="text-center py-8">
|
||||
<p class="text-sm text-base-content/70">
|
||||
{{ isEmpty ? emptyMessage : noResultsMessage }}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-for="(row, idx) in rows" :key="getRowKey(row)">
|
||||
<tr>
|
||||
<td
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
:class="[
|
||||
col.class,
|
||||
alignClass(col),
|
||||
{ 'hidden sm:table-cell': col.hiddenMobile },
|
||||
]"
|
||||
>
|
||||
<slot :name="`cell-${col.key}`" :row="row" :column="col" :index="idx">
|
||||
{{ row[col.key] ?? '—' }}
|
||||
</slot>
|
||||
</td>
|
||||
<td v-if="expandable" class="text-center">
|
||||
<button
|
||||
v-if="!canExpand || canExpand(row)"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
@click="emit('toggle-expand', getRowKey(row))"
|
||||
>
|
||||
{{ isExpanded(row) ? 'Masquer' : 'Voir' }}
|
||||
</button>
|
||||
<span v-else class="text-xs text-base-content/50">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Expanded row -->
|
||||
<tr v-if="expandable && isExpanded(row)">
|
||||
<td :colspan="columns.length + 1" class="bg-base-200/50 p-4">
|
||||
<slot name="row-expanded" :row="row" :index="idx" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination
|
||||
v-if="pagination && pagination.totalPages > 1"
|
||||
:current-page="pagination.currentPage"
|
||||
:total-pages="pagination.totalPages"
|
||||
@update:current-page="emit('update:currentPage', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { DataTableColumn, DataTableSort, DataTablePagination, DataTableColumnFilters } from '~/shared/types/dataTable'
|
||||
import Pagination from '~/components/common/Pagination.vue'
|
||||
import IconLucideChevronUp from '~icons/lucide/chevron-up'
|
||||
import IconLucideChevronDown from '~icons/lucide/chevron-down'
|
||||
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
columns: DataTableColumn[]
|
||||
rows: any[]
|
||||
rowKey?: string
|
||||
loading?: boolean
|
||||
sort?: DataTableSort | null
|
||||
pagination?: DataTablePagination | null
|
||||
columnFilters?: DataTableColumnFilters
|
||||
emptyMessage?: string
|
||||
noResultsMessage?: string
|
||||
expandable?: boolean
|
||||
expandedKeys?: Set<string>
|
||||
canExpand?: (row: any) => boolean
|
||||
tableClass?: string
|
||||
showCounter?: boolean
|
||||
showPerPage?: boolean
|
||||
}>(), {
|
||||
rowKey: 'id',
|
||||
loading: false,
|
||||
sort: null,
|
||||
pagination: null,
|
||||
columnFilters: () => ({}),
|
||||
emptyMessage: 'Aucune donnée disponible.',
|
||||
noResultsMessage: 'Aucun résultat ne correspond à vos critères.',
|
||||
expandable: false,
|
||||
expandedKeys: () => new Set<string>(),
|
||||
canExpand: undefined,
|
||||
tableClass: '',
|
||||
showCounter: true,
|
||||
showPerPage: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'sort', sort: DataTableSort): void
|
||||
(e: 'update:currentPage', page: number): void
|
||||
(e: 'update:perPage', perPage: number): void
|
||||
(e: 'update:columnFilters', filters: DataTableColumnFilters): void
|
||||
(e: 'toggle-expand', key: string): void
|
||||
}>()
|
||||
|
||||
const hasFilterableColumns = computed(() =>
|
||||
props.columns.some(col => col.filterable),
|
||||
)
|
||||
|
||||
const isEmpty = computed(() => {
|
||||
if (props.pagination) {
|
||||
return props.pagination.totalItems === 0
|
||||
}
|
||||
return props.rows.length === 0
|
||||
})
|
||||
|
||||
const getRowKey = (row: any): string => {
|
||||
return String(row[props.rowKey] ?? '')
|
||||
}
|
||||
|
||||
const isExpanded = (row: any): boolean => {
|
||||
return props.expandedKeys?.has(getRowKey(row)) ?? false
|
||||
}
|
||||
|
||||
const sortKeyForColumn = (col: DataTableColumn): string => {
|
||||
return col.sortKey ?? col.key
|
||||
}
|
||||
|
||||
const isSortedAsc = (col: DataTableColumn): boolean => {
|
||||
return props.sort?.field === sortKeyForColumn(col) && props.sort?.direction === 'asc'
|
||||
}
|
||||
|
||||
const isSortedDesc = (col: DataTableColumn): boolean => {
|
||||
return props.sort?.field === sortKeyForColumn(col) && props.sort?.direction === 'desc'
|
||||
}
|
||||
|
||||
const handleHeaderSort = (col: DataTableColumn) => {
|
||||
const key = sortKeyForColumn(col)
|
||||
const currentDirection = props.sort?.field === key ? props.sort.direction : null
|
||||
|
||||
emit('sort', {
|
||||
field: key,
|
||||
direction: currentDirection === 'asc' ? 'desc' : 'asc',
|
||||
})
|
||||
}
|
||||
|
||||
let filterDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const handleFilterInput = (key: string, value: string) => {
|
||||
if (filterDebounceTimer) clearTimeout(filterDebounceTimer)
|
||||
filterDebounceTimer = setTimeout(() => {
|
||||
const updated = { ...props.columnFilters, [key]: value }
|
||||
// Remove empty filter keys
|
||||
for (const k of Object.keys(updated)) {
|
||||
if (!updated[k]) delete updated[k]
|
||||
}
|
||||
emit('update:columnFilters', updated)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const alignClass = (col: DataTableColumn): string => {
|
||||
if (col.align === 'center') return 'text-center'
|
||||
if (col.align === 'right') return 'text-right'
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
@@ -9,35 +9,95 @@
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ModelTypesToolbar
|
||||
:category="selectedCategory"
|
||||
:search="searchInput"
|
||||
:sort="sort"
|
||||
:dir="dir"
|
||||
:loading="loading"
|
||||
:show-category-tabs="allowCategorySwitch"
|
||||
:can-edit="canEdit"
|
||||
@update:category="onCategoryChange"
|
||||
@update:search="onSearchInput"
|
||||
@update:sort="onSortChange"
|
||||
@update:dir="onDirChange"
|
||||
@create="openCreatePage"
|
||||
/>
|
||||
<nav
|
||||
v-if="allowCategorySwitch"
|
||||
class="tabs tabs-boxed inline-flex"
|
||||
role="tablist"
|
||||
aria-label="Catégories"
|
||||
>
|
||||
<button
|
||||
v-for="option in categories"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ 'tab-active': option.value === selectedCategory }"
|
||||
role="tab"
|
||||
:aria-selected="option.value === selectedCategory"
|
||||
@click="onCategoryChange(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<ModelTypesTable
|
||||
:items="items"
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:rows="items"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
:limit="limit"
|
||||
:offset="offset"
|
||||
:category="selectedCategory"
|
||||
:can-edit="canEdit"
|
||||
@related="openRelatedModal"
|
||||
@edit="openEditPage"
|
||||
@delete="confirmDelete"
|
||||
@convert="openConversionModal"
|
||||
@update:offset="onOffsetChange"
|
||||
/>
|
||||
:sort="currentSort"
|
||||
:pagination="paginationState"
|
||||
:show-per-page="true"
|
||||
row-key="id"
|
||||
empty-message="Aucune catégorie trouvée."
|
||||
no-results-message="Aucune catégorie ne correspond à votre recherche."
|
||||
@sort="handleSort"
|
||||
@update:current-page="handlePageChange"
|
||||
@update:per-page="handlePerPageChange"
|
||||
>
|
||||
<template #toolbar>
|
||||
<label class="input input-bordered flex items-center gap-2 w-full sm:w-72" :aria-busy="loading">
|
||||
<IconLucideSearch class="w-4 h-4" aria-hidden="true" />
|
||||
<input
|
||||
v-model="searchInput"
|
||||
type="search"
|
||||
class="grow min-w-0"
|
||||
placeholder="Rechercher par nom…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="loading"
|
||||
@click="openCreatePage"
|
||||
>
|
||||
<IconLucidePlus class="w-4 h-4" aria-hidden="true" />
|
||||
Créer
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #cell-name="{ row }">
|
||||
<span class="font-medium">{{ row.name }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-notes="{ row }">
|
||||
<span v-if="row.notes" class="block text-sm text-base-content/80 break-words">{{ row.notes }}</span>
|
||||
<span v-else class="text-base-content/50">—</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="btn btn-ghost btn-xs" @click="openRelatedModal(row)">
|
||||
Liés
|
||||
</button>
|
||||
<button
|
||||
v-if="canEdit && showConvertButton"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-warning"
|
||||
@click="openConversionModal(row)"
|
||||
>
|
||||
Convertir
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-xs" @click="openEditPage(row)">
|
||||
Éditer
|
||||
</button>
|
||||
<button v-if="canEdit" type="button" class="btn btn-ghost btn-xs text-error" @click="confirmDelete(row)">
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<ModelTypesConversionModal
|
||||
:open="conversionModalOpen"
|
||||
@@ -57,7 +117,7 @@
|
||||
|
||||
<div class="mt-4 rounded-xl border border-base-200 bg-base-100">
|
||||
<div v-if="relatedLoading" class="flex items-center gap-2 px-4 py-6 text-sm text-info">
|
||||
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
|
||||
Chargement des éléments liés…
|
||||
</div>
|
||||
|
||||
@@ -103,44 +163,46 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from "vue";
|
||||
import { useHead, useRouter } from "#imports";
|
||||
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
|
||||
import ModelTypesTable from "~/components/model-types/Table.vue";
|
||||
import ModelTypesConversionModal from "~/components/model-types/ConversionModal.vue";
|
||||
import { useApi } from "~/composables/useApi";
|
||||
import { useUrlState } from "~/composables/useUrlState";
|
||||
import { extractCollection } from "~/shared/utils/apiHelpers";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
|
||||
import { useHead, useRouter } from '#imports'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import ModelTypesConversionModal from '~/components/model-types/ConversionModal.vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useUrlState } from '~/composables/useUrlState'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
import type { DataTableSort } from '~/shared/types/dataTable'
|
||||
import {
|
||||
deleteModelType,
|
||||
listModelTypes,
|
||||
type ModelCategory,
|
||||
type ModelType,
|
||||
type ModelTypeListResponse,
|
||||
} from "~/services/modelTypes";
|
||||
import { useToast } from "~/composables/useToast";
|
||||
import { humanizeError } from "~/shared/utils/errorMessages";
|
||||
import { invalidateEntityTypeCache } from "~/composables/useEntityTypes";
|
||||
} from '~/services/modelTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
||||
import IconLucideSearch from '~icons/lucide/search'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
|
||||
const DEFAULT_DESCRIPTION =
|
||||
"Gérez les catégories utilisées pour structurer les catalogues de composants, de pièces et de produits. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.";
|
||||
const DEFAULT_DESCRIPTION
|
||||
= 'Gérez les catégories utilisées pour structurer les catalogues de composants, de pièces et de produits. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
category: ModelCategory;
|
||||
heading: string;
|
||||
description?: string;
|
||||
allowCategorySwitch?: boolean;
|
||||
category: ModelCategory
|
||||
heading: string
|
||||
description?: string
|
||||
allowCategorySwitch?: boolean
|
||||
}>(),
|
||||
{
|
||||
allowCategorySwitch: false,
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
|
||||
const selectedCategory = ref<ModelCategory>(props.category);
|
||||
const searchInput = ref("");
|
||||
const selectedCategory = ref<ModelCategory>(props.category)
|
||||
const searchInput = ref('')
|
||||
|
||||
// State synced with URL query params (preserved on back/forward navigation)
|
||||
// State synced with URL query params
|
||||
const urlState = useUrlState({
|
||||
q: { default: '' },
|
||||
sort: { default: 'name' },
|
||||
@@ -149,77 +211,126 @@ const urlState = useUrlState({
|
||||
offset: { default: 0, type: 'number' },
|
||||
}, {
|
||||
onRestore: () => {
|
||||
searchInput.value = urlState.q.value;
|
||||
refresh();
|
||||
searchInput.value = urlState.q.value
|
||||
doRefresh()
|
||||
},
|
||||
});
|
||||
const searchTerm = urlState.q;
|
||||
const sort = urlState.sort as Ref<'name' | 'createdAt'>;
|
||||
const dir = urlState.dir as Ref<'asc' | 'desc'>;
|
||||
const limit = urlState.limit;
|
||||
const offset = urlState.offset;
|
||||
})
|
||||
const searchTerm = urlState.q
|
||||
const sort = urlState.sort as Ref<'name' | 'createdAt'>
|
||||
const dir = urlState.dir as Ref<'asc' | 'desc'>
|
||||
const limit = urlState.limit
|
||||
const offset = urlState.offset
|
||||
|
||||
// Initialize searchInput from URL (for direct navigation with ?q=...)
|
||||
searchInput.value = searchTerm.value;
|
||||
// Initialize searchInput from URL
|
||||
searchInput.value = searchTerm.value
|
||||
|
||||
const items = ref<ModelType[]>([]);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const items = ref<ModelType[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let activeController: AbortController | null = null;
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let activeController: AbortController | null = null
|
||||
|
||||
const router = useRouter();
|
||||
const { showError, showSuccess } = useToast();
|
||||
const { get } = useApi();
|
||||
const { canEdit } = usePermissions();
|
||||
const router = useRouter()
|
||||
const { showError, showSuccess } = useToast()
|
||||
const { get } = useApi()
|
||||
const { canEdit } = usePermissions()
|
||||
|
||||
const headingText = computed(() => props.heading);
|
||||
const descriptionText = computed(
|
||||
() => props.description ?? DEFAULT_DESCRIPTION
|
||||
);
|
||||
const allowCategorySwitch = computed(() => props.allowCategorySwitch ?? false);
|
||||
const headingText = computed(() => props.heading)
|
||||
const descriptionText = computed(() => props.description ?? DEFAULT_DESCRIPTION)
|
||||
const allowCategorySwitch = computed(() => props.allowCategorySwitch ?? false)
|
||||
|
||||
useHead(() => ({
|
||||
title: headingText.value,
|
||||
}));
|
||||
useHead(() => ({ title: headingText.value }))
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Nom', sortable: true },
|
||||
{ key: 'notes', label: 'Notes' },
|
||||
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-48' },
|
||||
]
|
||||
|
||||
const showConvertButton = computed(() =>
|
||||
selectedCategory.value === 'PIECE' || selectedCategory.value === 'COMPONENT',
|
||||
)
|
||||
|
||||
const categories: Array<{ label: string, value: ModelCategory }> = [
|
||||
{ label: 'Composants', value: 'COMPONENT' },
|
||||
{ label: 'Pièces', value: 'PIECE' },
|
||||
{ label: 'Produits', value: 'PRODUCT' },
|
||||
]
|
||||
|
||||
// Sort state for DataTable
|
||||
const currentSort = computed<DataTableSort>(() => ({
|
||||
field: sort.value,
|
||||
direction: dir.value,
|
||||
}))
|
||||
|
||||
const handleSort = (newSort: DataTableSort) => {
|
||||
sort.value = newSort.field as 'name' | 'createdAt'
|
||||
dir.value = newSort.direction as 'asc' | 'desc'
|
||||
offset.value = 0
|
||||
doRefresh()
|
||||
}
|
||||
|
||||
// Pagination: convert offset/limit to page-based for DataTable
|
||||
const currentPage = computed(() => {
|
||||
if (limit.value <= 0) return 1
|
||||
return Math.floor(offset.value / limit.value) + 1
|
||||
})
|
||||
|
||||
const totalPages = computed(() => {
|
||||
if (limit.value <= 0) return 1
|
||||
return Math.max(1, Math.ceil(total.value / limit.value))
|
||||
})
|
||||
|
||||
const paginationState = computed(() => ({
|
||||
currentPage: currentPage.value,
|
||||
totalPages: totalPages.value,
|
||||
totalItems: total.value,
|
||||
pageItems: items.value.length,
|
||||
perPageOptions: [20, 50, 100],
|
||||
perPage: limit.value,
|
||||
}))
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
offset.value = (page - 1) * limit.value
|
||||
doRefresh()
|
||||
}
|
||||
|
||||
const handlePerPageChange = (perPage: number) => {
|
||||
limit.value = perPage
|
||||
offset.value = 0
|
||||
doRefresh()
|
||||
}
|
||||
|
||||
const extractErrorMessage = (error: unknown): string => {
|
||||
let raw: string | null = null;
|
||||
if (error && typeof error === "object") {
|
||||
let raw: string | null = null
|
||||
if (error && typeof error === 'object') {
|
||||
const maybeFetchError = error as {
|
||||
data?: Record<string, unknown>;
|
||||
statusMessage?: string;
|
||||
message?: string;
|
||||
};
|
||||
if (maybeFetchError.data) {
|
||||
const data = maybeFetchError.data;
|
||||
if (typeof data['hydra:description'] === "string") raw = data['hydra:description'];
|
||||
else if (typeof data.detail === "string") raw = data.detail;
|
||||
else if (typeof data.message === "string") raw = data.message;
|
||||
else if (Array.isArray(data.message) && data.message.length > 0) raw = data.message[0];
|
||||
else if (typeof data.error === "string") raw = data.error;
|
||||
data?: Record<string, unknown>
|
||||
statusMessage?: string
|
||||
message?: string
|
||||
}
|
||||
if (!raw && typeof maybeFetchError.statusMessage === "string") raw = maybeFetchError.statusMessage;
|
||||
if (!raw && typeof maybeFetchError.message === "string") raw = maybeFetchError.message;
|
||||
if (maybeFetchError.data) {
|
||||
const data = maybeFetchError.data
|
||||
if (typeof data['hydra:description'] === 'string') raw = data['hydra:description']
|
||||
else if (typeof data.detail === 'string') raw = data.detail
|
||||
else if (typeof data.message === 'string') raw = data.message
|
||||
else if (Array.isArray(data.message) && data.message.length > 0) raw = data.message[0]
|
||||
else if (typeof data.error === 'string') raw = data.error
|
||||
}
|
||||
if (!raw && typeof maybeFetchError.statusMessage === 'string') raw = maybeFetchError.statusMessage
|
||||
if (!raw && typeof maybeFetchError.message === 'string') raw = maybeFetchError.message
|
||||
}
|
||||
return humanizeError(raw);
|
||||
};
|
||||
return humanizeError(raw)
|
||||
}
|
||||
|
||||
const refresh = async ({
|
||||
resetOffset = false,
|
||||
}: { resetOffset?: boolean } = {}) => {
|
||||
if (resetOffset) {
|
||||
offset.value = 0;
|
||||
}
|
||||
const doRefresh = async ({ resetOffset = false }: { resetOffset?: boolean } = {}) => {
|
||||
if (resetOffset) offset.value = 0
|
||||
|
||||
if (activeController) {
|
||||
activeController.abort();
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
activeController = controller;
|
||||
loading.value = true;
|
||||
if (activeController) activeController.abort()
|
||||
const controller = new AbortController()
|
||||
activeController = controller
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response: ModelTypeListResponse = await listModelTypes(
|
||||
@@ -231,312 +342,236 @@ const refresh = async ({
|
||||
limit: limit.value,
|
||||
offset: offset.value,
|
||||
},
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
|
||||
items.value = response.items;
|
||||
total.value = response.total;
|
||||
offset.value = response.offset;
|
||||
limit.value = response.limit;
|
||||
} catch (error: unknown) {
|
||||
if (error && typeof error === "object" && (error as { name?: string }).name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
showError(extractErrorMessage(error));
|
||||
} finally {
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
items.value = response.items
|
||||
total.value = response.total
|
||||
offset.value = response.offset
|
||||
limit.value = response.limit
|
||||
}
|
||||
catch (error: unknown) {
|
||||
if (error && typeof error === 'object' && (error as { name?: string }).name === 'AbortError') return
|
||||
showError(extractErrorMessage(error))
|
||||
}
|
||||
finally {
|
||||
if (activeController === controller) {
|
||||
loading.value = false;
|
||||
activeController = null;
|
||||
loading.value = false
|
||||
activeController = null
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.category,
|
||||
(value) => {
|
||||
if (value !== selectedCategory.value) {
|
||||
selectedCategory.value = value;
|
||||
refresh({ resetOffset: true });
|
||||
selectedCategory.value = value
|
||||
doRefresh({ resetOffset: true })
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const onSearchInput = (value: string) => {
|
||||
searchInput.value = value;
|
||||
};
|
||||
},
|
||||
)
|
||||
|
||||
const onCategoryChange = (value: ModelCategory) => {
|
||||
if (!allowCategorySwitch.value) {
|
||||
return;
|
||||
}
|
||||
if (!props.allowCategorySwitch) return
|
||||
if (selectedCategory.value !== value) {
|
||||
selectedCategory.value = value;
|
||||
refresh({ resetOffset: true });
|
||||
selectedCategory.value = value
|
||||
doRefresh({ resetOffset: true })
|
||||
}
|
||||
};
|
||||
|
||||
const onSortChange = (value: "name" | "createdAt") => {
|
||||
if (sort.value !== value) {
|
||||
sort.value = value;
|
||||
refresh({ resetOffset: true });
|
||||
}
|
||||
};
|
||||
|
||||
const onDirChange = (value: "asc" | "desc") => {
|
||||
if (dir.value !== value) {
|
||||
dir.value = value;
|
||||
refresh({ resetOffset: true });
|
||||
}
|
||||
};
|
||||
|
||||
const onOffsetChange = (value: number) => {
|
||||
const nextOffset = Math.max(0, value);
|
||||
if (nextOffset !== offset.value) {
|
||||
offset.value = nextOffset;
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const resolveCategoryBasePath = (category: ModelCategory) => {
|
||||
if (category === "COMPONENT") {
|
||||
return "/component-category";
|
||||
}
|
||||
if (category === "PIECE") {
|
||||
return "/piece-category";
|
||||
}
|
||||
return "/product-category";
|
||||
};
|
||||
if (category === 'COMPONENT') return '/component-category'
|
||||
if (category === 'PIECE') return '/piece-category'
|
||||
return '/product-category'
|
||||
}
|
||||
|
||||
const openCreatePage = () => {
|
||||
const basePath = resolveCategoryBasePath(selectedCategory.value);
|
||||
const basePath = resolveCategoryBasePath(selectedCategory.value)
|
||||
router.push(`${basePath}/new`).catch(() => {
|
||||
showError("Navigation impossible vers la page de création.");
|
||||
});
|
||||
};
|
||||
showError('Navigation impossible vers la page de création.')
|
||||
})
|
||||
}
|
||||
|
||||
const openEditPage = (item: ModelType) => {
|
||||
const category = item.category ?? selectedCategory.value;
|
||||
const basePath = resolveCategoryBasePath(category);
|
||||
const category = item.category ?? selectedCategory.value
|
||||
const basePath = resolveCategoryBasePath(category)
|
||||
router.push(`${basePath}/${item.id}/edit`).catch(() => {
|
||||
showError("Navigation impossible vers la page d'édition.");
|
||||
});
|
||||
};
|
||||
showError("Navigation impossible vers la page d'édition.")
|
||||
})
|
||||
}
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
const confirmDelete = async (item: ModelType) => {
|
||||
const confirmed = await confirm({
|
||||
message: 'Supprimer ce type ? Cette action est irréversible.',
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await deleteModelType(item.id);
|
||||
invalidateEntityTypeCache(item.category);
|
||||
showSuccess(`Type « ${item.name} » supprimé avec succès.`);
|
||||
|
||||
await deleteModelType(item.id)
|
||||
invalidateEntityTypeCache(item.category)
|
||||
showSuccess(`Type « ${item.name} » supprimé avec succès.`)
|
||||
if (items.value.length === 1 && offset.value >= limit.value) {
|
||||
offset.value = Math.max(0, offset.value - limit.value);
|
||||
offset.value = Math.max(0, offset.value - limit.value)
|
||||
}
|
||||
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
showError(extractErrorMessage(error));
|
||||
await doRefresh()
|
||||
}
|
||||
};
|
||||
catch (error) {
|
||||
showError(extractErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
type RelatedEntry = {
|
||||
id: string;
|
||||
name: string;
|
||||
reference?: string | null;
|
||||
};
|
||||
id: string
|
||||
name: string
|
||||
reference?: string | null
|
||||
}
|
||||
|
||||
const relatedModalOpen = ref(false);
|
||||
const relatedLoading = ref(false);
|
||||
const relatedError = ref<string | null>(null);
|
||||
const relatedItems = ref<RelatedEntry[]>([]);
|
||||
const relatedType = ref<ModelType | null>(null);
|
||||
const relatedModalOpen = ref(false)
|
||||
const relatedLoading = ref(false)
|
||||
const relatedError = ref<string | null>(null)
|
||||
const relatedItems = ref<RelatedEntry[]>([])
|
||||
const relatedType = ref<ModelType | null>(null)
|
||||
|
||||
const relatedCategoryLabels: Record<
|
||||
ModelCategory,
|
||||
{ plural: string; singular: string }
|
||||
> = {
|
||||
COMPONENT: { plural: "composants", singular: "composant" },
|
||||
PIECE: { plural: "pièces", singular: "pièce" },
|
||||
PRODUCT: { plural: "produits", singular: "produit" },
|
||||
};
|
||||
const relatedCategoryLabels: Record<ModelCategory, { plural: string, singular: string }> = {
|
||||
COMPONENT: { plural: 'composants', singular: 'composant' },
|
||||
PIECE: { plural: 'pièces', singular: 'pièce' },
|
||||
PRODUCT: { plural: 'produits', singular: 'produit' },
|
||||
}
|
||||
|
||||
const relatedModalTitle = computed(() => {
|
||||
const current = relatedType.value;
|
||||
if (!current) {
|
||||
return "Éléments liés";
|
||||
}
|
||||
return `Éléments liés à « ${current.name} »`;
|
||||
});
|
||||
const current = relatedType.value
|
||||
if (!current) return 'Éléments liés'
|
||||
return `Éléments liés à « ${current.name} »`
|
||||
})
|
||||
|
||||
const relatedModalSubtitle = computed(() => {
|
||||
const current = relatedType.value;
|
||||
if (!current) {
|
||||
return "";
|
||||
}
|
||||
const labels =
|
||||
relatedCategoryLabels[current.category] ?? relatedCategoryLabels.COMPONENT;
|
||||
const count = relatedItems.value.length;
|
||||
if (relatedLoading.value) {
|
||||
return `Chargement des ${labels.plural}…`;
|
||||
}
|
||||
if (count === 0) {
|
||||
return `Aucun ${labels.singular} lié.`;
|
||||
}
|
||||
if (count === 1) {
|
||||
return `1 ${labels.singular} lié.`;
|
||||
}
|
||||
return `${count} ${labels.plural} liés.`;
|
||||
});
|
||||
const current = relatedType.value
|
||||
if (!current) return ''
|
||||
const labels = relatedCategoryLabels[current.category] ?? relatedCategoryLabels.COMPONENT
|
||||
const count = relatedItems.value.length
|
||||
if (relatedLoading.value) return `Chargement des ${labels.plural}…`
|
||||
if (count === 0) return `Aucun ${labels.singular} lié.`
|
||||
if (count === 1) return `1 ${labels.singular} lié.`
|
||||
return `${count} ${labels.plural} liés.`
|
||||
})
|
||||
|
||||
const buildModelTypeIri = (id: string) => `/api/model_types/${id}`;
|
||||
const buildModelTypeIri = (id: string) => `/api/model_types/${id}`
|
||||
|
||||
const resolveRelatedConfig = (category: ModelCategory) => {
|
||||
if (category === "COMPONENT") {
|
||||
return { endpoint: "/composants", filterKey: "typeComposant" };
|
||||
}
|
||||
if (category === "PIECE") {
|
||||
return { endpoint: "/pieces", filterKey: "typePiece" };
|
||||
}
|
||||
return { endpoint: "/products", filterKey: "typeProduct" };
|
||||
};
|
||||
if (category === 'COMPONENT') return { endpoint: '/composants', filterKey: 'typeComposant' }
|
||||
if (category === 'PIECE') return { endpoint: '/pieces', filterKey: 'typePiece' }
|
||||
return { endpoint: '/products', filterKey: 'typeProduct' }
|
||||
}
|
||||
|
||||
const resolveRelatedEditBasePath = (category: ModelCategory) => {
|
||||
if (category === "COMPONENT") {
|
||||
return "/component";
|
||||
}
|
||||
if (category === "PIECE") {
|
||||
return "/pieces";
|
||||
}
|
||||
return "/product";
|
||||
};
|
||||
if (category === 'COMPONENT') return '/component'
|
||||
if (category === 'PIECE') return '/pieces'
|
||||
return '/product'
|
||||
}
|
||||
|
||||
const mapRelatedEntry = (item: unknown): RelatedEntry | null => {
|
||||
if (!item || typeof item !== "object") {
|
||||
return null;
|
||||
}
|
||||
const record = item as Record<string, unknown>;
|
||||
if (typeof record.id !== "string") {
|
||||
return null;
|
||||
}
|
||||
const name =
|
||||
typeof record.name === "string" && record.name.trim()
|
||||
? record.name
|
||||
: "Sans nom";
|
||||
const reference =
|
||||
typeof record.reference === "string" && record.reference.trim()
|
||||
if (!item || typeof item !== 'object') return null
|
||||
const record = item as Record<string, unknown>
|
||||
if (typeof record.id !== 'string') return null
|
||||
const name = typeof record.name === 'string' && record.name.trim() ? record.name : 'Sans nom'
|
||||
const reference
|
||||
= typeof record.reference === 'string' && record.reference.trim()
|
||||
? record.reference
|
||||
: typeof record.code === "string" && record.code.trim()
|
||||
: typeof record.code === 'string' && record.code.trim()
|
||||
? record.code
|
||||
: null;
|
||||
return {
|
||||
id: record.id,
|
||||
name,
|
||||
reference,
|
||||
};
|
||||
};
|
||||
: null
|
||||
return { id: record.id, name, reference }
|
||||
}
|
||||
|
||||
const loadRelatedItems = async (item: ModelType) => {
|
||||
const { endpoint, filterKey } = resolveRelatedConfig(item.category);
|
||||
const params = new URLSearchParams();
|
||||
params.set("itemsPerPage", "200");
|
||||
params.set(filterKey, buildModelTypeIri(item.id));
|
||||
params.set("order[name]", "asc");
|
||||
const { endpoint, filterKey } = resolveRelatedConfig(item.category)
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', '200')
|
||||
params.set(filterKey, buildModelTypeIri(item.id))
|
||||
params.set('order[name]', 'asc')
|
||||
|
||||
relatedLoading.value = true;
|
||||
relatedError.value = null;
|
||||
relatedItems.value = [];
|
||||
relatedLoading.value = true
|
||||
relatedError.value = null
|
||||
relatedItems.value = []
|
||||
|
||||
try {
|
||||
const result = await get(`${endpoint}?${params.toString()}`);
|
||||
const result = await get(`${endpoint}?${params.toString()}`)
|
||||
if (!result.success) {
|
||||
relatedError.value =
|
||||
result.error ?? "Impossible de charger les éléments liés.";
|
||||
return;
|
||||
relatedError.value = result.error ?? 'Impossible de charger les éléments liés.'
|
||||
return
|
||||
}
|
||||
const collection = extractCollection(result.data);
|
||||
const collection = extractCollection(result.data)
|
||||
relatedItems.value = collection
|
||||
.map(mapRelatedEntry)
|
||||
.filter((entry): entry is RelatedEntry => Boolean(entry));
|
||||
} catch (error) {
|
||||
relatedError.value = extractErrorMessage(error);
|
||||
} finally {
|
||||
relatedLoading.value = false;
|
||||
.filter((entry): entry is RelatedEntry => Boolean(entry))
|
||||
}
|
||||
};
|
||||
catch (error) {
|
||||
relatedError.value = extractErrorMessage(error)
|
||||
}
|
||||
finally {
|
||||
relatedLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openRelatedModal = (item: ModelType) => {
|
||||
relatedType.value = item;
|
||||
relatedModalOpen.value = true;
|
||||
void loadRelatedItems(item);
|
||||
};
|
||||
relatedType.value = item
|
||||
relatedModalOpen.value = true
|
||||
void loadRelatedItems(item)
|
||||
}
|
||||
|
||||
const openRelatedEdit = (entry: RelatedEntry) => {
|
||||
const current = relatedType.value;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
const basePath = resolveRelatedEditBasePath(current.category);
|
||||
relatedModalOpen.value = false;
|
||||
const current = relatedType.value
|
||||
if (!current) return
|
||||
const basePath = resolveRelatedEditBasePath(current.category)
|
||||
relatedModalOpen.value = false
|
||||
router.push(`${basePath}/${entry.id}/edit`).catch(() => {
|
||||
showError("Navigation impossible vers la fiche d'édition.");
|
||||
});
|
||||
};
|
||||
showError("Navigation impossible vers la fiche d'édition.")
|
||||
})
|
||||
}
|
||||
|
||||
const closeRelatedModal = () => {
|
||||
relatedModalOpen.value = false;
|
||||
};
|
||||
relatedModalOpen.value = false
|
||||
}
|
||||
|
||||
const conversionModalOpen = ref(false);
|
||||
const conversionTarget = ref<ModelType | null>(null);
|
||||
const conversionModalOpen = ref(false)
|
||||
const conversionTarget = ref<ModelType | null>(null)
|
||||
|
||||
const openConversionModal = (item: ModelType) => {
|
||||
conversionTarget.value = item;
|
||||
conversionModalOpen.value = true;
|
||||
};
|
||||
conversionTarget.value = item
|
||||
conversionModalOpen.value = true
|
||||
}
|
||||
|
||||
const closeConversionModal = () => {
|
||||
conversionModalOpen.value = false;
|
||||
};
|
||||
conversionModalOpen.value = false
|
||||
}
|
||||
|
||||
const onConverted = () => {
|
||||
conversionModalOpen.value = false;
|
||||
invalidateEntityTypeCache("PIECE");
|
||||
invalidateEntityTypeCache("COMPONENT");
|
||||
showSuccess("Catégorie convertie avec succès.");
|
||||
refresh();
|
||||
};
|
||||
conversionModalOpen.value = false
|
||||
invalidateEntityTypeCache('PIECE')
|
||||
invalidateEntityTypeCache('COMPONENT')
|
||||
showSuccess('Catégorie convertie avec succès.')
|
||||
doRefresh()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => searchInput.value,
|
||||
(value) => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
searchTerm.value = value.trim();
|
||||
refresh({ resetOffset: true });
|
||||
}, 300);
|
||||
}
|
||||
);
|
||||
searchTerm.value = value.trim()
|
||||
doRefresh({ resetOffset: true })
|
||||
}, 300)
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
refresh();
|
||||
});
|
||||
doRefresh()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
if (activeController) {
|
||||
activeController.abort();
|
||||
}
|
||||
});
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
if (activeController) activeController.abort()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -68,15 +68,21 @@ export function useComments() {
|
||||
const fetchAllComments = async (options: {
|
||||
status?: string
|
||||
entityType?: string
|
||||
entityName?: string
|
||||
page?: number
|
||||
itemsPerPage?: number
|
||||
orderBy?: string
|
||||
orderDir?: string
|
||||
} = {}): Promise<CommentListResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (options.status) params.set('status', options.status)
|
||||
if (options.entityType) params.set('entityType', options.entityType)
|
||||
params.set('order[createdAt]', 'desc')
|
||||
if (options.entityName) params.set('entityName', options.entityName)
|
||||
const sortField = options.orderBy || 'createdAt'
|
||||
const sortDir = options.orderDir || 'desc'
|
||||
params.set(`order[${sortField}]`, sortDir)
|
||||
params.set('itemsPerPage', String(options.itemsPerPage || 30))
|
||||
params.set('page', String(options.page || 1))
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ interface LoadComposantsOptions {
|
||||
itemsPerPage?: number
|
||||
orderBy?: string
|
||||
orderDir?: 'asc' | 'desc'
|
||||
typeName?: string
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
@@ -107,10 +108,11 @@ export function useComposants() {
|
||||
itemsPerPage = 30,
|
||||
orderBy = 'name',
|
||||
orderDir = 'asc',
|
||||
typeName,
|
||||
force = false,
|
||||
} = options
|
||||
|
||||
if (!force && loaded.value && !search && page === 1) {
|
||||
if (!force && loaded.value && !search && !typeName && page === 1) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: composants.value, total: total.value, page, itemsPerPage },
|
||||
@@ -135,6 +137,10 @@ export function useComposants() {
|
||||
params.set('name', search.trim())
|
||||
}
|
||||
|
||||
if (typeName && typeName.trim()) {
|
||||
params.set('typeComposant.name', typeName.trim())
|
||||
}
|
||||
|
||||
params.set(`order[${orderBy}]`, orderDir)
|
||||
|
||||
const result = await get(`/composants?${params.toString()}`)
|
||||
|
||||
186
app/composables/useDataTable.ts
Normal file
186
app/composables/useDataTable.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { ref, computed, type Ref, type ComputedRef } from 'vue'
|
||||
import { useUrlState } from './useUrlState'
|
||||
import type { DataTableSort, DataTablePagination, DataTableColumnFilters, SortDirection } from '~/shared/types/dataTable'
|
||||
|
||||
export interface UseDataTableDeps {
|
||||
/** Called whenever sort/page/search/perPage/filter changes. The composable does NOT fetch data itself. */
|
||||
fetchData: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export interface UseDataTableOptions {
|
||||
/** Default sort field */
|
||||
defaultSort?: string
|
||||
/** Default sort direction */
|
||||
defaultDirection?: SortDirection
|
||||
/** Default items per page */
|
||||
defaultPerPage?: number
|
||||
/** Available per-page options */
|
||||
perPageOptions?: number[]
|
||||
/** Search debounce in ms. Default: 300 */
|
||||
searchDebounceMs?: number
|
||||
/** Whether to persist state to URL. Default: true */
|
||||
persistToUrl?: boolean
|
||||
/** Extra URL state params for page-specific filters */
|
||||
extraParams?: Record<string, { default: string | number; type?: 'string' | 'number' }>
|
||||
}
|
||||
|
||||
export interface UseDataTableReturn {
|
||||
searchTerm: Ref<string>
|
||||
sortField: Ref<string>
|
||||
sortDirection: Ref<SortDirection>
|
||||
currentPage: Ref<number>
|
||||
itemsPerPage: Ref<number>
|
||||
columnFilters: Ref<DataTableColumnFilters>
|
||||
filters: Record<string, Ref<string | number>>
|
||||
sort: ComputedRef<DataTableSort>
|
||||
pagination: (total: Ref<number>, pageItems: Ref<number>) => ComputedRef<DataTablePagination>
|
||||
handleSort: (newSort: DataTableSort) => void
|
||||
handlePageChange: (page: number) => void
|
||||
handlePerPageChange: (perPage: number) => void
|
||||
handleFilterChange: () => void
|
||||
handleColumnFiltersChange: (filters: DataTableColumnFilters) => void
|
||||
debouncedSearch: () => void
|
||||
refresh: () => void
|
||||
perPageOptions: number[]
|
||||
}
|
||||
|
||||
export function useDataTable(
|
||||
deps: UseDataTableDeps,
|
||||
options: UseDataTableOptions = {},
|
||||
): UseDataTableReturn {
|
||||
const {
|
||||
defaultSort = 'name',
|
||||
defaultDirection = 'asc',
|
||||
defaultPerPage = 20,
|
||||
perPageOptions = [20, 50, 100],
|
||||
searchDebounceMs = 300,
|
||||
persistToUrl = true,
|
||||
extraParams = {},
|
||||
} = options
|
||||
|
||||
let searchTerm: Ref<string>
|
||||
let sortField: Ref<string>
|
||||
let sortDirection: Ref<SortDirection>
|
||||
let currentPage: Ref<number>
|
||||
let itemsPerPage: Ref<number>
|
||||
const filters: Record<string, Ref<string | number>> = {}
|
||||
|
||||
if (persistToUrl) {
|
||||
const paramDefs: Record<string, { default: string | number; type?: 'string' | 'number'; debounce?: number }> = {
|
||||
page: { default: 1, type: 'number' },
|
||||
perPage: { default: defaultPerPage, type: 'number' },
|
||||
q: { default: '', debounce: searchDebounceMs },
|
||||
sort: { default: defaultSort },
|
||||
dir: { default: defaultDirection },
|
||||
...extraParams,
|
||||
}
|
||||
|
||||
const state = useUrlState(paramDefs, {
|
||||
onRestore: () => deps.fetchData(),
|
||||
})
|
||||
|
||||
searchTerm = state.q as Ref<string>
|
||||
sortField = state.sort as Ref<string>
|
||||
sortDirection = state.dir as unknown as Ref<SortDirection>
|
||||
currentPage = state.page as unknown as Ref<number>
|
||||
itemsPerPage = state.perPage as unknown as Ref<number>
|
||||
|
||||
for (const key of Object.keys(extraParams)) {
|
||||
filters[key] = (state as Record<string, Ref<string | number>>)[key]!
|
||||
}
|
||||
}
|
||||
else {
|
||||
searchTerm = ref('')
|
||||
sortField = ref(defaultSort)
|
||||
sortDirection = ref(defaultDirection) as Ref<SortDirection>
|
||||
currentPage = ref(1)
|
||||
itemsPerPage = ref(defaultPerPage)
|
||||
|
||||
for (const [key, def] of Object.entries(extraParams)) {
|
||||
filters[key] = ref(def.default)
|
||||
}
|
||||
}
|
||||
|
||||
// Search debounce
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const debouncedSearch = () => {
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage.value = 1
|
||||
deps.fetchData()
|
||||
}, searchDebounceMs)
|
||||
}
|
||||
|
||||
// Sort
|
||||
const sort = computed<DataTableSort>(() => ({
|
||||
field: sortField.value,
|
||||
direction: sortDirection.value,
|
||||
}))
|
||||
|
||||
const handleSort = (newSort: DataTableSort) => {
|
||||
sortField.value = newSort.field
|
||||
sortDirection.value = newSort.direction
|
||||
currentPage.value = 1
|
||||
deps.fetchData()
|
||||
}
|
||||
|
||||
// Pagination
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
deps.fetchData()
|
||||
}
|
||||
|
||||
const handlePerPageChange = (perPage: number) => {
|
||||
itemsPerPage.value = perPage
|
||||
currentPage.value = 1
|
||||
deps.fetchData()
|
||||
}
|
||||
|
||||
// Column filters
|
||||
const columnFilters = ref<DataTableColumnFilters>({})
|
||||
|
||||
const handleColumnFiltersChange = (newFilters: DataTableColumnFilters) => {
|
||||
columnFilters.value = newFilters
|
||||
currentPage.value = 1
|
||||
deps.fetchData()
|
||||
}
|
||||
|
||||
// Generic filter change handler (resets page and refetches)
|
||||
const handleFilterChange = () => {
|
||||
currentPage.value = 1
|
||||
deps.fetchData()
|
||||
}
|
||||
|
||||
const pagination = (total: Ref<number>, pageItems: Ref<number>): ComputedRef<DataTablePagination> =>
|
||||
computed(() => ({
|
||||
currentPage: currentPage.value,
|
||||
totalPages: Math.ceil(total.value / itemsPerPage.value) || 1,
|
||||
totalItems: total.value,
|
||||
pageItems: pageItems.value,
|
||||
perPageOptions,
|
||||
perPage: itemsPerPage.value,
|
||||
}))
|
||||
|
||||
const refresh = () => deps.fetchData()
|
||||
|
||||
return {
|
||||
searchTerm,
|
||||
sortField,
|
||||
sortDirection,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
columnFilters,
|
||||
filters,
|
||||
sort,
|
||||
pagination,
|
||||
handleSort,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
handleFilterChange,
|
||||
handleColumnFiltersChange,
|
||||
debouncedSearch,
|
||||
refresh,
|
||||
perPageOptions,
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ interface LoadPiecesOptions {
|
||||
itemsPerPage?: number
|
||||
orderBy?: string
|
||||
orderDir?: 'asc' | 'desc'
|
||||
typeName?: string
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
@@ -117,10 +118,11 @@ export function usePieces() {
|
||||
itemsPerPage = 30,
|
||||
orderBy = 'name',
|
||||
orderDir = 'asc',
|
||||
typeName,
|
||||
force = false,
|
||||
} = options
|
||||
|
||||
if (!force && loaded.value && !search && page === 1) {
|
||||
if (!force && loaded.value && !search && !typeName && page === 1) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: pieces.value, total: total.value, page, itemsPerPage },
|
||||
@@ -145,6 +147,10 @@ export function usePieces() {
|
||||
params.set('name', search.trim())
|
||||
}
|
||||
|
||||
if (typeName && typeName.trim()) {
|
||||
params.set('typePiece.name', typeName.trim())
|
||||
}
|
||||
|
||||
params.set(`order[${orderBy}]`, orderDir)
|
||||
|
||||
const result = await get(`/pieces?${params.toString()}`)
|
||||
|
||||
@@ -40,6 +40,7 @@ interface LoadProductsOptions {
|
||||
itemsPerPage?: number
|
||||
orderBy?: string
|
||||
orderDir?: 'asc' | 'desc'
|
||||
typeName?: string
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
@@ -116,10 +117,11 @@ export function useProducts() {
|
||||
itemsPerPage = 30,
|
||||
orderBy = 'name',
|
||||
orderDir = 'asc',
|
||||
typeName,
|
||||
force = false,
|
||||
} = options
|
||||
|
||||
if (!force && loaded.value && !search && page === 1) {
|
||||
if (!force && loaded.value && !search && !typeName && page === 1) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: products.value, total: total.value, page, itemsPerPage },
|
||||
@@ -144,6 +146,10 @@ export function useProducts() {
|
||||
params.set('name', search.trim())
|
||||
}
|
||||
|
||||
if (typeName && typeName.trim()) {
|
||||
params.set('typeProduct.name', typeName.trim())
|
||||
}
|
||||
|
||||
params.set(`order[${orderBy}]`, orderDir)
|
||||
|
||||
const result = await get(`/products?${params.toString()}`)
|
||||
|
||||
@@ -9,8 +9,24 @@
|
||||
|
||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||
<div class="card-body space-y-4">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:rows="entries"
|
||||
:loading="loading"
|
||||
:pagination="paginationState"
|
||||
:show-per-page="true"
|
||||
:show-counter="true"
|
||||
:expandable="true"
|
||||
:expanded-keys="expandedIds"
|
||||
:can-expand="canExpandRow"
|
||||
row-key="id"
|
||||
empty-message="Aucune activité enregistrée."
|
||||
no-results-message="Aucune activité ne correspond à vos filtres."
|
||||
@update:current-page="table.handlePageChange"
|
||||
@update:per-page="table.handlePerPageChange"
|
||||
@toggle-expand="toggleExpanded"
|
||||
>
|
||||
<template #toolbar>
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
@@ -22,7 +38,7 @@
|
||||
id="activity-entity-type"
|
||||
v-model="entityTypeFilter"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleFilterChange"
|
||||
@change="table.handleFilterChange"
|
||||
>
|
||||
<option value="">Tous</option>
|
||||
<option value="piece">Pièce</option>
|
||||
@@ -42,7 +58,7 @@
|
||||
id="activity-action"
|
||||
v-model="actionFilter"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleFilterChange"
|
||||
@change="table.handleFilterChange"
|
||||
>
|
||||
<option value="">Toutes</option>
|
||||
<option value="create">Création</option>
|
||||
@@ -50,157 +66,111 @@
|
||||
<option value="delete">Suppression</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="activity-per-page"
|
||||
<template #cell-createdAt="{ row }">
|
||||
<span class="whitespace-nowrap">{{ formatHistoryDate(row.createdAt) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-action="{ row }">
|
||||
<span
|
||||
class="badge badge-sm"
|
||||
:class="actionBadgeClass(row.action)"
|
||||
>
|
||||
{{ historyActionLabel(row.action) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-entityType="{ row }">
|
||||
<span class="badge badge-ghost badge-sm">
|
||||
{{ entityTypeLabel(row.entityType) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-entity="{ row }">
|
||||
<NuxtLink
|
||||
v-if="row.action !== 'delete'"
|
||||
:to="entityEditLink(row)"
|
||||
class="link link-hover link-primary"
|
||||
>
|
||||
{{ row.entityName || 'Sans nom' }}
|
||||
</NuxtLink>
|
||||
<span v-else class="text-base-content/50 line-through">
|
||||
{{ row.entityName || 'Sans nom' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.entityRef"
|
||||
class="text-xs text-base-content/50 ml-1"
|
||||
>
|
||||
({{ row.entityRef }})
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-author="{ row }">
|
||||
{{ row.actor?.label || '—' }}
|
||||
</template>
|
||||
|
||||
<template #row-expanded="{ row }">
|
||||
<div class="space-y-1 text-sm">
|
||||
<div
|
||||
v-for="diffEntry in historyDiffEntries(row, globalFieldLabels)"
|
||||
:key="diffEntry.field"
|
||||
class="flex gap-2"
|
||||
>
|
||||
Par page
|
||||
</label>
|
||||
<select
|
||||
id="activity-per-page"
|
||||
v-model.number="itemsPerPage"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<option :value="20">20</option>
|
||||
<option :value="50">50</option>
|
||||
<option :value="100">100</option>
|
||||
</select>
|
||||
<span class="font-medium min-w-[10rem]">{{ diffEntry.label }} :</span>
|
||||
<span class="text-error line-through">{{ diffEntry.fromLabel }}</span>
|
||||
<span>→</span>
|
||||
<span class="text-success">{{ diffEntry.toLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-base-content/50 lg:text-right">
|
||||
{{ entries.length }} / {{ total }} résultat{{ total > 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex justify-center py-8">
|
||||
<span class="loading loading-spinner" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<p v-else-if="!total" class="text-sm text-base-content/70">
|
||||
Aucune activité enregistrée.
|
||||
</p>
|
||||
|
||||
<p v-else-if="!entries.length" class="text-sm text-base-content/70">
|
||||
Aucune activité ne correspond à vos filtres.
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm md:table-md">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Action</th>
|
||||
<th>Type</th>
|
||||
<th>Entité</th>
|
||||
<th>Auteur</th>
|
||||
<th>Détails</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="entry in entries" :key="entry.id">
|
||||
<tr>
|
||||
<td class="whitespace-nowrap">{{ formatHistoryDate(entry.createdAt) }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="badge badge-sm"
|
||||
:class="actionBadgeClass(entry.action)"
|
||||
>
|
||||
{{ historyActionLabel(entry.action) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-ghost badge-sm">
|
||||
{{ entityTypeLabel(entry.entityType) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<NuxtLink
|
||||
v-if="entry.action !== 'delete'"
|
||||
:to="entityEditLink(entry)"
|
||||
class="link link-hover link-primary"
|
||||
>
|
||||
{{ entry.entityName || 'Sans nom' }}
|
||||
</NuxtLink>
|
||||
<span v-else class="text-base-content/50 line-through">
|
||||
{{ entry.entityName || 'Sans nom' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="entry.entityRef"
|
||||
class="text-xs text-base-content/50 ml-1"
|
||||
>
|
||||
({{ entry.entityRef }})
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ entry.actor?.label || '—' }}</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="hasDiff(entry)"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
@click="toggleExpanded(entry.id)"
|
||||
>
|
||||
{{ expandedIds.has(entry.id) ? 'Masquer' : 'Voir' }}
|
||||
</button>
|
||||
<span v-else class="text-xs text-base-content/50">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-if="expandedIds.has(entry.id)">
|
||||
<td colspan="6" class="bg-base-200/50 p-4">
|
||||
<div class="space-y-1 text-sm">
|
||||
<div
|
||||
v-for="diffEntry in historyDiffEntries(entry, globalFieldLabels)"
|
||||
:key="diffEntry.field"
|
||||
class="flex gap-2"
|
||||
>
|
||||
<span class="font-medium min-w-[10rem]">{{ diffEntry.label }} :</span>
|
||||
<span class="text-error line-through">{{ diffEntry.fromLabel }}</span>
|
||||
<span>→</span>
|
||||
<span class="text-success">{{ diffEntry.toLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
:current-page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
@update:current-page="handlePageChange"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, type Ref } from 'vue'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import { useActivityLog } from '~/composables/useActivityLog'
|
||||
import type { ActivityLogEntry } from '~/composables/useActivityLog'
|
||||
import { useDataTable } from '~/composables/useDataTable'
|
||||
import {
|
||||
historyActionLabel,
|
||||
formatHistoryDate,
|
||||
historyDiffEntries,
|
||||
} from '~/shared/utils/historyDisplayUtils'
|
||||
import Pagination from '~/components/common/Pagination.vue'
|
||||
|
||||
const { entries, total, loading, loadActivityLog } = useActivityLog()
|
||||
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = ref(50)
|
||||
const totalPages = computed(() => Math.ceil(total.value / itemsPerPage.value) || 1)
|
||||
const table = useDataTable(
|
||||
{ fetchData: fetchLog },
|
||||
{
|
||||
defaultSort: 'createdAt',
|
||||
defaultDirection: 'desc',
|
||||
defaultPerPage: 50,
|
||||
persistToUrl: true,
|
||||
extraParams: {
|
||||
entityType: { default: '' },
|
||||
action: { default: '' },
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const entityTypeFilter = ref('')
|
||||
const actionFilter = ref('')
|
||||
const entityTypeFilter = table.filters.entityType as Ref<string>
|
||||
const actionFilter = table.filters.action as Ref<string>
|
||||
|
||||
const entriesOnPage = computed(() => entries.value.length)
|
||||
const paginationState = table.pagination(total, entriesOnPage)
|
||||
|
||||
const columns = [
|
||||
{ key: 'createdAt', label: 'Date' },
|
||||
{ key: 'action', label: 'Action' },
|
||||
{ key: 'entityType', label: 'Type' },
|
||||
{ key: 'entity', label: 'Entité' },
|
||||
{ key: 'author', label: 'Auteur' },
|
||||
]
|
||||
|
||||
const expandedIds = reactive(new Set<string>())
|
||||
|
||||
@@ -209,28 +179,18 @@ const toggleExpanded = (id: string) => {
|
||||
else expandedIds.add(id)
|
||||
}
|
||||
|
||||
const hasDiff = (entry: ActivityLogEntry) =>
|
||||
entry.diff !== null && entry.diff !== undefined && Object.keys(entry.diff).length > 0
|
||||
const canExpandRow = (row: any) =>
|
||||
row.diff !== null && row.diff !== undefined && Object.keys(row.diff).length > 0
|
||||
|
||||
const fetchLog = () => {
|
||||
function fetchLog() {
|
||||
loadActivityLog({
|
||||
page: currentPage.value,
|
||||
itemsPerPage: itemsPerPage.value,
|
||||
page: table.currentPage.value,
|
||||
itemsPerPage: table.itemsPerPage.value,
|
||||
entityType: entityTypeFilter.value || undefined,
|
||||
action: actionFilter.value || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const handleFilterChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchLog()
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
fetchLog()
|
||||
}
|
||||
|
||||
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||
piece: 'Pièce',
|
||||
product: 'Produit',
|
||||
|
||||
@@ -9,82 +9,66 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg" />
|
||||
</div>
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:rows="sortedProfiles"
|
||||
:loading="isLoading"
|
||||
:sort="sortState"
|
||||
:show-counter="false"
|
||||
table-class="table-zebra"
|
||||
empty-message="Aucun profil."
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #cell-name="{ row }">
|
||||
<span class="font-medium">{{ row.firstName }} {{ row.lastName }}</span>
|
||||
</template>
|
||||
|
||||
<div v-else-if="profiles.length" class="overflow-x-auto">
|
||||
<table class="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Mot de passe</th>
|
||||
<th>Statut</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="profile in profiles" :key="profile.id">
|
||||
<td class="font-medium">
|
||||
{{ profile.firstName }} {{ profile.lastName }}
|
||||
</td>
|
||||
<td class="text-sm text-base-content/70">
|
||||
{{ profile.email || '-' }}
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
class="select select-bordered select-xs"
|
||||
:value="primaryRole(profile)"
|
||||
@change="handleRoleChange(profile.id, $event.target.value)"
|
||||
>
|
||||
<option value="ROLE_ADMIN">
|
||||
Admin
|
||||
</option>
|
||||
<option value="ROLE_GESTIONNAIRE">
|
||||
Gestionnaire
|
||||
</option>
|
||||
<option value="ROLE_VIEWER">
|
||||
Viewer
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="profile.hasPassword" class="badge badge-success badge-sm">Oui</span>
|
||||
<span v-else class="badge badge-ghost badge-sm">Non</span>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs ml-1"
|
||||
@click="openPasswordDialog(profile.id)"
|
||||
>
|
||||
{{ profile.hasPassword ? 'Changer' : 'Definir' }}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="badge badge-sm"
|
||||
:class="profile.isActive ? 'badge-success' : 'badge-error'"
|
||||
>
|
||||
{{ profile.isActive ? 'Actif' : 'Inactif' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="profile.isActive"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
@click="handleDeactivate(profile.id)"
|
||||
>
|
||||
Desactiver
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<template #cell-email="{ row }">
|
||||
<span class="text-sm text-base-content/70">{{ row.email || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<div v-else class="text-center py-12 text-base-content/60">
|
||||
Aucun profil.
|
||||
</div>
|
||||
<template #cell-role="{ row }">
|
||||
<select
|
||||
class="select select-bordered select-xs"
|
||||
:value="primaryRole(row)"
|
||||
@change="handleRoleChange(row.id, $event.target.value)"
|
||||
>
|
||||
<option value="ROLE_ADMIN">Admin</option>
|
||||
<option value="ROLE_GESTIONNAIRE">Gestionnaire</option>
|
||||
<option value="ROLE_VIEWER">Viewer</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<template #cell-password="{ row }">
|
||||
<span v-if="row.hasPassword" class="badge badge-success badge-sm">Oui</span>
|
||||
<span v-else class="badge badge-ghost badge-sm">Non</span>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs ml-1"
|
||||
@click="openPasswordDialog(row.id)"
|
||||
>
|
||||
{{ row.hasPassword ? 'Changer' : 'Definir' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ row }">
|
||||
<span
|
||||
class="badge badge-sm"
|
||||
:class="row.isActive ? 'badge-success' : 'badge-error'"
|
||||
>
|
||||
{{ row.isActive ? 'Actif' : 'Inactif' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<button
|
||||
v-if="row.isActive"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
@click="handleDeactivate(row.id)"
|
||||
>
|
||||
Desactiver
|
||||
</button>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<!-- Create Profile Dialog -->
|
||||
<dialog ref="createDialog" class="modal" :open="showCreateDialog || undefined">
|
||||
@@ -112,15 +96,9 @@
|
||||
<div class="form-control mb-3">
|
||||
<label class="label"><span class="label-text">Role</span></label>
|
||||
<select v-model="createForm.role" class="select select-bordered">
|
||||
<option value="ROLE_ADMIN">
|
||||
Admin
|
||||
</option>
|
||||
<option value="ROLE_GESTIONNAIRE">
|
||||
Gestionnaire
|
||||
</option>
|
||||
<option value="ROLE_VIEWER">
|
||||
Viewer
|
||||
</option>
|
||||
<option value="ROLE_ADMIN">Admin</option>
|
||||
<option value="ROLE_GESTIONNAIRE">Gestionnaire</option>
|
||||
<option value="ROLE_VIEWER">Viewer</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
@@ -173,11 +151,55 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import { useAdminProfiles } from '#imports'
|
||||
|
||||
const { profiles, loading, fetchAll, createProfile, updateRole, setPassword, deactivateProfile } = useAdminProfiles()
|
||||
|
||||
const loaded = ref(false)
|
||||
const isLoading = computed(() => loading.value || !loaded.value)
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Nom', sortable: true },
|
||||
{ key: 'email', label: 'Email', sortable: true },
|
||||
{ key: 'role', label: 'Role', sortable: true },
|
||||
{ key: 'password', label: 'Mot de passe' },
|
||||
{ key: 'status', label: 'Statut', sortable: true },
|
||||
{ key: 'actions', label: 'Actions' },
|
||||
]
|
||||
|
||||
const sortState = ref({ field: 'name', direction: 'asc' })
|
||||
|
||||
const handleSort = (sort) => {
|
||||
sortState.value = sort
|
||||
}
|
||||
|
||||
const sortedProfiles = computed(() => {
|
||||
const { field, direction } = sortState.value
|
||||
const dir = direction === 'desc' ? -1 : 1
|
||||
return [...profiles.value].sort((a, b) => {
|
||||
let valA, valB
|
||||
if (field === 'name') {
|
||||
valA = `${a.firstName} ${a.lastName}`.toLowerCase()
|
||||
valB = `${b.firstName} ${b.lastName}`.toLowerCase()
|
||||
}
|
||||
else if (field === 'role') {
|
||||
valA = primaryRole(a)
|
||||
valB = primaryRole(b)
|
||||
}
|
||||
else if (field === 'status') {
|
||||
valA = a.isActive ? '1' : '0'
|
||||
valB = b.isActive ? '1' : '0'
|
||||
}
|
||||
else {
|
||||
valA = (a[field] || '').toLowerCase()
|
||||
valB = (b[field] || '').toLowerCase()
|
||||
}
|
||||
return dir * valA.localeCompare(valB)
|
||||
})
|
||||
})
|
||||
|
||||
const showCreateDialog = ref(false)
|
||||
const showPasswordDialog = ref(false)
|
||||
const creating = ref(false)
|
||||
@@ -209,7 +231,8 @@ const handleCreate = async () => {
|
||||
await createProfile(data)
|
||||
showCreateDialog.value = false
|
||||
createForm.value = { firstName: '', lastName: '', email: '', password: '', role: 'ROLE_VIEWER' }
|
||||
} finally {
|
||||
}
|
||||
finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
@@ -230,7 +253,8 @@ const handleSetPassword = async () => {
|
||||
try {
|
||||
await setPassword(passwordProfileId.value, newPassword.value)
|
||||
showPasswordDialog.value = false
|
||||
} finally {
|
||||
}
|
||||
finally {
|
||||
settingPassword.value = false
|
||||
}
|
||||
}
|
||||
@@ -239,7 +263,8 @@ const handleDeactivate = async (profileId) => {
|
||||
await deactivateProfile(profileId)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAll()
|
||||
onMounted(async () => {
|
||||
await fetchAll()
|
||||
loaded.value = true
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -11,9 +11,22 @@
|
||||
|
||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||
<div class="card-body space-y-4">
|
||||
<!-- Filtres -->
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:rows="comments"
|
||||
:loading="loadingList"
|
||||
:sort="table.sort.value"
|
||||
:pagination="paginationState"
|
||||
:column-filters="table.columnFilters.value"
|
||||
:show-per-page="true"
|
||||
empty-message="Aucun commentaire trouvé."
|
||||
no-results-message="Aucun commentaire trouvé."
|
||||
@sort="table.handleSort"
|
||||
@update:current-page="table.handlePageChange"
|
||||
@update:per-page="table.handlePerPageChange"
|
||||
@update:column-filters="table.handleColumnFiltersChange"
|
||||
>
|
||||
<template #toolbar>
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
@@ -25,17 +38,11 @@
|
||||
id="comment-status"
|
||||
v-model="statusFilter"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleFilterChange"
|
||||
@change="table.handleFilterChange"
|
||||
>
|
||||
<option value="open">
|
||||
Ouverts
|
||||
</option>
|
||||
<option value="resolved">
|
||||
Résolus
|
||||
</option>
|
||||
<option value="">
|
||||
Tous
|
||||
</option>
|
||||
<option value="open">Ouverts</option>
|
||||
<option value="resolved">Résolus</option>
|
||||
<option value="">Tous</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -50,186 +57,84 @@
|
||||
id="comment-entity-type"
|
||||
v-model="entityTypeFilter"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleFilterChange"
|
||||
@change="table.handleFilterChange"
|
||||
>
|
||||
<option value="">
|
||||
Tous
|
||||
</option>
|
||||
<option value="machine">
|
||||
Machine
|
||||
</option>
|
||||
<option value="piece">
|
||||
Pièce
|
||||
</option>
|
||||
<option value="composant">
|
||||
Composant
|
||||
</option>
|
||||
<option value="product">
|
||||
Produit
|
||||
</option>
|
||||
<option value="piece_category">
|
||||
Catégorie pièce
|
||||
</option>
|
||||
<option value="component_category">
|
||||
Catégorie composant
|
||||
</option>
|
||||
<option value="product_category">
|
||||
Catégorie produit
|
||||
</option>
|
||||
<option value="machine_skeleton">
|
||||
Squelette machine
|
||||
</option>
|
||||
<option value="">Tous</option>
|
||||
<option value="machine">Machine</option>
|
||||
<option value="piece">Pièce</option>
|
||||
<option value="composant">Composant</option>
|
||||
<option value="product">Produit</option>
|
||||
<option value="piece_category">Catégorie pièce</option>
|
||||
<option value="component_category">Catégorie composant</option>
|
||||
<option value="product_category">Catégorie produit</option>
|
||||
<option value="machine_skeleton">Squelette machine</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="comment-per-page"
|
||||
>
|
||||
Par page
|
||||
</label>
|
||||
<select
|
||||
id="comment-per-page"
|
||||
v-model.number="itemsPerPage"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<option :value="20">
|
||||
20
|
||||
</option>
|
||||
<option :value="50">
|
||||
50
|
||||
</option>
|
||||
<option :value="100">
|
||||
100
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<template #cell-content="{ row }">
|
||||
<span class="line-clamp-2 text-sm">{{ row.content }}</span>
|
||||
</template>
|
||||
|
||||
<p class="text-xs text-base-content/50 lg:text-right">
|
||||
{{ comments.length }} / {{ total }} résultat{{ total > 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loadingList" class="flex justify-center py-8">
|
||||
<span class="loading loading-spinner" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<!-- Empty states -->
|
||||
<p v-else-if="!comments.length" class="text-sm text-base-content/70 py-4">
|
||||
Aucun commentaire trouvé.
|
||||
</p>
|
||||
|
||||
<!-- Table -->
|
||||
<template v-else>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm md:table-md">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Contenu</th>
|
||||
<th>Type</th>
|
||||
<th>Item</th>
|
||||
<th>Auteur</th>
|
||||
<th>Date</th>
|
||||
<th>Statut</th>
|
||||
<th v-if="canEdit">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="comment in comments"
|
||||
:key="comment.id"
|
||||
class="hover"
|
||||
>
|
||||
<td class="max-w-xs">
|
||||
<span class="line-clamp-2 text-sm">{{ comment.content }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-outline badge-sm">
|
||||
{{ entityTypeLabel(comment.entityType) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<NuxtLink
|
||||
v-if="getEntityRoute(comment)"
|
||||
:to="getEntityRoute(comment)!"
|
||||
class="link link-primary text-sm font-medium"
|
||||
>
|
||||
{{ comment.entityName || comment.entityId }}
|
||||
</NuxtLink>
|
||||
<span v-else class="text-sm">
|
||||
{{ comment.entityName || comment.entityId }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-sm">
|
||||
{{ comment.authorName }}
|
||||
</td>
|
||||
<td class="text-sm whitespace-nowrap">
|
||||
{{ formatCommentDate(comment.createdAt) }}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="badge badge-sm"
|
||||
:class="comment.status === 'open' ? 'badge-warning' : 'badge-success'"
|
||||
>
|
||||
{{ comment.status === 'open' ? 'Ouvert' : 'Résolu' }}
|
||||
</span>
|
||||
</td>
|
||||
<td v-if="canEdit" @click.stop>
|
||||
<button
|
||||
v-if="comment.status === 'open'"
|
||||
type="button"
|
||||
class="btn btn-success btn-xs gap-1"
|
||||
:disabled="loading"
|
||||
@click="handleResolve(comment.id)"
|
||||
>
|
||||
<IconLucideCheck class="w-3 h-3" />
|
||||
Résoudre
|
||||
</button>
|
||||
<span v-else class="text-xs text-base-content/50">
|
||||
{{ comment.resolvedByName }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="totalPages > 1" class="flex justify-center gap-2 pt-2">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:disabled="page <= 1"
|
||||
@click="goToPage(page - 1)"
|
||||
>
|
||||
Précédent
|
||||
</button>
|
||||
<span class="flex items-center text-sm text-base-content/70">
|
||||
Page {{ page }} / {{ totalPages }}
|
||||
<template #cell-entityType="{ row }">
|
||||
<span class="badge badge-outline badge-sm">
|
||||
{{ entityTypeLabel(row.entityType) }}
|
||||
</span>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:disabled="page >= totalPages"
|
||||
@click="goToPage(page + 1)"
|
||||
</template>
|
||||
|
||||
<template #cell-entity="{ row }">
|
||||
<NuxtLink
|
||||
v-if="getEntityRoute(row)"
|
||||
:to="getEntityRoute(row)!"
|
||||
class="link link-primary text-sm font-medium"
|
||||
>
|
||||
Suivant
|
||||
{{ row.entityName || row.entityId }}
|
||||
</NuxtLink>
|
||||
<span v-else class="text-sm">
|
||||
{{ row.entityName || row.entityId }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-createdAt="{ row }">
|
||||
<span class="whitespace-nowrap">{{ formatCommentDate(row.createdAt) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ row }">
|
||||
<span
|
||||
class="badge badge-sm"
|
||||
:class="row.status === 'open' ? 'badge-warning' : 'badge-success'"
|
||||
>
|
||||
{{ row.status === 'open' ? 'Ouvert' : 'Résolu' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-if="canEdit" #cell-actions="{ row }">
|
||||
<button
|
||||
v-if="row.status === 'open'"
|
||||
type="button"
|
||||
class="btn btn-success btn-xs gap-1"
|
||||
:disabled="loading"
|
||||
@click="handleResolve(row.id)"
|
||||
>
|
||||
<IconLucideCheck class="w-3 h-3" />
|
||||
Résoudre
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else class="text-xs text-base-content/50">
|
||||
{{ row.resolvedByName }}
|
||||
</span>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, type Ref } from 'vue'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import { useComments, type Comment } from '~/composables/useComments'
|
||||
import { usePermissions } from '~/composables/usePermissions'
|
||||
import { useDataTable } from '~/composables/useDataTable'
|
||||
import IconLucideCheck from '~icons/lucide/check'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
@@ -241,16 +146,43 @@ const {
|
||||
|
||||
const comments = ref<Comment[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(20)
|
||||
const statusFilter = ref('open')
|
||||
const entityTypeFilter = ref('')
|
||||
const loadingList = ref(false)
|
||||
const loadingList = ref(true)
|
||||
|
||||
const totalPages = computed(() =>
|
||||
Math.max(1, Math.ceil(total.value / itemsPerPage.value)),
|
||||
const table = useDataTable(
|
||||
{ fetchData: loadComments },
|
||||
{
|
||||
defaultSort: 'createdAt',
|
||||
defaultDirection: 'desc',
|
||||
defaultPerPage: 20,
|
||||
persistToUrl: true,
|
||||
extraParams: {
|
||||
status: { default: 'open' },
|
||||
entityType: { default: '' },
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const statusFilter = table.filters.status as Ref<string>
|
||||
const entityTypeFilter = table.filters.entityType as Ref<string>
|
||||
|
||||
const commentsOnPage = computed(() => comments.value.length)
|
||||
const paginationState = table.pagination(total, commentsOnPage)
|
||||
|
||||
const columns = computed(() => {
|
||||
const cols = [
|
||||
{ key: 'content', label: 'Contenu', class: 'max-w-xs' },
|
||||
{ key: 'entityType', label: 'Type' },
|
||||
{ key: 'entity', label: 'Item', filterable: true, filterPlaceholder: 'Rechercher…' },
|
||||
{ key: 'authorName', label: 'Auteur', sortable: true, sortKey: 'authorName' },
|
||||
{ key: 'createdAt', label: 'Date', sortable: true, sortKey: 'createdAt' },
|
||||
{ key: 'status', label: 'Statut', sortable: true, sortKey: 'status' },
|
||||
]
|
||||
if (canEdit.value) {
|
||||
cols.push({ key: 'actions', label: 'Actions' })
|
||||
}
|
||||
return cols
|
||||
})
|
||||
|
||||
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||
machine: 'Machine',
|
||||
piece: 'Pièce',
|
||||
@@ -277,13 +209,16 @@ const formatCommentDate = (dateStr: string): string => {
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
const loadComments = async () => {
|
||||
async function loadComments() {
|
||||
loadingList.value = true
|
||||
const result = await fetchAllComments({
|
||||
status: statusFilter.value || undefined,
|
||||
entityType: entityTypeFilter.value || undefined,
|
||||
page: page.value,
|
||||
itemsPerPage: itemsPerPage.value,
|
||||
entityName: table.columnFilters.value.entity || undefined,
|
||||
page: table.currentPage.value,
|
||||
itemsPerPage: table.itemsPerPage.value,
|
||||
orderBy: table.sortField.value,
|
||||
orderDir: table.sortDirection.value,
|
||||
})
|
||||
if (result.success) {
|
||||
comments.value = result.data ?? []
|
||||
@@ -292,16 +227,6 @@ const loadComments = async () => {
|
||||
loadingList.value = false
|
||||
}
|
||||
|
||||
const handleFilterChange = () => {
|
||||
page.value = 1
|
||||
loadComments()
|
||||
}
|
||||
|
||||
const goToPage = (p: number) => {
|
||||
page.value = p
|
||||
loadComments()
|
||||
}
|
||||
|
||||
const handleResolve = async (commentId: string) => {
|
||||
const result = await resolveComment(commentId)
|
||||
if (result.success) {
|
||||
|
||||
@@ -26,174 +26,107 @@
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:rows="componentRows"
|
||||
:loading="loadingComposants"
|
||||
:sort="table.sort.value"
|
||||
:pagination="paginationState"
|
||||
:column-filters="table.columnFilters.value"
|
||||
:show-per-page="true"
|
||||
empty-message="Aucun composant n'a encore été créé."
|
||||
no-results-message="Aucun composant ne correspond à votre recherche."
|
||||
@sort="table.handleSort"
|
||||
@update:current-page="table.handlePageChange"
|
||||
@update:per-page="table.handlePerPageChange"
|
||||
@update:column-filters="table.handleColumnFiltersChange"
|
||||
>
|
||||
<template #toolbar>
|
||||
<label class="w-full sm:w-72">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
v-model="table.searchTerm.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full mt-1"
|
||||
placeholder="Nom ou référence…"
|
||||
@input="debouncedSearch"
|
||||
@input="table.debouncedSearch"
|
||||
/>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="component-catalog-sort"
|
||||
>
|
||||
Trier par
|
||||
</label>
|
||||
<select
|
||||
id="component-catalog-sort"
|
||||
v-model="sortField"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleSortChange"
|
||||
>
|
||||
<option value="name">Nom</option>
|
||||
<option value="createdAt">Date de création</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<template #cell-preview="{ row }">
|
||||
<DocumentThumbnail
|
||||
:document="resolvePrimaryDocument(row.component)"
|
||||
:alt="resolvePreviewAlt(row.component)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #cell-name="{ row }">
|
||||
{{ row.component.name || 'Composant sans nom' }}
|
||||
</template>
|
||||
|
||||
<template #cell-reference="{ row }">
|
||||
{{ row.component.reference || '—' }}
|
||||
</template>
|
||||
|
||||
<template #cell-description="{ row }">
|
||||
<div v-if="row.component.description" class="group relative">
|
||||
<span class="block cursor-help truncate">{{ row.component.description }}</span>
|
||||
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-lg group-hover:pointer-events-auto group-hover:visible">
|
||||
<p class="break-words whitespace-pre-wrap">{{ row.component.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
|
||||
<template #cell-typeComposant="{ row }">
|
||||
<NuxtLink
|
||||
v-if="row.component.typeComposant?.id"
|
||||
:to="`/component-category/${row.component.typeComposant.id}/edit`"
|
||||
class="link link-hover link-primary"
|
||||
>
|
||||
{{ resolveComponentType(row.component) }}
|
||||
</NuxtLink>
|
||||
<span v-else>{{ resolveComponentType(row.component) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-createdAt="{ row }">
|
||||
<span class="whitespace-nowrap">{{ formatDate(row.component.createdAt) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="component-catalog-dir"
|
||||
<NuxtLink
|
||||
:to="`/component/${row.component.id}/edit`"
|
||||
class="btn btn-ghost btn-xs"
|
||||
>
|
||||
Ordre
|
||||
</label>
|
||||
<select
|
||||
id="component-catalog-dir"
|
||||
v-model="sortDirection"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleSortChange"
|
||||
Modifier
|
||||
</NuxtLink>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs"
|
||||
:disabled="loadingComposants"
|
||||
@click="handleDeleteComponent(row.component)"
|
||||
>
|
||||
<option value="asc">Ascendant</option>
|
||||
<option value="desc">Descendant</option>
|
||||
</select>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="component-catalog-per-page"
|
||||
>
|
||||
Par page
|
||||
</label>
|
||||
<select
|
||||
id="component-catalog-per-page"
|
||||
v-model.number="itemsPerPage"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handlePerPageChange"
|
||||
>
|
||||
<option :value="20">20</option>
|
||||
<option :value="50">50</option>
|
||||
<option :value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/50 lg:text-right">
|
||||
{{ composantsOnPage }} / {{ composantsTotal }} résultat{{ composantsTotal > 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingComposants" class="flex justify-center py-8">
|
||||
<span class="loading loading-spinner" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<p v-else-if="!composantsTotal" class="text-sm text-base-content/70">
|
||||
Aucun composant n'a encore été créé.
|
||||
</p>
|
||||
|
||||
<p v-else-if="!composantsList.length" class="text-sm text-base-content/70">
|
||||
Aucun composant ne correspond à votre recherche.
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm md:table-md">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-24">Aperçu</th>
|
||||
<th>Nom</th>
|
||||
<th>Référence</th>
|
||||
<th>Description</th>
|
||||
<th>Type de composant</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="component in composantsList" :key="component.id">
|
||||
<td class="align-middle">
|
||||
<DocumentThumbnail
|
||||
:document="resolvePrimaryDocument(component)"
|
||||
:alt="resolvePreviewAlt(component)"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ component.name || 'Composant sans nom' }}</td>
|
||||
<td>{{ component.reference || '—' }}</td>
|
||||
<td class="max-w-xs">
|
||||
<div v-if="component.description" class="group relative">
|
||||
<span class="block cursor-help truncate">{{ component.description }}</span>
|
||||
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-lg group-hover:pointer-events-auto group-hover:visible">
|
||||
<p class="break-words whitespace-pre-wrap">{{ component.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
<td>
|
||||
<NuxtLink
|
||||
v-if="component.typeComposant?.id"
|
||||
:to="`/component-category/${component.typeComposant.id}/edit`"
|
||||
class="link link-hover link-primary"
|
||||
>
|
||||
{{ resolveComponentType(component) }}
|
||||
</NuxtLink>
|
||||
<span v-else>{{ resolveComponentType(component) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<NuxtLink
|
||||
:to="`/component/${component.id}/edit`"
|
||||
class="btn btn-ghost btn-xs"
|
||||
>
|
||||
Modifier
|
||||
</NuxtLink>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs"
|
||||
:disabled="loadingComposants"
|
||||
@click="handleDeleteComponent(component)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
:current-page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
@update:current-page="handlePageChange"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import { useComposants } from '~/composables/useComposants'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useUrlState } from '~/composables/useUrlState'
|
||||
import { useDataTable } from '~/composables/useDataTable'
|
||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||
import Pagination from '~/components/common/Pagination.vue'
|
||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
@@ -202,175 +135,110 @@ const { composants, total, loadComposants, loading: loadingComposantsRef, delete
|
||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||
const loadingComposants = computed(() => loadingComposantsRef.value)
|
||||
|
||||
// State synced with URL query params (preserved on back/forward navigation)
|
||||
const {
|
||||
page: currentPage,
|
||||
perPage: itemsPerPage,
|
||||
q: searchTerm,
|
||||
sort: sortField,
|
||||
dir: sortDirection,
|
||||
} = useUrlState({
|
||||
page: { default: 1, type: 'number' },
|
||||
perPage: { default: 20, type: 'number' },
|
||||
q: { default: '', debounce: 300 },
|
||||
sort: { default: 'name' },
|
||||
dir: { default: 'asc' },
|
||||
}, {
|
||||
onRestore: () => fetchComposants(),
|
||||
})
|
||||
const table = useDataTable(
|
||||
{ fetchData: fetchComposants },
|
||||
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true },
|
||||
)
|
||||
|
||||
const composantsTotal = computed(() => total.value)
|
||||
const composantsOnPage = computed(() => composants.value.length)
|
||||
const totalPages = computed(() => Math.ceil(composantsTotal.value / itemsPerPage.value) || 1)
|
||||
const columns = [
|
||||
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
|
||||
{ key: 'name', label: 'Nom', sortable: true },
|
||||
{ key: 'reference', label: 'Référence' },
|
||||
{ key: 'description', label: 'Description' },
|
||||
{ key: 'typeComposant', label: 'Type de composant', filterable: true, filterPlaceholder: 'Filtrer…' },
|
||||
{ key: 'createdAt', label: 'Date', sortable: true },
|
||||
{ key: 'actions', label: 'Actions' },
|
||||
]
|
||||
|
||||
// Search debounce for API calls
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const composantsOnPage = computed(() => componentRows.value.length)
|
||||
const paginationState = table.pagination(total, composantsOnPage)
|
||||
|
||||
const debouncedSearch = () => {
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
}
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage.value = 1
|
||||
fetchComposants()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// Enrichir les composants avec les types de composants complets
|
||||
// Enrich composants with full type data
|
||||
const composantsList = computed(() => {
|
||||
return (composants.value || []).map((composant) => {
|
||||
const typeComposant = componentTypes.value.find(t => t.id === composant.typeComposantId)
|
||||
return {
|
||||
...composant,
|
||||
typeComposant: typeComposant || composant.typeComposant || null
|
||||
}
|
||||
return { ...composant, typeComposant: typeComposant || composant.typeComposant || null }
|
||||
})
|
||||
})
|
||||
|
||||
const fetchComposants = async () => {
|
||||
const componentRows = computed(() =>
|
||||
composantsList.value.map(component => ({
|
||||
id: component.id,
|
||||
component,
|
||||
})),
|
||||
)
|
||||
|
||||
async function fetchComposants() {
|
||||
await loadComposants({
|
||||
search: searchTerm.value,
|
||||
page: currentPage.value,
|
||||
itemsPerPage: itemsPerPage.value,
|
||||
orderBy: sortField.value,
|
||||
orderDir: sortDirection.value as 'asc' | 'desc',
|
||||
force: true
|
||||
search: table.searchTerm.value,
|
||||
page: table.currentPage.value,
|
||||
itemsPerPage: table.itemsPerPage.value,
|
||||
orderBy: table.sortField.value,
|
||||
orderDir: table.sortDirection.value as 'asc' | 'desc',
|
||||
typeName: table.columnFilters.value.typeComposant || undefined,
|
||||
force: true,
|
||||
})
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
fetchComposants()
|
||||
}
|
||||
|
||||
const handleSortChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchComposants()
|
||||
}
|
||||
|
||||
const handlePerPageChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchComposants()
|
||||
}
|
||||
|
||||
const resolvePrimaryDocument = (component: Record<string, any>) => {
|
||||
const documents = Array.isArray(component?.documents) ? component.documents : []
|
||||
if (!documents.length) {
|
||||
return null
|
||||
}
|
||||
const normalized = documents.filter((doc) => doc && typeof doc === 'object')
|
||||
const withPath = normalized.filter((doc) => doc?.fileUrl || doc?.path)
|
||||
const pdf = withPath.find((doc) => isPdfDocument(doc))
|
||||
if (pdf) {
|
||||
return pdf
|
||||
}
|
||||
const image = withPath.find((doc) => isImageDocument(doc))
|
||||
if (image) {
|
||||
return image
|
||||
}
|
||||
if (!documents.length) return null
|
||||
const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
|
||||
const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
|
||||
const pdf = withPath.find((doc: any) => isPdfDocument(doc))
|
||||
if (pdf) return pdf
|
||||
const image = withPath.find((doc: any) => isImageDocument(doc))
|
||||
if (image) return image
|
||||
return withPath[0] ?? normalized[0] ?? null
|
||||
}
|
||||
|
||||
const resolvePreviewAlt = (component: Record<string, any>) => {
|
||||
const parts = [component?.name, component?.reference].filter(Boolean)
|
||||
if (parts.length) {
|
||||
return `Aperçu du document de ${parts.join(' – ')}`
|
||||
}
|
||||
return 'Aperçu du document'
|
||||
return parts.length ? `Aperçu du document de ${parts.join(' – ')}` : 'Aperçu du document'
|
||||
}
|
||||
|
||||
const resolveComponentType = (component: Record<string, any>) => {
|
||||
const type = component?.typeComposant
|
||||
if (type?.name) {
|
||||
return type.name
|
||||
}
|
||||
if (component?.typeComposantLabel) {
|
||||
return component.typeComposantLabel
|
||||
}
|
||||
if (component?.typeComposant?.name) return component.typeComposant.name
|
||||
if (component?.typeComposantLabel) return component.typeComposantLabel
|
||||
return '—'
|
||||
}
|
||||
|
||||
const resolveDeleteGuard = (component: Record<string, any>) => {
|
||||
const blockingReasons: string[] = []
|
||||
const machineLinks = Array.isArray(component?.machineLinks)
|
||||
? component.machineLinks.length
|
||||
: component?.machineLinksCount ?? 0
|
||||
const documents = Array.isArray(component?.documents)
|
||||
? component.documents.length
|
||||
: component?.documentsCount ?? 0
|
||||
const customFields = Array.isArray(component?.customFieldValues)
|
||||
? component.customFieldValues.length
|
||||
: component?.customFieldValuesCount ?? 0
|
||||
|
||||
if (machineLinks > 0) {
|
||||
blockingReasons.push(`${machineLinks} liaison${machineLinks > 1 ? 's' : ''} machine`)
|
||||
}
|
||||
if (documents > 0) {
|
||||
blockingReasons.push(`${documents} document${documents > 1 ? 's' : ''}`)
|
||||
}
|
||||
return {
|
||||
blockingReasons,
|
||||
hasCustomFields: customFields > 0,
|
||||
}
|
||||
const machineLinks = Array.isArray(component?.machineLinks) ? component.machineLinks.length : component?.machineLinksCount ?? 0
|
||||
const documents = Array.isArray(component?.documents) ? component.documents.length : component?.documentsCount ?? 0
|
||||
const customFields = Array.isArray(component?.customFieldValues) ? component.customFieldValues.length : component?.customFieldValuesCount ?? 0
|
||||
if (machineLinks > 0) blockingReasons.push(`${machineLinks} liaison${machineLinks > 1 ? 's' : ''} machine`)
|
||||
if (documents > 0) blockingReasons.push(`${documents} document${documents > 1 ? 's' : ''}`)
|
||||
return { blockingReasons, hasCustomFields: customFields > 0 }
|
||||
}
|
||||
|
||||
const handleDeleteComponent = async (component: Record<string, any>) => {
|
||||
const { blockingReasons, hasCustomFields } = resolveDeleteGuard(component)
|
||||
|
||||
if (blockingReasons.length) {
|
||||
showError(
|
||||
`Impossible de supprimer ce composant car il possède encore: ${blockingReasons.join(
|
||||
', ',
|
||||
)}. Supprimez ou détachez ces éléments avant de réessayer.`
|
||||
)
|
||||
showError(`Impossible de supprimer ce composant car il possède encore: ${blockingReasons.join(', ')}. Supprimez ou détachez ces éléments avant de réessayer.`)
|
||||
return
|
||||
}
|
||||
|
||||
const componentName = component?.name || 'ce composant'
|
||||
const confirmLines = [
|
||||
`Voulez-vous vraiment supprimer ${componentName} ?`,
|
||||
]
|
||||
const confirmLines = [`Voulez-vous vraiment supprimer ${componentName} ?`]
|
||||
if (hasCustomFields) {
|
||||
confirmLines.push(
|
||||
'Les valeurs de champs personnalisés associées seront également supprimées.'
|
||||
)
|
||||
confirmLines.push('Les valeurs de champs personnalisés associées seront également supprimées.')
|
||||
}
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
const confirmed = await confirm({ message: confirmLines.join('\n\n') })
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirmed) return
|
||||
await deleteComposant(component.id)
|
||||
// Reload current page after deletion
|
||||
fetchComposants()
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return '—'
|
||||
const date = new Date(dateStr)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
fetchComposants(),
|
||||
loadComponentTypes()
|
||||
])
|
||||
await Promise.all([fetchComposants(), loadComponentTypes()])
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -17,73 +17,48 @@
|
||||
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body space-y-4">
|
||||
<div class="flex flex-col md:flex-row md:items-end md:justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<label class="label"><span class="label-text">Recherche</span></label>
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
type="search"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="Nom, email ou téléphone"
|
||||
@input="debouncedSearch"
|
||||
>
|
||||
</div>
|
||||
<div class="md:w-1/3">
|
||||
<label class="label"><span class="label-text">Tri</span></label>
|
||||
<select v-model="sortKey" class="select select-bordered w-full">
|
||||
<option value="name">
|
||||
Nom
|
||||
</option>
|
||||
<option value="email">
|
||||
Email
|
||||
</option>
|
||||
<option value="phone">
|
||||
Téléphone
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:rows="filteredConstructeurs"
|
||||
:loading="loading"
|
||||
:sort="currentSort"
|
||||
:show-counter="false"
|
||||
empty-message="Aucun fournisseur trouvé."
|
||||
no-results-message="Aucun fournisseur trouvé."
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #toolbar>
|
||||
<label class="w-full sm:w-72">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
type="search"
|
||||
class="input input-bordered input-sm w-full mt-1"
|
||||
placeholder="Nom, email ou téléphone"
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<div v-if="loading" class="py-16 text-center text-sm text-gray-500">
|
||||
<span class="loading loading-spinner loading-lg mb-2" />
|
||||
Chargement des fournisseurs...
|
||||
</div>
|
||||
<template #cell-phone="{ row }">
|
||||
{{ formatPhoneDisplay(row.phone) }}
|
||||
</template>
|
||||
|
||||
<div v-else-if="filteredConstructeurs.length === 0" class="py-16 text-center text-sm text-gray-500">
|
||||
Aucun fournisseur trouvé.
|
||||
</div>
|
||||
<template #cell-createdAt="{ row }">
|
||||
<span class="whitespace-nowrap">{{ formatDate(row.createdAt) }}</span>
|
||||
</template>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr class="text-xs uppercase">
|
||||
<th>Nom</th>
|
||||
<th>Email</th>
|
||||
<th>Téléphone</th>
|
||||
<th class="text-right">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="constructeur in filteredConstructeurs" :key="constructeur.id" class="text-sm">
|
||||
<td>{{ constructeur.name }}</td>
|
||||
<td>{{ constructeur.email || '—' }}</td>
|
||||
<td>{{ formatPhoneDisplay(constructeur.phone) }}</td>
|
||||
<td class="text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="btn btn-ghost btn-xs" @click="openEditModal(constructeur)">
|
||||
{{ canEdit ? 'Modifier' : 'Consulter' }}
|
||||
</button>
|
||||
<button v-if="canEdit" class="btn btn-error btn-xs" @click="confirmDelete(constructeur)">
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="btn btn-ghost btn-xs" @click="openEditModal(row)">
|
||||
{{ canEdit ? 'Modifier' : 'Consulter' }}
|
||||
</button>
|
||||
<button v-if="canEdit" class="btn btn-error btn-xs" @click="confirmDelete(row)">
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -118,6 +93,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import FieldEmail from '~/components/form/FieldEmail.vue'
|
||||
import FieldPhone from '~/components/form/FieldPhone.vue'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
@@ -130,22 +106,46 @@ const { canEdit } = usePermissions()
|
||||
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
|
||||
const { showError } = useToast()
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Nom', sortable: true },
|
||||
{ key: 'email', label: 'Email', sortable: true },
|
||||
{ key: 'phone', label: 'Téléphone', sortable: true },
|
||||
{ key: 'createdAt', label: 'Date de création', sortable: true },
|
||||
{ key: 'actions', label: 'Actions', align: 'right' },
|
||||
]
|
||||
|
||||
const searchTerm = ref('')
|
||||
const sortKey = usePersistedValue('constructeurs-sort', 'name')
|
||||
const sortDir = ref('asc')
|
||||
|
||||
const currentSort = computed(() => ({
|
||||
field: sortKey.value,
|
||||
direction: sortDir.value,
|
||||
}))
|
||||
|
||||
const handleSort = (sort) => {
|
||||
sortKey.value = sort.field
|
||||
sortDir.value = sort.direction
|
||||
}
|
||||
|
||||
const modalOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingConstructeur = ref(null)
|
||||
const form = ref({ name: '', email: '', phone: '' })
|
||||
|
||||
const filteredConstructeurs = computed(() => {
|
||||
const key = sortKey.value
|
||||
const dir = sortDir.value === 'desc' ? -1 : 1
|
||||
const sorted = [...constructeurs.value].sort((a, b) => {
|
||||
const key = sortKey.value
|
||||
return (a[key] || '').localeCompare(b[key] || '')
|
||||
if (key === 'createdAt') {
|
||||
return dir * (new Date(a[key] || 0).getTime() - new Date(b[key] || 0).getTime())
|
||||
}
|
||||
return dir * (a[key] || '').localeCompare(b[key] || '')
|
||||
})
|
||||
if (!searchTerm.value) { return sorted }
|
||||
const term = searchTerm.value.toLowerCase()
|
||||
return sorted.filter(item =>
|
||||
[item.name, item.email, item.phone].some(value => value && value.toLowerCase().includes(term))
|
||||
[item.name, item.email, item.phone].some(value => value && value.toLowerCase().includes(term)),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -153,6 +153,17 @@ const debouncedSearch = debounce(async () => {
|
||||
await searchConstructeurs(searchTerm.value)
|
||||
}, 300)
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '—'
|
||||
const date = new Date(dateStr)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return new Intl.DateTimeFormat('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
const formatPhoneDisplay = (value) => {
|
||||
const formatted = formatPhone(value)
|
||||
if (formatted) {
|
||||
@@ -161,7 +172,7 @@ const formatPhoneDisplay = (value) => {
|
||||
return value || '—'
|
||||
}
|
||||
|
||||
function debounce (fn, delay) {
|
||||
function debounce(fn, delay) {
|
||||
let timeout
|
||||
return (...args) => {
|
||||
clearTimeout(timeout)
|
||||
@@ -184,7 +195,7 @@ const openEditModal = (constructeur) => {
|
||||
form.value = {
|
||||
name: constructeur.name,
|
||||
email: constructeur.email || '',
|
||||
phone: constructeur.phone || ''
|
||||
phone: constructeur.phone || '',
|
||||
}
|
||||
modalOpen.value = true
|
||||
}
|
||||
@@ -197,7 +208,7 @@ const closeModal = () => {
|
||||
const saveConstructeur = async () => {
|
||||
const trimmedName = form.value.name.trim()
|
||||
const duplicate = constructeurs.value.find(
|
||||
(c) => c.name.toLowerCase() === trimmedName.toLowerCase()
|
||||
c => c.name.toLowerCase() === trimmedName.toLowerCase()
|
||||
&& c.id !== editingConstructeur.value?.id,
|
||||
)
|
||||
if (duplicate) {
|
||||
@@ -212,7 +223,8 @@ const saveConstructeur = async () => {
|
||||
let result
|
||||
if (editingConstructeur.value) {
|
||||
result = await updateConstructeur(editingConstructeur.value.id, payload)
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
result = await createConstructeur(payload)
|
||||
}
|
||||
saving.value = false
|
||||
@@ -234,6 +246,3 @@ const confirmDelete = async (constructeur) => {
|
||||
|
||||
onMounted(() => loadConstructeurs())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
||||
@@ -3,22 +3,34 @@
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
:documents="documents"
|
||||
:documents="documentRows"
|
||||
@close="closePreview"
|
||||
/>
|
||||
|
||||
<section class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body space-y-6">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:rows="documentRows"
|
||||
:loading="loading"
|
||||
:sort="table.sort.value"
|
||||
:pagination="paginationState"
|
||||
:show-per-page="true"
|
||||
empty-message="Aucun document n'a encore été ajouté."
|
||||
no-results-message="Aucun document ne correspond à votre recherche."
|
||||
@sort="table.handleSort"
|
||||
@update:current-page="table.handlePageChange"
|
||||
@update:per-page="table.handlePerPageChange"
|
||||
>
|
||||
<template #toolbar>
|
||||
<label class="w-full sm:w-72">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
v-model="table.searchTerm.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full mt-1"
|
||||
placeholder="Nom du document..."
|
||||
@input="debouncedSearch"
|
||||
@input="table.debouncedSearch"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -33,7 +45,7 @@
|
||||
id="doc-filter"
|
||||
v-model="attachmentFilter"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleFilterChange"
|
||||
@change="table.handleFilterChange"
|
||||
>
|
||||
<option value="all">Tous</option>
|
||||
<option value="site">Sites</option>
|
||||
@@ -43,242 +55,124 @@
|
||||
<option value="product">Produits</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="doc-sort"
|
||||
>
|
||||
Trier par
|
||||
</label>
|
||||
<select
|
||||
id="doc-sort"
|
||||
v-model="sortField"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleSortChange"
|
||||
>
|
||||
<option value="createdAt">Date</option>
|
||||
<option value="name">Nom</option>
|
||||
<option value="size">Taille</option>
|
||||
</select>
|
||||
<template #cell-name="{ row }">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xl" :class="documentIcon(row).colorClass">
|
||||
<component
|
||||
:is="documentIcon(row).component"
|
||||
class="h-6 w-6"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<div class="font-semibold">{{ row.name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ row.filename }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="doc-dir"
|
||||
>
|
||||
Ordre
|
||||
</label>
|
||||
<select
|
||||
id="doc-dir"
|
||||
v-model="sortDirection"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleSortChange"
|
||||
>
|
||||
<option value="asc">Ascendant</option>
|
||||
<option value="desc">Descendant</option>
|
||||
</select>
|
||||
<template #cell-mimeType="{ row }">
|
||||
{{ row.mimeType || 'Inconnu' }}
|
||||
</template>
|
||||
|
||||
<template #cell-size="{ row }">
|
||||
{{ formatSize(row.size) }}
|
||||
</template>
|
||||
|
||||
<template #cell-attachment="{ row }">
|
||||
<div class="flex flex-col text-xs">
|
||||
<span v-if="row.site">Site · {{ row.site.name }}</span>
|
||||
<span v-else-if="row.machine">Machine · {{ row.machine.name }}</span>
|
||||
<span v-else-if="row.composant">Composant · {{ row.composant.name }}</span>
|
||||
<span v-else-if="row.piece">Pièce · {{ row.piece.name }}</span>
|
||||
<span v-else-if="row.product">Produit · {{ row.product.name }}</span>
|
||||
<span v-else class="text-gray-400">Non défini</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="doc-per-page"
|
||||
<template #cell-createdAt="{ row }">
|
||||
{{ formatFrenchDate(row.createdAt) }}
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
class="btn btn-ghost btn-xs"
|
||||
type="button"
|
||||
:disabled="!canPreviewDocument(row)"
|
||||
:title="canPreviewDocument(row) ? 'Consulter le document' : 'Aucun aper\u00E7u disponible pour ce type'"
|
||||
@click="openPreview(row)"
|
||||
>
|
||||
Par page
|
||||
</label>
|
||||
<select
|
||||
id="doc-per-page"
|
||||
v-model.number="itemsPerPage"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handlePerPageChange"
|
||||
>
|
||||
<option :value="20">20</option>
|
||||
<option :value="50">50</option>
|
||||
<option :value="100">100</option>
|
||||
</select>
|
||||
Consulter
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-xs" type="button" @click="downloadDocument(row)">
|
||||
Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-base-content/50 lg:text-right">
|
||||
{{ documentsOnPage }} / {{ documentsTotal }} résultat{{ documentsTotal > 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="divider my-0" />
|
||||
|
||||
<div v-if="loading" class="flex flex-col items-center justify-center py-16 text-sm text-gray-500">
|
||||
<span class="loading loading-spinner loading-lg mb-3" />
|
||||
Chargement des documents...
|
||||
</div>
|
||||
|
||||
<div v-else-if="!documentsTotal" class="text-center py-16 text-sm text-gray-500">
|
||||
<IconLucideFileSearch class="mx-auto mb-4 h-14 w-14 text-gray-400" aria-hidden="true" />
|
||||
Aucun document n'a encore été ajouté.
|
||||
</div>
|
||||
|
||||
<div v-else-if="!documents.length" class="text-center py-16 text-sm text-gray-500">
|
||||
<IconLucideFileSearch class="mx-auto mb-4 h-14 w-14 text-gray-400" aria-hidden="true" />
|
||||
Aucun document ne correspond à votre recherche.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr class="text-xs uppercase">
|
||||
<th>Nom</th>
|
||||
<th>Type</th>
|
||||
<th>Taille</th>
|
||||
<th>Rattaché à</th>
|
||||
<th>Date</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="doc in documents" :key="doc.id" class="text-sm">
|
||||
<td>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xl" :class="documentIcon(doc).colorClass">
|
||||
<component
|
||||
:is="documentIcon(doc).component"
|
||||
class="h-6 w-6"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<div class="font-semibold">{{ doc.name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ doc.filename }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ doc.mimeType || 'Inconnu' }}</td>
|
||||
<td>{{ formatSize(doc.size) }}</td>
|
||||
<td>
|
||||
<div class="flex flex-col text-xs">
|
||||
<span v-if="doc.site">Site · {{ doc.site.name }}</span>
|
||||
<span v-else-if="doc.machine">Machine · {{ doc.machine.name }}</span>
|
||||
<span v-else-if="doc.composant">Composant · {{ doc.composant.name }}</span>
|
||||
<span v-else-if="doc.piece">Pièce · {{ doc.piece.name }}</span>
|
||||
<span v-else-if="doc.product">Produit · {{ doc.product.name }}</span>
|
||||
<span v-else class="text-gray-400">Non défini</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ formatFrenchDate(doc.createdAt) }}</td>
|
||||
<td class="text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
class="btn btn-ghost btn-xs"
|
||||
type="button"
|
||||
:disabled="!canPreviewDocument(doc)"
|
||||
:title="canPreviewDocument(doc) ? 'Consulter le document' : 'Aucun aper\u00E7u disponible pour ce type'"
|
||||
@click="openPreview(doc)"
|
||||
>
|
||||
Consulter
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-xs" type="button" @click="downloadDocument(doc)">
|
||||
Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
:current-page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
@update:current-page="handlePageChange"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, type Ref } from 'vue'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useUrlState } from '~/composables/useUrlState'
|
||||
import { useDataTable } from '~/composables/useDataTable'
|
||||
import { getFileIcon } from '~/utils/fileIcons'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import { formatFrenchDate } from '~/utils/date'
|
||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
import Pagination from '~/components/common/Pagination.vue'
|
||||
import IconLucideFileSearch from '~icons/lucide/file-search'
|
||||
|
||||
const { documents, total, loading, loadDocuments } = useDocuments()
|
||||
|
||||
const {
|
||||
page: currentPage,
|
||||
perPage: itemsPerPage,
|
||||
q: searchTerm,
|
||||
filter: attachmentFilter,
|
||||
sort: sortField,
|
||||
dir: sortDirection,
|
||||
} = useUrlState({
|
||||
page: { default: 1, type: 'number' },
|
||||
perPage: { default: 30, type: 'number' },
|
||||
q: { default: '', debounce: 300 },
|
||||
filter: { default: 'all' },
|
||||
sort: { default: 'createdAt' },
|
||||
dir: { default: 'desc' },
|
||||
}, {
|
||||
onRestore: () => fetchDocuments(),
|
||||
})
|
||||
const table = useDataTable(
|
||||
{ fetchData: fetchDocuments },
|
||||
{
|
||||
defaultSort: 'createdAt',
|
||||
defaultDirection: 'desc',
|
||||
defaultPerPage: 30,
|
||||
persistToUrl: true,
|
||||
extraParams: {
|
||||
filter: { default: 'all' },
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const attachmentFilter = table.filters.filter as Ref<string>
|
||||
|
||||
const previewDocument = ref<any>(null)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
const documentsTotal = computed(() => total.value)
|
||||
const documentRows = computed(() => documents.value)
|
||||
const documentsOnPage = computed(() => documents.value.length)
|
||||
const totalPages = computed(() => Math.ceil(documentsTotal.value / itemsPerPage.value) || 1)
|
||||
const paginationState = table.pagination(total, documentsOnPage)
|
||||
|
||||
const fetchDocuments = async () => {
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Nom', sortable: true, sortKey: 'name' },
|
||||
{ key: 'mimeType', label: 'Type' },
|
||||
{ key: 'size', label: 'Taille', sortable: true, sortKey: 'size' },
|
||||
{ key: 'attachment', label: 'Rattaché à' },
|
||||
{ key: 'createdAt', label: 'Date', sortable: true, sortKey: 'createdAt' },
|
||||
{ key: 'actions', label: 'Actions', align: 'right' as const },
|
||||
]
|
||||
|
||||
async function fetchDocuments() {
|
||||
await loadDocuments({
|
||||
search: searchTerm.value,
|
||||
page: currentPage.value,
|
||||
itemsPerPage: itemsPerPage.value,
|
||||
orderBy: sortField.value,
|
||||
orderDir: sortDirection.value as 'asc' | 'desc',
|
||||
search: table.searchTerm.value,
|
||||
page: table.currentPage.value,
|
||||
itemsPerPage: table.itemsPerPage.value,
|
||||
orderBy: table.sortField.value,
|
||||
orderDir: table.sortDirection.value as 'asc' | 'desc',
|
||||
attachmentFilter: attachmentFilter.value,
|
||||
force: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Search debounce
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const debouncedSearch = () => {
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage.value = 1
|
||||
fetchDocuments()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
fetchDocuments()
|
||||
}
|
||||
|
||||
const handleSortChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchDocuments()
|
||||
}
|
||||
|
||||
const handleFilterChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchDocuments()
|
||||
}
|
||||
|
||||
const handlePerPageChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchDocuments()
|
||||
}
|
||||
|
||||
const formatSize = (size: number | undefined | null) => {
|
||||
if (size === undefined || size === null) return '\u2014'
|
||||
if (size === 0) return '0 B'
|
||||
@@ -291,9 +185,7 @@ const formatSize = (size: number | undefined | null) => {
|
||||
const documentIcon = (doc: any) => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
|
||||
|
||||
const downloadDocument = (doc: any) => {
|
||||
if (doc?.downloadUrl) {
|
||||
window.open(doc.downloadUrl, '_blank')
|
||||
}
|
||||
if (doc?.downloadUrl) window.open(doc.downloadUrl, '_blank')
|
||||
}
|
||||
|
||||
const openPreview = (doc: any) => {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||
<div class="card-body space-y-4">
|
||||
<header class="flex flex-col gap-2">
|
||||
@@ -25,197 +26,130 @@
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:rows="pieceRows"
|
||||
:loading="loadingPieces"
|
||||
:sort="table.sort.value"
|
||||
:pagination="paginationState"
|
||||
:column-filters="table.columnFilters.value"
|
||||
:show-per-page="true"
|
||||
empty-message="Aucune pièce n'a encore été créée."
|
||||
no-results-message="Aucune pièce ne correspond à votre recherche."
|
||||
@sort="table.handleSort"
|
||||
@update:current-page="table.handlePageChange"
|
||||
@update:per-page="table.handlePerPageChange"
|
||||
@update:column-filters="table.handleColumnFiltersChange"
|
||||
>
|
||||
<template #toolbar>
|
||||
<label class="w-full sm:w-72">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
v-model="table.searchTerm.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full mt-1"
|
||||
placeholder="Nom ou référence…"
|
||||
@input="debouncedSearch"
|
||||
@input="table.debouncedSearch"
|
||||
/>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="piece-catalog-sort"
|
||||
>
|
||||
Trier par
|
||||
</label>
|
||||
<select
|
||||
id="piece-catalog-sort"
|
||||
v-model="sortField"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleSortChange"
|
||||
>
|
||||
<option value="name">Nom</option>
|
||||
<option value="createdAt">Date de création</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<template #cell-preview="{ row }">
|
||||
<DocumentThumbnail
|
||||
:document="resolvePrimaryDocument(row.piece)"
|
||||
:alt="resolvePreviewAlt(row.piece)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #cell-name="{ row }">
|
||||
{{ row.piece.name || 'Pièce sans nom' }}
|
||||
</template>
|
||||
|
||||
<template #cell-reference="{ row }">
|
||||
{{ row.piece.reference || '—' }}
|
||||
</template>
|
||||
|
||||
<template #cell-description="{ row }">
|
||||
<div v-if="row.piece.description" class="group relative">
|
||||
<span class="block cursor-help truncate">{{ row.piece.description }}</span>
|
||||
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-lg group-hover:pointer-events-auto group-hover:visible">
|
||||
<p class="break-words whitespace-pre-wrap">{{ row.piece.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="piece-catalog-dir"
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
|
||||
<template #cell-suppliers="{ row }">
|
||||
<div
|
||||
v-if="row.suppliers.visible.length"
|
||||
class="flex max-w-[14rem] flex-wrap items-center gap-1"
|
||||
:title="row.suppliers.tooltip"
|
||||
>
|
||||
<span
|
||||
v-for="supplier in row.suppliers.visible"
|
||||
:key="supplier"
|
||||
class="badge badge-ghost badge-sm whitespace-nowrap"
|
||||
>
|
||||
Ordre
|
||||
</label>
|
||||
<select
|
||||
id="piece-catalog-dir"
|
||||
v-model="sortDirection"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleSortChange"
|
||||
{{ supplier }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.suppliers.overflow"
|
||||
class="badge badge-outline badge-sm"
|
||||
>
|
||||
<option value="asc">Ascendant</option>
|
||||
<option value="desc">Descendant</option>
|
||||
</select>
|
||||
+{{ row.suppliers.overflow }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
|
||||
<template #cell-typePiece="{ row }">
|
||||
<NuxtLink
|
||||
v-if="row.piece.typePiece?.id"
|
||||
:to="`/piece-category/${row.piece.typePiece.id}/edit`"
|
||||
class="link link-hover link-primary"
|
||||
>
|
||||
{{ resolvePieceType(row.piece) }}
|
||||
</NuxtLink>
|
||||
<span v-else>{{ resolvePieceType(row.piece) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-createdAt="{ row }">
|
||||
<span class="whitespace-nowrap">{{ formatDate(row.piece.createdAt) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="piece-catalog-per-page"
|
||||
<NuxtLink
|
||||
:to="`/pieces/${row.piece.id}/edit`"
|
||||
class="btn btn-ghost btn-xs"
|
||||
>
|
||||
Par page
|
||||
</label>
|
||||
<select
|
||||
id="piece-catalog-per-page"
|
||||
v-model.number="itemsPerPage"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handlePerPageChange"
|
||||
Modifier
|
||||
</NuxtLink>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs"
|
||||
:disabled="loadingPieces"
|
||||
@click="handleDeletePiece(row.piece)"
|
||||
>
|
||||
<option :value="20">20</option>
|
||||
<option :value="50">50</option>
|
||||
<option :value="100">100</option>
|
||||
</select>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/50 lg:text-right">
|
||||
{{ piecesOnPage }} / {{ piecesTotal }} résultat{{ piecesTotal > 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingPieces" class="flex justify-center py-8">
|
||||
<span class="loading loading-spinner" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<p v-else-if="!piecesTotal" class="text-sm text-base-content/70">
|
||||
Aucune pièce n'a encore été créée.
|
||||
</p>
|
||||
|
||||
<p v-else-if="!piecesList.length" class="text-sm text-base-content/70">
|
||||
Aucune pièce ne correspond à votre recherche.
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm md:table-md">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-24">Aperçu</th>
|
||||
<th>Nom</th>
|
||||
<th>Référence</th>
|
||||
<th>Description</th>
|
||||
<th>Fournisseurs</th>
|
||||
<th>Type de pièce</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in pieceRows" :key="row.piece.id">
|
||||
<td class="align-middle">
|
||||
<DocumentThumbnail
|
||||
:document="resolvePrimaryDocument(row.piece)"
|
||||
:alt="resolvePreviewAlt(row.piece)"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ row.piece.name || 'Pièce sans nom' }}</td>
|
||||
<td>{{ row.piece.reference || '—' }}</td>
|
||||
<td class="max-w-xs">
|
||||
<div v-if="row.piece.description" class="group relative">
|
||||
<span class="block cursor-help truncate">{{ row.piece.description }}</span>
|
||||
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-lg group-hover:pointer-events-auto group-hover:visible">
|
||||
<p class="break-words whitespace-pre-wrap">{{ row.piece.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
v-if="row.suppliers.visible.length"
|
||||
class="flex max-w-[14rem] flex-wrap items-center gap-1"
|
||||
:title="row.suppliers.tooltip"
|
||||
>
|
||||
<span
|
||||
v-for="supplier in row.suppliers.visible"
|
||||
:key="supplier"
|
||||
class="badge badge-ghost badge-sm whitespace-nowrap"
|
||||
>
|
||||
{{ supplier }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.suppliers.overflow"
|
||||
class="badge badge-outline badge-sm"
|
||||
>
|
||||
+{{ row.suppliers.overflow }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
<td>
|
||||
<NuxtLink
|
||||
v-if="row.piece.typePiece?.id"
|
||||
:to="`/piece-category/${row.piece.typePiece.id}/edit`"
|
||||
class="link link-hover link-primary"
|
||||
>
|
||||
{{ resolvePieceType(row.piece) }}
|
||||
</NuxtLink>
|
||||
<span v-else>{{ resolvePieceType(row.piece) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<NuxtLink
|
||||
:to="`/pieces/${row.piece.id}/edit`"
|
||||
class="btn btn-ghost btn-xs"
|
||||
>
|
||||
Modifier
|
||||
</NuxtLink>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs"
|
||||
:disabled="loadingPieces"
|
||||
@click="handleDeletePiece(row.piece)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
:current-page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
@update:current-page="handlePageChange"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useUrlState } from '~/composables/useUrlState'
|
||||
import { useDataTable } from '~/composables/useDataTable'
|
||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||
import Pagination from '~/components/common/Pagination.vue'
|
||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
@@ -224,114 +158,73 @@ const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = us
|
||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const loadingPieces = computed(() => loadingPiecesRef.value)
|
||||
|
||||
// State synced with URL query params (preserved on back/forward navigation)
|
||||
const {
|
||||
page: currentPage,
|
||||
perPage: itemsPerPage,
|
||||
q: searchTerm,
|
||||
sort: sortField,
|
||||
dir: sortDirection,
|
||||
} = useUrlState({
|
||||
page: { default: 1, type: 'number' },
|
||||
perPage: { default: 20, type: 'number' },
|
||||
q: { default: '', debounce: 300 },
|
||||
sort: { default: 'name' },
|
||||
dir: { default: 'asc' },
|
||||
}, {
|
||||
onRestore: () => fetchPieces(),
|
||||
})
|
||||
const table = useDataTable(
|
||||
{ fetchData: fetchPieces },
|
||||
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true },
|
||||
)
|
||||
|
||||
const piecesTotal = computed(() => total.value)
|
||||
const piecesOnPage = computed(() => pieces.value.length)
|
||||
const totalPages = computed(() => Math.ceil(piecesTotal.value / itemsPerPage.value) || 1)
|
||||
const columns = [
|
||||
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
|
||||
{ key: 'name', label: 'Nom', sortable: true },
|
||||
{ key: 'reference', label: 'Référence' },
|
||||
{ key: 'description', label: 'Description' },
|
||||
{ key: 'suppliers', label: 'Fournisseurs' },
|
||||
{ key: 'typePiece', label: 'Type de pièce', filterable: true, filterPlaceholder: 'Filtrer…' },
|
||||
{ key: 'createdAt', label: 'Date', sortable: true },
|
||||
{ key: 'actions', label: 'Actions' },
|
||||
]
|
||||
|
||||
// Search debounce for API calls
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const piecesOnPage = computed(() => pieceRows.value.length)
|
||||
const paginationState = table.pagination(total, piecesOnPage)
|
||||
|
||||
const debouncedSearch = () => {
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
}
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage.value = 1
|
||||
fetchPieces()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// Enrichir les pièces avec les types de pièces complets
|
||||
// Enrich pieces with full type data
|
||||
const piecesList = computed(() => {
|
||||
return (pieces.value || []).map((piece) => {
|
||||
const typePiece = pieceTypes.value.find(t => t.id === piece.typePieceId)
|
||||
return {
|
||||
...piece,
|
||||
typePiece: typePiece || piece.typePiece || null
|
||||
}
|
||||
return { ...piece, typePiece: typePiece || piece.typePiece || null }
|
||||
})
|
||||
})
|
||||
|
||||
const fetchPieces = async () => {
|
||||
const pieceRows = computed(() =>
|
||||
piecesList.value.map(piece => ({
|
||||
id: piece.id,
|
||||
piece,
|
||||
suppliers: buildPieceSuppliersDisplay(piece),
|
||||
})),
|
||||
)
|
||||
|
||||
async function fetchPieces() {
|
||||
await loadPieces({
|
||||
search: searchTerm.value,
|
||||
page: currentPage.value,
|
||||
itemsPerPage: itemsPerPage.value,
|
||||
orderBy: sortField.value,
|
||||
orderDir: sortDirection.value as 'asc' | 'desc',
|
||||
force: true
|
||||
search: table.searchTerm.value,
|
||||
page: table.currentPage.value,
|
||||
itemsPerPage: table.itemsPerPage.value,
|
||||
orderBy: table.sortField.value,
|
||||
orderDir: table.sortDirection.value as 'asc' | 'desc',
|
||||
typeName: table.columnFilters.value.typePiece || undefined,
|
||||
force: true,
|
||||
})
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
fetchPieces()
|
||||
}
|
||||
|
||||
const handleSortChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchPieces()
|
||||
}
|
||||
|
||||
const handlePerPageChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchPieces()
|
||||
}
|
||||
|
||||
const resolvePrimaryDocument = (piece: Record<string, any>) => {
|
||||
const documents = Array.isArray(piece?.documents) ? piece.documents : []
|
||||
if (!documents.length) {
|
||||
return null
|
||||
}
|
||||
const normalized = documents.filter((doc) => doc && typeof doc === 'object')
|
||||
const withPath = normalized.filter((doc) => doc?.fileUrl || doc?.path)
|
||||
|
||||
const pdf = withPath.find((doc) => isPdfDocument(doc))
|
||||
if (pdf) {
|
||||
return pdf
|
||||
}
|
||||
|
||||
const image = withPath.find((doc) => isImageDocument(doc))
|
||||
if (image) {
|
||||
return image
|
||||
}
|
||||
|
||||
if (!documents.length) return null
|
||||
const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
|
||||
const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
|
||||
const pdf = withPath.find((doc: any) => isPdfDocument(doc))
|
||||
if (pdf) return pdf
|
||||
const image = withPath.find((doc: any) => isImageDocument(doc))
|
||||
if (image) return image
|
||||
return withPath[0] ?? normalized[0] ?? null
|
||||
}
|
||||
|
||||
const resolvePreviewAlt = (piece: Record<string, any>) => {
|
||||
const parts = [piece?.name, piece?.reference].filter(Boolean)
|
||||
if (parts.length) {
|
||||
return `Aperçu du document de ${parts.join(' – ')}`
|
||||
}
|
||||
return 'Aperçu du document'
|
||||
return parts.length ? `Aperçu du document de ${parts.join(' – ')}` : 'Aperçu du document'
|
||||
}
|
||||
|
||||
const resolvePieceType = (piece: Record<string, any>) => {
|
||||
const type = piece?.typePiece
|
||||
if (type?.name) {
|
||||
return type.name
|
||||
}
|
||||
if (piece?.typePieceLabel) {
|
||||
return piece.typePieceLabel
|
||||
}
|
||||
if (piece?.typePiece?.name) return piece.typePiece.name
|
||||
if (piece?.typePieceLabel) return piece.typePieceLabel
|
||||
return '—'
|
||||
}
|
||||
|
||||
@@ -342,61 +235,36 @@ const resolvePieceSuppliers = (piece: Record<string, any>) => {
|
||||
const seen = new Set<string>()
|
||||
|
||||
const pushName = (maybeName: unknown) => {
|
||||
if (typeof maybeName !== 'string') {
|
||||
return
|
||||
}
|
||||
if (typeof maybeName !== 'string') return
|
||||
const normalized = maybeName.trim().replace(/\s+/g, ' ')
|
||||
if (!normalized.length) {
|
||||
return
|
||||
}
|
||||
if (!normalized.length) return
|
||||
const key = normalized.toLowerCase()
|
||||
if (seen.has(key)) {
|
||||
return
|
||||
}
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
names.push(normalized)
|
||||
}
|
||||
|
||||
const collectConstructeurs = (value: unknown): void => {
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(collectConstructeurs)
|
||||
return
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
pushName(value)
|
||||
return
|
||||
}
|
||||
if (!value) return
|
||||
if (Array.isArray(value)) { value.forEach(collectConstructeurs); return }
|
||||
if (typeof value === 'string') { pushName(value); return }
|
||||
if (typeof value === 'object') {
|
||||
const record = value as Record<string, any>
|
||||
pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null)
|
||||
if (record?.constructeur) {
|
||||
collectConstructeurs(record.constructeur)
|
||||
}
|
||||
if (Array.isArray(record?.constructeurs)) {
|
||||
collectConstructeurs(record.constructeurs)
|
||||
}
|
||||
if (record?.constructeur) collectConstructeurs(record.constructeur)
|
||||
if (Array.isArray(record?.constructeurs)) collectConstructeurs(record.constructeurs)
|
||||
}
|
||||
}
|
||||
|
||||
const collectFromLabel = (value: unknown): void => {
|
||||
if (typeof value !== 'string') {
|
||||
return
|
||||
}
|
||||
value
|
||||
.split(/[,;\\/•·|]+/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.forEach(pushName)
|
||||
if (typeof value !== 'string') return
|
||||
value.split(/[,;\\/•·|]+/).map(part => part.trim()).filter(Boolean).forEach(pushName)
|
||||
}
|
||||
|
||||
collectConstructeurs(piece?.constructeurs)
|
||||
collectConstructeurs(piece?.constructeur)
|
||||
collectConstructeurs(piece?.product?.constructeurs)
|
||||
collectConstructeurs(piece?.product?.constructeur)
|
||||
|
||||
collectFromLabel(piece?.constructeursLabel)
|
||||
collectFromLabel(piece?.supplierLabel)
|
||||
collectFromLabel(piece?.product?.constructeursLabel)
|
||||
@@ -409,83 +277,45 @@ const buildPieceSuppliersDisplay = (piece: Record<string, any>) => {
|
||||
const suppliers = resolvePieceSuppliers(piece)
|
||||
const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS)
|
||||
const overflow = Math.max(suppliers.length - visible.length, 0)
|
||||
return {
|
||||
suppliers,
|
||||
visible,
|
||||
overflow,
|
||||
tooltip: suppliers.length ? suppliers.join(', ') : '',
|
||||
}
|
||||
return { suppliers, visible, overflow, tooltip: suppliers.length ? suppliers.join(', ') : '' }
|
||||
}
|
||||
|
||||
const resolveDeleteGuard = (piece: Record<string, any>) => {
|
||||
const blockingReasons: string[] = []
|
||||
const machineLinks = Array.isArray(piece?.machineLinks)
|
||||
? piece.machineLinks.length
|
||||
: piece?.machineLinksCount ?? 0
|
||||
const documents = Array.isArray(piece?.documents)
|
||||
? piece.documents.length
|
||||
: piece?.documentsCount ?? 0
|
||||
const customFields = Array.isArray(piece?.customFieldValues)
|
||||
? piece.customFieldValues.length
|
||||
: piece?.customFieldValuesCount ?? 0
|
||||
|
||||
if (machineLinks > 0) {
|
||||
blockingReasons.push(`${machineLinks} liaison${machineLinks > 1 ? 's' : ''} machine`)
|
||||
}
|
||||
if (documents > 0) {
|
||||
blockingReasons.push(`${documents} document${documents > 1 ? 's' : ''}`)
|
||||
}
|
||||
return {
|
||||
blockingReasons,
|
||||
hasCustomFields: customFields > 0,
|
||||
}
|
||||
const machineLinks = Array.isArray(piece?.machineLinks) ? piece.machineLinks.length : piece?.machineLinksCount ?? 0
|
||||
const documents = Array.isArray(piece?.documents) ? piece.documents.length : piece?.documentsCount ?? 0
|
||||
const customFields = Array.isArray(piece?.customFieldValues) ? piece.customFieldValues.length : piece?.customFieldValuesCount ?? 0
|
||||
if (machineLinks > 0) blockingReasons.push(`${machineLinks} liaison${machineLinks > 1 ? 's' : ''} machine`)
|
||||
if (documents > 0) blockingReasons.push(`${documents} document${documents > 1 ? 's' : ''}`)
|
||||
return { blockingReasons, hasCustomFields: customFields > 0 }
|
||||
}
|
||||
|
||||
const pieceRows = computed(() =>
|
||||
piecesList.value.map((piece) => ({
|
||||
piece,
|
||||
suppliers: buildPieceSuppliersDisplay(piece),
|
||||
})),
|
||||
)
|
||||
|
||||
const handleDeletePiece = async (piece: Record<string, any>) => {
|
||||
const { blockingReasons, hasCustomFields } = resolveDeleteGuard(piece)
|
||||
|
||||
if (blockingReasons.length) {
|
||||
showError(
|
||||
`Impossible de supprimer cette pièce car elle possède encore: ${blockingReasons.join(
|
||||
', ',
|
||||
)}. Supprimez ou détachez ces éléments avant de réessayer.`
|
||||
)
|
||||
showError(`Impossible de supprimer cette pièce car elle possède encore: ${blockingReasons.join(', ')}. Supprimez ou détachez ces éléments avant de réessayer.`)
|
||||
return
|
||||
}
|
||||
|
||||
const pieceName = piece?.name || 'cette pièce'
|
||||
const confirmLines = [
|
||||
`Voulez-vous vraiment supprimer ${pieceName} ?`,
|
||||
]
|
||||
|
||||
const confirmLines = [`Voulez-vous vraiment supprimer ${pieceName} ?`]
|
||||
if (hasCustomFields) {
|
||||
confirmLines.push(
|
||||
'Les valeurs de champs personnalisés associées seront également supprimées.'
|
||||
)
|
||||
confirmLines.push('Les valeurs de champs personnalisés associées seront également supprimées.')
|
||||
}
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
const confirmed = await confirm({ message: confirmLines.join('\n\n') })
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirmed) return
|
||||
await deletePiece(piece.id)
|
||||
// Reload current page after deletion
|
||||
fetchPieces()
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return '—'
|
||||
const date = new Date(dateStr)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
fetchPieces(),
|
||||
loadPieceTypes()
|
||||
])
|
||||
await Promise.all([fetchPieces(), loadPieceTypes()])
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -19,51 +19,8 @@
|
||||
|
||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||
<div class="card-body space-y-4">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<label class="w-full sm:w-72">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full mt-1"
|
||||
placeholder="Nom ou référence…"
|
||||
/>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-base-content/70" for="product-sort">Trier par</label>
|
||||
<select
|
||||
id="product-sort"
|
||||
v-model="sortField"
|
||||
class="select select-bordered select-sm"
|
||||
>
|
||||
<option value="name">Nom</option>
|
||||
<option value="createdAt">Date de création</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-base-content/70" for="product-dir">Ordre</label>
|
||||
<select
|
||||
id="product-dir"
|
||||
v-model="sortDirection"
|
||||
class="select select-bordered select-sm"
|
||||
>
|
||||
<option value="asc">Ascendant</option>
|
||||
<option value="desc">Descendant</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/60 lg:text-right">
|
||||
{{ filteredCount }} / {{ totalCount }} résultat{{ filteredCount > 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex justify-center py-10">
|
||||
<span class="loading loading-spinner" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="errorMessage"
|
||||
v-if="errorMessage"
|
||||
class="alert alert-error"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
@@ -75,96 +32,107 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-else-if="!hasLoaded" class="text-sm text-base-content/70">
|
||||
Chargement du catalogue…
|
||||
</p>
|
||||
<DataTable
|
||||
v-else
|
||||
:columns="columns"
|
||||
:rows="productRows"
|
||||
:loading="loadingProducts"
|
||||
:sort="table.sort.value"
|
||||
:pagination="paginationState"
|
||||
:column-filters="table.columnFilters.value"
|
||||
:show-per-page="true"
|
||||
empty-message="Aucun produit n'a encore été enregistré."
|
||||
no-results-message="Aucun produit ne correspond à votre recherche."
|
||||
@sort="table.handleSort"
|
||||
@update:current-page="table.handlePageChange"
|
||||
@update:per-page="table.handlePerPageChange"
|
||||
@update:column-filters="table.handleColumnFiltersChange"
|
||||
>
|
||||
<template #toolbar>
|
||||
<label class="w-full sm:w-72">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||||
<input
|
||||
v-model="table.searchTerm.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full mt-1"
|
||||
placeholder="Nom ou référence…"
|
||||
@input="table.debouncedSearch"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<p v-else-if="!normalizedProducts.length" class="text-sm text-base-content/70">
|
||||
Aucun produit n'a encore été enregistré.
|
||||
</p>
|
||||
<template #cell-preview="{ row }">
|
||||
<DocumentThumbnail
|
||||
:document="resolvePrimaryDocument(row.product)"
|
||||
:alt="resolvePreviewAlt(row.product)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<p v-else-if="filteredProducts.length === 0" class="text-sm text-base-content/70">
|
||||
Aucun produit ne correspond à votre recherche.
|
||||
</p>
|
||||
<template #cell-name="{ row }">
|
||||
<span class="font-medium">{{ row.product.name }}</span>
|
||||
</template>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="table table-sm md:table-md">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-16">Aperçu</th>
|
||||
<th>Nom</th>
|
||||
<th>Référence</th>
|
||||
<th>Type de produit</th>
|
||||
<th>Fournisseurs</th>
|
||||
<th class="text-right">Prix indicatif</th>
|
||||
<th class="w-32 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in productRows" :key="row.product.id">
|
||||
<td class="align-middle">
|
||||
<DocumentThumbnail
|
||||
:document="resolvePrimaryDocument(row.product)"
|
||||
:alt="resolvePreviewAlt(row.product)"
|
||||
/>
|
||||
</td>
|
||||
<td class="font-medium">{{ row.product.name }}</td>
|
||||
<td>{{ row.product.reference || '—' }}</td>
|
||||
<td>
|
||||
<NuxtLink
|
||||
v-if="row.product.typeProduct?.id"
|
||||
:to="`/product-category/${row.product.typeProduct.id}/edit`"
|
||||
class="link link-hover link-primary"
|
||||
>
|
||||
{{ row.product.typeProduct.name }}
|
||||
</NuxtLink>
|
||||
<span v-else>{{ row.product.typeProduct?.name || '—' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
v-if="row.suppliers.visible.length"
|
||||
class="flex max-w-[14rem] flex-wrap items-center gap-1 text-sm"
|
||||
:title="row.suppliers.tooltip"
|
||||
>
|
||||
<span
|
||||
v-for="supplier in row.suppliers.visible"
|
||||
:key="supplier"
|
||||
class="badge badge-ghost badge-sm whitespace-nowrap"
|
||||
>
|
||||
{{ supplier }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.suppliers.overflow"
|
||||
class="badge badge-outline badge-sm"
|
||||
>
|
||||
+{{ row.suppliers.overflow }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-sm text-base-content/50">—</span>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{{ formatPrice(row.product.supplierPrice) }}
|
||||
</td>
|
||||
<td class="text-right space-x-2">
|
||||
<NuxtLink
|
||||
:to="`/product/${row.product.id}/edit`"
|
||||
class="btn btn-ghost btn-xs"
|
||||
>
|
||||
Modifier
|
||||
</NuxtLink>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
@click="confirmDelete(row.product)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<template #cell-reference="{ row }">
|
||||
{{ row.product.reference || '—' }}
|
||||
</template>
|
||||
|
||||
<template #cell-typeProduct="{ row }">
|
||||
<NuxtLink
|
||||
v-if="row.product.typeProduct?.id"
|
||||
:to="`/product-category/${row.product.typeProduct.id}/edit`"
|
||||
class="link link-hover link-primary"
|
||||
>
|
||||
{{ row.product.typeProduct.name }}
|
||||
</NuxtLink>
|
||||
<span v-else>{{ row.product.typeProduct?.name || '—' }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-suppliers="{ row }">
|
||||
<div
|
||||
v-if="row.suppliers.visible.length"
|
||||
class="flex max-w-[14rem] flex-wrap items-center gap-1 text-sm"
|
||||
:title="row.suppliers.tooltip"
|
||||
>
|
||||
<span
|
||||
v-for="supplier in row.suppliers.visible"
|
||||
:key="supplier"
|
||||
class="badge badge-ghost badge-sm whitespace-nowrap"
|
||||
>
|
||||
{{ supplier }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.suppliers.overflow"
|
||||
class="badge badge-outline badge-sm"
|
||||
>
|
||||
+{{ row.suppliers.overflow }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-sm text-base-content/50">—</span>
|
||||
</template>
|
||||
|
||||
<template #cell-price="{ row }">
|
||||
{{ formatPrice(row.product.supplierPrice) }}
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex justify-end gap-2">
|
||||
<NuxtLink
|
||||
:to="`/product/${row.product.id}/edit`"
|
||||
class="btn btn-ghost btn-xs"
|
||||
>
|
||||
Modifier
|
||||
</NuxtLink>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
@click="confirmDelete(row.product)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -173,24 +141,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useHead } from '#imports'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useUrlState } from '~/composables/useUrlState'
|
||||
import { useDataTable } from '~/composables/useDataTable'
|
||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
|
||||
useHead(() => ({
|
||||
title: 'Catalogue des produits',
|
||||
}))
|
||||
useHead(() => ({ title: 'Catalogue des produits' }))
|
||||
|
||||
const {
|
||||
products,
|
||||
total,
|
||||
loading,
|
||||
loaded,
|
||||
error,
|
||||
loadProducts,
|
||||
deleteProduct,
|
||||
@@ -198,65 +164,54 @@ const {
|
||||
const { productTypes, loadProductTypes } = useProductTypes()
|
||||
const toast = useToast()
|
||||
|
||||
const { q: searchTerm, sort: sortField, dir: sortDirection } = useUrlState({
|
||||
q: { default: '', debounce: 300 },
|
||||
sort: { default: 'name' },
|
||||
dir: { default: 'asc' },
|
||||
})
|
||||
const table = useDataTable(
|
||||
{ fetchData: fetchProducts },
|
||||
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true },
|
||||
)
|
||||
|
||||
// Enrichir les produits avec les types de produits complets
|
||||
const loadingProducts = computed(() => loading.value)
|
||||
const errorMessage = computed(() => (typeof error.value === 'string' && error.value.length ? error.value : null))
|
||||
|
||||
const columns = [
|
||||
{ key: 'preview', label: 'Aperçu', width: 'w-16' },
|
||||
{ key: 'name', label: 'Nom', sortable: true },
|
||||
{ key: 'reference', label: 'Référence' },
|
||||
{ key: 'typeProduct', label: 'Type de produit', filterable: true, filterPlaceholder: 'Filtrer…' },
|
||||
{ key: 'suppliers', label: 'Fournisseurs' },
|
||||
{ key: 'price', label: 'Prix indicatif', sortable: true, sortKey: 'supplierPrice', align: 'right' as const },
|
||||
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-32' },
|
||||
]
|
||||
|
||||
const productsOnPage = computed(() => productRows.value.length)
|
||||
const paginationState = table.pagination(total, productsOnPage)
|
||||
|
||||
// Enrich products with full type data
|
||||
const normalizedProducts = computed(() => {
|
||||
return (Array.isArray(products.value) ? products.value : []).map((product) => {
|
||||
const typeProduct = productTypes.value.find(t => t.id === product.typeProductId)
|
||||
return {
|
||||
...product,
|
||||
typeProduct: typeProduct || product.typeProduct || null
|
||||
}
|
||||
})
|
||||
})
|
||||
const hasLoaded = computed(() => loaded.value)
|
||||
const errorMessage = computed(() => (typeof error.value === 'string' && error.value.length ? error.value : null))
|
||||
|
||||
const filteredProducts = computed(() => {
|
||||
const term = searchTerm.value.trim().toLowerCase()
|
||||
const items = normalizedProducts.value.slice()
|
||||
|
||||
const filtered = term
|
||||
? items.filter((product) => {
|
||||
const name = (product?.name || '').toLowerCase()
|
||||
const reference = (product?.reference || '').toLowerCase()
|
||||
const typeName = (product?.typeProduct?.name || '').toLowerCase()
|
||||
return (
|
||||
name.includes(term) ||
|
||||
reference.includes(term) ||
|
||||
typeName.includes(term)
|
||||
)
|
||||
})
|
||||
: items
|
||||
|
||||
const direction = sortDirection.value === 'asc' ? 1 : -1
|
||||
|
||||
return filtered.sort((a, b) => {
|
||||
if (sortField.value === 'name') {
|
||||
return (
|
||||
(a?.name || '').localeCompare(b?.name || '', 'fr', { sensitivity: 'base' })
|
||||
) * direction
|
||||
}
|
||||
|
||||
const dateA = a?.createdAt ? new Date(a.createdAt).getTime() : 0
|
||||
const dateB = b?.createdAt ? new Date(b.createdAt).getTime() : 0
|
||||
return (dateA - dateB) * direction
|
||||
return { ...product, typeProduct: typeProduct || product.typeProduct || null }
|
||||
})
|
||||
})
|
||||
|
||||
const filteredCount = computed(() => filteredProducts.value.length)
|
||||
const totalCount = computed(() => {
|
||||
const reported = Number(total.value)
|
||||
if (!Number.isFinite(reported) || reported < 0) {
|
||||
return normalizedProducts.value.length
|
||||
}
|
||||
return reported
|
||||
})
|
||||
const productRows = computed(() =>
|
||||
normalizedProducts.value.map(product => ({
|
||||
id: product.id,
|
||||
product,
|
||||
suppliers: buildSuppliersDisplay(product),
|
||||
})),
|
||||
)
|
||||
|
||||
async function fetchProducts() {
|
||||
await loadProducts({
|
||||
search: table.searchTerm.value,
|
||||
page: table.currentPage.value,
|
||||
itemsPerPage: table.itemsPerPage.value,
|
||||
orderBy: table.sortField.value,
|
||||
orderDir: table.sortDirection.value as 'asc' | 'desc',
|
||||
typeName: table.columnFilters.value.typeProduct || undefined,
|
||||
force: true,
|
||||
})
|
||||
}
|
||||
|
||||
const priceFormatter = new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
@@ -265,14 +220,9 @@ const priceFormatter = new Intl.NumberFormat('fr-FR', {
|
||||
})
|
||||
|
||||
const formatPrice = (value: any) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '—'
|
||||
}
|
||||
if (value === null || value === undefined || value === '') return '—'
|
||||
const number = Number(value)
|
||||
if (Number.isNaN(number)) {
|
||||
return '—'
|
||||
}
|
||||
return priceFormatter.format(number)
|
||||
return Number.isNaN(number) ? '—' : priceFormatter.format(number)
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_SUPPLIERS = 3
|
||||
@@ -282,59 +232,34 @@ const resolveProductSuppliers = (product: Record<string, any>) => {
|
||||
const seen = new Set<string>()
|
||||
|
||||
const pushName = (maybeName: unknown) => {
|
||||
if (typeof maybeName !== 'string') {
|
||||
return
|
||||
}
|
||||
if (typeof maybeName !== 'string') return
|
||||
const normalized = maybeName.trim().replace(/\s+/g, ' ')
|
||||
if (!normalized.length) {
|
||||
return
|
||||
}
|
||||
if (!normalized.length) return
|
||||
const key = normalized.toLowerCase()
|
||||
if (seen.has(key)) {
|
||||
return
|
||||
}
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
names.push(normalized)
|
||||
}
|
||||
|
||||
const collectConstructeurs = (value: unknown): void => {
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(collectConstructeurs)
|
||||
return
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
pushName(value)
|
||||
return
|
||||
}
|
||||
if (!value) return
|
||||
if (Array.isArray(value)) { value.forEach(collectConstructeurs); return }
|
||||
if (typeof value === 'string') { pushName(value); return }
|
||||
if (typeof value === 'object') {
|
||||
const record = value as Record<string, any>
|
||||
pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null)
|
||||
if (record?.constructeur) {
|
||||
collectConstructeurs(record.constructeur)
|
||||
}
|
||||
if (Array.isArray(record?.constructeurs)) {
|
||||
collectConstructeurs(record.constructeurs)
|
||||
}
|
||||
if (record?.constructeur) collectConstructeurs(record.constructeur)
|
||||
if (Array.isArray(record?.constructeurs)) collectConstructeurs(record.constructeurs)
|
||||
}
|
||||
}
|
||||
|
||||
const collectFromLabel = (value: unknown): void => {
|
||||
if (typeof value !== 'string') {
|
||||
return
|
||||
}
|
||||
value
|
||||
.split(/[,;\\/•·|]+/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.forEach(pushName)
|
||||
if (typeof value !== 'string') return
|
||||
value.split(/[,;\\/•·|]+/).map(part => part.trim()).filter(Boolean).forEach(pushName)
|
||||
}
|
||||
|
||||
collectConstructeurs(product?.constructeurs)
|
||||
collectConstructeurs(product?.constructeur)
|
||||
|
||||
collectFromLabel(product?.constructeursLabel)
|
||||
collectFromLabel(product?.supplierLabel)
|
||||
collectFromLabel(product?.suppliers)
|
||||
@@ -346,53 +271,28 @@ const buildSuppliersDisplay = (product: Record<string, any>) => {
|
||||
const suppliers = resolveProductSuppliers(product)
|
||||
const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS)
|
||||
const overflow = Math.max(suppliers.length - visible.length, 0)
|
||||
return {
|
||||
suppliers,
|
||||
visible,
|
||||
overflow,
|
||||
tooltip: suppliers.length ? suppliers.join(', ') : '',
|
||||
}
|
||||
return { suppliers, visible, overflow, tooltip: suppliers.length ? suppliers.join(', ') : '' }
|
||||
}
|
||||
|
||||
const productRows = computed(() =>
|
||||
filteredProducts.value.map((product) => ({
|
||||
product,
|
||||
suppliers: buildSuppliersDisplay(product),
|
||||
})),
|
||||
)
|
||||
|
||||
const resolvePrimaryDocument = (product: Record<string, any>) => {
|
||||
const documents = Array.isArray(product?.documents) ? product.documents : []
|
||||
if (!documents.length) {
|
||||
return null
|
||||
}
|
||||
const normalized = documents.filter((doc) => doc && typeof doc === 'object')
|
||||
const withPath = normalized.filter((doc) => doc?.fileUrl || doc?.path)
|
||||
if (!withPath.length) {
|
||||
return normalized[0] ?? null
|
||||
}
|
||||
const images = withPath.filter((doc) => isImageDocument(doc))
|
||||
if (images.length) {
|
||||
return images[0]
|
||||
}
|
||||
const pdf = withPath.find((doc) => isPdfDocument(doc))
|
||||
if (pdf) {
|
||||
return pdf
|
||||
}
|
||||
if (!documents.length) return null
|
||||
const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
|
||||
const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
|
||||
if (!withPath.length) return normalized[0] ?? null
|
||||
const images = withPath.filter((doc: any) => isImageDocument(doc))
|
||||
if (images.length) return images[0]
|
||||
const pdf = withPath.find((doc: any) => isPdfDocument(doc))
|
||||
if (pdf) return pdf
|
||||
return withPath[0]
|
||||
}
|
||||
|
||||
const resolvePreviewAlt = (product: Record<string, any>) => {
|
||||
const parts = [product?.name, product?.reference].filter(Boolean)
|
||||
if (parts.length) {
|
||||
return `Aperçu du document de ${parts.join(' – ')}`
|
||||
}
|
||||
return 'Aperçu du document'
|
||||
return parts.length ? `Aperçu du document de ${parts.join(' – ')}` : 'Aperçu du document'
|
||||
}
|
||||
|
||||
const reload = async () => {
|
||||
await loadProducts({ itemsPerPage: 200, force: true })
|
||||
}
|
||||
const reload = () => fetchProducts()
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
@@ -400,10 +300,7 @@ const confirmDelete = async (product: Record<string, any>) => {
|
||||
const confirmed = await confirm({
|
||||
message: `Voulez-vous vraiment supprimer le produit "${product.name}" ?\n\nCette action est irréversible.`,
|
||||
})
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirmed) return
|
||||
const result = await deleteProduct(product.id)
|
||||
if (result.success) {
|
||||
toast.showSuccess(`Produit "${product.name}" supprimé`)
|
||||
@@ -411,9 +308,6 @@ const confirmDelete = async (product: Record<string, any>) => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadProducts({ itemsPerPage: 200, force: true }),
|
||||
loadProductTypes()
|
||||
])
|
||||
await Promise.all([fetchProducts(), loadProductTypes()])
|
||||
})
|
||||
</script>
|
||||
|
||||
46
app/shared/types/dataTable.ts
Normal file
46
app/shared/types/dataTable.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export type SortDirection = 'asc' | 'desc'
|
||||
|
||||
export interface DataTableColumn {
|
||||
/** Unique key — used as slot name prefix (`#cell-{key}`) and default data accessor (`row[key]`) */
|
||||
key: string
|
||||
/** Display label for the column header */
|
||||
label: string
|
||||
/** Whether clicking this column header triggers sorting. Default: false */
|
||||
sortable?: boolean
|
||||
/** Sort key sent to the API (may differ from `key`). Falls back to `key` if omitted. */
|
||||
sortKey?: string
|
||||
/** CSS class applied to both <th> and <td> */
|
||||
class?: string
|
||||
/** CSS class applied only to <th> */
|
||||
headerClass?: string
|
||||
/** Width hint (e.g. 'w-24', 'min-w-[10rem]') */
|
||||
width?: string
|
||||
/** Text alignment: 'left' (default), 'center', 'right' */
|
||||
align?: 'left' | 'center' | 'right'
|
||||
/** Hide on mobile (adds 'hidden sm:table-cell') */
|
||||
hiddenMobile?: boolean
|
||||
/** Whether this column has a filter input under the header */
|
||||
filterable?: boolean
|
||||
/** Placeholder for the filter input */
|
||||
filterPlaceholder?: string
|
||||
}
|
||||
|
||||
export type DataTableColumnFilters = Record<string, string>
|
||||
|
||||
export interface DataTableSort {
|
||||
field: string
|
||||
direction: SortDirection
|
||||
}
|
||||
|
||||
export interface DataTablePagination {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
/** Total items matching current filters */
|
||||
totalItems: number
|
||||
/** Items displayed on current page */
|
||||
pageItems: number
|
||||
/** Available per-page options. If omitted, per-page selector is hidden. */
|
||||
perPageOptions?: number[]
|
||||
/** Currently selected items per page */
|
||||
perPage?: number
|
||||
}
|
||||
Reference in New Issue
Block a user