14 Commits

Author SHA1 Message Date
Matthieu
2e82e854bf feat(machines) : multi-select site checkboxes, alphabetical sort, OR search param
- Replace site dropdown with inline checkboxes for multi-site filtering
- Sort machines alphabetically (localeCompare fr)
- Switch catalog search from ?name= to ?q= for OR search on name/reference

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:15:16 +01:00
Matthieu
ac860d3165 fix(constructeurs) : always send constructeurs array in PATCH payload 2026-03-23 13:52:39 +01:00
Matthieu
8176635eb8 fix(machine) : use linkId instead of composantId when deleting a component from machine
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:35:43 +01:00
Matthieu
a730a18794 fix(creation) : redirect to edit page after creating composant, piece, or product
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:26:18 +01:00
Matthieu
40d0753637 fix(model-types) : extract error field from 409 response for user-friendly messages
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 11:47:51 +01:00
Matthieu
db630e315b fix(custom-fields) : preserve CustomField ID in piece structure payload
Prevents data loss when saving ModelType: the frontend now sends existing
CustomField IDs so the backend can match them instead of deleting and recreating.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:07:37 +01:00
Matthieu
53530dc16d fix(piece-edit) : stay on page after saving piece
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 16:12:15 +01:00
Matthieu
974b74ee9f fix(SearchSelect) : render option-description slot even without optionDescription prop
The v-if on resolveDescription() was hiding the entire slot when
optionDescription prop was not provided. Now checks for slot presence
first, allowing custom formatDescription in PieceSelect/ProductSelect/
ComposantSelect to render properly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 15:54:24 +01:00
Matthieu
ab05ce589d fix(ui) : show type name and ref in slot selects, stay on page after save
- PieceSelect, ProductSelect, ComposantSelect: show type name and
  "Ref." prefix in dropdown descriptions (matching create page format)
- Category edit pages (component, piece, product): stay on page after
  successful save instead of navigating back to list
- Component and product edit pages: same — stay on page after save

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 15:52:02 +01:00
Matthieu
ce3f081a0a refactor(category) : remove quantity field from category structure editor
Quantity is now managed per-component on the component edit page,
not at the category level.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:52:49 +01:00
Matthieu
63fba4138e perf(component-edit) : remove redundant full-catalog loads on mount
The 3 loadPieces/loadProducts/loadComposants(200) calls on mount were
redundant since select components now load filtered data server-side.
Removing them eliminates ~3 heavy API calls + constructeur resolution
per page load.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:50:34 +01:00
Matthieu
d58a8c2479 feat(component-edit) : add inline quantity input for piece slots
Quantity can now be edited directly on the component edit page next to
each piece selector, instead of only being defined in the category.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:34:51 +01:00
Matthieu
81f7b1a9ac feat(component-edit) : add link to category edit page from component editor
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:32:54 +01:00
Matthieu
9e303426a7 fix(slots) : filter slot select options server-side instead of client-side
PieceSelect, ProductSelect and ComposantSelect were loading up to 200
items then filtering client-side by typeId. If the matching items were
not in the first 200, the dropdown appeared empty.

Now each select component uses API Platform filters (typePiece,
typeProduct, typeComposant) to fetch only relevant items server-side,
with local state to avoid overwriting the global catalog cache.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 11:59:51 +01:00
25 changed files with 262 additions and 207 deletions

View File

