Compare commits
28 Commits
master
...
d0dc01deb1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0dc01deb1 | ||
|
|
a76f25321a | ||
|
|
2410ebb7dc | ||
|
|
1d6c520945 | ||
|
|
10ad7b7f41 | ||
|
|
aebe7ed586 | ||
|
|
5b42bf1504 | ||
|
|
5ab63e8b27 | ||
|
|
4db832bc8c | ||
|
|
736a8bccf9 | ||
|
|
bd69b37524 | ||
|
|
e7402dda4d | ||
|
|
6b0d2d1b0a | ||
|
|
7a4a77e3fc | ||
|
|
2e82e854bf | ||
|
|
ac860d3165 | ||
|
|
8176635eb8 | ||
|
|
a730a18794 | ||
|
|
40d0753637 | ||
|
|
db630e315b | ||
|
|
53530dc16d | ||
|
|
974b74ee9f | ||
|
|
ab05ce589d | ||
|
|
ce3f081a0a | ||
|
|
63fba4138e | ||
|
|
d58a8c2479 | ||
|
|
81f7b1a9ac | ||
|
|
9e303426a7 |
55
app/components/CommentDocumentList.vue
Normal file
55
app/components/CommentDocumentList.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="documents.length" class="space-y-1 mt-2">
|
||||||
|
<div
|
||||||
|
v-for="doc in documents"
|
||||||
|
:key="doc.id"
|
||||||
|
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-2 py-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<component
|
||||||
|
:is="documentIcon(doc).component"
|
||||||
|
class="w-4 h-4 flex-shrink-0"
|
||||||
|
:class="documentIcon(doc).colorClass"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ doc.name || doc.filename }}</span>
|
||||||
|
<span class="text-base-content/40 flex-shrink-0">{{ formatSize(doc.size) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 flex-shrink-0 ml-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
:disabled="!canPreviewDocument(doc)"
|
||||||
|
:title="canPreviewDocument(doc) ? 'Consulter' : 'Aperçu non disponible'"
|
||||||
|
@click="openPreview(doc)"
|
||||||
|
>
|
||||||
|
Consulter
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
@click="downloadDocument(doc)"
|
||||||
|
>
|
||||||
|
Télécharger
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { CommentDocument } from '~/composables/useComments'
|
||||||
|
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||||
|
import { formatSize, documentIcon, downloadDocument } from '~/shared/utils/documentDisplayUtils'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
documents: CommentDocument[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const openPreview = (doc: CommentDocument) => {
|
||||||
|
if (!canPreviewDocument(doc)) return
|
||||||
|
// Open file URL in new tab for preview
|
||||||
|
if (doc.fileUrl) {
|
||||||
|
window.open(doc.fileUrl, '_blank')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -19,24 +19,55 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Formulaire d'ajout -->
|
<!-- Formulaire d'ajout -->
|
||||||
<div class="flex gap-2">
|
<div class="space-y-2">
|
||||||
<textarea
|
<div class="flex gap-2">
|
||||||
v-model="newContent"
|
<textarea
|
||||||
class="textarea textarea-bordered flex-1 text-sm"
|
v-model="newContent"
|
||||||
rows="2"
|
class="textarea textarea-bordered flex-1 text-sm"
|
||||||
placeholder="Ajouter un commentaire..."
|
rows="2"
|
||||||
:disabled="submitting"
|
placeholder="Ajouter un commentaire..."
|
||||||
@keydown.ctrl.enter="handleSubmit"
|
:disabled="submitting"
|
||||||
/>
|
@keydown.ctrl.enter="handleSubmit"
|
||||||
<button
|
/>
|
||||||
type="button"
|
<div class="flex flex-col gap-1 self-end">
|
||||||
class="btn btn-primary btn-sm self-end"
|
<label
|
||||||
:disabled="!newContent.trim() || submitting"
|
class="btn btn-ghost btn-sm btn-square tooltip tooltip-left"
|
||||||
@click="handleSubmit"
|
data-tip="Joindre des fichiers"
|
||||||
>
|
>
|
||||||
<span v-if="submitting" class="loading loading-spinner loading-xs" />
|
<IconLucidePaperclip class="w-4 h-4" />
|
||||||
<IconLucideSend v-else class="w-4 h-4" />
|
<input
|
||||||
</button>
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
class="hidden"
|
||||||
|
@change="handleFilesSelected"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-sm btn-square"
|
||||||
|
:disabled="!newContent.trim() || submitting"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
<span v-if="submitting" class="loading loading-spinner loading-xs" />
|
||||||
|
<IconLucideSend v-else class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Selected files preview -->
|
||||||
|
<div v-if="selectedFiles.length" class="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="(file, i) in selectedFiles"
|
||||||
|
:key="i"
|
||||||
|
class="badge badge-sm badge-outline gap-1"
|
||||||
|
>
|
||||||
|
<IconLucideFile class="w-3 h-3" />
|
||||||
|
{{ file.name }}
|
||||||
|
<button type="button" class="ml-1" @click="removeFile(i)">
|
||||||
|
<IconLucideX class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Liste des commentaires ouverts -->
|
<!-- Liste des commentaires ouverts -->
|
||||||
@@ -57,6 +88,8 @@
|
|||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
|
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
|
||||||
|
<!-- Documents attachés -->
|
||||||
|
<CommentDocumentList :documents="getDocuments(comment)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between text-xs text-base-content/60">
|
<div class="flex items-center justify-between text-xs text-base-content/60">
|
||||||
@@ -97,6 +130,8 @@
|
|||||||
class="bg-base-200/50 rounded-lg p-3 opacity-60 space-y-1"
|
class="bg-base-200/50 rounded-lg p-3 opacity-60 space-y-1"
|
||||||
>
|
>
|
||||||
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
|
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
|
||||||
|
<!-- Documents attachés (résolus) -->
|
||||||
|
<CommentDocumentList :documents="getDocuments(comment)" />
|
||||||
<div class="flex items-center justify-between text-xs text-base-content/50">
|
<div class="flex items-center justify-between text-xs text-base-content/50">
|
||||||
<span>{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}</span>
|
<span>{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}</span>
|
||||||
<span v-if="comment.resolvedByName">
|
<span v-if="comment.resolvedByName">
|
||||||
@@ -110,12 +145,16 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useComments, type Comment } from '~/composables/useComments'
|
import { useComments, type Comment, type CommentDocument } from '~/composables/useComments'
|
||||||
import { usePermissions } from '~/composables/usePermissions'
|
import { usePermissions } from '~/composables/usePermissions'
|
||||||
|
import CommentDocumentList from '~/components/CommentDocumentList.vue'
|
||||||
import IconLucideMessageSquare from '~icons/lucide/message-square'
|
import IconLucideMessageSquare from '~icons/lucide/message-square'
|
||||||
import IconLucideSend from '~icons/lucide/send'
|
import IconLucideSend from '~icons/lucide/send'
|
||||||
import IconLucideCheck from '~icons/lucide/check'
|
import IconLucideCheck from '~icons/lucide/check'
|
||||||
import IconLucideTrash2 from '~icons/lucide/trash-2'
|
import IconLucideTrash2 from '~icons/lucide/trash-2'
|
||||||
|
import IconLucidePaperclip from '~icons/lucide/paperclip'
|
||||||
|
import IconLucideFile from '~icons/lucide/file'
|
||||||
|
import IconLucideX from '~icons/lucide/x'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
entityType: string
|
entityType: string
|
||||||
@@ -138,6 +177,11 @@ const newContent = ref('')
|
|||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const loadingComments = ref(false)
|
const loadingComments = ref(false)
|
||||||
const showResolvedList = ref(false)
|
const showResolvedList = ref(false)
|
||||||
|
const selectedFiles = ref<File[]>([])
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const getDocuments = (comment: Comment): CommentDocument[] =>
|
||||||
|
comment.documents?.filter((d): d is CommentDocument => typeof d === 'object' && d !== null && 'id' in d) ?? []
|
||||||
|
|
||||||
const openComments = computed(() =>
|
const openComments = computed(() =>
|
||||||
comments.value.filter(c => c.status === 'open'),
|
comments.value.filter(c => c.status === 'open'),
|
||||||
@@ -159,6 +203,18 @@ const formatCommentDate = (dateStr: string): string => {
|
|||||||
}).format(date)
|
}).format(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleFilesSelected = (e: Event) => {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
if (input.files) {
|
||||||
|
selectedFiles.value.push(...Array.from(input.files))
|
||||||
|
}
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFile = (index: number) => {
|
||||||
|
selectedFiles.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
const loadComments = async () => {
|
const loadComments = async () => {
|
||||||
loadingComments.value = true
|
loadingComments.value = true
|
||||||
const [openResult, resolvedResult] = await Promise.all([
|
const [openResult, resolvedResult] = await Promise.all([
|
||||||
@@ -182,10 +238,12 @@ const handleSubmit = async () => {
|
|||||||
props.entityId,
|
props.entityId,
|
||||||
content,
|
content,
|
||||||
props.entityName,
|
props.entityName,
|
||||||
|
selectedFiles.value.length > 0 ? selectedFiles.value : undefined,
|
||||||
)
|
)
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
newContent.value = ''
|
newContent.value = ''
|
||||||
|
selectedFiles.value = []
|
||||||
await loadComments()
|
await loadComments()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
:documents="componentDocuments"
|
:documents="componentDocuments"
|
||||||
@close="closePreview"
|
@close="closePreview"
|
||||||
/>
|
/>
|
||||||
|
<DocumentEditModal
|
||||||
|
:visible="editModalVisible"
|
||||||
|
:document="editingDocument"
|
||||||
|
@close="editModalVisible = false"
|
||||||
|
@updated="handleDocumentUpdated"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Component Header -->
|
<!-- Component Header -->
|
||||||
<div class="flex items-center gap-3 p-3 bg-base-200 rounded-lg cursor-pointer" @click="toggleCollapse">
|
<div class="flex items-center gap-3 p-3 bg-base-200 rounded-lg cursor-pointer" @click="toggleCollapse">
|
||||||
@@ -208,9 +214,11 @@
|
|||||||
<DocumentListInline
|
<DocumentListInline
|
||||||
:documents="componentDocuments"
|
:documents="componentDocuments"
|
||||||
:can-delete="isEditMode"
|
:can-delete="isEditMode"
|
||||||
|
:can-edit="isEditMode"
|
||||||
:delete-disabled="uploadingDocuments"
|
:delete-disabled="uploadingDocuments"
|
||||||
empty-text="Aucun document lié à ce composant."
|
empty-text="Aucun document lié à ce composant."
|
||||||
@preview="openPreview"
|
@preview="openPreview"
|
||||||
|
@edit="openEditModal"
|
||||||
@delete="removeDocument"
|
@delete="removeDocument"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -319,6 +327,7 @@ const {
|
|||||||
ensureDocumentsLoaded,
|
ensureDocumentsLoaded,
|
||||||
handleFilesAdded,
|
handleFilesAdded,
|
||||||
removeDocument,
|
removeDocument,
|
||||||
|
editDocument,
|
||||||
} = useEntityDocuments({ entity: () => props.component, entityType: 'composant' })
|
} = useEntityDocuments({ entity: () => props.component, entityType: 'composant' })
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -333,6 +342,21 @@ const {
|
|||||||
updateCustomField: updateComponentCustomField,
|
updateCustomField: updateComponentCustomField,
|
||||||
} = useEntityCustomFields({ entity: () => props.component, entityType: 'composant' })
|
} = useEntityCustomFields({ entity: () => props.component, entityType: 'composant' })
|
||||||
|
|
||||||
|
// --- Document edit modal ---
|
||||||
|
const editingDocument = ref(null)
|
||||||
|
const editModalVisible = ref(false)
|
||||||
|
|
||||||
|
const openEditModal = (doc) => {
|
||||||
|
editingDocument.value = doc
|
||||||
|
editModalVisible.value = true
|
||||||
|
}
|
||||||
|
const handleDocumentUpdated = async (data) => {
|
||||||
|
if (!editingDocument.value?.id) return
|
||||||
|
await editDocument(editingDocument.value.id, data)
|
||||||
|
editModalVisible.value = false
|
||||||
|
editingDocument.value = null
|
||||||
|
}
|
||||||
|
|
||||||
// --- Collapse state ---
|
// --- Collapse state ---
|
||||||
const isCollapsed = ref(true)
|
const isCollapsed = ref(true)
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||||
import { useComposants } from '~/composables/useComposants'
|
import { useComposants } from '~/composables/useComposants'
|
||||||
|
|
||||||
@@ -52,43 +52,39 @@ const emit = defineEmits<{
|
|||||||
(e: 'update:modelValue', value: string | null): void
|
(e: 'update:modelValue', value: string | null): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { composants, loading, loadComposants } = useComposants()
|
const { loading: globalLoading, loadComposants } = useComposants()
|
||||||
|
|
||||||
const composantOptions = computed(() => {
|
const localComposants = ref<any[]>([])
|
||||||
const baseOptions = Array.isArray(composants.value) ? composants.value : []
|
const localLoading = ref(false)
|
||||||
if (!props.typeComposantId) {
|
const loading = computed(() => localLoading.value || globalLoading.value)
|
||||||
return baseOptions
|
|
||||||
|
const composantOptions = computed(() => localComposants.value)
|
||||||
|
|
||||||
|
const loadFilteredComposants = async () => {
|
||||||
|
if (!props.typeComposantId) return
|
||||||
|
localLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await loadComposants({ typeComposantId: props.typeComposantId, itemsPerPage: 500, force: true })
|
||||||
|
if (result.success && result.data?.items) {
|
||||||
|
localComposants.value = result.data.items
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
catch (error: unknown) {
|
||||||
const allowedTypeId = String(props.typeComposantId)
|
console.error('Erreur lors du chargement des composants:', error)
|
||||||
return baseOptions.filter((composant: any) => {
|
}
|
||||||
const typeId =
|
finally {
|
||||||
composant?.typeComposantId ||
|
localLoading.value = false
|
||||||
composant?.typeComposant?.id ||
|
}
|
||||||
null
|
}
|
||||||
return typeId ? String(typeId) === allowedTypeId : false
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (composantOptions.value.length === 0) {
|
loadFilteredComposants()
|
||||||
loadComposants({ itemsPerPage: 200 }).catch((error: unknown) => {
|
|
||||||
console.error('Erreur lors du chargement des composants:', error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.typeComposantId,
|
||||||
(value) => {
|
() => {
|
||||||
if (typeof value === 'string' && value) {
|
loadFilteredComposants()
|
||||||
const exists = composantOptions.value.some((c: any) => c.id === value)
|
|
||||||
if (!exists && !loading.value) {
|
|
||||||
loadComposants({ itemsPerPage: 200, force: true }).catch((error: unknown) => {
|
|
||||||
console.error('Erreur lors du chargement des composants:', error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -102,8 +98,12 @@ const updateValue = (value: string | number | null | undefined) => {
|
|||||||
|
|
||||||
const formatDescription = (option: any) => {
|
const formatDescription = (option: any) => {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
|
const typeName = option?.typeComposant?.name
|
||||||
|
if (typeName) {
|
||||||
|
parts.push(typeName)
|
||||||
|
}
|
||||||
if (option?.reference) {
|
if (option?.reference) {
|
||||||
parts.push(option.reference)
|
parts.push(`Ref. ${option.reference}`)
|
||||||
}
|
}
|
||||||
if (option?.prix !== undefined && option.prix !== null) {
|
if (option?.prix !== undefined && option.prix !== null) {
|
||||||
const price = Number(option.prix)
|
const price = Number(option.prix)
|
||||||
|
|||||||
90
app/components/DocumentEditModal.vue
Normal file
90
app/components/DocumentEditModal.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible" class="modal modal-open" @click.self="$emit('close')">
|
||||||
|
<div class="modal-box max-w-sm">
|
||||||
|
<h3 class="font-bold text-lg mb-4">
|
||||||
|
Modifier le document
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<label class="form-control w-full">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text">Nom</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="form.name"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm md:input-md w-full"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-control w-full">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text">Type</span>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
v-model="form.type"
|
||||||
|
class="select select-bordered select-sm md:select-md w-full"
|
||||||
|
>
|
||||||
|
<option v-for="t in DOCUMENT_TYPES" :key="t.value" :value="t.value">
|
||||||
|
{{ t.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="$emit('close')">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-sm md:btn-md"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
|
<span v-if="saving" class="loading loading-spinner loading-xs" />
|
||||||
|
Sauvegarder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, watch, ref } from 'vue'
|
||||||
|
import { DOCUMENT_TYPES } from '~/shared/documentTypes'
|
||||||
|
import type { Document } from '~/composables/useDocuments'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
document: Document | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void
|
||||||
|
(e: 'updated', data: { name: string; type: string }): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const form = reactive({ name: '', type: 'documentation' })
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.document,
|
||||||
|
(doc) => {
|
||||||
|
if (doc) {
|
||||||
|
form.name = doc.name || ''
|
||||||
|
form.type = doc.type || 'documentation'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
if (!form.name.trim()) return
|
||||||
|
saving.value = true
|
||||||
|
emit('updated', { name: form.name.trim(), type: form.type })
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -34,6 +34,21 @@
|
|||||||
@change="onFileChange"
|
@change="onFileChange"
|
||||||
>
|
>
|
||||||
|
|
||||||
|
<div class="w-full max-w-xs mt-2">
|
||||||
|
<label class="text-xs font-semibold uppercase tracking-wide text-base-content/70">
|
||||||
|
Type de document
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
class="select select-bordered select-sm w-full mt-1"
|
||||||
|
:value="documentType"
|
||||||
|
@change="emit('update:documentType', $event.target.value)"
|
||||||
|
>
|
||||||
|
<option v-for="t in DOCUMENT_TYPES" :key="t.value" :value="t.value">
|
||||||
|
{{ t.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ul v-if="selectedFiles.length" class="mt-4 w-full space-y-2 text-left">
|
<ul v-if="selectedFiles.length" class="mt-4 w-full space-y-2 text-left">
|
||||||
<li v-for="file in selectedFiles" :key="file.name" class="flex items-center justify-between text-sm">
|
<li v-for="file in selectedFiles" :key="file.name" class="flex items-center justify-between text-sm">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -69,6 +84,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { DOCUMENT_TYPES } from '~/shared/documentTypes'
|
||||||
import { getFileIcon } from '~/utils/fileIcons'
|
import { getFileIcon } from '~/utils/fileIcons'
|
||||||
import IconLucideCloudUpload from '~icons/lucide/cloud-upload'
|
import IconLucideCloudUpload from '~icons/lucide/cloud-upload'
|
||||||
|
|
||||||
@@ -96,10 +112,14 @@ const props = defineProps({
|
|||||||
maxFileSizeMb: {
|
maxFileSizeMb: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 200
|
default: 200
|
||||||
|
},
|
||||||
|
documentType: {
|
||||||
|
type: String,
|
||||||
|
default: 'documentation'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'files-added'])
|
const emit = defineEmits(['update:modelValue', 'files-added', 'update:documentType'])
|
||||||
|
|
||||||
const dragActive = ref(false)
|
const dragActive = ref(false)
|
||||||
const fileInput = ref(null)
|
const fileInput = ref(null)
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
:documents="pieceDocuments"
|
:documents="pieceDocuments"
|
||||||
@close="closePreview"
|
@close="closePreview"
|
||||||
/>
|
/>
|
||||||
|
<DocumentEditModal
|
||||||
|
:visible="editModalVisible"
|
||||||
|
:document="editingDocument"
|
||||||
|
@close="editModalVisible = false"
|
||||||
|
@updated="handleDocumentUpdated"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Piece Header (collapsible, same pattern as ComponentItem) -->
|
<!-- Piece Header (collapsible, same pattern as ComponentItem) -->
|
||||||
<div class="flex items-start justify-between p-4 bg-base-200 rounded-lg">
|
<div class="flex items-start justify-between p-4 bg-base-200 rounded-lg">
|
||||||
@@ -247,9 +253,11 @@
|
|||||||
<DocumentListInline
|
<DocumentListInline
|
||||||
:documents="pieceDocuments"
|
:documents="pieceDocuments"
|
||||||
:can-delete="isEditMode"
|
:can-delete="isEditMode"
|
||||||
|
:can-edit="isEditMode"
|
||||||
:delete-disabled="uploadingDocuments"
|
:delete-disabled="uploadingDocuments"
|
||||||
empty-text="Aucun document lié à cette pièce."
|
empty-text="Aucun document lié à cette pièce."
|
||||||
@preview="openPreview"
|
@preview="openPreview"
|
||||||
|
@edit="openEditModal"
|
||||||
@delete="removeDocument"
|
@delete="removeDocument"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -329,6 +337,7 @@ const {
|
|||||||
refreshDocuments,
|
refreshDocuments,
|
||||||
handleFilesAdded,
|
handleFilesAdded,
|
||||||
removeDocument,
|
removeDocument,
|
||||||
|
editDocument,
|
||||||
} = useEntityDocuments({ entity: () => props.piece, entityType: 'piece' })
|
} = useEntityDocuments({ entity: () => props.piece, entityType: 'piece' })
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -343,6 +352,21 @@ const {
|
|||||||
updateCustomField,
|
updateCustomField,
|
||||||
} = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' })
|
} = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' })
|
||||||
|
|
||||||
|
// --- Document edit modal ---
|
||||||
|
const editingDocument = ref(null)
|
||||||
|
const editModalVisible = ref(false)
|
||||||
|
|
||||||
|
const openEditModal = (doc) => {
|
||||||
|
editingDocument.value = doc
|
||||||
|
editModalVisible.value = true
|
||||||
|
}
|
||||||
|
const handleDocumentUpdated = async (data) => {
|
||||||
|
if (!editingDocument.value?.id) return
|
||||||
|
await editDocument(editingDocument.value.id, data)
|
||||||
|
editModalVisible.value = false
|
||||||
|
editingDocument.value = null
|
||||||
|
}
|
||||||
|
|
||||||
// --- Collapse state ---
|
// --- Collapse state ---
|
||||||
const isCollapsed = ref(true)
|
const isCollapsed = ref(true)
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||||
import { usePieces } from '~/composables/usePieces'
|
import { usePieces } from '~/composables/usePieces'
|
||||||
|
|
||||||
@@ -52,43 +52,39 @@ const emit = defineEmits<{
|
|||||||
(e: 'update:modelValue', value: string | null): void
|
(e: 'update:modelValue', value: string | null): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { pieces, loading, loadPieces } = usePieces()
|
const { loading: globalLoading, loadPieces } = usePieces()
|
||||||
|
|
||||||
const pieceOptions = computed(() => {
|
const localPieces = ref<any[]>([])
|
||||||
const baseOptions = Array.isArray(pieces.value) ? pieces.value : []
|
const localLoading = ref(false)
|
||||||
if (!props.typePieceId) {
|
const loading = computed(() => localLoading.value || globalLoading.value)
|
||||||
return baseOptions
|
|
||||||
|
const pieceOptions = computed(() => localPieces.value)
|
||||||
|
|
||||||
|
const loadFilteredPieces = async () => {
|
||||||
|
if (!props.typePieceId) return
|
||||||
|
localLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await loadPieces({ typePieceId: props.typePieceId, itemsPerPage: 500, force: true })
|
||||||
|
if (result.success && result.data?.items) {
|
||||||
|
localPieces.value = result.data.items
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
catch (error: unknown) {
|
||||||
const allowedTypeId = String(props.typePieceId)
|
console.error('Erreur lors du chargement des pièces:', error)
|
||||||
return baseOptions.filter((piece: any) => {
|
}
|
||||||
const typeId =
|
finally {
|
||||||
piece?.typePieceId ||
|
localLoading.value = false
|
||||||
piece?.typePiece?.id ||
|
}
|
||||||
null
|
}
|
||||||
return typeId ? String(typeId) === allowedTypeId : false
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (pieceOptions.value.length === 0) {
|
loadFilteredPieces()
|
||||||
loadPieces({ itemsPerPage: 200 }).catch((error: unknown) => {
|
|
||||||
console.error('Erreur lors du chargement des pièces:', error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.typePieceId,
|
||||||
(value) => {
|
() => {
|
||||||
if (typeof value === 'string' && value) {
|
loadFilteredPieces()
|
||||||
const exists = pieceOptions.value.some((piece: any) => piece.id === value)
|
|
||||||
if (!exists && !loading.value) {
|
|
||||||
loadPieces({ itemsPerPage: 200, force: true }).catch((error: unknown) => {
|
|
||||||
console.error('Erreur lors du chargement des pièces:', error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -102,8 +98,12 @@ const updateValue = (value: string | number | null | undefined) => {
|
|||||||
|
|
||||||
const formatDescription = (option: any) => {
|
const formatDescription = (option: any) => {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
|
const typeName = option?.typePiece?.name
|
||||||
|
if (typeName) {
|
||||||
|
parts.push(typeName)
|
||||||
|
}
|
||||||
if (option?.reference) {
|
if (option?.reference) {
|
||||||
parts.push(option.reference)
|
parts.push(`Ref. ${option.reference}`)
|
||||||
}
|
}
|
||||||
if (option?.prix !== undefined && option.prix !== null) {
|
if (option?.prix !== undefined && option.prix !== null) {
|
||||||
const price = Number(option.prix)
|
const price = Number(option.prix)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||||
import { useProducts } from '~/composables/useProducts'
|
import { useProducts } from '~/composables/useProducts'
|
||||||
|
|
||||||
@@ -52,43 +52,39 @@ const emit = defineEmits<{
|
|||||||
(e: 'update:modelValue', value: string | null): void
|
(e: 'update:modelValue', value: string | null): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { products, loading, loadProducts } = useProducts()
|
const { loading: globalLoading, loadProducts } = useProducts()
|
||||||
|
|
||||||
const productOptions = computed(() => {
|
const localProducts = ref<any[]>([])
|
||||||
const baseOptions = Array.isArray(products.value) ? products.value : []
|
const localLoading = ref(false)
|
||||||
if (!props.typeProductId) {
|
const loading = computed(() => localLoading.value || globalLoading.value)
|
||||||
return baseOptions
|
|
||||||
|
const productOptions = computed(() => localProducts.value)
|
||||||
|
|
||||||
|
const loadFilteredProducts = async () => {
|
||||||
|
if (!props.typeProductId) return
|
||||||
|
localLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await loadProducts({ typeProductId: props.typeProductId, itemsPerPage: 500, force: true })
|
||||||
|
if (result.success && result.data?.items) {
|
||||||
|
localProducts.value = result.data.items
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
catch (error: unknown) {
|
||||||
const allowedTypeId = String(props.typeProductId)
|
console.error('Erreur lors du chargement des produits:', error)
|
||||||
return baseOptions.filter((product) => {
|
}
|
||||||
const typeId =
|
finally {
|
||||||
product?.typeProductId ||
|
localLoading.value = false
|
||||||
product?.typeProduct?.id ||
|
}
|
||||||
null
|
}
|
||||||
return typeId ? String(typeId) === allowedTypeId : false
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (productOptions.value.length === 0) {
|
loadFilteredProducts()
|
||||||
loadProducts().catch((error) => {
|
|
||||||
console.error('Erreur lors du chargement des produits:', error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.typeProductId,
|
||||||
(value) => {
|
() => {
|
||||||
if (typeof value === 'string' && value) {
|
loadFilteredProducts()
|
||||||
const exists = productOptions.value.some((product) => product.id === value)
|
|
||||||
if (!exists && !loading.value) {
|
|
||||||
loadProducts({ force: true }).catch((error) => {
|
|
||||||
console.error('Erreur lors du chargement des produits:', error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -102,8 +98,12 @@ const updateValue = (value: string | number | null | undefined) => {
|
|||||||
|
|
||||||
const formatDescription = (option: any) => {
|
const formatDescription = (option: any) => {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
|
const typeName = option?.typeProduct?.name
|
||||||
|
if (typeName) {
|
||||||
|
parts.push(typeName)
|
||||||
|
}
|
||||||
if (option?.reference) {
|
if (option?.reference) {
|
||||||
parts.push(option.reference)
|
parts.push(`Ref. ${option.reference}`)
|
||||||
}
|
}
|
||||||
if (option?.supplierPrice !== undefined && option.supplierPrice !== null) {
|
if (option?.supplierPrice !== undefined && option.supplierPrice !== null) {
|
||||||
const price = Number(option.supplierPrice)
|
const price = Number(option.supplierPrice)
|
||||||
|
|||||||
@@ -258,18 +258,7 @@
|
|||||||
{{ piece.typePieceId ? `Sélection : ${getPieceTypeLabel(piece.typePieceId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
|
{{ piece.typePieceId ? `Sélection : ${getPieceTypeLabel(piece.typePieceId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<!-- Quantity is set per-component on the component edit page -->
|
||||||
<label class="label py-1"><span class="label-text text-xs">Quantité</span></label>
|
|
||||||
<input
|
|
||||||
v-model.number="piece.quantity"
|
|
||||||
type="number"
|
|
||||||
:min="1"
|
|
||||||
step="1"
|
|
||||||
placeholder="Qté"
|
|
||||||
class="input input-bordered input-sm md:input-md w-20"
|
|
||||||
@input="piece.quantity = Math.max(1, piece.quantity || 1)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)">
|
<button type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)">
|
||||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||||
|
|||||||
@@ -31,8 +31,9 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-medium">
|
<div class="font-medium flex items-center gap-2">
|
||||||
{{ document.name }}
|
{{ document.name }}
|
||||||
|
<span class="badge badge-sm badge-outline">{{ getDocumentTypeLabel(document.type || 'documentation') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-base-content/70">
|
<div class="text-xs text-base-content/70">
|
||||||
{{ document.mimeType || 'Inconnu' }} • {{ formatSize(document.size) }}
|
{{ document.mimeType || 'Inconnu' }} • {{ formatSize(document.size) }}
|
||||||
@@ -40,6 +41,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
title="Modifier"
|
||||||
|
@click="$emit('edit', document)"
|
||||||
|
>
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ghost btn-xs"
|
class="btn btn-ghost btn-xs"
|
||||||
@@ -74,6 +84,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { getDocumentTypeLabel } from '~/shared/documentTypes'
|
||||||
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
|
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
|
||||||
import {
|
import {
|
||||||
documentIcon,
|
documentIcon,
|
||||||
@@ -89,10 +100,12 @@ import type { Document } from '~/composables/useDocuments'
|
|||||||
withDefaults(defineProps<{
|
withDefaults(defineProps<{
|
||||||
documents: Document[]
|
documents: Document[]
|
||||||
canDelete?: boolean
|
canDelete?: boolean
|
||||||
|
canEdit?: boolean
|
||||||
deleteDisabled?: boolean
|
deleteDisabled?: boolean
|
||||||
emptyText?: string
|
emptyText?: string
|
||||||
}>(), {
|
}>(), {
|
||||||
canDelete: false,
|
canDelete: false,
|
||||||
|
canEdit: false,
|
||||||
deleteDisabled: false,
|
deleteDisabled: false,
|
||||||
emptyText: 'Aucun document.',
|
emptyText: 'Aucun document.',
|
||||||
})
|
})
|
||||||
@@ -100,5 +113,6 @@ withDefaults(defineProps<{
|
|||||||
defineEmits<{
|
defineEmits<{
|
||||||
(e: 'preview', document: Document): void
|
(e: 'preview', document: Document): void
|
||||||
(e: 'delete', documentId: string): void
|
(e: 'delete', documentId: string): void
|
||||||
|
(e: 'edit', document: Document): void
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
{{ resolveLabel(option) }}
|
{{ resolveLabel(option) }}
|
||||||
</slot>
|
</slot>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="resolveDescription(option)" class="text-xs text-base-content/50">
|
<span v-if="$slots['option-description'] || resolveDescription(option)" class="text-xs text-base-content/50">
|
||||||
<slot name="option-description" :option="option">
|
<slot name="option-description" :option="option">
|
||||||
{{ resolveDescription(option) }}
|
{{ resolveDescription(option) }}
|
||||||
</slot>
|
</slot>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
:empty-text="`Aucun ${entityLabelLower} disponible dans cette catégorie`"
|
:empty-text="`Aucun ${entityLabelLower} disponible dans cette catégorie`"
|
||||||
:option-label="entityOptionLabel"
|
:option-label="entityOptionLabel"
|
||||||
:option-description="entityOptionDescription"
|
:option-description="entityOptionDescription"
|
||||||
|
@search="handleEntitySearch"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -187,6 +188,30 @@ watch(selectedTypeId, async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let searchDebounce: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const handleEntitySearch = (term: string) => {
|
||||||
|
if (searchDebounce) clearTimeout(searchDebounce)
|
||||||
|
searchDebounce = setTimeout(async () => {
|
||||||
|
if (!selectedTypeName.value) return
|
||||||
|
loadingEntities.value = true
|
||||||
|
try {
|
||||||
|
if (props.entityKind === 'component') {
|
||||||
|
const result = await loadComposants({ typeName: selectedTypeName.value, search: term.trim(), itemsPerPage: 200 })
|
||||||
|
entities.value = result?.data?.items || []
|
||||||
|
} else if (props.entityKind === 'piece') {
|
||||||
|
const result = await loadPieces({ typeName: selectedTypeName.value, search: term.trim(), itemsPerPage: 200 })
|
||||||
|
entities.value = result?.data?.items || []
|
||||||
|
} else {
|
||||||
|
const result = await loadProducts({ typeName: selectedTypeName.value, search: term.trim(), itemsPerPage: 200 })
|
||||||
|
entities.value = result?.data?.items || []
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loadingEntities.value = false
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
resetState()
|
resetState()
|
||||||
emit('close')
|
emit('close')
|
||||||
|
|||||||
@@ -3,6 +3,18 @@ import { useApi } from './useApi'
|
|||||||
import { useToast } from './useToast'
|
import { useToast } from './useToast'
|
||||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||||
|
|
||||||
|
export interface CommentDocument {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
filename: string
|
||||||
|
mimeType: string
|
||||||
|
size: number
|
||||||
|
type: string
|
||||||
|
fileUrl: string
|
||||||
|
downloadUrl: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Comment {
|
export interface Comment {
|
||||||
id: string
|
id: string
|
||||||
content: string
|
content: string
|
||||||
@@ -17,6 +29,7 @@ export interface Comment {
|
|||||||
resolvedAt?: string | null
|
resolvedAt?: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
documents?: CommentDocument[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CommentResult {
|
interface CommentResult {
|
||||||
@@ -33,7 +46,7 @@ interface CommentListResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useComments() {
|
export function useComments() {
|
||||||
const { get, post, patch, delete: del } = useApi()
|
const { get, post, patch, postFormData, delete: del } = useApi()
|
||||||
const { showSuccess, showError } = useToast()
|
const { showSuccess, showError } = useToast()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
@@ -44,16 +57,9 @@ export function useComments() {
|
|||||||
): Promise<CommentListResult> => {
|
): Promise<CommentListResult> => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const result = await get<Comment[]>(`/comments/by-entity/${entityType}/${entityId}?status=${status}`)
|
||||||
entityType,
|
|
||||||
entityId,
|
|
||||||
status,
|
|
||||||
'order[createdAt]': 'desc',
|
|
||||||
itemsPerPage: '200',
|
|
||||||
})
|
|
||||||
const result = await get(`/comments?${params.toString()}`)
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const items = extractCollection<Comment>(result.data)
|
const items = (result.data ?? []) as Comment[]
|
||||||
return { success: true, data: items }
|
return { success: true, data: items }
|
||||||
}
|
}
|
||||||
return { success: false, error: result.error }
|
return { success: false, error: result.error }
|
||||||
@@ -80,18 +86,15 @@ export function useComments() {
|
|||||||
if (options.status) params.set('status', options.status)
|
if (options.status) params.set('status', options.status)
|
||||||
if (options.entityType) params.set('entityType', options.entityType)
|
if (options.entityType) params.set('entityType', options.entityType)
|
||||||
if (options.entityName) params.set('entityName', options.entityName)
|
if (options.entityName) params.set('entityName', options.entityName)
|
||||||
const sortField = options.orderBy || 'createdAt'
|
params.set('sort', options.orderBy || 'createdAt')
|
||||||
const sortDir = options.orderDir || 'desc'
|
params.set('direction', options.orderDir || 'desc')
|
||||||
params.set(`order[${sortField}]`, sortDir)
|
|
||||||
params.set('itemsPerPage', String(options.itemsPerPage || 30))
|
params.set('itemsPerPage', String(options.itemsPerPage || 30))
|
||||||
params.set('page', String(options.page || 1))
|
params.set('page', String(options.page || 1))
|
||||||
|
|
||||||
const result = await get(`/comments?${params.toString()}`)
|
const result = await get<{ items: Comment[]; total: number }>(`/comments/search/list?${params.toString()}`)
|
||||||
if (result.success) {
|
if (result.success && result.data) {
|
||||||
const items = extractCollection<Comment>(result.data)
|
const data = result.data as { items: Comment[]; total: number }
|
||||||
const raw = result.data as Record<string, unknown> | null
|
return { success: true, data: data.items, total: data.total }
|
||||||
const total = Number(raw?.['hydra:totalItems'] ?? raw?.totalItems ?? items.length)
|
|
||||||
return { success: true, data: items, total }
|
|
||||||
}
|
}
|
||||||
return { success: false, error: result.error }
|
return { success: false, error: result.error }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -107,12 +110,26 @@ export function useComments() {
|
|||||||
entityId: string,
|
entityId: string,
|
||||||
content: string,
|
content: string,
|
||||||
entityName?: string,
|
entityName?: string,
|
||||||
|
files?: File[],
|
||||||
): Promise<CommentResult> => {
|
): Promise<CommentResult> => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const payload: Record<string, string> = { entityType, entityId, content }
|
let result
|
||||||
if (entityName) payload.entityName = entityName
|
if (files && files.length > 0) {
|
||||||
const result = await post('/comments', payload)
|
const formData = new FormData()
|
||||||
|
formData.append('content', content)
|
||||||
|
formData.append('entityType', entityType)
|
||||||
|
formData.append('entityId', entityId)
|
||||||
|
if (entityName) formData.append('entityName', entityName)
|
||||||
|
for (const file of files) {
|
||||||
|
formData.append('files[]', file)
|
||||||
|
}
|
||||||
|
result = await postFormData('/comments', formData)
|
||||||
|
} else {
|
||||||
|
const payload: Record<string, string> = { entityType, entityId, content }
|
||||||
|
if (entityName) payload.entityName = entityName
|
||||||
|
result = await post('/comments', payload)
|
||||||
|
}
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showSuccess('Commentaire ajouté')
|
showSuccess('Commentaire ajouté')
|
||||||
return { success: true, data: result.data as Comment }
|
return { success: true, data: result.data as Comment }
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ export function useComponentCreate() {
|
|||||||
await _saveCustomFieldValues(
|
await _saveCustomFieldValues(
|
||||||
'composant',
|
'composant',
|
||||||
createdComponent.id,
|
createdComponent.id,
|
||||||
[createdComponent?.typeComposant?.customFields],
|
[createdComponent?.typeComposant?.structure?.customFields],
|
||||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||||
)
|
)
|
||||||
if (selectedDocuments.value.length && result.data?.id) {
|
if (selectedDocuments.value.length && result.data?.id) {
|
||||||
@@ -344,7 +344,7 @@ export function useComponentCreate() {
|
|||||||
selectedDocuments.value = []
|
selectedDocuments.value = []
|
||||||
}
|
}
|
||||||
toast.showSuccess('Composant créé avec succès')
|
toast.showSuccess('Composant créé avec succès')
|
||||||
await router.push('/component-catalog')
|
await router.replace(`/component/${createdComponent.id}/edit`)
|
||||||
}
|
}
|
||||||
else if (result.error) {
|
else if (result.error) {
|
||||||
toast.showError(result.error)
|
toast.showError(result.error)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useProductTypes } from '~/composables/useProductTypes'
|
|||||||
import { usePieces } from '~/composables/usePieces'
|
import { usePieces } from '~/composables/usePieces'
|
||||||
import { useProducts } from '~/composables/useProducts'
|
import { useProducts } from '~/composables/useProducts'
|
||||||
import { useCustomFields } from '~/composables/useCustomFields'
|
import { useCustomFields } from '~/composables/useCustomFields'
|
||||||
import type { SelectionEntry } from '~/shared/utils/structureSelectionUtils'
|
|
||||||
import { useApi } from '~/composables/useApi'
|
import { useApi } from '~/composables/useApi'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { extractRelationId } from '~/shared/apiRelations'
|
import { extractRelationId } from '~/shared/apiRelations'
|
||||||
@@ -58,9 +57,9 @@ export function useComponentEdit(componentId: string) {
|
|||||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||||
const { productTypes, loadProductTypes } = useProductTypes()
|
const { productTypes, loadProductTypes } = useProductTypes()
|
||||||
const { updateComposant, loadComposants, composants: componentCatalogRef } = useComposants()
|
const { updateComposant, composants: componentCatalogRef } = useComposants()
|
||||||
const { pieces, loadPieces } = usePieces()
|
const { pieces } = usePieces()
|
||||||
const { products, loadProducts } = useProducts()
|
const { products } = useProducts()
|
||||||
const { ensureConstructeurs } = useConstructeurs()
|
const { ensureConstructeurs } = useConstructeurs()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -348,19 +347,17 @@ export function useComponentEdit(componentId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveSlotQuantity = async (entry: SelectionEntry) => {
|
const saveSlotQuantity = async (slotId: string, quantity: number) => {
|
||||||
const slotId = entry.slotId
|
if (!slotId || quantity < 1) return
|
||||||
const quantity = typeof entry._definition?.quantity === 'number'
|
const result = await patch(`/composant-piece-slots/${slotId}`, { quantity: Math.max(1, quantity) })
|
||||||
? Math.max(1, entry._definition.quantity)
|
if (result.success) {
|
||||||
: null
|
const structure = component.value?.structure
|
||||||
if (!slotId || quantity === null) return
|
if (structure?.pieces) {
|
||||||
try {
|
const slot = (structure.pieces as any[]).find((s: any) => s.slotId === slotId)
|
||||||
await patch(`/composant-piece-slots/${slotId}`, { quantity })
|
if (slot) slot.quantity = quantity
|
||||||
|
}
|
||||||
toast.showSuccess('Quantité mise à jour')
|
toast.showSuccess('Quantité mise à jour')
|
||||||
}
|
}
|
||||||
catch (error: any) {
|
|
||||||
toast.showError(error?.message || 'Erreur lors de la mise à jour de la quantité')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitEdition = async () => {
|
const submitEdition = async () => {
|
||||||
@@ -402,11 +399,11 @@ export function useComponentEdit(componentId: string) {
|
|||||||
'composant',
|
'composant',
|
||||||
updatedComponent.id,
|
updatedComponent.id,
|
||||||
[
|
[
|
||||||
updatedComponent?.typeComposant?.customFields,
|
updatedComponent?.typeComposant?.structure?.customFields,
|
||||||
],
|
],
|
||||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||||
)
|
)
|
||||||
await router.push('/component-catalog')
|
toast.showSuccess('Composant mis à jour avec succès.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error: any) {
|
catch (error: any) {
|
||||||
@@ -500,13 +497,6 @@ export function useComponentEdit(componentId: string) {
|
|||||||
fetchComponent(),
|
fetchComponent(),
|
||||||
])
|
])
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
|
||||||
// Load catalogs for slot selectors (force: true to bypass cache from list pages that load fewer items)
|
|
||||||
Promise.allSettled([
|
|
||||||
loadPieces({ itemsPerPage: 200, force: true }),
|
|
||||||
loadProducts({ itemsPerPage: 200, force: true }),
|
|
||||||
loadComposants({ itemsPerPage: 200, force: true }),
|
|
||||||
]).catch(() => {})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ interface LoadComposantsOptions {
|
|||||||
orderBy?: string
|
orderBy?: string
|
||||||
orderDir?: 'asc' | 'desc'
|
orderDir?: 'asc' | 'desc'
|
||||||
typeName?: string
|
typeName?: string
|
||||||
|
typeComposantId?: string
|
||||||
force?: boolean
|
force?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,17 +110,18 @@ export function useComposants() {
|
|||||||
orderBy = 'name',
|
orderBy = 'name',
|
||||||
orderDir = 'asc',
|
orderDir = 'asc',
|
||||||
typeName,
|
typeName,
|
||||||
|
typeComposantId,
|
||||||
force = false,
|
force = false,
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
if (!force && loaded.value && !search && !typeName && page === 1) {
|
if (!force && loaded.value && !search && !typeName && !typeComposantId && page === 1) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: { items: composants.value, total: total.value, page, itemsPerPage },
|
data: { items: composants.value, total: total.value, page, itemsPerPage },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading.value) {
|
if (!typeComposantId && loading.value) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: { items: composants.value, total: total.value, page, itemsPerPage },
|
data: { items: composants.value, total: total.value, page, itemsPerPage },
|
||||||
@@ -128,33 +130,41 @@ export function useComposants() {
|
|||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
params.set('itemsPerPage', String(itemsPerPage))
|
params.set('itemsPerPage', String(itemsPerPage))
|
||||||
params.set('page', String(page))
|
params.set('page', String(page))
|
||||||
|
|
||||||
if (search && search.trim()) {
|
if (search && search.trim()) {
|
||||||
params.set('name', search.trim())
|
params.set('search', search.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeName && typeName.trim()) {
|
if (typeName && typeName.trim()) {
|
||||||
params.set('typeComposant.name', typeName.trim())
|
params.set('typeComposant.name', typeName.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeComposantId) {
|
||||||
|
params.set('typeComposant', typeComposantId)
|
||||||
|
}
|
||||||
|
|
||||||
params.set(`order[${orderBy}]`, orderDir)
|
params.set(`order[${orderBy}]`, orderDir)
|
||||||
|
|
||||||
const result = await get(`/composants?${params.toString()}`)
|
const result = await get(`/composants?${params.toString()}`)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const items = extractCollection(result.data)
|
const items = extractCollection(result.data)
|
||||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||||
composants.value = enrichedItems
|
const resultTotal = extractTotal(result.data, items.length)
|
||||||
total.value = extractTotal(result.data, items.length)
|
|
||||||
loaded.value = true
|
if (!typeComposantId) {
|
||||||
|
composants.value = enrichedItems
|
||||||
|
total.value = resultTotal
|
||||||
|
loaded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
items: enrichedItems,
|
items: enrichedItems,
|
||||||
total: total.value,
|
total: resultTotal,
|
||||||
page,
|
page,
|
||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface Document {
|
|||||||
size: number
|
size: number
|
||||||
fileUrl: string
|
fileUrl: string
|
||||||
downloadUrl: string
|
downloadUrl: string
|
||||||
|
type?: string
|
||||||
/** @deprecated Legacy Base64 data URI — use fileUrl instead */
|
/** @deprecated Legacy Base64 data URI — use fileUrl instead */
|
||||||
path?: string
|
path?: string
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
@@ -32,6 +33,7 @@ export interface UploadContext {
|
|||||||
composantId?: string
|
composantId?: string
|
||||||
productId?: string
|
productId?: string
|
||||||
pieceId?: string
|
pieceId?: string
|
||||||
|
type?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentResult {
|
export interface DocumentResult {
|
||||||
@@ -47,6 +49,7 @@ interface LoadDocumentsOptions {
|
|||||||
orderBy?: string
|
orderBy?: string
|
||||||
orderDir?: 'asc' | 'desc'
|
orderDir?: 'asc' | 'desc'
|
||||||
attachmentFilter?: string
|
attachmentFilter?: string
|
||||||
|
type?: string
|
||||||
force?: boolean
|
force?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +66,7 @@ const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useDocuments() {
|
export function useDocuments() {
|
||||||
const { get, postFormData, delete: del } = useApi()
|
const { get, patch, postFormData, delete: del } = useApi()
|
||||||
const { showError, showSuccess } = useToast()
|
const { showError, showSuccess } = useToast()
|
||||||
|
|
||||||
const loadFromEndpoint = async (
|
const loadFromEndpoint = async (
|
||||||
@@ -103,10 +106,11 @@ export function useDocuments() {
|
|||||||
orderBy = 'createdAt',
|
orderBy = 'createdAt',
|
||||||
orderDir = 'desc',
|
orderDir = 'desc',
|
||||||
attachmentFilter = 'all',
|
attachmentFilter = 'all',
|
||||||
|
type = 'all',
|
||||||
force = false,
|
force = false,
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
if (!force && loaded.value && !search && page === 1 && attachmentFilter === 'all') {
|
if (!force && loaded.value && !search && page === 1 && attachmentFilter === 'all' && type === 'all') {
|
||||||
return { success: true, data: documents.value }
|
return { success: true, data: documents.value }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +132,10 @@ export function useDocuments() {
|
|||||||
params.set(`exists[${attachmentFilter}]`, 'true')
|
params.set(`exists[${attachmentFilter}]`, 'true')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type && type !== 'all') {
|
||||||
|
params.set('type', type)
|
||||||
|
}
|
||||||
|
|
||||||
params.set(`order[${orderBy}]`, orderDir)
|
params.set(`order[${orderBy}]`, orderDir)
|
||||||
|
|
||||||
const result = await get(`/documents?${params.toString()}`)
|
const result = await get(`/documents?${params.toString()}`)
|
||||||
@@ -218,6 +226,7 @@ export function useDocuments() {
|
|||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
formData.append('name', file.name)
|
formData.append('name', file.name)
|
||||||
|
if (context.type) formData.append('type', context.type)
|
||||||
|
|
||||||
if (context.siteId) formData.append('siteId', context.siteId)
|
if (context.siteId) formData.append('siteId', context.siteId)
|
||||||
if (context.machineId) formData.append('machineId', context.machineId)
|
if (context.machineId) formData.append('machineId', context.machineId)
|
||||||
@@ -280,6 +289,33 @@ export function useDocuments() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateDocument = async (
|
||||||
|
id: string,
|
||||||
|
data: { name?: string; type?: string },
|
||||||
|
): Promise<DocumentResult> => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await patch(`/documents/${id}`, data)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const updated = result.data as Document
|
||||||
|
const index = documents.value.findIndex((doc) => doc.id === id)
|
||||||
|
if (index !== -1) {
|
||||||
|
documents.value[index] = { ...documents.value[index], ...updated }
|
||||||
|
}
|
||||||
|
showSuccess('Document mis à jour')
|
||||||
|
return { success: true, data: updated }
|
||||||
|
}
|
||||||
|
if (result.error) showError(result.error)
|
||||||
|
return result as DocumentResult
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error
|
||||||
|
showError('Impossible de mettre à jour le document')
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
documents,
|
documents,
|
||||||
total,
|
total,
|
||||||
@@ -292,6 +328,7 @@ export function useDocuments() {
|
|||||||
loadDocumentsByPiece,
|
loadDocumentsByPiece,
|
||||||
loadDocumentsByProduct,
|
loadDocumentsByProduct,
|
||||||
uploadDocuments,
|
uploadDocuments,
|
||||||
|
updateDocument,
|
||||||
deleteDocument,
|
deleteDocument,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export interface EntityDocumentsDeps {
|
|||||||
|
|
||||||
export function useEntityDocuments(deps: EntityDocumentsDeps) {
|
export function useEntityDocuments(deps: EntityDocumentsDeps) {
|
||||||
const { entity, entityType } = deps
|
const { entity, entityType } = deps
|
||||||
const { uploadDocuments, deleteDocument } = useDocuments()
|
const { uploadDocuments, deleteDocument, updateDocument } = useDocuments()
|
||||||
|
|
||||||
const loadDocumentsFn = entityType === 'composant'
|
const loadDocumentsFn = entityType === 'composant'
|
||||||
? useDocuments().loadDocumentsByComponent
|
? useDocuments().loadDocumentsByComponent
|
||||||
@@ -104,6 +104,19 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const editDocument = async (id: string, data: { name?: string; type?: string }) => {
|
||||||
|
const result: any = await updateDocument(id, data)
|
||||||
|
if (result.success) {
|
||||||
|
const e = entity()
|
||||||
|
const docs = e.documents || []
|
||||||
|
const index = docs.findIndex((doc: any) => doc.id === id)
|
||||||
|
if (index !== -1) {
|
||||||
|
docs[index] = { ...docs[index], ...data }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
documents,
|
documents,
|
||||||
selectedFiles,
|
selectedFiles,
|
||||||
@@ -118,5 +131,6 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
|
|||||||
ensureDocumentsLoaded,
|
ensureDocumentsLoaded,
|
||||||
handleFilesAdded,
|
handleFilesAdded,
|
||||||
removeDocument,
|
removeDocument,
|
||||||
|
editDocument,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,8 +127,8 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
|||||||
showSuccess(`Type de ${label} "${data.name}" créé`)
|
showSuccess(`Type de ${label} "${data.name}" créé`)
|
||||||
return { success: true, data: normalized }
|
return { success: true, data: normalized }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
const err = error as Error & { data?: { error?: string; message?: string }; message?: string }
|
||||||
const raw = err?.data?.message || err?.message
|
const raw = err?.data?.error || err?.data?.message || err?.message
|
||||||
const message = humanizeError(raw)
|
const message = humanizeError(raw)
|
||||||
showError(`Impossible de créer le type de ${label} : ${message}`)
|
showError(`Impossible de créer le type de ${label} : ${message}`)
|
||||||
return { success: false, error: message }
|
return { success: false, error: message }
|
||||||
@@ -153,8 +153,8 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
|||||||
showSuccess(`Type de ${label} "${data.name}" mis à jour`)
|
showSuccess(`Type de ${label} "${data.name}" mis à jour`)
|
||||||
return { success: true, data: normalized }
|
return { success: true, data: normalized }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
const err = error as Error & { data?: { error?: string; message?: string }; message?: string }
|
||||||
const raw = err?.data?.message || err?.message
|
const raw = err?.data?.error || err?.data?.message || err?.message
|
||||||
const message = humanizeError(raw)
|
const message = humanizeError(raw)
|
||||||
showError(`Impossible de mettre à jour le type de ${label} : ${message}`)
|
showError(`Impossible de mettre à jour le type de ${label} : ${message}`)
|
||||||
return { success: false, error: message }
|
return { success: false, error: message }
|
||||||
@@ -171,8 +171,8 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
|||||||
showSuccess(`Type de ${label} supprimé`)
|
showSuccess(`Type de ${label} supprimé`)
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
const err = error as Error & { data?: { error?: string; message?: string }; message?: string }
|
||||||
const raw = err?.data?.message || err?.message
|
const raw = err?.data?.error || err?.data?.message || err?.message
|
||||||
const message = humanizeError(raw)
|
const message = humanizeError(raw)
|
||||||
showError(`Impossible de supprimer le type de ${label} : ${message}`)
|
showError(`Impossible de supprimer le type de ${label} : ${message}`)
|
||||||
return { success: false, error: message }
|
return { success: false, error: message }
|
||||||
|
|||||||
@@ -273,6 +273,7 @@ export const buildMachineHierarchyFromLinks = (
|
|||||||
originalComposant: originalComponent,
|
originalComposant: originalComponent,
|
||||||
machineComponentLink: link,
|
machineComponentLink: link,
|
||||||
machineComponentLinkId,
|
machineComponentLinkId,
|
||||||
|
linkId: machineComponentLinkId,
|
||||||
componentLinkId: machineComponentLinkId,
|
componentLinkId: machineComponentLinkId,
|
||||||
parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedComponent.parentComponentLinkId),
|
parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedComponent.parentComponentLinkId),
|
||||||
parentComposantId: resolveIdentifier(appliedComponent.parentComposantId, link.parentComponentId),
|
parentComposantId: resolveIdentifier(appliedComponent.parentComposantId, link.parentComponentId),
|
||||||
|
|||||||
@@ -408,11 +408,11 @@ export function usePieceEdit(pieceId: string) {
|
|||||||
'piece',
|
'piece',
|
||||||
updatedPiece.id,
|
updatedPiece.id,
|
||||||
[
|
[
|
||||||
updatedPiece?.typePiece?.pieceCustomFields,
|
updatedPiece?.typePiece?.structure?.customFields,
|
||||||
],
|
],
|
||||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||||
)
|
)
|
||||||
await router.push('/pieces-catalog')
|
toast.showSuccess('Pièce mise à jour avec succès.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error: any) {
|
catch (error: any) {
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ const toEditorField = (
|
|||||||
type: baseType as PieceModelCustomFieldType,
|
type: baseType as PieceModelCustomFieldType,
|
||||||
required: Boolean(input?.required),
|
required: Boolean(input?.required),
|
||||||
optionsText,
|
optionsText,
|
||||||
|
defaultValue:
|
||||||
|
input?.defaultValue !== undefined && input.defaultValue !== null && input.defaultValue !== ''
|
||||||
|
? String(input.defaultValue)
|
||||||
|
: null,
|
||||||
|
...(typeof input?.id === 'string' && input.id ? { id: input.id } : {}),
|
||||||
|
...(typeof input?.customFieldId === 'string' && input.customFieldId ? { customFieldId: input.customFieldId } : {}),
|
||||||
orderIndex: typeof input?.orderIndex === 'number' ? input.orderIndex : index,
|
orderIndex: typeof input?.orderIndex === 'number' ? input.orderIndex : index,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,6 +164,16 @@ const buildPayload = (
|
|||||||
orderIndex: index,
|
orderIndex: index,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (field.id) {
|
||||||
|
payload.id = field.id
|
||||||
|
}
|
||||||
|
if (field.customFieldId) {
|
||||||
|
payload.customFieldId = field.customFieldId
|
||||||
|
}
|
||||||
|
if (field.defaultValue !== undefined && field.defaultValue !== null && field.defaultValue !== '') {
|
||||||
|
payload.defaultValue = String(field.defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'select') {
|
if (type === 'select') {
|
||||||
const options = normalizeLineEndings(field.optionsText)
|
const options = normalizeLineEndings(field.optionsText)
|
||||||
.split('\n')
|
.split('\n')
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ interface LoadPiecesOptions {
|
|||||||
orderBy?: string
|
orderBy?: string
|
||||||
orderDir?: 'asc' | 'desc'
|
orderDir?: 'asc' | 'desc'
|
||||||
typeName?: string
|
typeName?: string
|
||||||
|
typePieceId?: string
|
||||||
force?: boolean
|
force?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,17 +120,20 @@ export function usePieces() {
|
|||||||
orderBy = 'name',
|
orderBy = 'name',
|
||||||
orderDir = 'asc',
|
orderDir = 'asc',
|
||||||
typeName,
|
typeName,
|
||||||
|
typePieceId,
|
||||||
force = false,
|
force = false,
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
if (!force && loaded.value && !search && !typeName && page === 1) {
|
// Only use cache for unfiltered full-catalog loads
|
||||||
|
if (!force && loaded.value && !search && !typeName && !typePieceId && page === 1) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: { items: pieces.value, total: total.value, page, itemsPerPage },
|
data: { items: pieces.value, total: total.value, page, itemsPerPage },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading.value) {
|
// For filtered queries, don't block on global loading state
|
||||||
|
if (!typePieceId && loading.value) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: { items: pieces.value, total: total.value, page, itemsPerPage },
|
data: { items: pieces.value, total: total.value, page, itemsPerPage },
|
||||||
@@ -138,33 +142,42 @@ export function usePieces() {
|
|||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
params.set('itemsPerPage', String(itemsPerPage))
|
params.set('itemsPerPage', String(itemsPerPage))
|
||||||
params.set('page', String(page))
|
params.set('page', String(page))
|
||||||
|
|
||||||
if (search && search.trim()) {
|
if (search && search.trim()) {
|
||||||
params.set('name', search.trim())
|
params.set('search', search.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeName && typeName.trim()) {
|
if (typeName && typeName.trim()) {
|
||||||
params.set('typePiece.name', typeName.trim())
|
params.set('typePiece.name', typeName.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typePieceId) {
|
||||||
|
params.set('typePiece', typePieceId)
|
||||||
|
}
|
||||||
|
|
||||||
params.set(`order[${orderBy}]`, orderDir)
|
params.set(`order[${orderBy}]`, orderDir)
|
||||||
|
|
||||||
const result = await get(`/pieces?${params.toString()}`)
|
const result = await get(`/pieces?${params.toString()}`)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const items = extractCollection(result.data)
|
const items = extractCollection(result.data)
|
||||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||||
pieces.value = enrichedItems
|
const resultTotal = extractTotal(result.data, items.length)
|
||||||
total.value = extractTotal(result.data, items.length)
|
|
||||||
loaded.value = true
|
// Only update global cache for unfiltered queries
|
||||||
|
if (!typePieceId) {
|
||||||
|
pieces.value = enrichedItems
|
||||||
|
total.value = resultTotal
|
||||||
|
loaded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
items: enrichedItems,
|
items: enrichedItems,
|
||||||
total: total.value,
|
total: resultTotal,
|
||||||
page,
|
page,
|
||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ interface LoadProductsOptions {
|
|||||||
orderBy?: string
|
orderBy?: string
|
||||||
orderDir?: 'asc' | 'desc'
|
orderDir?: 'asc' | 'desc'
|
||||||
typeName?: string
|
typeName?: string
|
||||||
|
typeProductId?: string
|
||||||
force?: boolean
|
force?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,17 +119,18 @@ export function useProducts() {
|
|||||||
orderBy = 'name',
|
orderBy = 'name',
|
||||||
orderDir = 'asc',
|
orderDir = 'asc',
|
||||||
typeName,
|
typeName,
|
||||||
|
typeProductId,
|
||||||
force = false,
|
force = false,
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
if (!force && loaded.value && !search && !typeName && page === 1) {
|
if (!force && loaded.value && !search && !typeName && !typeProductId && page === 1) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: { items: products.value, total: total.value, page, itemsPerPage },
|
data: { items: products.value, total: total.value, page, itemsPerPage },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading.value) {
|
if (!typeProductId && loading.value) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: { items: products.value, total: total.value, page, itemsPerPage },
|
data: { items: products.value, total: total.value, page, itemsPerPage },
|
||||||
@@ -143,27 +145,36 @@ export function useProducts() {
|
|||||||
params.set('page', String(page))
|
params.set('page', String(page))
|
||||||
|
|
||||||
if (search && search.trim()) {
|
if (search && search.trim()) {
|
||||||
params.set('name', search.trim())
|
params.set('search', search.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeName && typeName.trim()) {
|
if (typeName && typeName.trim()) {
|
||||||
params.set('typeProduct.name', typeName.trim())
|
params.set('typeProduct.name', typeName.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeProductId) {
|
||||||
|
params.set('typeProduct', typeProductId)
|
||||||
|
}
|
||||||
|
|
||||||
params.set(`order[${orderBy}]`, orderDir)
|
params.set(`order[${orderBy}]`, orderDir)
|
||||||
|
|
||||||
const result = await get(`/products?${params.toString()}`)
|
const result = await get(`/products?${params.toString()}`)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const items = extractCollection(result.data)
|
const items = extractCollection(result.data)
|
||||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||||
products.value = enrichedItems
|
const resultTotal = extractTotal(result.data, items.length)
|
||||||
total.value = extractTotal(result.data, items.length)
|
|
||||||
loaded.value = true
|
if (!typeProductId) {
|
||||||
|
products.value = enrichedItems
|
||||||
|
total.value = resultTotal
|
||||||
|
loaded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
items: enrichedItems,
|
items: enrichedItems,
|
||||||
total: total.value,
|
total: resultTotal,
|
||||||
page,
|
page,
|
||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -105,9 +105,9 @@ export function useStructureAssignmentFetch(deps: StructureAssignmentFetchDeps)
|
|||||||
definition.typeComposantId || definition.modelId || definition.typeComposant?.id || null
|
definition.typeComposantId || definition.modelId || definition.typeComposant?.id || null
|
||||||
|
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
params.set('itemsPerPage', '50')
|
params.set('itemsPerPage', '200')
|
||||||
if (term.trim()) {
|
if (term.trim()) {
|
||||||
params.set('name', term.trim())
|
params.set('search', term.trim())
|
||||||
}
|
}
|
||||||
if (requiredTypeId) {
|
if (requiredTypeId) {
|
||||||
params.set('typeComposant', typeIri(requiredTypeId))
|
params.set('typeComposant', typeIri(requiredTypeId))
|
||||||
@@ -173,9 +173,9 @@ export function useStructureAssignmentFetch(deps: StructureAssignmentFetchDeps)
|
|||||||
definition.typePieceId || definition.typePiece?.id || null
|
definition.typePieceId || definition.typePiece?.id || null
|
||||||
|
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
params.set('itemsPerPage', '50')
|
params.set('itemsPerPage', '200')
|
||||||
if (term.trim()) {
|
if (term.trim()) {
|
||||||
params.set('name', term.trim())
|
params.set('search', term.trim())
|
||||||
}
|
}
|
||||||
if (requiredTypeId) {
|
if (requiredTypeId) {
|
||||||
params.set('typePiece', typeIri(requiredTypeId))
|
params.set('typePiece', typeIri(requiredTypeId))
|
||||||
@@ -246,9 +246,9 @@ export function useStructureAssignmentFetch(deps: StructureAssignmentFetchDeps)
|
|||||||
definition.typeProductId || definition.typeProduct?.id || null
|
definition.typeProductId || definition.typeProduct?.id || null
|
||||||
|
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
params.set('itemsPerPage', '50')
|
params.set('itemsPerPage', '200')
|
||||||
if (term.trim()) {
|
if (term.trim()) {
|
||||||
params.set('name', term.trim())
|
params.set('search', term.trim())
|
||||||
}
|
}
|
||||||
if (requiredTypeId) {
|
if (requiredTypeId) {
|
||||||
params.set('typeProduct', typeIri(requiredTypeId))
|
params.set('typeProduct', typeIri(requiredTypeId))
|
||||||
|
|||||||
@@ -1,260 +1,646 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="container mx-auto max-w-4xl px-6 py-10 space-y-8">
|
<main class="container mx-auto max-w-4xl px-6 py-10 space-y-8">
|
||||||
<header class="space-y-2">
|
<header class="space-y-2">
|
||||||
<h1 class="text-3xl font-bold text-base-content">Changelog</h1>
|
<h1 class="text-3xl font-bold text-base-content">Changelog</h1>
|
||||||
<p class="text-sm text-base-content/70">
|
<p class="text-sm text-base-content/70">
|
||||||
Historique des modifications et nouvelles fonctionnalités de l'application.
|
Historique des modifications et nouvelles fonctionnalités de
|
||||||
</p>
|
l'application.
|
||||||
</header>
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
v-for="release in releases"
|
v-for="release in releases"
|
||||||
:key="release.version"
|
:key="release.version"
|
||||||
class="card border border-base-200 bg-base-100 shadow-sm"
|
class="card border border-base-200 bg-base-100 shadow-sm"
|
||||||
>
|
>
|
||||||
<div class="card-body space-y-3">
|
<div class="card-body space-y-3">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<h2 class="text-xl font-bold text-base-content">
|
<h2 class="text-xl font-bold text-base-content">
|
||||||
{{ release.version }}
|
{{ release.version }}
|
||||||
</h2>
|
</h2>
|
||||||
<span class="badge badge-ghost text-xs">{{ release.date }}</span>
|
<span class="badge badge-ghost text-xs">{{
|
||||||
</div>
|
release.date
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ul class="space-y-2">
|
<ul class="space-y-2">
|
||||||
<li
|
<li
|
||||||
v-for="(item, i) in release.changes"
|
v-for="(item, i) in release.changes"
|
||||||
:key="i"
|
:key="i"
|
||||||
class="flex items-start gap-2 text-sm text-base-content/80"
|
class="flex items-start gap-2 text-sm text-base-content/80"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="badge badge-sm mt-0.5 shrink-0"
|
class="badge badge-sm mt-0.5 shrink-0"
|
||||||
:class="badgeClass(item.type)"
|
:class="badgeClass(item.type)"
|
||||||
>
|
>
|
||||||
{{ item.type }}
|
{{ item.type }}
|
||||||
</span>
|
</span>
|
||||||
<span>{{ item.text }}</span>
|
<span>{{ item.text }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useHead } from '#imports'
|
import { useHead } from "#imports";
|
||||||
|
|
||||||
useHead({ title: 'Changelog' })
|
useHead({ title: "Changelog" });
|
||||||
|
|
||||||
type ChangeType = 'feat' | 'fix' | 'perf' | 'chore'
|
type ChangeType = "feat" | "fix" | "perf" | "chore";
|
||||||
|
|
||||||
interface Change {
|
interface Change {
|
||||||
type: ChangeType
|
type: ChangeType;
|
||||||
text: string
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Release {
|
interface Release {
|
||||||
version: string
|
version: string;
|
||||||
date: string
|
date: string;
|
||||||
changes: Change[]
|
changes: Change[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const badgeClass = (type: ChangeType) => {
|
const badgeClass = (type: ChangeType) => {
|
||||||
const map: Record<ChangeType, string> = {
|
const map: Record<ChangeType, string> = {
|
||||||
feat: 'badge-primary',
|
feat: "badge-primary",
|
||||||
fix: 'badge-error',
|
fix: "badge-error",
|
||||||
perf: 'badge-warning',
|
perf: "badge-warning",
|
||||||
chore: 'badge-ghost',
|
chore: "badge-ghost",
|
||||||
}
|
};
|
||||||
return map[type] ?? 'badge-ghost'
|
return map[type] ?? "badge-ghost";
|
||||||
}
|
};
|
||||||
|
|
||||||
const releases: Release[] = [
|
const releases: Release[] = [
|
||||||
{
|
{
|
||||||
version: 'v1.9.1',
|
version: "v1.9.2",
|
||||||
date: '2026-03-16',
|
date: "2026-03-23",
|
||||||
changes: [
|
changes: [
|
||||||
{ type: 'feat', text: 'Normalisation JSON → tables relationnelles : les structures des composants (pièces, produits, sous-composants) et les squelettes des catégories sont désormais stockés dans des tables dédiées au lieu de colonnes JSON, améliorant la fiabilité et les performances des requêtes' },
|
{
|
||||||
{ type: 'feat', text: 'Synchronisation des catégories (ModelType Sync) : la modification d\'une catégorie (ajout/suppression de slots ou champs personnalisés) peut être propagée automatiquement à tous les éléments existants de cette catégorie, avec prévisualisation des changements avant application' },
|
type: "feat",
|
||||||
{ type: 'feat', text: 'Sélection interactive des items dans les slots : sur la page d\'édition d\'un composant, il est maintenant possible de choisir directement la pièce, le produit ou le sous-composant assigné à chaque emplacement du squelette via des sélecteurs avec recherche' },
|
text: "Serveur MCP (Model Context Protocol) : l'application expose désormais un serveur MCP permettant l'intégration avec des assistants IA — outils CRUD complets pour toutes les entités, recherche inventaire, historique, commentaires, champs personnalisés, documents, slots et structure machine",
|
||||||
{ type: 'feat', text: 'Endpoints PATCH pour les slots composant : modification de la quantité et de l\'item sélectionné sur les slots pièce, produit et sous-composant' },
|
},
|
||||||
{ type: 'feat', text: 'Table de relation pièce ↔ produit (PieceProductSlot) avec versioning pour le suivi des modifications de structure' },
|
{
|
||||||
{ type: 'feat', text: 'Gestion des champs personnalisés sur les catégories : synchronisation automatique des définitions de champs (ajout, modification, suppression) lors de la sauvegarde d\'une catégorie' },
|
type: "feat",
|
||||||
{ type: 'feat', text: 'Suite de tests étendue : 219 tests couvrant les stratégies de synchronisation, le contrôleur de sync et les nouvelles entités' },
|
text: "Types de documents : classification des documents par type (Plan, Photo, Fiche technique, Notice, Certificat, Facture, Bon de commande, Autre) avec filtre dédié sur la page documents, sélection du type à l'upload et possibilité de modifier le type après upload",
|
||||||
{ type: 'fix', text: 'Correction de l\'affichage des sélections pré-existantes dans les slots : les pièces, produits et sous-composants déjà assignés sont maintenant correctement affichés à l\'ouverture de la page d\'édition (correction du cache catalogue)' },
|
},
|
||||||
{ type: 'fix', text: 'Fallback position/orderIndex sur index de tableau dans les stratégies de sync pour éviter les erreurs quand le champ est absent' },
|
{
|
||||||
],
|
type: "feat",
|
||||||
},
|
text: "Filtre sites multi-sélection sur le Parc Machines : remplacement du menu déroulant par des cases à cocher permettant de filtrer sur un ou plusieurs sites simultanément",
|
||||||
{
|
},
|
||||||
version: 'v1.9.0',
|
{
|
||||||
date: '2026-03-09',
|
type: "feat",
|
||||||
changes: [
|
text: "Tri alphabétique automatique des machines sur le Parc Machines",
|
||||||
{ type: 'feat', text: 'Gestion des champs personnalisés sur les machines : ajout, modification et suppression de définitions de champs directement depuis la fiche machine' },
|
},
|
||||||
{ type: 'feat', text: 'Refonte UI globale : amélioration du styling, des layouts et du responsive sur l\'ensemble des composants et pages' },
|
{
|
||||||
{ type: 'feat', text: 'Suite de tests API complète : 167 tests couvrant toutes les entités, la sécurité et les validations' },
|
type: "feat",
|
||||||
{ type: 'feat', text: 'Endpoint /api/health pour le monitoring applicatif' },
|
text: "Recherche par nom OU référence sur les catalogues : la recherche dans les catalogues pièces, composants et produits cherche désormais dans le nom et la référence simultanément (extension Doctrine OR search)",
|
||||||
{ type: 'fix', text: 'Sécurité renforcée : désactivation de la migration de session sur le firewall API, durcissement des accès documents et sessions' },
|
},
|
||||||
{ type: 'fix', text: 'Confirmation de suppression avec impact sur le catalogue produits (documents, liaisons machines en cascade)' },
|
{
|
||||||
{ type: 'fix', text: 'Correction du débordement des dropdowns dans les DataTable' },
|
type: "feat",
|
||||||
{ type: 'perf', text: 'Refactoring massif du frontend : extraction de 15+ composables et composants partagés, réduction de la taille des fichiers' },
|
text: "Quantité sur les slots pièces : ajout d'un champ quantité éditable directement depuis la page d'édition d'un composant",
|
||||||
{ type: 'chore', text: 'Extraction de CuidEntityTrait et abstraction du subscriber d\'audit côté backend' },
|
},
|
||||||
{ type: 'chore', text: 'Ajout de DAMA DoctrineTestBundle pour l\'isolation des tests par transaction' },
|
{
|
||||||
],
|
type: "feat",
|
||||||
},
|
text: "Lien rapide vers la catégorie depuis la page d'édition d'un composant",
|
||||||
{
|
},
|
||||||
version: 'v1.8.1',
|
{
|
||||||
date: '2026-03-05',
|
type: "feat",
|
||||||
changes: [
|
text: "Redirection vers la page d'édition après création d'un composant, d'une pièce ou d'un produit",
|
||||||
{ type: 'feat', text: 'Composant DataTable générique avec tri, recherche, pagination et filtres server-side — toutes les pages catalogue migrées vers ce composant partagé' },
|
},
|
||||||
{ type: 'feat', text: 'Messages d\'erreur humanisés : les erreurs backend sont traduites en messages compréhensibles pour l\'utilisateur final' },
|
{
|
||||||
{ type: 'feat', text: 'Modal d\'ajout d\'entités aux machines : ajout direct de composants, pièces et produits depuis la fiche machine' },
|
type: "fix",
|
||||||
{ type: 'feat', text: 'Filtres SearchFilter ipartial sur les noms de types de modèles et commentaires côté API' },
|
text: "Correction de la suppression de fournisseurs sur les pièces, composants et produits : la suppression est maintenant persistée correctement",
|
||||||
{ type: 'feat', text: 'Suppression du système TypeMachine (squelettes machines) : les champs personnalisés sont désormais liés directement à chaque machine' },
|
},
|
||||||
{ type: 'feat', text: 'Simplification de la création de machines : plus besoin de sélectionner un squelette, ajout direct des entités' },
|
{
|
||||||
{ type: 'fix', text: 'Suppression catalogue pièces/composants : confirmation avec liste des éléments supprimés en cascade (documents, liaisons machine, champs personnalisés) au lieu de bloquer la suppression' },
|
type: "fix",
|
||||||
{ type: 'fix', text: 'Affichage des catégories sur les pages d\'édition (produit, composant, pièce) : correction de « Catégorie inconnue » causée par un import obsolète dans ModelType' },
|
text: "Correction de la création de composants : les sélections de pièces, produits et sous-composants sont maintenant sauvegardées, et les slots squelette sont correctement initialisés",
|
||||||
{ type: 'fix', text: 'Recherche insensible à la casse sur les commentaires et documents (partial → ipartial)' },
|
},
|
||||||
{ type: 'chore', text: 'Suppression des pages squelettes machines (/machine-skeleton, /type) et composants associés' },
|
{
|
||||||
],
|
type: "fix",
|
||||||
},
|
text: "Correction de la perte de données lors de la sauvegarde d'une catégorie (champs personnalisés et structure)",
|
||||||
{
|
},
|
||||||
version: 'v1.8.0',
|
{
|
||||||
date: '2026-03-03',
|
type: "fix",
|
||||||
changes: [
|
text: "Correction de la suppression de composants depuis la fiche machine (utilisation du linkId au lieu du composantId)",
|
||||||
{ type: 'feat', text: 'Stockage des documents sur le système de fichiers au lieu de Base64 en base de données, avec endpoints dédiés pour servir et télécharger les fichiers' },
|
},
|
||||||
{ type: 'feat', text: 'Pagination serveur sur la page Documents avec recherche, tri (date/nom/taille), filtre par rattachement et sélecteur d\'éléments par page' },
|
{
|
||||||
{ type: 'feat', text: 'Compression PDF automatique à l\'upload via Ghostscript, avec commande pour compresser les PDFs existants' },
|
type: "fix",
|
||||||
{ type: 'feat', text: 'Champ description sur les pièces et composants, visible dans les catalogues avec popover au survol' },
|
text: "Amélioration de l'envoi des fournisseurs en PATCH : le tableau est toujours envoyé pour éviter les pertes",
|
||||||
{ type: 'feat', text: 'Commande de migration app:migrate-documents-to-filesystem pour migrer les documents existants (Base64 → fichiers)' },
|
},
|
||||||
{ type: 'fix', text: 'Normalisation des documents : fileUrl et downloadUrl toujours exposés dans l\'API' },
|
{
|
||||||
{ type: 'fix', text: 'Édition de squelettes machines : correction du conflit UniqueEntity et de l\'interférence du désérialiseur' },
|
type: "fix",
|
||||||
{ type: 'fix', text: 'Sites : ajout de l\'opération PATCH et correction de la migration de contrainte' },
|
text: "Filtrage serveur des options dans les sélecteurs de slots au lieu du filtrage client",
|
||||||
{ type: 'chore', text: 'Réorganisation de la navbar avec nouvelles icônes Lucide' },
|
},
|
||||||
],
|
{
|
||||||
},
|
type: "fix",
|
||||||
{
|
text: "Page d'édition pièce : rester sur la page après sauvegarde au lieu de rediriger",
|
||||||
version: 'v1.7.0',
|
},
|
||||||
date: '2026-03-02',
|
{
|
||||||
changes: [
|
type: "fix",
|
||||||
{ type: 'feat', text: 'Système de commentaires / tickets : possibilité de laisser des commentaires sur les fiches (machines, pièces, composants, produits, catégories, squelettes) avec résolution par les gestionnaires' },
|
text: "Messages d'erreur 409 (conflit) : extraction du champ d'erreur pour un message compréhensible",
|
||||||
{ type: 'feat', text: 'Page commentaires centralisée (/comments) avec filtres par statut, type d\'entité, pagination et liens cliquables vers les fiches' },
|
},
|
||||||
{ type: 'feat', text: 'Badge notifications : compteur de commentaires ouverts sur l\'avatar utilisateur et dans le menu profil (polling 60s)' },
|
{
|
||||||
{ type: 'feat', text: 'Contrôle d\'accès par rôles : ROLE_ADMIN, ROLE_GESTIONNAIRE, ROLE_VIEWER avec permissions granulaires sur toutes les pages' },
|
type: "perf",
|
||||||
{ type: 'feat', text: 'Journal d\'audit étendu : suivi des opérations sur machines, fournisseurs, types de modèles, documents et conversions' },
|
text: "Suppression des chargements catalogue redondants sur la page d'édition composant",
|
||||||
{ type: 'feat', text: 'Commande app:init-profile-passwords pour l\'initialisation en masse des mots de passe et rôles' },
|
},
|
||||||
{ type: 'fix', text: 'Toggle switch pour les champs personnalisés booléens (remplace les checkboxes)' },
|
],
|
||||||
{ type: 'fix', text: 'Recherche fournisseur : filtrage côté client au lieu d\'appels API debounce' },
|
},
|
||||||
{ type: 'fix', text: 'Prévention des doublons de noms de fournisseurs et de références de pièces (contraintes unique)' },
|
{
|
||||||
{ type: 'fix', text: 'Correction de la création de squelettes machines : pagination, duplication, champs personnalisés' },
|
version: "v1.9.1",
|
||||||
],
|
date: "2026-03-16",
|
||||||
},
|
changes: [
|
||||||
{
|
{
|
||||||
version: 'v1.6.1',
|
type: "feat",
|
||||||
date: '2026-02-12',
|
text: "Normalisation JSON → tables relationnelles : les structures des composants (pièces, produits, sous-composants) et les squelettes des catégories sont désormais stockés dans des tables dédiées au lieu de colonnes JSON, améliorant la fiabilité et les performances des requêtes",
|
||||||
changes: [
|
},
|
||||||
{ type: 'feat', text: 'Suivi d\'audit étendu : enregistrement des opérations CRUD sur les machines, fournisseurs, catégories (ModelType) et documents' },
|
{
|
||||||
{ type: 'feat', text: 'Traçabilité des conversions de catégories dans le journal d\'activité (action « convert » avec direction, nombre et noms des éléments)' },
|
type: "feat",
|
||||||
{ type: 'feat', text: 'Endpoint historique machine : GET /api/machines/{id}/history' },
|
text: "Synchronisation des catégories (ModelType Sync) : la modification d'une catégorie (ajout/suppression de slots ou champs personnalisés) peut être propagée automatiquement à tous les éléments existants de cette catégorie, avec prévisualisation des changements avant application",
|
||||||
],
|
},
|
||||||
},
|
{
|
||||||
{
|
type: "feat",
|
||||||
version: 'v1.6.0',
|
text: "Sélection interactive des items dans les slots : sur la page d'édition d'un composant, il est maintenant possible de choisir directement la pièce, le produit ou le sous-composant assigné à chaque emplacement du squelette via des sélecteurs avec recherche",
|
||||||
date: '2026-02-12',
|
},
|
||||||
changes: [
|
{
|
||||||
{ type: 'feat', text: 'Conversion bidirectionnelle des catégories : possibilité de convertir une catégorie de pièce en catégorie de composant (et inversement) avec transfert automatique de tous les éléments, documents, champs personnalisés et fournisseurs' },
|
type: "feat",
|
||||||
{ type: 'feat', text: 'Vérification des conditions de blocage avant conversion : liaisons machines, templates de type machine, sous-composants dans la structure, collisions de noms' },
|
text: "Endpoints PATCH pour les slots composant : modification de la quantité et de l'item sélectionné sur les slots pièce, produit et sous-composant",
|
||||||
{ type: 'feat', text: 'Bouton « Convertir » sur les listes de catégories pièce et composant avec modale de confirmation détaillée' },
|
},
|
||||||
{ type: 'chore', text: 'Passage php-cs-fixer sur l\'ensemble des contrôleurs et entités du backend' },
|
{
|
||||||
],
|
type: "feat",
|
||||||
},
|
text: "Table de relation pièce ↔ produit (PieceProductSlot) avec versioning pour le suivi des modifications de structure",
|
||||||
{
|
},
|
||||||
version: 'v1.5.0',
|
{
|
||||||
date: '2026-02-11',
|
type: "feat",
|
||||||
changes: [
|
text: "Gestion des champs personnalisés sur les catégories : synchronisation automatique des définitions de champs (ajout, modification, suppression) lors de la sauvegarde d'une catégorie",
|
||||||
{ type: 'feat', text: 'Page de journal d\'activité globale avec filtres par entité, par acteur et pagination serveur' },
|
},
|
||||||
{ type: 'feat', text: 'Suivi d\'audit : enregistrement des noms de fournisseurs et des modifications de champs personnalisés' },
|
{
|
||||||
{ type: 'feat', text: 'Préservation de l\'état des listes dans l\'URL (page courante, recherche, tri, direction, filtres) — le retour navigateur restaure exactement la position précédente' },
|
type: "feat",
|
||||||
{ type: 'feat', text: 'Boutons « Retour » sur toutes les pages de création et d\'édition utilisent désormais l\'historique du navigateur au lieu de liens fixes' },
|
text: "Suite de tests étendue : 219 tests couvrant les stratégies de synchronisation, le contrôleur de sync et les nouvelles entités",
|
||||||
{ type: 'feat', text: 'Première lettre automatiquement en majuscule lors de la création de catégories et de composants' },
|
},
|
||||||
{ type: 'feat', text: 'Les types de catégories dans les tableaux des catalogues sont maintenant cliquables (lien vers la fiche d\'édition)' },
|
{
|
||||||
{ type: 'feat', text: 'Application des couleurs de marque Malio sur l\'ensemble du thème (navbar, boutons, badges)' },
|
type: "fix",
|
||||||
{ type: 'feat', text: 'Page changelog accessible depuis le footer' },
|
text: "Correction de l'affichage des sélections pré-existantes dans les slots : les pièces, produits et sous-composants déjà assignés sont maintenant correctement affichés à l'ouverture de la page d'édition (correction du cache catalogue)",
|
||||||
{ type: 'fix', text: 'Correction des filtres de tri et de recherche cassés sur les catalogues composants, pièces et produits' },
|
},
|
||||||
{ type: 'fix', text: 'Correction du filtre par rattachement (site, machine, composant, pièce) sur la page documents' },
|
{
|
||||||
{ type: 'fix', text: 'Correction de l\'affichage des champs personnalisés sur les pages d\'édition (condition de concurrence)' },
|
type: "fix",
|
||||||
{ type: 'fix', text: 'Plafonnement de la pagination à 200 éléments par page pour éviter les erreurs mémoire en production' },
|
text: "Fallback position/orderIndex sur index de tableau dans les stratégies de sync pour éviter les erreurs quand le champ est absent",
|
||||||
{ type: 'perf', text: 'Cache intelligent sur les composables usePieces et useComposants : les données déjà chargées ne sont plus re-téléchargées inutilement' },
|
},
|
||||||
{ type: 'perf', text: 'Réduction des appels API bloquants sur les pages d\'édition' },
|
],
|
||||||
],
|
},
|
||||||
},
|
{
|
||||||
{
|
version: "v1.9.0",
|
||||||
version: 'v1.4.0',
|
date: "2026-03-09",
|
||||||
date: '2026-02-04',
|
changes: [
|
||||||
changes: [
|
{
|
||||||
{ type: 'perf', text: 'Optimisation de la sérialisation API : ajout de groupes dédiés pour CustomFieldValue et CustomField, réduisant significativement la taille des réponses' },
|
type: "feat",
|
||||||
{ type: 'perf', text: 'Pages d\'édition machines/composants/pièces : chargement parallèle des données au lieu de séquentiel' },
|
text: "Gestion des champs personnalisés sur les machines : ajout, modification et suppression de définitions de champs directement depuis la fiche machine",
|
||||||
],
|
},
|
||||||
},
|
{
|
||||||
{
|
type: "feat",
|
||||||
version: 'v1.3.0',
|
text: "Refonte UI globale : amélioration du styling, des layouts et du responsive sur l'ensemble des composants et pages",
|
||||||
date: '2026-01-28',
|
},
|
||||||
changes: [
|
{
|
||||||
{ type: 'feat', text: 'Refactoring complet du frontend : découpage des méga-composants en modules réutilisables (7 chantiers F1-F7)' },
|
type: "feat",
|
||||||
{ type: 'feat', text: 'Page détail machine découpée de 2989 à 219 lignes avec 2 composables et 7 sous-composants' },
|
text: "Suite de tests API complète : 167 tests couvrant toutes les entités, la sécurité et les validations",
|
||||||
{ type: 'feat', text: 'Page création machine découpée de 1231 à 196 lignes avec 1 composable et 5 sous-composants' },
|
},
|
||||||
{ type: 'feat', text: 'Extraction de 4 modules utilitaires partagés (champs personnalisés, affichage produits, documents, fournisseurs)' },
|
{
|
||||||
{ type: 'feat', text: 'Fusion des composables dupliqués : 3 composables d\'historique et 3 composables de types fusionnés en versions génériques' },
|
type: "feat",
|
||||||
{ type: 'feat', text: 'Remplacement de confirm() natif par une modale DaisyUI personnalisée sur l\'ensemble de l\'application' },
|
text: "Endpoint /api/health pour le monitoring applicatif",
|
||||||
{ type: 'feat', text: 'Extraction de la navbar dans un composant AppNavbar dédié' },
|
},
|
||||||
{ type: 'feat', text: 'Suite de 54 tests unitaires avec Vitest couvrant les utilitaires et composables' },
|
{
|
||||||
{ type: 'perf', text: 'Optimisations API : helper extractCollection partagé, invalidation de cache ciblée' },
|
type: "fix",
|
||||||
{ type: 'chore', text: 'Migration des composables JavaScript vers TypeScript strict' },
|
text: "Sécurité renforcée : désactivation de la migration de session sur le firewall API, durcissement des accès documents et sessions",
|
||||||
{ type: 'chore', text: 'Activation de règles ESLint strictes et suppression de 19 console.log de débogage' },
|
},
|
||||||
],
|
{
|
||||||
},
|
type: "fix",
|
||||||
{
|
text: "Confirmation de suppression avec impact sur le catalogue produits (documents, liaisons machines en cascade)",
|
||||||
version: 'v1.2.0',
|
},
|
||||||
date: '2026-01-21',
|
{
|
||||||
changes: [
|
type: "fix",
|
||||||
{ type: 'feat', text: 'Système de suivi d\'historique (audit) avec enregistrement automatique des modifications sur toutes les entités' },
|
text: "Correction du débordement des dropdowns dans les DataTable",
|
||||||
{ type: 'feat', text: 'Interface dédiée à l\'historique sur les fiches produits, pièces et composants' },
|
},
|
||||||
{ type: 'feat', text: 'Modale d\'éléments liés sur les pages de gestion des catégories avec navigation directe vers la fiche d\'édition' },
|
{
|
||||||
{ type: 'feat', text: 'Possibilité d\'ajouter des champs personnalisés en mode restreint sur les catégories' },
|
type: "perf",
|
||||||
],
|
text: "Refactoring massif du frontend : extraction de 15+ composables et composants partagés, réduction de la taille des fichiers",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
version: 'v1.1.1',
|
type: "chore",
|
||||||
date: '2026-01-14',
|
text: "Extraction de CuidEntityTrait et abstraction du subscriber d'audit côté backend",
|
||||||
changes: [
|
},
|
||||||
{ type: 'feat', text: 'Compression automatique des fichiers PDF à l\'upload via qpdf, réduisant l\'espace de stockage' },
|
{
|
||||||
{ type: 'chore', text: 'Ajout de qpdf dans l\'image Docker pour le support de la compression PDF' },
|
type: "chore",
|
||||||
],
|
text: "Ajout de DAMA DoctrineTestBundle pour l'isolation des tests par transaction",
|
||||||
},
|
},
|
||||||
{
|
],
|
||||||
version: 'v1.1.0',
|
},
|
||||||
date: '2026-01-07',
|
{
|
||||||
changes: [
|
version: "v1.8.1",
|
||||||
{ type: 'fix', text: 'Recherche insensible à la casse sur l\'ensemble des filtres de toutes les entités (machines, composants, pièces, produits)' },
|
date: "2026-03-05",
|
||||||
{ type: 'chore', text: 'Réinitialisation des migrations vers un schéma initial unique avec guide de déploiement' },
|
changes: [
|
||||||
{ type: 'chore', text: 'Mise à jour des fixtures avec les données courantes de la base' },
|
{
|
||||||
],
|
type: "feat",
|
||||||
},
|
text: "Composant DataTable générique avec tri, recherche, pagination et filtres server-side — toutes les pages catalogue migrées vers ce composant partagé",
|
||||||
{
|
},
|
||||||
version: 'v1.0.0',
|
{
|
||||||
date: '2025-12-15',
|
type: "feat",
|
||||||
changes: [
|
text: "Messages d'erreur humanisés : les erreurs backend sont traduites en messages compréhensibles pour l'utilisateur final",
|
||||||
{ type: 'feat', text: 'Gestion complète des machines : création, édition, vue détaillée avec liaisons composants et pièces' },
|
},
|
||||||
{ type: 'feat', text: 'Catalogues composants, pièces et produits avec recherche serveur, tri et pagination' },
|
{
|
||||||
{ type: 'feat', text: 'Système de catégories (types) avec squelettes de champs personnalisés et drag & drop pour réordonner' },
|
type: "feat",
|
||||||
{ type: 'feat', text: 'Upload de documents avec prévisualisation PDF et images, miniatures dans les tableaux' },
|
text: "Modal d'ajout d'entités aux machines : ajout direct de composants, pièces et produits depuis la fiche machine",
|
||||||
{ type: 'feat', text: 'Gestion des fournisseurs multiples avec résolution automatique des noms' },
|
},
|
||||||
{ type: 'feat', text: 'Exigences produit sur les pièces : support de liaisons multiples' },
|
{
|
||||||
{ type: 'feat', text: 'Sélections de composants sur les pièces avec recherche dynamique' },
|
type: "feat",
|
||||||
{ type: 'feat', text: 'Système de sessions utilisateurs avec authentification par cookie' },
|
text: "Filtres SearchFilter ipartial sur les noms de types de modèles et commentaires côté API",
|
||||||
{ type: 'feat', text: 'Mémorisation des préférences de tri par catalogue (cookies)' },
|
},
|
||||||
{ type: 'feat', text: 'Formatage automatique des contacts et des montants en format français' },
|
{
|
||||||
{ type: 'feat', text: 'Protection contre les suppressions : affichage des dépendances bloquantes avant confirmation' },
|
type: "feat",
|
||||||
{ type: 'chore', text: 'Infrastructure Docker complète avec PostgreSQL, PHP 8.4, API Platform et pgAdmin' },
|
text: "Suppression du système TypeMachine (squelettes machines) : les champs personnalisés sont désormais liés directement à chaque machine",
|
||||||
],
|
},
|
||||||
},
|
{
|
||||||
]
|
type: "feat",
|
||||||
|
text: "Simplification de la création de machines : plus besoin de sélectionner un squelette, ajout direct des entités",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "fix",
|
||||||
|
text: "Suppression catalogue pièces/composants : confirmation avec liste des éléments supprimés en cascade (documents, liaisons machine, champs personnalisés) au lieu de bloquer la suppression",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "fix",
|
||||||
|
text: "Affichage des catégories sur les pages d'édition (produit, composant, pièce) : correction de « Catégorie inconnue » causée par un import obsolète dans ModelType",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "fix",
|
||||||
|
text: "Recherche insensible à la casse sur les commentaires et documents (partial → ipartial)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "chore",
|
||||||
|
text: "Suppression des pages squelettes machines (/machine-skeleton, /type) et composants associés",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "v1.8.0",
|
||||||
|
date: "2026-03-03",
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Stockage des documents sur le système de fichiers au lieu de Base64 en base de données, avec endpoints dédiés pour servir et télécharger les fichiers",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Pagination serveur sur la page Documents avec recherche, tri (date/nom/taille), filtre par rattachement et sélecteur d'éléments par page",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Compression PDF automatique à l'upload via Ghostscript, avec commande pour compresser les PDFs existants",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Champ description sur les pièces et composants, visible dans les catalogues avec popover au survol",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Commande de migration app:migrate-documents-to-filesystem pour migrer les documents existants (Base64 → fichiers)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "fix",
|
||||||
|
text: "Normalisation des documents : fileUrl et downloadUrl toujours exposés dans l'API",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "fix",
|
||||||
|
text: "Édition de squelettes machines : correction du conflit UniqueEntity et de l'interférence du désérialiseur",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "fix",
|
||||||
|
text: "Sites : ajout de l'opération PATCH et correction de la migration de contrainte",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "chore",
|
||||||
|
text: "Réorganisation de la navbar avec nouvelles icônes Lucide",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "v1.7.0",
|
||||||
|
date: "2026-03-02",
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Système de commentaires / tickets : possibilité de laisser des commentaires sur les fiches (machines, pièces, composants, produits, catégories, squelettes) avec résolution par les gestionnaires",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Page commentaires centralisée (/comments) avec filtres par statut, type d'entité, pagination et liens cliquables vers les fiches",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Badge notifications : compteur de commentaires ouverts sur l'avatar utilisateur et dans le menu profil (polling 60s)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Contrôle d'accès par rôles : ROLE_ADMIN, ROLE_GESTIONNAIRE, ROLE_VIEWER avec permissions granulaires sur toutes les pages",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Journal d'audit étendu : suivi des opérations sur machines, fournisseurs, types de modèles, documents et conversions",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Commande app:init-profile-passwords pour l'initialisation en masse des mots de passe et rôles",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "fix",
|
||||||
|
text: "Toggle switch pour les champs personnalisés booléens (remplace les checkboxes)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "fix",
|
||||||
|
text: "Recherche fournisseur : filtrage côté client au lieu d'appels API debounce",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "fix",
|
||||||
|
text: "Prévention des doublons de noms de fournisseurs et de références de pièces (contraintes unique)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "fix",
|
||||||
|
text: "Correction de la création de squelettes machines : pagination, duplication, champs personnalisés",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "v1.6.1",
|
||||||
|
date: "2026-02-12",
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Suivi d'audit étendu : enregistrement des opérations CRUD sur les machines, fournisseurs, catégories (ModelType) et documents",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Traçabilité des conversions de catégories dans le journal d'activité (action « convert » avec direction, nombre et noms des éléments)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Endpoint historique machine : GET /api/machines/{id}/history",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "v1.6.0",
|
||||||
|
date: "2026-02-12",
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Conversion bidirectionnelle des catégories : possibilité de convertir une catégorie de pièce en catégorie de composant (et inversement) avec transfert automatique de tous les éléments, documents, champs personnalisés et fournisseurs",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Vérification des conditions de blocage avant conversion : liaisons machines, templates de type machine, sous-composants dans la structure, collisions de noms",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Bouton « Convertir » sur les listes de catégories pièce et composant avec modale de confirmation détaillée",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "chore",
|
||||||
|
text: "Passage php-cs-fixer sur l'ensemble des contrôleurs et entités du backend",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "v1.5.0",
|
||||||
|
date: "2026-02-11",
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Page de journal d'activité globale avec filtres par entité, par acteur et pagination serveur",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Suivi d'audit : enregistrement des noms de fournisseurs et des modifications de champs personnalisés",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Préservation de l'état des listes dans l'URL (page courante, recherche, tri, direction, filtres) — le retour navigateur restaure exactement la position précédente",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Boutons « Retour » sur toutes les pages de création et d'édition utilisent désormais l'historique du navigateur au lieu de liens fixes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Première lettre automatiquement en majuscule lors de la création de catégories et de composants",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Les types de catégories dans les tableaux des catalogues sont maintenant cliquables (lien vers la fiche d'édition)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Application des couleurs de marque Malio sur l'ensemble du thème (navbar, boutons, badges)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Page changelog accessible depuis le footer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "fix",
|
||||||
|
text: "Correction des filtres de tri et de recherche cassés sur les catalogues composants, pièces et produits",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "fix",
|
||||||
|
text: "Correction du filtre par rattachement (site, machine, composant, pièce) sur la page documents",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "fix",
|
||||||
|
text: "Correction de l'affichage des champs personnalisés sur les pages d'édition (condition de concurrence)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "fix",
|
||||||
|
text: "Plafonnement de la pagination à 200 éléments par page pour éviter les erreurs mémoire en production",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "perf",
|
||||||
|
text: "Cache intelligent sur les composables usePieces et useComposants : les données déjà chargées ne sont plus re-téléchargées inutilement",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "perf",
|
||||||
|
text: "Réduction des appels API bloquants sur les pages d'édition",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "v1.4.0",
|
||||||
|
date: "2026-02-04",
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
type: "perf",
|
||||||
|
text: "Optimisation de la sérialisation API : ajout de groupes dédiés pour CustomFieldValue et CustomField, réduisant significativement la taille des réponses",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "perf",
|
||||||
|
text: "Pages d'édition machines/composants/pièces : chargement parallèle des données au lieu de séquentiel",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "v1.3.0",
|
||||||
|
date: "2026-01-28",
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Refactoring complet du frontend : découpage des méga-composants en modules réutilisables (7 chantiers F1-F7)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Page détail machine découpée de 2989 à 219 lignes avec 2 composables et 7 sous-composants",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Page création machine découpée de 1231 à 196 lignes avec 1 composable et 5 sous-composants",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Extraction de 4 modules utilitaires partagés (champs personnalisés, affichage produits, documents, fournisseurs)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Fusion des composables dupliqués : 3 composables d'historique et 3 composables de types fusionnés en versions génériques",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Remplacement de confirm() natif par une modale DaisyUI personnalisée sur l'ensemble de l'application",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Extraction de la navbar dans un composant AppNavbar dédié",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Suite de 54 tests unitaires avec Vitest couvrant les utilitaires et composables",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "perf",
|
||||||
|
text: "Optimisations API : helper extractCollection partagé, invalidation de cache ciblée",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "chore",
|
||||||
|
text: "Migration des composables JavaScript vers TypeScript strict",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "chore",
|
||||||
|
text: "Activation de règles ESLint strictes et suppression de 19 console.log de débogage",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "v1.2.0",
|
||||||
|
date: "2026-01-21",
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Système de suivi d'historique (audit) avec enregistrement automatique des modifications sur toutes les entités",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Interface dédiée à l'historique sur les fiches produits, pièces et composants",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Modale d'éléments liés sur les pages de gestion des catégories avec navigation directe vers la fiche d'édition",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Possibilité d'ajouter des champs personnalisés en mode restreint sur les catégories",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "v1.1.1",
|
||||||
|
date: "2026-01-14",
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Compression automatique des fichiers PDF à l'upload via qpdf, réduisant l'espace de stockage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "chore",
|
||||||
|
text: "Ajout de qpdf dans l'image Docker pour le support de la compression PDF",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "v1.1.0",
|
||||||
|
date: "2026-01-07",
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
type: "fix",
|
||||||
|
text: "Recherche insensible à la casse sur l'ensemble des filtres de toutes les entités (machines, composants, pièces, produits)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "chore",
|
||||||
|
text: "Réinitialisation des migrations vers un schéma initial unique avec guide de déploiement",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "chore",
|
||||||
|
text: "Mise à jour des fixtures avec les données courantes de la base",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "v1.0.0",
|
||||||
|
date: "2025-12-15",
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Gestion complète des machines : création, édition, vue détaillée avec liaisons composants et pièces",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Catalogues composants, pièces et produits avec recherche serveur, tri et pagination",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Système de catégories (types) avec squelettes de champs personnalisés et drag & drop pour réordonner",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Upload de documents avec prévisualisation PDF et images, miniatures dans les tableaux",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Gestion des fournisseurs multiples avec résolution automatique des noms",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Exigences produit sur les pièces : support de liaisons multiples",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Sélections de composants sur les pièces avec recherche dynamique",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Système de sessions utilisateurs avec authentification par cookie",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Mémorisation des préférences de tri par catalogue (cookies)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Formatage automatique des contacts et des montants en format français",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "feat",
|
||||||
|
text: "Protection contre les suppressions : affichage des dépendances bloquantes avant confirmation",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "chore",
|
||||||
|
text: "Infrastructure Docker complète avec PostgreSQL, PHP 8.4, API Platform et pgAdmin",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -73,7 +73,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-content="{ row }">
|
<template #cell-content="{ row }">
|
||||||
<span class="line-clamp-2 text-sm">{{ row.content }}</span>
|
<div class="tooltip tooltip-top max-w-xs" :data-tip="row.content">
|
||||||
|
<span class="line-clamp-2 text-sm text-left">{{ row.content }}</span>
|
||||||
|
</div>
|
||||||
|
<CommentDocumentList :documents="getDocuments(row)" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-entityType="{ row }">
|
<template #cell-entityType="{ row }">
|
||||||
@@ -132,7 +135,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, type Ref } from 'vue'
|
import { ref, computed, onMounted, type Ref } from 'vue'
|
||||||
import DataTable from '~/components/common/DataTable.vue'
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
import { useComments, type Comment } from '~/composables/useComments'
|
import { useComments, type Comment, type CommentDocument } from '~/composables/useComments'
|
||||||
|
import CommentDocumentList from '~/components/CommentDocumentList.vue'
|
||||||
import { usePermissions } from '~/composables/usePermissions'
|
import { usePermissions } from '~/composables/usePermissions'
|
||||||
import { useDataTable } from '~/composables/useDataTable'
|
import { useDataTable } from '~/composables/useDataTable'
|
||||||
import IconLucideCheck from '~icons/lucide/check'
|
import IconLucideCheck from '~icons/lucide/check'
|
||||||
@@ -148,6 +152,9 @@ const comments = ref<Comment[]>([])
|
|||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const loadingList = ref(true)
|
const loadingList = ref(true)
|
||||||
|
|
||||||
|
const getDocuments = (comment: Comment): CommentDocument[] =>
|
||||||
|
comment.documents?.filter((d): d is CommentDocument => typeof d === 'object' && d !== null && 'id' in d) ?? []
|
||||||
|
|
||||||
const table = useDataTable(
|
const table = useDataTable(
|
||||||
{ fetchData: loadComments },
|
{ fetchData: loadComments },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -158,7 +158,6 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
|||||||
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
|
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
|
||||||
await loadComponentTypes({ force: true })
|
await loadComponentTypes({ force: true })
|
||||||
showSuccess('Catégorie de composant mise à jour avec succès.')
|
showSuccess('Catégorie de composant mise à jour avec succès.')
|
||||||
await navigateBackToList()
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(normalizeError(error))
|
showError(normalizeError(error))
|
||||||
@@ -183,7 +182,6 @@ const handleSyncConfirm = async () => {
|
|||||||
})
|
})
|
||||||
await loadComponentTypes({ force: true })
|
await loadComponentTypes({ force: true })
|
||||||
showSuccess('Catégorie de composant mise à jour avec succès.')
|
showSuccess('Catégorie de composant mise à jour avec succès.')
|
||||||
await navigateBackToList()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(normalizeError(error))
|
showError(normalizeError(error))
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
:documents="componentDocuments"
|
:documents="componentDocuments"
|
||||||
@close="closePreview"
|
@close="closePreview"
|
||||||
/>
|
/>
|
||||||
|
<DocumentEditModal
|
||||||
|
:visible="editModalVisible"
|
||||||
|
:document="editingDocument"
|
||||||
|
@close="editModalVisible = false"
|
||||||
|
@updated="handleDocumentUpdated"
|
||||||
|
/>
|
||||||
<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" />
|
||||||
@@ -45,20 +51,33 @@
|
|||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">Catégorie de composant</span>
|
<span class="label-text">Catégorie de composant</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<div class="flex items-center gap-2">
|
||||||
v-model="selectedTypeId"
|
<select
|
||||||
class="select select-bordered select-sm md:select-md"
|
v-model="selectedTypeId"
|
||||||
disabled
|
class="select select-bordered select-sm md:select-md flex-1"
|
||||||
>
|
disabled
|
||||||
<option value="">Sélectionner une catégorie</option>
|
|
||||||
<option
|
|
||||||
v-for="type in componentTypeList"
|
|
||||||
:key="type.id"
|
|
||||||
:value="type.id"
|
|
||||||
>
|
>
|
||||||
{{ type.name }}
|
<option value="">Sélectionner une catégorie</option>
|
||||||
</option>
|
<option
|
||||||
</select>
|
v-for="type in componentTypeList"
|
||||||
|
:key="type.id"
|
||||||
|
:value="type.id"
|
||||||
|
>
|
||||||
|
{{ type.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="selectedTypeId"
|
||||||
|
:to="`/component-category/${selectedTypeId}/edit`"
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
title="Voir la catégorie"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
|
||||||
|
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
|
||||||
|
</svg>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
<p class="text-xs text-base-content/60 mt-1">
|
<p class="text-xs text-base-content/60 mt-1">
|
||||||
La catégorie d'origine ne peut pas être modifiée depuis cette page.
|
La catégorie d'origine ne peut pas être modifiée depuis cette page.
|
||||||
</p>
|
</p>
|
||||||
@@ -173,12 +192,27 @@
|
|||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
|
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
|
||||||
</label>
|
</label>
|
||||||
<PieceSelect
|
<div class="flex items-start gap-2">
|
||||||
:model-value="slot.selectedPieceId"
|
<div class="flex-1">
|
||||||
:disabled="!canEdit || saving"
|
<PieceSelect
|
||||||
:type-piece-id="slot.typePieceId"
|
:model-value="slot.selectedPieceId"
|
||||||
@update:model-value="(value) => savePieceSlotSelection(slot.slotId, value)"
|
:disabled="!canEdit || saving"
|
||||||
/>
|
:type-piece-id="slot.typePieceId"
|
||||||
|
@update:model-value="(value) => savePieceSlotSelection(slot.slotId, value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="w-20 shrink-0">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="slot.quantity"
|
||||||
|
min="1"
|
||||||
|
class="input input-bordered input-sm w-full text-center"
|
||||||
|
:disabled="!canEdit || saving"
|
||||||
|
title="Quantité"
|
||||||
|
@change="(e) => saveSlotQuantity(slot.slotId, Number((e.target as HTMLInputElement).value))"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -266,9 +300,11 @@
|
|||||||
v-else
|
v-else
|
||||||
:documents="componentDocuments"
|
:documents="componentDocuments"
|
||||||
:can-delete="canEdit"
|
:can-delete="canEdit"
|
||||||
|
:can-edit="true"
|
||||||
:delete-disabled="uploadingDocuments"
|
:delete-disabled="uploadingDocuments"
|
||||||
empty-text="Aucun document n'est associé à ce composant pour le moment."
|
empty-text="Aucun document n'est associé à ce composant pour le moment."
|
||||||
@preview="openPreview"
|
@preview="openPreview"
|
||||||
|
@edit="openEditModal"
|
||||||
@delete="removeDocument"
|
@delete="removeDocument"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -306,10 +342,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
import { useRoute } from '#imports'
|
import { useRoute } from '#imports'
|
||||||
import { useComponentEdit } from '~/composables/useComponentEdit'
|
import { useComponentEdit } from '~/composables/useComponentEdit'
|
||||||
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const { updateDocument } = useDocuments()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
component,
|
component,
|
||||||
@@ -351,4 +390,24 @@ const {
|
|||||||
resolveSubcomponentLabel,
|
resolveSubcomponentLabel,
|
||||||
formatStructurePreview,
|
formatStructurePreview,
|
||||||
} = useComponentEdit(String(route.params.id))
|
} = useComponentEdit(String(route.params.id))
|
||||||
|
|
||||||
|
const editingDocument = ref<any | null>(null)
|
||||||
|
const editModalVisible = ref(false)
|
||||||
|
|
||||||
|
const openEditModal = (doc: any) => {
|
||||||
|
editingDocument.value = doc
|
||||||
|
editModalVisible.value = true
|
||||||
|
}
|
||||||
|
const handleDocumentUpdated = async (data: { name?: string; type?: string }) => {
|
||||||
|
if (!editingDocument.value?.id) return
|
||||||
|
const result = await updateDocument(editingDocument.value.id, data)
|
||||||
|
if (result.success) {
|
||||||
|
const idx = componentDocuments.value.findIndex((d: any) => d.id === editingDocument.value?.id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
componentDocuments.value[idx] = { ...componentDocuments.value[idx], ...data }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editModalVisible.value = false
|
||||||
|
editingDocument.value = null
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,6 +7,13 @@
|
|||||||
@close="closePreview"
|
@close="closePreview"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DocumentEditModal
|
||||||
|
:visible="editModalVisible"
|
||||||
|
:document="editingDocument"
|
||||||
|
@close="editModalVisible = false"
|
||||||
|
@updated="handleDocumentUpdated"
|
||||||
|
/>
|
||||||
|
|
||||||
<section class="card bg-base-100 shadow-sm">
|
<section class="card bg-base-100 shadow-sm">
|
||||||
<div class="card-body space-y-6">
|
<div class="card-body space-y-6">
|
||||||
<DataTable
|
<DataTable
|
||||||
@@ -55,6 +62,26 @@
|
|||||||
<option value="product">Produits</option>
|
<option value="product">Produits</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label
|
||||||
|
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||||
|
for="doc-type-filter"
|
||||||
|
>
|
||||||
|
Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="doc-type-filter"
|
||||||
|
v-model="typeFilter"
|
||||||
|
class="select select-bordered select-sm"
|
||||||
|
@change="table.handleFilterChange"
|
||||||
|
>
|
||||||
|
<option value="all">Tous</option>
|
||||||
|
<option v-for="t in DOCUMENT_TYPES" :key="t.value" :value="t.value">
|
||||||
|
{{ t.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-name="{ row }">
|
<template #cell-name="{ row }">
|
||||||
@@ -77,6 +104,10 @@
|
|||||||
{{ row.mimeType || 'Inconnu' }}
|
{{ row.mimeType || 'Inconnu' }}
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #cell-type="{ row }">
|
||||||
|
<span class="badge badge-sm badge-outline">{{ getDocumentTypeLabel(row.type || 'documentation') }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #cell-size="{ row }">
|
<template #cell-size="{ row }">
|
||||||
{{ formatSize(row.size) }}
|
{{ formatSize(row.size) }}
|
||||||
</template>
|
</template>
|
||||||
@@ -98,6 +129,14 @@
|
|||||||
|
|
||||||
<template #cell-actions="{ row }">
|
<template #cell-actions="{ row }">
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
type="button"
|
||||||
|
@click="openEditModal(row)"
|
||||||
|
>
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn btn-ghost btn-xs"
|
class="btn btn-ghost btn-xs"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -123,12 +162,15 @@ import { computed, onMounted, ref, type Ref } from 'vue'
|
|||||||
import DataTable from '~/components/common/DataTable.vue'
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { useDataTable } from '~/composables/useDataTable'
|
import { useDataTable } from '~/composables/useDataTable'
|
||||||
|
import { usePermissions } from '~/composables/usePermissions'
|
||||||
import { getFileIcon } from '~/utils/fileIcons'
|
import { getFileIcon } from '~/utils/fileIcons'
|
||||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||||
import { formatFrenchDate } from '~/utils/date'
|
import { formatFrenchDate } from '~/utils/date'
|
||||||
|
import { DOCUMENT_TYPES, getDocumentTypeLabel } from '~/shared/documentTypes'
|
||||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||||
|
|
||||||
const { documents, total, loading, loadDocuments } = useDocuments()
|
const { documents, total, loading, loadDocuments, updateDocument } = useDocuments()
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
const table = useDataTable(
|
const table = useDataTable(
|
||||||
{ fetchData: fetchDocuments },
|
{ fetchData: fetchDocuments },
|
||||||
@@ -139,21 +181,26 @@ const table = useDataTable(
|
|||||||
persistToUrl: true,
|
persistToUrl: true,
|
||||||
extraParams: {
|
extraParams: {
|
||||||
filter: { default: 'all' },
|
filter: { default: 'all' },
|
||||||
|
typeFilter: { default: 'all' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const attachmentFilter = table.filters.filter as Ref<string>
|
const attachmentFilter = table.filters.filter as Ref<string>
|
||||||
|
const typeFilter = table.filters.typeFilter as Ref<string>
|
||||||
|
|
||||||
const previewDocument = ref<any>(null)
|
const previewDocument = ref<any>(null)
|
||||||
const previewVisible = ref(false)
|
const previewVisible = ref(false)
|
||||||
|
const editingDocument = ref<any>(null)
|
||||||
|
const editModalVisible = ref(false)
|
||||||
|
|
||||||
const documentsOnPage = computed(() => documents.value.length)
|
const documentsOnPage = computed(() => documents.value.length)
|
||||||
const paginationState = table.pagination(total, documentsOnPage)
|
const paginationState = table.pagination(total, documentsOnPage)
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'name', label: 'Nom', sortable: true, sortKey: 'name' },
|
{ key: 'name', label: 'Nom', sortable: true, sortKey: 'name' },
|
||||||
{ key: 'mimeType', label: 'Type' },
|
{ key: 'mimeType', label: 'Type MIME' },
|
||||||
|
{ key: 'type', label: 'Type' },
|
||||||
{ key: 'size', label: 'Taille', sortable: true, sortKey: 'size' },
|
{ key: 'size', label: 'Taille', sortable: true, sortKey: 'size' },
|
||||||
{ key: 'attachment', label: 'Rattaché à' },
|
{ key: 'attachment', label: 'Rattaché à' },
|
||||||
{ key: 'createdAt', label: 'Date', sortable: true, sortKey: 'createdAt' },
|
{ key: 'createdAt', label: 'Date', sortable: true, sortKey: 'createdAt' },
|
||||||
@@ -168,6 +215,7 @@ async function fetchDocuments() {
|
|||||||
orderBy: table.sortField.value,
|
orderBy: table.sortField.value,
|
||||||
orderDir: table.sortDirection.value as 'asc' | 'desc',
|
orderDir: table.sortDirection.value as 'asc' | 'desc',
|
||||||
attachmentFilter: attachmentFilter.value,
|
attachmentFilter: attachmentFilter.value,
|
||||||
|
type: typeFilter.value,
|
||||||
force: true,
|
force: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -198,6 +246,25 @@ const closePreview = () => {
|
|||||||
previewDocument.value = null
|
previewDocument.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openEditModal = (doc: any) => {
|
||||||
|
editingDocument.value = doc
|
||||||
|
editModalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDocumentUpdated = async (data: { name: string; type: string }) => {
|
||||||
|
if (!editingDocument.value?.id) return
|
||||||
|
const result = await updateDocument(editingDocument.value.id, data)
|
||||||
|
if (result.success) {
|
||||||
|
const doc = documents.value.find((d) => d.id === editingDocument.value.id)
|
||||||
|
if (doc) {
|
||||||
|
doc.name = data.name
|
||||||
|
doc.type = data.type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editModalVisible.value = false
|
||||||
|
editingDocument.value = null
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchDocuments()
|
fetchDocuments()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -79,6 +79,7 @@
|
|||||||
|
|
||||||
<!-- Documents -->
|
<!-- Documents -->
|
||||||
<MachineDocumentsCard
|
<MachineDocumentsCard
|
||||||
|
v-if="d.isEditMode.value || d.machineDocumentsList.value.length > 0"
|
||||||
:documents="d.machineDocumentsList.value"
|
:documents="d.machineDocumentsList.value"
|
||||||
:is-edit-mode="d.isEditMode.value"
|
:is-edit-mode="d.isEditMode.value"
|
||||||
:uploading="d.machineDocumentsUploading.value"
|
:uploading="d.machineDocumentsUploading.value"
|
||||||
@@ -92,6 +93,7 @@
|
|||||||
|
|
||||||
<!-- Produits associés -->
|
<!-- Produits associés -->
|
||||||
<MachineProductsCard
|
<MachineProductsCard
|
||||||
|
v-if="d.isEditMode.value || d.machineDirectProducts.value.length > 0"
|
||||||
:products="d.machineDirectProducts.value"
|
:products="d.machineDirectProducts.value"
|
||||||
:is-edit-mode="d.isEditMode.value"
|
:is-edit-mode="d.isEditMode.value"
|
||||||
@add-product="openAddModal('product')"
|
@add-product="openAddModal('product')"
|
||||||
@@ -100,6 +102,7 @@
|
|||||||
|
|
||||||
<!-- Components Section -->
|
<!-- Components Section -->
|
||||||
<MachineComponentsCard
|
<MachineComponentsCard
|
||||||
|
v-if="d.isEditMode.value || d.components.value.length > 0"
|
||||||
:components="d.components.value"
|
:components="d.components.value"
|
||||||
:is-edit-mode="d.isEditMode.value"
|
:is-edit-mode="d.isEditMode.value"
|
||||||
:collapsed="d.componentsCollapsed.value"
|
:collapsed="d.componentsCollapsed.value"
|
||||||
@@ -114,6 +117,7 @@
|
|||||||
|
|
||||||
<!-- Machine Pieces Section -->
|
<!-- Machine Pieces Section -->
|
||||||
<MachinePiecesCard
|
<MachinePiecesCard
|
||||||
|
v-if="d.isEditMode.value || d.machinePieces.value.length > 0"
|
||||||
:pieces="d.machinePieces.value"
|
:pieces="d.machinePieces.value"
|
||||||
:is-edit-mode="d.isEditMode.value"
|
:is-edit-mode="d.isEditMode.value"
|
||||||
:collapsed="d.piecesCollapsed.value"
|
:collapsed="d.piecesCollapsed.value"
|
||||||
|
|||||||
@@ -16,16 +16,23 @@
|
|||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">Site</span>
|
<span class="label-text">Sites</span>
|
||||||
</label>
|
</label>
|
||||||
<select v-model="selectedSite" class="select select-bordered">
|
<div class="flex flex-wrap gap-3">
|
||||||
<option value="">
|
<label
|
||||||
Tous les sites
|
v-for="site in sites"
|
||||||
</option>
|
:key="site.id"
|
||||||
<option v-for="site in sites" :key="site.id" :value="site.id">
|
class="flex items-center gap-2 cursor-pointer"
|
||||||
{{ site.name }}
|
>
|
||||||
</option>
|
<input
|
||||||
</select>
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-sm"
|
||||||
|
:checked="selectedSites.has(site.id)"
|
||||||
|
@change="selectedSites.has(site.id) ? selectedSites.delete(site.id) : selectedSites.add(site.id)"
|
||||||
|
>
|
||||||
|
<span class="text-sm">{{ site.name }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
@@ -113,7 +120,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { useMachines } from '~/composables/useMachines'
|
import { useMachines } from '~/composables/useMachines'
|
||||||
import { useSites } from '~/composables/useSites'
|
import { useSites } from '~/composables/useSites'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
@@ -128,7 +135,7 @@ const { machines, loading, loadMachines, deleteMachine } = useMachines()
|
|||||||
const { sites, loadSites } = useSites()
|
const { sites, loadSites } = useSites()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
const selectedSite = ref('')
|
const selectedSites = reactive(new Set())
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
|
||||||
// Enrichir les machines avec les objets site complets
|
// Enrichir les machines avec les objets site complets
|
||||||
@@ -145,8 +152,8 @@ const enrichedMachines = computed(() => {
|
|||||||
const filteredMachines = computed(() => {
|
const filteredMachines = computed(() => {
|
||||||
let filtered = enrichedMachines.value
|
let filtered = enrichedMachines.value
|
||||||
|
|
||||||
if (selectedSite.value) {
|
if (selectedSites.size > 0) {
|
||||||
filtered = filtered.filter(machine => machine.siteId === selectedSite.value)
|
filtered = filtered.filter(machine => selectedSites.has(machine.siteId))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchQuery.value.trim()) {
|
if (searchQuery.value.trim()) {
|
||||||
@@ -157,6 +164,10 @@ const filteredMachines = computed(() => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
filtered = [...filtered].sort((a, b) =>
|
||||||
|
(a.name || '').localeCompare(b.name || '', 'fr')
|
||||||
|
)
|
||||||
|
|
||||||
return filtered
|
return filtered
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -156,7 +156,6 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
|||||||
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
|
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
|
||||||
await loadPieceTypes({ force: true })
|
await loadPieceTypes({ force: true })
|
||||||
showSuccess('Catégorie de pièce mise à jour avec succès.')
|
showSuccess('Catégorie de pièce mise à jour avec succès.')
|
||||||
await navigateBackToList()
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(normalizeError(error))
|
showError(normalizeError(error))
|
||||||
@@ -181,7 +180,6 @@ const handleSyncConfirm = async () => {
|
|||||||
})
|
})
|
||||||
await loadPieceTypes({ force: true })
|
await loadPieceTypes({ force: true })
|
||||||
showSuccess('Catégorie de pièce mise à jour avec succès.')
|
showSuccess('Catégorie de pièce mise à jour avec succès.')
|
||||||
await navigateBackToList()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(normalizeError(error))
|
showError(normalizeError(error))
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
:documents="pieceDocuments"
|
:documents="pieceDocuments"
|
||||||
@close="closePreview"
|
@close="closePreview"
|
||||||
/>
|
/>
|
||||||
|
<DocumentEditModal
|
||||||
|
:visible="editModalVisible"
|
||||||
|
:document="editingDocument"
|
||||||
|
@close="editModalVisible = false"
|
||||||
|
@updated="handleDocumentUpdated"
|
||||||
|
/>
|
||||||
<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" />
|
||||||
@@ -231,9 +237,11 @@
|
|||||||
v-else
|
v-else
|
||||||
:documents="pieceDocuments"
|
:documents="pieceDocuments"
|
||||||
:can-delete="canEdit"
|
:can-delete="canEdit"
|
||||||
|
:can-edit="true"
|
||||||
:delete-disabled="uploadingDocuments"
|
:delete-disabled="uploadingDocuments"
|
||||||
empty-text="Aucun document n'est associé à cette pièce pour le moment."
|
empty-text="Aucun document n'est associé à cette pièce pour le moment."
|
||||||
@preview="openPreview"
|
@preview="openPreview"
|
||||||
|
@edit="openEditModal"
|
||||||
@delete="removeDocument"
|
@delete="removeDocument"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -271,10 +279,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
import { useRoute } from '#imports'
|
import { useRoute } from '#imports'
|
||||||
import { usePieceEdit } from '~/composables/usePieceEdit'
|
import { usePieceEdit } from '~/composables/usePieceEdit'
|
||||||
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const { updateDocument } = useDocuments()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
piece,
|
piece,
|
||||||
@@ -310,4 +321,24 @@ const {
|
|||||||
submitEdition,
|
submitEdition,
|
||||||
formatPieceStructurePreview,
|
formatPieceStructurePreview,
|
||||||
} = usePieceEdit(String(route.params.id))
|
} = usePieceEdit(String(route.params.id))
|
||||||
|
|
||||||
|
const editingDocument = ref<any | null>(null)
|
||||||
|
const editModalVisible = ref(false)
|
||||||
|
|
||||||
|
const openEditModal = (doc: any) => {
|
||||||
|
editingDocument.value = doc
|
||||||
|
editModalVisible.value = true
|
||||||
|
}
|
||||||
|
const handleDocumentUpdated = async (data: { name?: string; type?: string }) => {
|
||||||
|
if (!editingDocument.value?.id) return
|
||||||
|
const result = await updateDocument(editingDocument.value.id, data)
|
||||||
|
if (result.success) {
|
||||||
|
const idx = pieceDocuments.value.findIndex((d: any) => d.id === editingDocument.value?.id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
pieceDocuments.value[idx] = { ...pieceDocuments.value[idx], ...data }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editModalVisible.value = false
|
||||||
|
editingDocument.value = null
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -444,7 +444,7 @@ const submitCreation = async () => {
|
|||||||
'piece',
|
'piece',
|
||||||
createdPiece.id,
|
createdPiece.id,
|
||||||
[
|
[
|
||||||
createdPiece?.typePiece?.pieceCustomFields,
|
createdPiece?.typePiece?.structure?.customFields,
|
||||||
],
|
],
|
||||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||||
)
|
)
|
||||||
@@ -466,7 +466,7 @@ const submitCreation = async () => {
|
|||||||
selectedDocuments.value = []
|
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.replace(`/pieces/${createdPiece.id}/edit`)
|
||||||
} else if (result.error) {
|
} else if (result.error) {
|
||||||
toast.showError(result.error)
|
toast.showError(result.error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,7 +156,6 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
|||||||
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
|
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
|
||||||
await loadProductTypes({ force: true })
|
await loadProductTypes({ force: true })
|
||||||
showSuccess('Catégorie de produit mise à jour avec succès.')
|
showSuccess('Catégorie de produit mise à jour avec succès.')
|
||||||
await navigateBackToList()
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(normalizeError(error))
|
showError(normalizeError(error))
|
||||||
@@ -181,7 +180,6 @@ const handleSyncConfirm = async () => {
|
|||||||
})
|
})
|
||||||
await loadProductTypes({ force: true })
|
await loadProductTypes({ force: true })
|
||||||
showSuccess('Catégorie de produit mise à jour avec succès.')
|
showSuccess('Catégorie de produit mise à jour avec succès.')
|
||||||
await navigateBackToList()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(normalizeError(error))
|
showError(normalizeError(error))
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
:documents="productDocuments"
|
:documents="productDocuments"
|
||||||
@close="closePreview"
|
@close="closePreview"
|
||||||
/>
|
/>
|
||||||
|
<DocumentEditModal
|
||||||
|
:visible="editModalVisible"
|
||||||
|
:document="editingDocument"
|
||||||
|
@close="editModalVisible = false"
|
||||||
|
@updated="handleDocumentUpdated"
|
||||||
|
/>
|
||||||
<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-16 text-center">
|
<div v-if="loading" class="flex flex-col items-center gap-4 py-16 text-center">
|
||||||
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
|
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
|
||||||
@@ -167,9 +173,11 @@
|
|||||||
v-else
|
v-else
|
||||||
:documents="productDocuments"
|
:documents="productDocuments"
|
||||||
:can-delete="canEdit"
|
:can-delete="canEdit"
|
||||||
|
:can-edit="true"
|
||||||
:delete-disabled="uploadingDocuments || saving"
|
:delete-disabled="uploadingDocuments || saving"
|
||||||
empty-text="Aucun document n'est associé à ce produit pour le moment."
|
empty-text="Aucun document n'est associé à ce produit pour le moment."
|
||||||
@preview="openPreview"
|
@preview="openPreview"
|
||||||
|
@edit="openEditModal"
|
||||||
@delete="removeDocument"
|
@delete="removeDocument"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -244,6 +252,7 @@ const {
|
|||||||
loadDocumentsByProduct,
|
loadDocumentsByProduct,
|
||||||
uploadDocuments: uploadProductDocuments,
|
uploadDocuments: uploadProductDocuments,
|
||||||
deleteDocument: deleteProductDocument,
|
deleteDocument: deleteProductDocument,
|
||||||
|
updateDocument,
|
||||||
} = useDocuments()
|
} = useDocuments()
|
||||||
const { ensureConstructeurs } = useConstructeurs()
|
const { ensureConstructeurs } = useConstructeurs()
|
||||||
const {
|
const {
|
||||||
@@ -265,6 +274,8 @@ const loadingDocuments = ref(false)
|
|||||||
const productDocuments = ref<any[]>([])
|
const productDocuments = ref<any[]>([])
|
||||||
const previewDocument = ref<any | null>(null)
|
const previewDocument = ref<any | null>(null)
|
||||||
const previewVisible = ref(false)
|
const previewVisible = ref(false)
|
||||||
|
const editingDocument = ref<any | null>(null)
|
||||||
|
const editModalVisible = ref(false)
|
||||||
|
|
||||||
const historyFieldLabels: Record<string, string> = {
|
const historyFieldLabels: Record<string, string> = {
|
||||||
name: 'Nom',
|
name: 'Nom',
|
||||||
@@ -307,6 +318,23 @@ const openPreview = (doc: any) => {
|
|||||||
}
|
}
|
||||||
const closePreview = () => { previewVisible.value = false; previewDocument.value = null }
|
const closePreview = () => { previewVisible.value = false; previewDocument.value = null }
|
||||||
|
|
||||||
|
const openEditModal = (doc: any) => {
|
||||||
|
editingDocument.value = doc
|
||||||
|
editModalVisible.value = true
|
||||||
|
}
|
||||||
|
const handleDocumentUpdated = async (data: { name?: string; type?: string }) => {
|
||||||
|
if (!editingDocument.value?.id) return
|
||||||
|
const result = await updateDocument(editingDocument.value.id, data)
|
||||||
|
if (result.success) {
|
||||||
|
const idx = productDocuments.value.findIndex((d: any) => d.id === editingDocument.value?.id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
productDocuments.value[idx] = { ...productDocuments.value[idx], ...data }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editModalVisible.value = false
|
||||||
|
editingDocument.value = null
|
||||||
|
}
|
||||||
|
|
||||||
const loadProduct = async () => {
|
const loadProduct = async () => {
|
||||||
const id = route.params.id
|
const id = route.params.id
|
||||||
if (!id || typeof id !== 'string') {
|
if (!id || typeof id !== 'string') {
|
||||||
@@ -474,7 +502,7 @@ const submitEdition = async () => {
|
|||||||
const failedFields = await _saveCustomFieldValues(
|
const failedFields = await _saveCustomFieldValues(
|
||||||
'product',
|
'product',
|
||||||
result.data.id,
|
result.data.id,
|
||||||
[],
|
[result.data?.typeProduct?.structure?.customFields],
|
||||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||||
)
|
)
|
||||||
if (failedFields.length) {
|
if (failedFields.length) {
|
||||||
@@ -482,7 +510,6 @@ const submitEdition = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
toast.showSuccess('Produit mis à jour avec succès')
|
toast.showSuccess('Produit mis à jour avec succès')
|
||||||
await router.push('/product-catalog')
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')
|
toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ const submitCreation = async () => {
|
|||||||
const failedFields = await saveCustomFieldValues(result.data.id)
|
const failedFields = await saveCustomFieldValues(result.data.id)
|
||||||
if (failedFields.length) {
|
if (failedFields.length) {
|
||||||
toast.showError(`Produit créé, mais impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
|
toast.showError(`Produit créé, mais impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
|
||||||
await router.push(`/product/${result.data.id}/edit`)
|
await router.replace(`/product/${result.data.id}/edit`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (selectedDocuments.value.length) {
|
if (selectedDocuments.value.length) {
|
||||||
@@ -352,7 +352,7 @@ const submitCreation = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
toast.showSuccess('Produit créé avec succès')
|
toast.showSuccess('Produit créé avec succès')
|
||||||
await router.push('/product-catalog')
|
await router.replace(`/product/${productId}/edit`)
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.showError(error?.message || 'Erreur lors de la création du produit')
|
toast.showError(error?.message || 'Erreur lors de la création du produit')
|
||||||
|
|||||||
@@ -155,9 +155,7 @@ export const buildConstructeurRequestPayload = <T extends Record<string, any>>(
|
|||||||
delete next.constructeurs;
|
delete next.constructeurs;
|
||||||
delete next.constructeurIds;
|
delete next.constructeurIds;
|
||||||
|
|
||||||
if (ids.length) {
|
next.constructeurs = ids.map((id) => `/api/constructeurs/${id}`);
|
||||||
next.constructeurs = ids.map((id) => `/api/constructeurs/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return next as T & { constructeurs?: string[] };
|
return next as T & { constructeurs?: string[] };
|
||||||
};
|
};
|
||||||
|
|||||||
15
app/shared/documentTypes.ts
Normal file
15
app/shared/documentTypes.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export const DOCUMENT_TYPES = [
|
||||||
|
{ value: 'documentation', label: 'Documentation' },
|
||||||
|
{ value: 'devis', label: 'Devis' },
|
||||||
|
{ value: 'facture', label: 'Facture' },
|
||||||
|
{ value: 'plan', label: 'Plan' },
|
||||||
|
{ value: 'photo', label: 'Photo' },
|
||||||
|
{ value: 'autre', label: 'Autre' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type DocumentTypeValue = (typeof DOCUMENT_TYPES)[number]['value']
|
||||||
|
|
||||||
|
export const getDocumentTypeLabel = (value: string): string => {
|
||||||
|
const found = DOCUMENT_TYPES.find((t) => t.value === value)
|
||||||
|
return found?.label ?? value
|
||||||
|
}
|
||||||
@@ -86,6 +86,19 @@ const sanitizePieceCustomFields = (fields: any[]): PieceModelCustomField[] => {
|
|||||||
if (options) {
|
if (options) {
|
||||||
result.options = options
|
result.options = options
|
||||||
}
|
}
|
||||||
|
const defaultValue =
|
||||||
|
field?.defaultValue !== undefined && field?.defaultValue !== null && field?.defaultValue !== ''
|
||||||
|
? String(field.defaultValue)
|
||||||
|
: null
|
||||||
|
if (defaultValue !== null) {
|
||||||
|
result.defaultValue = defaultValue
|
||||||
|
}
|
||||||
|
if (typeof field?.id === 'string' && field.id) {
|
||||||
|
result.id = field.id
|
||||||
|
}
|
||||||
|
if (typeof field?.customFieldId === 'string' && field.customFieldId) {
|
||||||
|
result.customFieldId = field.customFieldId
|
||||||
|
}
|
||||||
const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index
|
const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index
|
||||||
result.orderIndex = orderIndex
|
result.orderIndex = orderIndex
|
||||||
return result
|
return result
|
||||||
@@ -125,6 +138,8 @@ const hydratePieceCustomFields = (fields: any[]): PieceModelStructureEditorField
|
|||||||
? field.options.join('\n')
|
? field.options.join('\n')
|
||||||
: '',
|
: '',
|
||||||
orderIndex: typeof field?.orderIndex === 'number' ? field.orderIndex : index,
|
orderIndex: typeof field?.orderIndex === 'number' ? field.orderIndex : index,
|
||||||
|
...(field?.id ? { id: field.id } : {}),
|
||||||
|
...(field?.customFieldId ? { customFieldId: field.customFieldId } : {}),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export interface PieceModelCustomField {
|
|||||||
key?: string
|
key?: string
|
||||||
value?: unknown
|
value?: unknown
|
||||||
defaultValue?: string | null
|
defaultValue?: string | null
|
||||||
|
id?: string
|
||||||
|
customFieldId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PieceModelProduct {
|
export interface PieceModelProduct {
|
||||||
|
|||||||
Reference in New Issue
Block a user