6 Commits

Author SHA1 Message Date
Matthieu
439db8117a feat(changelog) : add changelog page accessible from footer version link
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 17:01:28 +01:00
Matthieu
675820532c Merge branch 'develop' into master — v1.5.0 2026-02-11 16:50:44 +01:00
Matthieu
4edfc55c37 Merge branch 'fix/filtres-listes' into develop 2026-02-11 16:50:39 +01:00
Matthieu
480aaa24b2 feat(navigation) : preserve list state in URL and use browser history for back buttons
Add useUrlState composable to sync page, search, sort and filter state
with URL query params. Back/forward navigation now restores the exact
list position. Replace hardcoded NuxtLink back buttons with
router.back() across all create/edit pages. Fix documents attachment
filter that checked non-existent ID fields instead of relation objects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:48:40 +01:00
Matthieu
185af65519 fix(filters) : repair broken filters on catalog and document pages
- modelTypes.ts: use API Platform OrderFilter format (order[field]=dir) and proper page param
- product-catalog: load all products (itemsPerPage: 200) instead of default 30
- documents: load all documents (itemsPerPage: 200) instead of default 30
- useDocuments: support itemsPerPage option in loadDocuments/loadFromEndpoint
- pieces-catalog + component-catalog: add force:true to bypass stale cache on sort/filter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:32:54 +01:00
Matthieu
8fecf67a7f fix(api): reduce itemsPerPage from 500 to 200 on bulk catalog loads
Prevents memory exhaustion (OOM) on production server when loading
pieces, products, and composants in the component edit page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 11:10:52 +01:00
22 changed files with 428 additions and 123 deletions

View File

@@ -19,7 +19,9 @@
<footer class="footer p-4 bg-neutral text-neutral-content">
<div class="items-center grid-flow-col">
<p>@Malio 2025 · v{{ appVersion }}</p>
<p>
@Malio 2025 · <NuxtLink to="/changelog" class="link link-hover">v{{ appVersion }}</NuxtLink>
</p>
</div>
</footer>
</div>

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
}

163
app/pages/changelog.vue Normal file
View File

