Merge branch 'fix/filtres-listes' into develop
This commit is contained in:
@@ -92,11 +92,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useHead, useRouter } from "#imports";
|
||||||
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
|
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
|
||||||
import ModelTypesTable from "~/components/model-types/Table.vue";
|
import ModelTypesTable from "~/components/model-types/Table.vue";
|
||||||
import { useApi } from "~/composables/useApi";
|
import { useApi } from "~/composables/useApi";
|
||||||
|
import { useUrlState } from "~/composables/useUrlState";
|
||||||
import { extractCollection } from "~/shared/utils/apiHelpers";
|
import { extractCollection } from "~/shared/utils/apiHelpers";
|
||||||
import {
|
import {
|
||||||
deleteModelType,
|
deleteModelType,
|
||||||
@@ -125,11 +126,28 @@ const props = withDefaults(
|
|||||||
|
|
||||||
const selectedCategory = ref<ModelCategory>(props.category);
|
const selectedCategory = ref<ModelCategory>(props.category);
|
||||||
const searchInput = ref("");
|
const searchInput = ref("");
|
||||||
const searchTerm = ref("");
|
|
||||||
const sort = ref<"name" | "createdAt">("name");
|
// State synced with URL query params (preserved on back/forward navigation)
|
||||||
const dir = ref<"asc" | "desc">("asc");
|
const urlState = useUrlState({
|
||||||
const limit = ref(20);
|
q: { default: '' },
|
||||||
const offset = ref(0);
|
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 items = ref<ModelType[]>([]);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
|
|||||||
@@ -49,11 +49,12 @@ export function useDocuments() {
|
|||||||
|
|
||||||
const loadFromEndpoint = async (
|
const loadFromEndpoint = async (
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
{ updateStore = false }: { updateStore?: boolean } = {},
|
{ updateStore = false, itemsPerPage }: { updateStore?: boolean; itemsPerPage?: number } = {},
|
||||||
): Promise<DocumentResult> => {
|
): Promise<DocumentResult> => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await get(endpoint)
|
const url = itemsPerPage ? `${endpoint}${endpoint.includes('?') ? '&' : '?'}itemsPerPage=${itemsPerPage}` : endpoint
|
||||||
|
const result = await get(url)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const data = extractCollection(result.data)
|
const data = extractCollection(result.data)
|
||||||
if (updateStore) {
|
if (updateStore) {
|
||||||
@@ -76,9 +77,9 @@ export function useDocuments() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadDocuments = async (
|
const loadDocuments = async (
|
||||||
options: { updateStore?: boolean } = {},
|
options: { updateStore?: boolean; itemsPerPage?: number } = {},
|
||||||
): Promise<DocumentResult> => {
|
): Promise<DocumentResult> => {
|
||||||
return loadFromEndpoint('/documents', { updateStore: options.updateStore ?? true })
|
return loadFromEndpoint('/documents', { updateStore: options.updateStore ?? true, itemsPerPage: options.itemsPerPage })
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadDocumentsBySite = async (
|
const loadDocumentsBySite = async (
|
||||||
|
|||||||
116
app/composables/useUrlState.ts
Normal file
116
app/composables/useUrlState.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -176,11 +176,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { useComposants } from '~/composables/useComposants'
|
import { useComposants } from '~/composables/useComposants'
|
||||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { usePersistedSort } from '~/composables/usePersistedSort'
|
import { useUrlState } from '~/composables/useUrlState'
|
||||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||||
import Pagination from '~/components/common/Pagination.vue'
|
import Pagination from '~/components/common/Pagination.vue'
|
||||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||||
@@ -190,15 +190,28 @@ const { composants, total, loadComposants, loading: loadingComposantsRef, delete
|
|||||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||||
const loadingComposants = computed(() => loadingComposantsRef.value)
|
const loadingComposants = computed(() => loadingComposantsRef.value)
|
||||||
|
|
||||||
// Pagination state
|
// State synced with URL query params (preserved on back/forward navigation)
|
||||||
const currentPage = ref(1)
|
const {
|
||||||
const itemsPerPage = ref(30)
|
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 composantsTotal = computed(() => total.value)
|
||||||
const composantsOnPage = computed(() => composants.value.length)
|
const composantsOnPage = computed(() => composants.value.length)
|
||||||
const totalPages = computed(() => Math.ceil(composantsTotal.value / itemsPerPage.value) || 1)
|
const totalPages = computed(() => Math.ceil(composantsTotal.value / itemsPerPage.value) || 1)
|
||||||
|
|
||||||
// Search state with debounce
|
// Search debounce for API calls
|
||||||
const searchTerm = ref('')
|
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
const debouncedSearch = () => {
|
const debouncedSearch = () => {
|
||||||
@@ -211,12 +224,6 @@ const debouncedSearch = () => {
|
|||||||
}, 300)
|
}, 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
|
// Enrichir les composants avec les types de composants complets
|
||||||
const composantsList = computed(() => {
|
const composantsList = computed(() => {
|
||||||
return (composants.value || []).map((composant) => {
|
return (composants.value || []).map((composant) => {
|
||||||
@@ -234,7 +241,8 @@ const fetchComposants = async () => {
|
|||||||
page: currentPage.value,
|
page: currentPage.value,
|
||||||
itemsPerPage: itemsPerPage.value,
|
itemsPerPage: itemsPerPage.value,
|
||||||
orderBy: sortField.value,
|
orderBy: sortField.value,
|
||||||
orderDir: sortDirection.value
|
orderDir: sortDirection.value as 'asc' | 'desc',
|
||||||
|
force: true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink class="btn btn-ghost" to="/component-category">
|
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
Configurez le squelette canonique qui sera appliqué lors de la création des composants appartenant à cette catégorie.
|
Configurez le squelette canonique qui sera appliqué lors de la création des composants appartenant à cette catégorie.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink class="btn btn-ghost" to="/component-category">
|
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
<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.
|
Mettez à jour les informations du composant et ses champs personnalisés.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
Sélectionnez la catégorie cible puis complétez les informations du composant.
|
Sélectionnez la catégorie cible puis complétez les informations du composant.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||||
|
|||||||
@@ -132,6 +132,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
|
import { useUrlState } from '~/composables/useUrlState'
|
||||||
import { getFileIcon } from '~/utils/fileIcons'
|
import { getFileIcon } from '~/utils/fileIcons'
|
||||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||||
import { formatFrenchDate } from '~/utils/date'
|
import { formatFrenchDate } from '~/utils/date'
|
||||||
@@ -140,13 +141,15 @@ import IconLucideFileSearch from '~icons/lucide/file-search'
|
|||||||
|
|
||||||
const { documents, loading, loadDocuments } = useDocuments()
|
const { documents, loading, loadDocuments } = useDocuments()
|
||||||
|
|
||||||
const searchTerm = ref('')
|
const { q: searchTerm, filter: attachmentFilter } = useUrlState({
|
||||||
const attachmentFilter = ref('all')
|
q: { default: '', debounce: 300 },
|
||||||
|
filter: { default: 'all' },
|
||||||
|
})
|
||||||
const previewDocument = ref(null)
|
const previewDocument = ref(null)
|
||||||
const previewVisible = ref(false)
|
const previewVisible = ref(false)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadDocuments()
|
loadDocuments({ itemsPerPage: 200 })
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredDocuments = computed(() => {
|
const filteredDocuments = computed(() => {
|
||||||
@@ -156,10 +159,10 @@ const filteredDocuments = computed(() => {
|
|||||||
return documents.value.filter((document) => {
|
return documents.value.filter((document) => {
|
||||||
const matchesFilter =
|
const matchesFilter =
|
||||||
filter === 'all' ||
|
filter === 'all' ||
|
||||||
(filter === 'site' && document.siteId) ||
|
(filter === 'site' && document.site) ||
|
||||||
(filter === 'machine' && document.machineId) ||
|
(filter === 'machine' && document.machine) ||
|
||||||
(filter === 'composant' && document.composantId) ||
|
(filter === 'composant' && document.composant) ||
|
||||||
(filter === 'piece' && document.pieceId)
|
(filter === 'piece' && document.piece)
|
||||||
|
|
||||||
if (!matchesFilter) { return false }
|
if (!matchesFilter) { return false }
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
Mettez à jour la structure et les champs personnalisés de cette catégorie de pièce pour préparer les futures créations.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink class="btn btn-ghost" to="/piece-category">
|
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
Définissez les champs personnalisés et le squelette appliqué lors de la création des pièces de cette catégorie.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink class="btn btn-ghost" to="/piece-category">
|
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -198,11 +198,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { usePieces } from '~/composables/usePieces'
|
import { usePieces } from '~/composables/usePieces'
|
||||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { usePersistedSort } from '~/composables/usePersistedSort'
|
import { useUrlState } from '~/composables/useUrlState'
|
||||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||||
import Pagination from '~/components/common/Pagination.vue'
|
import Pagination from '~/components/common/Pagination.vue'
|
||||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||||
@@ -212,15 +212,28 @@ const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = us
|
|||||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||||
const loadingPieces = computed(() => loadingPiecesRef.value)
|
const loadingPieces = computed(() => loadingPiecesRef.value)
|
||||||
|
|
||||||
// Pagination state
|
// State synced with URL query params (preserved on back/forward navigation)
|
||||||
const currentPage = ref(1)
|
const {
|
||||||
const itemsPerPage = ref(30)
|
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 piecesTotal = computed(() => total.value)
|
||||||
const piecesOnPage = computed(() => pieces.value.length)
|
const piecesOnPage = computed(() => pieces.value.length)
|
||||||
const totalPages = computed(() => Math.ceil(piecesTotal.value / itemsPerPage.value) || 1)
|
const totalPages = computed(() => Math.ceil(piecesTotal.value / itemsPerPage.value) || 1)
|
||||||
|
|
||||||
// Search state with debounce
|
// Search debounce for API calls
|
||||||
const searchTerm = ref('')
|
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
const debouncedSearch = () => {
|
const debouncedSearch = () => {
|
||||||
@@ -233,12 +246,6 @@ const debouncedSearch = () => {
|
|||||||
}, 300)
|
}, 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
|
// Enrichir les pièces avec les types de pièces complets
|
||||||
const piecesList = computed(() => {
|
const piecesList = computed(() => {
|
||||||
return (pieces.value || []).map((piece) => {
|
return (pieces.value || []).map((piece) => {
|
||||||
@@ -256,7 +263,8 @@ const fetchPieces = async () => {
|
|||||||
page: currentPage.value,
|
page: currentPage.value,
|
||||||
itemsPerPage: itemsPerPage.value,
|
itemsPerPage: itemsPerPage.value,
|
||||||
orderBy: sortField.value,
|
orderBy: sortField.value,
|
||||||
orderDir: sortDirection.value
|
orderDir: sortDirection.value as 'asc' | 'desc',
|
||||||
|
force: true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
<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.
|
Ajustez les informations de la pièce et ses champs personnalisés.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
Choisissez la catégorie adaptée puis renseignez toutes les informations de votre pièce.
|
Choisissez la catégorie adaptée puis renseignez toutes les informations de votre pièce.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||||
|
|||||||
@@ -170,12 +170,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { useHead } from '#imports'
|
import { useHead } from '#imports'
|
||||||
import { useProducts } from '~/composables/useProducts'
|
import { useProducts } from '~/composables/useProducts'
|
||||||
import { useProductTypes } from '~/composables/useProductTypes'
|
import { useProductTypes } from '~/composables/useProductTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { usePersistedSort } from '~/composables/usePersistedSort'
|
import { useUrlState } from '~/composables/useUrlState'
|
||||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||||
|
|
||||||
@@ -195,11 +195,11 @@ const {
|
|||||||
const { productTypes, loadProductTypes } = useProductTypes()
|
const { productTypes, loadProductTypes } = useProductTypes()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
const searchTerm = ref('')
|
const { q: searchTerm, sort: sortField, dir: sortDirection } = useUrlState({
|
||||||
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
|
q: { default: '', debounce: 300 },
|
||||||
'product-catalog',
|
sort: { default: 'name' },
|
||||||
{ field: 'name', direction: 'asc' },
|
dir: { default: 'asc' },
|
||||||
)
|
})
|
||||||
|
|
||||||
// Enrichir les produits avec les types de produits complets
|
// Enrichir les produits avec les types de produits complets
|
||||||
const normalizedProducts = computed(() => {
|
const normalizedProducts = computed(() => {
|
||||||
@@ -388,7 +388,7 @@ const resolvePreviewAlt = (product: Record<string, any>) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reload = async () => {
|
const reload = async () => {
|
||||||
await loadProducts({ force: true })
|
await loadProducts({ itemsPerPage: 200, force: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { confirm } = useConfirm()
|
const { confirm } = useConfirm()
|
||||||
@@ -409,7 +409,7 @@ const confirmDelete = async (product: Record<string, any>) => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadProducts(),
|
loadProducts({ itemsPerPage: 200, force: true }),
|
||||||
loadProductTypes()
|
loadProductTypes()
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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.
|
Mettez à jour la structure et les champs personnalisés de cette catégorie de produit pour préparer les futures créations.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink class="btn btn-ghost" to="/product-category">
|
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
Définissez les champs personnalisés et le squelette appliqué lors de la création des produits de cette catégorie.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink class="btn btn-ghost" to="/product-category">
|
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-4xl mx-auto">
|
<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.
|
Mettez à jour les informations du produit et ses champs personnalisés.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
Sélectionnez la catégorie cible puis renseignez toutes les informations de votre produit catalogue.
|
Sélectionnez la catégorie cible puis renseignez toutes les informations de votre produit catalogue.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||||
|
|||||||
@@ -132,28 +132,19 @@ export async function listModelTypes(params: ModelTypeListParams = {}, opts: { s
|
|||||||
if (params.category) {
|
if (params.category) {
|
||||||
query.category = 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) {
|
// Sort: API Platform OrderFilter uses order[field]=direction
|
||||||
// Fetch enough items to allow client-side category filtering + pagination.
|
const sortField = params.sort || 'name';
|
||||||
query.itemsPerPage = Math.max(effectiveLimit ?? 200, 200);
|
const sortDir = params.dir || 'asc';
|
||||||
query.offset = 0;
|
query[`order[${sortField}]`] = sortDir;
|
||||||
} else {
|
|
||||||
if (typeof params.limit === 'number') {
|
// Pagination: API Platform uses page + itemsPerPage
|
||||||
query.itemsPerPage = params.limit;
|
const effectiveLimit = typeof params.limit === 'number' ? params.limit : 20;
|
||||||
}
|
const effectiveOffset = typeof params.offset === 'number' ? params.offset : 0;
|
||||||
if (typeof params.offset === 'number') {
|
const page = Math.floor(effectiveOffset / effectiveLimit) + 1;
|
||||||
query.offset = params.offset;
|
|
||||||
}
|
query.itemsPerPage = effectiveLimit;
|
||||||
}
|
query.page = page;
|
||||||
|
|
||||||
const payload = await requestFetch<Record<string, any>>(ENDPOINT, createOptions({
|
const payload = await requestFetch<Record<string, any>>(ENDPOINT, createOptions({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -168,25 +159,20 @@ export async function listModelTypes(params: ModelTypeListParams = {}, opts: { s
|
|||||||
: Array.isArray(payload?.items)
|
: Array.isArray(payload?.items)
|
||||||
? payload.items
|
? payload.items
|
||||||
: [];
|
: [];
|
||||||
const filteredItems = params.category
|
|
||||||
? rawItems.filter((item: any) => item?.category === params.category)
|
const total = typeof payload?.totalItems === 'number'
|
||||||
: rawItems;
|
? payload.totalItems
|
||||||
const total = params.category
|
: typeof payload?.['hydra:totalItems'] === 'number'
|
||||||
? filteredItems.length
|
? payload['hydra:totalItems']
|
||||||
: typeof payload?.totalItems === 'number'
|
: rawItems.length;
|
||||||
? payload.totalItems
|
|
||||||
: Array.isArray(payload?.items)
|
const items = rawItems.map(normalizeModelType);
|
||||||
? payload.items.length
|
|
||||||
: rawItems.length;
|
|
||||||
const items = (params.category && typeof effectiveLimit === 'number'
|
|
||||||
? filteredItems.slice(effectiveOffset, effectiveOffset + effectiveLimit)
|
|
||||||
: filteredItems).map(normalizeModelType);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items,
|
items,
|
||||||
total,
|
total,
|
||||||
offset: effectiveOffset,
|
offset: effectiveOffset,
|
||||||
limit: typeof effectiveLimit === 'number' ? effectiveLimit : items.length,
|
limit: effectiveLimit,
|
||||||
} satisfies ModelTypeListResponse;
|
} satisfies ModelTypeListResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user