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'
|
return getComponentTypeLabel(props.node?.typeComposantId) || 'Famille non définie'
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatModelTypeOption = (type: ModelTypeOption | undefined | null) => {
|
const formatModelTypeOption = (type: ModelTypeOption | undefined | null) =>
|
||||||
if (!type) return ''
|
type?.name ?? ''
|
||||||
return type.code ? `${type.name} (${type.code})` : type.name
|
|
||||||
}
|
|
||||||
|
|
||||||
const componentTypeMap = computed(() => {
|
const componentTypeMap = computed(() => {
|
||||||
const map = new Map<string, ModelTypeOption>()
|
const map = new Map<string, ModelTypeOption>()
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ const props = withDefaults(
|
|||||||
const selectedCategory = ref<ModelCategory>(props.category);
|
const selectedCategory = ref<ModelCategory>(props.category);
|
||||||
const searchInput = ref("");
|
const searchInput = ref("");
|
||||||
const searchTerm = ref("");
|
const searchTerm = ref("");
|
||||||
const sort = ref<"name" | "code" | "createdAt">("createdAt");
|
const sort = ref<"name" | "createdAt">("createdAt");
|
||||||
const dir = ref<"asc" | "desc">("desc");
|
const dir = ref<"asc" | "desc">("desc");
|
||||||
const limit = ref(20);
|
const limit = ref(20);
|
||||||
const offset = ref(0);
|
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) {
|
if (sort.value !== value) {
|
||||||
sort.value = value;
|
sort.value = value;
|
||||||
refresh({ resetOffset: true });
|
refresh({ resetOffset: true });
|
||||||
|
|||||||
@@ -18,26 +18,6 @@
|
|||||||
/>
|
/>
|
||||||
<p v-if="errors.name" class="mt-1 text-sm text-error">{{ errors.name }}</p>
|
<p v-if="errors.name" class="mt-1 text-sm text-error">{{ errors.name }}</p>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label class="label" for="model-type-category">
|
<label class="label" for="model-type-category">
|
||||||
<span class="label-text">Catégorie *</span>
|
<span class="label-text">Catégorie *</span>
|
||||||
@@ -177,13 +157,26 @@ const form = reactive<ModelTypePayload>({
|
|||||||
structure: undefined,
|
structure: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const errors = reactive<{ name?: string; code?: string }>({})
|
const errors = reactive<{ name?: string }>({})
|
||||||
const nameInput = ref<HTMLInputElement | null>(null)
|
const nameInput = ref<HTMLInputElement | null>(null)
|
||||||
const codePattern = /^[a-z0-9\-_.]+$/i
|
|
||||||
|
|
||||||
const componentStructure = ref(normalizeStructureForSave(defaultStructure()))
|
const componentStructure = ref(normalizeStructureForSave(defaultStructure()))
|
||||||
const pieceStructure = ref(normalizePieceStructureForSave(defaultPieceStructure()))
|
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) => {
|
const resetStructures = (incomingStructure: ModelTypePayload['structure'], category: ModelCategory) => {
|
||||||
if (category === 'COMPONENT') {
|
if (category === 'COMPONENT') {
|
||||||
componentStructure.value = normalizeStructureForSave(
|
componentStructure.value = normalizeStructureForSave(
|
||||||
@@ -204,7 +197,9 @@ const resetStructures = (incomingStructure: ModelTypePayload['structure'], categ
|
|||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
const incoming = props.initialData ?? {}
|
const incoming = props.initialData ?? {}
|
||||||
form.name = typeof incoming.name === 'string' ? incoming.name : ''
|
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.category = incoming.category ?? props.initialCategory
|
||||||
form.notes = typeof incoming.notes === 'string'
|
form.notes = typeof incoming.notes === 'string'
|
||||||
? incoming.notes
|
? incoming.notes
|
||||||
@@ -213,7 +208,6 @@ const resetForm = () => {
|
|||||||
: ''
|
: ''
|
||||||
|
|
||||||
errors.name = undefined
|
errors.name = undefined
|
||||||
errors.code = undefined
|
|
||||||
|
|
||||||
resetStructures(incoming.structure, form.category)
|
resetStructures(incoming.structure, form.category)
|
||||||
}
|
}
|
||||||
@@ -223,22 +217,16 @@ const isSubmitDisabled = computed(() => saving.value || structureLoading.value)
|
|||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
errors.name = undefined
|
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.'
|
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.'
|
errors.name = 'Le nom ne peut pas dépasser 120 caractères.'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!codePattern.test(form.code.trim())) {
|
return !errors.name
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
@@ -246,9 +234,14 @@ const handleSubmit = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trimmedName = form.name.trim()
|
||||||
|
const resolvedCode = form.code?.trim()
|
||||||
|
? form.code.trim()
|
||||||
|
: generateCodeFromName(trimmedName)
|
||||||
|
|
||||||
const common = {
|
const common = {
|
||||||
name: form.name.trim(),
|
name: trimmedName,
|
||||||
code: form.code.trim(),
|
code: resolvedCode,
|
||||||
notes: form.notes?.trim() ? form.notes.trim() : undefined,
|
notes: form.notes?.trim() ? form.notes.trim() : undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +269,15 @@ watch(
|
|||||||
{ deep: true, immediate: true },
|
{ deep: true, immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => form.name,
|
||||||
|
(value) => {
|
||||||
|
if (props.mode === 'create') {
|
||||||
|
form.code = generateCodeFromName(value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.initialCategory,
|
() => props.initialCategory,
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -33,7 +33,6 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr class="text-base-content/70">
|
<tr class="text-base-content/70">
|
||||||
<th scope="col">Nom</th>
|
<th scope="col">Nom</th>
|
||||||
<th scope="col">Code</th>
|
|
||||||
<th scope="col">Catégorie</th>
|
<th scope="col">Catégorie</th>
|
||||||
<th scope="col">Notes</th>
|
<th scope="col">Notes</th>
|
||||||
<th scope="col" class="w-32 text-right">Actions</th>
|
<th scope="col" class="w-32 text-right">Actions</th>
|
||||||
@@ -42,7 +41,6 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="item in items" :key="item.id">
|
<tr v-for="item in items" :key="item.id">
|
||||||
<td class="font-medium">{{ item.name }}</td>
|
<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>{{ categoryLabel(item.category) }}</td>
|
||||||
<td class="max-w-xs align-middle">
|
<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>
|
<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>
|
<h3 class="text-lg font-semibold text-base-content">{{ item.name }}</h3>
|
||||||
<p class="text-sm text-base-content/60">{{ categoryLabel(item.category) }}</p>
|
<p class="text-sm text-base-content/60">{{ categoryLabel(item.category) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<code class="badge badge-neutral">{{ item.code }}</code>
|
|
||||||
</header>
|
</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/80" v-if="item.notes">{{ item.notes }}</p>
|
||||||
<p class="mt-3 text-sm text-base-content/50" v-else>Pas de notes</p>
|
<p class="mt-3 text-sm text-base-content/50" v-else>Pas de notes</p>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
:value="search"
|
:value="search"
|
||||||
type="search"
|
type="search"
|
||||||
class="grow min-w-0"
|
class="grow min-w-0"
|
||||||
placeholder="Rechercher par nom ou code…"
|
placeholder="Rechercher par nom…"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
@input="onSearch"
|
@input="onSearch"
|
||||||
/>
|
/>
|
||||||
@@ -43,7 +43,6 @@
|
|||||||
@change="emit('update:sort', ($event.target as HTMLSelectElement).value as SortField)"
|
@change="emit('update:sort', ($event.target as HTMLSelectElement).value as SortField)"
|
||||||
>
|
>
|
||||||
<option value="name">Nom</option>
|
<option value="name">Nom</option>
|
||||||
<option value="code">Code</option>
|
|
||||||
<option value="createdAt">Date de création</option>
|
<option value="createdAt">Date de création</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,7 +79,7 @@ import { computed } from 'vue';
|
|||||||
import IconLucidePlus from '~icons/lucide/plus';
|
import IconLucidePlus from '~icons/lucide/plus';
|
||||||
import IconLucideSearch from '~icons/lucide/search';
|
import IconLucideSearch from '~icons/lucide/search';
|
||||||
|
|
||||||
type SortField = 'name' | 'code' | 'createdAt';
|
type SortField = 'name' | 'createdAt';
|
||||||
type SortDirection = 'asc' | 'desc';
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<DocumentPreviewModal
|
||||||
|
:document="previewDocument"
|
||||||
|
:visible="previewVisible"
|
||||||
|
@close="closePreview"
|
||||||
|
/>
|
||||||
<main class="container mx-auto px-6 py-10">
|
<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">
|
<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" />
|
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
|
||||||
@@ -262,6 +267,88 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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 }">
|
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
||||||
Annuler
|
Annuler
|
||||||
@@ -280,14 +367,19 @@
|
|||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from '#imports'
|
import { useRoute, useRouter } from '#imports'
|
||||||
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
||||||
|
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||||
|
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||||
import { useComposants } from '~/composables/useComposants'
|
import { useComposants } from '~/composables/useComposants'
|
||||||
import { useCustomFields } from '~/composables/useCustomFields'
|
import { useCustomFields } from '~/composables/useCustomFields'
|
||||||
import { useApi } from '~/composables/useApi'
|
import { useApi } from '~/composables/useApi'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { formatStructurePreview } from '~/shared/modelUtils'
|
import { formatStructurePreview } from '~/shared/modelUtils'
|
||||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||||
import type { ModelType } from '~/services/modelTypes'
|
import type { ModelType } from '~/services/modelTypes'
|
||||||
|
import { getFileIcon } from '~/utils/fileIcons'
|
||||||
|
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||||
|
|
||||||
interface ComponentCatalogType extends ModelType {
|
interface ComponentCatalogType extends ModelType {
|
||||||
structure: ComponentModelStructure | null
|
structure: ComponentModelStructure | null
|
||||||
@@ -312,10 +404,17 @@ const { componentTypes, loadComponentTypes } = useComponentTypes()
|
|||||||
const { updateComposant } = useComposants()
|
const { updateComposant } = useComposants()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
|
||||||
|
|
||||||
const component = ref<any | null>(null)
|
const component = ref<any | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const saving = ref(false)
|
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 selectedTypeId = ref<string>('')
|
||||||
const editionForm = reactive({
|
const editionForm = reactive({
|
||||||
@@ -326,6 +425,90 @@ const editionForm = reactive({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
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[]>(() =>
|
const componentTypeList = computed<ComponentCatalogType[]>(() =>
|
||||||
(componentTypes.value || [])
|
(componentTypes.value || [])
|
||||||
@@ -375,10 +558,17 @@ const fetchComponent = async () => {
|
|||||||
const id = route.params.id
|
const id = route.params.id
|
||||||
if (!id || typeof id !== 'string') {
|
if (!id || typeof id !== 'string') {
|
||||||
component.value = null
|
component.value = null
|
||||||
|
componentDocuments.value = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const result = await get(`/composants/${id}`)
|
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
|
let initialized = false
|
||||||
@@ -739,5 +929,8 @@ const saveCustomFieldValues = async (updatedComponent: any) => {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.allSettled([loadComponentTypes(), fetchComponent()])
|
await Promise.allSettled([loadComponentTypes(), fetchComponent()])
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
if (component.value?.id) {
|
||||||
|
await refreshDocuments()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -274,6 +274,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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 }">
|
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
|
||||||
Annuler
|
Annuler
|
||||||
@@ -292,6 +316,7 @@
|
|||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from '#imports'
|
import { useRoute, useRouter } from '#imports'
|
||||||
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
||||||
|
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||||
import ComponentStructureAssignmentNode, {
|
import ComponentStructureAssignmentNode, {
|
||||||
type StructureAssignmentNode,
|
type StructureAssignmentNode,
|
||||||
} from '~/components/ComponentStructureAssignmentNode.vue'
|
} from '~/components/ComponentStructureAssignmentNode.vue'
|
||||||
@@ -301,6 +326,7 @@ import { useComposants } from '~/composables/useComposants'
|
|||||||
import { usePieces } from '~/composables/usePieces'
|
import { usePieces } from '~/composables/usePieces'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { useCustomFields } from '~/composables/useCustomFields'
|
import { useCustomFields } from '~/composables/useCustomFields'
|
||||||
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { formatStructurePreview } from '~/shared/modelUtils'
|
import { formatStructurePreview } from '~/shared/modelUtils'
|
||||||
import type {
|
import type {
|
||||||
ComponentModelPiece,
|
ComponentModelPiece,
|
||||||
@@ -331,6 +357,7 @@ const {
|
|||||||
} = usePieces()
|
} = usePieces()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||||
|
const { uploadDocuments } = useDocuments()
|
||||||
|
|
||||||
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
||||||
const selectedTypeId = ref<string>(initialTypeId.value)
|
const selectedTypeId = ref<string>(initialTypeId.value)
|
||||||
@@ -344,6 +371,8 @@ const creationForm = reactive({
|
|||||||
const lastSuggestedName = ref('')
|
const lastSuggestedName = ref('')
|
||||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||||
const structureAssignments = ref<StructureAssignmentNode | null>(null)
|
const structureAssignments = ref<StructureAssignmentNode | null>(null)
|
||||||
|
const selectedDocuments = ref<File[]>([])
|
||||||
|
const uploadingDocuments = ref(false)
|
||||||
|
|
||||||
const availablePieces = computed(() => pieceCatalogRef.value ?? [])
|
const availablePieces = computed(() => pieceCatalogRef.value ?? [])
|
||||||
const availableComponents = computed(() => componentCatalogRef.value ?? [])
|
const availableComponents = computed(() => componentCatalogRef.value ?? [])
|
||||||
@@ -745,6 +774,23 @@ const submitCreation = async () => {
|
|||||||
const result = await createComposant(payload)
|
const result = await createComposant(payload)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
await saveCustomFieldValues(result.data)
|
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')
|
toast.showSuccess('Composant créé avec succès')
|
||||||
await router.push('/component-catalog')
|
await router.push('/component-catalog')
|
||||||
} else if (result.error) {
|
} else if (result.error) {
|
||||||
@@ -754,6 +800,7 @@ const submitCreation = async () => {
|
|||||||
toast.showError(error?.message || 'Erreur lors de la création du composant')
|
toast.showError(error?.message || 'Erreur lors de la création du composant')
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
|
uploadingDocuments.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<DocumentPreviewModal
|
||||||
|
:document="previewDocument"
|
||||||
|
:visible="previewVisible"
|
||||||
|
@close="closePreview"
|
||||||
|
/>
|
||||||
<main class="container mx-auto px-6 py-10">
|
<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">
|
<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" />
|
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
|
||||||
@@ -232,6 +237,88 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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 }">
|
<NuxtLink to="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
||||||
Annuler
|
Annuler
|
||||||
@@ -250,11 +337,16 @@
|
|||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from '#imports'
|
import { useRoute, useRouter } from '#imports'
|
||||||
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
||||||
|
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||||
|
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
import { usePieces } from '~/composables/usePieces'
|
import { usePieces } from '~/composables/usePieces'
|
||||||
import { useCustomFields } from '~/composables/useCustomFields'
|
import { useCustomFields } from '~/composables/useCustomFields'
|
||||||
import { useApi } from '~/composables/useApi'
|
import { useApi } from '~/composables/useApi'
|
||||||
import { useToast } from '~/composables/useToast'
|
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 { formatPieceStructurePreview } from '~/shared/modelUtils'
|
||||||
import type { PieceModelStructure } from '~/shared/types/inventory'
|
import type { PieceModelStructure } from '~/shared/types/inventory'
|
||||||
import type { ModelType } from '~/services/modelTypes'
|
import type { ModelType } from '~/services/modelTypes'
|
||||||
@@ -282,10 +374,17 @@ const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
|||||||
const { updatePiece } = usePieces()
|
const { updatePiece } = usePieces()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
|
||||||
|
|
||||||
const piece = ref<any | null>(null)
|
const piece = ref<any | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const saving = ref(false)
|
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 selectedTypeId = ref<string>('')
|
||||||
const editionForm = reactive({
|
const editionForm = reactive({
|
||||||
@@ -296,6 +395,90 @@ const editionForm = reactive({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
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[])
|
const pieceTypeList = computed<PieceCatalogType[]>(() => (pieceTypes.value || []) as PieceCatalogType[])
|
||||||
|
|
||||||
@@ -342,10 +525,17 @@ const fetchPiece = async () => {
|
|||||||
const id = route.params.id
|
const id = route.params.id
|
||||||
if (!id || typeof id !== 'string') {
|
if (!id || typeof id !== 'string') {
|
||||||
piece.value = null
|
piece.value = null
|
||||||
|
pieceDocuments.value = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const result = await get(`/pieces/${id}`)
|
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
|
let initialized = false
|
||||||
@@ -650,5 +840,8 @@ const saveCustomFieldValues = async (updatedPiece: any) => {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.allSettled([loadPieceTypes(), fetchPiece()])
|
await Promise.allSettled([loadPieceTypes(), fetchPiece()])
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
if (piece.value?.id) {
|
||||||
|
await refreshDocuments()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -210,6 +210,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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 }">
|
<NuxtLink to="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
|
||||||
Annuler
|
Annuler
|
||||||
@@ -228,11 +252,13 @@
|
|||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from '#imports'
|
import { useRoute, useRouter } from '#imports'
|
||||||
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
||||||
|
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
import { usePieces } from '~/composables/usePieces'
|
import { usePieces } from '~/composables/usePieces'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { useCustomFields } from '~/composables/useCustomFields'
|
import { useCustomFields } from '~/composables/useCustomFields'
|
||||||
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
||||||
import type { PieceModelStructure } from '~/shared/types/inventory'
|
import type { PieceModelStructure } from '~/shared/types/inventory'
|
||||||
import type { ModelType } from '~/services/modelTypes'
|
import type { ModelType } from '~/services/modelTypes'
|
||||||
@@ -249,6 +275,7 @@ const { pieceTypes, loadPieceTypes, loadingPieceTypes } = usePieceTypes()
|
|||||||
const { createPiece } = usePieces()
|
const { createPiece } = usePieces()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||||
|
const { uploadDocuments } = useDocuments()
|
||||||
|
|
||||||
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
||||||
const selectedTypeId = ref<string>(initialTypeId.value)
|
const selectedTypeId = ref<string>(initialTypeId.value)
|
||||||
@@ -262,6 +289,8 @@ const creationForm = reactive({
|
|||||||
|
|
||||||
const lastSuggestedName = ref('')
|
const lastSuggestedName = ref('')
|
||||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||||
|
const selectedDocuments = ref<File[]>([])
|
||||||
|
const uploadingDocuments = ref(false)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.query.typeId,
|
() => route.query.typeId,
|
||||||
@@ -394,6 +423,23 @@ const submitCreation = async () => {
|
|||||||
const result = await createPiece(payload)
|
const result = await createPiece(payload)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
await saveCustomFieldValues(result.data)
|
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')
|
toast.showSuccess('Pièce créée avec succès')
|
||||||
await router.push('/pieces-catalog')
|
await router.push('/pieces-catalog')
|
||||||
} else if (result.error) {
|
} 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')
|
toast.showError(error?.message || 'Erreur lors de la création de la pièce')
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
|
uploadingDocuments.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export interface ModelType extends BaseModelTypePayload {
|
|||||||
export interface ModelTypeListParams {
|
export interface ModelTypeListParams {
|
||||||
q?: string;
|
q?: string;
|
||||||
category?: ModelCategory;
|
category?: ModelCategory;
|
||||||
sort?: 'name' | 'code' | 'createdAt';
|
sort?: 'name' | 'createdAt';
|
||||||
dir?: 'asc' | 'desc';
|
dir?: 'asc' | 'desc';
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user