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

@@ -266,10 +266,8 @@ const lockedTypeDisplay = computed(() => {
return getComponentTypeLabel(props.node?.typeComposantId) || 'Famille non définie'
})
const formatModelTypeOption = (type: ModelTypeOption | undefined | null) => {
if (!type) return ''
return type.code ? `${type.name} (${type.code})` : type.name
}
const formatModelTypeOption = (type: ModelTypeOption | undefined | null) =>
type?.name ?? ''
const componentTypeMap = computed(() => {
const map = new Map<string, ModelTypeOption>()

View File

@@ -68,7 +68,7 @@ const props = withDefaults(
const selectedCategory = ref<ModelCategory>(props.category);
const searchInput = ref("");
const searchTerm = ref("");
const sort = ref<"name" | "code" | "createdAt">("createdAt");
const sort = ref<"name" | "createdAt">("createdAt");
const dir = ref<"asc" | "desc">("desc");
const limit = ref(20);
const offset = ref(0);
@@ -188,7 +188,7 @@ const onCategoryChange = (value: ModelCategory) => {
}
};
const onSortChange = (value: "name" | "code" | "createdAt") => {
const onSortChange = (value: "name" | "createdAt") => {
if (sort.value !== value) {
sort.value = value;
refresh({ resetOffset: true });

View File

@@ -18,26 +18,6 @@
/>
<p v-if="errors.name" class="mt-1 text-sm text-error">{{ errors.name }}</p>
</div>
<div>
<label class="label" for="model-type-code">
<span class="label-text">Code *</span>
</label>
<input
id="model-type-code"
v-model.trim="form.code"
type="text"
class="input input-bordered w-full"
name="code"
minlength="2"
maxlength="60"
required
autocomplete="off"
/>
<p class="mt-1 text-xs text-base-content/70">Caractères autorisés : lettres, chiffres, -, _ et .</p>
<p v-if="errors.code" class="mt-1 text-sm text-error">{{ errors.code }}</p>
</div>
<div>
<label class="label" for="model-type-category">
<span class="label-text">Catégorie *</span>
@@ -177,13 +157,26 @@ const form = reactive<ModelTypePayload>({
structure: undefined,
})
const errors = reactive<{ name?: string; code?: string }>({})
const errors = reactive<{ name?: string }>({})
const nameInput = ref<HTMLInputElement | null>(null)
const codePattern = /^[a-z0-9\-_.]+$/i
const componentStructure = ref(normalizeStructureForSave(defaultStructure()))
const pieceStructure = ref(normalizePieceStructureForSave(defaultPieceStructure()))
const generateCodeFromName = (name: string) => {
const fallback = 'type'
if (!name) {
return fallback
}
return name
.normalize('NFD')
.replace(/[\u0300-\u036F]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-+/g, '-') || fallback
}
const resetStructures = (incomingStructure: ModelTypePayload['structure'], category: ModelCategory) => {
if (category === 'COMPONENT') {
componentStructure.value = normalizeStructureForSave(
@@ -204,7 +197,9 @@ const resetStructures = (incomingStructure: ModelTypePayload['structure'], categ
const resetForm = () => {
const incoming = props.initialData ?? {}
form.name = typeof incoming.name === 'string' ? incoming.name : ''
form.code = typeof incoming.code === 'string' ? incoming.code : ''
form.code = typeof incoming.code === 'string' && incoming.code
? incoming.code
: generateCodeFromName(form.name)
form.category = incoming.category ?? props.initialCategory
form.notes = typeof incoming.notes === 'string'
? incoming.notes
@@ -213,7 +208,6 @@ const resetForm = () => {
: ''
errors.name = undefined
errors.code = undefined
resetStructures(incoming.structure, form.category)
}
@@ -223,22 +217,16 @@ const isSubmitDisabled = computed(() => saving.value || structureLoading.value)
const validate = () => {
errors.name = undefined
errors.code = undefined
if (form.name.trim().length < 2) {
const trimmedName = form.name.trim()
if (trimmedName.length < 2) {
errors.name = 'Le nom doit contenir au moins 2 caractères.'
}
if (form.name.trim().length > 120) {
if (trimmedName.length > 120) {
errors.name = 'Le nom ne peut pas dépasser 120 caractères.'
}
if (!codePattern.test(form.code.trim())) {
errors.code = 'Le code doit respecter le format demandé.'
} else if (form.code.trim().length < 2 || form.code.trim().length > 60) {
errors.code = 'Le code doit contenir entre 2 et 60 caractères.'
}
return !errors.name && !errors.code
return !errors.name
}
const handleSubmit = () => {
@@ -246,9 +234,14 @@ const handleSubmit = () => {
return
}
const trimmedName = form.name.trim()
const resolvedCode = form.code?.trim()
? form.code.trim()
: generateCodeFromName(trimmedName)
const common = {
name: form.name.trim(),
code: form.code.trim(),
name: trimmedName,
code: resolvedCode,
notes: form.notes?.trim() ? form.notes.trim() : undefined,
}
@@ -276,6 +269,15 @@ watch(
{ deep: true, immediate: true },
)
watch(
() => form.name,
(value) => {
if (props.mode === 'create') {
form.code = generateCodeFromName(value)
}
},
)
watch(
() => props.initialCategory,
() => {

View File

@@ -33,7 +33,6 @@
<thead>
<tr class="text-base-content/70">
<th scope="col">Nom</th>
<th scope="col">Code</th>
<th scope="col">Catégorie</th>
<th scope="col">Notes</th>
<th scope="col" class="w-32 text-right">Actions</th>
@@ -42,7 +41,6 @@
<tbody>
<tr v-for="item in items" :key="item.id">
<td class="font-medium">{{ item.name }}</td>
<td><code class="badge badge-neutral badge-sm">{{ item.code }}</code></td>
<td>{{ categoryLabel(item.category) }}</td>
<td class="max-w-xs align-middle">
<span v-if="item.notes" class="block text-sm text-base-content/80 break-words">{{ item.notes }}</span>
@@ -72,7 +70,6 @@
<h3 class="text-lg font-semibold text-base-content">{{ item.name }}</h3>
<p class="text-sm text-base-content/60">{{ categoryLabel(item.category) }}</p>
</div>
<code class="badge badge-neutral">{{ item.code }}</code>
</header>
<p class="mt-3 text-sm text-base-content/80" v-if="item.notes">{{ item.notes }}</p>
<p class="mt-3 text-sm text-base-content/50" v-else>Pas de notes</p>

View File

@@ -28,7 +28,7 @@
:value="search"
type="search"
class="grow min-w-0"
placeholder="Rechercher par nom ou code…"
placeholder="Rechercher par nom…"
autocomplete="off"
@input="onSearch"
/>
@@ -43,7 +43,6 @@
@change="emit('update:sort', ($event.target as HTMLSelectElement).value as SortField)"
>
<option value="name">Nom</option>
<option value="code">Code</option>
<option value="createdAt">Date de création</option>
</select>
</div>
@@ -80,7 +79,7 @@ import { computed } from 'vue';
import IconLucidePlus from '~icons/lucide/plus';
import IconLucideSearch from '~icons/lucide/search';
type SortField = 'name' | 'code' | 'createdAt';
type SortField = 'name' | 'createdAt';
type SortDirection = 'asc' | 'desc';
const props = defineProps<{

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
}
}

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" />
@@ -232,6 +237,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 à cette pièce.
</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="pieceDocuments.length" class="space-y-2">
<div
v-for="document in pieceDocuments"
: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é à cette pièce pour le moment.
</p>
</div>
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
Annuler
@@ -250,11 +337,16 @@
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 { usePieceTypes } from '~/composables/usePieceTypes'
import { usePieces } from '~/composables/usePieces'
import { useCustomFields } from '~/composables/useCustomFields'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { useDocuments } from '~/composables/useDocuments'
import { getFileIcon } from '~/utils/fileIcons'
import { canPreviewDocument } from '~/utils/documentPreview'
import { formatPieceStructurePreview } from '~/shared/modelUtils'
import type { PieceModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes'
@@ -282,10 +374,17 @@ const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { updatePiece } = usePieces()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const toast = useToast()
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
const piece = 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 pieceDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null)
const previewVisible = ref(false)
const selectedTypeId = ref<string>('')
const editionForm = reactive({
@@ -296,6 +395,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) {
pieceDocuments.value = pieceDocuments.value.filter((doc) => doc.id !== documentId)
}
}
const handleFilesAdded = async (files: File[]) => {
if (!files?.length || !piece.value?.id) {
return
}
uploadingDocuments.value = true
try {
const result = await uploadDocuments(
{
files,
context: { pieceId: piece.value.id },
},
{ updateStore: false },
)
if (result.success) {
selectedFiles.value = []
await refreshDocuments()
}
} finally {
uploadingDocuments.value = false
}
}
const refreshDocuments = async () => {
if (!piece.value?.id) {
pieceDocuments.value = []
return
}
loadingDocuments.value = true
try {
const result = await loadDocumentsByPiece(piece.value.id, { updateStore: false })
if (result.success) {
pieceDocuments.value = result.data || []
}
} finally {
loadingDocuments.value = false
}
}
const pieceTypeList = computed<PieceCatalogType[]>(() => (pieceTypes.value || []) as PieceCatalogType[])
@@ -342,10 +525,17 @@ const fetchPiece = async () => {
const id = route.params.id
if (!id || typeof id !== 'string') {
piece.value = null
pieceDocuments.value = []
return
}
const result = await get(`/pieces/${id}`)
piece.value = result.success ? result.data : null
if (result.success) {
piece.value = result.data
pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
} else {
piece.value = null
pieceDocuments.value = []
}
}
let initialized = false
@@ -650,5 +840,8 @@ const saveCustomFieldValues = async (updatedPiece: any) => {
onMounted(async () => {
await Promise.allSettled([loadPieceTypes(), fetchPiece()])
loading.value = false
if (piece.value?.id) {
await refreshDocuments()
}
})
</script>

View File

@@ -210,6 +210,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 à cette pièce.
</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="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
Annuler
@@ -228,11 +252,13 @@
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 SearchSelect from '~/components/common/SearchSelect.vue'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { usePieces } from '~/composables/usePieces'
import { useToast } from '~/composables/useToast'
import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments'
import { formatPieceStructurePreview } from '~/shared/modelUtils'
import type { PieceModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes'
@@ -249,6 +275,7 @@ const { pieceTypes, loadPieceTypes, loadingPieceTypes } = usePieceTypes()
const { createPiece } = 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)
@@ -262,6 +289,8 @@ const creationForm = reactive({
const lastSuggestedName = ref('')
const customFieldInputs = ref<CustomFieldInput[]>([])
const selectedDocuments = ref<File[]>([])
const uploadingDocuments = ref(false)
watch(
() => route.query.typeId,
@@ -394,6 +423,23 @@ const submitCreation = async () => {
const result = await createPiece(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: { pieceId: 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('Pièce créée avec succès')
await router.push('/pieces-catalog')
} else if (result.error) {
@@ -403,6 +449,7 @@ const submitCreation = async () => {
toast.showError(error?.message || 'Erreur lors de la création de la pièce')
} finally {
submitting.value = false
uploadingDocuments.value = false
}
}

View File

@@ -39,7 +39,7 @@ export interface ModelType extends BaseModelTypePayload {
export interface ModelTypeListParams {
q?: string;
category?: ModelCategory;
sort?: 'name' | 'code' | 'createdAt';
sort?: 'name' | 'createdAt';
dir?: 'asc' | 'desc';
limit?: number;
offset?: number;