@@ -0,0 +1,163 @@
<template>
<main class="container mx-auto max-w-4xl px-6 py-10 space-y-8">
<header class="space-y-2">
<h1 class="text-3xl font-bold text-base-content">Changelog</h1>
<p class="text-sm text-base-content/70">
Historique des modifications et nouvelles fonctionnalités de l'application.
</p>
</header>
<section
v-for="release in releases"
:key="release.version"
class="card border border-base-200 bg-base-100 shadow-sm"
>
<div class="card-body space-y-3">
<div class="flex items-center gap-3">
<h2 class="text-xl font-bold text-base-content">
{{ release.version }}
</h2>
<span class="badge badge-ghost text-xs">{{ release.date }}</span>
</div>
<ul class="space-y-2">
<li
v-for="(item, i) in release.changes"
:key="i"
class="flex items-start gap-2 text-sm text-base-content/80"
>
<span
class="badge badge-sm mt-0.5 shrink-0"
:class="badgeClass(item.type)"
>
{{ item.type }}
</span>
<span>{{ item.text }}</span>
</li>
</ul>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { useHead } from '#imports'
useHead({ title: 'Changelog' })
type ChangeType = 'feat' | 'fix' | 'perf' | 'chore'
interface Change {
type: ChangeType
text: string
}
interface Release {
version: string
date: string
changes: Change[]
}
const badgeClass = (type: ChangeType) => {
const map: Record<ChangeType, string> = {
feat: 'badge-primary',
fix: 'badge-error',
perf: 'badge-warning',
chore: 'badge-ghost',
}
return map[type] ?? 'badge-ghost'
}
const releases: Release[] = [
{
version: 'v1.5.0',
date: '2026-02-11',
changes: [
{ type: 'feat', text: 'Page de journal d\'activité globale avec filtres par entité, par acteur et pagination serveur' },
{ type: 'feat', text: 'Suivi d\'audit : enregistrement des noms de fournisseurs et des modifications de champs personnalisés' },
{ type: 'feat', text: 'Préservation de l\'état des listes dans l\'URL (page courante, recherche, tri, direction, filtres) — le retour navigateur restaure exactement la position précédente' },
{ type: 'feat', text: 'Boutons « Retour » sur toutes les pages de création et d\'édition utilisent désormais l\'historique du navigateur au lieu de liens fixes' },
{ type: 'feat', text: 'Première lettre automatiquement en majuscule lors de la création de catégories et de composants' },
{ type: 'feat', text: 'Les types de catégories dans les tableaux des catalogues sont maintenant cliquables (lien vers la fiche d\'édition)' },
{ type: 'feat', text: 'Application des couleurs de marque Malio sur l\'ensemble du thème (navbar, boutons, badges)' },
{ type: 'feat', text: 'Page changelog accessible depuis le footer' },
{ type: 'fix', text: 'Correction des filtres de tri et de recherche cassés sur les catalogues composants, pièces et produits' },
{ type: 'fix', text: 'Correction du filtre par rattachement (site, machine, composant, pièce) sur la page documents' },
{ type: 'fix', text: 'Correction de l\'affichage des champs personnalisés sur les pages d\'édition (condition de concurrence)' },
{ type: 'fix', text: 'Plafonnement de la pagination à 200 éléments par page pour éviter les erreurs mémoire en production' },
{ type: 'perf', text: 'Cache intelligent sur les composables usePieces et useComposants : les données déjà chargées ne sont plus re-téléchargées inutilement' },
{ type: 'perf', text: 'Réduction des appels API bloquants sur les pages d\'édition' },
],
},
{
version: 'v1.4.0',
date: '2026-02-04',
changes: [
{ type: 'perf', text: 'Optimisation de la sérialisation API : ajout de groupes dédiés pour CustomFieldValue et CustomField, réduisant significativement la taille des réponses' },
{ type: 'perf', text: 'Pages d\'édition machines/composants/pièces : chargement parallèle des données au lieu de séquentiel' },
],
},
{
version: 'v1.3.0',
date: '2026-01-28',
changes: [
{ type: 'feat', text: 'Refactoring complet du frontend : découpage des méga-composants en modules réutilisables (7 chantiers F1-F7)' },
{ type: 'feat', text: 'Page détail machine découpée de 2989 à 219 lignes avec 2 composables et 7 sous-composants' },
{ type: 'feat', text: 'Page création machine découpée de 1231 à 196 lignes avec 1 composable et 5 sous-composants' },
{ type: 'feat', text: 'Extraction de 4 modules utilitaires partagés (champs personnalisés, affichage produits, documents, fournisseurs)' },
{ type: 'feat', text: 'Fusion des composables dupliqués : 3 composables d\'historique et 3 composables de types fusionnés en versions génériques' },
{ type: 'feat', text: 'Remplacement de confirm() natif par une modale DaisyUI personnalisée sur l\'ensemble de l\'application' },
{ type: 'feat', text: 'Extraction de la navbar dans un composant AppNavbar dédié' },
{ type: 'feat', text: 'Suite de 54 tests unitaires avec Vitest couvrant les utilitaires et composables' },
{ type: 'perf', text: 'Optimisations API : helper extractCollection partagé, invalidation de cache ciblée' },
{ type: 'chore', text: 'Migration des composables JavaScript vers TypeScript strict' },
{ type: 'chore', text: 'Activation de règles ESLint strictes et suppression de 19 console.log de débogage' },
],
},
{
version: 'v1.2.0',
date: '2026-01-21',
changes: [
{ type: 'feat', text: 'Système de suivi d\'historique (audit) avec enregistrement automatique des modifications sur toutes les entités' },
{ type: 'feat', text: 'Interface dédiée à l\'historique sur les fiches produits, pièces et composants' },
{ type: 'feat', text: 'Modale d\'éléments liés sur les pages de gestion des catégories avec navigation directe vers la fiche d\'édition' },
{ type: 'feat', text: 'Possibilité d\'ajouter des champs personnalisés en mode restreint sur les catégories' },
],
},
{
version: 'v1.1.1',
date: '2026-01-14',
changes: [
{ type: 'feat', text: 'Compression automatique des fichiers PDF à l\'upload via qpdf, réduisant l\'espace de stockage' },
{ type: 'chore', text: 'Ajout de qpdf dans l\'image Docker pour le support de la compression PDF' },
],
},
{
version: 'v1.1.0',
date: '2026-01-07',
changes: [
{ type: 'fix', text: 'Recherche insensible à la casse sur l\'ensemble des filtres de toutes les entités (machines, composants, pièces, produits)' },
{ type: 'chore', text: 'Réinitialisation des migrations vers un schéma initial unique avec guide de déploiement' },
{ type: 'chore', text: 'Mise à jour des fixtures avec les données courantes de la base' },
],
},
{
version: 'v1.0.0',
date: '2025-12-15',
changes: [
{ type: 'feat', text: 'Gestion complète des machines : création, édition, vue détaillée avec liaisons composants et pièces' },
{ type: 'feat', text: 'Catalogues composants, pièces et produits avec recherche serveur, tri et pagination' },
{ type: 'feat', text: 'Système de catégories (types) avec squelettes de champs personnalisés et drag & drop pour réordonner' },
{ type: 'feat', text: 'Upload de documents avec prévisualisation PDF et images, miniatures dans les tableaux' },
{ type: 'feat', text: 'Gestion des fournisseurs multiples avec résolution automatique des noms' },
{ type: 'feat', text: 'Exigences produit sur les pièces : support de liaisons multiples' },
{ type: 'feat', text: 'Sélections de composants sur les pièces avec recherche dynamique' },
{ type: 'feat', text: 'Système de sessions utilisateurs avec authentification JWT' },
{ type: 'feat', text: 'Mémorisation des préférences de tri par catalogue (cookies)' },
{ type: 'feat', text: 'Formatage automatique des contacts et des montants en format français' },
{ type: 'feat', text: 'Protection contre les suppressions : affichage des dépendances bloquantes avant confirmation' },
{ type: 'chore', text: 'Infrastructure Docker complète avec PostgreSQL, PHP 8.4, API Platform et pgAdmin' },
],
},
]
</script>

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">
@@ -1136,9 +1136,9 @@ onMounted(async () => {
// Defer bulk catalog loads — not needed for initial render
Promise.allSettled([
loadPieces({ itemsPerPage: 500 }),
loadProducts({ itemsPerPage: 500 }),
loadComposants({ itemsPerPage: 500 }),
loadPieces({ itemsPerPage: 200 }),
loadProducts({ itemsPerPage: 200 }),
loadComposants({ itemsPerPage: 200 }),
]).catch(() => {})
})
</script>

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;
}