@@ -25,7 +25,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import SearchSelect from '~/components/common/SearchSelect.vue' import SearchSelect from '~/components/common/SearchSelect.vue'
import { useComposants } from '~/composables/useComposants' import { useComposants } from '~/composables/useComposants'
@@ -52,43 +52,39 @@ const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void (e: 'update:modelValue', value: string | null): void
}>() }>()
const { composants, loading, loadComposants } = useComposants() const { loading: globalLoading, loadComposants } = useComposants()
const composantOptions = computed(() => { const localComposants = ref<any[]>([])
const baseOptions = Array.isArray(composants.value) ? composants.value : [] const localLoading = ref(false)
if (!props.typeComposantId) { const loading = computed(() => localLoading.value || globalLoading.value)
return baseOptions
const composantOptions = computed(() => localComposants.value)
const loadFilteredComposants = async () => {
if (!props.typeComposantId) return
localLoading.value = true
try {
const result = await loadComposants({ typeComposantId: props.typeComposantId, itemsPerPage: 500, force: true })
if (result.success && result.data?.items) {
localComposants.value = result.data.items
}
} }
catch (error: unknown) {
const allowedTypeId = String(props.typeComposantId) console.error('Erreur lors du chargement des composants:', error)
return baseOptions.filter((composant: any) => { }
const typeId = finally {
composant?.typeComposantId || localLoading.value = false
composant?.typeComposant?.id || }
null }
return typeId ? String(typeId) === allowedTypeId : false
})
})
onMounted(() => { onMounted(() => {
if (composantOptions.value.length === 0) { loadFilteredComposants()
loadComposants({ itemsPerPage: 200 }).catch((error: unknown) => {
console.error('Erreur lors du chargement des composants:', error)
})
}
}) })
watch( watch(
() => props.modelValue, () => props.typeComposantId,
(value) => { () => {
if (typeof value === 'string' && value) { loadFilteredComposants()
const exists = composantOptions.value.some((c: any) => c.id === value)
if (!exists && !loading.value) {
loadComposants({ itemsPerPage: 200, force: true }).catch((error: unknown) => {
console.error('Erreur lors du chargement des composants:', error)
})
}
}
}, },
) )
@@ -102,8 +98,12 @@ const updateValue = (value: string | number | null | undefined) => {
const formatDescription = (option: any) => { const formatDescription = (option: any) => {
const parts: string[] = [] const parts: string[] = []
const typeName = option?.typeComposant?.name
if (typeName) {
parts.push(typeName)
}
if (option?.reference) { if (option?.reference) {
parts.push(option.reference) parts.push(`Ref. ${option.reference}`)
} }
if (option?.prix !== undefined && option.prix !== null) { if (option?.prix !== undefined && option.prix !== null) {
const price = Number(option.prix) const price = Number(option.prix)

View File

@@ -25,7 +25,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import SearchSelect from '~/components/common/SearchSelect.vue' import SearchSelect from '~/components/common/SearchSelect.vue'
import { usePieces } from '~/composables/usePieces' import { usePieces } from '~/composables/usePieces'
@@ -52,43 +52,39 @@ const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void (e: 'update:modelValue', value: string | null): void
}>() }>()
const { pieces, loading, loadPieces } = usePieces() const { loading: globalLoading, loadPieces } = usePieces()
const pieceOptions = computed(() => { const localPieces = ref<any[]>([])
const baseOptions = Array.isArray(pieces.value) ? pieces.value : [] const localLoading = ref(false)
if (!props.typePieceId) { const loading = computed(() => localLoading.value || globalLoading.value)
return baseOptions
const pieceOptions = computed(() => localPieces.value)
const loadFilteredPieces = async () => {
if (!props.typePieceId) return
localLoading.value = true
try {
const result = await loadPieces({ typePieceId: props.typePieceId, itemsPerPage: 500, force: true })
if (result.success && result.data?.items) {
localPieces.value = result.data.items
}
} }
catch (error: unknown) {
const allowedTypeId = String(props.typePieceId) console.error('Erreur lors du chargement des pièces:', error)
return baseOptions.filter((piece: any) => { }
const typeId = finally {
piece?.typePieceId || localLoading.value = false
piece?.typePiece?.id || }
null }
return typeId ? String(typeId) === allowedTypeId : false
})
})
onMounted(() => { onMounted(() => {
if (pieceOptions.value.length === 0) { loadFilteredPieces()
loadPieces({ itemsPerPage: 200 }).catch((error: unknown) => {
console.error('Erreur lors du chargement des pièces:', error)
})
}
}) })
watch( watch(
() => props.modelValue, () => props.typePieceId,
(value) => { () => {
if (typeof value === 'string' && value) { loadFilteredPieces()
const exists = pieceOptions.value.some((piece: any) => piece.id === value)
if (!exists && !loading.value) {
loadPieces({ itemsPerPage: 200, force: true }).catch((error: unknown) => {
console.error('Erreur lors du chargement des pièces:', error)
})
}
}
}, },
) )
@@ -102,8 +98,12 @@ const updateValue = (value: string | number | null | undefined) => {
const formatDescription = (option: any) => { const formatDescription = (option: any) => {
const parts: string[] = [] const parts: string[] = []
const typeName = option?.typePiece?.name
if (typeName) {
parts.push(typeName)
}
if (option?.reference) { if (option?.reference) {
parts.push(option.reference) parts.push(`Ref. ${option.reference}`)
} }
if (option?.prix !== undefined && option.prix !== null) { if (option?.prix !== undefined && option.prix !== null) {
const price = Number(option.prix) const price = Number(option.prix)

View File

@@ -25,7 +25,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import SearchSelect from '~/components/common/SearchSelect.vue' import SearchSelect from '~/components/common/SearchSelect.vue'
import { useProducts } from '~/composables/useProducts' import { useProducts } from '~/composables/useProducts'
@@ -52,43 +52,39 @@ const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void (e: 'update:modelValue', value: string | null): void
}>() }>()
const { products, loading, loadProducts } = useProducts() const { loading: globalLoading, loadProducts } = useProducts()
const productOptions = computed(() => { const localProducts = ref<any[]>([])
const baseOptions = Array.isArray(products.value) ? products.value : [] const localLoading = ref(false)
if (!props.typeProductId) { const loading = computed(() => localLoading.value || globalLoading.value)
return baseOptions
const productOptions = computed(() => localProducts.value)
const loadFilteredProducts = async () => {
if (!props.typeProductId) return
localLoading.value = true
try {
const result = await loadProducts({ typeProductId: props.typeProductId, itemsPerPage: 500, force: true })
if (result.success && result.data?.items) {
localProducts.value = result.data.items
}
} }
catch (error: unknown) {
const allowedTypeId = String(props.typeProductId) console.error('Erreur lors du chargement des produits:', error)
return baseOptions.filter((product) => { }
const typeId = finally {
product?.typeProductId || localLoading.value = false
product?.typeProduct?.id || }
null }
return typeId ? String(typeId) === allowedTypeId : false
})
})
onMounted(() => { onMounted(() => {
if (productOptions.value.length === 0) { loadFilteredProducts()
loadProducts().catch((error) => {
console.error('Erreur lors du chargement des produits:', error)
})
}
}) })
watch( watch(
() => props.modelValue, () => props.typeProductId,
(value) => { () => {
if (typeof value === 'string' && value) { loadFilteredProducts()
const exists = productOptions.value.some((product) => product.id === value)
if (!exists && !loading.value) {
loadProducts({ force: true }).catch((error) => {
console.error('Erreur lors du chargement des produits:', error)
})
}
}
}, },
) )
@@ -102,8 +98,12 @@ const updateValue = (value: string | number | null | undefined) => {
const formatDescription = (option: any) => { const formatDescription = (option: any) => {
const parts: string[] = [] const parts: string[] = []
const typeName = option?.typeProduct?.name
if (typeName) {
parts.push(typeName)
}
if (option?.reference) { if (option?.reference) {
parts.push(option.reference) parts.push(`Ref. ${option.reference}`)
} }
if (option?.supplierPrice !== undefined && option.supplierPrice !== null) { if (option?.supplierPrice !== undefined && option.supplierPrice !== null) {
const price = Number(option.supplierPrice) const price = Number(option.supplierPrice)

View File

@@ -258,18 +258,7 @@
{{ piece.typePieceId ? `Sélection : ${getPieceTypeLabel(piece.typePieceId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }} {{ piece.typePieceId ? `Sélection : ${getPieceTypeLabel(piece.typePieceId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
</p> </p>
</div> </div>
<div class="form-control"> <!-- Quantity is set per-component on the component edit page -->
<label class="label py-1"><span class="label-text text-xs">Quantité</span></label>
<input
v-model.number="piece.quantity"
type="number"
:min="1"
step="1"
placeholder="Qté"
class="input input-bordered input-sm md:input-md w-20"
@input="piece.quantity = Math.max(1, piece.quantity || 1)"
/>
</div>
</div> </div>
<button type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)"> <button type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)">
<IconLucideTrash class="w-4 h-4" aria-hidden="true" /> <IconLucideTrash class="w-4 h-4" aria-hidden="true" />

View File

@@ -69,7 +69,7 @@
{{ resolveLabel(option) }} {{ resolveLabel(option) }}
</slot> </slot>
</span> </span>
<span v-if="resolveDescription(option)" class="text-xs text-base-content/50"> <span v-if="$slots['option-description'] || resolveDescription(option)" class="text-xs text-base-content/50">
<slot name="option-description" :option="option"> <slot name="option-description" :option="option">
{{ resolveDescription(option) }} {{ resolveDescription(option) }}
</slot> </slot>

View File

@@ -344,7 +344,7 @@ export function useComponentCreate() {
selectedDocuments.value = [] selectedDocuments.value = []
} }
toast.showSuccess('Composant créé avec succès') toast.showSuccess('Composant créé avec succès')
await router.push('/component-catalog') await router.push(`/component/${createdComponent.id}/edit`)
} }
else if (result.error) { else if (result.error) {
toast.showError(result.error) toast.showError(result.error)

View File

@@ -7,7 +7,6 @@ import { useProductTypes } from '~/composables/useProductTypes'
import { usePieces } from '~/composables/usePieces' import { usePieces } from '~/composables/usePieces'
import { useProducts } from '~/composables/useProducts' import { useProducts } from '~/composables/useProducts'
import { useCustomFields } from '~/composables/useCustomFields' import { useCustomFields } from '~/composables/useCustomFields'
import type { SelectionEntry } from '~/shared/utils/structureSelectionUtils'
import { useApi } from '~/composables/useApi' import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { extractRelationId } from '~/shared/apiRelations' import { extractRelationId } from '~/shared/apiRelations'
@@ -58,9 +57,9 @@ export function useComponentEdit(componentId: string) {
const { componentTypes, loadComponentTypes } = useComponentTypes() const { componentTypes, loadComponentTypes } = useComponentTypes()
const { pieceTypes, loadPieceTypes } = usePieceTypes() const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { productTypes, loadProductTypes } = useProductTypes() const { productTypes, loadProductTypes } = useProductTypes()
const { updateComposant, loadComposants, composants: componentCatalogRef } = useComposants() const { updateComposant, composants: componentCatalogRef } = useComposants()
const { pieces, loadPieces } = usePieces() const { pieces } = usePieces()
const { products, loadProducts } = useProducts() const { products } = useProducts()
const { ensureConstructeurs } = useConstructeurs() const { ensureConstructeurs } = useConstructeurs()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields() const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const toast = useToast() const toast = useToast()
@@ -348,19 +347,17 @@ export function useComponentEdit(componentId: string) {
} }
} }
const saveSlotQuantity = async (entry: SelectionEntry) => { const saveSlotQuantity = async (slotId: string, quantity: number) => {
const slotId = entry.slotId if (!slotId || quantity < 1) return
const quantity = typeof entry._definition?.quantity === 'number' const result = await patch(`/composant-piece-slots/${slotId}`, { quantity: Math.max(1, quantity) })
? Math.max(1, entry._definition.quantity) if (result.success) {
: null const structure = component.value?.structure
if (!slotId || quantity === null) return if (structure?.pieces) {
try { const slot = (structure.pieces as any[]).find((s: any) => s.slotId === slotId)
await patch(`/composant-piece-slots/${slotId}`, { quantity }) if (slot) slot.quantity = quantity
}
toast.showSuccess('Quantité mise à jour') toast.showSuccess('Quantité mise à jour')
} }
catch (error: any) {
toast.showError(error?.message || 'Erreur lors de la mise à jour de la quantité')
}
} }
const submitEdition = async () => { const submitEdition = async () => {
@@ -406,7 +403,7 @@ export function useComponentEdit(componentId: string) {
], ],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast }, { customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
) )
await router.push('/component-catalog') toast.showSuccess('Composant mis à jour avec succès.')
} }
} }
catch (error: any) { catch (error: any) {
@@ -500,13 +497,6 @@ export function useComponentEdit(componentId: string) {
fetchComponent(), fetchComponent(),
]) ])
loading.value = false loading.value = false
// Load catalogs for slot selectors (force: true to bypass cache from list pages that load fewer items)
Promise.allSettled([
loadPieces({ itemsPerPage: 200, force: true }),
loadProducts({ itemsPerPage: 200, force: true }),
loadComposants({ itemsPerPage: 200, force: true }),
]).catch(() => {})
}) })
return { return {

View File

@@ -42,6 +42,7 @@ interface LoadComposantsOptions {
orderBy?: string orderBy?: string
orderDir?: 'asc' | 'desc' orderDir?: 'asc' | 'desc'
typeName?: string typeName?: string
typeComposantId?: string
force?: boolean force?: boolean
} }
@@ -109,17 +110,18 @@ export function useComposants() {
orderBy = 'name', orderBy = 'name',
orderDir = 'asc', orderDir = 'asc',
typeName, typeName,
typeComposantId,
force = false, force = false,
} = options } = options
if (!force && loaded.value && !search && !typeName && page === 1) { if (!force && loaded.value && !search && !typeName && !typeComposantId && page === 1) {
return { return {
success: true, success: true,
data: { items: composants.value, total: total.value, page, itemsPerPage }, data: { items: composants.value, total: total.value, page, itemsPerPage },
} }
} }
if (loading.value) { if (!typeComposantId && loading.value) {
return { return {
success: true, success: true,
data: { items: composants.value, total: total.value, page, itemsPerPage }, data: { items: composants.value, total: total.value, page, itemsPerPage },
@@ -128,33 +130,41 @@ export function useComposants() {
loading.value = true loading.value = true
try { try {
const params = new URLSearchParams() const params = new URLSearchParams()
params.set('itemsPerPage', String(itemsPerPage)) params.set('itemsPerPage', String(itemsPerPage))
params.set('page', String(page)) params.set('page', String(page))
if (search && search.trim()) { if (search && search.trim()) {
params.set('name', search.trim()) params.set('q', search.trim())
} }
if (typeName && typeName.trim()) { if (typeName && typeName.trim()) {
params.set('typeComposant.name', typeName.trim()) params.set('typeComposant.name', typeName.trim())
} }
if (typeComposantId) {
params.set('typeComposant', typeComposantId)
}
params.set(`order[${orderBy}]`, orderDir) params.set(`order[${orderBy}]`, orderDir)
const result = await get(`/composants?${params.toString()}`) const result = await get(`/composants?${params.toString()}`)
if (result.success) { if (result.success) {
const items = extractCollection(result.data) const items = extractCollection(result.data)
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item))) const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
composants.value = enrichedItems const resultTotal = extractTotal(result.data, items.length)
total.value = extractTotal(result.data, items.length)
loaded.value = true if (!typeComposantId) {
composants.value = enrichedItems
total.value = resultTotal
loaded.value = true
}
return { return {
success: true, success: true,
data: { data: {
items: enrichedItems, items: enrichedItems,
total: total.value, total: resultTotal,
page, page,
itemsPerPage, itemsPerPage,
}, },

View File

@@ -127,8 +127,8 @@ export function useEntityTypes(config: EntityTypeConfig) {
showSuccess(`Type de ${label} "${data.name}" créé`) showSuccess(`Type de ${label} "${data.name}" créé`)
return { success: true, data: normalized } return { success: true, data: normalized }
} catch (error) { } catch (error) {
const err = error as Error & { data?: { message?: string }; message?: string } const err = error as Error & { data?: { error?: string; message?: string }; message?: string }
const raw = err?.data?.message || err?.message const raw = err?.data?.error || err?.data?.message || err?.message
const message = humanizeError(raw) const message = humanizeError(raw)
showError(`Impossible de créer le type de ${label} : ${message}`) showError(`Impossible de créer le type de ${label} : ${message}`)
return { success: false, error: message } return { success: false, error: message }
@@ -153,8 +153,8 @@ export function useEntityTypes(config: EntityTypeConfig) {
showSuccess(`Type de ${label} "${data.name}" mis à jour`) showSuccess(`Type de ${label} "${data.name}" mis à jour`)
return { success: true, data: normalized } return { success: true, data: normalized }
} catch (error) { } catch (error) {
const err = error as Error & { data?: { message?: string }; message?: string } const err = error as Error & { data?: { error?: string; message?: string }; message?: string }
const raw = err?.data?.message || err?.message const raw = err?.data?.error || err?.data?.message || err?.message
const message = humanizeError(raw) const message = humanizeError(raw)
showError(`Impossible de mettre à jour le type de ${label} : ${message}`) showError(`Impossible de mettre à jour le type de ${label} : ${message}`)
return { success: false, error: message } return { success: false, error: message }
@@ -171,8 +171,8 @@ export function useEntityTypes(config: EntityTypeConfig) {
showSuccess(`Type de ${label} supprimé`) showSuccess(`Type de ${label} supprimé`)
return { success: true } return { success: true }
} catch (error) { } catch (error) {
const err = error as Error & { data?: { message?: string }; message?: string } const err = error as Error & { data?: { error?: string; message?: string }; message?: string }
const raw = err?.data?.message || err?.message const raw = err?.data?.error || err?.data?.message || err?.message
const message = humanizeError(raw) const message = humanizeError(raw)
showError(`Impossible de supprimer le type de ${label} : ${message}`) showError(`Impossible de supprimer le type de ${label} : ${message}`)
return { success: false, error: message } return { success: false, error: message }

View File

@@ -273,6 +273,7 @@ export const buildMachineHierarchyFromLinks = (
originalComposant: originalComponent, originalComposant: originalComponent,
machineComponentLink: link, machineComponentLink: link,
machineComponentLinkId, machineComponentLinkId,
linkId: machineComponentLinkId,
componentLinkId: machineComponentLinkId, componentLinkId: machineComponentLinkId,
parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedComponent.parentComponentLinkId), parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedComponent.parentComponentLinkId),
parentComposantId: resolveIdentifier(appliedComponent.parentComposantId, link.parentComponentId), parentComposantId: resolveIdentifier(appliedComponent.parentComposantId, link.parentComponentId),

View File

@@ -412,7 +412,7 @@ export function usePieceEdit(pieceId: string) {
], ],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast }, { customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
) )
await router.push('/pieces-catalog') toast.showSuccess('Pièce mise à jour avec succès.')
} }
} }
catch (error: any) { catch (error: any) {

View File

@@ -158,6 +158,13 @@ const buildPayload = (
orderIndex: index, orderIndex: index,
} }
if (field.id) {
payload.id = field.id
}
if (field.customFieldId) {
payload.customFieldId = field.customFieldId
}
if (type === 'select') { if (type === 'select') {
const options = normalizeLineEndings(field.optionsText) const options = normalizeLineEndings(field.optionsText)
.split('\n') .split('\n')

View File

@@ -43,6 +43,7 @@ interface LoadPiecesOptions {
orderBy?: string orderBy?: string
orderDir?: 'asc' | 'desc' orderDir?: 'asc' | 'desc'
typeName?: string typeName?: string
typePieceId?: string
force?: boolean force?: boolean
} }
@@ -119,17 +120,20 @@ export function usePieces() {
orderBy = 'name', orderBy = 'name',
orderDir = 'asc', orderDir = 'asc',
typeName, typeName,
typePieceId,
force = false, force = false,
} = options } = options
if (!force && loaded.value && !search && !typeName && page === 1) { // Only use cache for unfiltered full-catalog loads
if (!force && loaded.value && !search && !typeName && !typePieceId && page === 1) {
return { return {
success: true, success: true,
data: { items: pieces.value, total: total.value, page, itemsPerPage }, data: { items: pieces.value, total: total.value, page, itemsPerPage },
} }
} }
if (loading.value) { // For filtered queries, don't block on global loading state
if (!typePieceId && loading.value) {
return { return {
success: true, success: true,
data: { items: pieces.value, total: total.value, page, itemsPerPage }, data: { items: pieces.value, total: total.value, page, itemsPerPage },
@@ -138,33 +142,42 @@ export function usePieces() {
loading.value = true loading.value = true
try { try {
const params = new URLSearchParams() const params = new URLSearchParams()
params.set('itemsPerPage', String(itemsPerPage)) params.set('itemsPerPage', String(itemsPerPage))
params.set('page', String(page)) params.set('page', String(page))
if (search && search.trim()) { if (search && search.trim()) {
params.set('name', search.trim()) params.set('q', search.trim())
} }
if (typeName && typeName.trim()) { if (typeName && typeName.trim()) {
params.set('typePiece.name', typeName.trim()) params.set('typePiece.name', typeName.trim())
} }
if (typePieceId) {
params.set('typePiece', typePieceId)
}
params.set(`order[${orderBy}]`, orderDir) params.set(`order[${orderBy}]`, orderDir)
const result = await get(`/pieces?${params.toString()}`) const result = await get(`/pieces?${params.toString()}`)
if (result.success) { if (result.success) {
const items = extractCollection(result.data) const items = extractCollection(result.data)
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item))) const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
pieces.value = enrichedItems const resultTotal = extractTotal(result.data, items.length)
total.value = extractTotal(result.data, items.length)
loaded.value = true // Only update global cache for unfiltered queries
if (!typePieceId) {
pieces.value = enrichedItems
total.value = resultTotal
loaded.value = true
}
return { return {
success: true, success: true,
data: { data: {
items: enrichedItems, items: enrichedItems,
total: total.value, total: resultTotal,
page, page,
itemsPerPage, itemsPerPage,
}, },

View File

@@ -41,6 +41,7 @@ interface LoadProductsOptions {
orderBy?: string orderBy?: string
orderDir?: 'asc' | 'desc' orderDir?: 'asc' | 'desc'
typeName?: string typeName?: string
typeProductId?: string
force?: boolean force?: boolean
} }
@@ -118,17 +119,18 @@ export function useProducts() {
orderBy = 'name', orderBy = 'name',
orderDir = 'asc', orderDir = 'asc',
typeName, typeName,
typeProductId,
force = false, force = false,
} = options } = options
if (!force && loaded.value && !search && !typeName && page === 1) { if (!force && loaded.value && !search && !typeName && !typeProductId && page === 1) {
return { return {
success: true, success: true,
data: { items: products.value, total: total.value, page, itemsPerPage }, data: { items: products.value, total: total.value, page, itemsPerPage },
} }
} }
if (loading.value) { if (!typeProductId && loading.value) {
return { return {
success: true, success: true,
data: { items: products.value, total: total.value, page, itemsPerPage }, data: { items: products.value, total: total.value, page, itemsPerPage },
@@ -143,27 +145,36 @@ export function useProducts() {
params.set('page', String(page)) params.set('page', String(page))
if (search && search.trim()) { if (search && search.trim()) {
params.set('name', search.trim()) params.set('q', search.trim())
} }
if (typeName && typeName.trim()) { if (typeName && typeName.trim()) {
params.set('typeProduct.name', typeName.trim()) params.set('typeProduct.name', typeName.trim())
} }
if (typeProductId) {
params.set('typeProduct', typeProductId)
}
params.set(`order[${orderBy}]`, orderDir) params.set(`order[${orderBy}]`, orderDir)
const result = await get(`/products?${params.toString()}`) const result = await get(`/products?${params.toString()}`)
if (result.success) { if (result.success) {
const items = extractCollection(result.data) const items = extractCollection(result.data)
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item))) const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
products.value = enrichedItems const resultTotal = extractTotal(result.data, items.length)
total.value = extractTotal(result.data, items.length)
loaded.value = true if (!typeProductId) {
products.value = enrichedItems
total.value = resultTotal
loaded.value = true
}
return { return {
success: true, success: true,
data: { data: {
items: enrichedItems, items: enrichedItems,
total: total.value, total: resultTotal,
page, page,
itemsPerPage, itemsPerPage,
}, },

View File

@@ -158,7 +158,6 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false }) await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
await loadComponentTypes({ force: true }) await loadComponentTypes({ force: true })
showSuccess('Catégorie de composant mise à jour avec succès.') showSuccess('Catégorie de composant mise à jour avec succès.')
await navigateBackToList()
} }
} catch (error) { } catch (error) {
showError(normalizeError(error)) showError(normalizeError(error))
@@ -183,7 +182,6 @@ const handleSyncConfirm = async () => {
}) })
await loadComponentTypes({ force: true }) await loadComponentTypes({ force: true })
showSuccess('Catégorie de composant mise à jour avec succès.') showSuccess('Catégorie de composant mise à jour avec succès.')
await navigateBackToList()
} catch (error) { } catch (error) {
showError(normalizeError(error)) showError(normalizeError(error))
} finally { } finally {

View File

@@ -45,20 +45,33 @@
<label class="label"> <label class="label">
<span class="label-text">Catégorie de composant</span> <span class="label-text">Catégorie de composant</span>
</label> </label>
<select <div class="flex items-center gap-2">
v-model="selectedTypeId" <select
class="select select-bordered select-sm md:select-md" v-model="selectedTypeId"
disabled class="select select-bordered select-sm md:select-md flex-1"
> disabled
<option value="">Sélectionner une catégorie</option>
<option
v-for="type in componentTypeList"
:key="type.id"
:value="type.id"
> >
{{ type.name }} <option value="">Sélectionner une catégorie</option>
</option> <option
</select> v-for="type in componentTypeList"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
<NuxtLink
v-if="selectedTypeId"
:to="`/component-category/${selectedTypeId}/edit`"
class="btn btn-ghost btn-sm"
title="Voir la catégorie"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
</svg>
</NuxtLink>
</div>
<p class="text-xs text-base-content/60 mt-1"> <p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page. La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p> </p>
@@ -173,12 +186,27 @@
<label class="label"> <label class="label">
<span class="label-text text-xs font-medium">{{ slot.label }}</span> <span class="label-text text-xs font-medium">{{ slot.label }}</span>
</label> </label>
<PieceSelect <div class="flex items-start gap-2">
:model-value="slot.selectedPieceId" <div class="flex-1">
:disabled="!canEdit || saving" <PieceSelect
:type-piece-id="slot.typePieceId" :model-value="slot.selectedPieceId"
@update:model-value="(value) => savePieceSlotSelection(slot.slotId, value)" :disabled="!canEdit || saving"
/> :type-piece-id="slot.typePieceId"
@update:model-value="(value) => savePieceSlotSelection(slot.slotId, value)"
/>
</div>
<div class="w-20 shrink-0">
<input
type="number"
:value="slot.quantity"
min="1"
class="input input-bordered input-sm w-full text-center"
:disabled="!canEdit || saving"
title="Quantité"
@change="(e) => saveSlotQuantity(slot.slotId, Number((e.target as HTMLInputElement).value))"
>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -16,16 +16,23 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
<span class="label-text">Site</span> <span class="label-text">Sites</span>
</label> </label>
<select v-model="selectedSite" class="select select-bordered"> <div class="flex flex-wrap gap-3">
<option value=""> <label
Tous les sites v-for="site in sites"
</option> :key="site.id"
<option v-for="site in sites" :key="site.id" :value="site.id"> class="flex items-center gap-2 cursor-pointer"
{{ site.name }} >
</option> <input
</select> type="checkbox"
class="checkbox checkbox-sm"
:checked="selectedSites.has(site.id)"
@change="selectedSites.has(site.id) ? selectedSites.delete(site.id) : selectedSites.add(site.id)"
>
<span class="text-sm">{{ site.name }}</span>
</label>
</div>
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
@@ -113,7 +120,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { useMachines } from '~/composables/useMachines' import { useMachines } from '~/composables/useMachines'
import { useSites } from '~/composables/useSites' import { useSites } from '~/composables/useSites'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
@@ -128,7 +135,7 @@ const { machines, loading, loadMachines, deleteMachine } = useMachines()
const { sites, loadSites } = useSites() const { sites, loadSites } = useSites()
const toast = useToast() const toast = useToast()
const selectedSite = ref('') const selectedSites = reactive(new Set())
const searchQuery = ref('') const searchQuery = ref('')
// Enrichir les machines avec les objets site complets // Enrichir les machines avec les objets site complets
@@ -145,8 +152,8 @@ const enrichedMachines = computed(() => {
const filteredMachines = computed(() => { const filteredMachines = computed(() => {
let filtered = enrichedMachines.value let filtered = enrichedMachines.value
if (selectedSite.value) { if (selectedSites.size > 0) {
filtered = filtered.filter(machine => machine.siteId === selectedSite.value) filtered = filtered.filter(machine => selectedSites.has(machine.siteId))
} }
if (searchQuery.value.trim()) { if (searchQuery.value.trim()) {
@@ -157,6 +164,10 @@ const filteredMachines = computed(() => {
) )
} }
filtered = [...filtered].sort((a, b) =>
(a.name || '').localeCompare(b.name || '', 'fr')
)
return filtered return filtered
}) })

View File

@@ -156,7 +156,6 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false }) await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
await loadPieceTypes({ force: true }) await loadPieceTypes({ force: true })
showSuccess('Catégorie de pièce mise à jour avec succès.') showSuccess('Catégorie de pièce mise à jour avec succès.')
await navigateBackToList()
} }
} catch (error) { } catch (error) {
showError(normalizeError(error)) showError(normalizeError(error))
@@ -181,7 +180,6 @@ const handleSyncConfirm = async () => {
}) })
await loadPieceTypes({ force: true }) await loadPieceTypes({ force: true })
showSuccess('Catégorie de pièce mise à jour avec succès.') showSuccess('Catégorie de pièce mise à jour avec succès.')
await navigateBackToList()
} catch (error) { } catch (error) {
showError(normalizeError(error)) showError(normalizeError(error))
} finally { } finally {

View File

@@ -466,7 +466,7 @@ const submitCreation = async () => {
selectedDocuments.value = [] selectedDocuments.value = []
} }
toast.showSuccess('Pièce créée avec succès') toast.showSuccess('Pièce créée avec succès')
await router.push('/pieces-catalog') await router.push(`/pieces/${createdPiece.id}/edit`)
} else if (result.error) { } else if (result.error) {
toast.showError(result.error) toast.showError(result.error)
} }

View File

@@ -156,7 +156,6 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false }) await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
await loadProductTypes({ force: true }) await loadProductTypes({ force: true })
showSuccess('Catégorie de produit mise à jour avec succès.') showSuccess('Catégorie de produit mise à jour avec succès.')
await navigateBackToList()
} }
} catch (error) { } catch (error) {
showError(normalizeError(error)) showError(normalizeError(error))
@@ -181,7 +180,6 @@ const handleSyncConfirm = async () => {
}) })
await loadProductTypes({ force: true }) await loadProductTypes({ force: true })
showSuccess('Catégorie de produit mise à jour avec succès.') showSuccess('Catégorie de produit mise à jour avec succès.')
await navigateBackToList()
} catch (error) { } catch (error) {
showError(normalizeError(error)) showError(normalizeError(error))
} finally { } finally {

View File

@@ -482,7 +482,6 @@ const submitEdition = async () => {
return return
} }
toast.showSuccess('Produit mis à jour avec succès') toast.showSuccess('Produit mis à jour avec succès')
await router.push('/product-catalog')
} }
} catch (error: any) { } catch (error: any) {
toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit') toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')

