feat: add file upload on componet and delete code champs
This commit is contained in:
@@ -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>()
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user