890 lines
28 KiB
Vue
890 lines
28 KiB
Vue
<template>
|
|
<div class="space-y-4">
|
|
<DocumentPreviewModal
|
|
:document="previewDocument"
|
|
:visible="previewVisible"
|
|
@close="closePreview"
|
|
/>
|
|
|
|
<!-- Component Header -->
|
|
<div class="flex items-start justify-between p-4 bg-base-200 rounded-lg">
|
|
<div class="flex items-start gap-3 w-full">
|
|
<button
|
|
type="button"
|
|
class="btn btn-ghost btn-sm btn-circle shrink-0 transition-transform"
|
|
:class="{ 'rotate-90': !isCollapsed }"
|
|
:aria-expanded="!isCollapsed"
|
|
:title="isCollapsed ? 'Déplier les détails du composant' : 'Replier les détails du composant'"
|
|
@click="toggleCollapse"
|
|
>
|
|
<IconLucideChevronRight class="w-5 h-5 transition-transform" aria-hidden="true" />
|
|
<span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} le composant</span>
|
|
</button>
|
|
<div class="flex-1">
|
|
<h3 class="text-lg font-semibold">
|
|
{{ component.name }}
|
|
</h3>
|
|
<div class="flex flex-wrap gap-2 mt-2">
|
|
<span v-if="component.reference" class="badge badge-outline badge-sm">{{ component.reference }}</span>
|
|
<span v-if="component.constructeur" class="badge badge-outline badge-sm">{{ component.constructeur?.name }}</span>
|
|
<span v-if="component.prix" class="badge badge-primary badge-sm">{{ component.prix }}€</span>
|
|
<span
|
|
v-if="component.typeMachineComponentRequirement"
|
|
class="badge badge-outline badge-sm"
|
|
>
|
|
Groupe : {{ component.typeMachineComponentRequirement.label || component.typeMachineComponentRequirement.typeComposant?.name || 'Non défini' }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-show="!isCollapsed" class="space-y-4">
|
|
<!-- Component Info Display - Editable or Read-only -->
|
|
<div class="p-4 bg-base-100 border border-gray-200 rounded-lg">
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">Nom</span></label>
|
|
<input
|
|
v-if="isEditMode"
|
|
v-model="component.name"
|
|
type="text"
|
|
class="input input-bordered input-sm"
|
|
@blur="updateComponent"
|
|
>
|
|
<div v-else class="input input-bordered input-sm bg-base-200">
|
|
{{ component.name }}
|
|
</div>
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">Référence</span></label>
|
|
<input
|
|
v-if="isEditMode"
|
|
v-model="component.reference"
|
|
type="text"
|
|
class="input input-bordered input-sm"
|
|
@blur="updateComponent"
|
|
>
|
|
<div v-else class="input input-bordered input-sm bg-base-200">
|
|
{{ component.reference || 'Non définie' }}
|
|
</div>
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">Prix</span></label>
|
|
<input
|
|
v-if="isEditMode"
|
|
v-model="component.prix"
|
|
type="number"
|
|
step="0.01"
|
|
class="input input-bordered input-sm"
|
|
@blur="updateComponent"
|
|
>
|
|
<div v-else class="input input-bordered input-sm bg-base-200">
|
|
{{ component.prix ? `${component.prix}€` : 'Non défini' }}
|
|
</div>
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">Constructeur</span></label>
|
|
<ConstructeurSelect
|
|
v-if="isEditMode"
|
|
class="w-full"
|
|
:model-value="component.constructeurId || component.constructeur?.id || null"
|
|
@update:model-value="handleConstructeurChange"
|
|
/>
|
|
<div v-else class="input input-bordered input-sm bg-base-200">
|
|
<div class="flex flex-col">
|
|
<span class="font-medium">{{ component.constructeur?.name || 'Non défini' }}</span>
|
|
<span class="text-xs text-gray-500">
|
|
{{ [component.constructeur?.email, component.constructeur?.phone].filter(Boolean).join(' • ') }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- Custom Fields Display - Editable or Read-only -->
|
|
<div v-if="displayedCustomFields.length" class="mt-4 pt-4 border-t border-gray-200">
|
|
<h4 class="font-semibold text-sm text-gray-700 mb-3">
|
|
Champs personnalisés
|
|
</h4>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div
|
|
v-for="(field, index) in displayedCustomFields"
|
|
:key="resolveFieldKey(field, index)"
|
|
class="form-control"
|
|
>
|
|
<label class="label">
|
|
<span class="label-text text-sm">{{ resolveFieldName(field) }}</span>
|
|
<span v-if="resolveFieldRequired(field)" class="label-text-alt text-error">*</span>
|
|
</label>
|
|
<template v-if="isEditMode && !resolveFieldReadOnly(field)">
|
|
<input
|
|
v-if="resolveFieldType(field) === 'text'"
|
|
v-model="field.value"
|
|
type="text"
|
|
class="input input-bordered input-sm"
|
|
:required="resolveFieldRequired(field)"
|
|
@blur="updateComponentCustomField(field)"
|
|
>
|
|
<input
|
|
v-else-if="resolveFieldType(field) === 'number'"
|
|
v-model="field.value"
|
|
type="number"
|
|
class="input input-bordered input-sm"
|
|
:required="resolveFieldRequired(field)"
|
|
@blur="updateComponentCustomField(field)"
|
|
>
|
|
<select
|
|
v-else-if="resolveFieldType(field) === 'select'"
|
|
v-model="field.value"
|
|
class="select select-bordered select-sm"
|
|
:required="resolveFieldRequired(field)"
|
|
@change="updateComponentCustomField(field)"
|
|
>
|
|
<option value="">
|
|
Sélectionner...
|
|
</option>
|
|
<option v-for="option in resolveFieldOptions(field)" :key="option" :value="option">
|
|
{{ option }}
|
|
</option>
|
|
</select>
|
|
<div v-else-if="resolveFieldType(field) === 'boolean'" class="flex items-center gap-2">
|
|
<input
|
|
v-model="field.value"
|
|
type="checkbox"
|
|
class="checkbox checkbox-sm"
|
|
true-value="true"
|
|
false-value="false"
|
|
@change="updateComponentCustomField(field)"
|
|
>
|
|
<span class="text-sm">{{ String(field.value).toLowerCase() === 'true' ? 'Oui' : 'Non' }}</span>
|
|
</div>
|
|
<input
|
|
v-else-if="resolveFieldType(field) === 'date'"
|
|
v-model="field.value"
|
|
type="date"
|
|
class="input input-bordered input-sm"
|
|
:required="resolveFieldRequired(field)"
|
|
@blur="updateComponentCustomField(field)"
|
|
>
|
|
</template>
|
|
<template v-else>
|
|
<div class="input input-bordered input-sm bg-base-200">
|
|
{{ formatFieldDisplayValue(field) }}
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-4 pt-4 border-t border-gray-200 space-y-3">
|
|
<div class="flex items-center justify-between">
|
|
<h4 class="font-semibold text-sm text-gray-700">
|
|
Documents
|
|
</h4>
|
|
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
|
|
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }} sélectionné{{ selectedFiles.length > 1 ? 's' : '' }}
|
|
</span>
|
|
</div>
|
|
|
|
<p v-if="loadingDocuments" class="text-xs text-gray-500">
|
|
Chargement des documents...
|
|
</p>
|
|
|
|
<DocumentUpload
|
|
v-if="isEditMode"
|
|
v-model="selectedFiles"
|
|
title="Déposer des fichiers pour ce composant"
|
|
subtitle="Formats acceptés : PDF, images, documents..."
|
|
@files-added="handleFilesAdded"
|
|
/>
|
|
|
|
<div v-if="componentDocuments.length" class="space-y-2">
|
|
<div
|
|
v-for="document in componentDocuments"
|
|
:key="document.id"
|
|
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
|
|
>
|
|
<div class="flex items-center gap-3 text-sm">
|
|
<span class="text-xl" :class="documentIcon(document).colorClass">
|
|
<component
|
|
:is="documentIcon(document).component"
|
|
class="h-6 w-6"
|
|
aria-hidden="true"
|
|
/>
|
|
</span>
|
|
<div>
|
|
<div class="font-medium">
|
|
{{ document.name }}
|
|
</div>
|
|
<div class="text-xs text-gray-500">
|
|
{{ document.mimeType || 'Inconnu' }} • {{ formatSize(document.size) }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
class="btn btn-ghost btn-xs"
|
|
:disabled="!canPreviewDocument(document)"
|
|
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
|
|
@click="openPreview(document)"
|
|
>
|
|
Consulter
|
|
</button>
|
|
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
|
|
Télécharger
|
|
</button>
|
|
<button
|
|
v-if="isEditMode"
|
|
type="button"
|
|
class="btn btn-error btn-xs"
|
|
:disabled="uploadingDocuments"
|
|
@click="removeDocument(document.id)"
|
|
>
|
|
Supprimer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p v-else-if="!loadingDocuments" class="text-xs text-gray-500">
|
|
Aucun document lié à ce composant.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Component Pieces -->
|
|
<div v-if="component.pieces && component.pieces.length > 0" class="space-y-2">
|
|
<h4 class="font-semibold text-gray-700">
|
|
Pièces du composant
|
|
</h4>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
<PieceItem
|
|
v-for="piece in component.pieces"
|
|
:key="piece.id"
|
|
:piece="piece"
|
|
:is-edit-mode="isEditMode"
|
|
:piece-model-options="pieceModelOptionsProvider(piece)"
|
|
@update="updatePiece"
|
|
@edit="editPiece"
|
|
@custom-field-update="updatePieceCustomField"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sub Components -->
|
|
<div v-if="childComponents.length > 0" class="space-y-3">
|
|
<h4 class="font-semibold text-gray-700">
|
|
Sous-composants
|
|
</h4>
|
|
<div class="space-y-3 pl-4 border-l-2 border-gray-200">
|
|
<ComponentItem
|
|
v-for="subComponent in childComponents"
|
|
:key="subComponent.id"
|
|
:component="subComponent"
|
|
:is-edit-mode="isEditMode"
|
|
:collapse-all="collapseAll"
|
|
:toggle-token="toggleToken"
|
|
@update="$emit('update', $event)"
|
|
@edit-piece="$emit('edit-piece', $event)"
|
|
@custom-field-update="$emit('custom-field-update', $event)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, watch, computed } from 'vue'
|
|
import PieceItem from './PieceItem.vue'
|
|
import DocumentUpload from './DocumentUpload.vue'
|
|
import ConstructeurSelect from './ConstructeurSelect.vue'
|
|
import { useDocuments } from '~/composables/useDocuments'
|
|
import { getFileIcon } from '~/utils/fileIcons'
|
|
import { canPreviewDocument } from '~/utils/documentPreview'
|
|
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
|
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
|
import { useCustomFields } from '~/composables/useCustomFields'
|
|
import { useToast } from '~/composables/useToast'
|
|
|
|
const props = defineProps({
|
|
component: {
|
|
type: Object,
|
|
required: true
|
|
},
|
|
isEditMode: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
collapseAll: {
|
|
type: Boolean,
|
|
default: true
|
|
},
|
|
toggleToken: {
|
|
type: Number,
|
|
default: 0
|
|
}
|
|
})
|
|
|
|
const emit = defineEmits([
|
|
'update',
|
|
'edit-piece',
|
|
'custom-field-update'
|
|
])
|
|
|
|
const isCollapsed = ref(true)
|
|
const selectedFiles = ref([])
|
|
const uploadingDocuments = ref(false)
|
|
const loadingDocuments = ref(false)
|
|
const documentsLoaded = ref(!!(props.component.documents && props.component.documents.length))
|
|
const componentDocuments = computed(() => props.component.documents || [])
|
|
const documentIcon = doc => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
|
|
const previewDocument = ref(null)
|
|
const previewVisible = ref(false)
|
|
|
|
const childComponents = computed(() => {
|
|
const list = props.component.subcomponents || props.component.subComponents || []
|
|
return Array.isArray(list) ? list : []
|
|
})
|
|
|
|
const extractStructureCustomFields = (structure) => {
|
|
if (!structure || typeof structure !== 'object') {
|
|
return []
|
|
}
|
|
const customFields = structure.customFields
|
|
return Array.isArray(customFields) ? customFields : []
|
|
}
|
|
|
|
function fieldKeyFromNameAndType(name, type) {
|
|
const normalizedName = typeof name === 'string' ? name : ''
|
|
const normalizedType = typeof type === 'string' ? type : ''
|
|
return normalizedName ? `${normalizedName}::${normalizedType}` : null
|
|
}
|
|
|
|
function mergeFieldDefinitionsWithValues(definitions, values) {
|
|
const definitionList = Array.isArray(definitions) ? definitions : []
|
|
const valueList = Array.isArray(values) ? values : []
|
|
|
|
const valueMap = new Map()
|
|
valueList.forEach((entry) => {
|
|
if (!entry || typeof entry !== 'object') {
|
|
return
|
|
}
|
|
const fieldId = entry.customField?.id ?? entry.customFieldId ?? null
|
|
if (fieldId) {
|
|
valueMap.set(fieldId, entry)
|
|
}
|
|
const nameKey = fieldKeyFromNameAndType(entry.customField?.name, entry.customField?.type)
|
|
if (nameKey) {
|
|
valueMap.set(nameKey, entry)
|
|
}
|
|
})
|
|
|
|
const merged = definitionList.map((field) => {
|
|
if (!field || typeof field !== 'object') {
|
|
return field
|
|
}
|
|
|
|
const fieldId = ensureCustomFieldId(field)
|
|
const nameKey = fieldKeyFromNameAndType(resolveFieldName(field), resolveFieldType(field))
|
|
|
|
const matchedValue =
|
|
(fieldId ? valueMap.get(fieldId) : undefined) ??
|
|
(nameKey ? valueMap.get(nameKey) : undefined)
|
|
|
|
if (!matchedValue) {
|
|
return {
|
|
...field,
|
|
value: field?.value ?? ''
|
|
}
|
|
}
|
|
|
|
return {
|
|
...field,
|
|
customFieldValueId: matchedValue.id ?? field.customFieldValueId ?? null,
|
|
customFieldId:
|
|
matchedValue.customField?.id ??
|
|
matchedValue.customFieldId ??
|
|
fieldId ??
|
|
null,
|
|
customField: matchedValue.customField ?? field.customField ?? null,
|
|
value: matchedValue.value ?? field.value ?? ''
|
|
}
|
|
})
|
|
|
|
valueList.forEach((entry) => {
|
|
if (!entry || typeof entry !== 'object') {
|
|
return
|
|
}
|
|
|
|
const fieldId = entry.customField?.id ?? entry.customFieldId ?? null
|
|
const nameKey = fieldKeyFromNameAndType(entry.customField?.name, entry.customField?.type)
|
|
|
|
const exists = merged.some((field) => {
|
|
if (!field || typeof field !== 'object') {
|
|
return false
|
|
}
|
|
if (field.customFieldValueId && field.customFieldValueId === entry.id) {
|
|
return true
|
|
}
|
|
const existingId = ensureCustomFieldId(field)
|
|
if (fieldId && existingId && existingId === fieldId) {
|
|
return true
|
|
}
|
|
if (!fieldId && nameKey) {
|
|
return (
|
|
fieldKeyFromNameAndType(resolveFieldName(field), resolveFieldType(field)) === nameKey
|
|
)
|
|
}
|
|
return false
|
|
})
|
|
|
|
if (!exists) {
|
|
merged.push({
|
|
customFieldValueId: entry.id ?? null,
|
|
customFieldId: fieldId,
|
|
name: entry.customField?.name ?? '',
|
|
type: entry.customField?.type ?? 'text',
|
|
required: entry.customField?.required ?? false,
|
|
options: entry.customField?.options ?? [],
|
|
value: entry.value ?? '',
|
|
customField: entry.customField ?? null
|
|
})
|
|
}
|
|
})
|
|
|
|
return merged
|
|
}
|
|
|
|
const componentDefinitionSources = computed(() => {
|
|
const requirement = props.component.typeMachineComponentRequirement || {}
|
|
const type = requirement.typeComposant || props.component.typeComposant || {}
|
|
|
|
const definitions = []
|
|
const pushFields = (collection) => {
|
|
if (Array.isArray(collection)) {
|
|
definitions.push(...collection)
|
|
}
|
|
}
|
|
|
|
pushFields(props.component.customFields)
|
|
pushFields(props.component.definition?.customFields)
|
|
pushFields(type.customFields)
|
|
pushFields(requirement.customFields)
|
|
pushFields(requirement.definition?.customFields)
|
|
|
|
;[
|
|
props.component.definition?.structure,
|
|
type.structure,
|
|
type.componentSkeleton,
|
|
requirement.structure,
|
|
requirement.componentSkeleton,
|
|
].forEach((structure) => {
|
|
const fields = extractStructureCustomFields(structure)
|
|
if (fields.length) {
|
|
definitions.push(...fields)
|
|
}
|
|
})
|
|
|
|
return definitions
|
|
})
|
|
|
|
const displayedCustomFields = computed(() =>
|
|
mergeFieldDefinitionsWithValues(
|
|
componentDefinitionSources.value,
|
|
props.component.customFieldValues,
|
|
),
|
|
)
|
|
|
|
const candidateCustomFields = computed(() => {
|
|
const map = new Map()
|
|
const register = (collection) => {
|
|
if (!Array.isArray(collection)) {
|
|
return
|
|
}
|
|
collection.forEach((item) => {
|
|
if (!item || typeof item !== 'object') {
|
|
return
|
|
}
|
|
const id = item.id || item.customFieldId
|
|
const name = typeof item.name === 'string' ? item.name : null
|
|
const key = id || (name ? `${name}::${item.type ?? ''}` : null)
|
|
if (!key || map.has(key)) {
|
|
return
|
|
}
|
|
map.set(key, item)
|
|
})
|
|
}
|
|
|
|
register(props.component.customFieldValues?.map((value) => value?.customField))
|
|
register(componentDefinitionSources.value)
|
|
|
|
return Array.from(map.values())
|
|
})
|
|
|
|
watch(
|
|
candidateCustomFields,
|
|
() => {
|
|
displayedCustomFields.value.forEach((field) => ensureCustomFieldId(field))
|
|
},
|
|
{ immediate: true, deep: true }
|
|
)
|
|
|
|
watch(
|
|
displayedCustomFields,
|
|
(fields) => {
|
|
(fields || []).forEach((field) => ensureCustomFieldId(field))
|
|
},
|
|
{ immediate: true, deep: true }
|
|
)
|
|
|
|
const handleConstructeurChange = async (value) => {
|
|
props.component.constructeurId = value
|
|
await updateComponent()
|
|
}
|
|
|
|
const { uploadDocuments, deleteDocument, loadDocumentsByComponent } = useDocuments()
|
|
const {
|
|
updateCustomFieldValue: updateComponentCustomFieldValueApi,
|
|
upsertCustomFieldValue: upsertComponentCustomFieldValue,
|
|
} = useCustomFields()
|
|
const { showSuccess, showError } = useToast()
|
|
|
|
watch(
|
|
() => props.toggleToken,
|
|
() => {
|
|
isCollapsed.value = props.collapseAll
|
|
if (!isCollapsed.value) {
|
|
ensureDocumentsLoaded()
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
watch(
|
|
() => props.component.documents,
|
|
(docs) => {
|
|
documentsLoaded.value = !!(docs && docs.length)
|
|
}
|
|
)
|
|
|
|
const toggleCollapse = () => {
|
|
isCollapsed.value = !isCollapsed.value
|
|
if (!isCollapsed.value) {
|
|
ensureDocumentsLoaded()
|
|
}
|
|
}
|
|
|
|
const updateComponent = () => {
|
|
emit('update', props.component)
|
|
}
|
|
|
|
function resolveFieldKey(field, index) {
|
|
return field?.id
|
|
?? field?.customFieldValueId
|
|
?? field?.customFieldId
|
|
?? field?.name
|
|
?? `field-${index}`
|
|
}
|
|
|
|
function resolveFieldId(field) {
|
|
return field?.customFieldValueId ?? null
|
|
}
|
|
|
|
function resolveFieldName(field) {
|
|
return field?.name ?? 'Champ'
|
|
}
|
|
|
|
function resolveFieldType(field) {
|
|
return field?.type ?? 'text'
|
|
}
|
|
|
|
function resolveFieldOptions(field) {
|
|
return field?.options ?? []
|
|
}
|
|
|
|
function resolveFieldRequired(field) {
|
|
return !!field?.required
|
|
}
|
|
|
|
function resolveFieldReadOnly(field) {
|
|
return !!field?.readOnly
|
|
}
|
|
|
|
function buildCustomFieldMetadata(field) {
|
|
return {
|
|
customFieldName: resolveFieldName(field),
|
|
customFieldType: resolveFieldType(field),
|
|
customFieldRequired: resolveFieldRequired(field),
|
|
customFieldOptions: resolveFieldOptions(field)
|
|
}
|
|
}
|
|
|
|
function resolveCustomFieldId(field) {
|
|
return field?.customFieldId ?? field?.id ?? field?.customField?.id ?? null
|
|
}
|
|
|
|
function ensureCustomFieldId(field) {
|
|
const existingId = resolveCustomFieldId(field)
|
|
if (existingId) {
|
|
return existingId
|
|
}
|
|
|
|
const name = resolveFieldName(field)
|
|
if (!name || name === 'Champ') {
|
|
return null
|
|
}
|
|
|
|
const matches = candidateCustomFields.value.filter((candidate) => {
|
|
if (!candidate || typeof candidate !== 'object') {
|
|
return false
|
|
}
|
|
const candidateId = candidate.id || candidate.customFieldId
|
|
if (candidateId && (candidateId === field?.id || candidateId === field?.customFieldId)) {
|
|
return true
|
|
}
|
|
return typeof candidate.name === 'string' && candidate.name === name
|
|
})
|
|
|
|
if (matches.length) {
|
|
const withId = matches.find((candidate) => candidate?.id || candidate?.customFieldId) || matches[0]
|
|
const id = withId?.id || withId?.customFieldId || null
|
|
if (id) {
|
|
field.customFieldId = id
|
|
}
|
|
if (!field.customField && typeof withId === 'object') {
|
|
field.customField = withId
|
|
}
|
|
return id
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
watch(
|
|
candidateCustomFields,
|
|
() => {
|
|
displayedCustomFields.value.forEach((field) => ensureCustomFieldId(field))
|
|
},
|
|
{ immediate: true, deep: true }
|
|
)
|
|
|
|
watch(
|
|
displayedCustomFields,
|
|
(fields) => {
|
|
(fields || []).forEach((field) => ensureCustomFieldId(field))
|
|
},
|
|
{ immediate: true, deep: true }
|
|
)
|
|
const formatFieldDisplayValue = (field) => {
|
|
const type = resolveFieldType(field)
|
|
const rawValue = field?.value ?? ''
|
|
if (type === 'boolean') {
|
|
const normalized = String(rawValue).toLowerCase()
|
|
if (normalized === 'true') return 'Oui'
|
|
if (normalized === 'false') return 'Non'
|
|
}
|
|
return rawValue || 'Non défini'
|
|
}
|
|
|
|
const updateComponentCustomField = async (field) => {
|
|
if (!field || resolveFieldReadOnly(field)) {
|
|
return
|
|
}
|
|
|
|
const fieldValueId = resolveFieldId(field)
|
|
if (fieldValueId) {
|
|
const result = await updateComponentCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
|
|
if (result.success) {
|
|
const existingValue = props.component.customFieldValues?.find((value) => value.id === fieldValueId)
|
|
if (existingValue?.customField?.id) {
|
|
field.customFieldId = existingValue.customField.id
|
|
field.customField = existingValue.customField
|
|
}
|
|
showSuccess(`Champ "${resolveFieldName(field)}" mis à jour avec succès`)
|
|
} else {
|
|
showError(`Erreur lors de la mise à jour du champ "${resolveFieldName(field)}"`)
|
|
}
|
|
return
|
|
}
|
|
|
|
const customFieldId = ensureCustomFieldId(field)
|
|
const fieldName = resolveFieldName(field)
|
|
if (!props.component?.id) {
|
|
showError('Impossible de créer la valeur pour ce champ de composant')
|
|
return
|
|
}
|
|
|
|
if (!customFieldId && (!fieldName || fieldName === 'Champ')) {
|
|
showError('Impossible de créer la valeur pour ce champ de composant')
|
|
return
|
|
}
|
|
|
|
const metadata = customFieldId ? undefined : buildCustomFieldMetadata(field)
|
|
|
|
const result = await upsertComponentCustomFieldValue(
|
|
customFieldId,
|
|
'composant',
|
|
props.component.id,
|
|
field.value ?? '',
|
|
metadata,
|
|
)
|
|
|
|
if (result.success) {
|
|
const newValue = result.data
|
|
if (newValue?.id) {
|
|
field.customFieldValueId = newValue.id
|
|
field.value = newValue.value ?? field.value ?? ''
|
|
if (newValue.customField?.id) {
|
|
field.customFieldId = newValue.customField.id
|
|
field.customField = newValue.customField
|
|
}
|
|
|
|
if (Array.isArray(props.component.customFieldValues)) {
|
|
const index = props.component.customFieldValues.findIndex((value) => value.id === newValue.id)
|
|
if (index !== -1) {
|
|
props.component.customFieldValues.splice(index, 1, newValue)
|
|
} else {
|
|
props.component.customFieldValues.push(newValue)
|
|
}
|
|
} else {
|
|
props.component.customFieldValues = [newValue]
|
|
}
|
|
}
|
|
showSuccess(`Champ "${resolveFieldName(field)}" créé avec succès`)
|
|
|
|
const definitions = Array.isArray(props.component.customFields)
|
|
? [...props.component.customFields]
|
|
: []
|
|
const fieldIdentifier = ensureCustomFieldId(field)
|
|
const existingIndex = definitions.findIndex((definition) => {
|
|
const definitionId = ensureCustomFieldId(definition)
|
|
if (fieldIdentifier && definitionId) {
|
|
return definitionId === fieldIdentifier
|
|
}
|
|
return definition?.name === resolveFieldName(field)
|
|
})
|
|
|
|
const updatedDefinition = {
|
|
...(existingIndex !== -1 ? definitions[existingIndex] : {}),
|
|
customFieldValueId: field.customFieldValueId,
|
|
customFieldId: fieldIdentifier,
|
|
name: resolveFieldName(field),
|
|
type: resolveFieldType(field),
|
|
required: resolveFieldRequired(field),
|
|
options: resolveFieldOptions(field),
|
|
value: field.value ?? '',
|
|
customField: field.customField ?? null,
|
|
}
|
|
|
|
if (existingIndex !== -1) {
|
|
definitions.splice(existingIndex, 1, updatedDefinition)
|
|
} else {
|
|
definitions.push(updatedDefinition)
|
|
}
|
|
|
|
props.component.customFields = definitions
|
|
} else {
|
|
showError(`Erreur lors de la sauvegarde du champ "${resolveFieldName(field)}"`)
|
|
}
|
|
}
|
|
|
|
const updatePiece = (updatedPiece) => {
|
|
emit('edit-piece', updatedPiece)
|
|
}
|
|
|
|
const editPiece = (piece) => {
|
|
emit('edit-piece', piece)
|
|
}
|
|
|
|
const updatePieceCustomField = (fieldUpdate) => {
|
|
emit('custom-field-update', fieldUpdate)
|
|
emit('edit-piece', { ...fieldUpdate, type: 'custom-field-update' })
|
|
}
|
|
|
|
const ensureDocumentsLoaded = async () => {
|
|
if (documentsLoaded.value || !props.component?.id) { return }
|
|
await refreshDocuments()
|
|
}
|
|
|
|
const refreshDocuments = async () => {
|
|
loadingDocuments.value = true
|
|
try {
|
|
const result = await loadDocumentsByComponent(props.component.id, { updateStore: false })
|
|
if (result.success) {
|
|
props.component.documents = result.data || []
|
|
documentsLoaded.value = true
|
|
}
|
|
} finally {
|
|
loadingDocuments.value = false
|
|
}
|
|
}
|
|
|
|
const handleFilesAdded = async (files) => {
|
|
if (!files.length || !props.component?.id) { return }
|
|
uploadingDocuments.value = true
|
|
try {
|
|
const result = await uploadDocuments(
|
|
{
|
|
files,
|
|
context: { composantId: props.component.id }
|
|
},
|
|
{ updateStore: false }
|
|
)
|
|
|
|
if (result.success) {
|
|
const newDocs = result.data || []
|
|
props.component.documents = [...newDocs, ...(props.component.documents || [])]
|
|
documentsLoaded.value = true
|
|
selectedFiles.value = []
|
|
}
|
|
} finally {
|
|
uploadingDocuments.value = false
|
|
}
|
|
}
|
|
|
|
const removeDocument = async (documentId) => {
|
|
if (!documentId) { return }
|
|
const result = await deleteDocument(documentId, { updateStore: false })
|
|
if (result.success) {
|
|
props.component.documents = (props.component.documents || []).filter(doc => doc.id !== documentId)
|
|
}
|
|
}
|
|
|
|
const downloadDocument = (doc) => {
|
|
if (!doc?.path) { return }
|
|
|
|
if (doc.path.startsWith('data:')) {
|
|
const link = document.createElement('a')
|
|
link.href = doc.path
|
|
link.download = doc.filename || doc.name || 'document'
|
|
link.click()
|
|
return
|
|
}
|
|
|
|
window.open(doc.path, '_blank')
|
|
}
|
|
|
|
const openPreview = (doc) => {
|
|
if (!canPreviewDocument(doc)) { return }
|
|
previewDocument.value = doc
|
|
previewVisible.value = true
|
|
}
|
|
|
|
const closePreview = () => {
|
|
previewVisible.value = false
|
|
previewDocument.value = null
|
|
}
|
|
|
|
const formatSize = (size) => {
|
|
if (size === undefined || size === null) { return '—' }
|
|
if (size === 0) { return '0 B' }
|
|
const units = ['B', 'KB', 'MB', 'GB']
|
|
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
|
|
const formatted = size / Math.pow(1024, index)
|
|
return `${formatted.toFixed(1)} ${units[index]}`
|
|
}
|
|
</script>
|