View File

@@ -352,7 +352,7 @@ const submitCreation = async () => {
} }
} }
toast.showSuccess('Produit créé avec succès') toast.showSuccess('Produit créé avec succès')
await router.push('/product-catalog') await router.push(`/product/${productId}/edit`)
} }
} catch (error: any) { } catch (error: any) {
toast.showError(error?.message || 'Erreur lors de la création du produit') toast.showError(error?.message || 'Erreur lors de la création du produit')

View File

@@ -155,9 +155,7 @@ export const buildConstructeurRequestPayload = <T extends Record<string, any>>(
delete next.constructeurs; delete next.constructeurs;
delete next.constructeurIds; delete next.constructeurIds;
if (ids.length) { next.constructeurs = ids.map((id) => `/api/constructeurs/${id}`);
next.constructeurs = ids.map((id) => `/api/constructeurs/${id}`);
}
return next as T & { constructeurs?: string[] }; return next as T & { constructeurs?: string[] };
}; };

View File

@@ -125,6 +125,8 @@ const hydratePieceCustomFields = (fields: any[]): PieceModelStructureEditorField
? field.options.join('\n') ? field.options.join('\n')
: '', : '',
orderIndex: typeof field?.orderIndex === 'number' ? field.orderIndex : index, orderIndex: typeof field?.orderIndex === 'number' ? field.orderIndex : index,
...(field?.id ? { id: field.id } : {}),
...(field?.customFieldId ? { customFieldId: field.customFieldId } : {}),
})) }))
} }

View File

@@ -61,6 +61,8 @@ export interface PieceModelCustomField {
key?: string key?: string
value?: unknown value?: unknown
defaultValue?: string | null defaultValue?: string | null
id?: string
customFieldId?: string
} }
export interface PieceModelProduct { export interface PieceModelProduct {