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 @@
+
+
+
+
+
+
+
+
+
+
+ ...
+
+ {{ page }}
+
+
+
+
+
+
+
+
+
+
+
+
+
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"
>
Nom
Date de création
@@ -64,14 +66,33 @@
id="component-catalog-dir"
v-model="sortDirection"
class="select select-bordered select-sm"
+ @change="handleSortChange"
>
Ascendant
Descendant
+
+
+ Par page
+
+
+ 20
+ 50
+ 100
+
+
- {{ 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çu
- Nom
- Référence
- Type de composant
- Actions
-
-
-
-
-
-
-
- {{ component.name || 'Composant sans nom' }}
- {{ component.reference || '—' }}
- {{ resolveComponentType(component) }}
-
-
-
- Modifier
-
-
- Supprimer
-
-
-
-
-
-
-
+
+
+
+
+
+ Aperçu
+ Nom
+ Référence
+ Type de composant
+ Actions
+
+
+
+
+
+
+
+ {{ component.name || 'Composant sans nom' }}
+ {{ component.reference || '—' }}
+ {{ resolveComponentType(component) }}
+
+
+
+ Modifier
+
+
+ Supprimer
+
+
+
+
+
+
+
+
+
+
@@ -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"
>
Nom
Date de création
@@ -63,14 +65,33 @@
id="piece-catalog-dir"
v-model="sortDirection"
class="select select-bordered select-sm"
+ @change="handleSortChange"
>
Ascendant
Descendant
+
+
+ Par page
+
+
+ 20
+ 50
+ 100
+
+
- {{ 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çu
- Nom
- Référence
- Fournisseurs
- Type de pièce
- Actions
-
-
-
-
-
-
-
- {{ row.piece.name || 'Pièce sans nom' }}
- {{ row.piece.reference || '—' }}
-
-
-
+
+
+
+
+ Aperçu
+ Nom
+ Référence
+ Fournisseurs
+ Type de pièce
+ Actions
+
+
+
+
+
+
+
+ {{ row.piece.name || 'Pièce sans nom' }}
+ {{ row.piece.reference || '—' }}
+
+
- {{ supplier }}
-
-
- +{{ row.suppliers.overflow }}
-
-
- —
-
- {{ resolvePieceType(row.piece) }}
-
-
-
- Modifier
-
-
- Supprimer
-
-
-
-
-
-
-
+
+ {{ supplier }}
+
+
+ +{{ row.suppliers.overflow }}
+
+
+ —
+
+ {{ resolvePieceType(row.piece) }}
+
+
+
+ Modifier
+
+
+ Supprimer
+
+
+
+
+
+
+
+
+
+
@@ -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 @@
fetchPieceOptions(requirement, entryIndex, term)"
@update:modelValue="setPieceRequirementPiece(requirement, entryIndex, $event || '')"
/>
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"
>
-
();
-const PDF_PREVIEW_MAX_BYTES = 5 * 1024 * 1024;
-
const normalizedDocument = computed(() => props.document ?? null);
const canRenderImage = computed(() => {
@@ -64,14 +56,9 @@ const canRenderImage = computed(() => {
});
const canRenderPdf = computed(() => {
- const doc = normalizedDocument.value;
- if (!doc || !isPdfDocument(doc) || !doc.path) {
- return false;
- }
- if (typeof doc.size === 'number' && doc.size > PDF_PREVIEW_MAX_BYTES) {
- return false;
- }
- return true;
+ // Rendering many PDF iframes in a list is very heavy for the browser.
+ // We intentionally disable inline PDF previews and fall back to an icon.
+ return false;
});
const appendPdfViewerParams = (src: string) => {
diff --git a/app/pages/index.vue b/app/pages/index.vue
index 67711a3..8c1eae2 100644
--- a/app/pages/index.vue
+++ b/app/pages/index.vue
@@ -472,10 +472,11 @@ import IconLucideChevronDown from '~icons/lucide/chevron-down'
import IconLucideSettings2 from '~icons/lucide/settings-2'
import IconLucideTag from '~icons/lucide/tag'
import { formatPhone } from '~/utils/formatters/phone'
+import { extractRelationId } from '~/shared/apiRelations'
const { sites, loading, loadSites, createSite } = useSites()
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
-const { createMachineFromType, deleteMachine } = useMachines()
+const { machines, loadMachines, createMachineFromType, deleteMachine } = useMachines()
// Data
const showAddSiteModal = ref(false)
@@ -517,8 +518,50 @@ const categories = computed(() => {
return Array.from(cats)
})
+const machinesWithType = computed(() => {
+ return machines.value.map((machine) => {
+ const resolvedTypeMachineId = machine.typeMachineId || extractRelationId(machine.typeMachine)
+ const resolvedTypeMachine = resolvedTypeMachineId
+ ? machineTypes.value.find(type => type.id === resolvedTypeMachineId) || null
+ : null
+
+ return {
+ ...machine,
+ typeMachineId: resolvedTypeMachineId || machine.typeMachineId,
+ typeMachine:
+ machine.typeMachine && typeof machine.typeMachine === 'object'
+ ? machine.typeMachine
+ : resolvedTypeMachine
+ }
+ })
+})
+
+const machinesBySiteId = computed(() => {
+ const map = new Map()
+
+ machinesWithType.value.forEach((machine) => {
+ const siteId = machine.siteId || extractRelationId(machine.site)
+ if (!siteId) { return }
+
+ if (!map.has(siteId)) {
+ map.set(siteId, [])
+ }
+
+ map.get(siteId).push(machine)
+ })
+
+ return map
+})
+
+const sitesWithMachines = computed(() => {
+ return sites.value.map((site) => ({
+ ...site,
+ machines: machinesBySiteId.value.get(site.id) || []
+ }))
+})
+
const totalMachines = computed(() => {
- return sites.value.reduce((total, site) => {
+ return sitesWithMachines.value.reduce((total, site) => {
return total + (site.machines?.length || 0)
}, 0)
})
@@ -532,7 +575,7 @@ const formatPhoneDisplay = (value) => {
}
const filteredSites = computed(() => {
- let filtered = sites.value
+ let filtered = sitesWithMachines.value
// Filtrer par terme de recherche
if (searchTerm.value) {
@@ -551,9 +594,11 @@ const filteredSites = computed(() => {
})
const machineMatches = site.machines?.some(
- machine =>
- machine.name.toLowerCase().includes(lowerTerm) ||
- machine.reference?.toLowerCase().includes(lowerTerm)
+ machine => {
+ const name = (machine.name || '').toLowerCase()
+ const reference = (machine.reference || '').toLowerCase()
+ return name.includes(lowerTerm) || reference.includes(lowerTerm)
+ }
)
return siteMatches || machineMatches
@@ -637,6 +682,7 @@ const handleCreateMachine = async () => {
newMachine.typeMachineId = ''
newMachine.reference = ''
showAddMachineModal.value = false
+ await loadMachines()
}
}
@@ -671,6 +717,7 @@ const confirmDeleteMachine = async (machine) => {
const result = await deleteMachine(machine.id)
if (result.success) {
showSuccess(`Machine "${machine.name}" supprimée avec succès`)
+ await loadMachines()
} else {
showError(`Erreur lors de la suppression: ${result.error}`)
}
@@ -698,6 +745,6 @@ const getCategoryBadgeClass = (category) => {
// Lifecycle
onMounted(async () => {
- await Promise.all([loadSites(), loadMachineTypes()])
+ await Promise.all([loadSites(), loadMachineTypes(), loadMachines()])
})
From b27662d2bcd1a36896c19332bd8ac8d7420b9d62 Mon Sep 17 00:00:00 2001
From: Matthieu
Date: Sun, 25 Jan 2026 11:40:29 +0100
Subject: [PATCH 15/16] Show component selections and support multi product
requirements
---
app/composables/usePieces.js | 9 +
app/pages/component/[id]/edit.vue | 268 +++++++++++++++++++++++++++++-
app/pages/pieces/[id]/edit.vue | 120 ++++++++++---
app/pages/pieces/create.vue | 96 ++++++++---
4 files changed, 438 insertions(+), 55 deletions(-)
diff --git a/app/composables/usePieces.js b/app/composables/usePieces.js
index 037ff33..13ec585 100644
--- a/app/composables/usePieces.js
+++ b/app/composables/usePieces.js
@@ -56,6 +56,15 @@ export function usePieces () {
piece.productId = productId
}
}
+ const productIds = Array.isArray(piece.productIds) ? piece.productIds.filter(Boolean) : []
+ if (productIds.length === 0 && piece.productId) {
+ piece.productIds = [piece.productId]
+ } else if (productIds.length > 0) {
+ piece.productIds = productIds.map((id) => String(id))
+ if (!piece.productId) {
+ piece.productId = piece.productIds[0] || null
+ }
+ }
const ids = uniqueConstructeurIds(
piece.constructeurIds,
piece.constructeurs,
diff --git a/app/pages/component/[id]/edit.vue b/app/pages/component/[id]/edit.vue
index d0f38a5..d550d27 100644
--- a/app/pages/component/[id]/edit.vue
+++ b/app/pages/component/[id]/edit.vue
@@ -176,6 +176,18 @@
+
+
Produits imposés
+
+
+ {{ resolveProductLabel(product) }}
+
+
+
+
Ce squelette ne définit pas encore de pièces, sous-composants ou valeurs par défaut.
@@ -198,6 +210,50 @@
+
+
+
+
+
+
Pièces choisies
+
+
+ {{ entry.resolvedName }}
+ — {{ entry.requirementLabel }}
+
+
+
+
+
+
Produits choisis
+
+
+ {{ entry.resolvedName }}
+ — {{ entry.requirementLabel }}
+
+
+
+
+
+
Sous-composants choisis
+
+
+ {{ entry.resolvedName }}
+ — {{ entry.requirementLabel }}
+
+
+
+
+
+
Champs personnalisés
@@ -401,6 +457,9 @@ import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useComposants } from '~/composables/useComposants'
import { usePieceTypes } from '~/composables/usePieceTypes'
+import { useProductTypes } from '~/composables/useProductTypes'
+import { usePieces } from '~/composables/usePieces'
+import { useProducts } from '~/composables/useProducts'
import { useCustomFields } from '~/composables/useCustomFields'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
@@ -436,7 +495,10 @@ const router = useRouter()
const { get } = useApi()
const { componentTypes, loadComponentTypes } = useComponentTypes()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
-const { updateComposant } = useComposants()
+const { productTypes, loadProductTypes } = useProductTypes()
+const { updateComposant, loadComposants, composants: componentCatalogRef } = useComposants()
+const { pieces, loadPieces } = usePieces()
+const { products, loadProducts } = useProducts()
const { ensureConstructeurs } = useConstructeurs()
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
const toast = useToast()
@@ -512,6 +574,36 @@ const pieceTypeLabelMap = computed(() => ({
),
...fetchedPieceTypeMap.value,
}))
+const fetchedProductTypeMap = ref>({})
+const productTypeLabelMap = computed(() => ({
+ ...Object.fromEntries(
+ (productTypes.value || [])
+ .filter((type: any) => type?.id)
+ .map((type: any) => [type.id, type.name || type.code || '']),
+ ),
+ ...fetchedProductTypeMap.value,
+}))
+const pieceCatalogMap = computed(() =>
+ new Map(
+ (pieces.value || [])
+ .filter((item: any) => item?.id)
+ .map((item: any) => [String(item.id), item]),
+ ),
+)
+const productCatalogMap = computed(() =>
+ new Map(
+ (products.value || [])
+ .filter((item: any) => item?.id)
+ .map((item: any) => [String(item.id), item]),
+ ),
+)
+const componentCatalogMap = computed(() =>
+ new Map(
+ (componentCatalogRef.value || [])
+ .filter((item: any) => item?.id)
+ .map((item: any) => [String(item.id), item]),
+ ),
+)
const documentThumbnailClass = (document: any) => {
if (shouldInlinePdf(document) || (isImageDocument(document) && document?.path)) {
return 'h-24 w-20'
@@ -1018,6 +1110,10 @@ const getStructurePieces = (structure: ComponentModelStructure | null) => {
return Array.isArray(structure?.pieces) ? structure.pieces : []
}
+const getStructureProducts = (structure: ComponentModelStructure | null) => {
+ return Array.isArray(structure?.products) ? structure.products : []
+}
+
const getStructureSubcomponents = (structure: ComponentModelStructure | null) => {
if (Array.isArray(structure?.subcomponents)) {
return structure.subcomponents
@@ -1026,6 +1122,9 @@ const getStructureSubcomponents = (structure: ComponentModelStructure | null) =>
return Array.isArray(legacy) ? legacy : []
}
+const isNonEmptyString = (value: unknown): value is string =>
+ typeof value === 'string' && value.trim().length > 0
+
const resolvePieceLabel = (piece: Record) => {
const parts: string[] = []
if (piece.role) {
@@ -1069,16 +1168,65 @@ const fetchPieceTypeNames = async (ids: string[]) => {
fetchedPieceTypeMap.value = next
}
+const resolveProductLabel = (product: Record) => {
+ const parts: string[] = []
+ if (product.role) {
+ parts.push(product.role)
+ }
+ if (product.typeProduct?.name) {
+ parts.push(product.typeProduct.name)
+ } else if (product.typeProductLabel) {
+ parts.push(product.typeProductLabel)
+ } else if (product.typeProductId && productTypeLabelMap.value[product.typeProductId]) {
+ parts.push(productTypeLabelMap.value[product.typeProductId])
+ } else if (product.typeProduct?.code) {
+ parts.push(`Catégorie ${product.typeProduct.code}`)
+ } else if (product.familyCode) {
+ parts.push(`Catégorie ${product.familyCode}`)
+ } else if (product.typeProductId) {
+ parts.push(`#${product.typeProductId}`)
+ }
+ return parts.length ? parts.join(' • ') : 'Produit'
+}
+
+const fetchProductTypeNames = async (ids: string[]) => {
+ const missing = ids.filter((id) => id && !productTypeLabelMap.value[id])
+ if (!missing.length) {
+ return
+ }
+ const results = await Promise.allSettled(
+ missing.map((id) => get(`/model_types/${id}`)),
+ )
+ const next = { ...fetchedProductTypeMap.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
+ }
+ })
+ fetchedProductTypeMap.value = next
+}
+
watch(
selectedTypeStructure,
(structure) => {
- const ids = getStructurePieces(structure)
+ const pieceIds = getStructurePieces(structure)
.map((piece: any) => piece?.typePieceId)
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
- if (!ids.length) {
- return
+ if (pieceIds.length) {
+ fetchPieceTypeNames(Array.from(new Set(pieceIds))).catch(() => {})
+ }
+
+ const productIds = getStructureProducts(structure)
+ .map((product: any) => product?.typeProductId)
+ .filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
+ if (productIds.length) {
+ fetchProductTypeNames(Array.from(new Set(productIds))).catch(() => {})
}
- fetchPieceTypeNames(Array.from(new Set(ids))).catch(() => {})
},
{ immediate: true },
)
@@ -1109,6 +1257,104 @@ const resolveSubcomponentLabel = (node: Record) => {
return parts.length ? parts.join(' • ') : 'Sous-composant'
}
+type SelectionEntry = {
+ id: string
+ path: string
+ requirementLabel: string
+ resolvedName: string
+}
+
+const collectStructureSelections = (root: any): {
+ pieces: SelectionEntry[]
+ products: SelectionEntry[]
+ components: SelectionEntry[]
+} => {
+ const piecesSelected: SelectionEntry[] = []
+ const productsSelected: SelectionEntry[] = []
+ const componentsSelected: SelectionEntry[] = []
+
+ if (!root || typeof root !== 'object') {
+ return { pieces: piecesSelected, products: productsSelected, components: componentsSelected }
+ }
+
+ const visitNode = (node: any, fallbackPath = 'racine') => {
+ if (!node || typeof node !== 'object') {
+ return
+ }
+
+ const nodePath = isNonEmptyString(node.path) ? node.path : fallbackPath
+
+ const nodePieces = Array.isArray(node.pieces) ? node.pieces : []
+ nodePieces.forEach((entry: any, index: number) => {
+ const selectedId = entry?.selectedPieceId
+ if (!isNonEmptyString(selectedId)) {
+ return
+ }
+ const definition = entry?.definition ?? entry
+ const catalogPiece = pieceCatalogMap.value.get(selectedId)
+ piecesSelected.push({
+ id: selectedId,
+ path: isNonEmptyString(entry?.path) ? entry.path : `${nodePath}:piece-${index + 1}`,
+ requirementLabel: resolvePieceLabel(definition),
+ resolvedName: catalogPiece?.name || selectedId,
+ })
+ })
+
+ const nodeProducts = Array.isArray(node.products) ? node.products : []
+ nodeProducts.forEach((entry: any, index: number) => {
+ const selectedId = entry?.selectedProductId
+ if (!isNonEmptyString(selectedId)) {
+ return
+ }
+ const definition = entry?.definition ?? entry
+ const catalogProduct = productCatalogMap.value.get(selectedId)
+ productsSelected.push({
+ id: selectedId,
+ path: isNonEmptyString(entry?.path) ? entry.path : `${nodePath}:product-${index + 1}`,
+ requirementLabel: resolveProductLabel(definition),
+ resolvedName: catalogProduct?.name || selectedId,
+ })
+ })
+
+ const nodeChildren = Array.isArray(node.subcomponents)
+ ? node.subcomponents
+ : Array.isArray(node.subComponents)
+ ? node.subComponents
+ : []
+
+ nodeChildren.forEach((child: any, index: number) => {
+ const selectedId = child?.selectedComponentId
+ if (isNonEmptyString(selectedId)) {
+ const definition = child?.definition ?? child
+ const catalogComponent = componentCatalogMap.value.get(selectedId)
+ componentsSelected.push({
+ id: selectedId,
+ path: isNonEmptyString(child?.path) ? child.path : `${nodePath}:subcomponent-${index + 1}`,
+ requirementLabel: resolveSubcomponentLabel(definition),
+ resolvedName: catalogComponent?.name || selectedId,
+ })
+ }
+
+ visitNode(child, isNonEmptyString(child?.path) ? child.path : `${nodePath}:subcomponent-${index + 1}`)
+ })
+ }
+
+ visitNode(root, isNonEmptyString(root?.path) ? root.path : 'racine')
+
+ return { pieces: piecesSelected, products: productsSelected, components: componentsSelected }
+}
+
+const structureSelections = computed(() => {
+ const selections = collectStructureSelections(component.value?.structure)
+ const total =
+ selections.pieces.length + selections.products.length + selections.components.length
+ return {
+ ...selections,
+ total,
+ hasAny: total > 0,
+ }
+})
+
const buildCustomFieldMetadata = (field: CustomFieldInput) => ({
customFieldName: field.name,
customFieldType: field.type,
@@ -1208,7 +1454,15 @@ const saveCustomFieldValues = async (updatedComponent: any) => {
}
onMounted(async () => {
- await Promise.allSettled([loadComponentTypes(), loadPieceTypes(), fetchComponent()])
+ await Promise.allSettled([
+ loadComponentTypes(),
+ loadPieceTypes(),
+ loadProductTypes(),
+ loadPieces({ itemsPerPage: 500 }),
+ loadProducts({ itemsPerPage: 500, force: true }),
+ loadComposants({ itemsPerPage: 500 }),
+ fetchComponent(),
+ ])
loading.value = false
if (component.value?.id) {
await refreshDocuments()
diff --git a/app/pages/pieces/[id]/edit.vue b/app/pages/pieces/[id]/edit.vue
index 5df0bed..44ddbe6 100644
--- a/app/pages/pieces/[id]/edit.vue
+++ b/app/pages/pieces/[id]/edit.vue
@@ -146,12 +146,26 @@
{{ description }}
-
+
@@ -448,8 +462,8 @@ const editionForm = reactive({
reference: '' as string,
constructeurIds: [] as string[],
prix: '' as string,
- productId: null as string | null,
})
+const productSelections = ref<(string | null)[]>([])
const customFieldInputs = ref
([])
const documentIcon = (doc: any) =>
@@ -592,14 +606,18 @@ const selectedType = computed(() => {
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
})
+const getStructureProducts = (structure: PieceModelStructure | null) =>
+ Array.isArray(structure?.products) ? structure.products : []
+
+const getStructureCustomFields = (structure: PieceModelStructure | null) =>
+ Array.isArray(structure?.customFields) ? structure.customFields : []
+
const structureProducts = computed(() =>
getStructureProducts(resolvedStructure.value),
)
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
-const primaryProductRequirement = computed(() => structureProducts.value[0] ?? null)
-
const describeProductRequirement = (requirement: PieceModelProduct, index: number) => {
if (!requirement) {
return `Produit ${index + 1}`
@@ -628,6 +646,50 @@ const productRequirementDescriptions = computed(() =>
),
)
+const ensureProductSelections = (count: number) => {
+ const next = Array.from({ length: count }, (_, index) => productSelections.value[index] ?? null)
+ productSelections.value = next
+}
+
+let pendingProductIds: string[] = []
+
+const productRequirementEntries = computed(() =>
+ structureProducts.value.map((requirement, index) => ({
+ index,
+ key: `piece-product-requirement-${index}-${requirement?.typeProductId || 'any'}`,
+ label: describeProductRequirement(requirement, index),
+ typeProductId: requirement?.typeProductId ? String(requirement.typeProductId) : null,
+ })),
+)
+
+const productSelectionsFilled = computed(() =>
+ !requiresProductSelection.value ||
+ productRequirementEntries.value.every((entry) => {
+ const value = productSelections.value[entry.index]
+ return typeof value === 'string' && value.trim().length > 0
+ }),
+)
+
+const setProductSelection = (index: number, value: string | null) => {
+ const normalized = typeof value === 'string' ? value : null
+ const next = [...productSelections.value]
+ next[index] = normalized
+ productSelections.value = next
+}
+
+watch(structureProducts, (products) => {
+ ensureProductSelections(products.length)
+ if (!pendingProductIds.length || products.length === 0) {
+ return
+ }
+ const next = Array.from(
+ { length: products.length },
+ (_, index) => pendingProductIds[index] ?? null,
+ )
+ productSelections.value = next
+ pendingProductIds = []
+})
+
const requiredCustomFieldsFilled = computed(() =>
customFieldInputs.value.every((field) => {
if (!field.required) {
@@ -645,7 +707,7 @@ const canSubmit = computed(() =>
piece.value &&
editionForm.name &&
requiredCustomFieldsFilled.value &&
- (!requiresProductSelection.value || editionForm.productId) &&
+ productSelectionsFilled.value &&
!saving.value,
),
)
@@ -730,11 +792,26 @@ watch(
currentPiece.constructeur ? [currentPiece.constructeur] : [],
)
editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : ''
- editionForm.productId = currentPiece.product?.id || currentPiece.productId || null
if (editionForm.constructeurIds.length) {
void ensureConstructeurs(editionForm.constructeurIds)
}
+ const existingProductIds = Array.isArray(currentPiece.productIds) && currentPiece.productIds.length
+ ? currentPiece.productIds.map((id: unknown) => String(id))
+ : currentPiece.product?.id || currentPiece.productId
+ ? [String(currentPiece.product?.id || currentPiece.productId)]
+ : []
+ pendingProductIds = existingProductIds
+ ensureProductSelections(structureProducts.value.length)
+ if (existingProductIds.length && structureProducts.value.length) {
+ const next = Array.from(
+ { length: structureProducts.value.length },
+ (_, index) => existingProductIds[index] ?? null,
+ )
+ productSelections.value = next
+ pendingProductIds = []
+ }
+
refreshCustomFieldInputs(currentType?.structure ?? null, currentPiece.customFieldValues)
initialized = true
@@ -755,6 +832,7 @@ watch(resolvedStructure, (currentStructure) => {
if (!piece.value) {
return
}
+ ensureProductSelections(structureProducts.value.length)
refreshCustomFieldInputs(currentStructure, piece.value.customFieldValues)
})
@@ -763,7 +841,7 @@ const submitEdition = async () => {
return
}
- if (requiresProductSelection.value && !editionForm.productId) {
+ if (!productSelectionsFilled.value) {
toast.showError('Sélectionnez un produit conforme au squelette.')
return
}
@@ -784,11 +862,13 @@ const submitEdition = async () => {
const reference = editionForm.reference.trim()
payload.reference = reference ? reference : null
- const selectedProductId =
- typeof editionForm.productId === 'string'
- ? editionForm.productId.trim()
- : ''
- payload.productId = selectedProductId || null
+ const normalizedProductIds = productRequirementEntries.value
+ .map((entry) => productSelections.value[entry.index])
+ .filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
+ .map((value) => value.trim())
+
+ payload.productIds = normalizedProductIds
+ payload.productId = normalizedProductIds[0] || null
if (rawPrice) {
const parsed = Number(rawPrice)
@@ -981,12 +1061,6 @@ const formatDefaultValue = (type: string, defaultValue: any): string => {
return String(defaultValue)
}
-const getStructureProducts = (structure: PieceModelStructure | null) =>
- Array.isArray(structure?.products) ? structure.products : []
-
-const getStructureCustomFields = (structure: PieceModelStructure | null) =>
- Array.isArray(structure?.customFields) ? structure.customFields : []
-
const buildCustomFieldMetadata = (field: CustomFieldInput) => ({
customFieldName: field.name,
customFieldType: field.type,
diff --git a/app/pages/pieces/create.vue b/app/pages/pieces/create.vue
index 950c65d..b744e2d 100644
--- a/app/pages/pieces/create.vue
+++ b/app/pages/pieces/create.vue
@@ -118,12 +118,26 @@
{{ description }}
-
+
@@ -317,8 +331,8 @@ const creationForm = reactive({
reference: '' as string,
constructeurIds: [] as string[],
prix: '' as string,
- productId: null as string | null,
})
+const productSelections = ref<(string | null)[]>([])
const lastSuggestedName = ref('')
const customFieldInputs = ref([])
@@ -364,14 +378,18 @@ const selectedType = computed(() => {
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
})
+const getStructureCustomFields = (structure: PieceModelStructure | null) =>
+ Array.isArray(structure?.customFields) ? structure.customFields : []
+
+const getStructureProducts = (structure: PieceModelStructure | null) =>
+ Array.isArray(structure?.products) ? structure.products : []
+
const structureProducts = computed(() =>
getStructureProducts(selectedType.value?.structure ?? null),
)
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
-const primaryProductRequirement = computed(() => structureProducts.value[0] ?? null)
-
const describeProductRequirement = (requirement: PieceModelProduct, index: number) => {
if (!requirement) {
return `Produit ${index + 1}`
@@ -400,6 +418,39 @@ const productRequirementDescriptions = computed(() =>
),
)
+const ensureProductSelections = (count: number) => {
+ const next = Array.from({ length: count }, (_, index) => productSelections.value[index] ?? null)
+ productSelections.value = next
+}
+
+const productRequirementEntries = computed(() =>
+ structureProducts.value.map((requirement, index) => ({
+ index,
+ key: `piece-create-product-requirement-${index}-${requirement?.typeProductId || 'any'}`,
+ label: describeProductRequirement(requirement, index),
+ typeProductId: requirement?.typeProductId ? String(requirement.typeProductId) : null,
+ })),
+)
+
+const productSelectionsFilled = computed(() =>
+ !requiresProductSelection.value ||
+ productRequirementEntries.value.every((entry) => {
+ const value = productSelections.value[entry.index]
+ return typeof value === 'string' && value.trim().length > 0
+ }),
+)
+
+const setProductSelection = (index: number, value: string | null) => {
+ const normalized = typeof value === 'string' ? value : null
+ const next = [...productSelections.value]
+ next[index] = normalized
+ productSelections.value = next
+}
+
+watch(structureProducts, (products) => {
+ ensureProductSelections(products.length)
+})
+
watch(selectedType, (type) => {
if (!type) {
clearCreationForm()
@@ -411,7 +462,7 @@ watch(selectedType, (type) => {
}
lastSuggestedName.value = creationForm.name
customFieldInputs.value = normalizeCustomFieldInputs(type.structure)
- creationForm.productId = null
+ productSelections.value = Array.from({ length: structureProducts.value.length }, () => null)
})
const requiredCustomFieldsFilled = computed(() =>
@@ -431,7 +482,7 @@ const canSubmit = computed(() =>
selectedType.value &&
creationForm.name &&
requiredCustomFieldsFilled.value &&
- (!requiresProductSelection.value || creationForm.productId) &&
+ productSelectionsFilled.value &&
!submitting.value,
),
)
@@ -449,18 +500,12 @@ const toFieldString = (value: unknown): string => {
return ''
}
-const getStructureCustomFields = (structure: PieceModelStructure | null) =>
- Array.isArray(structure?.customFields) ? structure.customFields : []
-
-const getStructureProducts = (structure: PieceModelStructure | null) =>
- Array.isArray(structure?.products) ? structure.products : []
-
const clearCreationForm = () => {
creationForm.name = ''
creationForm.reference = ''
creationForm.constructeurIds = []
creationForm.prix = ''
- creationForm.productId = null
+ productSelections.value = []
lastSuggestedName.value = ''
}
@@ -470,7 +515,7 @@ const submitCreation = async () => {
return
}
- if (requiresProductSelection.value && !creationForm.productId) {
+ if (!productSelectionsFilled.value) {
toast.showError('Sélectionnez un produit conforme au squelette.')
return
}
@@ -487,12 +532,13 @@ const submitCreation = async () => {
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
- const selectedProductId =
- typeof creationForm.productId === 'string'
- ? creationForm.productId.trim()
- : ''
- if (selectedProductId) {
- payload.productId = selectedProductId
+ const normalizedProductIds = productRequirementEntries.value
+ .map((entry) => productSelections.value[entry.index])
+ .filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
+ .map((value) => value.trim())
+ if (normalizedProductIds.length) {
+ payload.productIds = normalizedProductIds
+ payload.productId = normalizedProductIds[0]
}
const rawPrice = typeof creationForm.prix === 'string'
From 94239031d69be4bfa9dc4fe07d43404ce8b7a98c Mon Sep 17 00:00:00 2001
From: Matthieu
Date: Sun, 25 Jan 2026 12:01:26 +0100
Subject: [PATCH 16/16] feat: add version system from parent VERSION file
---
nuxt.config.ts | 19 ++++++++++++++++++-
1 file changed, 18 insertions(+), 1 deletion(-)
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 89f61fe..250e09a 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -1,4 +1,21 @@
import tailwindcss from '@tailwindcss/vite'
+import { readFileSync } from 'node:fs'
+import { dirname, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+// Lire la version depuis le fichier VERSION à la racine du projet parent
+const getAppVersion = (): string => {
+ try {
+ const __dirname = dirname(fileURLToPath(import.meta.url))
+ const versionPath = resolve(__dirname, '..', 'VERSION')
+ return readFileSync(versionPath, 'utf-8').trim()
+ } catch {
+ return '0.0.0'
+ }
+}
+
+const appVersion = process.env.NUXT_PUBLIC_APP_VERSION || getAppVersion()
+
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
ssr: false, // Désactive le SSR pour un mode SPA pur (Client-Side Rendering uniquement)
@@ -27,7 +44,7 @@ export default defineNuxtConfig({
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',
+ appVersion: appVersion,
apiTimeout: process.env.NUXT_PUBLIC_API_TIMEOUT || '30000',
requestTimeout: process.env.NUXT_PUBLIC_REQUEST_TIMEOUT || '10000',
enableDebug: process.env.NUXT_PUBLIC_ENABLE_DEBUG || 'true',