diff --git a/app/components/ComponentStructureAssignmentNode.vue b/app/components/ComponentStructureAssignmentNode.vue
index 0baff5d..2d6b86c 100644
--- a/app/components/ComponentStructureAssignmentNode.vue
+++ b/app/components/ComponentStructureAssignmentNode.vue
@@ -17,12 +17,13 @@
{ assignment.selectedComponentId = normalizeSelectionValue(value); }"
/>
@@ -45,22 +46,23 @@
>
- {{ describePieceRequirement(pieceAssignment.definition) }}
+ {{ describePieceRequirement(pieceAssignment) }}
-
+
Aucune pièce disponible pour cette famille.
fetchPieceOptions(pieceAssignment, term)"
@update:modelValue="(value) => { pieceAssignment.selectedPieceId = normalizeSelectionValue(value); }"
/>
@@ -83,22 +85,23 @@
>
- {{ describeProductRequirement(productAssignment.definition) }}
+ {{ describeProductRequirement(productAssignment) }}
-
+
Aucun produit disponible pour cette catégorie.
fetchProductOptions(productAssignment, term)"
@update:modelValue="(value) => { productAssignment.selectedProductId = normalizeSelectionValue(value); }"
/>
@@ -131,8 +134,9 @@
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/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/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/components/common/SearchSelect.vue b/app/components/common/SearchSelect.vue
index b4e8f63..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)
@@ -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) => {
@@ -265,6 +267,7 @@ function handleInput () {
if (!openDropdown.value) {
openDropdown.value = true
}
+ emit('search', searchTerm.value)
}
function closeDropdown () {
diff --git a/app/composables/useApi.js b/app/composables/useApi.js
index b2945e7..14d4ec2 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'
}
@@ -23,6 +24,10 @@ export function useApi () {
const response = await fetch(url, {
...defaultOptions,
...options,
+ headers: {
+ ...defaultOptions.headers,
+ ...options.headers
+ },
signal: controller.signal
})
@@ -32,7 +37,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 {
@@ -69,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)
})
}
@@ -76,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/composables/useComposants.js b/app/composables/useComposants.js
index 5599ede..138ccb8 100644
--- a/app/composables/useComposants.js
+++ b/app/composables/useComposants.js
@@ -3,10 +3,38 @@ 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 total = ref(0)
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 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()
@@ -16,15 +44,29 @@ 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,
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
@@ -34,30 +76,70 @@ 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`)
+ /**
+ * 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 {
+ 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
+ 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
}
- } 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)
+ composants.value.unshift(enriched)
+ total.value += 1
const displayName = result.data?.name
|| composantData?.definition?.name
|| composantData?.name
@@ -76,7 +158,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)
@@ -101,6 +184,7 @@ const loadComposants = async () => {
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
@@ -117,6 +201,7 @@ const loadComposants = async () => {
return {
composants,
+ total,
loading,
loadComposants,
createComposant,
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..5712f60 100644
--- a/app/composables/useMachineTypesApi.js
+++ b/app/composables/useMachineTypesApi.js
@@ -1,11 +1,40 @@
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, 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 relationId = extractRelationId(relationValue)
+ console.log(`[normalizeRequirementList] Extracted ID:`, relationId)
+ if (relationId) {
+ normalized[relationKey] = relationId
+ }
+ }
+ console.log(`[normalizeRequirementList] Normalized entry:`, normalized)
+ return normalized
+ })
+}
const normalizeMachineType = (type) => {
if (!type || typeof type !== 'object') {
@@ -13,24 +42,39 @@ 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()
+ const { get, post, put, delete: del } = useApi()
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 +87,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 +104,7 @@ export function useMachineTypesApi () {
const updateMachineType = async (id, typeData) => {
loading.value = true
try {
- const result = await patch(`/types/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)
@@ -81,7 +125,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)
@@ -96,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(`/types/machines/${id}`)
+ 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/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/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/composables/usePieces.js b/app/composables/usePieces.js
index aa0112e..13ec585 100644
--- a/app/composables/usePieces.js
+++ b/app/composables/usePieces.js
@@ -3,10 +3,38 @@ 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 total = ref(0)
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 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()
@@ -16,15 +44,38 @@ 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 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,
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
@@ -34,18 +85,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 = 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`)
+ 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
}
@@ -54,10 +145,12 @@ 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)
+ pieces.value.unshift(enriched)
+ total.value += 1
const displayName = result.data?.name
|| pieceData?.definition?.name
|| pieceData?.name
@@ -76,7 +169,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)
@@ -101,6 +195,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
@@ -117,6 +212,7 @@ export function usePieces () {
return {
pieces,
+ total,
loading,
loadPieces,
createPiece,
diff --git a/app/composables/useProducts.js b/app/composables/useProducts.js
index 60d2780..5ed4165 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,32 @@ 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 []
+}
+
+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()
@@ -34,19 +61,23 @@ 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,
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 (hasConstructeurs && ids.length) {
- product.constructeurIds = ids
- }
-
- if (ids.length && !hasConstructeurs) {
+ if (ids.length && !hasResolvedConstructeurs) {
const resolved = await ensureConstructeurs(ids)
if (resolved.length) {
product.constructeurs = resolved
@@ -56,30 +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?limit=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 = 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 = 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}`)
@@ -97,7 +160,7 @@ export function useProducts () {
}
const createProduct = async (payload) => {
- const normalizedPayload = buildConstructeurRequestPayload(payload)
+ const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(payload))
loading.value = true
error.value = null
try {
@@ -125,7 +188,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/pages/component-catalog.vue b/app/pages/component-catalog.vue
index 39b4615..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
+
+
+
+
+
+
+
+
+
+
@@ -140,19 +169,80 @@
diff --git a/app/pages/component/[id]/edit.vue b/app/pages/component/[id]/edit.vue
index bc7d32f..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
@@ -400,9 +456,14 @@ 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 { 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'
+import { extractRelationId } from '~/shared/apiRelations'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
@@ -433,9 +494,13 @@ const route = useRoute()
const router = useRouter()
const { get } = useApi()
const { componentTypes, loadComponentTypes } = useComponentTypes()
-const { updateComposant } = useComposants()
+const { pieceTypes, loadPieceTypes } = usePieceTypes()
+const { productTypes, loadProductTypes } = useProductTypes()
+const { updateComposant, loadComposants, composants: componentCatalogRef } = useComposants()
+const { pieces, loadPieces } = usePieces()
+const { products, loadProducts } = useProducts()
const { ensureConstructeurs } = useConstructeurs()
-const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
+const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
const toast = useToast()
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
@@ -499,6 +564,46 @@ 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 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'
@@ -593,6 +698,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) {
@@ -636,6 +750,12 @@ 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
+ refreshCustomFieldInputs(undefined, customValues.data)
+ }
} else {
component.value = null
componentDocuments.value = []
@@ -651,7 +771,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 || ''
@@ -665,10 +791,7 @@ watch(
void ensureConstructeurs(editionForm.constructeurIds)
}
- customFieldInputs.value = buildCustomFieldInputs(
- currentStructure,
- currentComponent.customFieldValues,
- )
+ refreshCustomFieldInputs(currentStructure, currentComponent.customFieldValues)
initialized = true
},
@@ -679,10 +802,7 @@ watch(selectedTypeStructure, (currentStructure) => {
if (!component.value) {
return
}
- customFieldInputs.value = buildCustomFieldInputs(
- currentStructure,
- component.value.customFieldValues,
- )
+ refreshCustomFieldInputs(currentStructure, component.value.customFieldValues)
})
const submitEdition = async () => {
@@ -707,7 +827,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
@@ -990,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
@@ -998,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) {
@@ -1007,6 +1134,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) {
@@ -1017,6 +1146,91 @@ 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
+}
+
+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 pieceIds = getStructurePieces(structure)
+ .map((piece: any) => piece?.typePieceId)
+ .filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
+ 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(() => {})
+ }
+ },
+ { immediate: true },
+)
+
const resolveSubcomponentLabel = (node: Record) => {
const parts: string[] = []
if (node.alias) {
@@ -1043,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,
@@ -1142,7 +1454,15 @@ const saveCustomFieldValues = async (updatedComponent: any) => {
}
onMounted(async () => {
- await Promise.allSettled([loadComponentTypes(), 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/component/create.vue b/app/pages/component/create.vue
index baa27f6..1e024ef 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,10 @@ 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 { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments'
@@ -370,22 +376,22 @@ interface ComponentCatalogType extends ModelType {
const route = useRoute()
const router = useRouter()
+const { get } = useApi()
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 +420,30 @@ const structureDataLoading = computed(
() => piecesLoading.value || componentsLoading.value || productsLoading.value,
)
+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 || [])
+ .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) => {
@@ -778,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) {
@@ -788,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) {
@@ -870,7 +938,7 @@ const submitCreation = async () => {
if (rawPrice) {
const parsed = Number(rawPrice)
if (!Number.isNaN(parsed)) {
- payload.prix = parsed
+ payload.prix = String(parsed)
}
}
@@ -934,9 +1002,8 @@ const submitCreation = async () => {
onMounted(async () => {
await Promise.allSettled([
loadComponentTypes(),
- loadPieces(),
- loadComposants(),
- loadProducts(),
+ loadPieceTypes(),
+ loadProductTypes(),
])
})
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/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()])
})
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/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/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)
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 = ''
diff --git a/app/pages/pieces-catalog.vue b/app/pages/pieces-catalog.vue
index 1ca85a0..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,21 +189,82 @@
diff --git a/app/pages/pieces/[id]/edit.vue b/app/pages/pieces/[id]/edit.vue
index cacec87..44ddbe6 100644
--- a/app/pages/pieces/[id]/edit.vue
+++ b/app/pages/pieces/[id]/edit.vue
@@ -146,34 +146,48 @@
{{ description }}
-
+
-
+
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 +409,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 +440,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,17 +456,30 @@ const previewDocument = ref(null)
const previewVisible = ref(false)
const selectedTypeId = ref('')
+const pieceTypeDetails = ref(null)
const editionForm = reactive({
name: '' as string,
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) =>
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 '—'
@@ -577,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(selectedType.value?.structure ?? null),
+ 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}`
@@ -613,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) {
@@ -630,7 +707,7 @@ const canSubmit = computed(() =>
piece.value &&
editionForm.name &&
requiredCustomFieldsFilled.value &&
- (!requiresProductSelection.value || editionForm.productId) &&
+ productSelectionsFilled.value &&
!saving.value,
),
)
@@ -659,12 +736,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 +776,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 || ''
@@ -684,15 +792,27 @@ 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)
}
- customFieldInputs.value = buildCustomFieldInputs(
- currentType?.structure ?? null,
- currentPiece.customFieldValues,
- )
+ 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
},
@@ -703,10 +823,17 @@ 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
+ }
+ ensureProductSelections(structureProducts.value.length)
+ refreshCustomFieldInputs(currentStructure, piece.value.customFieldValues)
})
const submitEdition = async () => {
@@ -714,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
}
@@ -735,16 +862,18 @@ 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)
if (!Number.isNaN(parsed)) {
- payload.prix = parsed
+ payload.prix = String(parsed)
}
} else {
payload.prix = null
@@ -932,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 5750858..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'
@@ -504,7 +550,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-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()
+ ])
})
diff --git a/app/pages/product/[id]/edit.vue b/app/pages/product/[id]/edit.vue
index 7b68ee7..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)
}
@@ -693,13 +707,11 @@ const submitEdition = async () => {
const rawPrice = typeof editionForm.supplierPrice === 'string'
? editionForm.supplierPrice.trim()
- : editionForm.supplierPrice === null || editionForm.supplierPrice === undefined
- ? ''
- : String(editionForm.supplierPrice).trim()
- payload.supplierPrice = rawPrice
+ : editionForm.supplierPrice
+ payload.supplierPrice = rawPrice !== '' && rawPrice !== null && rawPrice !== undefined
? Number.isNaN(Number(rawPrice))
? null
- : Number(rawPrice)
+ : String(Number(rawPrice))
: null
saving.value = true
@@ -734,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 c28d08a..6b950ff 100644
--- a/app/pages/product/create.vue
+++ b/app/pages/product/create.vue
@@ -1,5 +1,5 @@
-
-
+
+
+