Files
Inventory/frontend/app/components/ComponentItem.vue
r-dev 4f13f7d301 feat(ui) : clickable entity links, site→machines links, DataTable fixedLayout
- Add NuxtLink on component/piece/product names in machine hierarchy
  (using composantId, pieceId, product.id)
- Make site machine count badges clickable → /machines?sites={id}
- Add opt-in fixedLayout prop and minWidth to DataTable columns

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 16:39:21 +02:00

594 lines
21 KiB
Vue

<template>
<div>
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
: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 rounded-lg cursor-pointer transition-shadow"
:class="[
component.pendingEntity ? 'bg-error/10 border border-error' : 'bg-base-200',
!isCollapsed ? 'sticky top-16 z-10 shadow-sm' : '',
]"
@click="toggleCollapse"
>
<IconLucideChevronRight
class="w-4 h-4 shrink-0 transition-transform text-base-content/50"
:class="{ 'rotate-90': !isCollapsed }"
aria-hidden="true"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<h3 class="text-sm font-semibold truncate" :class="component.pendingEntity ? 'text-error' : 'text-base-content'">
<NuxtLink
v-if="!isEditMode && !component.pendingEntity && component.composantId"
:to="`/component/${component.composantId}`"
class="hover:underline hover:text-primary transition-colors"
@click.stop
>
{{ component.name }}
</NuxtLink>
<span v-else>{{ component.name }}</span>
</h3>
<button
v-if="component.pendingEntity"
type="button"
class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors"
title="Cliquer pour associer un item"
@click.stop="$emit('fill-entity', component.linkId, component.modelTypeId)"
>
À remplir
</button>
<span v-if="component.reference" class="badge badge-outline badge-xs">{{ component.reference }}</span>
<span v-if="component.prix" class="badge badge-primary badge-xs">{{ component.prix }}</span>
</div>
<div v-if="componentConstructeursDisplay.length || displayProductName" class="flex flex-wrap gap-1.5 mt-1">
<span
v-for="constructeur in componentConstructeursDisplay"
:key="constructeur.id"
class="text-xs text-base-content/50"
>
{{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="opacity-70">({{ supplierReferenceMap.get(constructeur.id) }})</span>
</span>
<span v-if="displayProductName" class="badge badge-info badge-xs">
{{ displayProductName }}
</span>
</div>
</div>
<button
v-if="showDelete"
type="button"
class="btn btn-ghost btn-xs text-error shrink-0"
title="Supprimer ce composant"
@click.stop="$emit('delete')"
>
Supprimer
</button>
</div>
<!-- Expanded content -->
<div v-show="!isCollapsed && !component.pendingEntity" class="mt-3 space-y-4 pl-7">
<!-- Info fields -->
<div v-if="isEditMode" class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Nom</span></label>
<input v-model="component.name" type="text" class="input input-bordered input-sm" @blur="updateComponent">
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Référence</span></label>
<input v-model="component.reference" type="text" class="input input-bordered input-sm" @blur="updateComponent">
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Prix</span></label>
<input v-model="component.prix" type="number" step="0.01" class="input input-bordered input-sm" @blur="updateComponent">
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Fournisseur</span></label>
<ConstructeurSelect
class="w-full"
:model-value="componentConstructeurIds"
:initial-options="componentConstructeursDisplay"
@update:model-value="handleConstructeurChange"
/>
</div>
</div>
<!-- Read-only info -->
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3 text-sm">
<div>
<p class="text-xs text-base-content/40 mb-0.5">Nom</p>
<p class="text-base-content">{{ component.name }}</p>
</div>
<div>
<p class="text-xs text-base-content/40 mb-0.5">Référence</p>
<p class="text-base-content">{{ component.reference || '—' }}</p>
</div>
<div>
<p class="text-xs text-base-content/40 mb-0.5">Prix</p>
<p class="text-base-content">{{ component.prix ? `${component.prix}` : '—' }}</p>
</div>
<div>
<p class="text-xs text-base-content/40 mb-0.5">Fournisseur</p>
<div v-if="componentConstructeursDisplay.length">
<p
v-for="constructeur in componentConstructeursDisplay"
:key="constructeur.id"
class="text-base-content"
>
{{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-sm text-base-content/60">
Réf. {{ supplierReferenceMap.get(constructeur.id) }}
</span>
<span v-if="formatConstructeurContact(constructeur)" class="text-xs text-base-content/50 block">
{{ formatConstructeurContact(constructeur) }}
</span>
</p>
</div>
<p v-else class="text-base-content"></p>
</div>
</div>
<!-- Product -->
<div v-if="displayProduct" class="rounded-lg border border-base-200 bg-base-100 p-3">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<p class="text-xs text-base-content/40">Produit catalogue</p>
<p class="text-sm font-semibold text-base-content">{{ displayProductName }}</p>
<p
v-for="info in productInfoRows"
:key="info.label"
class="text-xs text-base-content/60"
>
{{ info.label }} : {{ info.value }}
</p>
</div>
<NuxtLink
v-if="component.product?.id"
:to="`/product/${component.product.id}`"
class="btn btn-ghost btn-xs shrink-0"
>
Voir le produit
</NuxtLink>
</div>
<!-- Product documents -->
<div v-if="productDocuments.length" class="mt-3 pt-3 border-t border-base-200 space-y-2">
<p class="text-xs font-medium text-base-content/50">Documents du produit</p>
<div
v-for="document in productDocuments"
:key="document.id || document.path || document.name"
class="flex items-center justify-between gap-3 text-xs"
>
<div class="flex items-center gap-2 min-w-0">
<div class="flex-shrink-0 overflow-hidden rounded border border-base-200 bg-base-200/70 flex items-center justify-center h-8 w-7">
<img
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-4 w-4"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<span class="truncate text-base-content">{{ document.name }}</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
@click="openPreview(document)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
Télécharger
</button>
</div>
</div>
</div>
</div>
<!-- Custom Fields -->
<CustomFieldDisplay
:fields="displayedCustomFields"
:is-edit-mode="isEditMode"
:columns="2"
title="Champs personnalisés item"
:editable="false"
@field-blur="updateComponentCustomField"
/>
<template v-if="mergedContextFields.length">
<div class="divider my-4 text-xs text-base-content/50">
Champs personnalisés machine
</div>
<CustomFieldDisplay
:fields="mergedContextFields"
:is-edit-mode="isEditMode"
:columns="2"
:show-header="false"
:with-top-border="false"
:editable="true"
:emit-blur="false"
@field-input="queueContextCustomFieldUpdate"
/>
</template>
<!-- Documents -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Documents</p>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs">
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</div>
<p v-if="loadingDocuments" class="text-xs text-base-content/50">
Chargement...
</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"
/>
<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>
<!-- Component Pieces (real MachinePieceLinks) -->
<div v-if="linkedPieces.length > 0" class="space-y-2">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
Pièces du composant
</p>
<div class="space-y-2">
<PieceItem
v-for="piece in linkedPieces"
:key="piece.id"
:piece="piece"
:is-edit-mode="isEditMode"
@update="updatePiece"
@edit="editPiece"
@custom-field-update="updatePieceCustomField"
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
/>
</div>
</div>
<!-- Structure pieces (read-only, from composant definition) -->
<div v-if="structurePieces.length > 0" class="space-y-2">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
Pièces incluses par défaut
</p>
<div class="space-y-2">
<PieceItem
v-for="piece in structurePieces"
:key="piece.id"
:piece="piece"
:is-edit-mode="false"
/>
</div>
</div>
<!-- Sub Components -->
<div v-if="childComponents.length > 0" class="space-y-2">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
Sous-composants
</p>
<div class="space-y-2 pl-4 border-l-2 border-base-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)"
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
/>
</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 DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
import { useConstructeurs } from '~/composables/useConstructeurs'
import {
formatConstructeurContact as formatConstructeurContactSummary,
resolveConstructeurs,
uniqueConstructeurIds,
parseConstructeurLinksFromApi,
} from '~/shared/constructeurUtils'
import {
shouldInlinePdf,
documentPreviewSrc,
documentIcon,
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
import { useEntityDocuments } from '~/composables/useEntityDocuments'
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
import { useCustomFields } from '~/composables/useCustomFields'
import { mergeDefinitionsWithValues } from '~/shared/utils/customFields'
const props = defineProps({
component: { type: Object, required: true },
isEditMode: { type: Boolean, default: false },
showDelete: { type: Boolean, default: false },
collapseAll: { type: Boolean, default: true },
toggleToken: { type: Number, default: 0 },
})
const emit = defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete', 'fill-entity'])
// --- Shared composables ---
const {
documents: componentDocuments,
selectedFiles,
uploadingDocuments,
loadingDocuments,
previewDocument,
previewVisible,
openPreview,
closePreview,
ensureDocumentsLoaded,
handleFilesAdded,
removeDocument,
editDocument,
} = useEntityDocuments({ entity: () => props.component, entityType: 'composant' })
const {
displayProduct,
displayProductName,
productInfoRows,
productDocuments,
} = useEntityProductDisplay({ entity: () => props.component })
const {
updateCustomFieldValue: updateCustomFieldValueApi,
upsertCustomFieldValue,
} = useCustomFields()
const { showSuccess, showError } = useToast()
// Parent already pre-merges standalone custom fields into props.component.customFields
const displayedCustomFields = computed(() => {
const fields = props.component?.customFields
return Array.isArray(fields) ? fields.filter((f) => !f.machineContextOnly) : []
})
const updateComponentCustomField = async (field) => {
if (!field || field.readOnly) return
const e = props.component
const fieldValueId = field.customFieldValueId
if (fieldValueId) {
const result = await updateCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
if (result.success) {
showSuccess(`Champ "${field.name}" mis à jour avec succès`)
} else {
showError(`Erreur lors de la mise à jour du champ "${field.name}"`)
}
return
}
if (!e?.id) {
showError('Impossible de créer la valeur pour ce champ')
return
}
const metadata = field.customFieldId ? undefined : {
customFieldName: field.name,
customFieldType: field.type,
customFieldRequired: field.required,
customFieldOptions: field.options,
}
const result = await upsertCustomFieldValue(
field.customFieldId,
'composant',
e.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
}
}
showSuccess(`Champ "${field.name}" créé avec succès`)
} else {
showError(`Erreur lors de la sauvegarde du champ "${field.name}"`)
}
}
// Context fields are NOT pre-merged — merge locally
const mergedContextFields = computed(() => {
const definitions = props.component?.contextCustomFields ?? []
const values = props.component?.contextCustomFieldValues ?? []
if (!definitions.length && !values.length) return []
return mergeDefinitionsWithValues(definitions, values)
})
const queueContextCustomFieldUpdate = (field, value) => {
const linkId = props.component?.linkId
if (!linkId || !field) return
const customFieldId = field.customFieldId
const customFieldValueId = field.customFieldValueId
if (!customFieldId && !customFieldValueId) return
field.value = value
emit('custom-field-update', {
entityType: 'machineComponentLink',
entityId: linkId,
fieldId: customFieldId,
customFieldValueId,
value: value ?? '',
fieldName: field.name || 'Champ contextuel',
})
}
// --- 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)
watch(
() => props.toggleToken,
() => {
isCollapsed.value = props.collapseAll
if (!isCollapsed.value) ensureDocumentsLoaded()
},
{ immediate: true },
)
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value
if (!isCollapsed.value) ensureDocumentsLoaded()
}
// --- Child components ---
const childComponents = computed(() => {
const list = props.component.subcomponents || props.component.subComponents || []
return Array.isArray(list) ? list : []
})
// --- Pieces split: real links vs structure definitions ---
const allPieces = computed(() => {
const list = props.component.pieces
return Array.isArray(list) ? list : []
})
const linkedPieces = computed(() => allPieces.value.filter((p) => !p._structurePiece))
const structurePieces = computed(() => allPieces.value.filter((p) => p._structurePiece))
// --- Constructeurs ---
const { constructeurs } = useConstructeurs()
const componentConstructeurLinks = computed(() =>
parseConstructeurLinksFromApi(
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [],
),
)
const supplierReferenceMap = computed(() => {
const map = new Map()
componentConstructeurLinks.value.forEach(l => {
if (l.supplierReference) map.set(l.constructeurId, l.supplierReference)
})
return map
})
const componentConstructeurIds = computed(() =>
componentConstructeurLinks.value.map(l => l.constructeurId).filter(Boolean),
)
const componentConstructeursDisplay = computed(() => {
// Extract nested constructeur objects from link entries
const linkConstructeurs = componentConstructeurLinks.value
.filter(l => l.constructeur && l.constructeur.id)
.map(l => l.constructeur)
return resolveConstructeurs(
componentConstructeurIds.value,
linkConstructeurs,
constructeurs.value,
)
})
const formatConstructeurContact = (constructeur) =>
formatConstructeurContactSummary(constructeur)
const handleConstructeurChange = async (value) => {
const ids = uniqueConstructeurIds(value)
props.component.constructeurIds = [...ids]
props.component.constructeurId = null
props.component.constructeur = null
props.component.constructeurs = resolveConstructeurs(
ids,
constructeurs.value,
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [],
)
await updateComponent()
}
// --- Update / Event forwarding ---
const updateComponent = () => {
emit('update', {
...props.component,
constructeurIds: componentConstructeurIds.value,
})
}
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' })
}
</script>