From e99f0532332f1d33915885bc05a48f1809384297 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 11 Jan 2026 17:14:24 +0100 Subject: [PATCH 01/16] feat(front): aligner api platform et sessions [INV-20260111-02] --- app/app.vue | 2 +- app/composables/useApi.js | 3 +- app/composables/useComposants.js | 63 ++++++++++++++++++++------- app/composables/useConstructeurs.js | 18 +++++++- app/composables/useDocuments.js | 23 ++++++++-- app/composables/useMachineTypesApi.js | 58 +++++++++++++++++++----- app/composables/useMachines.js | 37 ++++++++++++---- app/composables/usePieces.js | 37 ++++++++++++++-- app/composables/useProducts.js | 35 ++++++++++++--- app/composables/useProfileSession.js | 5 ++- app/composables/useProfiles.js | 6 +-- app/composables/useSites.js | 15 ++++++- app/services/modelTypes.ts | 28 ++++++++++-- app/shared/apiRelations.ts | 57 ++++++++++++++++++++++++ app/shared/constructeurUtils.ts | 18 ++++++-- nuxt.config.ts | 5 ++- package-lock.json | 3 +- 17 files changed, 346 insertions(+), 67 deletions(-) create mode 100644 app/shared/apiRelations.ts diff --git a/app/app.vue b/app/app.vue index 329f5ac..e0dea99 100644 --- a/app/app.vue +++ b/app/app.vue @@ -30,7 +30,7 @@ : 'text-base-content hover:bg-primary/10 hover:text-primary' " > - Vue d'ensemble + Vue d'ensemblee
  • diff --git a/app/composables/useApi.js b/app/composables/useApi.js index b2945e7..b1a4233 100644 --- a/app/composables/useApi.js +++ b/app/composables/useApi.js @@ -10,6 +10,7 @@ export function useApi () { const apiCall = async (endpoint, options = {}) => { const url = `${API_BASE_URL}${endpoint}` const defaultOptions = { + credentials: 'include', headers: { 'Content-Type': 'application/json' } @@ -32,7 +33,7 @@ export function useApi () { let data = null if (response.status !== 204) { const contentType = response.headers.get('content-type') || '' - if (contentType.includes('application/json')) { + if (contentType.includes('application/json') || contentType.includes('application/ld+json') || contentType.includes('+json')) { const text = await response.text() data = text ? JSON.parse(text) : null } else { diff --git a/app/composables/useComposants.js b/app/composables/useComposants.js index 5599ede..5bdf629 100644 --- a/app/composables/useComposants.js +++ b/app/composables/useComposants.js @@ -3,10 +3,27 @@ import { useToast } from './useToast' import { useApi } from './useApi' import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils' import { useConstructeurs } from './useConstructeurs' +import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations' const composants = ref([]) const loading = ref(false) +const extractCollection = (payload) => { + if (Array.isArray(payload)) { + return payload + } + if (Array.isArray(payload?.member)) { + return payload.member + } + if (Array.isArray(payload?.['hydra:member'])) { + return payload['hydra:member'] + } + if (Array.isArray(payload?.data)) { + return payload.data + } + return [] +} + export function useComposants () { const { showSuccess, showError, showInfo } = useToast() const { get, post, patch, delete: del } = useApi() @@ -16,6 +33,18 @@ export function useComposants () { if (!composant || typeof composant !== 'object') { return composant } + if (!composant.typeComposantId) { + const typeComposantId = extractRelationId(composant.typeComposant) + if (typeComposantId) { + composant.typeComposantId = typeComposantId + } + } + if (!composant.productId) { + const productId = extractRelationId(composant.product) + if (productId) { + composant.productId = productId + } + } const ids = uniqueConstructeurIds( composant.constructeurIds, composant.constructeurs, @@ -34,27 +63,28 @@ export function useComposants () { return composant } -const loadComposants = async () => { - loading.value = true - try { - const result = await get('/composants') - if (result.success) { - const items = Array.isArray(result.data) ? result.data : [] - const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item))) - composants.value = enrichedItems - showInfo(`Chargement de ${composants.value.length} composant(s) réussi`) + const loadComposants = async () => { + loading.value = true + try { + const result = await get('/composants') + if (result.success) { + const items = extractCollection(result.data) + const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item))) + composants.value = enrichedItems + showInfo(`Chargement de ${composants.value.length} composant(s) réussi`) + } + } catch (error) { + console.error('Erreur lors du chargement des composants:', error) + } finally { + loading.value = false } - } catch (error) { - console.error('Erreur lors du chargement des composants:', error) - } finally { - loading.value = false } -} const createComposant = async (composantData) => { loading.value = true try { - const result = await post('/composants', buildConstructeurRequestPayload(composantData)) + const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(composantData)) + const result = await post('/composants', normalizedPayload) if (result.success) { const enriched = await withResolvedConstructeurs(result.data) composants.value.push(enriched) @@ -76,7 +106,8 @@ const loadComposants = async () => { const updateComposantData = async (id, composantData) => { loading.value = true try { - const result = await patch(`/composants/${id}`, buildConstructeurRequestPayload(composantData)) + const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(composantData)) + const result = await patch(`/composants/${id}`, normalizedPayload) if (result.success) { const updated = await withResolvedConstructeurs(result.data) const index = composants.value.findIndex(comp => comp.id === id) diff --git a/app/composables/useConstructeurs.js b/app/composables/useConstructeurs.js index 025e5ed..67a2996 100644 --- a/app/composables/useConstructeurs.js +++ b/app/composables/useConstructeurs.js @@ -39,6 +39,22 @@ const upsertConstructeurs = (items = []) => { const getIndexedConstructeur = (id) => constructeurs.value.find((item) => item && item.id === id) || null +const extractCollection = (payload) => { + if (Array.isArray(payload)) { + return payload + } + if (Array.isArray(payload?.member)) { + return payload.member + } + if (Array.isArray(payload?.['hydra:member'])) { + return payload['hydra:member'] + } + if (Array.isArray(payload?.data)) { + return payload.data + } + return [] +} + const pendingFetches = new Map() export function useConstructeurs () { @@ -51,7 +67,7 @@ export function useConstructeurs () { const query = search ? `?search=${encodeURIComponent(search)}` : '' const result = await get(`/constructeurs${query}`) if (result.success) { - const items = Array.isArray(result.data) ? result.data : [] + const items = extractCollection(result.data) constructeurs.value = uniqueConstructeurs(items) } return result diff --git a/app/composables/useDocuments.js b/app/composables/useDocuments.js index 5b89cd6..8177fe2 100644 --- a/app/composables/useDocuments.js +++ b/app/composables/useDocuments.js @@ -1,10 +1,27 @@ import { ref } from 'vue' import { useApi } from './useApi' import { useToast } from './useToast' +import { normalizeRelationIds } from '~/shared/apiRelations' const documents = ref([]) const loading = ref(false) +const extractCollection = (payload) => { + if (Array.isArray(payload)) { + return payload + } + if (Array.isArray(payload?.member)) { + return payload.member + } + if (Array.isArray(payload?.['hydra:member'])) { + return payload['hydra:member'] + } + if (Array.isArray(payload?.data)) { + return payload.data + } + return [] +} + const fileToBase64 = file => new Promise((resolve, reject) => { const reader = new FileReader() @@ -22,7 +39,7 @@ export function useDocuments () { try { const result = await get(endpoint) if (result.success) { - const data = result.data || [] + const data = extractCollection(result.data) if (updateStore) { documents.value = data } @@ -80,14 +97,14 @@ export function useDocuments () { for (const file of files) { const dataUrl = await fileToBase64(file) - const payload = { + const payload = normalizeRelationIds({ name: file.name, filename: file.name, mimeType: file.type || 'application/octet-stream', size: file.size, path: dataUrl, ...context - } + }) const result = await post('/documents', payload) if (result.success) { diff --git a/app/composables/useMachineTypesApi.js b/app/composables/useMachineTypesApi.js index 2f38673..83f47fa 100644 --- a/app/composables/useMachineTypesApi.js +++ b/app/composables/useMachineTypesApi.js @@ -1,11 +1,30 @@ import { ref } from 'vue' import { useToast } from './useToast' import { useApi } from './useApi' +import { extractRelationId } from '~/shared/apiRelations' const machineTypes = ref([]) const loading = ref(false) -const normalizeRequirementList = (value) => (Array.isArray(value) ? value : []) +const normalizeRequirementList = (value, relationKey) => { + if (!Array.isArray(value)) { + return [] + } + return value.map((entry) => { + if (!entry || typeof entry !== 'object') { + return entry + } + const normalized = { ...entry } + if (relationKey && !normalized[relationKey]) { + const relationValue = normalized[relationKey.replace('Id', '')] + const relationId = extractRelationId(relationValue) + if (relationId) { + normalized[relationKey] = relationId + } + } + return normalized + }) +} const normalizeMachineType = (type) => { if (!type || typeof type !== 'object') { @@ -13,12 +32,28 @@ const normalizeMachineType = (type) => { } return { ...type, - componentRequirements: normalizeRequirementList(type.componentRequirements), - pieceRequirements: normalizeRequirementList(type.pieceRequirements), - productRequirements: normalizeRequirementList(type.productRequirements), + componentRequirements: normalizeRequirementList(type.componentRequirements, 'typeComposantId'), + pieceRequirements: normalizeRequirementList(type.pieceRequirements, 'typePieceId'), + productRequirements: normalizeRequirementList(type.productRequirements, 'typeProductId'), } } +const extractCollection = (payload) => { + if (Array.isArray(payload)) { + return payload + } + if (Array.isArray(payload?.member)) { + return payload.member + } + if (Array.isArray(payload?.['hydra:member'])) { + return payload['hydra:member'] + } + if (Array.isArray(payload?.data)) { + return payload.data + } + return [] +} + export function useMachineTypesApi () { const { showSuccess, showError, showInfo } = useToast() const { get, post, patch, delete: del } = useApi() @@ -26,11 +61,10 @@ export function useMachineTypesApi () { const loadMachineTypes = async () => { loading.value = true try { - const result = await get('/types/machines') + const result = await get('/type_machines') if (result.success) { - machineTypes.value = Array.isArray(result.data) - ? result.data.map(normalizeMachineType) - : [] + const items = extractCollection(result.data) + machineTypes.value = items.map(normalizeMachineType) showInfo(`Chargement de ${machineTypes.value.length} type(s) de machine réussi`) } } catch (error) { @@ -43,7 +77,7 @@ export function useMachineTypesApi () { const createMachineType = async (typeData) => { loading.value = true try { - const result = await post('/types/machines', typeData) + const result = await post('/type_machines', typeData) if (result.success) { machineTypes.value.push(normalizeMachineType(result.data)) showSuccess(`Type de machine "${typeData.name}" créé avec succès`) @@ -60,7 +94,7 @@ export function useMachineTypesApi () { const updateMachineType = async (id, typeData) => { loading.value = true try { - const result = await patch(`/types/machines/${id}`, typeData) + const result = await patch(`/type_machines/${id}`, typeData) if (result.success) { const normalized = normalizeMachineType(result.data) const index = machineTypes.value.findIndex(type => type.id === id) @@ -81,7 +115,7 @@ export function useMachineTypesApi () { const deleteMachineType = async (id) => { loading.value = true try { - const result = await del(`/types/machines/${id}`) + const result = await del(`/type_machines/${id}`) if (result.success) { const deletedType = machineTypes.value.find(type => type.id === id) machineTypes.value = machineTypes.value.filter(type => type.id !== id) @@ -105,7 +139,7 @@ export function useMachineTypesApi () { // Si pas trouvé localement, récupérer depuis l'API try { - const result = await get(`/types/machines/${id}`) + const result = await get(`/type_machines/${id}`) if (result.success) { // Ajouter au cache local machineTypes.value.push(normalizeMachineType(result.data)) diff --git a/app/composables/useMachines.js b/app/composables/useMachines.js index 9e4a0c6..35141df 100644 --- a/app/composables/useMachines.js +++ b/app/composables/useMachines.js @@ -2,6 +2,7 @@ import { ref } from 'vue' import { useToast } from './useToast' import { useApi } from './useApi' import { buildConstructeurRequestPayload } from '~/shared/constructeurUtils' +import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations' const machines = ref([]) const loading = ref(false) @@ -32,6 +33,20 @@ const normalizeMachineResponse = (payload) => { const normalized = { ...container } + if (!normalized.siteId) { + const siteId = extractRelationId(container.site) + if (siteId) { + normalized.siteId = siteId + } + } + + if (!normalized.typeMachineId) { + const typeMachineId = extractRelationId(container.typeMachine) + if (typeMachineId) { + normalized.typeMachineId = typeMachineId + } + } + const componentLinks = resolveLinkCollection(payload, ['componentLinks', 'machineComponentLinks']) ?? resolveLinkCollection(container, ['componentLinks', 'machineComponentLinks']) ?? [] @@ -56,11 +71,15 @@ export function useMachines () { if (result.success) { const machineList = Array.isArray(result.data) ? result.data - : Array.isArray(result.data?.machines) - ? result.data.machines - : Array.isArray(result.data?.data) - ? result.data.data - : [] + : Array.isArray(result.data?.member) + ? result.data.member + : Array.isArray(result.data?.['hydra:member']) + ? result.data['hydra:member'] + : Array.isArray(result.data?.machines) + ? result.data.machines + : Array.isArray(result.data?.data) + ? result.data.data + : [] const normalized = machineList .map((item) => normalizeMachineResponse(item)) .filter(Boolean) @@ -77,7 +96,8 @@ export function useMachines () { const createMachine = async (machineData) => { loading.value = true try { - const result = await post('/machines', buildConstructeurRequestPayload(machineData)) + const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(machineData)) + const result = await post('/machines', normalizedPayload) if (result.success) { const createdMachine = normalizeMachineResponse(result.data) || normalizeMachineResponse(result.data?.machine) || @@ -106,13 +126,14 @@ export function useMachines () { // Les composants et pièces seront créés automatiquement } - return await createMachine(buildConstructeurRequestPayload(machineWithStructure)) + return await createMachine(machineWithStructure) } const updateMachineData = async (id, machineData) => { loading.value = true try { - const result = await patch(`/machines/${id}`, buildConstructeurRequestPayload(machineData)) + const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(machineData)) + const result = await patch(`/machines/${id}`, normalizedPayload) if (result.success) { const updatedMachine = normalizeMachineResponse(result.data) || normalizeMachineResponse(result.data?.machine) || diff --git a/app/composables/usePieces.js b/app/composables/usePieces.js index aa0112e..b27964e 100644 --- a/app/composables/usePieces.js +++ b/app/composables/usePieces.js @@ -3,10 +3,27 @@ import { useToast } from './useToast' import { useApi } from './useApi' import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils' import { useConstructeurs } from './useConstructeurs' +import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations' const pieces = ref([]) const loading = ref(false) +const extractCollection = (payload) => { + if (Array.isArray(payload)) { + return payload + } + if (Array.isArray(payload?.member)) { + return payload.member + } + if (Array.isArray(payload?.['hydra:member'])) { + return payload['hydra:member'] + } + if (Array.isArray(payload?.data)) { + return payload.data + } + return [] +} + export function usePieces () { const { showSuccess, showError, showInfo } = useToast() const { get, post, patch, delete: del } = useApi() @@ -16,6 +33,18 @@ export function usePieces () { if (!piece || typeof piece !== 'object') { return piece } + if (!piece.typePieceId) { + const typePieceId = extractRelationId(piece.typePiece) + if (typePieceId) { + piece.typePieceId = typePieceId + } + } + if (!piece.productId) { + const productId = extractRelationId(piece.product) + if (productId) { + piece.productId = productId + } + } const ids = uniqueConstructeurIds( piece.constructeurIds, piece.constructeurs, @@ -39,7 +68,7 @@ export function usePieces () { try { const result = await get('/pieces') if (result.success) { - const items = Array.isArray(result.data) ? result.data : [] + const items = extractCollection(result.data) const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item))) pieces.value = enrichedItems showInfo(`Chargement de ${pieces.value.length} pièce(s) réussi`) @@ -54,7 +83,8 @@ export function usePieces () { const createPiece = async (pieceData) => { loading.value = true try { - const result = await post('/pieces', buildConstructeurRequestPayload(pieceData)) + const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(pieceData)) + const result = await post('/pieces', normalizedPayload) if (result.success) { const enriched = await withResolvedConstructeurs(result.data) pieces.value.push(enriched) @@ -76,7 +106,8 @@ export function usePieces () { const updatePieceData = async (id, pieceData) => { loading.value = true try { - const result = await patch(`/pieces/${id}`, buildConstructeurRequestPayload(pieceData)) + const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(pieceData)) + const result = await patch(`/pieces/${id}`, normalizedPayload) if (result.success) { const updated = await withResolvedConstructeurs(result.data) const index = pieces.value.findIndex(piece => piece.id === id) diff --git a/app/composables/useProducts.js b/app/composables/useProducts.js index 9a90ed7..9b8b204 100644 --- a/app/composables/useProducts.js +++ b/app/composables/useProducts.js @@ -3,6 +3,7 @@ import { useToast } from './useToast' import { useApi } from './useApi' import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils' import { useConstructeurs } from './useConstructeurs' +import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations' const products = ref([]) const total = ref(0) @@ -25,6 +26,22 @@ const replaceInCache = (item) => { return false } +const extractCollection = (payload) => { + if (Array.isArray(payload)) { + return payload + } + if (Array.isArray(payload?.member)) { + return payload.member + } + if (Array.isArray(payload?.['hydra:member'])) { + return payload['hydra:member'] + } + if (Array.isArray(payload?.data)) { + return payload.data + } + return [] +} + export function useProducts () { const { showError } = useToast() const { get, post, patch, delete: del } = useApi() @@ -34,6 +51,12 @@ export function useProducts () { if (!product || typeof product !== 'object') { return product } + if (!product.typeProductId) { + const typeProductId = extractRelationId(product.typeProduct) + if (typeProductId) { + product.typeProductId = typeProductId + } + } const ids = uniqueConstructeurIds( product.constructeurIds, product.constructeurs, @@ -69,12 +92,14 @@ export function useProducts () { loading.value = true error.value = null try { - const result = await get('/products?limit=100') + const result = await get('/products?itemsPerPage=100') if (result.success) { - const items = Array.isArray(result.data?.items) ? result.data.items : [] + const items = extractCollection(result.data) const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item))) products.value = enrichedItems - total.value = typeof result.data?.total === 'number' ? result.data.total : items.length + total.value = typeof result.data?.totalItems === 'number' + ? result.data.totalItems + : items.length loaded.value = true } else if (result.error) { error.value = result.error @@ -93,7 +118,7 @@ export function useProducts () { } const createProduct = async (payload) => { - const normalizedPayload = buildConstructeurRequestPayload(payload) + const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(payload)) loading.value = true error.value = null try { @@ -121,7 +146,7 @@ export function useProducts () { } const updateProduct = async (id, payload) => { - const normalizedPayload = buildConstructeurRequestPayload(payload) + const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(payload)) loading.value = true error.value = null try { diff --git a/app/composables/useProfileSession.js b/app/composables/useProfileSession.js index 0a09fd1..83be2f2 100644 --- a/app/composables/useProfileSession.js +++ b/app/composables/useProfileSession.js @@ -2,7 +2,10 @@ import { useState, useRequestHeaders, useRuntimeConfig } from '#imports' const buildUrl = (path) => { const config = useRuntimeConfig() - const base = config.public.apiBaseUrl?.replace(/\/$/, '') || '' + const baseUrl = process.server + ? (config.apiBaseUrl || config.public.apiBaseUrl || '') + : (config.public.apiBaseUrl || '') + const base = baseUrl.replace(/\/$/, '') return `${base}${path}` } diff --git a/app/composables/useProfiles.js b/app/composables/useProfiles.js index a6e0e40..ad1c80d 100644 --- a/app/composables/useProfiles.js +++ b/app/composables/useProfiles.js @@ -20,7 +20,7 @@ export function useProfiles () { const fetchProfiles = async () => { loadingProfiles.value = true try { - profiles.value = await $fetch(buildUrl('/profiles'), { + profiles.value = await $fetch(buildUrl('/session/profiles'), { method: 'GET', credentials: 'include', headers: getSessionHeaders() @@ -37,7 +37,7 @@ export function useProfiles () { } const createProfile = async ({ firstName, lastName }) => { - const profile = await $fetch(buildUrl('/profiles'), { + const profile = await $fetch(buildUrl('/session/profiles'), { method: 'POST', credentials: 'include', body: { firstName, lastName }, @@ -48,7 +48,7 @@ export function useProfiles () { } const deleteProfile = async (profileId) => { - await $fetch(buildUrl(`/profiles/${profileId}`), { + await $fetch(buildUrl(`/session/profiles/${profileId}`), { method: 'DELETE', credentials: 'include', headers: getSessionHeaders() diff --git a/app/composables/useSites.js b/app/composables/useSites.js index 9d1eecd..1c76e51 100644 --- a/app/composables/useSites.js +++ b/app/composables/useSites.js @@ -13,9 +13,20 @@ export function useSites () { loading.value = true try { const result = await get('/sites') + console.log('sites api result', result) + if (result.success) { - sites.value = result.data - showInfo(`Chargement de ${sites.value.length} site(s) réussi`) + const collection = Array.isArray(result.data) + ? result.data + : Array.isArray(result.data?.member) + ? result.data.member + : Array.isArray(result.data?.['hydra:member']) + ? result.data['hydra:member'] + : Array.isArray(result.data?.data) + ? result.data.data + : [] + sites.value = collection + showInfo(`Chargement de ${collection.length} site(s) réussi`) } } catch (error) { console.error('Erreur lors du chargement des sites:', error) diff --git a/app/services/modelTypes.ts b/app/services/modelTypes.ts index 2c54e6f..18f46c2 100644 --- a/app/services/modelTypes.ts +++ b/app/services/modelTypes.ts @@ -65,7 +65,7 @@ export interface ModelTypeListResponse { limit: number; } -const ENDPOINT = '/api/model-types'; +const ENDPOINT = '/model_types'; function resolveBaseUrl() { const runtimeConfig = useRuntimeConfig(); @@ -80,7 +80,7 @@ function createOptions(options: FetchOptions = {}) { }; } -export function listModelTypes(params: ModelTypeListParams = {}, opts: { signal?: AbortSignal } = {}) { +export async function listModelTypes(params: ModelTypeListParams = {}, opts: { signal?: AbortSignal } = {}) { const requestFetch = useRequestFetch(); const query: Record = {}; @@ -97,17 +97,37 @@ export function listModelTypes(params: ModelTypeListParams = {}, opts: { signal? query.dir = params.dir; } if (typeof params.limit === 'number') { - query.limit = params.limit; + query.itemsPerPage = params.limit; } if (typeof params.offset === 'number') { query.offset = params.offset; } - return requestFetch(ENDPOINT, createOptions({ + const payload = await requestFetch>(ENDPOINT, createOptions({ method: 'GET', query, signal: opts.signal, })); + + const items = Array.isArray(payload?.member) + ? payload.member + : Array.isArray(payload?.['hydra:member']) + ? payload['hydra:member'] + : Array.isArray(payload?.items) + ? payload.items + : []; + const total = typeof payload?.totalItems === 'number' + ? payload.totalItems + : Array.isArray(payload?.items) + ? payload.items.length + : items.length; + + return { + items, + total, + offset: params.offset ?? 0, + limit: typeof params.limit === 'number' ? params.limit : items.length, + } satisfies ModelTypeListResponse; } export function createModelType(payload: ModelTypePayload, opts: { signal?: AbortSignal } = {}) { diff --git a/app/shared/apiRelations.ts b/app/shared/apiRelations.ts new file mode 100644 index 0000000..3a1b1ac --- /dev/null +++ b/app/shared/apiRelations.ts @@ -0,0 +1,57 @@ +export const RELATION_ID_MAP: Record = { + siteId: { key: 'site', path: 'sites' }, + machineId: { key: 'machine', path: 'machines' }, + composantId: { key: 'composant', path: 'composants' }, + pieceId: { key: 'piece', path: 'pieces' }, + productId: { key: 'product', path: 'products' }, + typeMachineId: { key: 'typeMachine', path: 'type_machines' }, + typeComposantId: { key: 'typeComposant', path: 'model_types' }, + typePieceId: { key: 'typePiece', path: 'model_types' }, + typeProductId: { key: 'typeProduct', path: 'model_types' }, +}; + +export const toIri = (path: string, id: string): string => `/api/${path}/${id}`; + +export const extractRelationId = (value: unknown): string | null => { + if (!value) { + return null; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + if (trimmed.includes('/')) { + const parts = trimmed.split('/').filter(Boolean); + return parts.length ? parts[parts.length - 1] : null; + } + return trimmed; + } + if (typeof value === 'object' && 'id' in (value as Record)) { + const id = (value as Record).id; + return typeof id === 'string' ? id : null; + } + return null; +}; + +export const normalizeRelationIds = >(payload: T): T => { + if (!payload || typeof payload !== 'object') { + return payload; + } + + const next: Record = { ...payload }; + Object.entries(RELATION_ID_MAP).forEach(([sourceKey, config]) => { + const raw = next[sourceKey]; + if (typeof raw !== 'string') { + return; + } + const trimmed = raw.trim(); + if (!trimmed) { + return; + } + next[config.key] = toIri(config.path, trimmed); + delete next[sourceKey]; + }); + + return next as T; +}; diff --git a/app/shared/constructeurUtils.ts b/app/shared/constructeurUtils.ts index 02649ed..31030d7 100644 --- a/app/shared/constructeurUtils.ts +++ b/app/shared/constructeurUtils.ts @@ -15,7 +15,14 @@ const toStringId = (value: unknown): string | null => { return null; } const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; + if (!trimmed) { + return null; + } + if (trimmed.includes('/')) { + const parts = trimmed.split('/').filter(Boolean); + return parts.length ? parts[parts.length - 1] : null; + } + return trimmed; }; export const uniqueConstructeurIds = (...sources: unknown[]): string[] => { @@ -107,7 +114,7 @@ export const formatConstructeurContact = ( export const buildConstructeurRequestPayload = >( payload: T, -): T & { constructeurIds: string[] } => { +): T & { constructeurs?: string[] } => { const ids = uniqueConstructeurIds( payload?.constructeurIds, payload?.constructeurId, @@ -116,10 +123,13 @@ export const buildConstructeurRequestPayload = >( ); const next = { ...payload } as Record; - next.constructeurIds = ids; + if (ids.length) { + next.constructeurs = ids.map((id) => `/api/constructeurs/${id}`); + } delete next.constructeurId; delete next.constructeur; delete next.constructeurs; + delete next.constructeurIds; - return next as T & { constructeurIds: string[] }; + return next as T & { constructeurs?: string[] }; }; diff --git a/nuxt.config.ts b/nuxt.config.ts index 6c85c85..964c43d 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -18,8 +18,11 @@ export default defineNuxtConfig({ ] ], runtimeConfig: { + apiBaseUrl: process.env.NUXT_API_BASE_URL + || process.env.NUXT_PUBLIC_API_BASE_URL + || 'http://localhost/api', public: { - apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', + apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || 'http://localhost:8081/api', appUrl: process.env.NUXT_PUBLIC_APP_URL || 'http://localhost:3001', appName: process.env.NUXT_PUBLIC_APP_NAME || 'Inventory Management System', appVersion: process.env.NUXT_PUBLIC_APP_VERSION || '0.1.0', diff --git a/package-lock.json b/package-lock.json index 78d6e80..fffcc3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3754,7 +3754,6 @@ "integrity": "sha512-UG8hdElzuBDzIbjG1QDwnYH0MQ73YLXDFHgZzB4Zh/YJfnw8XNsloVtytqzx0I2Qky9THSdpTmi8Vjn/pf/Lew==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.0", "@typescript-eslint/types": "^8.44.0", @@ -11673,7 +11672,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.2.tgz", "integrity": "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -12483,6 +12481,7 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", From b5af7f13b6f994032c6c6af9c7fb74d27c013a1d Mon Sep 17 00:00:00 2001 From: matthieu Date: Mon, 12 Jan 2026 13:03:41 +0100 Subject: [PATCH 02/16] wip(frontend) : api calls + skeleton fetch --- app/app.vue | 2 +- app/composables/useApi.js | 10 ++++++++++ app/pages/machine/[id].vue | 2 +- app/pages/type/[id].vue | 8 ++++++++ app/shared/constructeurUtils.ts | 4 +++- nuxt.config.ts | 1 + 6 files changed, 24 insertions(+), 3 deletions(-) diff --git a/app/app.vue b/app/app.vue index e0dea99..329f5ac 100644 --- a/app/app.vue +++ b/app/app.vue @@ -30,7 +30,7 @@ : 'text-base-content hover:bg-primary/10 hover:text-primary' " > - Vue d'ensemblee + Vue d'ensemble
  • diff --git a/app/composables/useApi.js b/app/composables/useApi.js index b1a4233..14d4ec2 100644 --- a/app/composables/useApi.js +++ b/app/composables/useApi.js @@ -24,6 +24,10 @@ export function useApi () { const response = await fetch(url, { ...defaultOptions, ...options, + headers: { + ...defaultOptions.headers, + ...options.headers + }, signal: controller.signal }) @@ -70,6 +74,9 @@ export function useApi () { const post = async (endpoint, data) => { return apiCall(endpoint, { method: 'POST', + headers: { + 'Content-Type': 'application/ld+json' + }, body: JSON.stringify(data) }) } @@ -77,6 +84,9 @@ export function useApi () { const patch = async (endpoint, data) => { return apiCall(endpoint, { method: 'PATCH', + headers: { + 'Content-Type': 'application/merge-patch+json' + }, body: JSON.stringify(data) }) } diff --git a/app/pages/machine/[id].vue b/app/pages/machine/[id].vue index f1e00dd..b177531 100644 --- a/app/pages/machine/[id].vue +++ b/app/pages/machine/[id].vue @@ -3921,7 +3921,7 @@ const applyMachineLinks = (source) => { const loadMachineData = async () => { loading.value = true try { - const machineResult = await get(`/machines/${machineId}`) + const machineResult = await get(`/machines/${machineId}/skeleton`) if (!machineResult.success) { console.error('Machine non trouvée:', machineId, machineResult.error) diff --git a/app/pages/type/[id].vue b/app/pages/type/[id].vue index 5566943..cdaf9b5 100644 --- a/app/pages/type/[id].vue +++ b/app/pages/type/[id].vue @@ -187,6 +187,14 @@ onMounted(async () => { const typeId = route.params.id console.log('=== TYPE DETAIL PAGE LOADING ===') console.log('Loading type with ID:', typeId) + console.log('Full route params:', route.params) + + if (!typeId) { + console.error('No type ID provided in route') + showError('Aucun identifiant de type fourni') + loading.value = false + return + } const result = await getMachineTypeById(typeId) console.log('API Result:', result) diff --git a/app/shared/constructeurUtils.ts b/app/shared/constructeurUtils.ts index 31030d7..0ebc386 100644 --- a/app/shared/constructeurUtils.ts +++ b/app/shared/constructeurUtils.ts @@ -60,7 +60,9 @@ export const uniqueConstructeurIds = (...sources: unknown[]): string[] => { if (value.constructeur) { explore(value.constructeur); } - if (typeof value.id === 'string') { + // Only extract ID if this looks like a constructeur object (has @type or recognizable fields) + // Don't extract ID from component/piece/product objects that happen to be passed in + if (typeof value.id === 'string' && !value.name && !value.typeComposant && !value.typePiece && !value.typeProduct) { pushId(value.id); } return; diff --git a/nuxt.config.ts b/nuxt.config.ts index 964c43d..33fcb7b 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,6 +1,7 @@ import tailwindcss from '@tailwindcss/vite' export default defineNuxtConfig({ compatibilityDate: '2025-07-15', + ssr: false, // Désactive le SSR pour un mode SPA pur (Client-Side Rendering uniquement) devtools: { enabled: true }, devServer: { port: 3001 From ddce3ff3ae58c63c791b6d5ebf5749edbbaa464b Mon Sep 17 00:00:00 2001 From: matthieu Date: Wed, 14 Jan 2026 23:10:27 +0100 Subject: [PATCH 03/16] =?UTF-8?q?feat(tri):=20m=C3=A9moriser=20les=20pr?= =?UTF-8?q?=C3=A9f=C3=A9rences=20de=20tri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/composables/usePersistedSort.ts | 53 ++++++++++++++++++++++++++++ app/composables/usePersistedValue.ts | 34 ++++++++++++++++++ app/pages/component-catalog.vue | 26 +++++++++++--- app/pages/constructeurs.vue | 3 +- app/pages/pieces-catalog.vue | 26 +++++++++++--- app/pages/product-catalog.vue | 25 ++++++++++--- 6 files changed, 154 insertions(+), 13 deletions(-) create mode 100644 app/composables/usePersistedSort.ts create mode 100644 app/composables/usePersistedValue.ts diff --git a/app/composables/usePersistedSort.ts b/app/composables/usePersistedSort.ts new file mode 100644 index 0000000..ea7c569 --- /dev/null +++ b/app/composables/usePersistedSort.ts @@ -0,0 +1,53 @@ +import { ref, watch } from 'vue' +import { useCookie } from '#imports' + +type SortCookie = { + field?: string + direction?: string +} + +const readSortCookie = (value: unknown): SortCookie | null => { + if (!value) { + return null + } + if (typeof value === 'object') { + return value as SortCookie + } + if (typeof value === 'string') { + try { + return JSON.parse(value) as SortCookie + } catch { + return null + } + } + return null +} + +export const usePersistedSort = < + TField extends string, + TDirection extends string, +>( + key: string, + defaults: { field: TField; direction: TDirection }, +) => { + const cookie = useCookie(`sort:${key}`, { + sameSite: 'lax', + }) + const stored = readSortCookie(cookie.value) + const sortField = ref((stored?.field as TField) || defaults.field) + const sortDirection = ref( + (stored?.direction as TDirection) || defaults.direction, + ) + + watch([sortField, sortDirection], () => { + cookie.value = JSON.stringify({ + field: sortField.value, + direction: sortDirection.value, + }) + }) + + return { + sortField, + sortDirection, + } +} diff --git a/app/composables/usePersistedValue.ts b/app/composables/usePersistedValue.ts new file mode 100644 index 0000000..3d9fe71 --- /dev/null +++ b/app/composables/usePersistedValue.ts @@ -0,0 +1,34 @@ +import { ref, watch } from 'vue' +import { useCookie } from '#imports' + +const parseValue = (value: unknown, fallback: T): T => { + if (value === null || value === undefined) { + return fallback + } + if (typeof value === 'string') { + try { + return JSON.parse(value) as T + } catch { + return value as unknown as T + } + } + return value as T +} + +export const usePersistedValue = (key: string, fallback: T) => { + const cookie = useCookie(`pref:${key}`, { + sameSite: 'lax', + }) + const initial = parseValue(cookie.value, fallback) + const state = ref(initial) + + watch( + state, + (value) => { + cookie.value = JSON.stringify(value) + }, + { deep: true }, + ) + + return state +} diff --git a/app/pages/component-catalog.vue b/app/pages/component-catalog.vue index 39b4615..83321ec 100644 --- a/app/pages/component-catalog.vue +++ b/app/pages/component-catalog.vue @@ -140,19 +140,34 @@ diff --git a/app/pages/constructeurs.vue b/app/pages/constructeurs.vue index 8d74f8a..ef62fff 100644 --- a/app/pages/constructeurs.vue +++ b/app/pages/constructeurs.vue @@ -122,6 +122,7 @@ import FieldEmail from '~/components/form/FieldEmail.vue' import FieldPhone from '~/components/form/FieldPhone.vue' import { useConstructeurs } from '~/composables/useConstructeurs' import { useToast } from '~/composables/useToast' +import { usePersistedValue } from '~/composables/usePersistedValue' import { formatPhone } from '~/utils/formatters/phone' import IconLucidePlus from '~icons/lucide/plus' @@ -129,7 +130,7 @@ const { constructeurs, loading, searchConstructeurs, createConstructeur, updateC const { showError, showSuccess } = useToast() const searchTerm = ref('') -const sortKey = ref('name') +const sortKey = usePersistedValue('constructeurs-sort', 'name') const modalOpen = ref(false) const saving = ref(false) const editingConstructeur = ref(null) diff --git a/app/pages/pieces-catalog.vue b/app/pages/pieces-catalog.vue index 1ca85a0..146aa54 100644 --- a/app/pages/pieces-catalog.vue +++ b/app/pages/pieces-catalog.vue @@ -162,19 +162,34 @@ diff --git a/app/pages/product-catalog.vue b/app/pages/product-catalog.vue index 648f663..19a421f 100644 --- a/app/pages/product-catalog.vue +++ b/app/pages/product-catalog.vue @@ -164,7 +164,9 @@ import { computed, onMounted, ref } 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 DocumentThumbnail from '~/components/DocumentThumbnail.vue' import { isImageDocument, isPdfDocument } from '~/utils/documentPreview' @@ -181,13 +183,25 @@ const { loadProducts, deleteProduct, } = useProducts() +const { productTypes, loadProductTypes } = useProductTypes() const toast = useToast() const searchTerm = ref('') -const sortField = ref<'name' | 'createdAt'>('name') -const sortDirection = ref<'asc' | 'desc'>('asc') +const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>( + 'product-catalog', + { field: 'name', direction: 'asc' }, +) -const normalizedProducts = computed(() => (Array.isArray(products.value) ? products.value : [])) +// Enrichir les produits avec les types de produits complets +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)) @@ -383,6 +397,9 @@ const confirmDelete = async (product: Record) => { } onMounted(async () => { - await loadProducts() + await Promise.all([ + loadProducts(), + loadProductTypes() + ]) }) From 0bfb69ad13ee092c82df70a53b6d6872c78cecb8 Mon Sep 17 00:00:00 2001 From: matthieu Date: Wed, 14 Jan 2026 23:10:34 +0100 Subject: [PATCH 04/16] =?UTF-8?q?fix(fournisseurs):=20r=C3=A9soudre=20les?= =?UTF-8?q?=20IRIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/composables/useComposants.js | 8 +++++--- app/composables/usePieces.js | 8 +++++--- app/composables/useProducts.js | 8 +++++--- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/app/composables/useComposants.js b/app/composables/useComposants.js index 5bdf629..192ce53 100644 --- a/app/composables/useComposants.js +++ b/app/composables/useComposants.js @@ -50,10 +50,12 @@ export function useComposants () { composant.constructeurs, composant.constructeur, ) - const hasConstructeurs = - Array.isArray(composant.constructeurs) && composant.constructeurs.length > 0 + const hasResolvedConstructeurs = + Array.isArray(composant.constructeurs) + && composant.constructeurs.length > 0 + && composant.constructeurs.every((item) => item && typeof item === 'object') - if (ids.length && !hasConstructeurs) { + if (ids.length && !hasResolvedConstructeurs) { const resolved = await ensureConstructeurs(ids) if (resolved.length) { composant.constructeurs = resolved diff --git a/app/composables/usePieces.js b/app/composables/usePieces.js index b27964e..3e944c4 100644 --- a/app/composables/usePieces.js +++ b/app/composables/usePieces.js @@ -50,10 +50,12 @@ export function usePieces () { piece.constructeurs, piece.constructeur, ) - const hasConstructeurs = - Array.isArray(piece.constructeurs) && piece.constructeurs.length > 0 + const hasResolvedConstructeurs = + Array.isArray(piece.constructeurs) + && piece.constructeurs.length > 0 + && piece.constructeurs.every((item) => item && typeof item === 'object') - if (ids.length && !hasConstructeurs) { + if (ids.length && !hasResolvedConstructeurs) { const resolved = await ensureConstructeurs(ids) if (resolved.length) { piece.constructeurs = resolved diff --git a/app/composables/useProducts.js b/app/composables/useProducts.js index 9b8b204..3e04da7 100644 --- a/app/composables/useProducts.js +++ b/app/composables/useProducts.js @@ -62,10 +62,12 @@ export function useProducts () { product.constructeurs, product.constructeur, ) - const hasConstructeurs = - Array.isArray(product.constructeurs) && product.constructeurs.length > 0 + const hasResolvedConstructeurs = + Array.isArray(product.constructeurs) + && product.constructeurs.length > 0 + && product.constructeurs.every((item) => item && typeof item === 'object') - if (ids.length && !hasConstructeurs) { + if (ids.length && !hasResolvedConstructeurs) { const resolved = await ensureConstructeurs(ids) if (resolved.length) { product.constructeurs = resolved From 84048bf3a2239d3863cd7c7b25fa9d6b76822f65 Mon Sep 17 00:00:00 2001 From: matthieu Date: Wed, 14 Jan 2026 23:10:42 +0100 Subject: [PATCH 05/16] fix(modeles): filtrer par categorie --- app/services/modelTypes.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/app/services/modelTypes.ts b/app/services/modelTypes.ts index 18f46c2..bede18c 100644 --- a/app/services/modelTypes.ts +++ b/app/services/modelTypes.ts @@ -109,18 +109,23 @@ export async function listModelTypes(params: ModelTypeListParams = {}, opts: { s signal: opts.signal, })); - const items = Array.isArray(payload?.member) + const rawItems = Array.isArray(payload?.member) ? payload.member : Array.isArray(payload?.['hydra:member']) ? payload['hydra:member'] : Array.isArray(payload?.items) ? payload.items : []; - const total = typeof payload?.totalItems === 'number' - ? payload.totalItems - : Array.isArray(payload?.items) - ? payload.items.length - : items.length; + const items = params.category + ? rawItems.filter((item: any) => item?.category === params.category) + : rawItems; + const total = params.category + ? items.length + : typeof payload?.totalItems === 'number' + ? payload.totalItems + : Array.isArray(payload?.items) + ? payload.items.length + : rawItems.length; return { items, From 52f75c5301ba6a3d6b444c65a7a3b9f73ccd89a2 Mon Sep 17 00:00:00 2001 From: matthieu Date: Thu, 15 Jan 2026 12:51:30 +0100 Subject: [PATCH 06/16] fix(modeles): paginer apres filtre categorie --- app/services/modelTypes.ts | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/app/services/modelTypes.ts b/app/services/modelTypes.ts index bede18c..ec875eb 100644 --- a/app/services/modelTypes.ts +++ b/app/services/modelTypes.ts @@ -96,11 +96,21 @@ export async function listModelTypes(params: ModelTypeListParams = {}, opts: { s if (params.dir) { query.dir = params.dir; } - if (typeof params.limit === 'number') { - query.itemsPerPage = params.limit; - } - if (typeof params.offset === 'number') { - query.offset = params.offset; + 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; + } } const payload = await requestFetch>(ENDPOINT, createOptions({ @@ -116,22 +126,25 @@ export async function listModelTypes(params: ModelTypeListParams = {}, opts: { s : Array.isArray(payload?.items) ? payload.items : []; - const items = params.category + const filteredItems = params.category ? rawItems.filter((item: any) => item?.category === params.category) : rawItems; const total = params.category - ? items.length + ? 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; return { items, total, - offset: params.offset ?? 0, - limit: typeof params.limit === 'number' ? params.limit : items.length, + offset: effectiveOffset, + limit: typeof effectiveLimit === 'number' ? effectiveLimit : items.length, } satisfies ModelTypeListResponse; } From 2e4d61c3ea114c5c44ca67ef8e48e98497a55142 Mon Sep 17 00:00:00 2001 From: matthieu Date: Thu, 15 Jan 2026 13:43:18 +0100 Subject: [PATCH 07/16] fix(modeles): normaliser structure et champs perso --- app/pages/component/[id]/edit.vue | 16 +++++++- app/services/modelTypes.ts | 67 +++++++++++++++++++++++++++---- 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/app/pages/component/[id]/edit.vue b/app/pages/component/[id]/edit.vue index bc7d32f..f6743ac 100644 --- a/app/pages/component/[id]/edit.vue +++ b/app/pages/component/[id]/edit.vue @@ -403,6 +403,7 @@ import { useComposants } from '~/composables/useComposants' import { useCustomFields } from '~/composables/useCustomFields' import { useApi } from '~/composables/useApi' import { useToast } from '~/composables/useToast' +import { extractRelationId } from '~/shared/apiRelations' import { useDocuments } from '~/composables/useDocuments' import { useConstructeurs } from '~/composables/useConstructeurs' import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils' @@ -435,7 +436,7 @@ const { get } = useApi() const { componentTypes, loadComponentTypes } = useComponentTypes() const { updateComposant } = useComposants() const { ensureConstructeurs } = useConstructeurs() -const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields() +const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields() const toast = useToast() const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments() @@ -636,6 +637,11 @@ const fetchComponent = async () => { if (result.success) { component.value = result.data componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : [] + + const customValues = await getCustomFieldValuesByEntity('composant', result.data.id) + if (customValues.success && Array.isArray(customValues.data)) { + component.value.customFieldValues = customValues.data + } } else { component.value = null componentDocuments.value = [] @@ -651,7 +657,13 @@ watch( return } - selectedTypeId.value = currentComponent.typeComposantId || '' + const resolvedTypeId = currentComponent.typeComposantId + || extractRelationId(currentComponent.typeComposant) + || '' + if (resolvedTypeId && !currentComponent.typeComposantId) { + currentComponent.typeComposantId = resolvedTypeId + } + selectedTypeId.value = resolvedTypeId editionForm.name = currentComponent.name || '' editionForm.reference = currentComponent.reference || '' diff --git a/app/services/modelTypes.ts b/app/services/modelTypes.ts index ec875eb..c2467bc 100644 --- a/app/services/modelTypes.ts +++ b/app/services/modelTypes.ts @@ -47,6 +47,9 @@ export interface ModelType extends BaseModelTypePayload { updatedAt: string; category: ModelCategory; structure: ModelTypeStructure; + componentSkeleton?: ComponentModelStructure | null; + pieceSkeleton?: PieceModelStructure | null; + productSkeleton?: ProductModelStructure | null; } export interface ModelTypeListParams { @@ -80,6 +83,46 @@ function createOptions(options: FetchOptions = {}) { }; } +const normalizeModelType = (item: any): ModelType => { + if (!item || typeof item !== 'object') { + return item as ModelType; + } + if (!item.structure) { + if (item.category === 'COMPONENT' && item.componentSkeleton) { + item.structure = item.componentSkeleton; + } else if (item.category === 'PIECE' && item.pieceSkeleton) { + item.structure = item.pieceSkeleton; + } else if (item.category === 'PRODUCT' && item.productSkeleton) { + item.structure = item.productSkeleton; + } + } + return item as ModelType; +}; + +const mapStructureToSkeleton = >(payload: T): T => { + if (!payload || typeof payload !== 'object') { + return payload; + } + if (!('structure' in payload)) { + return payload; + } + const structure = (payload as any).structure; + if (!structure) { + return payload; + } + const category = (payload as any).category; + const next = { ...payload } as Record; + if (category === 'COMPONENT') { + next.componentSkeleton = structure; + } else if (category === 'PIECE') { + next.pieceSkeleton = structure; + } else if (category === 'PRODUCT') { + next.productSkeleton = structure; + } + delete next.structure; + return next as T; +}; + export async function listModelTypes(params: ModelTypeListParams = {}, opts: { signal?: AbortSignal } = {}) { const requestFetch = useRequestFetch(); const query: Record = {}; @@ -136,9 +179,9 @@ export async function listModelTypes(params: ModelTypeListParams = {}, opts: { s : Array.isArray(payload?.items) ? payload.items.length : rawItems.length; - const items = params.category && typeof effectiveLimit === 'number' + const items = (params.category && typeof effectiveLimit === 'number' ? filteredItems.slice(effectiveOffset, effectiveOffset + effectiveLimit) - : filteredItems; + : filteredItems).map(normalizeModelType); return { items, @@ -150,20 +193,30 @@ export async function listModelTypes(params: ModelTypeListParams = {}, opts: { s export function createModelType(payload: ModelTypePayload, opts: { signal?: AbortSignal } = {}) { const requestFetch = useRequestFetch(); + const mappedPayload = mapStructureToSkeleton(payload); return requestFetch(ENDPOINT, createOptions({ method: 'POST', - body: payload, + headers: { + 'Content-Type': 'application/ld+json', + Accept: 'application/ld+json', + }, + body: mappedPayload, signal: opts.signal, - })); + })).then(normalizeModelType); } export function updateModelType(id: string, payload: Partial, opts: { signal?: AbortSignal } = {}) { const requestFetch = useRequestFetch(); + const mappedPayload = mapStructureToSkeleton(payload); return requestFetch(`${ENDPOINT}/${id}`, createOptions({ method: 'PATCH', - body: payload, + headers: { + 'Content-Type': 'application/merge-patch+json', + Accept: 'application/ld+json', + }, + body: mappedPayload, signal: opts.signal, - })); + })).then(normalizeModelType); } export function deleteModelType(id: string, opts: { signal?: AbortSignal } = {}) { @@ -179,5 +232,5 @@ export function getModelType(id: string, opts: { signal?: AbortSignal } = {}) { return requestFetch(`${ENDPOINT}/${id}`, createOptions({ method: 'GET', signal: opts.signal, - })); + })).then(normalizeModelType); } From 51edd7f655cb6d30ca0ea724ca37c82daa2bbf57 Mon Sep 17 00:00:00 2001 From: matthieu Date: Thu, 15 Jan 2026 13:43:23 +0100 Subject: [PATCH 08/16] fix(machines): enrichir les relations --- app/pages/machines/index.vue | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/app/pages/machines/index.vue b/app/pages/machines/index.vue index 0e52888..08de066 100644 --- a/app/pages/machines/index.vue +++ b/app/pages/machines/index.vue @@ -163,8 +163,21 @@ const categories = computed(() => { return Array.from(cats) }) +// Enrichir les machines avec les objets site et typeMachine complets +const enrichedMachines = computed(() => { + return machines.value.map((machine) => { + const site = sites.value.find(s => s.id === machine.siteId) + const typeMachine = machineTypes.value.find(t => t.id === machine.typeMachineId) + return { + ...machine, + site: site || null, + typeMachine: typeMachine || null + } + }) +}) + const filteredMachines = computed(() => { - let filtered = machines.value + let filtered = enrichedMachines.value if (selectedSite.value) { filtered = filtered.filter(machine => machine.siteId === selectedSite.value) From 2f3d4c52600bcc02fd7594fd61c1c3863a1a2429 Mon Sep 17 00:00:00 2001 From: matthieu Date: Thu, 15 Jan 2026 13:43:28 +0100 Subject: [PATCH 09/16] chore(dev): exposer le serveur nuxt --- nuxt.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/nuxt.config.ts b/nuxt.config.ts index 33fcb7b..8a66009 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -4,6 +4,7 @@ export default defineNuxtConfig({ ssr: false, // Désactive le SSR pour un mode SPA pur (Client-Side Rendering uniquement) devtools: { enabled: true }, devServer: { + host: '0.0.0.0', port: 3001 }, modules: [ From 9cc7ac10f07b5604d95f1efacbfa178581a2af2f Mon Sep 17 00:00:00 2001 From: matthieu Date: Fri, 23 Jan 2026 12:28:40 +0100 Subject: [PATCH 10/16] =?UTF-8?q?WIP:=20corrections=20multiples=20formulai?= =?UTF-8?q?res=20et=20s=C3=A9rialisation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix constructeurUtils: réordonner delete/add pour sauvegarder les fournisseurs - Fix prix/supplierPrice: envoyer en string pour DECIMAL Doctrine - Fix useMachineTypesApi: normaliser les requirements et forceRefresh - Fix SearchSelect: watch deep sur baseOptions - Debug logs temporaires pour pieceRequirements Co-Authored-By: Claude Opus 4.5 --- app/components/TypeEditForm.vue | 31 ++++--- .../TypeEditPieceRequirementsSection.vue | 8 +- app/components/common/SearchSelect.vue | 10 ++- app/composables/useMachineTypesApi.js | 43 +++++++--- app/pages/component/[id]/edit.vue | 22 +++-- app/pages/component/create.vue | 2 +- app/pages/machine-skeleton/new.vue | 27 ++++-- app/pages/pieces/[id]/edit.vue | 85 +++++++++++++++---- app/pages/pieces/create.vue | 2 +- app/pages/product/[id]/edit.vue | 43 ++++++++-- app/pages/product/create.vue | 24 ++++-- app/pages/type/edit/[id].vue | 29 +++++-- app/shared/constructeurUtils.ts | 37 ++++++-- nuxt.config.ts | 2 +- 14 files changed, 276 insertions(+), 89 deletions(-) diff --git a/app/components/TypeEditForm.vue b/app/components/TypeEditForm.vue index 18191e3..f6a6ba1 100644 --- a/app/components/TypeEditForm.vue +++ b/app/components/TypeEditForm.vue @@ -62,19 +62,30 @@ const deepClone = value => JSON.parse(JSON.stringify(value)) const createFieldKey = () => `cf-${Math.random().toString(16).slice(2)}-${Date.now()}` +const normalizeCustomField = (field = {}, index = 0) => { + const clone = deepClone(field) + if (clone.type === 'select') { + if (typeof clone.optionsText !== 'string' || !clone.optionsText.length) { + if (Array.isArray(clone.options)) { + clone.optionsText = clone.options.map(option => String(option).trim()).filter(Boolean).join('\n') + } else { + clone.optionsText = '' + } + } + } + const currentOrder = + typeof clone?.orderIndex === 'number' ? clone.orderIndex : index + clone.orderIndex = currentOrder + if (typeof clone?.__key !== 'string' || !clone.__key) { + clone.__key = createFieldKey() + } + return clone +} + const withNormalizedOrder = (items = []) => { if (!Array.isArray(items)) { return [] } return items - .map((item, index) => { - const clone = deepClone(item) - const currentOrder = - typeof clone?.orderIndex === 'number' ? clone.orderIndex : index - clone.orderIndex = currentOrder - if (typeof clone?.__key !== 'string' || !clone.__key) { - clone.__key = createFieldKey() - } - return clone - }) + .map((item, index) => normalizeCustomField(item, index)) .sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0)) .map((item, index) => ({ ...item, orderIndex: index })) } diff --git a/app/components/TypeEditPieceRequirementsSection.vue b/app/components/TypeEditPieceRequirementsSection.vue index 1ba9486..9d530f0 100644 --- a/app/components/TypeEditPieceRequirementsSection.vue +++ b/app/components/TypeEditPieceRequirementsSection.vue @@ -12,7 +12,7 @@ diff --git a/app/components/common/SearchSelect.vue b/app/components/common/SearchSelect.vue index b4e8f63..ebc1248 100644 --- a/app/components/common/SearchSelect.vue +++ b/app/components/common/SearchSelect.vue @@ -184,11 +184,13 @@ watch( watch( baseOptions, - () => { - if (!openDropdown.value) { - searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : searchTerm.value + (newOptions) => { + console.log('[SearchSelect] baseOptions changed, count:', newOptions.length, 'modelValue:', props.modelValue, 'selectedOption:', selectedOption.value?.id) + if (!openDropdown.value && selectedOption.value) { + searchTerm.value = resolveLabel(selectedOption.value) } - } + }, + { deep: true } ) watch(openDropdown, (isOpen) => { diff --git a/app/composables/useMachineTypesApi.js b/app/composables/useMachineTypesApi.js index 83f47fa..5712f60 100644 --- a/app/composables/useMachineTypesApi.js +++ b/app/composables/useMachineTypesApi.js @@ -10,18 +10,28 @@ const normalizeRequirementList = (value, relationKey) => { if (!Array.isArray(value)) { return [] } - return value.map((entry) => { + return value.map((entry, index) => { if (!entry || typeof entry !== 'object') { return entry } const normalized = { ...entry } + const relationField = relationKey.replace('Id', '') + const relationValue = normalized[relationField] + console.log(`[normalizeRequirementList] Entry ${index}:`, { + relationKey, + relationField, + hasRelationKey: !!normalized[relationKey], + relationValue, + relationValueType: typeof relationValue + }) if (relationKey && !normalized[relationKey]) { - const relationValue = normalized[relationKey.replace('Id', '')] const relationId = extractRelationId(relationValue) + console.log(`[normalizeRequirementList] Extracted ID:`, relationId) if (relationId) { normalized[relationKey] = relationId } } + console.log(`[normalizeRequirementList] Normalized entry:`, normalized) return normalized }) } @@ -56,7 +66,7 @@ const extractCollection = (payload) => { export function useMachineTypesApi () { const { showSuccess, showError, showInfo } = useToast() - const { get, post, patch, delete: del } = useApi() + const { get, post, put, delete: del } = useApi() const loadMachineTypes = async () => { loading.value = true @@ -94,7 +104,7 @@ export function useMachineTypesApi () { const updateMachineType = async (id, typeData) => { loading.value = true try { - const result = await patch(`/type_machines/${id}`, typeData) + const result = await put(`/type_machines/${id}`, typeData) if (result.success) { const normalized = normalizeMachineType(result.data) const index = machineTypes.value.findIndex(type => type.id === id) @@ -130,19 +140,28 @@ export function useMachineTypesApi () { } } - const getMachineTypeById = async (id) => { - // D'abord chercher dans le cache local - const localType = machineTypes.value.find(type => type.id === id) - if (localType) { - return { success: true, data: localType } + const getMachineTypeById = async (id, forceRefresh = false) => { + // D'abord chercher dans le cache local (sauf si forceRefresh) + if (!forceRefresh) { + const localType = machineTypes.value.find(type => type.id === id) + if (localType) { + return { success: true, data: localType } + } } - // Si pas trouvé localement, récupérer depuis l'API + // Récupérer depuis l'API try { const result = await get(`/type_machines/${id}`) if (result.success) { - // Ajouter au cache local - machineTypes.value.push(normalizeMachineType(result.data)) + const normalized = normalizeMachineType(result.data) + // Mettre à jour le cache local + const index = machineTypes.value.findIndex(type => type.id === id) + if (index !== -1) { + machineTypes.value[index] = normalized + } else { + machineTypes.value.push(normalized) + } + return { success: true, data: normalized } } return result } catch (error) { diff --git a/app/pages/component/[id]/edit.vue b/app/pages/component/[id]/edit.vue index f6743ac..b43c711 100644 --- a/app/pages/component/[id]/edit.vue +++ b/app/pages/component/[id]/edit.vue @@ -594,6 +594,15 @@ const selectedTypeStructure = computed(() => { return structure ? normalizeStructureForEditor(structure) : null }) +const refreshCustomFieldInputs = ( + structureOverride?: ComponentModelStructure | null, + valuesOverride?: any[] | null, +) => { + const structure = structureOverride ?? selectedTypeStructure.value ?? null + const values = valuesOverride ?? component.value?.customFieldValues ?? null + customFieldInputs.value = buildCustomFieldInputs(structure, values) +} + const requiredCustomFieldsFilled = computed(() => customFieldInputs.value.every((field) => { if (!field.required) { @@ -641,6 +650,7 @@ const fetchComponent = async () => { const customValues = await getCustomFieldValuesByEntity('composant', result.data.id) if (customValues.success && Array.isArray(customValues.data)) { component.value.customFieldValues = customValues.data + refreshCustomFieldInputs(undefined, customValues.data) } } else { component.value = null @@ -677,10 +687,7 @@ watch( void ensureConstructeurs(editionForm.constructeurIds) } - customFieldInputs.value = buildCustomFieldInputs( - currentStructure, - currentComponent.customFieldValues, - ) + refreshCustomFieldInputs(currentStructure, currentComponent.customFieldValues) initialized = true }, @@ -691,10 +698,7 @@ watch(selectedTypeStructure, (currentStructure) => { if (!component.value) { return } - customFieldInputs.value = buildCustomFieldInputs( - currentStructure, - component.value.customFieldValues, - ) + refreshCustomFieldInputs(currentStructure, component.value.customFieldValues) }) const submitEdition = async () => { @@ -719,7 +723,7 @@ const submitEdition = async () => { if (rawPrice) { const parsed = Number(rawPrice) if (!Number.isNaN(parsed)) { - payload.prix = parsed + payload.prix = String(parsed) } } else { payload.prix = null diff --git a/app/pages/component/create.vue b/app/pages/component/create.vue index baa27f6..db90535 100644 --- a/app/pages/component/create.vue +++ b/app/pages/component/create.vue @@ -870,7 +870,7 @@ const submitCreation = async () => { if (rawPrice) { const parsed = Number(rawPrice) if (!Number.isNaN(parsed)) { - payload.prix = parsed + payload.prix = String(parsed) } } diff --git a/app/pages/machine-skeleton/new.vue b/app/pages/machine-skeleton/new.vue index 0e7a666..7799828 100644 --- a/app/pages/machine-skeleton/new.vue +++ b/app/pages/machine-skeleton/new.vue @@ -86,6 +86,7 @@ import { ref, computed, onMounted } from 'vue' import { useMachineTypesApi } from '~/composables/useMachineTypesApi' import { useToast } from '~/composables/useToast' +import { extractRelationId } from '~/shared/apiRelations' import IconLucidePlus from '~icons/lucide/plus' import IconLucideClipboardList from '~icons/lucide/clipboard-list' import IconLucideList from '~icons/lucide/list' @@ -142,6 +143,20 @@ const parseOptions = (field = {}) => { return [] } +const toModelTypeIri = (value) => { + if (!value) { + return undefined + } + if (typeof value === 'string' && value.startsWith('/api/model_types/')) { + return value + } + const relationId = extractRelationId(value) + if (relationId) { + return `/api/model_types/${relationId}` + } + return typeof value === 'string' ? `/api/model_types/${value}` : undefined +} + const normalizeCustomFields = (fields = []) => fields .filter(field => field?.name && field.name.trim() !== '') @@ -165,9 +180,9 @@ const toIntegerOrNull = (value, fallback = null) => { const normalizeComponentRequirements = (requirements = []) => requirements - .filter(req => req?.typeComposantId) + .filter(req => req?.typeComposantId || req?.typeComposant) .map((req, index) => ({ - typeComposantId: req.typeComposantId, + typeComposant: toModelTypeIri(req.typeComposantId || req.typeComposant), label: req.label?.trim() ? req.label.trim() : undefined, minCount: toIntegerOrNull(req.minCount, 1), maxCount: toIntegerOrNull(req.maxCount, null), @@ -180,9 +195,9 @@ const normalizeComponentRequirements = (requirements = []) => const normalizePieceRequirements = (requirements = []) => requirements - .filter(req => req?.typePieceId) + .filter(req => req?.typePieceId || req?.typePiece) .map((req, index) => ({ - typePieceId: req.typePieceId, + typePiece: toModelTypeIri(req.typePieceId || req.typePiece), label: req.label?.trim() ? req.label.trim() : undefined, minCount: toIntegerOrNull(req.minCount, 0), maxCount: toIntegerOrNull(req.maxCount, null), @@ -195,9 +210,9 @@ const normalizePieceRequirements = (requirements = []) => const normalizeProductRequirements = (requirements = []) => requirements - .filter(req => req?.typeProductId) + .filter(req => req?.typeProductId || req?.typeProduct) .map((req, index) => ({ - typeProductId: req.typeProductId, + typeProduct: toModelTypeIri(req.typeProductId || req.typeProduct), label: req.label?.trim() ? req.label.trim() : undefined, minCount: toIntegerOrNull(req.minCount, 0), maxCount: toIntegerOrNull(req.maxCount, null), diff --git a/app/pages/pieces/[id]/edit.vue b/app/pages/pieces/[id]/edit.vue index cacec87..5df0bed 100644 --- a/app/pages/pieces/[id]/edit.vue +++ b/app/pages/pieces/[id]/edit.vue @@ -154,26 +154,26 @@ /> -
    +

    Squelette sélectionné

    - {{ selectedType.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.' }} + {{ selectedType?.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.' }}

    - {{ formatPieceStructurePreview(selectedType.structure) }} + {{ formatPieceStructurePreview(resolvedStructure) }}
    -
    +
    Consulter le détail du squelette
    -
    +

    Champs personnalisés

      -
    • +
    • {{ field.name }} : {{ field.value }}
    • @@ -395,12 +395,14 @@ import { useApi } from '~/composables/useApi' import { useToast } from '~/composables/useToast' import { useDocuments } from '~/composables/useDocuments' import { useConstructeurs } from '~/composables/useConstructeurs' +import { extractRelationId } from '~/shared/apiRelations' import { getFileIcon } from '~/utils/fileIcons' import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview' import { formatPieceStructurePreview } from '~/shared/modelUtils' import { uniqueConstructeurIds } from '~/shared/constructeurUtils' import type { PieceModelProduct, PieceModelStructure } from '~/shared/types/inventory' import type { ModelType } from '~/services/modelTypes' +import { getModelType } from '~/services/modelTypes' interface PieceCatalogType extends ModelType { structure: PieceModelStructure | null @@ -424,7 +426,7 @@ const router = useRouter() const { get } = useApi() const { pieceTypes, loadPieceTypes } = usePieceTypes() const { updatePiece } = usePieces() -const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields() +const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields() const toast = useToast() const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments() const { ensureConstructeurs } = useConstructeurs() @@ -440,6 +442,7 @@ const previewDocument = ref(null) const previewVisible = ref(false) const selectedTypeId = ref('') +const pieceTypeDetails = ref(null) const editionForm = reactive({ name: '' as string, reference: '' as string, @@ -451,6 +454,18 @@ const editionForm = reactive({ const customFieldInputs = ref([]) const documentIcon = (doc: any) => getFileIcon({ name: doc?.filename || doc?.name, mime: doc?.mimeType }) +const resolvedStructure = computed(() => + pieceTypeDetails.value?.structure ?? selectedType.value?.structure ?? null, +) + +const refreshCustomFieldInputs = ( + structureOverride?: PieceModelStructure | null, + valuesOverride?: any[] | null, +) => { + const structure = structureOverride ?? resolvedStructure.value ?? null + const values = valuesOverride ?? piece.value?.customFieldValues ?? null + customFieldInputs.value = buildCustomFieldInputs(structure, values) +} const formatSize = (size: number | null | undefined) => { if (size === null || size === undefined) { return '—' @@ -578,7 +593,7 @@ const selectedType = computed(() => { }) const structureProducts = computed(() => - getStructureProducts(selectedType.value?.structure ?? null), + getStructureProducts(resolvedStructure.value), ) const requiresProductSelection = computed(() => structureProducts.value.length > 0) @@ -659,12 +674,37 @@ const fetchPiece = async () => { if (result.success) { piece.value = result.data pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : [] + const customValues = await getCustomFieldValuesByEntity('piece', result.data.id) + if (customValues.success && Array.isArray(customValues.data)) { + piece.value.customFieldValues = customValues.data + refreshCustomFieldInputs(undefined, customValues.data) + } + await loadPieceTypeDetails(result.data) } else { piece.value = null pieceDocuments.value = [] } } +const loadPieceTypeDetails = async (currentPiece: any) => { + const typeId = currentPiece?.typePieceId + || extractRelationId(currentPiece?.typePiece) + || '' + if (!typeId) { + pieceTypeDetails.value = null + return + } + try { + const type = await getModelType(typeId) + if (type && typeof type === 'object') { + pieceTypeDetails.value = type + refreshCustomFieldInputs(type.structure ?? null, currentPiece?.customFieldValues ?? null) + } + } catch (error) { + pieceTypeDetails.value = null + } +} + let initialized = false watch( @@ -674,7 +714,13 @@ watch( return } - selectedTypeId.value = currentPiece.typePieceId || '' + const resolvedTypeId = currentPiece.typePieceId + || extractRelationId(currentPiece.typePiece) + || '' + if (resolvedTypeId && !currentPiece.typePieceId) { + currentPiece.typePieceId = resolvedTypeId + } + selectedTypeId.value = resolvedTypeId editionForm.name = currentPiece.name || '' editionForm.reference = currentPiece.reference || '' @@ -689,10 +735,7 @@ watch( void ensureConstructeurs(editionForm.constructeurIds) } - customFieldInputs.value = buildCustomFieldInputs( - currentType?.structure ?? null, - currentPiece.customFieldValues, - ) + refreshCustomFieldInputs(currentType?.structure ?? null, currentPiece.customFieldValues) initialized = true }, @@ -703,10 +746,16 @@ watch(selectedType, (currentType) => { if (!piece.value || !currentType) { return } - customFieldInputs.value = buildCustomFieldInputs( - currentType.structure, - piece.value.customFieldValues, - ) + if (!pieceTypeDetails.value) { + refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues) + } +}) + +watch(resolvedStructure, (currentStructure) => { + if (!piece.value) { + return + } + refreshCustomFieldInputs(currentStructure, piece.value.customFieldValues) }) const submitEdition = async () => { @@ -744,7 +793,7 @@ const submitEdition = async () => { if (rawPrice) { const parsed = Number(rawPrice) if (!Number.isNaN(parsed)) { - payload.prix = parsed + payload.prix = String(parsed) } } else { payload.prix = null diff --git a/app/pages/pieces/create.vue b/app/pages/pieces/create.vue index 5750858..950c65d 100644 --- a/app/pages/pieces/create.vue +++ b/app/pages/pieces/create.vue @@ -504,7 +504,7 @@ const submitCreation = async () => { if (rawPrice) { const parsed = Number(rawPrice) if (!Number.isNaN(parsed)) { - payload.prix = parsed + payload.prix = String(parsed) } } diff --git a/app/pages/product/[id]/edit.vue b/app/pages/product/[id]/edit.vue index 0fda70e..7c2fc2b 100644 --- a/app/pages/product/[id]/edit.vue +++ b/app/pages/product/[id]/edit.vue @@ -352,7 +352,7 @@ const route = useRoute() const router = useRouter() const toast = useToast() const { getProduct, updateProduct } = useProducts() -const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields() +const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields() const { loadDocumentsByProduct, uploadDocuments: uploadProductDocuments, @@ -373,6 +373,15 @@ const productDocuments = ref([]) const previewDocument = ref(null) const previewVisible = ref(false) +const refreshCustomFieldInputs = ( + structureOverride?: ProductModelStructure | null, + valuesOverride?: any[] | null, +) => { + const nextStructure = structureOverride ?? structure.value ?? null + const nextValues = valuesOverride ?? product.value?.customFieldValues ?? null + customFieldInputs.value = buildCustomFieldInputs(nextStructure, nextValues) +} + const editionForm = reactive({ name: '' as string, reference: '' as string, @@ -493,6 +502,11 @@ const loadProduct = async () => { product.value = result.data productDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : [] await loadProductType() + const customValues = await getCustomFieldValuesByEntity('product', result.data.id) + if (customValues.success && Array.isArray(customValues.data)) { + product.value.customFieldValues = customValues.data + refreshCustomFieldInputs(undefined, customValues.data) + } await hydrateForm() await refreshDocuments() } else { @@ -582,7 +596,7 @@ const hydrateForm = async () => { editionForm.supplierPrice = product.value.supplierPrice !== null && product.value.supplierPrice !== undefined ? String(product.value.supplierPrice) : '' - customFieldInputs.value = buildCustomFieldInputs(structure.value, product.value.customFieldValues) + refreshCustomFieldInputs(structure.value, product.value.customFieldValues) if (editionForm.constructeurIds.length) { await ensureConstructeurs(editionForm.constructeurIds) } @@ -691,11 +705,13 @@ const submitEdition = async () => { constructeurIds, } - const rawPrice = editionForm.supplierPrice.trim() - payload.supplierPrice = rawPrice + const rawPrice = typeof editionForm.supplierPrice === 'string' + ? editionForm.supplierPrice.trim() + : editionForm.supplierPrice + payload.supplierPrice = rawPrice !== '' && rawPrice !== null && rawPrice !== undefined ? Number.isNaN(Number(rawPrice)) ? null - : Number(rawPrice) + : String(Number(rawPrice)) : null saving.value = true @@ -730,20 +746,29 @@ const saveCustomFieldValues = async (productId: string) => { continue } - if (!field.customFieldId) { - continue - } + const metadata = field.customFieldId + ? undefined + : { customFieldName: field.name, customFieldType: field.type, customFieldRequired: field.required } const result = await upsertCustomFieldValue( field.customFieldId, 'product', productId, String(value ?? ''), - { customFieldName: field.name, customFieldType: field.type, customFieldRequired: field.required }, + metadata, ) if (!result.success) { failed.push(field.name) + } else { + const createdValue = result.data + if (createdValue?.id) { + field.customFieldValueId = createdValue.id + } + const resolvedId = createdValue?.customField?.id || field.customFieldId + if (resolvedId) { + field.customFieldId = resolvedId + } } } return failed diff --git a/app/pages/product/create.vue b/app/pages/product/create.vue index 791dda5..6b950ff 100644 --- a/app/pages/product/create.vue +++ b/app/pages/product/create.vue @@ -425,11 +425,13 @@ const buildPayload = () => { payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds) - const rawPrice = creationForm.supplierPrice.trim() - if (rawPrice) { + const rawPrice = typeof creationForm.supplierPrice === 'string' + ? creationForm.supplierPrice.trim() + : creationForm.supplierPrice + if (rawPrice !== '' && rawPrice !== null && rawPrice !== undefined) { const parsed = Number(rawPrice) if (!Number.isNaN(parsed)) { - payload.supplierPrice = parsed + payload.supplierPrice = String(parsed) } } @@ -486,19 +488,31 @@ const submitCreation = async () => { const saveCustomFieldValues = async (productId: string) => { const failed: string[] = [] for (const field of customFieldInputs.value) { - if (!field.customFieldId || !field.name) { + if (!field.name) { continue } const value = field.value ?? '' + const metadata = field.customFieldId + ? undefined + : { customFieldName: field.name, customFieldType: field.type, customFieldRequired: field.required } const result = await upsertCustomFieldValue( field.customFieldId, 'product', productId, String(value ?? ''), - { customFieldName: field.name, customFieldType: field.type, customFieldRequired: field.required }, + metadata, ) if (!result.success) { failed.push(field.name) + } else { + const createdValue = result.data + if (createdValue?.id) { + field.customFieldValueId = createdValue.id + } + const resolvedId = createdValue?.customField?.id || field.customFieldId + if (resolvedId) { + field.customFieldId = resolvedId + } } } return failed diff --git a/app/pages/type/edit/[id].vue b/app/pages/type/edit/[id].vue index 824b9db..df14c07 100644 --- a/app/pages/type/edit/[id].vue +++ b/app/pages/type/edit/[id].vue @@ -52,6 +52,7 @@ import { ref, onMounted } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useMachineTypesApi } from '~/composables/useMachineTypesApi' import { useToast } from '~/composables/useToast' +import { extractRelationId } from '~/shared/apiRelations' const route = useRoute() const router = useRouter() @@ -90,6 +91,20 @@ const parseOptions = (field = {}) => { return [] } +const toModelTypeIri = (value) => { + if (!value) { + return undefined + } + if (typeof value === 'string' && value.startsWith('/api/model_types/')) { + return value + } + const relationId = extractRelationId(value) + if (relationId) { + return `/api/model_types/${relationId}` + } + return typeof value === 'string' ? `/api/model_types/${value}` : undefined +} + const normalizeCustomFields = (fields = []) => fields .filter(field => field?.name && field.name.trim() !== '') @@ -113,9 +128,9 @@ const toIntegerOrNull = (value, fallback = null) => { const normalizeComponentRequirements = (requirements = []) => requirements - .filter(req => req?.typeComposantId) + .filter(req => req?.typeComposantId || req?.typeComposant) .map((req, index) => ({ - typeComposantId: req.typeComposantId, + typeComposant: toModelTypeIri(req.typeComposantId || req.typeComposant), label: req.label?.trim() ? req.label.trim() : undefined, minCount: toIntegerOrNull(req.minCount, 1), maxCount: toIntegerOrNull(req.maxCount, null), @@ -128,9 +143,9 @@ const normalizeComponentRequirements = (requirements = []) => const normalizePieceRequirements = (requirements = []) => requirements - .filter(req => req?.typePieceId) + .filter(req => req?.typePieceId || req?.typePiece) .map((req, index) => ({ - typePieceId: req.typePieceId, + typePiece: toModelTypeIri(req.typePieceId || req.typePiece), label: req.label?.trim() ? req.label.trim() : undefined, minCount: toIntegerOrNull(req.minCount, 0), maxCount: toIntegerOrNull(req.maxCount, null), @@ -143,9 +158,9 @@ const normalizePieceRequirements = (requirements = []) => const normalizeProductRequirements = (requirements = []) => requirements - .filter(req => req?.typeProductId) + .filter(req => req?.typeProductId || req?.typeProduct) .map((req, index) => ({ - typeProductId: req.typeProductId, + typeProduct: toModelTypeIri(req.typeProductId || req.typeProduct), label: req.label?.trim() ? req.label.trim() : undefined, minCount: toIntegerOrNull(req.minCount, 0), maxCount: toIntegerOrNull(req.maxCount, null), @@ -194,7 +209,7 @@ onMounted(async () => { console.log('=== EDIT TYPE PAGE LOADING ===') console.log('Loading type with ID:', typeId) - const result = await getMachineTypeById(typeId) + const result = await getMachineTypeById(typeId, true) console.log('API Result:', result) if (result.success) { diff --git a/app/shared/constructeurUtils.ts b/app/shared/constructeurUtils.ts index 0ebc386..1ec3733 100644 --- a/app/shared/constructeurUtils.ts +++ b/app/shared/constructeurUtils.ts @@ -117,21 +117,48 @@ export const formatConstructeurContact = ( export const buildConstructeurRequestPayload = >( payload: T, ): T & { constructeurs?: string[] } => { - const ids = uniqueConstructeurIds( + const collected = new Set(uniqueConstructeurIds( payload?.constructeurIds, payload?.constructeurId, payload?.constructeur, payload?.constructeurs, - ); + )); + + if (!collected.size) { + const fallbackLists = [ + payload?.constructeurIds, + payload?.constructeurs, + ]; + fallbackLists.forEach((list) => { + if (!Array.isArray(list)) { + return; + } + list.forEach((item) => { + if (typeof item === 'string') { + const id = toStringId(item); + if (id) { + collected.add(id); + } + return; + } + if (isObject(item) && typeof item.id === 'string') { + collected.add(item.id); + } + }); + }); + } + + const ids = Array.from(collected); const next = { ...payload } as Record; - if (ids.length) { - next.constructeurs = ids.map((id) => `/api/constructeurs/${id}`); - } delete next.constructeurId; delete next.constructeur; delete next.constructeurs; delete next.constructeurIds; + if (ids.length) { + next.constructeurs = ids.map((id) => `/api/constructeurs/${id}`); + } + return next as T & { constructeurs?: string[] }; }; diff --git a/nuxt.config.ts b/nuxt.config.ts index 8a66009..89f61fe 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -5,7 +5,7 @@ export default defineNuxtConfig({ devtools: { enabled: true }, devServer: { host: '0.0.0.0', - port: 3001 + port: 3000 }, modules: [ [ From 8af83742825251b2814554745e98551d8903536d Mon Sep 17 00:00:00 2001 From: matthieu Date: Fri, 23 Jan 2026 19:35:00 +0100 Subject: [PATCH 11/16] feat(ui): ajoute la pagination et la recherche serveur --- app/components/common/Pagination.vue | 128 ++++++++++++ app/composables/useComposants.js | 60 +++++- app/composables/usePieces.js | 62 +++++- app/composables/useProducts.js | 62 ++++-- app/pages/component-catalog.vue | 237 ++++++++++++---------- app/pages/pieces-catalog.vue | 285 +++++++++++++++------------ 6 files changed, 579 insertions(+), 255 deletions(-) create mode 100644 app/components/common/Pagination.vue diff --git a/app/components/common/Pagination.vue b/app/components/common/Pagination.vue new file mode 100644 index 0000000..ad6c253 --- /dev/null +++ b/app/components/common/Pagination.vue @@ -0,0 +1,128 @@ + + + diff --git a/app/composables/useComposants.js b/app/composables/useComposants.js index 192ce53..138ccb8 100644 --- a/app/composables/useComposants.js +++ b/app/composables/useComposants.js @@ -6,6 +6,7 @@ import { useConstructeurs } from './useConstructeurs' import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations' const composants = ref([]) +const total = ref(0) const loading = ref(false) const extractCollection = (payload) => { @@ -24,6 +25,16 @@ const extractCollection = (payload) => { return [] } +const extractTotal = (payload, fallbackLength) => { + if (typeof payload?.totalItems === 'number') { + return payload.totalItems + } + if (typeof payload?.['hydra:totalItems'] === 'number') { + return payload['hydra:totalItems'] + } + return fallbackLength +} + export function useComposants () { const { showSuccess, showError, showInfo } = useToast() const { get, post, patch, delete: del } = useApi() @@ -65,18 +76,56 @@ export function useComposants () { return composant } - const loadComposants = async () => { + /** + * Load composants with pagination and search support + * @param {Object} options - Query options + * @param {string} [options.search] - Search term for name/reference + * @param {number} [options.page=1] - Current page (1-based) + * @param {number} [options.itemsPerPage=30] - Items per page + * @param {string} [options.orderBy='name'] - Field to order by + * @param {string} [options.orderDir='asc'] - Order direction (asc/desc) + */ + const loadComposants = async (options = {}) => { loading.value = true try { - const result = await get('/composants') + const { + search = '', + page = 1, + itemsPerPage = 30, + orderBy = 'name', + orderDir = 'asc' + } = options + + const params = new URLSearchParams() + params.set('itemsPerPage', String(itemsPerPage)) + params.set('page', String(page)) + + if (search && search.trim()) { + params.set('name', search.trim()) + } + + params.set(`order[${orderBy}]`, orderDir) + + const result = await get(`/composants?${params.toString()}`) if (result.success) { const items = extractCollection(result.data) const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item))) composants.value = enrichedItems - showInfo(`Chargement de ${composants.value.length} composant(s) réussi`) + total.value = extractTotal(result.data, items.length) + return { + success: true, + data: { + items: enrichedItems, + total: total.value, + page, + itemsPerPage + } + } } + return result } catch (error) { console.error('Erreur lors du chargement des composants:', error) + return { success: false, error: error.message } } finally { loading.value = false } @@ -89,7 +138,8 @@ export function useComposants () { const result = await post('/composants', normalizedPayload) if (result.success) { const enriched = await withResolvedConstructeurs(result.data) - composants.value.push(enriched) + composants.value.unshift(enriched) + total.value += 1 const displayName = result.data?.name || composantData?.definition?.name || composantData?.name @@ -134,6 +184,7 @@ export function useComposants () { if (result.success) { const deletedComposant = composants.value.find(comp => comp.id === id) composants.value = composants.value.filter(comp => comp.id !== id) + total.value = Math.max(0, total.value - 1) showSuccess(`Composant "${deletedComposant?.name || 'inconnu'}" supprimé avec succès`) } return result @@ -150,6 +201,7 @@ export function useComposants () { return { composants, + total, loading, loadComposants, createComposant, diff --git a/app/composables/usePieces.js b/app/composables/usePieces.js index 3e944c4..037ff33 100644 --- a/app/composables/usePieces.js +++ b/app/composables/usePieces.js @@ -6,6 +6,7 @@ import { useConstructeurs } from './useConstructeurs' import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations' const pieces = ref([]) +const total = ref(0) const loading = ref(false) const extractCollection = (payload) => { @@ -24,6 +25,16 @@ const extractCollection = (payload) => { return [] } +const extractTotal = (payload, fallbackLength) => { + if (typeof payload?.totalItems === 'number') { + return payload.totalItems + } + if (typeof payload?.['hydra:totalItems'] === 'number') { + return payload['hydra:totalItems'] + } + return fallbackLength +} + export function usePieces () { const { showSuccess, showError, showInfo } = useToast() const { get, post, patch, delete: del } = useApi() @@ -65,18 +76,58 @@ export function usePieces () { return piece } - const loadPieces = async () => { + /** + * Load pieces with pagination and search support + * @param {Object} options - Query options + * @param {string} [options.search] - Search term for name/reference + * @param {number} [options.page=1] - Current page (1-based) + * @param {number} [options.itemsPerPage=30] - Items per page + * @param {string} [options.orderBy='name'] - Field to order by + * @param {string} [options.orderDir='asc'] - Order direction (asc/desc) + */ + const loadPieces = async (options = {}) => { loading.value = true try { - const result = await get('/pieces') + const { + search = '', + page = 1, + itemsPerPage = 30, + orderBy = 'name', + orderDir = 'asc' + } = options + + const params = new URLSearchParams() + params.set('itemsPerPage', String(itemsPerPage)) + params.set('page', String(page)) + + if (search && search.trim()) { + // API Platform uses property filters + params.set('name', search.trim()) + } + + // API Platform OrderFilter syntax: order[field]=direction + params.set(`order[${orderBy}]`, orderDir) + + const result = await get(`/pieces?${params.toString()}`) if (result.success) { const items = extractCollection(result.data) const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item))) pieces.value = enrichedItems - showInfo(`Chargement de ${pieces.value.length} pièce(s) réussi`) + total.value = extractTotal(result.data, items.length) + return { + success: true, + data: { + items: enrichedItems, + total: total.value, + page, + itemsPerPage + } + } } + return result } catch (error) { console.error('Erreur lors du chargement des pièces:', error) + return { success: false, error: error.message } } finally { loading.value = false } @@ -89,7 +140,8 @@ export function usePieces () { const result = await post('/pieces', normalizedPayload) if (result.success) { const enriched = await withResolvedConstructeurs(result.data) - pieces.value.push(enriched) + pieces.value.unshift(enriched) + total.value += 1 const displayName = result.data?.name || pieceData?.definition?.name || pieceData?.name @@ -134,6 +186,7 @@ export function usePieces () { if (result.success) { const deletedPiece = pieces.value.find(piece => piece.id === id) pieces.value = pieces.value.filter(piece => piece.id !== id) + total.value = Math.max(0, total.value - 1) showSuccess(`Pièce "${deletedPiece?.name || 'inconnu'}" supprimée avec succès`) } return result @@ -150,6 +203,7 @@ export function usePieces () { return { pieces, + total, loading, loadPieces, createPiece, diff --git a/app/composables/useProducts.js b/app/composables/useProducts.js index 3e04da7..5ed4165 100644 --- a/app/composables/useProducts.js +++ b/app/composables/useProducts.js @@ -42,6 +42,16 @@ const extractCollection = (payload) => { return [] } +const extractTotal = (payload, fallbackLength) => { + if (typeof payload?.totalItems === 'number') { + return payload.totalItems + } + if (typeof payload?.['hydra:totalItems'] === 'number') { + return payload['hydra:totalItems'] + } + return fallbackLength +} + export function useProducts () { const { showError } = useToast() const { get, post, patch, delete: del } = useApi() @@ -77,32 +87,62 @@ export function useProducts () { return product } + /** + * Load products with pagination and search support + * @param {Object} options - Query options + * @param {string} [options.search] - Search term for name/reference + * @param {number} [options.page=1] - Current page (1-based) + * @param {number} [options.itemsPerPage=30] - Items per page + * @param {string} [options.orderBy='name'] - Field to order by + * @param {string} [options.orderDir='asc'] - Order direction (asc/desc) + * @param {boolean} [options.force=false] - Force reload even if already loaded + */ const loadProducts = async (options = {}) => { + const { + search = '', + page = 1, + itemsPerPage = 30, + orderBy = 'name', + orderDir = 'asc', + force = false + } = options + if (loading.value) { return { success: true, - data: { items: products.value, total: total.value }, - } - } - if (loaded.value && !options.force) { - return { - success: true, - data: { items: products.value, total: total.value }, + data: { items: products.value, total: total.value, page, itemsPerPage }, } } loading.value = true error.value = null try { - const result = await get('/products?itemsPerPage=100') + const params = new URLSearchParams() + params.set('itemsPerPage', String(itemsPerPage)) + params.set('page', String(page)) + + if (search && search.trim()) { + params.set('name', search.trim()) + } + + params.set(`order[${orderBy}]`, orderDir) + + const result = await get(`/products?${params.toString()}`) if (result.success) { const items = extractCollection(result.data) const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item))) products.value = enrichedItems - total.value = typeof result.data?.totalItems === 'number' - ? result.data.totalItems - : items.length + total.value = extractTotal(result.data, items.length) loaded.value = true + return { + success: true, + data: { + items: enrichedItems, + total: total.value, + page, + itemsPerPage + } + } } else if (result.error) { error.value = result.error showError(`Impossible de charger les produits: ${result.error}`) diff --git a/app/pages/component-catalog.vue b/app/pages/component-catalog.vue index 83321ec..a80f86c 100644 --- a/app/pages/component-catalog.vue +++ b/app/pages/component-catalog.vue @@ -35,6 +35,7 @@ type="text" class="input input-bordered input-sm w-full mt-1" placeholder="Nom ou référence…" + @input="debouncedSearch" />
      @@ -48,6 +49,7 @@ id="component-catalog-sort" v-model="sortField" class="select select-bordered select-sm" + @change="handleSortChange" > @@ -64,14 +66,33 @@ id="component-catalog-dir" v-model="sortDirection" class="select select-bordered select-sm" + @change="handleSortChange" >
      +
      + + +

    - {{ visibleComposants.length }} / {{ composantsTotal }} résultat{{ visibleComposants.length > 1 ? 's' : '' }} + {{ composantsOnPage }} / {{ composantsTotal }} résultat{{ composantsTotal > 1 ? 's' : '' }}

    @@ -83,54 +104,62 @@ Aucun composant n'a encore été créé.

    -

    +

    Aucun composant ne correspond à votre recherche.

    -
    - - - - - - - - - - - - - - - - - - - -
    AperçuNomRéférenceType de composantActions
    - - {{ component.name || 'Composant sans nom' }}{{ component.reference || '—' }}{{ resolveComponentType(component) }} -
    - - Modifier - - -
    -
    -
    +
    @@ -144,13 +173,41 @@ import { useComponentTypes } from '~/composables/useComponentTypes' import { useToast } from '~/composables/useToast' import { usePersistedSort } from '~/composables/usePersistedSort' import DocumentThumbnail from '~/components/DocumentThumbnail.vue' +import Pagination from '~/components/common/Pagination.vue' import { isImageDocument, isPdfDocument } from '~/utils/documentPreview' const { showError } = useToast() -const { composants, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants() +const { composants, total, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants() const { componentTypes, loadComponentTypes } = useComponentTypes() const loadingComposants = computed(() => loadingComposantsRef.value) +// Pagination state +const currentPage = ref(1) +const itemsPerPage = ref(30) +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('') +let searchTimeout: ReturnType | null = null + +const debouncedSearch = () => { + if (searchTimeout) { + clearTimeout(searchTimeout) + } + searchTimeout = setTimeout(() => { + currentPage.value = 1 + fetchComposants() + }, 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) => { @@ -161,13 +218,31 @@ const composantsList = computed(() => { } }) }) -const composantsTotal = computed(() => composantsList.value.length) -const searchTerm = ref('') -const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>( - 'component-catalog', - { field: 'name', direction: 'asc' }, -) +const fetchComposants = async () => { + await loadComposants({ + search: searchTerm.value, + page: currentPage.value, + itemsPerPage: itemsPerPage.value, + orderBy: sortField.value, + orderDir: sortDirection.value + }) +} + +const handlePageChange = (page: number) => { + currentPage.value = page + fetchComposants() +} + +const handleSortChange = () => { + currentPage.value = 1 + fetchComposants() +} + +const handlePerPageChange = () => { + currentPage.value = 1 + fetchComposants() +} const resolvePrimaryDocument = (component: Record) => { const documents = Array.isArray(component?.documents) ? component.documents : [] @@ -230,58 +305,6 @@ const resolveDeleteGuard = (component: Record) => { } } -const resolveComparableName = (component: Record) => { - const toComparable = (value?: string | null) => - (value ?? '').toString().trim().toLowerCase() - - return ( - toComparable(component?.name) || - toComparable(component?.reference) || - toComparable(component?.id) - ) -} - -const resolveComparableDate = (component: Record) => { - const raw = component?.createdAt ?? component?.created_at ?? null - if (!raw) { - return 0 - } - const parsed = new Date(raw).getTime() - return Number.isNaN(parsed) ? 0 : parsed -} - -const visibleComposants = computed(() => { - const term = searchTerm.value.trim().toLowerCase() - const source = composantsList.value || [] - - const filtered = term - ? source.filter((component) => { - const name = (component?.name || '').toLowerCase() - const reference = (component?.reference || '').toLowerCase() - return ( - name.includes(term) || - reference.includes(term) - ) - }) - : [...source] - - const direction = sortDirection.value === 'asc' ? 1 : -1 - - return filtered.sort((a, b) => { - if (sortField.value === 'name') { - return ( - resolveComparableName(a).localeCompare( - resolveComparableName(b), - 'fr', - { sensitivity: 'base' } - ) * direction - ) - } - - return (resolveComparableDate(a) - resolveComparableDate(b)) * direction - }) -}) - const handleDeleteComponent = async (component: Record) => { const { blockingReasons, hasCustomFields } = resolveDeleteGuard(component) @@ -310,11 +333,13 @@ const handleDeleteComponent = async (component: Record) => { } await deleteComposant(component.id) + // Reload current page after deletion + fetchComposants() } onMounted(async () => { await Promise.all([ - loadComposants(), + fetchComposants(), loadComponentTypes() ]) }) diff --git a/app/pages/pieces-catalog.vue b/app/pages/pieces-catalog.vue index 146aa54..08f07d0 100644 --- a/app/pages/pieces-catalog.vue +++ b/app/pages/pieces-catalog.vue @@ -34,6 +34,7 @@ type="text" class="input input-bordered input-sm w-full mt-1" placeholder="Nom ou référence…" + @input="debouncedSearch" />
    @@ -47,6 +48,7 @@ id="piece-catalog-sort" v-model="sortField" class="select select-bordered select-sm" + @change="handleSortChange" > @@ -63,14 +65,33 @@ id="piece-catalog-dir" v-model="sortDirection" class="select select-bordered select-sm" + @change="handleSortChange" >
    +
    + + +

    - {{ visiblePieces.length }} / {{ piecesTotal }} résultat{{ visiblePieces.length > 1 ? 's' : '' }} + {{ piecesOnPage }} / {{ piecesTotal }} résultat{{ piecesTotal > 1 ? 's' : '' }}

    @@ -82,77 +103,85 @@ Aucune pièce n'a encore été créée.

    -

    +

    Aucune pièce ne correspond à votre recherche.

    -
    - - - - - - - - - - - - - - - - - + + + + +
    AperçuNomRéférenceFournisseursType de pièceActions
    - - {{ row.piece.name || 'Pièce sans nom' }}{{ row.piece.reference || '—' }} -
    - +
    + + + + + + + + + + + + + + + + + - - - - -
    AperçuNomRéférenceFournisseursType de pièceActions
    + + {{ row.piece.name || 'Pièce sans nom' }}{{ row.piece.reference || '—' }} +
    - {{ supplier }} - - - +{{ row.suppliers.overflow }} - -
    - -
    {{ resolvePieceType(row.piece) }} -
    - - Modifier - - -
    -
    -
    + + {{ supplier }} + + + +{{ row.suppliers.overflow }} + +
    + +
    {{ resolvePieceType(row.piece) }} +
    + + Modifier + + +
    +
    +
    + + + @@ -160,19 +189,47 @@ diff --git a/app/components/common/SearchSelect.vue b/app/components/common/SearchSelect.vue index ebc1248..c56de77 100644 --- a/app/components/common/SearchSelect.vue +++ b/app/components/common/SearchSelect.vue @@ -122,7 +122,7 @@ const props = defineProps({ } }) -const emit = defineEmits(['update:modelValue']) +const emit = defineEmits(['update:modelValue', 'search']) const searchTerm = ref('') const openDropdown = ref(false) @@ -267,6 +267,7 @@ function handleInput () { if (!openDropdown.value) { openDropdown.value = true } + emit('search', searchTerm.value) } function closeDropdown () { diff --git a/app/pages/component/create.vue b/app/pages/component/create.vue index db90535..4e3b860 100644 --- a/app/pages/component/create.vue +++ b/app/pages/component/create.vue @@ -212,6 +212,9 @@ :pieces-loading="piecesLoading" :products-loading="productsLoading" :components-loading="componentsLoading" + :piece-type-label-map="pieceTypeLabelMap" + :product-type-label-map="productTypeLabelMap" + :component-type-label-map="componentTypeLabelMap" />

    Impossible de générer les emplacements définis par le squelette. @@ -349,7 +352,9 @@ import SearchSelect from '~/components/common/SearchSelect.vue' import { useComponentTypes } from '~/composables/useComponentTypes' import { useComposants } from '~/composables/useComposants' import { usePieces } from '~/composables/usePieces' +import { usePieceTypes } from '~/composables/usePieceTypes' import { useProducts } from '~/composables/useProducts' +import { useProductTypes } from '~/composables/useProductTypes' import { useToast } from '~/composables/useToast' import { useCustomFields } from '~/composables/useCustomFields' import { useDocuments } from '~/composables/useDocuments' @@ -372,20 +377,19 @@ const route = useRoute() const router = useRouter() const { componentTypes, loadComponentTypes, loadingComponentTypes } = useComponentTypes() +const { pieceTypes, loadPieceTypes } = usePieceTypes() +const { productTypes, loadProductTypes } = useProductTypes() const { createComposant, composants: componentCatalogRef, - loadComposants, loading: componentsLoading, } = useComposants() const { pieces: pieceCatalogRef, - loadPieces, loading: piecesLoading, } = usePieces() const { products: productCatalogRef, - loadProducts, loading: productsLoading, } = useProducts() const toast = useToast() @@ -414,6 +418,28 @@ const structureDataLoading = computed( () => piecesLoading.value || componentsLoading.value || productsLoading.value, ) +const pieceTypeLabelMap = computed(() => + Object.fromEntries( + (pieceTypes.value || []) + .filter((type: any) => type?.id) + .map((type: any) => [type.id, type.name || type.code || '']), + ), +) +const productTypeLabelMap = computed(() => + Object.fromEntries( + (productTypes.value || []) + .filter((type: any) => type?.id) + .map((type: any) => [type.id, type.name || type.code || '']), + ), +) +const componentTypeLabelMap = computed(() => + Object.fromEntries( + (componentTypes.value || []) + .filter((type: any) => type?.id) + .map((type: any) => [type.id, type.name || type.code || '']), + ), +) + watch( () => route.query.typeId, (value) => { @@ -934,9 +960,8 @@ const submitCreation = async () => { onMounted(async () => { await Promise.allSettled([ loadComponentTypes(), - loadPieces(), - loadComposants(), - loadProducts(), + loadPieceTypes(), + loadProductTypes(), ]) }) From 1f5f1509a95ecfe4e0a5f89beb67695dec9e6fbd Mon Sep 17 00:00:00 2001 From: Matthieu Date: Sat, 24 Jan 2026 00:58:06 +0100 Subject: [PATCH 13/16] wip: machine create skeleton links --- app/pages/component/[id]/edit.vue | 52 +++++++++++- app/pages/component/create.vue | 48 ++++++++++- app/pages/machines/new.vue | 132 +++++++++++++++++++++++++----- 3 files changed, 209 insertions(+), 23 deletions(-) diff --git a/app/pages/component/[id]/edit.vue b/app/pages/component/[id]/edit.vue index b43c711..d0f38a5 100644 --- a/app/pages/component/[id]/edit.vue +++ b/app/pages/component/[id]/edit.vue @@ -400,6 +400,7 @@ import DocumentUpload from '~/components/DocumentUpload.vue' import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue' import { useComponentTypes } from '~/composables/useComponentTypes' import { useComposants } from '~/composables/useComposants' +import { usePieceTypes } from '~/composables/usePieceTypes' import { useCustomFields } from '~/composables/useCustomFields' import { useApi } from '~/composables/useApi' import { useToast } from '~/composables/useToast' @@ -434,6 +435,7 @@ const route = useRoute() const router = useRouter() const { get } = useApi() const { componentTypes, loadComponentTypes } = useComponentTypes() +const { pieceTypes, loadPieceTypes } = usePieceTypes() const { updateComposant } = useComposants() const { ensureConstructeurs } = useConstructeurs() const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields() @@ -500,6 +502,16 @@ const documentPreviewSrc = (document: any) => { } return document.path } + +const fetchedPieceTypeMap = ref>({}) +const pieceTypeLabelMap = computed(() => ({ + ...Object.fromEntries( + (pieceTypes.value || []) + .filter((type: any) => type?.id) + .map((type: any) => [type.id, type.name || type.code || '']), + ), + ...fetchedPieceTypeMap.value, +})) const documentThumbnailClass = (document: any) => { if (shouldInlinePdf(document) || (isImageDocument(document) && document?.path)) { return 'h-24 w-20' @@ -1023,6 +1035,8 @@ const resolvePieceLabel = (piece: Record) => { parts.push(piece.typePiece.name) } else if (piece.typePieceLabel) { parts.push(piece.typePieceLabel) + } else if (piece.typePieceId && pieceTypeLabelMap.value[piece.typePieceId]) { + parts.push(pieceTypeLabelMap.value[piece.typePieceId]) } else if (piece.typePiece?.code) { parts.push(`Famille ${piece.typePiece.code}`) } else if (piece.familyCode) { @@ -1033,6 +1047,42 @@ const resolvePieceLabel = (piece: Record) => { return parts.length ? parts.join(' • ') : 'Pièce' } +const fetchPieceTypeNames = async (ids: string[]) => { + const missing = ids.filter((id) => id && !pieceTypeLabelMap.value[id]) + if (!missing.length) { + return + } + const results = await Promise.allSettled( + missing.map((id) => get(`/model_types/${id}`)), + ) + const next = { ...fetchedPieceTypeMap.value } + results.forEach((result, index) => { + if (result.status !== 'fulfilled') { + return + } + const data = result.value?.data + const name = data?.name || data?.code + if (name) { + next[missing[index]] = name + } + }) + fetchedPieceTypeMap.value = next +} + +watch( + selectedTypeStructure, + (structure) => { + const ids = getStructurePieces(structure) + .map((piece: any) => piece?.typePieceId) + .filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0) + if (!ids.length) { + return + } + fetchPieceTypeNames(Array.from(new Set(ids))).catch(() => {}) + }, + { immediate: true }, +) + const resolveSubcomponentLabel = (node: Record) => { const parts: string[] = [] if (node.alias) { @@ -1158,7 +1208,7 @@ const saveCustomFieldValues = async (updatedComponent: any) => { } onMounted(async () => { - await Promise.allSettled([loadComponentTypes(), fetchComponent()]) + await Promise.allSettled([loadComponentTypes(), loadPieceTypes(), fetchComponent()]) loading.value = false if (component.value?.id) { await refreshDocuments() diff --git a/app/pages/component/create.vue b/app/pages/component/create.vue index 4e3b860..1e024ef 100644 --- a/app/pages/component/create.vue +++ b/app/pages/component/create.vue @@ -355,6 +355,7 @@ import { usePieces } from '~/composables/usePieces' import { usePieceTypes } from '~/composables/usePieceTypes' import { useProducts } from '~/composables/useProducts' import { useProductTypes } from '~/composables/useProductTypes' +import { useApi } from '~/composables/useApi' import { useToast } from '~/composables/useToast' import { useCustomFields } from '~/composables/useCustomFields' import { useDocuments } from '~/composables/useDocuments' @@ -375,6 +376,7 @@ interface ComponentCatalogType extends ModelType { const route = useRoute() const router = useRouter() +const { get } = useApi() const { componentTypes, loadComponentTypes, loadingComponentTypes } = useComponentTypes() const { pieceTypes, loadPieceTypes } = usePieceTypes() @@ -418,13 +420,15 @@ const structureDataLoading = computed( () => piecesLoading.value || componentsLoading.value || productsLoading.value, ) -const pieceTypeLabelMap = computed(() => - Object.fromEntries( +const fetchedPieceTypeMap = ref>({}) +const pieceTypeLabelMap = computed(() => ({ + ...Object.fromEntries( (pieceTypes.value || []) .filter((type: any) => type?.id) .map((type: any) => [type.id, type.name || type.code || '']), ), -) + ...fetchedPieceTypeMap.value, +})) const productTypeLabelMap = computed(() => Object.fromEntries( (productTypes.value || []) @@ -804,6 +808,8 @@ const resolvePieceLabel = (piece: Record) => { parts.push(piece.typePiece.name) } else if (piece.typePieceLabel) { parts.push(piece.typePieceLabel) + } else if (piece.typePieceId && pieceTypeLabelMap.value[piece.typePieceId]) { + parts.push(pieceTypeLabelMap.value[piece.typePieceId]) } else if (piece.typePiece?.code) { parts.push(`Famille ${piece.typePiece.code}`) } else if (piece.familyCode) { @@ -814,6 +820,42 @@ const resolvePieceLabel = (piece: Record) => { return parts.length ? parts.join(' • ') : 'Pièce' } +const fetchPieceTypeNames = async (ids: string[]) => { + const missing = ids.filter((id) => id && !pieceTypeLabelMap.value[id]) + if (!missing.length) { + return + } + const results = await Promise.allSettled( + missing.map((id) => get(`/model_types/${id}`)), + ) + const next = { ...fetchedPieceTypeMap.value } + results.forEach((result, index) => { + if (result.status !== 'fulfilled') { + return + } + const data = result.value?.data + const name = data?.name || data?.code + if (name) { + next[missing[index]] = name + } + }) + fetchedPieceTypeMap.value = next +} + +watch( + selectedTypeStructure, + (structure) => { + const ids = getStructurePieces(structure) + .map((piece: any) => piece?.typePieceId) + .filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0) + if (!ids.length) { + return + } + fetchPieceTypeNames(Array.from(new Set(ids))).catch(() => {}) + }, + { immediate: true }, +) + const resolveProductLabel = (product: Record) => { const parts: string[] = [] if (product.role) { diff --git a/app/pages/machines/new.vue b/app/pages/machines/new.vue index c88be5f..7fbe50c 100644 --- a/app/pages/machines/new.vue +++ b/app/pages/machines/new.vue @@ -273,18 +273,19 @@

    Aucune pièce disponible pour cette famille. @@ -743,6 +744,7 @@ import { useMachineTypesApi } from '~/composables/useMachineTypesApi' import { useComposants } from '~/composables/useComposants' import { usePieces } from '~/composables/usePieces' import { useProducts } from '~/composables/useProducts' +import { useApi } from '~/composables/useApi' import { useToast } from '~/composables/useToast' import { sanitizeDefinitionOverrides } from '~/shared/modelUtils' import SearchSelect from '~/components/common/SearchSelect.vue' @@ -754,12 +756,13 @@ import IconLucideAlertTriangle from '~icons/lucide/alert-triangle' import IconLucideCheckCircle2 from '~icons/lucide/check-circle-2' import IconLucideCircle from '~icons/lucide/circle' -const { createMachine, createMachineFromType } = useMachines() +const { createMachine, createMachineFromType, reconfigureSkeleton } = useMachines() const { sites, loadSites } = useSites() const { machineTypes, loadMachineTypes, loading: machineTypesLoading } = useMachineTypesApi() const { composants, loadComposants, loading: composantsLoading } = useComposants() const { pieces, loadPieces, loading: piecesLoading } = usePieces() const { products, loadProducts, loading: productsLoading } = useProducts() +const { get } = useApi() const toast = useToast() const submitting = ref(false) @@ -842,6 +845,85 @@ const productById = computed(() => { return map }) +const pieceOptionsByKey = ref({}) +const pieceLoadingByKey = ref({}) + +const extractCollection = (payload) => { + if (Array.isArray(payload)) { + return payload + } + if (Array.isArray(payload?.member)) { + return payload.member + } + if (Array.isArray(payload?.['hydra:member'])) { + return payload['hydra:member'] + } + if (Array.isArray(payload?.data)) { + return payload.data + } + return [] +} + +const getPieceKey = (requirement, entryIndex) => `${requirement?.id || 'req'}:${entryIndex}` + +const findPieceInCachedOptions = (id) => { + if (!id) { + return null + } + const buckets = Object.values(pieceOptionsByKey.value || {}) + for (const bucket of buckets) { + if (!Array.isArray(bucket)) { + continue + } + const found = bucket.find((piece) => piece?.id === id) + if (found) { + return found + } + } + return null +} + +const cachePieceIfMissing = (piece) => { + if (!piece?.id) { + return + } + if (pieceById.value.has(piece.id)) { + return + } + const current = Array.isArray(pieces.value) ? pieces.value : [] + pieces.value = [...current, piece] +} + +const fetchPieceOptions = async (requirement, entryIndex, term = '') => { + const key = getPieceKey(requirement, entryIndex) + if (pieceLoadingByKey.value[key]) { + return + } + + const requirementTypeId = requirement?.typePieceId || requirement?.typePiece?.id || null + const params = new URLSearchParams() + params.set('itemsPerPage', '50') + if (term && term.trim()) { + params.set('name', term.trim()) + } + if (requirementTypeId) { + params.set('typePiece', `/api/model_types/${requirementTypeId}`) + } + + pieceLoadingByKey.value = { ...pieceLoadingByKey.value, [key]: true } + try { + const result = await get(`/pieces?${params.toString()}`) + if (result.success) { + pieceOptionsByKey.value = { + ...pieceOptionsByKey.value, + [key]: extractCollection(result.data) + } + } + } finally { + pieceLoadingByKey.value = { ...pieceLoadingByKey.value, [key]: false } + } +} + const isPlainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value) const toTrimmedString = (value) => { @@ -1077,7 +1159,12 @@ const getComponentOptions = (requirement, currentEntry) => { }) } -const getPieceOptions = (requirement, currentEntry) => { +const getPieceOptions = (requirement, currentEntry, entryIndex) => { + const key = getPieceKey(requirement, entryIndex) + const cached = pieceOptionsByKey.value[key] + if (cached) { + return cached + } const requirementTypeId = requirement?.typePieceId || requirement?.typePiece?.id || null const usedIds = new Set( selectedPieceIds.value.filter((id) => id && (!currentEntry || id !== currentEntry.pieceId)), @@ -1241,8 +1328,11 @@ const setPieceRequirementPiece = (requirement, index, pieceId) => { if (!entry) return entry.pieceId = pieceId || null if (pieceId) { - const piece = findPieceById(pieceId) + const piece = findPieceById(pieceId) || findPieceInCachedOptions(pieceId) entry.typePieceId = piece?.typePieceId || requirement?.typePieceId || null + if (piece) { + cachePieceIfMissing(piece) + } } else { entry.typePieceId = requirement?.typePieceId || null } @@ -1259,7 +1349,7 @@ const findPieceById = (id) => { if (!id) { return null } - return pieceById.value.get(id) || null + return pieceById.value.get(id) || findPieceInCachedOptions(id) || null } const findProductById = (id) => { @@ -1519,6 +1609,7 @@ const addPieceSelectionEntry = (requirement) => { ...entries, createPieceSelectionEntry(requirement), ] + fetchPieceOptions(requirement, entries.length).catch(() => {}) } const removePieceSelectionEntry = (requirementId, index) => { @@ -2096,6 +2187,9 @@ const initializeRequirementSelections = (type) => { const initialCount = Math.max(min, requirement.required ? 1 : 0) if (initialCount > 0) { pieceRequirementSelections[requirement.id] = Array.from({ length: initialCount }, () => createPieceSelectionEntry(requirement)) + pieceRequirementSelections[requirement.id].forEach((_, index) => { + fetchPieceOptions(requirement, index).catch(() => {}) + }) } else { pieceRequirementSelections[requirement.id] = [] } @@ -2158,22 +2252,22 @@ const finalizeMachineCreation = async () => { productLinks = validationResult.productLinks } - const payload = { - ...baseMachineData, - ...(hasRequirements - ? { - componentLinks, - pieceLinks, - productLinks - } - : {}) - } - const result = hasRequirements - ? await createMachine(payload) + ? await createMachine(baseMachineData) : await createMachineFromType(baseMachineData, type) if (result.success) { + if (hasRequirements && result.data?.id) { + const skeletonResult = await reconfigureSkeleton(result.data.id, { + componentLinks, + pieceLinks, + productLinks, + }) + if (!skeletonResult.success) { + toast.showError(skeletonResult.error || 'Impossible d\'enregistrer les pièces/composants') + return + } + } newMachine.name = '' newMachine.siteId = '' newMachine.typeMachineId = '' From 55739fe50f5857809a82e933e045fb76b99d260d Mon Sep 17 00:00:00 2001 From: Matthieu Date: Sun, 25 Jan 2026 09:46:11 +0100 Subject: [PATCH 14/16] Fix machines display on overview; disable inline PDF thumbnails --- app/components/DocumentThumbnail.vue | 19 ++------- app/pages/index.vue | 61 ++++++++++++++++++++++++---- 2 files changed, 57 insertions(+), 23 deletions(-) diff --git a/app/components/DocumentThumbnail.vue b/app/components/DocumentThumbnail.vue index d35516b..b26d27e 100644 --- a/app/components/DocumentThumbnail.vue +++ b/app/components/DocumentThumbnail.vue @@ -12,12 +12,6 @@ loading="lazy" decoding="async" > -