feat: add file upload on componet and delete code champs

This commit is contained in:
Matthieu
2025-10-16 10:05:32 +02:00
parent ebc02f41d9
commit 8eada12438
10 changed files with 527 additions and 51 deletions

View File

@@ -1,4 +1,9 @@
<template>
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
@close="closePreview"
/>
<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" />
@@ -262,6 +267,88 @@
</div>
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Gérez les documents associés à ce composant.
</p>
</div>
<span v-if="selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
@files-added="handleFilesAdded"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours
</p>
<div v-else-if="componentDocuments.length" class="space-y-2">
<div
v-for="document in componentDocuments"
:key="document.id || document.path || document.name"
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-base-content/70">
{{ 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
type="button"
class="btn btn-error btn-xs"
:disabled="uploadingDocuments"
@click="removeDocument(document.id)"
>
Supprimer
</button>
</div>
</div>
</div>
<p v-else class="text-xs text-base-content/70">
Aucun document n'est associé à ce composant pour le moment.
</p>
</div>
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
Annuler
@@ -280,14 +367,19 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from '#imports'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useComposants } from '~/composables/useComposants'
import { useCustomFields } from '~/composables/useCustomFields'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { useDocuments } from '~/composables/useDocuments'
import { formatStructurePreview } from '~/shared/modelUtils'
import type { ComponentModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes'
import { getFileIcon } from '~/utils/fileIcons'
import { canPreviewDocument } from '~/utils/documentPreview'
interface ComponentCatalogType extends ModelType {
structure: ComponentModelStructure | null
@@ -312,10 +404,17 @@ const { componentTypes, loadComponentTypes } = useComponentTypes()
const { updateComposant } = useComposants()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const toast = useToast()
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
const component = ref<any | null>(null)
const loading = ref(true)
const saving = ref(false)
const selectedFiles = ref<File[]>([])
const uploadingDocuments = ref(false)
const loadingDocuments = ref(false)
const componentDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null)
const previewVisible = ref(false)
const selectedTypeId = ref<string>('')
const editionForm = reactive({
@@ -326,6 +425,90 @@ const editionForm = reactive({
})
const customFieldInputs = ref<CustomFieldInput[]>([])
const documentIcon = (doc: any) =>
getFileIcon({ name: doc?.filename || doc?.name, mime: doc?.mimeType })
const formatSize = (size: number | null | undefined) => {
if (size === null || size === undefined) {
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]}`
}
const openPreview = (doc: any) => {
if (!doc || !canPreviewDocument(doc)) {
return
}
previewDocument.value = doc
previewVisible.value = true
}
const closePreview = () => {
previewVisible.value = false
previewDocument.value = null
}
const downloadDocument = (doc: any) => {
if (!doc?.path) {
return
}
const target = String(doc.path)
if (target.startsWith('data:')) {
const link = document.createElement('a')
link.href = target
link.download = doc.filename || doc.name || 'document'
link.click()
return
}
window.open(target, '_blank')
}
const removeDocument = async (documentId: string | number | null | undefined) => {
if (!documentId) {
return
}
const result = await deleteDocument(documentId, { updateStore: false })
if (result.success) {
componentDocuments.value = componentDocuments.value.filter((doc) => doc.id !== documentId)
}
}
const handleFilesAdded = async (files: File[]) => {
if (!files?.length || !component.value?.id) {
return
}
uploadingDocuments.value = true
try {
const result = await uploadDocuments(
{
files,
context: { composantId: component.value.id },
},
{ updateStore: false },
)
if (result.success) {
selectedFiles.value = []
await refreshDocuments()
}
} finally {
uploadingDocuments.value = false
}
}
const refreshDocuments = async () => {
if (!component.value?.id) {
componentDocuments.value = []
return
}
loadingDocuments.value = true
try {
const result = await loadDocumentsByComponent(component.value.id, { updateStore: false })
if (result.success) {
componentDocuments.value = result.data || []
}
} finally {
loadingDocuments.value = false
}
}
const componentTypeList = computed<ComponentCatalogType[]>(() =>
(componentTypes.value || [])
@@ -375,10 +558,17 @@ const fetchComponent = async () => {
const id = route.params.id
if (!id || typeof id !== 'string') {
component.value = null
componentDocuments.value = []
return
}
const result = await get(`/composants/${id}`)
component.value = result.success ? result.data : null
if (result.success) {
component.value = result.data
componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
} else {
component.value = null
componentDocuments.value = []
}
}
let initialized = false
@@ -739,5 +929,8 @@ const saveCustomFieldValues = async (updatedComponent: any) => {
onMounted(async () => {
await Promise.allSettled([loadComponentTypes(), fetchComponent()])
loading.value = false
if (component.value?.id) {
await refreshDocuments()
}
})
</script>

View File

@@ -274,6 +274,30 @@
</div>
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Ajoutez des documents (PDF, images, textes) liés à ce composant.
</p>
</div>
<span v-if="selectedDocuments.length" class="badge badge-outline">
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': submitting }">
<DocumentUpload
v-model="selectedDocuments"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours
</p>
</div>
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
Annuler
@@ -292,6 +316,7 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from '#imports'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
import ComponentStructureAssignmentNode, {
type StructureAssignmentNode,
} from '~/components/ComponentStructureAssignmentNode.vue'
@@ -301,6 +326,7 @@ import { useComposants } from '~/composables/useComposants'
import { usePieces } from '~/composables/usePieces'
import { useToast } from '~/composables/useToast'
import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments'
import { formatStructurePreview } from '~/shared/modelUtils'
import type {
ComponentModelPiece,
@@ -331,6 +357,7 @@ const {
} = usePieces()
const toast = useToast()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const { uploadDocuments } = useDocuments()
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
const selectedTypeId = ref<string>(initialTypeId.value)
@@ -344,6 +371,8 @@ const creationForm = reactive({
const lastSuggestedName = ref('')
const customFieldInputs = ref<CustomFieldInput[]>([])
const structureAssignments = ref<StructureAssignmentNode | null>(null)
const selectedDocuments = ref<File[]>([])
const uploadingDocuments = ref(false)
const availablePieces = computed(() => pieceCatalogRef.value ?? [])
const availableComponents = computed(() => componentCatalogRef.value ?? [])
@@ -745,6 +774,23 @@ const submitCreation = async () => {
const result = await createComposant(payload)
if (result.success) {
await saveCustomFieldValues(result.data)
if (selectedDocuments.value.length && result.data?.id) {
uploadingDocuments.value = true
const uploadResult = await uploadDocuments(
{
files: selectedDocuments.value,
context: { composantId: result.data.id },
},
{ updateStore: false },
)
if (!uploadResult.success) {
const message = uploadResult.error
? `Documents non ajoutés : ${uploadResult.error}`
: 'Documents non ajoutés : une erreur est survenue.'
toast.showError(message)
}
selectedDocuments.value = []
}
toast.showSuccess('Composant créé avec succès')
await router.push('/component-catalog')
} else if (result.error) {
@@ -754,6 +800,7 @@ const submitCreation = async () => {
toast.showError(error?.message || 'Erreur lors de la création du composant')
} finally {
submitting.value = false
uploadingDocuments.value = false
}
}