Files
Inventory_frontend/app/components/ComponentItem.vue
2025-09-26 11:29:47 +02:00

568 lines
20 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>
<span
v-if="component.composantModel"
class="badge badge-outline badge-sm badge-primary"
>
Modèle : {{ component.composantModel.name }}
</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
v-if="isEditMode && component.typeMachineComponentRequirement"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Modèle de composant</span>
<span class="label-text-alt text-xs">
{{ component.typeMachineComponentRequirement.label || component.typeMachineComponentRequirement.typeComposant?.name || 'Famille' }}
</span>
</label>
<div class="flex flex-col md:flex-row gap-2 items-start md:items-center">
<select
:value="selectedComponentModelId"
class="select select-bordered select-sm"
@change="assignComponentModel($event.target.value)"
>
<option value="">
Définir manuellement
</option>
<option
v-for="model in componentModelOptionsList"
:key="model.id"
:value="model.id"
>
{{ model.name }}
</option>
</select>
<button
v-if="isEditMode && component.typeMachineComponentRequirement?.typeComposantId"
type="button"
class="btn btn-ghost btn-xs"
@click="emit('create-model-from-component', component)"
>
Sauvegarder comme modèle
</button>
</div>
</div>
</div>
</div>
<!-- Custom Fields Display - Editable or Read-only -->
<div v-if="component.customFields && component.customFields.length > 0" 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 in component.customFields" :key="field.id" class="form-control">
<label class="label">
<span class="label-text text-sm">{{ field.name }}</span>
<span v-if="field.required" class="label-text-alt text-error">*</span>
</label>
<template v-if="isEditMode">
<input
v-if="field.type === 'text'"
v-model="field.value"
type="text"
class="input input-bordered input-sm"
:required="field.required"
@blur="updateComponentCustomField(field)"
>
<input
v-else-if="field.type === 'number'"
v-model="field.value"
type="number"
class="input input-bordered input-sm"
:required="field.required"
@blur="updateComponentCustomField(field)"
>
<select
v-else-if="field.type === 'select'"
v-model="field.value"
class="select select-bordered select-sm"
:required="field.required"
@change="updateComponentCustomField(field)"
>
<option value="">
Sélectionner...
</option>
<option v-for="option in field.options" :key="option" :value="option">
{{ option }}
</option>
</select>
<div v-else-if="field.type === '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">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</div>
<input
v-else-if="field.type === 'date'"
v-model="field.value"
type="date"
class="input input-bordered input-sm"
:required="field.required"
@blur="updateComponentCustomField(field)"
>
</template>
<template v-else>
<div class="input input-bordered input-sm bg-base-200">
{{ field.value || 'Non défini' }}
</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"
@assign-model="emitAssignPieceModel"
/>
</div>
</div>
<!-- Sub Components -->
<div v-if="component.subComponents && component.subComponents.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 component.subComponents"
:key="subComponent.id"
:component="subComponent"
:is-edit-mode="isEditMode"
:collapse-all="collapseAll"
:toggle-token="toggleToken"
:component-model-options="componentModelOptionsProvider(subComponent)"
:component-model-options-provider="componentModelOptionsProvider"
:piece-model-options-provider="pieceModelOptionsProvider"
@update="$emit('update', $event)"
@edit-piece="$emit('edit-piece', $event)"
@assign-model="$emit('assign-model', $event)"
@assign-piece-model="$emit('assign-piece-model', $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'
const props = defineProps({
component: {
type: Object,
required: true
},
isEditMode: {
type: Boolean,
default: false
},
collapseAll: {
type: Boolean,
default: true
},
toggleToken: {
type: Number,
default: 0
},
componentModelOptions: {
type: Array,
default: () => []
},
componentModelOptionsProvider: {
type: Function,
default: () => []
},
pieceModelOptionsProvider: {
type: Function,
default: () => []
}
})
const emit = defineEmits([
'update',
'edit-piece',
'custom-field-update',
'assign-model',
'assign-piece-model',
'create-model-from-component'
])
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 selectedComponentModelId = computed(() => props.component.composantModelId || props.component.composantModel?.id || '')
const componentModelOptionsList = computed(() => {
const provided = props.componentModelOptionsProvider(props.component)
return Array.isArray(provided) && provided.length ? provided : props.componentModelOptions
})
const pieceModelOptionsList = computed(() => props.pieceModelOptionsProvider(props.component) || [])
const handleConstructeurChange = async (value) => {
props.component.constructeurId = value
await updateComponent()
}
const { uploadDocuments, deleteDocument, loadDocumentsByComponent } = useDocuments()
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)
}
const updateComponentCustomField = () => {
emit('update', props.component)
}
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 assignComponentModel = (value) => {
const previousModelId = props.component.composantModelId || props.component.composantModel?.id || null
const previousModel = props.component.composantModel || null
props.component.composantModelId = value || null
if (!value) {
props.component.composantModel = null
}
emit('assign-model', {
componentId: props.component.id,
composantModelId: value || null,
previousModelId,
previousModel
})
}
const emitAssignPieceModel = (payload) => {
emit('assign-piece-model', payload)
}
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>