Merge branch 'fix/filtres-listes' into develop

This commit is contained in:
Matthieu
2026-02-11 16:50:39 +01:00
20 changed files with 259 additions and 119 deletions

View File

@@ -92,11 +92,12 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
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 { useApi } from "~/composables/useApi";
import { useUrlState } from "~/composables/useUrlState";
import { extractCollection } from "~/shared/utils/apiHelpers";
import {
deleteModelType,
@@ -125,11 +126,28 @@ const props = withDefaults(
const selectedCategory = ref<ModelCategory>(props.category);
const searchInput = ref("");
const searchTerm = ref("");
const sort = ref<"name" | "createdAt">("name");
const dir = ref<"asc" | "desc">("asc");
const limit = ref(20);
const offset = ref(0);
// State synced with URL query params (preserved on back/forward navigation)
const urlState = useUrlState({
q: { default: '' },
sort: { default: 'name' },
dir: { default: 'asc' },
limit: { default: 20, type: 'number' },
offset: { default: 0, type: 'number' },
}, {
onRestore: () => {
searchInput.value = urlState.q.value;
refresh();
},
});
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;
const items = ref<ModelType[]>([]);
const total = ref(0);

View File

@@ -49,11 +49,12 @@ export function useDocuments() {
const loadFromEndpoint = async (
endpoint: string,
{ updateStore = false }: { updateStore?: boolean } = {},
{ updateStore = false, itemsPerPage }: { updateStore?: boolean; itemsPerPage?: number } = {},
): Promise<DocumentResult> => {
loading.value = true
try {
const result = await get(endpoint)
const url = itemsPerPage ? `${endpoint}${endpoint.includes('?') ? '&' : '?'}itemsPerPage=${itemsPerPage}` : endpoint
const result = await get(url)
if (result.success) {
const data = extractCollection(result.data)
if (updateStore) {
@@ -76,9 +77,9 @@ export function useDocuments() {
}
const loadDocuments = async (
options: { updateStore?: boolean } = {},
options: { updateStore?: boolean; itemsPerPage?: number } = {},
): Promise<DocumentResult> => {
return loadFromEndpoint('/documents', { updateStore: options.updateStore ?? true })
return loadFromEndpoint('/documents', { updateStore: options.updateStore ?? true, itemsPerPage: options.itemsPerPage })
}
const loadDocumentsBySite = async (

View File

@@ -0,0 +1,116 @@
import { ref, watch, nextTick, type Ref } from 'vue'
import { useRoute, useRouter } from '#imports'
interface ParamDef<T extends string | number = string | number> {
default: T
type?: 'string' | 'number'
/** Debounce URL writes (ms). Default: 0 (immediate). */
debounce?: number
}
type ParamDefs = Record<string, ParamDef>
type InferRef<D extends ParamDef> = D['default'] extends number ? Ref<number> : Ref<string>
type StateRefs<T extends ParamDefs> = {
[K in keyof T]: InferRef<T[K]>
}
interface UseUrlStateOptions {
/** Called when state is restored from URL (back/forward navigation). */
onRestore?: () => void
}
export function useUrlState<T extends ParamDefs>(
params: T,
options?: UseUrlStateOptions,
): StateRefs<T> {
const route = useRoute()
const router = useRouter()
const keys = Object.keys(params) as (keyof T & string)[]
const refs: Record<string, Ref<string | number>> = {}
const timers: Record<string, ReturnType<typeof setTimeout> | null> = {}
for (const key of keys) {
refs[key] = ref(parseValue(route.query[key], params[key]!))
timers[key] = null
}
let isProgrammatic = false
const buildQuery = (): Record<string, string> => {
const q: Record<string, string> = {}
for (const key of keys) {
const val = refs[key]!.value
if (val !== params[key]!.default) {
q[key] = String(val)
}
}
return q
}
const pushToUrl = () => {
if (isProgrammatic) return
isProgrammatic = true
const query = buildQuery()
router
.replace({ path: route.path, query })
.catch(() => {})
.finally(() => {
nextTick(() => {
isProgrammatic = false
})
})
}
for (const key of keys) {
const ms = params[key]!.debounce ?? 0
watch(refs[key]!, () => {
if (isProgrammatic) return
if (ms > 0) {
if (timers[key]) clearTimeout(timers[key]!)
timers[key] = setTimeout(pushToUrl, ms)
} else {
pushToUrl()
}
})
}
watch(
() => ({ ...route.query }),
(newQuery) => {
if (isProgrammatic) return
isProgrammatic = true
let changed = false
for (const key of keys) {
const parsed = parseValue(newQuery[key], params[key]!)
if (refs[key]!.value !== parsed) {
refs[key]!.value = parsed
changed = true
}
}
nextTick(() => {
isProgrammatic = false
if (changed && options?.onRestore) {
options.onRestore()
}
})
},
)
return refs as StateRefs<T>
}
function parseValue(
raw: unknown,
def: ParamDef,
): string | number {
const str = typeof raw === 'string' ? raw : null
if (str === null) return def.default
if (def.type === 'number' || typeof def.default === 'number') {
const n = Number(str)
return Number.isFinite(n) ? n : def.default
}
return str
}

View File

@@ -176,11 +176,11 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted } from 'vue'
import { useComposants } from '~/composables/useComposants'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useToast } from '~/composables/useToast'
import { usePersistedSort } from '~/composables/usePersistedSort'
import { useUrlState } from '~/composables/useUrlState'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import Pagination from '~/components/common/Pagination.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
@@ -190,15 +190,28 @@ const { composants, total, loadComposants, loading: loadingComposantsRef, delete
const { componentTypes, loadComponentTypes } = useComponentTypes()
const loadingComposants = computed(() => loadingComposantsRef.value)
// Pagination state
const currentPage = ref(1)
const itemsPerPage = ref(30)
// 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 composantsTotal = computed(() => total.value)
const composantsOnPage = computed(() => composants.value.length)
const totalPages = computed(() => Math.ceil(composantsTotal.value / itemsPerPage.value) || 1)
// Search state with debounce
const searchTerm = ref('')
// Search debounce for API calls
let searchTimeout: ReturnType<typeof setTimeout> | null = null
const debouncedSearch = () => {
@@ -211,12 +224,6 @@ const debouncedSearch = () => {
}, 300)
}
// Sort state
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
'component-catalog',
{ field: 'name', direction: 'asc' },
)
// Enrichir les composants avec les types de composants complets
const composantsList = computed(() => {
return (composants.value || []).map((composant) => {
@@ -234,7 +241,8 @@ const fetchComposants = async () => {
page: currentPage.value,
itemsPerPage: itemsPerPage.value,
orderBy: sortField.value,
orderDir: sortDirection.value
orderDir: sortDirection.value as 'asc' | 'desc',
force: true
})
}

View File

@@ -8,9 +8,9 @@
Ajustez le squelette et les métadonnées de cette catégorie de composant. Les modifications seront appliquées lors des prochaines créations de composants.
</p>
</div>
<NuxtLink class="btn btn-ghost" to="/component-category">
<button type="button" class="btn btn-ghost" @click="$router.back()">
Retour au catalogue
</NuxtLink>
</button>
</div>
</header>

View File

@@ -8,9 +8,9 @@
Configurez le squelette canonique qui sera appliqué lors de la création des composants appartenant à cette catégorie.
</p>
</div>
<NuxtLink class="btn btn-ghost" to="/component-category">
<button type="button" class="btn btn-ghost" @click="$router.back()">
Retour au catalogue
</NuxtLink>
</button>
</div>
</header>

View File

@@ -19,9 +19,9 @@
</p>
</div>
</div>
<NuxtLink to="/component-catalog" class="btn btn-primary mt-6">
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
Retour au catalogue
</NuxtLink>
</button>
</div>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
@@ -33,9 +33,9 @@
Mettez à jour les informations du composant et ses champs personnalisés.
</p>
</div>
<NuxtLink to="/component-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue
</NuxtLink>
</button>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">

View File

@@ -7,9 +7,9 @@
Sélectionnez la catégorie cible puis complétez les informations du composant.
</p>
</div>
<NuxtLink to="/component-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue
</NuxtLink>
</button>
</header>
<section class="card border border-base-200 bg-base-100 shadow-sm">

View File

@@ -132,6 +132,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useDocuments } from '~/composables/useDocuments'
import { useUrlState } from '~/composables/useUrlState'
import { getFileIcon } from '~/utils/fileIcons'
import { canPreviewDocument } from '~/utils/documentPreview'
import { formatFrenchDate } from '~/utils/date'
@@ -140,13 +141,15 @@ import IconLucideFileSearch from '~icons/lucide/file-search'
const { documents, loading, loadDocuments } = useDocuments()
const searchTerm = ref('')
const attachmentFilter = ref('all')
const { q: searchTerm, filter: attachmentFilter } = useUrlState({
q: { default: '', debounce: 300 },
filter: { default: 'all' },
})
const previewDocument = ref(null)
const previewVisible = ref(false)
onMounted(() => {
loadDocuments()
loadDocuments({ itemsPerPage: 200 })
})
const filteredDocuments = computed(() => {
@@ -156,10 +159,10 @@ const filteredDocuments = computed(() => {
return documents.value.filter((document) => {
const matchesFilter =
filter === 'all' ||
(filter === 'site' && document.siteId) ||
(filter === 'machine' && document.machineId) ||
(filter === 'composant' && document.composantId) ||
(filter === 'piece' && document.pieceId)
(filter === 'site' && document.site) ||
(filter === 'machine' && document.machine) ||
(filter === 'composant' && document.composant) ||
(filter === 'piece' && document.piece)
if (!matchesFilter) { return false }

View File

@@ -8,9 +8,9 @@
Mettez à jour la structure et les champs personnalisés de cette catégorie de pièce pour préparer les futures créations.
</p>
</div>
<NuxtLink class="btn btn-ghost" to="/piece-category">
<button type="button" class="btn btn-ghost" @click="$router.back()">
Retour au catalogue
</NuxtLink>
</button>
</div>
</header>

View File

@@ -8,9 +8,9 @@
Définissez les champs personnalisés et le squelette appliqué lors de la création des pièces de cette catégorie.
</p>
</div>
<NuxtLink class="btn btn-ghost" to="/piece-category">
<button type="button" class="btn btn-ghost" @click="$router.back()">
Retour au catalogue
</NuxtLink>
</button>
</div>
</header>

View File

@@ -198,11 +198,11 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted } from 'vue'
import { usePieces } from '~/composables/usePieces'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useToast } from '~/composables/useToast'
import { usePersistedSort } from '~/composables/usePersistedSort'
import { useUrlState } from '~/composables/useUrlState'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import Pagination from '~/components/common/Pagination.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
@@ -212,15 +212,28 @@ const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = us
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const loadingPieces = computed(() => loadingPiecesRef.value)
// Pagination state
const currentPage = ref(1)
const itemsPerPage = ref(30)
// 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 piecesTotal = computed(() => total.value)
const piecesOnPage = computed(() => pieces.value.length)
const totalPages = computed(() => Math.ceil(piecesTotal.value / itemsPerPage.value) || 1)
// Search state with debounce
const searchTerm = ref('')
// Search debounce for API calls
let searchTimeout: ReturnType<typeof setTimeout> | null = null
const debouncedSearch = () => {
@@ -233,12 +246,6 @@ const debouncedSearch = () => {
}, 300)
}
// Sort state
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
'pieces-catalog',
{ field: 'name', direction: 'asc' },
)
// Enrichir les pièces avec les types de pièces complets
const piecesList = computed(() => {
return (pieces.value || []).map((piece) => {
@@ -256,7 +263,8 @@ const fetchPieces = async () => {
page: currentPage.value,
itemsPerPage: itemsPerPage.value,
orderBy: sortField.value,
orderDir: sortDirection.value
orderDir: sortDirection.value as 'asc' | 'desc',
force: true
})
}

View File

@@ -19,9 +19,9 @@
</p>
</div>
</div>
<NuxtLink to="/pieces-catalog" class="btn btn-primary mt-6">
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
Retour au catalogue
</NuxtLink>
</button>
</div>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
@@ -33,9 +33,9 @@
Ajustez les informations de la pièce et ses champs personnalisés.
</p>
</div>
<NuxtLink to="/pieces-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue
</NuxtLink>
</button>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">

View File

@@ -7,9 +7,9 @@
Choisissez la catégorie adaptée puis renseignez toutes les informations de votre pièce.
</p>
</div>
<NuxtLink to="/pieces-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue
</NuxtLink>
</button>
</header>
<section class="card border border-base-200 bg-base-100 shadow-sm">

View File

@@ -170,12 +170,12 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted } from 'vue'
import { useHead } from '#imports'
import { useProducts } from '~/composables/useProducts'
import { useProductTypes } from '~/composables/useProductTypes'
import { useToast } from '~/composables/useToast'
import { usePersistedSort } from '~/composables/usePersistedSort'
import { useUrlState } from '~/composables/useUrlState'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
@@ -195,11 +195,11 @@ const {
const { productTypes, loadProductTypes } = useProductTypes()
const toast = useToast()
const searchTerm = ref('')
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
'product-catalog',
{ field: 'name', direction: 'asc' },
)
const { q: searchTerm, sort: sortField, dir: sortDirection } = useUrlState({
q: { default: '', debounce: 300 },
sort: { default: 'name' },
dir: { default: 'asc' },
})
// Enrichir les produits avec les types de produits complets
const normalizedProducts = computed(() => {
@@ -388,7 +388,7 @@ const resolvePreviewAlt = (product: Record<string, any>) => {
}
const reload = async () => {
await loadProducts({ force: true })
await loadProducts({ itemsPerPage: 200, force: true })
}
const { confirm } = useConfirm()
@@ -409,7 +409,7 @@ const confirmDelete = async (product: Record<string, any>) => {
onMounted(async () => {
await Promise.all([
loadProducts(),
loadProducts({ itemsPerPage: 200, force: true }),
loadProductTypes()
])
})

View File

@@ -8,9 +8,9 @@
Mettez à jour la structure et les champs personnalisés de cette catégorie de produit pour préparer les futures créations.
</p>
</div>
<NuxtLink class="btn btn-ghost" to="/product-category">
<button type="button" class="btn btn-ghost" @click="$router.back()">
Retour au catalogue
</NuxtLink>
</button>
</div>
</header>

View File

@@ -8,9 +8,9 @@
Définissez les champs personnalisés et le squelette appliqué lors de la création des produits de cette catégorie.
</p>
</div>
<NuxtLink class="btn btn-ghost" to="/product-category">
<button type="button" class="btn btn-ghost" @click="$router.back()">
Retour au catalogue
</NuxtLink>
</button>
</div>
</header>

View File

@@ -19,9 +19,9 @@
</p>
</div>
</div>
<NuxtLink to="/product-catalog" class="btn btn-primary mt-6">
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
Retour au catalogue
</NuxtLink>
</button>
</div>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-4xl mx-auto">
@@ -33,9 +33,9 @@
Mettez à jour les informations du produit et ses champs personnalisés.
</p>
</div>
<NuxtLink to="/product-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue
</NuxtLink>
</button>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">

View File

@@ -7,9 +7,9 @@
Sélectionnez la catégorie cible puis renseignez toutes les informations de votre produit catalogue.
</p>
</div>
<NuxtLink to="/product-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue
</NuxtLink>
</button>
</header>
<section class="card border border-base-200 bg-base-100 shadow-sm">

View File

@@ -132,28 +132,19 @@ export async function listModelTypes(params: ModelTypeListParams = {}, opts: { s
if (params.category) {
query.category = params.category;
}
if (params.sort) {
query.sort = params.sort;
}
if (params.dir) {
query.dir = params.dir;
}
const hasCategoryFilter = Boolean(params.category);
const effectiveLimit = typeof params.limit === 'number' ? params.limit : undefined;
const effectiveOffset = typeof params.offset === 'number' ? params.offset : 0;
if (hasCategoryFilter) {
// Fetch enough items to allow client-side category filtering + pagination.
query.itemsPerPage = Math.max(effectiveLimit ?? 200, 200);
query.offset = 0;
} else {
if (typeof params.limit === 'number') {
query.itemsPerPage = params.limit;
}
if (typeof params.offset === 'number') {
query.offset = params.offset;
}
}
// Sort: API Platform OrderFilter uses order[field]=direction
const sortField = params.sort || 'name';
const sortDir = params.dir || 'asc';
query[`order[${sortField}]`] = sortDir;
// Pagination: API Platform uses page + itemsPerPage
const effectiveLimit = typeof params.limit === 'number' ? params.limit : 20;
const effectiveOffset = typeof params.offset === 'number' ? params.offset : 0;
const page = Math.floor(effectiveOffset / effectiveLimit) + 1;
query.itemsPerPage = effectiveLimit;
query.page = page;
const payload = await requestFetch<Record<string, any>>(ENDPOINT, createOptions({
method: 'GET',
@@ -168,25 +159,20 @@ export async function listModelTypes(params: ModelTypeListParams = {}, opts: { s
: Array.isArray(payload?.items)
? payload.items
: [];
const filteredItems = params.category
? rawItems.filter((item: any) => item?.category === params.category)
: rawItems;
const total = params.category
? filteredItems.length
: typeof payload?.totalItems === 'number'
? payload.totalItems
: Array.isArray(payload?.items)
? payload.items.length
: rawItems.length;
const items = (params.category && typeof effectiveLimit === 'number'
? filteredItems.slice(effectiveOffset, effectiveOffset + effectiveLimit)
: filteredItems).map(normalizeModelType);
const total = typeof payload?.totalItems === 'number'
? payload.totalItems
: typeof payload?.['hydra:totalItems'] === 'number'
? payload['hydra:totalItems']
: rawItems.length;
const items = rawItems.map(normalizeModelType);
return {
items,
total,
offset: effectiveOffset,
limit: typeof effectiveLimit === 'number' ? effectiveLimit : items.length,
limit: effectiveLimit,
} satisfies ModelTypeListResponse;
}