Compare commits
20 Commits
master
...
4db832bc8c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4db832bc8c | ||
|
|
736a8bccf9 | ||
|
|
bd69b37524 | ||
|
|
e7402dda4d | ||
|
|
6b0d2d1b0a | ||
|
|
7a4a77e3fc | ||
|
|
2e82e854bf | ||
|
|
ac860d3165 | ||
|
|
8176635eb8 | ||
|
|
a730a18794 | ||
|
|
40d0753637 | ||
|
|
db630e315b | ||
|
|
53530dc16d | ||
|
|
974b74ee9f | ||
|
|
ab05ce589d | ||
|
|
ce3f081a0a | ||
|
|
63fba4138e | ||
|
|
d58a8c2479 | ||
|
|
81f7b1a9ac | ||
|
|
9e303426a7 |
@@ -6,6 +6,12 @@
|
||||
:documents="componentDocuments"
|
||||
@close="closePreview"
|
||||
/>
|
||||
<DocumentEditModal
|
||||
:visible="editModalVisible"
|
||||
:document="editingDocument"
|
||||
@close="editModalVisible = false"
|
||||
@updated="handleDocumentUpdated"
|
||||
/>
|
||||
|
||||
<!-- Component Header -->
|
||||
<div class="flex items-center gap-3 p-3 bg-base-200 rounded-lg cursor-pointer" @click="toggleCollapse">
|
||||
@@ -208,9 +214,11 @@
|
||||
<DocumentListInline
|
||||
:documents="componentDocuments"
|
||||
:can-delete="isEditMode"
|
||||
:can-edit="isEditMode"
|
||||
:delete-disabled="uploadingDocuments"
|
||||
empty-text="Aucun document lié à ce composant."
|
||||
@preview="openPreview"
|
||||
@edit="openEditModal"
|
||||
@delete="removeDocument"
|
||||
/>
|
||||
</div>
|
||||
@@ -319,6 +327,7 @@ const {
|
||||
ensureDocumentsLoaded,
|
||||
handleFilesAdded,
|
||||
removeDocument,
|
||||
editDocument,
|
||||
} = useEntityDocuments({ entity: () => props.component, entityType: 'composant' })
|
||||
|
||||
const {
|
||||
@@ -333,6 +342,21 @@ const {
|
||||
updateCustomField: updateComponentCustomField,
|
||||
} = useEntityCustomFields({ entity: () => props.component, entityType: 'composant' })
|
||||
|
||||
// --- Document edit modal ---
|
||||
const editingDocument = ref(null)
|
||||
const editModalVisible = ref(false)
|
||||
|
||||
const openEditModal = (doc) => {
|
||||
editingDocument.value = doc
|
||||
editModalVisible.value = true
|
||||
}
|
||||
const handleDocumentUpdated = async (data) => {
|
||||
if (!editingDocument.value?.id) return
|
||||
await editDocument(editingDocument.value.id, data)
|
||||
editModalVisible.value = false
|
||||
editingDocument.value = null
|
||||
}
|
||||
|
||||
// --- Collapse state ---
|
||||
const isCollapsed = ref(true)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</template>
|
||||
|
||||
<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 { useComposants } from '~/composables/useComposants'
|
||||
|
||||
@@ -52,43 +52,39 @@ const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | null): void
|
||||
}>()
|
||||
|
||||
const { composants, loading, loadComposants } = useComposants()
|
||||
const { loading: globalLoading, loadComposants } = useComposants()
|
||||
|
||||
const composantOptions = computed(() => {
|
||||
const baseOptions = Array.isArray(composants.value) ? composants.value : []
|
||||
if (!props.typeComposantId) {
|
||||
return baseOptions
|
||||
const localComposants = ref<any[]>([])
|
||||
const localLoading = ref(false)
|
||||
const loading = computed(() => localLoading.value || globalLoading.value)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const allowedTypeId = String(props.typeComposantId)
|
||||
return baseOptions.filter((composant: any) => {
|
||||
const typeId =
|
||||
composant?.typeComposantId ||
|
||||
composant?.typeComposant?.id ||
|
||||
null
|
||||
return typeId ? String(typeId) === allowedTypeId : false
|
||||
})
|
||||
})
|
||||
catch (error: unknown) {
|
||||
console.error('Erreur lors du chargement des composants:', error)
|
||||
}
|
||||
finally {
|
||||
localLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (composantOptions.value.length === 0) {
|
||||
loadComposants({ itemsPerPage: 200 }).catch((error: unknown) => {
|
||||
console.error('Erreur lors du chargement des composants:', error)
|
||||
})
|
||||
}
|
||||
loadFilteredComposants()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
if (typeof value === 'string' && value) {
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
() => props.typeComposantId,
|
||||
() => {
|
||||
loadFilteredComposants()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -102,8 +98,12 @@ const updateValue = (value: string | number | null | undefined) => {
|
||||
|
||||
const formatDescription = (option: any) => {
|
||||
const parts: string[] = []
|
||||
const typeName = option?.typeComposant?.name
|
||||
if (typeName) {
|
||||
parts.push(typeName)
|
||||
}
|
||||
if (option?.reference) {
|
||||
parts.push(option.reference)
|
||||
parts.push(`Ref. ${option.reference}`)
|
||||
}
|
||||
if (option?.prix !== undefined && option.prix !== null) {
|
||||
const price = Number(option.prix)
|
||||
|
||||
90
app/components/DocumentEditModal.vue
Normal file
90
app/components/DocumentEditModal.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="visible" class="modal modal-open" @click.self="$emit('close')">
|
||||
<div class="modal-box max-w-sm">
|
||||
<h3 class="font-bold text-lg mb-4">
|
||||
Modifier le document
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<label class="form-control w-full">
|
||||
<div class="label">
|
||||
<span class="label-text">Nom</span>
|
||||
</div>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md w-full"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="form-control w-full">
|
||||
<div class="label">
|
||||
<span class="label-text">Type</span>
|
||||
</div>
|
||||
<select
|
||||
v-model="form.type"
|
||||
class="select select-bordered select-sm md:select-md w-full"
|
||||
>
|
||||
<option v-for="t in DOCUMENT_TYPES" :key="t.value" :value="t.value">
|
||||
{{ t.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="$emit('close')">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm md:btn-md"
|
||||
:disabled="saving"
|
||||
@click="save"
|
||||
>
|
||||
<span v-if="saving" class="loading loading-spinner loading-xs" />
|
||||
Sauvegarder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch, ref } from 'vue'
|
||||
import { DOCUMENT_TYPES } from '~/shared/documentTypes'
|
||||
import type { Document } from '~/composables/useDocuments'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
document: Document | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'updated', data: { name: string; type: string }): void
|
||||
}>()
|
||||
|
||||
const form = reactive({ name: '', type: 'documentation' })
|
||||
const saving = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.document,
|
||||
(doc) => {
|
||||
if (doc) {
|
||||
form.name = doc.name || ''
|
||||
form.type = doc.type || 'documentation'
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const save = () => {
|
||||
if (!form.name.trim()) return
|
||||
saving.value = true
|
||||
emit('updated', { name: form.name.trim(), type: form.type })
|
||||
saving.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -34,6 +34,21 @@
|
||||
@change="onFileChange"
|
||||
>
|
||||
|
||||
<div class="w-full max-w-xs mt-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-base-content/70">
|
||||
Type de document
|
||||
</label>
|
||||
<select
|
||||
class="select select-bordered select-sm w-full mt-1"
|
||||
:value="documentType"
|
||||
@change="emit('update:documentType', $event.target.value)"
|
||||
>
|
||||
<option v-for="t in DOCUMENT_TYPES" :key="t.value" :value="t.value">
|
||||
{{ t.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<ul v-if="selectedFiles.length" class="mt-4 w-full space-y-2 text-left">
|
||||
<li v-for="file in selectedFiles" :key="file.name" class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -69,6 +84,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { DOCUMENT_TYPES } from '~/shared/documentTypes'
|
||||
import { getFileIcon } from '~/utils/fileIcons'
|
||||
import IconLucideCloudUpload from '~icons/lucide/cloud-upload'
|
||||
|
||||
@@ -96,10 +112,14 @@ const props = defineProps({
|
||||
maxFileSizeMb: {
|
||||
type: Number,
|
||||
default: 200
|
||||
},
|
||||
documentType: {
|
||||
type: String,
|
||||
default: 'documentation'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'files-added'])
|
||||
const emit = defineEmits(['update:modelValue', 'files-added', 'update:documentType'])
|
||||
|
||||
const dragActive = ref(false)
|
||||
const fileInput = ref(null)
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
:documents="pieceDocuments"
|
||||
@close="closePreview"
|
||||
/>
|
||||
<DocumentEditModal
|
||||
:visible="editModalVisible"
|
||||
:document="editingDocument"
|
||||
@close="editModalVisible = false"
|
||||
@updated="handleDocumentUpdated"
|
||||
/>
|
||||
|
||||
<!-- Piece Header (collapsible, same pattern as ComponentItem) -->
|
||||
<div class="flex items-start justify-between p-4 bg-base-200 rounded-lg">
|
||||
@@ -247,9 +253,11 @@
|
||||
<DocumentListInline
|
||||
:documents="pieceDocuments"
|
||||
:can-delete="isEditMode"
|
||||
:can-edit="isEditMode"
|
||||
:delete-disabled="uploadingDocuments"
|
||||
empty-text="Aucun document lié à cette pièce."
|
||||
@preview="openPreview"
|
||||
@edit="openEditModal"
|
||||
@delete="removeDocument"
|
||||
/>
|
||||
</div>
|
||||
@@ -329,6 +337,7 @@ const {
|
||||
refreshDocuments,
|
||||
handleFilesAdded,
|
||||
removeDocument,
|
||||
editDocument,
|
||||
} = useEntityDocuments({ entity: () => props.piece, entityType: 'piece' })
|
||||
|
||||
const {
|
||||
@@ -343,6 +352,21 @@ const {
|
||||
updateCustomField,
|
||||
} = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' })
|
||||
|
||||
// --- Document edit modal ---
|
||||
const editingDocument = ref(null)
|
||||
const editModalVisible = ref(false)
|
||||
|
||||
const openEditModal = (doc) => {
|
||||
editingDocument.value = doc
|
||||
editModalVisible.value = true
|
||||
}
|
||||
const handleDocumentUpdated = async (data) => {
|
||||
if (!editingDocument.value?.id) return
|
||||
await editDocument(editingDocument.value.id, data)
|
||||
editModalVisible.value = false
|
||||
editingDocument.value = null
|
||||
}
|
||||
|
||||
// --- Collapse state ---
|
||||
const isCollapsed = ref(true)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</template>
|
||||
|
||||
<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 { usePieces } from '~/composables/usePieces'
|
||||
|
||||
@@ -52,43 +52,39 @@ const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | null): void
|
||||
}>()
|
||||
|
||||
const { pieces, loading, loadPieces } = usePieces()
|
||||
const { loading: globalLoading, loadPieces } = usePieces()
|
||||
|
||||
const pieceOptions = computed(() => {
|
||||
const baseOptions = Array.isArray(pieces.value) ? pieces.value : []
|
||||
if (!props.typePieceId) {
|
||||
return baseOptions
|
||||
const localPieces = ref<any[]>([])
|
||||
const localLoading = ref(false)
|
||||
const loading = computed(() => localLoading.value || globalLoading.value)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const allowedTypeId = String(props.typePieceId)
|
||||
return baseOptions.filter((piece: any) => {
|
||||
const typeId =
|
||||
piece?.typePieceId ||
|
||||
piece?.typePiece?.id ||
|
||||
null
|
||||
return typeId ? String(typeId) === allowedTypeId : false
|
||||
})
|
||||
})
|
||||
catch (error: unknown) {
|
||||
console.error('Erreur lors du chargement des pièces:', error)
|
||||
}
|
||||
finally {
|
||||
localLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (pieceOptions.value.length === 0) {
|
||||
loadPieces({ itemsPerPage: 200 }).catch((error: unknown) => {
|
||||
console.error('Erreur lors du chargement des pièces:', error)
|
||||
})
|
||||
}
|
||||
loadFilteredPieces()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
if (typeof value === 'string' && value) {
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
() => props.typePieceId,
|
||||
() => {
|
||||
loadFilteredPieces()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -102,8 +98,12 @@ const updateValue = (value: string | number | null | undefined) => {
|
||||
|
||||
const formatDescription = (option: any) => {
|
||||
const parts: string[] = []
|
||||
const typeName = option?.typePiece?.name
|
||||
if (typeName) {
|
||||
parts.push(typeName)
|
||||
}
|
||||
if (option?.reference) {
|
||||
parts.push(option.reference)
|
||||
parts.push(`Ref. ${option.reference}`)
|
||||
}
|
||||
if (option?.prix !== undefined && option.prix !== null) {
|
||||
const price = Number(option.prix)
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</template>
|
||||
|
||||
<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 { useProducts } from '~/composables/useProducts'
|
||||
|
||||
@@ -52,43 +52,39 @@ const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | null): void
|
||||
}>()
|
||||
|
||||
const { products, loading, loadProducts } = useProducts()
|
||||
const { loading: globalLoading, loadProducts } = useProducts()
|
||||
|
||||
const productOptions = computed(() => {
|
||||
const baseOptions = Array.isArray(products.value) ? products.value : []
|
||||
if (!props.typeProductId) {
|
||||
return baseOptions
|
||||
const localProducts = ref<any[]>([])
|
||||
const localLoading = ref(false)
|
||||
const loading = computed(() => localLoading.value || globalLoading.value)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const allowedTypeId = String(props.typeProductId)
|
||||
return baseOptions.filter((product) => {
|
||||
const typeId =
|
||||
product?.typeProductId ||
|
||||
product?.typeProduct?.id ||
|
||||
null
|
||||
return typeId ? String(typeId) === allowedTypeId : false
|
||||
})
|
||||
})
|
||||
catch (error: unknown) {
|
||||
console.error('Erreur lors du chargement des produits:', error)
|
||||
}
|
||||
finally {
|
||||
localLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (productOptions.value.length === 0) {
|
||||
loadProducts().catch((error) => {
|
||||
console.error('Erreur lors du chargement des produits:', error)
|
||||
})
|
||||
}
|
||||
loadFilteredProducts()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
if (typeof value === 'string' && value) {
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
() => props.typeProductId,
|
||||
() => {
|
||||
loadFilteredProducts()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -102,8 +98,12 @@ const updateValue = (value: string | number | null | undefined) => {
|
||||
|
||||
const formatDescription = (option: any) => {
|
||||
const parts: string[] = []
|
||||
const typeName = option?.typeProduct?.name
|
||||
if (typeName) {
|
||||
parts.push(typeName)
|
||||
}
|
||||
if (option?.reference) {
|
||||
parts.push(option.reference)
|
||||
parts.push(`Ref. ${option.reference}`)
|
||||
}
|
||||
if (option?.supplierPrice !== undefined && option.supplierPrice !== null) {
|
||||
const price = Number(option.supplierPrice)
|
||||
|
||||
@@ -258,18 +258,7 @@
|
||||
{{ piece.typePieceId ? `Sélection : ${getPieceTypeLabel(piece.typePieceId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<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>
|
||||
<!-- Quantity is set per-component on the component edit page -->
|
||||
</div>
|
||||
<button type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)">
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
|
||||
@@ -31,8 +31,9 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium">
|
||||
<div class="font-medium flex items-center gap-2">
|
||||
{{ document.name }}
|
||||
<span class="badge badge-sm badge-outline">{{ getDocumentTypeLabel(document.type || 'documentation') }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-base-content/70">
|
||||
{{ document.mimeType || 'Inconnu' }} • {{ formatSize(document.size) }}
|
||||
@@ -40,6 +41,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
title="Modifier"
|
||||
@click="$emit('edit', document)"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
@@ -74,6 +84,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getDocumentTypeLabel } from '~/shared/documentTypes'
|
||||
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
|
||||
import {
|
||||
documentIcon,
|
||||
@@ -89,10 +100,12 @@ import type { Document } from '~/composables/useDocuments'
|
||||
withDefaults(defineProps<{
|
||||
documents: Document[]
|
||||
canDelete?: boolean
|
||||
canEdit?: boolean
|
||||
deleteDisabled?: boolean
|
||||
emptyText?: string
|
||||
}>(), {
|
||||
canDelete: false,
|
||||
canEdit: false,
|
||||
deleteDisabled: false,
|
||||
emptyText: 'Aucun document.',
|
||||
})
|
||||
@@ -100,5 +113,6 @@ withDefaults(defineProps<{
|
||||
defineEmits<{
|
||||
(e: 'preview', document: Document): void
|
||||
(e: 'delete', documentId: string): void
|
||||
(e: 'edit', document: Document): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
{{ resolveLabel(option) }}
|
||||
</slot>
|
||||
</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">
|
||||
{{ resolveDescription(option) }}
|
||||
</slot>
|
||||
|
||||
@@ -344,7 +344,7 @@ export function useComponentCreate() {
|
||||
selectedDocuments.value = []
|
||||
}
|
||||
toast.showSuccess('Composant créé avec succès')
|
||||
await router.push('/component-catalog')
|
||||
await router.push(`/component/${createdComponent.id}/edit`)
|
||||
}
|
||||
else if (result.error) {
|
||||
toast.showError(result.error)
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import type { SelectionEntry } from '~/shared/utils/structureSelectionUtils'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
@@ -58,9 +57,9 @@ export function useComponentEdit(componentId: string) {
|
||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const { productTypes, loadProductTypes } = useProductTypes()
|
||||
const { updateComposant, loadComposants, composants: componentCatalogRef } = useComposants()
|
||||
const { pieces, loadPieces } = usePieces()
|
||||
const { products, loadProducts } = useProducts()
|
||||
const { updateComposant, composants: componentCatalogRef } = useComposants()
|
||||
const { pieces } = usePieces()
|
||||
const { products } = useProducts()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const toast = useToast()
|
||||
@@ -348,19 +347,17 @@ export function useComponentEdit(componentId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const saveSlotQuantity = async (entry: SelectionEntry) => {
|
||||
const slotId = entry.slotId
|
||||
const quantity = typeof entry._definition?.quantity === 'number'
|
||||
? Math.max(1, entry._definition.quantity)
|
||||
: null
|
||||
if (!slotId || quantity === null) return
|
||||
try {
|
||||
await patch(`/composant-piece-slots/${slotId}`, { quantity })
|
||||
const saveSlotQuantity = async (slotId: string, quantity: number) => {
|
||||
if (!slotId || quantity < 1) return
|
||||
const result = await patch(`/composant-piece-slots/${slotId}`, { quantity: Math.max(1, quantity) })
|
||||
if (result.success) {
|
||||
const structure = component.value?.structure
|
||||
if (structure?.pieces) {
|
||||
const slot = (structure.pieces as any[]).find((s: any) => s.slotId === slotId)
|
||||
if (slot) slot.quantity = quantity
|
||||
}
|
||||
toast.showSuccess('Quantité mise à jour')
|
||||
}
|
||||
catch (error: any) {
|
||||
toast.showError(error?.message || 'Erreur lors de la mise à jour de la quantité')
|
||||
}
|
||||
}
|
||||
|
||||
const submitEdition = async () => {
|
||||
@@ -406,7 +403,7 @@ export function useComponentEdit(componentId: string) {
|
||||
],
|
||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||
)
|
||||
await router.push('/component-catalog')
|
||||
toast.showSuccess('Composant mis à jour avec succès.')
|
||||
}
|
||||
}
|
||||
catch (error: any) {
|
||||
@@ -500,13 +497,6 @@ export function useComponentEdit(componentId: string) {
|
||||
fetchComponent(),
|
||||
])
|
||||
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 {
|
||||
|
||||
@@ -42,6 +42,7 @@ interface LoadComposantsOptions {
|
||||
orderBy?: string
|
||||
orderDir?: 'asc' | 'desc'
|
||||
typeName?: string
|
||||
typeComposantId?: string
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
@@ -109,17 +110,18 @@ export function useComposants() {
|
||||
orderBy = 'name',
|
||||
orderDir = 'asc',
|
||||
typeName,
|
||||
typeComposantId,
|
||||
force = false,
|
||||
} = options
|
||||
|
||||
if (!force && loaded.value && !search && !typeName && page === 1) {
|
||||
if (!force && loaded.value && !search && !typeName && !typeComposantId && page === 1) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: composants.value, total: total.value, page, itemsPerPage },
|
||||
}
|
||||
}
|
||||
|
||||
if (loading.value) {
|
||||
if (!typeComposantId && loading.value) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: composants.value, total: total.value, page, itemsPerPage },
|
||||
@@ -128,33 +130,41 @@ export function useComposants() {
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
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('q', search.trim())
|
||||
}
|
||||
|
||||
if (typeName && typeName.trim()) {
|
||||
params.set('typeComposant.name', typeName.trim())
|
||||
}
|
||||
|
||||
if (typeComposantId) {
|
||||
params.set('typeComposant', typeComposantId)
|
||||
}
|
||||
|
||||
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)
|
||||
loaded.value = true
|
||||
const resultTotal = extractTotal(result.data, items.length)
|
||||
|
||||
if (!typeComposantId) {
|
||||
composants.value = enrichedItems
|
||||
total.value = resultTotal
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: enrichedItems,
|
||||
total: total.value,
|
||||
total: resultTotal,
|
||||
page,
|
||||
itemsPerPage,
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface Document {
|
||||
size: number
|
||||
fileUrl: string
|
||||
downloadUrl: string
|
||||
type?: string
|
||||
/** @deprecated Legacy Base64 data URI — use fileUrl instead */
|
||||
path?: string
|
||||
createdAt?: string
|
||||
@@ -32,6 +33,7 @@ export interface UploadContext {
|
||||
composantId?: string
|
||||
productId?: string
|
||||
pieceId?: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
export interface DocumentResult {
|
||||
@@ -47,6 +49,7 @@ interface LoadDocumentsOptions {
|
||||
orderBy?: string
|
||||
orderDir?: 'asc' | 'desc'
|
||||
attachmentFilter?: string
|
||||
type?: string
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
@@ -63,7 +66,7 @@ const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
||||
}
|
||||
|
||||
export function useDocuments() {
|
||||
const { get, postFormData, delete: del } = useApi()
|
||||
const { get, patch, postFormData, delete: del } = useApi()
|
||||
const { showError, showSuccess } = useToast()
|
||||
|
||||
const loadFromEndpoint = async (
|
||||
@@ -103,10 +106,11 @@ export function useDocuments() {
|
||||
orderBy = 'createdAt',
|
||||
orderDir = 'desc',
|
||||
attachmentFilter = 'all',
|
||||
type = 'all',
|
||||
force = false,
|
||||
} = options
|
||||
|
||||
if (!force && loaded.value && !search && page === 1 && attachmentFilter === 'all') {
|
||||
if (!force && loaded.value && !search && page === 1 && attachmentFilter === 'all' && type === 'all') {
|
||||
return { success: true, data: documents.value }
|
||||
}
|
||||
|
||||
@@ -128,6 +132,10 @@ export function useDocuments() {
|
||||
params.set(`exists[${attachmentFilter}]`, 'true')
|
||||
}
|
||||
|
||||
if (type && type !== 'all') {
|
||||
params.set('type', type)
|
||||
}
|
||||
|
||||
params.set(`order[${orderBy}]`, orderDir)
|
||||
|
||||
const result = await get(`/documents?${params.toString()}`)
|
||||
@@ -218,6 +226,7 @@ export function useDocuments() {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('name', file.name)
|
||||
if (context.type) formData.append('type', context.type)
|
||||
|
||||
if (context.siteId) formData.append('siteId', context.siteId)
|
||||
if (context.machineId) formData.append('machineId', context.machineId)
|
||||
@@ -280,6 +289,33 @@ export function useDocuments() {
|
||||
}
|
||||
}
|
||||
|
||||
const updateDocument = async (
|
||||
id: string,
|
||||
data: { name?: string; type?: string },
|
||||
): Promise<DocumentResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await patch(`/documents/${id}`, data)
|
||||
if (result.success && result.data) {
|
||||
const updated = result.data as Document
|
||||
const index = documents.value.findIndex((doc) => doc.id === id)
|
||||
if (index !== -1) {
|
||||
documents.value[index] = { ...documents.value[index], ...updated }
|
||||
}
|
||||
showSuccess('Document mis à jour')
|
||||
return { success: true, data: updated }
|
||||
}
|
||||
if (result.error) showError(result.error)
|
||||
return result as DocumentResult
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
showError('Impossible de mettre à jour le document')
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
documents,
|
||||
total,
|
||||
@@ -292,6 +328,7 @@ export function useDocuments() {
|
||||
loadDocumentsByPiece,
|
||||
loadDocumentsByProduct,
|
||||
uploadDocuments,
|
||||
updateDocument,
|
||||
deleteDocument,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface EntityDocumentsDeps {
|
||||
|
||||
export function useEntityDocuments(deps: EntityDocumentsDeps) {
|
||||
const { entity, entityType } = deps
|
||||
const { uploadDocuments, deleteDocument } = useDocuments()
|
||||
const { uploadDocuments, deleteDocument, updateDocument } = useDocuments()
|
||||
|
||||
const loadDocumentsFn = entityType === 'composant'
|
||||
? useDocuments().loadDocumentsByComponent
|
||||
@@ -104,6 +104,19 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
|
||||
}
|
||||
}
|
||||
|
||||
const editDocument = async (id: string, data: { name?: string; type?: string }) => {
|
||||
const result: any = await updateDocument(id, data)
|
||||
if (result.success) {
|
||||
const e = entity()
|
||||
const docs = e.documents || []
|
||||
const index = docs.findIndex((doc: any) => doc.id === id)
|
||||
if (index !== -1) {
|
||||
docs[index] = { ...docs[index], ...data }
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return {
|
||||
documents,
|
||||
selectedFiles,
|
||||
@@ -118,5 +131,6 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
|
||||
ensureDocumentsLoaded,
|
||||
handleFilesAdded,
|
||||
removeDocument,
|
||||
editDocument,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,8 +127,8 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
||||
showSuccess(`Type de ${label} "${data.name}" créé`)
|
||||
return { success: true, data: normalized }
|
||||
} catch (error) {
|
||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||
const raw = err?.data?.message || err?.message
|
||||
const err = error as Error & { data?: { error?: string; message?: string }; message?: string }
|
||||
const raw = err?.data?.error || err?.data?.message || err?.message
|
||||
const message = humanizeError(raw)
|
||||
showError(`Impossible de créer le type de ${label} : ${message}`)
|
||||
return { success: false, error: message }
|
||||
@@ -153,8 +153,8 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
||||
showSuccess(`Type de ${label} "${data.name}" mis à jour`)
|
||||
return { success: true, data: normalized }
|
||||
} catch (error) {
|
||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||
const raw = err?.data?.message || err?.message
|
||||
const err = error as Error & { data?: { error?: string; message?: string }; message?: string }
|
||||
const raw = err?.data?.error || err?.data?.message || err?.message
|
||||
const message = humanizeError(raw)
|
||||
showError(`Impossible de mettre à jour le type de ${label} : ${message}`)
|
||||
return { success: false, error: message }
|
||||
@@ -171,8 +171,8 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
||||
showSuccess(`Type de ${label} supprimé`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||
const raw = err?.data?.message || err?.message
|
||||
const err = error as Error & { data?: { error?: string; message?: string }; message?: string }
|
||||
const raw = err?.data?.error || err?.data?.message || err?.message
|
||||
const message = humanizeError(raw)
|
||||
showError(`Impossible de supprimer le type de ${label} : ${message}`)
|
||||
return { success: false, error: message }
|
||||
|
||||
@@ -273,6 +273,7 @@ export const buildMachineHierarchyFromLinks = (
|
||||
originalComposant: originalComponent,
|
||||
machineComponentLink: link,
|
||||
machineComponentLinkId,
|
||||
linkId: machineComponentLinkId,
|
||||
componentLinkId: machineComponentLinkId,
|
||||
parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedComponent.parentComponentLinkId),
|
||||
parentComposantId: resolveIdentifier(appliedComponent.parentComposantId, link.parentComponentId),
|
||||
|
||||
@@ -412,7 +412,7 @@ export function usePieceEdit(pieceId: string) {
|
||||
],
|
||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||
)
|
||||
await router.push('/pieces-catalog')
|
||||
toast.showSuccess('Pièce mise à jour avec succès.')
|
||||
}
|
||||
}
|
||||
catch (error: any) {
|
||||
|
||||
@@ -158,6 +158,13 @@ const buildPayload = (
|
||||
orderIndex: index,
|
||||
}
|
||||
|
||||
if (field.id) {
|
||||
payload.id = field.id
|
||||
}
|
||||
if (field.customFieldId) {
|
||||
payload.customFieldId = field.customFieldId
|
||||
}
|
||||
|
||||
if (type === 'select') {
|
||||
const options = normalizeLineEndings(field.optionsText)
|
||||
.split('\n')
|
||||
|
||||
@@ -43,6 +43,7 @@ interface LoadPiecesOptions {
|
||||
orderBy?: string
|
||||
orderDir?: 'asc' | 'desc'
|
||||
typeName?: string
|
||||
typePieceId?: string
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
@@ -119,17 +120,20 @@ export function usePieces() {
|
||||
orderBy = 'name',
|
||||
orderDir = 'asc',
|
||||
typeName,
|
||||
typePieceId,
|
||||
force = false,
|
||||
} = 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 {
|
||||
success: true,
|
||||
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 {
|
||||
success: true,
|
||||
data: { items: pieces.value, total: total.value, page, itemsPerPage },
|
||||
@@ -138,33 +142,42 @@ export function usePieces() {
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
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('q', search.trim())
|
||||
}
|
||||
|
||||
if (typeName && typeName.trim()) {
|
||||
params.set('typePiece.name', typeName.trim())
|
||||
}
|
||||
|
||||
if (typePieceId) {
|
||||
params.set('typePiece', typePieceId)
|
||||
}
|
||||
|
||||
params.set(`order[${orderBy}]`, orderDir)
|
||||
|
||||
const result = await get(`/pieces?${params.toString()}`)
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||
pieces.value = enrichedItems
|
||||
total.value = extractTotal(result.data, items.length)
|
||||
loaded.value = true
|
||||
const resultTotal = extractTotal(result.data, items.length)
|
||||
|
||||
// Only update global cache for unfiltered queries
|
||||
if (!typePieceId) {
|
||||
pieces.value = enrichedItems
|
||||
total.value = resultTotal
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: enrichedItems,
|
||||
total: total.value,
|
||||
total: resultTotal,
|
||||
page,
|
||||
itemsPerPage,
|
||||
},
|
||||
|
||||
@@ -41,6 +41,7 @@ interface LoadProductsOptions {
|
||||
orderBy?: string
|
||||
orderDir?: 'asc' | 'desc'
|
||||
typeName?: string
|
||||
typeProductId?: string
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
@@ -118,17 +119,18 @@ export function useProducts() {
|
||||
orderBy = 'name',
|
||||
orderDir = 'asc',
|
||||
typeName,
|
||||
typeProductId,
|
||||
force = false,
|
||||
} = options
|
||||
|
||||
if (!force && loaded.value && !search && !typeName && page === 1) {
|
||||
if (!force && loaded.value && !search && !typeName && !typeProductId && page === 1) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: products.value, total: total.value, page, itemsPerPage },
|
||||
}
|
||||
}
|
||||
|
||||
if (loading.value) {
|
||||
if (!typeProductId && loading.value) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: products.value, total: total.value, page, itemsPerPage },
|
||||
@@ -143,27 +145,36 @@ export function useProducts() {
|
||||
params.set('page', String(page))
|
||||
|
||||
if (search && search.trim()) {
|
||||
params.set('name', search.trim())
|
||||
params.set('q', search.trim())
|
||||
}
|
||||
|
||||
if (typeName && typeName.trim()) {
|
||||
params.set('typeProduct.name', typeName.trim())
|
||||
}
|
||||
|
||||
if (typeProductId) {
|
||||
params.set('typeProduct', typeProductId)
|
||||
}
|
||||
|
||||
params.set(`order[${orderBy}]`, orderDir)
|
||||
|
||||
const result = await get(`/products?${params.toString()}`)
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||
products.value = enrichedItems
|
||||
total.value = extractTotal(result.data, items.length)
|
||||
loaded.value = true
|
||||
const resultTotal = extractTotal(result.data, items.length)
|
||||
|
||||
if (!typeProductId) {
|
||||
products.value = enrichedItems
|
||||
total.value = resultTotal
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: enrichedItems,
|
||||
total: total.value,
|
||||
total: resultTotal,
|
||||
page,
|
||||
itemsPerPage,
|
||||
},
|
||||
|
||||
@@ -158,7 +158,6 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
|
||||
await loadComponentTypes({ force: true })
|
||||
showSuccess('Catégorie de composant mise à jour avec succès.')
|
||||
await navigateBackToList()
|
||||
}
|
||||
} catch (error) {
|
||||
showError(normalizeError(error))
|
||||
@@ -183,7 +182,6 @@ const handleSyncConfirm = async () => {
|
||||
})
|
||||
await loadComponentTypes({ force: true })
|
||||
showSuccess('Catégorie de composant mise à jour avec succès.')
|
||||
await navigateBackToList()
|
||||
} catch (error) {
|
||||
showError(normalizeError(error))
|
||||
} finally {
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
:documents="componentDocuments"
|
||||
@close="closePreview"
|
||||
/>
|
||||
<DocumentEditModal
|
||||
:visible="editModalVisible"
|
||||
:document="editingDocument"
|
||||
@close="editModalVisible = false"
|
||||
@updated="handleDocumentUpdated"
|
||||
/>
|
||||
<main class="container mx-auto px-6 py-10">
|
||||
<div v-if="loading" class="flex flex-col items-center gap-4 py-20 text-center">
|
||||
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
|
||||
@@ -45,20 +51,33 @@
|
||||
<label class="label">
|
||||
<span class="label-text">Catégorie de composant</span>
|
||||
</label>
|
||||
<select
|
||||
v-model="selectedTypeId"
|
||||
class="select select-bordered select-sm md:select-md"
|
||||
disabled
|
||||
>
|
||||
<option value="">Sélectionner une catégorie</option>
|
||||
<option
|
||||
v-for="type in componentTypeList"
|
||||
:key="type.id"
|
||||
:value="type.id"
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
v-model="selectedTypeId"
|
||||
class="select select-bordered select-sm md:select-md flex-1"
|
||||
disabled
|
||||
>
|
||||
{{ type.name }}
|
||||
</option>
|
||||
</select>
|
||||
<option value="">Sélectionner une catégorie</option>
|
||||
<option
|
||||
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">
|
||||
La catégorie d'origine ne peut pas être modifiée depuis cette page.
|
||||
</p>
|
||||
@@ -173,12 +192,27 @@
|
||||
<label class="label">
|
||||
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
|
||||
</label>
|
||||
<PieceSelect
|
||||
:model-value="slot.selectedPieceId"
|
||||
:disabled="!canEdit || saving"
|
||||
:type-piece-id="slot.typePieceId"
|
||||
@update:model-value="(value) => savePieceSlotSelection(slot.slotId, value)"
|
||||
/>
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex-1">
|
||||
<PieceSelect
|
||||
:model-value="slot.selectedPieceId"
|
||||
: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>
|
||||
@@ -266,9 +300,11 @@
|
||||
v-else
|
||||
:documents="componentDocuments"
|
||||
:can-delete="canEdit"
|
||||
:can-edit="true"
|
||||
:delete-disabled="uploadingDocuments"
|
||||
empty-text="Aucun document n'est associé à ce composant pour le moment."
|
||||
@preview="openPreview"
|
||||
@edit="openEditModal"
|
||||
@delete="removeDocument"
|
||||
/>
|
||||
</div>
|
||||
@@ -306,10 +342,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from '#imports'
|
||||
import { useComponentEdit } from '~/composables/useComponentEdit'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
|
||||
const route = useRoute()
|
||||
const { updateDocument } = useDocuments()
|
||||
|
||||
const {
|
||||
component,
|
||||
@@ -351,4 +390,24 @@ const {
|
||||
resolveSubcomponentLabel,
|
||||
formatStructurePreview,
|
||||
} = useComponentEdit(String(route.params.id))
|
||||
|
||||
const editingDocument = ref<any | null>(null)
|
||||
const editModalVisible = ref(false)
|
||||
|
||||
const openEditModal = (doc: any) => {
|
||||
editingDocument.value = doc
|
||||
editModalVisible.value = true
|
||||
}
|
||||
const handleDocumentUpdated = async (data: { name?: string; type?: string }) => {
|
||||
if (!editingDocument.value?.id) return
|
||||
const result = await updateDocument(editingDocument.value.id, data)
|
||||
if (result.success) {
|
||||
const idx = componentDocuments.value.findIndex((d: any) => d.id === editingDocument.value?.id)
|
||||
if (idx !== -1) {
|
||||
componentDocuments.value[idx] = { ...componentDocuments.value[idx], ...data }
|
||||
}
|
||||
}
|
||||
editModalVisible.value = false
|
||||
editingDocument.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
@close="closePreview"
|
||||
/>
|
||||
|
||||
<DocumentEditModal
|
||||
:visible="editModalVisible"
|
||||
:document="editingDocument"
|
||||
@close="editModalVisible = false"
|
||||
@updated="handleDocumentUpdated"
|
||||
/>
|
||||
|
||||
<section class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body space-y-6">
|
||||
<DataTable
|
||||
@@ -55,6 +62,26 @@
|
||||
<option value="product">Produits</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="doc-type-filter"
|
||||
>
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
id="doc-type-filter"
|
||||
v-model="typeFilter"
|
||||
class="select select-bordered select-sm"
|
||||
@change="table.handleFilterChange"
|
||||
>
|
||||
<option value="all">Tous</option>
|
||||
<option v-for="t in DOCUMENT_TYPES" :key="t.value" :value="t.value">
|
||||
{{ t.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-name="{ row }">
|
||||
@@ -77,6 +104,10 @@
|
||||
{{ row.mimeType || 'Inconnu' }}
|
||||
</template>
|
||||
|
||||
<template #cell-type="{ row }">
|
||||
<span class="badge badge-sm badge-outline">{{ getDocumentTypeLabel(row.type || 'documentation') }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-size="{ row }">
|
||||
{{ formatSize(row.size) }}
|
||||
</template>
|
||||
@@ -98,6 +129,14 @@
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
v-if="canEdit"
|
||||
class="btn btn-ghost btn-xs"
|
||||
type="button"
|
||||
@click="openEditModal(row)"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs"
|
||||
type="button"
|
||||
@@ -123,12 +162,15 @@ import { computed, onMounted, ref, type Ref } from 'vue'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useDataTable } from '~/composables/useDataTable'
|
||||
import { usePermissions } from '~/composables/usePermissions'
|
||||
import { getFileIcon } from '~/utils/fileIcons'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import { formatFrenchDate } from '~/utils/date'
|
||||
import { DOCUMENT_TYPES, getDocumentTypeLabel } from '~/shared/documentTypes'
|
||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
|
||||
const { documents, total, loading, loadDocuments } = useDocuments()
|
||||
const { documents, total, loading, loadDocuments, updateDocument } = useDocuments()
|
||||
const { canEdit } = usePermissions()
|
||||
|
||||
const table = useDataTable(
|
||||
{ fetchData: fetchDocuments },
|
||||
@@ -139,21 +181,26 @@ const table = useDataTable(
|
||||
persistToUrl: true,
|
||||
extraParams: {
|
||||
filter: { default: 'all' },
|
||||
typeFilter: { default: 'all' },
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const attachmentFilter = table.filters.filter as Ref<string>
|
||||
const typeFilter = table.filters.typeFilter as Ref<string>
|
||||
|
||||
const previewDocument = ref<any>(null)
|
||||
const previewVisible = ref(false)
|
||||
const editingDocument = ref<any>(null)
|
||||
const editModalVisible = ref(false)
|
||||
|
||||
const documentsOnPage = computed(() => documents.value.length)
|
||||
const paginationState = table.pagination(total, documentsOnPage)
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Nom', sortable: true, sortKey: 'name' },
|
||||
{ key: 'mimeType', label: 'Type' },
|
||||
{ key: 'mimeType', label: 'Type MIME' },
|
||||
{ key: 'type', label: 'Type' },
|
||||
{ key: 'size', label: 'Taille', sortable: true, sortKey: 'size' },
|
||||
{ key: 'attachment', label: 'Rattaché à' },
|
||||
{ key: 'createdAt', label: 'Date', sortable: true, sortKey: 'createdAt' },
|
||||
@@ -168,6 +215,7 @@ async function fetchDocuments() {
|
||||
orderBy: table.sortField.value,
|
||||
orderDir: table.sortDirection.value as 'asc' | 'desc',
|
||||
attachmentFilter: attachmentFilter.value,
|
||||
type: typeFilter.value,
|
||||
force: true,
|
||||
})
|
||||
}
|
||||
@@ -198,6 +246,25 @@ const closePreview = () => {
|
||||
previewDocument.value = null
|
||||
}
|
||||
|
||||
const openEditModal = (doc: any) => {
|
||||
editingDocument.value = doc
|
||||
editModalVisible.value = true
|
||||
}
|
||||
|
||||
const handleDocumentUpdated = async (data: { name: string; type: string }) => {
|
||||
if (!editingDocument.value?.id) return
|
||||
const result = await updateDocument(editingDocument.value.id, data)
|
||||
if (result.success) {
|
||||
const doc = documents.value.find((d) => d.id === editingDocument.value.id)
|
||||
if (doc) {
|
||||
doc.name = data.name
|
||||
doc.type = data.type
|
||||
}
|
||||
}
|
||||
editModalVisible.value = false
|
||||
editingDocument.value = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchDocuments()
|
||||
})
|
||||
|
||||
@@ -16,16 +16,23 @@
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Site</span>
|
||||
<span class="label-text">Sites</span>
|
||||
</label>
|
||||
<select v-model="selectedSite" class="select select-bordered">
|
||||
<option value="">
|
||||
Tous les sites
|
||||
</option>
|
||||
<option v-for="site in sites" :key="site.id" :value="site.id">
|
||||
{{ site.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<label
|
||||
v-for="site in sites"
|
||||
:key="site.id"
|
||||
class="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
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 class="form-control">
|
||||
<label class="label">
|
||||
@@ -113,7 +120,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useMachines } from '~/composables/useMachines'
|
||||
import { useSites } from '~/composables/useSites'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
@@ -128,7 +135,7 @@ const { machines, loading, loadMachines, deleteMachine } = useMachines()
|
||||
const { sites, loadSites } = useSites()
|
||||
const toast = useToast()
|
||||
|
||||
const selectedSite = ref('')
|
||||
const selectedSites = reactive(new Set())
|
||||
const searchQuery = ref('')
|
||||
|
||||
// Enrichir les machines avec les objets site complets
|
||||
@@ -145,8 +152,8 @@ const enrichedMachines = computed(() => {
|
||||
const filteredMachines = computed(() => {
|
||||
let filtered = enrichedMachines.value
|
||||
|
||||
if (selectedSite.value) {
|
||||
filtered = filtered.filter(machine => machine.siteId === selectedSite.value)
|
||||
if (selectedSites.size > 0) {
|
||||
filtered = filtered.filter(machine => selectedSites.has(machine.siteId))
|
||||
}
|
||||
|
||||
if (searchQuery.value.trim()) {
|
||||
@@ -157,6 +164,10 @@ const filteredMachines = computed(() => {
|
||||
)
|
||||
}
|
||||
|
||||
filtered = [...filtered].sort((a, b) =>
|
||||
(a.name || '').localeCompare(b.name || '', 'fr')
|
||||
)
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
|
||||
@@ -156,7 +156,6 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
|
||||
await loadPieceTypes({ force: true })
|
||||
showSuccess('Catégorie de pièce mise à jour avec succès.')
|
||||
await navigateBackToList()
|
||||
}
|
||||
} catch (error) {
|
||||
showError(normalizeError(error))
|
||||
@@ -181,7 +180,6 @@ const handleSyncConfirm = async () => {
|
||||
})
|
||||
await loadPieceTypes({ force: true })
|
||||
showSuccess('Catégorie de pièce mise à jour avec succès.')
|
||||
await navigateBackToList()
|
||||
} catch (error) {
|
||||
showError(normalizeError(error))
|
||||
} finally {
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
:documents="pieceDocuments"
|
||||
@close="closePreview"
|
||||
/>
|
||||
<DocumentEditModal
|
||||
:visible="editModalVisible"
|
||||
:document="editingDocument"
|
||||
@close="editModalVisible = false"
|
||||
@updated="handleDocumentUpdated"
|
||||
/>
|
||||
<main class="container mx-auto px-6 py-10">
|
||||
<div v-if="loading" class="flex flex-col items-center gap-4 py-20 text-center">
|
||||
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
|
||||
@@ -231,9 +237,11 @@
|
||||
v-else
|
||||
:documents="pieceDocuments"
|
||||
:can-delete="canEdit"
|
||||
:can-edit="true"
|
||||
:delete-disabled="uploadingDocuments"
|
||||
empty-text="Aucun document n'est associé à cette pièce pour le moment."
|
||||
@preview="openPreview"
|
||||
@edit="openEditModal"
|
||||
@delete="removeDocument"
|
||||
/>
|
||||
</div>
|
||||
@@ -271,10 +279,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from '#imports'
|
||||
import { usePieceEdit } from '~/composables/usePieceEdit'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
|
||||
const route = useRoute()
|
||||
const { updateDocument } = useDocuments()
|
||||
|
||||
const {
|
||||
piece,
|
||||
@@ -310,4 +321,24 @@ const {
|
||||
submitEdition,
|
||||
formatPieceStructurePreview,
|
||||
} = usePieceEdit(String(route.params.id))
|
||||
|
||||
const editingDocument = ref<any | null>(null)
|
||||
const editModalVisible = ref(false)
|
||||
|
||||
const openEditModal = (doc: any) => {
|
||||
editingDocument.value = doc
|
||||
editModalVisible.value = true
|
||||
}
|
||||
const handleDocumentUpdated = async (data: { name?: string; type?: string }) => {
|
||||
if (!editingDocument.value?.id) return
|
||||
const result = await updateDocument(editingDocument.value.id, data)
|
||||
if (result.success) {
|
||||
const idx = pieceDocuments.value.findIndex((d: any) => d.id === editingDocument.value?.id)
|
||||
if (idx !== -1) {
|
||||
pieceDocuments.value[idx] = { ...pieceDocuments.value[idx], ...data }
|
||||
}
|
||||
}
|
||||
editModalVisible.value = false
|
||||
editingDocument.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -466,7 +466,7 @@ const submitCreation = async () => {
|
||||
selectedDocuments.value = []
|
||||
}
|
||||
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) {
|
||||
toast.showError(result.error)
|
||||
}
|
||||
|
||||
@@ -156,7 +156,6 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
|
||||
await loadProductTypes({ force: true })
|
||||
showSuccess('Catégorie de produit mise à jour avec succès.')
|
||||
await navigateBackToList()
|
||||
}
|
||||
} catch (error) {
|
||||
showError(normalizeError(error))
|
||||
@@ -181,7 +180,6 @@ const handleSyncConfirm = async () => {
|
||||
})
|
||||
await loadProductTypes({ force: true })
|
||||
showSuccess('Catégorie de produit mise à jour avec succès.')
|
||||
await navigateBackToList()
|
||||
} catch (error) {
|
||||
showError(normalizeError(error))
|
||||
} finally {
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
:documents="productDocuments"
|
||||
@close="closePreview"
|
||||
/>
|
||||
<DocumentEditModal
|
||||
:visible="editModalVisible"
|
||||
:document="editingDocument"
|
||||
@close="editModalVisible = false"
|
||||
@updated="handleDocumentUpdated"
|
||||
/>
|
||||
<main class="container mx-auto px-6 py-10">
|
||||
<div v-if="loading" class="flex flex-col items-center gap-4 py-16 text-center">
|
||||
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
|
||||
@@ -167,9 +173,11 @@
|
||||
v-else
|
||||
:documents="productDocuments"
|
||||
:can-delete="canEdit"
|
||||
:can-edit="true"
|
||||
:delete-disabled="uploadingDocuments || saving"
|
||||
empty-text="Aucun document n'est associé à ce produit pour le moment."
|
||||
@preview="openPreview"
|
||||
@edit="openEditModal"
|
||||
@delete="removeDocument"
|
||||
/>
|
||||
</div>
|
||||
@@ -244,6 +252,7 @@ const {
|
||||
loadDocumentsByProduct,
|
||||
uploadDocuments: uploadProductDocuments,
|
||||
deleteDocument: deleteProductDocument,
|
||||
updateDocument,
|
||||
} = useDocuments()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
const {
|
||||
@@ -265,6 +274,8 @@ const loadingDocuments = ref(false)
|
||||
const productDocuments = ref<any[]>([])
|
||||
const previewDocument = ref<any | null>(null)
|
||||
const previewVisible = ref(false)
|
||||
const editingDocument = ref<any | null>(null)
|
||||
const editModalVisible = ref(false)
|
||||
|
||||
const historyFieldLabels: Record<string, string> = {
|
||||
name: 'Nom',
|
||||
@@ -307,6 +318,23 @@ const openPreview = (doc: any) => {
|
||||
}
|
||||
const closePreview = () => { previewVisible.value = false; previewDocument.value = null }
|
||||
|
||||
const openEditModal = (doc: any) => {
|
||||
editingDocument.value = doc
|
||||
editModalVisible.value = true
|
||||
}
|
||||
const handleDocumentUpdated = async (data: { name?: string; type?: string }) => {
|
||||
if (!editingDocument.value?.id) return
|
||||
const result = await updateDocument(editingDocument.value.id, data)
|
||||
if (result.success) {
|
||||
const idx = productDocuments.value.findIndex((d: any) => d.id === editingDocument.value?.id)
|
||||
if (idx !== -1) {
|
||||
productDocuments.value[idx] = { ...productDocuments.value[idx], ...data }
|
||||
}
|
||||
}
|
||||
editModalVisible.value = false
|
||||
editingDocument.value = null
|
||||
}
|
||||
|
||||
const loadProduct = async () => {
|
||||
const id = route.params.id
|
||||
if (!id || typeof id !== 'string') {
|
||||
@@ -482,7 +510,6 @@ const submitEdition = async () => {
|
||||
return
|
||||
}
|
||||
toast.showSuccess('Produit mis à jour avec succès')
|
||||
await router.push('/product-catalog')
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')
|
||||
|
||||
@@ -352,7 +352,7 @@ const submitCreation = async () => {
|
||||
}
|
||||
}
|
||||
toast.showSuccess('Produit créé avec succès')
|
||||
await router.push('/product-catalog')
|
||||
await router.push(`/product/${productId}/edit`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.showError(error?.message || 'Erreur lors de la création du produit')
|
||||
|
||||
@@ -155,9 +155,7 @@ export const buildConstructeurRequestPayload = <T extends Record<string, any>>(
|
||||
delete next.constructeurs;
|
||||
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[] };
|
||||
};
|
||||
|
||||
15
app/shared/documentTypes.ts
Normal file
15
app/shared/documentTypes.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export const DOCUMENT_TYPES = [
|
||||
{ value: 'documentation', label: 'Documentation' },
|
||||
{ value: 'devis', label: 'Devis' },
|
||||
{ value: 'facture', label: 'Facture' },
|
||||
{ value: 'plan', label: 'Plan' },
|
||||
{ value: 'photo', label: 'Photo' },
|
||||
{ value: 'autre', label: 'Autre' },
|
||||
] as const
|
||||
|
||||
export type DocumentTypeValue = (typeof DOCUMENT_TYPES)[number]['value']
|
||||
|
||||
export const getDocumentTypeLabel = (value: string): string => {
|
||||
const found = DOCUMENT_TYPES.find((t) => t.value === value)
|
||||
return found?.label ?? value
|
||||
}
|
||||
@@ -125,6 +125,8 @@ const hydratePieceCustomFields = (fields: any[]): PieceModelStructureEditorField
|
||||
? field.options.join('\n')
|
||||
: '',
|
||||
orderIndex: typeof field?.orderIndex === 'number' ? field.orderIndex : index,
|
||||
...(field?.id ? { id: field.id } : {}),
|
||||
...(field?.customFieldId ? { customFieldId: field.customFieldId } : {}),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,8 @@ export interface PieceModelCustomField {
|
||||
key?: string
|
||||
value?: unknown
|
||||
defaultValue?: string | null
|
||||
id?: string
|
||||
customFieldId?: string
|
||||
}
|
||||
|
||||
export interface PieceModelProduct {
|
||||
|
||||
Reference in New Issue
Block a user