Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
958a00c8fc | ||
|
|
e0f761da2b | ||
|
|
80739a4528 | ||
|
|
c5988ec7a6 | ||
|
|
63a56c47ba | ||
|
|
c82c21c0cd | ||
|
|
a339e722a6 | ||
|
|
a7415964a7 | ||
|
|
767c9a7424 | ||
|
|
d197d30eb0 | ||
|
|
452de8b069 | ||
|
|
92141c6564 | ||
|
|
9e1504ddb7 | ||
|
|
a72279f978 | ||
|
|
9cc8b28122 | ||
|
|
02ca3549d5 | ||
|
|
5485bac339 | ||
|
|
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,6 +19,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Formulaire d'ajout -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex gap-2">
|
||||
<textarea
|
||||
v-model="newContent"
|
||||
@@ -28,9 +29,23 @@
|
||||
:disabled="submitting"
|
||||
@keydown.ctrl.enter="handleSubmit"
|
||||
/>
|
||||
<div class="flex flex-col gap-1 self-end">
|
||||
<label
|
||||
class="btn btn-ghost btn-sm btn-square tooltip tooltip-left"
|
||||
data-tip="Joindre des fichiers"
|
||||
>
|
||||
<IconLucidePaperclip class="w-4 h-4" />
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="handleFilesSelected"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm self-end"
|
||||
class="btn btn-primary btn-sm btn-square"
|
||||
:disabled="!newContent.trim() || submitting"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
@@ -38,6 +53,22 @@
|
||||
<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>
|
||||
|
||||
<!-- Liste des commentaires ouverts -->
|
||||
<div v-if="loadingComments" class="flex justify-center py-4">
|
||||
@@ -57,6 +88,8 @@
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
|
||||
<!-- Documents attachés -->
|
||||
<CommentDocumentList :documents="getDocuments(comment)" />
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
<span>{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}</span>
|
||||
<span v-if="comment.resolvedByName">
|
||||
@@ -110,12 +145,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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 CommentDocumentList from '~/components/CommentDocumentList.vue'
|
||||
import IconLucideMessageSquare from '~icons/lucide/message-square'
|
||||
import IconLucideSend from '~icons/lucide/send'
|
||||
import IconLucideCheck from '~icons/lucide/check'
|
||||
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<{
|
||||
entityType: string
|
||||
@@ -138,6 +177,11 @@ const newContent = ref('')
|
||||
const submitting = ref(false)
|
||||
const loadingComments = 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(() =>
|
||||
comments.value.filter(c => c.status === 'open'),
|
||||
@@ -159,6 +203,18 @@ const formatCommentDate = (dateStr: string): string => {
|
||||
}).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 () => {
|
||||
loadingComments.value = true
|
||||
const [openResult, resolvedResult] = await Promise.all([
|
||||
@@ -182,10 +238,12 @@ const handleSubmit = async () => {
|
||||
props.entityId,
|
||||
content,
|
||||
props.entityName,
|
||||
selectedFiles.value.length > 0 ? selectedFiles.value : undefined,
|
||||
)
|
||||
submitting.value = false
|
||||
if (result.success) {
|
||||
newContent.value = ''
|
||||
selectedFiles.value = []
|
||||
await loadComments()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
:documents="componentDocuments"
|
||||
@close="closePreview"
|
||||
/>
|
||||
<DocumentEditModal
|
||||
:visible="editModalVisible"
|
||||
:document="editingDocument"
|
||||
@close="editModalVisible = false"
|
||||
@updated="handleDocumentUpdated"
|
||||
/>
|
||||
|
||||
<!-- Component Header -->
|
||||
<div class="flex items-center gap-3 p-3 bg-base-200 rounded-lg cursor-pointer" @click="toggleCollapse">
|
||||
@@ -29,6 +35,7 @@
|
||||
class="text-xs text-base-content/50"
|
||||
>
|
||||
{{ constructeur.name }}
|
||||
<span v-if="supplierReferenceMap.get(constructeur.id)" class="opacity-70">({{ supplierReferenceMap.get(constructeur.id) }})</span>
|
||||
</span>
|
||||
<span v-if="displayProductName" class="badge badge-info badge-xs">
|
||||
{{ displayProductName }}
|
||||
@@ -96,6 +103,9 @@
|
||||
class="text-base-content"
|
||||
>
|
||||
{{ constructeur.name }}
|
||||
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-sm text-base-content/60">
|
||||
— Réf. {{ supplierReferenceMap.get(constructeur.id) }}
|
||||
</span>
|
||||
<span v-if="formatConstructeurContact(constructeur)" class="text-xs text-base-content/50 block">
|
||||
{{ formatConstructeurContact(constructeur) }}
|
||||
</span>
|
||||
@@ -121,7 +131,7 @@
|
||||
</div>
|
||||
<NuxtLink
|
||||
v-if="component.product?.id"
|
||||
:to="`/product/${component.product.id}/edit`"
|
||||
:to="`/product/${component.product.id}`"
|
||||
class="btn btn-ghost btn-xs shrink-0"
|
||||
>
|
||||
Voir le produit
|
||||
@@ -208,9 +218,11 @@
|
||||
<DocumentListInline
|
||||
:documents="componentDocuments"
|
||||
:can-delete="isEditMode"
|
||||
:can-edit="isEditMode"
|
||||
:delete-disabled="uploadingDocuments"
|
||||
empty-text="Aucun document lié à ce composant."
|
||||
@preview="openPreview"
|
||||
@edit="openEditModal"
|
||||
@delete="removeDocument"
|
||||
/>
|
||||
</div>
|
||||
@@ -284,6 +296,7 @@ import {
|
||||
formatConstructeurContact as formatConstructeurContactSummary,
|
||||
resolveConstructeurs,
|
||||
uniqueConstructeurIds,
|
||||
parseConstructeurLinksFromApi,
|
||||
} from '~/shared/constructeurUtils'
|
||||
import {
|
||||
formatSize,
|
||||
@@ -319,6 +332,7 @@ const {
|
||||
ensureDocumentsLoaded,
|
||||
handleFilesAdded,
|
||||
removeDocument,
|
||||
editDocument,
|
||||
} = useEntityDocuments({ entity: () => props.component, entityType: 'composant' })
|
||||
|
||||
const {
|
||||
@@ -333,6 +347,21 @@ const {
|
||||
updateCustomField: updateComponentCustomField,
|
||||
} = 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 ---
|
||||
const isCollapsed = ref(true)
|
||||
|
||||
@@ -367,23 +396,36 @@ const structurePieces = computed(() => allPieces.value.filter((p) => p._structur
|
||||
// --- Constructeurs ---
|
||||
const { constructeurs } = useConstructeurs()
|
||||
|
||||
const componentConstructeurIds = computed(() =>
|
||||
uniqueConstructeurIds(
|
||||
props.component,
|
||||
const componentConstructeurLinks = computed(() =>
|
||||
parseConstructeurLinksFromApi(
|
||||
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [],
|
||||
props.component.constructeur ? [props.component.constructeur] : [],
|
||||
),
|
||||
)
|
||||
|
||||
const componentConstructeursDisplay = computed(() =>
|
||||
resolveConstructeurs(
|
||||
componentConstructeurIds.value,
|
||||
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [],
|
||||
props.component.constructeur ? [props.component.constructeur] : [],
|
||||
constructeurs.value,
|
||||
),
|
||||
const supplierReferenceMap = computed(() => {
|
||||
const map = new Map()
|
||||
componentConstructeurLinks.value.forEach(l => {
|
||||
if (l.supplierReference) map.set(l.constructeurId, l.supplierReference)
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const componentConstructeurIds = computed(() =>
|
||||
componentConstructeurLinks.value.map(l => l.constructeurId).filter(Boolean),
|
||||
)
|
||||
|
||||
const componentConstructeursDisplay = computed(() => {
|
||||
// Extract nested constructeur objects from link entries
|
||||
const linkConstructeurs = componentConstructeurLinks.value
|
||||
.filter(l => l.constructeur && l.constructeur.id)
|
||||
.map(l => l.constructeur)
|
||||
return resolveConstructeurs(
|
||||
componentConstructeurIds.value,
|
||||
linkConstructeurs,
|
||||
constructeurs.value,
|
||||
)
|
||||
})
|
||||
|
||||
const formatConstructeurContact = (constructeur) =>
|
||||
formatConstructeurContactSummary(constructeur)
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
:empty-text="componentOptions.length ? 'Aucun résultat' : 'Aucun composant disponible'"
|
||||
:option-label="componentOptionLabel"
|
||||
:option-description="componentOptionDescription"
|
||||
server-search
|
||||
@search="fetchComponentOptions"
|
||||
@update:modelValue="(value) => { assignment.selectedComponentId = normalizeSelectionValue(value); }"
|
||||
/>
|
||||
@@ -62,6 +63,7 @@
|
||||
:empty-text="getPieceOptions(pieceAssignment).length ? 'Aucun résultat' : 'Aucune pièce disponible'"
|
||||
:option-label="pieceOptionLabel"
|
||||
:option-description="pieceOptionDescription"
|
||||
server-search
|
||||
@search="(term) => fetchPieceOptions(pieceAssignment, term)"
|
||||
@update:modelValue="(value) => { pieceAssignment.selectedPieceId = normalizeSelectionValue(value); }"
|
||||
/>
|
||||
@@ -101,6 +103,7 @@
|
||||
:empty-text="getProductOptions(productAssignment).length ? 'Aucun résultat' : 'Aucun produit disponible'"
|
||||
:option-label="productOptionLabel"
|
||||
:option-description="productOptionDescription"
|
||||
server-search
|
||||
@search="(term) => fetchProductOptions(productAssignment, term)"
|
||||
@update:modelValue="(value) => { productAssignment.selectedProductId = normalizeSelectionValue(value); }"
|
||||
/>
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
:empty-text="emptyText"
|
||||
size="sm"
|
||||
option-value="id"
|
||||
option-label="name"
|
||||
:option-label="formatLabel"
|
||||
:disabled="disabled"
|
||||
server-search
|
||||
@update:modelValue="updateValue"
|
||||
@search="handleSearch"
|
||||
>
|
||||
<template #option-description="{ option }">
|
||||
<span class="text-xs text-base-content/60">
|
||||
@@ -25,7 +27,7 @@
|
||||
</template>
|
||||
|
||||
<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 { useComposants } from '~/composables/useComposants'
|
||||
|
||||
@@ -52,43 +54,45 @@ const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | null): void
|
||||
}>()
|
||||
|
||||
const { composants, loading, loadComposants } = useComposants()
|
||||
const { loading: globalLoading, loadComposants } = useComposants()
|
||||
|
||||
const composantOptions = computed(() => {
|
||||
const baseOptions = Array.isArray(composants.value) ? composants.value : []
|
||||
if (!props.typeComposantId) {
|
||||
return baseOptions
|
||||
const localComposants = ref<any[]>([])
|
||||
const localLoading = ref(false)
|
||||
const loading = computed(() => localLoading.value || globalLoading.value)
|
||||
|
||||
const composantOptions = computed(() => localComposants.value)
|
||||
|
||||
const loadFilteredComposants = async (search = '') => {
|
||||
if (!props.typeComposantId) return
|
||||
localLoading.value = true
|
||||
try {
|
||||
const result = await loadComposants({ typeComposantId: props.typeComposantId, search, itemsPerPage: 200, force: true })
|
||||
if (result.success && result.data?.items) {
|
||||
localComposants.value = result.data.items
|
||||
}
|
||||
}
|
||||
catch (error: unknown) {
|
||||
console.error('Erreur lors du chargement des composants:', error)
|
||||
}
|
||||
finally {
|
||||
localLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const allowedTypeId = String(props.typeComposantId)
|
||||
return baseOptions.filter((composant: any) => {
|
||||
const typeId =
|
||||
composant?.typeComposantId ||
|
||||
composant?.typeComposant?.id ||
|
||||
null
|
||||
return typeId ? String(typeId) === allowedTypeId : false
|
||||
})
|
||||
})
|
||||
let searchDebounce: ReturnType<typeof setTimeout> | null = null
|
||||
const handleSearch = (term: string) => {
|
||||
if (searchDebounce) clearTimeout(searchDebounce)
|
||||
searchDebounce = setTimeout(() => loadFilteredComposants(term.trim()), 300)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (composantOptions.value.length === 0) {
|
||||
loadComposants({ itemsPerPage: 200 }).catch((error: unknown) => {
|
||||
console.error('Erreur lors du chargement des composants:', error)
|
||||
})
|
||||
}
|
||||
loadFilteredComposants()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
if (typeof value === 'string' && value) {
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
() => props.typeComposantId,
|
||||
() => {
|
||||
loadFilteredComposants()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -100,10 +104,20 @@ const updateValue = (value: string | number | null | undefined) => {
|
||||
emit('update:modelValue', String(value))
|
||||
}
|
||||
|
||||
const formatLabel = (option: any) => {
|
||||
if (!option) return ''
|
||||
const name = option.name || 'Composant'
|
||||
return option.reference ? `${name} — ${option.reference}` : name
|
||||
}
|
||||
|
||||
const formatDescription = (option: any) => {
|
||||
const parts: string[] = []
|
||||
const typeName = option?.typeComposant?.name
|
||||
if (typeName) {
|
||||
parts.push(typeName)
|
||||
}
|
||||
if (option?.reference) {
|
||||
parts.push(option.reference)
|
||||
parts.push(`Ref. ${option.reference}`)
|
||||
}
|
||||
if (option?.prix !== undefined && option.prix !== null) {
|
||||
const price = Number(option.prix)
|
||||
|
||||
93
app/components/ConstructeurLinksTable.vue
Normal file
93
app/components/ConstructeurLinksTable.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div v-if="modelValue.length" class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fournisseur</th>
|
||||
<th>Réf. fournisseur</th>
|
||||
<th v-if="!readonly" class="w-10" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(link, index) in modelValue" :key="link.constructeurId">
|
||||
<td class="font-medium">
|
||||
{{ getConstructeurName(link) }}
|
||||
<div v-if="getConstructeurContact(link)" class="text-xs text-gray-500">
|
||||
{{ getConstructeurContact(link) }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
v-if="!readonly"
|
||||
:value="link.supplierReference || ''"
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full"
|
||||
placeholder="Réf. fournisseur"
|
||||
@input="updateReference(index, ($event.target as HTMLInputElement).value)"
|
||||
>
|
||||
<span v-else>{{ link.supplierReference || '—' }}</span>
|
||||
</td>
|
||||
<td v-if="!readonly">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
aria-label="Retirer"
|
||||
@click="removeLink(index)"
|
||||
>
|
||||
<IconLucideX class="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
import { formatConstructeurContact } from '~/shared/constructeurUtils'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as PropType<ConstructeurLinkEntry[]>,
|
||||
default: () => [],
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: ConstructeurLinkEntry[]): void
|
||||
(e: 'remove', constructeurId: string): void
|
||||
}>()
|
||||
|
||||
const { getConstructeurById } = useConstructeurs()
|
||||
|
||||
const getConstructeurName = (link: ConstructeurLinkEntry): string =>
|
||||
link.constructeur?.name || getConstructeurById(link.constructeurId)?.name || link.constructeurId
|
||||
|
||||
const getConstructeurContact = (link: ConstructeurLinkEntry): string => {
|
||||
const c = link.constructeur || getConstructeurById(link.constructeurId)
|
||||
return formatConstructeurContact(c as any)
|
||||
}
|
||||
|
||||
const updateReference = (index: number, value: string) => {
|
||||
const updated = [...props.modelValue]
|
||||
const entry = updated[index]
|
||||
if (!entry) return
|
||||
updated[index] = { ...entry, supplierReference: value || null }
|
||||
emit('update:modelValue', updated)
|
||||
}
|
||||
|
||||
const removeLink = (index: number) => {
|
||||
const removed = props.modelValue[index]
|
||||
const updated = props.modelValue.filter((_, i) => i !== index)
|
||||
emit('update:modelValue', updated)
|
||||
if (removed) emit('remove', removed.constructeurId)
|
||||
}
|
||||
</script>
|
||||
51
app/components/DetailHeader.vue
Normal file
51
app/components/DetailHeader.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-3xl font-bold">{{ title }}</h1>
|
||||
<p v-if="subtitle" class="text-sm text-base-content/70">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="canEdit"
|
||||
class="btn btn-primary"
|
||||
:class="{ 'btn-outline': isEditMode }"
|
||||
@click="$emit('toggle-edit')"
|
||||
>
|
||||
<IconLucideSquarePen v-if="!isEditMode" class="w-5 h-5 mr-2" aria-hidden="true" />
|
||||
<IconLucideEye v-else class="w-5 h-5 mr-2" aria-hidden="true" />
|
||||
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
|
||||
Retour au catalogue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconLucideSquarePen from '~icons/lucide/square-pen'
|
||||
import IconLucideEye from '~icons/lucide/eye'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
subtitle?: string
|
||||
isEditMode: boolean
|
||||
canEdit: boolean
|
||||
backLink: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'toggle-edit': []
|
||||
}>()
|
||||
|
||||
function goBack() {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
}
|
||||
else {
|
||||
navigateTo(props.backLink)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
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"
|
||||
>
|
||||
|
||||
<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">
|
||||
<li v-for="file in selectedFiles" :key="file.name" class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -69,6 +84,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { DOCUMENT_TYPES } from '~/shared/documentTypes'
|
||||
import { getFileIcon } from '~/utils/fileIcons'
|
||||
import IconLucideCloudUpload from '~icons/lucide/cloud-upload'
|
||||
|
||||
@@ -96,10 +112,14 @@ const props = defineProps({
|
||||
maxFileSizeMb: {
|
||||
type: Number,
|
||||
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 fileInput = ref(null)
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
:documents="pieceDocuments"
|
||||
@close="closePreview"
|
||||
/>
|
||||
<DocumentEditModal
|
||||
:visible="editModalVisible"
|
||||
:document="editingDocument"
|
||||
@close="editModalVisible = false"
|
||||
@updated="handleDocumentUpdated"
|
||||
/>
|
||||
|
||||
<!-- Piece Header (collapsible, same pattern as ComponentItem) -->
|
||||
<div class="flex items-start justify-between p-4 bg-base-200 rounded-lg">
|
||||
@@ -36,6 +42,7 @@
|
||||
Rattachée à {{ piece.parentComponentName }}
|
||||
</span>
|
||||
<span v-if="pieceData.reference" class="badge badge-outline badge-sm">{{ pieceData.reference }}</span>
|
||||
<span v-if="pieceData.referenceAuto" class="badge badge-secondary badge-sm" title="Référence auto">{{ pieceData.referenceAuto }}</span>
|
||||
<template v-if="pieceConstructeursDisplay.length">
|
||||
<span
|
||||
v-for="constructeur in pieceConstructeursDisplay"
|
||||
@@ -43,6 +50,9 @@
|
||||
class="badge badge-outline badge-sm"
|
||||
>
|
||||
{{ constructeur.name }}
|
||||
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-xs opacity-60 ml-0.5">
|
||||
({{ supplierReferenceMap.get(constructeur.id) }})
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<span v-if="pieceData.prix" class="badge badge-primary badge-sm">{{ pieceData.prix }}€</span>
|
||||
@@ -100,6 +110,10 @@
|
||||
pieceData.reference || "Non définie"
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="pieceData.referenceAuto">
|
||||
<span class="font-medium">Référence auto:</span>
|
||||
<span class="ml-2">{{ pieceData.referenceAuto }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Fournisseur:</span>
|
||||
<div v-if="!isEditMode" class="ml-2">
|
||||
@@ -111,6 +125,9 @@
|
||||
>
|
||||
<span class="font-medium">
|
||||
{{ constructeur.name }}
|
||||
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-sm font-normal text-base-content/60">
|
||||
— Réf. {{ supplierReferenceMap.get(constructeur.id) }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="formatConstructeurContact(constructeur)"
|
||||
@@ -174,7 +191,7 @@
|
||||
</p>
|
||||
<NuxtLink
|
||||
v-if="selectedProduct.id"
|
||||
:to="`/product/${selectedProduct.id}/edit`"
|
||||
:to="`/product/${selectedProduct.id}`"
|
||||
class="link link-primary text-xs"
|
||||
>
|
||||
Ouvrir la fiche produit
|
||||
@@ -247,9 +264,11 @@
|
||||
<DocumentListInline
|
||||
:documents="pieceDocuments"
|
||||
:can-delete="isEditMode"
|
||||
:can-edit="isEditMode"
|
||||
:delete-disabled="uploadingDocuments"
|
||||
empty-text="Aucun document lié à cette pièce."
|
||||
@preview="openPreview"
|
||||
@edit="openEditModal"
|
||||
@delete="removeDocument"
|
||||
/>
|
||||
</div>
|
||||
@@ -270,6 +289,7 @@ import {
|
||||
formatConstructeurContact as formatConstructeurContactSummary,
|
||||
resolveConstructeurs,
|
||||
uniqueConstructeurIds,
|
||||
parseConstructeurLinksFromApi,
|
||||
} from '~/shared/constructeurUtils'
|
||||
import {
|
||||
resolveFieldId,
|
||||
@@ -293,6 +313,7 @@ const emit = defineEmits(['update', 'edit', 'custom-field-update', 'delete'])
|
||||
const pieceData = reactive({
|
||||
name: props.piece.name || '',
|
||||
reference: props.piece.reference || '',
|
||||
referenceAuto: props.piece.referenceAuto || null,
|
||||
prix: props.piece.prix || '',
|
||||
productId: props.piece.product?.id || props.piece.productId || null,
|
||||
quantity: props.piece.quantity ?? 1,
|
||||
@@ -329,6 +350,7 @@ const {
|
||||
refreshDocuments,
|
||||
handleFilesAdded,
|
||||
removeDocument,
|
||||
editDocument,
|
||||
} = useEntityDocuments({ entity: () => props.piece, entityType: 'piece' })
|
||||
|
||||
const {
|
||||
@@ -343,6 +365,21 @@ const {
|
||||
updateCustomField,
|
||||
} = 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 ---
|
||||
const isCollapsed = ref(true)
|
||||
|
||||
@@ -363,23 +400,36 @@ const toggleCollapse = () => {
|
||||
// --- Constructeurs ---
|
||||
const { constructeurs } = useConstructeurs()
|
||||
|
||||
const pieceConstructeurIds = computed(() =>
|
||||
uniqueConstructeurIds(
|
||||
props.piece,
|
||||
const pieceConstructeurLinks = computed(() =>
|
||||
parseConstructeurLinksFromApi(
|
||||
Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [],
|
||||
props.piece.constructeur ? [props.piece.constructeur] : [],
|
||||
),
|
||||
)
|
||||
|
||||
const pieceConstructeursDisplay = computed(() =>
|
||||
resolveConstructeurs(
|
||||
pieceConstructeurIds.value,
|
||||
Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [],
|
||||
props.piece.constructeur ? [props.piece.constructeur] : [],
|
||||
constructeurs.value,
|
||||
),
|
||||
const supplierReferenceMap = computed(() => {
|
||||
const map = new Map()
|
||||
pieceConstructeurLinks.value.forEach(l => {
|
||||
if (l.supplierReference) map.set(l.constructeurId, l.supplierReference)
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const pieceConstructeurIds = computed(() =>
|
||||
pieceConstructeurLinks.value.map(l => l.constructeurId).filter(Boolean),
|
||||
)
|
||||
|
||||
const pieceConstructeursDisplay = computed(() => {
|
||||
// Extract nested constructeur objects from link entries
|
||||
const linkConstructeurs = pieceConstructeurLinks.value
|
||||
.filter(l => l.constructeur && l.constructeur.id)
|
||||
.map(l => l.constructeur)
|
||||
return resolveConstructeurs(
|
||||
pieceConstructeurIds.value,
|
||||
linkConstructeurs,
|
||||
constructeurs.value,
|
||||
)
|
||||
})
|
||||
|
||||
const formatConstructeurContact = (constructeur) =>
|
||||
formatConstructeurContactSummary(constructeur)
|
||||
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
:empty-text="emptyText"
|
||||
size="sm"
|
||||
option-value="id"
|
||||
option-label="name"
|
||||
:option-label="formatLabel"
|
||||
:disabled="disabled"
|
||||
server-search
|
||||
@update:modelValue="updateValue"
|
||||
@search="handleSearch"
|
||||
>
|
||||
<template #option-description="{ option }">
|
||||
<span class="text-xs text-base-content/60">
|
||||
@@ -25,7 +27,7 @@
|
||||
</template>
|
||||
|
||||
<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 { usePieces } from '~/composables/usePieces'
|
||||
|
||||
@@ -52,43 +54,45 @@ const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | null): void
|
||||
}>()
|
||||
|
||||
const { pieces, loading, loadPieces } = usePieces()
|
||||
const { loading: globalLoading, loadPieces } = usePieces()
|
||||
|
||||
const pieceOptions = computed(() => {
|
||||
const baseOptions = Array.isArray(pieces.value) ? pieces.value : []
|
||||
if (!props.typePieceId) {
|
||||
return baseOptions
|
||||
const localPieces = ref<any[]>([])
|
||||
const localLoading = ref(false)
|
||||
const loading = computed(() => localLoading.value || globalLoading.value)
|
||||
|
||||
const pieceOptions = computed(() => localPieces.value)
|
||||
|
||||
const loadFilteredPieces = async (search = '') => {
|
||||
if (!props.typePieceId) return
|
||||
localLoading.value = true
|
||||
try {
|
||||
const result = await loadPieces({ typePieceId: props.typePieceId, search, itemsPerPage: 200, force: true })
|
||||
if (result.success && result.data?.items) {
|
||||
localPieces.value = result.data.items
|
||||
}
|
||||
}
|
||||
catch (error: unknown) {
|
||||
console.error('Erreur lors du chargement des pièces:', error)
|
||||
}
|
||||
finally {
|
||||
localLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const allowedTypeId = String(props.typePieceId)
|
||||
return baseOptions.filter((piece: any) => {
|
||||
const typeId =
|
||||
piece?.typePieceId ||
|
||||
piece?.typePiece?.id ||
|
||||
null
|
||||
return typeId ? String(typeId) === allowedTypeId : false
|
||||
})
|
||||
})
|
||||
let searchDebounce: ReturnType<typeof setTimeout> | null = null
|
||||
const handleSearch = (term: string) => {
|
||||
if (searchDebounce) clearTimeout(searchDebounce)
|
||||
searchDebounce = setTimeout(() => loadFilteredPieces(term.trim()), 300)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (pieceOptions.value.length === 0) {
|
||||
loadPieces({ itemsPerPage: 200 }).catch((error: unknown) => {
|
||||
console.error('Erreur lors du chargement des pièces:', error)
|
||||
})
|
||||
}
|
||||
loadFilteredPieces()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
if (typeof value === 'string' && value) {
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
() => props.typePieceId,
|
||||
() => {
|
||||
loadFilteredPieces()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -100,10 +104,20 @@ const updateValue = (value: string | number | null | undefined) => {
|
||||
emit('update:modelValue', String(value))
|
||||
}
|
||||
|
||||
const formatLabel = (option: any) => {
|
||||
if (!option) return ''
|
||||
const name = option.name || 'Pièce'
|
||||
return option.reference ? `${name} — ${option.reference}` : name
|
||||
}
|
||||
|
||||
const formatDescription = (option: any) => {
|
||||
const parts: string[] = []
|
||||
const typeName = option?.typePiece?.name
|
||||
if (typeName) {
|
||||
parts.push(typeName)
|
||||
}
|
||||
if (option?.reference) {
|
||||
parts.push(option.reference)
|
||||
parts.push(`Ref. ${option.reference}`)
|
||||
}
|
||||
if (option?.prix !== undefined && option.prix !== null) {
|
||||
const price = Number(option.prix)
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
:empty-text="emptyText"
|
||||
size="sm"
|
||||
option-value="id"
|
||||
option-label="name"
|
||||
:option-label="formatLabel"
|
||||
:disabled="disabled"
|
||||
server-search
|
||||
@update:modelValue="updateValue"
|
||||
@search="handleSearch"
|
||||
>
|
||||
<template #option-description="{ option }">
|
||||
<span class="text-xs text-base-content/60">
|
||||
@@ -25,7 +27,7 @@
|
||||
</template>
|
||||
|
||||
<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 { useProducts } from '~/composables/useProducts'
|
||||
|
||||
@@ -52,43 +54,45 @@ const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | null): void
|
||||
}>()
|
||||
|
||||
const { products, loading, loadProducts } = useProducts()
|
||||
const { loading: globalLoading, loadProducts } = useProducts()
|
||||
|
||||
const productOptions = computed(() => {
|
||||
const baseOptions = Array.isArray(products.value) ? products.value : []
|
||||
if (!props.typeProductId) {
|
||||
return baseOptions
|
||||
const localProducts = ref<any[]>([])
|
||||
const localLoading = ref(false)
|
||||
const loading = computed(() => localLoading.value || globalLoading.value)
|
||||
|
||||
const productOptions = computed(() => localProducts.value)
|
||||
|
||||
const loadFilteredProducts = async (search = '') => {
|
||||
if (!props.typeProductId) return
|
||||
localLoading.value = true
|
||||
try {
|
||||
const result = await loadProducts({ typeProductId: props.typeProductId, search, itemsPerPage: 200, force: true })
|
||||
if (result.success && result.data?.items) {
|
||||
localProducts.value = result.data.items
|
||||
}
|
||||
}
|
||||
catch (error: unknown) {
|
||||
console.error('Erreur lors du chargement des produits:', error)
|
||||
}
|
||||
finally {
|
||||
localLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const allowedTypeId = String(props.typeProductId)
|
||||
return baseOptions.filter((product) => {
|
||||
const typeId =
|
||||
product?.typeProductId ||
|
||||
product?.typeProduct?.id ||
|
||||
null
|
||||
return typeId ? String(typeId) === allowedTypeId : false
|
||||
})
|
||||
})
|
||||
let searchDebounce: ReturnType<typeof setTimeout> | null = null
|
||||
const handleSearch = (term: string) => {
|
||||
if (searchDebounce) clearTimeout(searchDebounce)
|
||||
searchDebounce = setTimeout(() => loadFilteredProducts(term.trim()), 300)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (productOptions.value.length === 0) {
|
||||
loadProducts().catch((error) => {
|
||||
console.error('Erreur lors du chargement des produits:', error)
|
||||
})
|
||||
}
|
||||
loadFilteredProducts()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
if (typeof value === 'string' && value) {
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
() => props.typeProductId,
|
||||
() => {
|
||||
loadFilteredProducts()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -100,10 +104,20 @@ const updateValue = (value: string | number | null | undefined) => {
|
||||
emit('update:modelValue', String(value))
|
||||
}
|
||||
|
||||
const formatLabel = (option: any) => {
|
||||
if (!option) return ''
|
||||
const name = option.name || 'Produit'
|
||||
return option.reference ? `${name} — ${option.reference}` : name
|
||||
}
|
||||
|
||||
const formatDescription = (option: any) => {
|
||||
const parts: string[] = []
|
||||
const typeName = option?.typeProduct?.name
|
||||
if (typeName) {
|
||||
parts.push(typeName)
|
||||
}
|
||||
if (option?.reference) {
|
||||
parts.push(option.reference)
|
||||
parts.push(`Ref. ${option.reference}`)
|
||||
}
|
||||
if (option?.supplierPrice !== undefined && option.supplierPrice !== null) {
|
||||
const price = Number(option.supplierPrice)
|
||||
|
||||
@@ -258,18 +258,7 @@
|
||||
{{ piece.typePieceId ? `Sélection : ${getPieceTypeLabel(piece.typePieceId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<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>
|
||||
<!-- Quantity is set per-component on the component edit page -->
|
||||
</div>
|
||||
<button type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)">
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
|
||||
@@ -31,8 +31,9 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium">
|
||||
<div class="font-medium flex items-center gap-2">
|
||||
{{ document.name }}
|
||||
<span class="badge badge-sm badge-outline">{{ getDocumentTypeLabel(document.type || 'documentation') }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-base-content/70">
|
||||
{{ document.mimeType || 'Inconnu' }} • {{ formatSize(document.size) }}
|
||||
@@ -40,6 +41,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
@@ -74,6 +84,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getDocumentTypeLabel } from '~/shared/documentTypes'
|
||||
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
|
||||
import {
|
||||
documentIcon,
|
||||
@@ -89,10 +100,12 @@ import type { Document } from '~/composables/useDocuments'
|
||||
withDefaults(defineProps<{
|
||||
documents: Document[]
|
||||
canDelete?: boolean
|
||||
canEdit?: boolean
|
||||
deleteDisabled?: boolean
|
||||
emptyText?: string
|
||||
}>(), {
|
||||
canDelete: false,
|
||||
canEdit: false,
|
||||
deleteDisabled: false,
|
||||
emptyText: 'Aucun document.',
|
||||
})
|
||||
@@ -100,5 +113,6 @@ withDefaults(defineProps<{
|
||||
defineEmits<{
|
||||
(e: 'preview', document: Document): void
|
||||
(e: 'delete', documentId: string): void
|
||||
(e: 'edit', document: Document): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
170
app/components/common/EntityVersionList.vue
Normal file
170
app/components/common/EntityVersionList.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
<header class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="font-semibold text-base-content">Versions</h2>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Historique des versions avec possibilite de restauration.
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="versions.length" class="badge badge-outline">
|
||||
{{ versions.length }} version{{ versions.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="flex items-center gap-2 text-sm text-base-content/70">
|
||||
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
|
||||
Chargement des versions...
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="alert alert-warning">
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<p v-else-if="versions.length === 0" class="text-xs text-base-content/70">
|
||||
Aucune version enregistree.
|
||||
</p>
|
||||
|
||||
<ul v-else class="max-h-96 space-y-2 overflow-y-auto pr-1">
|
||||
<li
|
||||
v-for="entry in versions"
|
||||
:key="entry.version"
|
||||
class="flex items-center justify-between rounded-md border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-mono text-sm font-semibold">v{{ entry.version }}</span>
|
||||
<span
|
||||
v-if="entry.version === currentVersion"
|
||||
class="badge badge-primary badge-sm"
|
||||
>
|
||||
actuelle
|
||||
</span>
|
||||
<span
|
||||
v-if="entry.action === 'restore'"
|
||||
class="badge badge-warning badge-sm"
|
||||
>
|
||||
restauration
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-base-content/60">
|
||||
<span>{{ actionLabel(entry.action) }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ formatDate(entry.createdAt) }}</span>
|
||||
<span v-if="entry.actor">· {{ entry.actor.label }}</span>
|
||||
</div>
|
||||
<div v-if="entry.diff && Object.keys(entry.diff).length" class="mt-1 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="(change, field) in entry.diff"
|
||||
:key="field"
|
||||
class="badge badge-ghost badge-xs"
|
||||
>
|
||||
{{ formatDiffEntry(String(field), change) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="canRestore && entry.version !== currentVersion"
|
||||
class="btn btn-ghost btn-xs"
|
||||
:disabled="restoring"
|
||||
@click="handleRestore(entry.version)"
|
||||
>
|
||||
Restaurer
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<VersionRestoreModal
|
||||
:visible="modalVisible"
|
||||
:preview="previewData"
|
||||
:restoring="restoring"
|
||||
:field-labels="fieldLabels"
|
||||
:entity-type="entityType"
|
||||
@close="modalVisible = false"
|
||||
@confirm="confirmRestore"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch, toRef } from 'vue'
|
||||
import { useEntityVersions, type RestorePreview } from '~/composables/useEntityVersions'
|
||||
import { usePermissions } from '~/composables/usePermissions'
|
||||
import { formatHistoryDate, historyActionLabel } from '~/shared/utils/historyDisplayUtils'
|
||||
import VersionRestoreModal from './VersionRestoreModal.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
entityType: 'machine' | 'composant' | 'piece' | 'product'
|
||||
entityId: string
|
||||
fieldLabels: Record<string, string>
|
||||
/** Increment this value to force a refresh of the versions list */
|
||||
refreshKey?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
restored: []
|
||||
}>()
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
const canRestore = computed(() => canEdit.value)
|
||||
|
||||
const { versions, loading, error, fetchVersions, fetchPreview, restore } = useEntityVersions({
|
||||
entityType: props.entityType,
|
||||
entityId: props.entityId,
|
||||
})
|
||||
|
||||
const currentVersion = computed(() => {
|
||||
if (versions.value.length === 0) return null
|
||||
return versions.value[0]?.version ?? null
|
||||
})
|
||||
|
||||
const modalVisible = ref(false)
|
||||
const previewData = ref<RestorePreview | null>(null)
|
||||
const restoring = ref(false)
|
||||
const targetVersion = ref<number | null>(null)
|
||||
|
||||
const actionLabel = (action: string) => historyActionLabel(action)
|
||||
const formatDate = (date: string) => formatHistoryDate(date)
|
||||
|
||||
const formatDiffEntry = (field: string, change: { from: unknown; to: unknown }): string => {
|
||||
const label = props.fieldLabels[field] || field
|
||||
// Link changes (addedComponent, removedPiece, etc.) have {id, name} as value
|
||||
const val = change.to ?? change.from
|
||||
if (val && typeof val === 'object' && 'name' in (val as Record<string, unknown>)) {
|
||||
return `${label}: ${(val as Record<string, unknown>).name}`
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
const handleRestore = async (version: number) => {
|
||||
targetVersion.value = version
|
||||
previewData.value = null
|
||||
modalVisible.value = true
|
||||
previewData.value = await fetchPreview(version)
|
||||
}
|
||||
|
||||
const confirmRestore = async () => {
|
||||
if (!targetVersion.value) return
|
||||
restoring.value = true
|
||||
const result = await restore(targetVersion.value)
|
||||
restoring.value = false
|
||||
if (result?.success) {
|
||||
modalVisible.value = false
|
||||
await fetchVersions()
|
||||
emit('restored')
|
||||
}
|
||||
else {
|
||||
error.value = 'La restauration a echoue.'
|
||||
modalVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchVersions()
|
||||
})
|
||||
|
||||
// Auto-refresh when parent signals a data change
|
||||
watch(toRef(props, 'refreshKey'), () => {
|
||||
fetchVersions()
|
||||
})
|
||||
</script>
|
||||
@@ -69,7 +69,7 @@
|
||||
{{ resolveLabel(option) }}
|
||||
</slot>
|
||||
</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">
|
||||
{{ resolveDescription(option) }}
|
||||
</slot>
|
||||
@@ -133,6 +133,10 @@ const props = defineProps({
|
||||
maxVisible: {
|
||||
type: Number,
|
||||
default: 50
|
||||
},
|
||||
serverSearch: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -150,11 +154,11 @@ const selectedOption = computed(() => {
|
||||
})
|
||||
|
||||
const displayedOptions = computed(() => {
|
||||
const term = searchTerm.value.trim().toLowerCase()
|
||||
const items = baseOptions.value.slice()
|
||||
|
||||
const filtered = term
|
||||
const filtered = (!props.serverSearch && searchTerm.value.trim())
|
||||
? items.filter((option) => {
|
||||
const term = searchTerm.value.trim().toLowerCase()
|
||||
const label = resolveLabel(option).toLowerCase()
|
||||
const description = resolveDescription(option)?.toLowerCase() || ''
|
||||
return label.includes(term) || description.includes(term)
|
||||
|
||||
198
app/components/common/VersionRestoreModal.vue
Normal file
198
app/components/common/VersionRestoreModal.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<dialog ref="dialogRef" class="modal" :class="{ 'modal-open': visible }">
|
||||
<div class="modal-box max-w-lg">
|
||||
<h3 class="text-lg font-bold">Restaurer la version {{ preview?.version }}</h3>
|
||||
|
||||
<div v-if="!preview" class="flex justify-center py-8">
|
||||
<span class="loading loading-spinner loading-md" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="mt-4 space-y-4">
|
||||
<!-- Restore mode explanation -->
|
||||
<div
|
||||
class="alert text-sm"
|
||||
:class="preview.restoreMode === 'full' ? 'alert-info' : 'alert-warning'"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<!-- FULL MODE -->
|
||||
<template v-if="preview.restoreMode === 'full'">
|
||||
<span class="font-semibold">Restauration complete</span>
|
||||
|
||||
<!-- Machine: always full, no category -->
|
||||
<template v-if="entityType === 'machine'">
|
||||
<span>Tous les elements de la machine seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, prix</li>
|
||||
<li>Site</li>
|
||||
<li>Fournisseurs</li>
|
||||
<li>Composants, pieces et produits lies</li>
|
||||
<li>Champs personnalises</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Composant -->
|
||||
<template v-else-if="entityType === 'composant'">
|
||||
<span>La categorie est identique. Tous les elements du composant seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, description, prix</li>
|
||||
<li>Fournisseurs</li>
|
||||
<li>Structure : pieces, sous-composants et produits lies</li>
|
||||
<li>Champs personnalises</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Piece -->
|
||||
<template v-else-if="entityType === 'piece'">
|
||||
<span>La categorie est identique. Tous les elements de la piece seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, description, prix</li>
|
||||
<li>Fournisseurs</li>
|
||||
<li>Produits lies</li>
|
||||
<li>Champs personnalises</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Product -->
|
||||
<template v-else-if="entityType === 'product'">
|
||||
<span>La categorie est identique. Tous les elements du produit seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, prix fournisseur</li>
|
||||
<li>Fournisseurs</li>
|
||||
<li>Champs personnalises</li>
|
||||
</ul>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- PARTIAL MODE (never for machines) -->
|
||||
<template v-else>
|
||||
<span class="font-semibold">Restauration partielle</span>
|
||||
|
||||
<!-- Composant -->
|
||||
<template v-if="entityType === 'composant'">
|
||||
<span>La categorie du composant a change depuis cette version. Seuls les champs de base seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, description, prix</li>
|
||||
<li>Fournisseurs</li>
|
||||
</ul>
|
||||
<span class="mt-1 text-xs font-medium">Ne seront PAS modifies :</span>
|
||||
<ul class="ml-4 list-disc text-xs opacity-70">
|
||||
<li>Structure actuelle (pieces, sous-composants, produits lies)</li>
|
||||
<li>Champs personnalises actuels</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Piece -->
|
||||
<template v-else-if="entityType === 'piece'">
|
||||
<span>La categorie de la piece a change depuis cette version. Seuls les champs de base seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, description, prix</li>
|
||||
<li>Fournisseurs</li>
|
||||
</ul>
|
||||
<span class="mt-1 text-xs font-medium">Ne seront PAS modifies :</span>
|
||||
<ul class="ml-4 list-disc text-xs opacity-70">
|
||||
<li>Produits lies actuels</li>
|
||||
<li>Champs personnalises actuels</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Product -->
|
||||
<template v-else-if="entityType === 'product'">
|
||||
<span>La categorie du produit a change depuis cette version. Seuls les champs de base seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, prix fournisseur</li>
|
||||
<li>Fournisseurs</li>
|
||||
</ul>
|
||||
<span class="mt-1 text-xs font-medium">Ne seront PAS modifies :</span>
|
||||
<ul class="ml-4 list-disc text-xs opacity-70">
|
||||
<li>Champs personnalises actuels</li>
|
||||
</ul>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Diff -->
|
||||
<div v-if="Object.keys(preview.diff).length" class="space-y-2">
|
||||
<h4 class="text-sm font-semibold">Changements qui seront appliques</h4>
|
||||
<ul class="space-y-1 text-sm">
|
||||
<li
|
||||
v-for="(change, field) in preview.diff"
|
||||
:key="field"
|
||||
class="flex flex-col rounded-md border border-base-200 px-3 py-2"
|
||||
>
|
||||
<span class="font-medium text-base-content">{{ fieldLabels[field] || formatFieldLabel(String(field)) }}</span>
|
||||
<span class="text-xs text-error line-through">{{ formatValue(change.current) }}</span>
|
||||
<span class="text-xs text-success">{{ formatValue(change.restored) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-sm text-base-content/60">
|
||||
Aucune difference detectee — l'entite est deja dans l'etat de cette version.
|
||||
</div>
|
||||
|
||||
<!-- Warnings -->
|
||||
<div v-if="preview.warnings.length" class="space-y-1">
|
||||
<h4 class="text-sm font-semibold text-warning">Avertissements</h4>
|
||||
<ul class="space-y-1">
|
||||
<li
|
||||
v-for="(warning, i) in preview.warnings"
|
||||
:key="i"
|
||||
class="alert alert-warning py-2 text-xs"
|
||||
>
|
||||
{{ warning.message }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-ghost btn-sm md:btn-md" :disabled="restoring" @click="$emit('close')">
|
||||
Annuler
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm md:btn-md" :disabled="restoring" @click="$emit('confirm')">
|
||||
<span v-if="restoring" class="loading loading-spinner loading-sm mr-2" />
|
||||
Confirmer la restauration
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop" @click="$emit('close')">
|
||||
<button type="button">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RestorePreview } from '~/composables/useEntityVersions'
|
||||
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
preview: RestorePreview | null
|
||||
restoring: boolean
|
||||
fieldLabels: Record<string, string>
|
||||
entityType: 'machine' | 'composant' | 'piece' | 'product'
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
confirm: []
|
||||
}>()
|
||||
|
||||
const formatFieldLabel = (field: string): string => {
|
||||
if (field.startsWith('customField:')) {
|
||||
return `Champ perso : ${field.replace('customField:', '')}`
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
const formatValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return '—'
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((v) => (typeof v === 'object' && v !== null ? (v as any).name || (v as any).id || JSON.stringify(v) : String(v))).join(', ') || '—'
|
||||
}
|
||||
if (typeof value === 'object') return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
</script>
|
||||
@@ -44,6 +44,8 @@
|
||||
:empty-text="`Aucun ${entityLabelLower} disponible dans cette catégorie`"
|
||||
:option-label="entityOptionLabel"
|
||||
:option-description="entityOptionDescription"
|
||||
server-search
|
||||
@search="handleEntitySearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -145,7 +147,10 @@ const selectedTypeName = computed(() => {
|
||||
return found?.name || ''
|
||||
})
|
||||
|
||||
const entityOptionLabel = (e: any) => e.name || '(sans nom)'
|
||||
const entityOptionLabel = (e: any) => {
|
||||
const name = e.name || '(sans nom)'
|
||||
return e.reference ? `${name} — ${e.reference}` : name
|
||||
}
|
||||
const entityOptionDescription = (e: any) => e.reference || ''
|
||||
|
||||
const selectedEntitySummary = computed(() => {
|
||||
@@ -187,6 +192,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 = () => {
|
||||
resetState()
|
||||
emit('close')
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
<template>
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold">
|
||||
Définitions des champs personnalisés
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="saving"
|
||||
@click="$emit('save')"
|
||||
>
|
||||
<span v-if="saving" class="loading loading-spinner loading-xs" />
|
||||
Enregistrer les champs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="!fields.length" class="text-xs text-gray-500">
|
||||
Aucun champ personnalisé défini. Cliquez sur « Ajouter » pour en créer un.
|
||||
@@ -117,7 +106,6 @@ defineProps<{
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
save: []
|
||||
'add-field': []
|
||||
'remove-field': [index: number]
|
||||
}>()
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
<IconLucidePrinter class="w-5 h-5 mr-2" aria-hidden="true" />
|
||||
Imprimer
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
|
||||
Retour aux machines
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -41,6 +44,8 @@ import IconLucideSquarePen from '~icons/lucide/square-pen'
|
||||
import IconLucideEye from '~icons/lucide/eye'
|
||||
import IconLucidePrinter from '~icons/lucide/printer'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
isEditMode: boolean
|
||||
@@ -50,4 +55,13 @@ defineEmits<{
|
||||
'toggle-edit': []
|
||||
'open-print': []
|
||||
}>()
|
||||
|
||||
function goBack() {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
}
|
||||
else {
|
||||
navigateTo('/machines')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
type="text"
|
||||
class="input input-bordered"
|
||||
@input="$emit('update:machine-name', ($event.target as HTMLInputElement).value)"
|
||||
@blur="$emit('blur-field')"
|
||||
/>
|
||||
<div v-else class="input input-bordered bg-base-200">
|
||||
{{ machineName }}
|
||||
@@ -28,7 +27,7 @@
|
||||
v-if="isEditMode"
|
||||
:value="machineSiteId"
|
||||
class="select select-bordered"
|
||||
@change="$emit('update:machine-site-id', ($event.target as HTMLSelectElement).value); $emit('blur-field')"
|
||||
@change="$emit('update:machine-site-id', ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">Sélectionner un site</option>
|
||||
<option
|
||||
@@ -54,13 +53,12 @@
|
||||
type="text"
|
||||
class="input input-bordered"
|
||||
@input="$emit('update:machine-reference', ($event.target as HTMLInputElement).value)"
|
||||
@blur="$emit('blur-field')"
|
||||
/>
|
||||
<div v-else class="input input-bordered bg-base-200">
|
||||
{{ machineReference }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isEditMode || hasMachineConstructeur" class="form-control">
|
||||
<div v-if="isEditMode || hasMachineConstructeur" class="form-control md:col-span-2">
|
||||
<label class="label">
|
||||
<span class="label-text">Fournisseur</span>
|
||||
</label>
|
||||
@@ -72,23 +70,15 @@
|
||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||
@update:modelValue="$emit('update:constructeur-ids', $event)"
|
||||
/>
|
||||
<div v-else class="border border-base-300 rounded-btn bg-base-200 px-4 py-2 min-h-12 flex items-center">
|
||||
<div v-if="machineConstructeursDisplay.length" class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="constructeur in machineConstructeursDisplay"
|
||||
:key="constructeur.id"
|
||||
class="badge badge-ghost gap-1"
|
||||
>
|
||||
{{ constructeur.name }}
|
||||
<span
|
||||
v-if="formatConstructeurContactSummary(constructeur)"
|
||||
class="text-xs opacity-60"
|
||||
>
|
||||
· {{ formatConstructeurContactSummary(constructeur) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-base-content/50">Non défini</span>
|
||||
<ConstructeurLinksTable
|
||||
v-if="constructeurLinks.length"
|
||||
:model-value="constructeurLinks"
|
||||
:readonly="!isEditMode"
|
||||
@update:model-value="$emit('update:constructeur-links', $event)"
|
||||
@remove="$emit('remove-constructeur-link', $event)"
|
||||
/>
|
||||
<div v-else-if="!isEditMode" class="border border-base-300 rounded-btn bg-base-200 px-4 py-2 min-h-12 flex items-center">
|
||||
<span class="text-base-content/50">Non défini</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,7 +105,6 @@
|
||||
class="input input-bordered input-sm"
|
||||
:required="field.required"
|
||||
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="$emit('update-custom-field', field)"
|
||||
/>
|
||||
<input
|
||||
v-else-if="field.type === 'number'"
|
||||
@@ -124,7 +113,6 @@
|
||||
class="input input-bordered input-sm"
|
||||
:required="field.required"
|
||||
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="$emit('update-custom-field', field)"
|
||||
/>
|
||||
<select
|
||||
v-else-if="field.type === 'select'"
|
||||
@@ -132,7 +120,6 @@
|
||||
class="select select-bordered select-sm"
|
||||
:required="field.required"
|
||||
@change="$emit('set-custom-field-value', field, ($event.target as HTMLSelectElement).value)"
|
||||
@blur="$emit('update-custom-field', field)"
|
||||
>
|
||||
<option value="">Sélectionner...</option>
|
||||
<option
|
||||
@@ -149,7 +136,6 @@
|
||||
class="toggle toggle-primary toggle-sm"
|
||||
:checked="String(field.value).toLowerCase() === 'true'"
|
||||
@change="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).checked ? 'true' : 'false')"
|
||||
@blur="$emit('update-custom-field', field)"
|
||||
>
|
||||
<span class="text-sm" :class="String(field.value).toLowerCase() === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ String(field.value).toLowerCase() === 'true' ? 'Oui' : 'Non' }}</span>
|
||||
</label>
|
||||
@@ -160,7 +146,6 @@
|
||||
class="input input-bordered input-sm"
|
||||
:required="field.required"
|
||||
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="$emit('update-custom-field', field)"
|
||||
/>
|
||||
<div v-else class="text-xs text-error">
|
||||
Type de champ non pris en charge
|
||||
@@ -184,7 +169,6 @@
|
||||
:on-drag-enter="fieldDefs.onDragEnter"
|
||||
:on-drop="fieldDefs.onDrop"
|
||||
:on-drag-end="fieldDefs.onDragEnd"
|
||||
@save="fieldDefs.saveDefinitions()"
|
||||
@add-field="fieldDefs.addField()"
|
||||
@remove-field="fieldDefs.removeField($event)"
|
||||
/>
|
||||
@@ -196,12 +180,11 @@
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
||||
import ConstructeurLinksTable from '~/components/ConstructeurLinksTable.vue'
|
||||
import MachineCustomFieldDefEditor from '~/components/machine/MachineCustomFieldDefEditor.vue'
|
||||
import {
|
||||
formatConstructeurContact as formatConstructeurContactSummary,
|
||||
} from '~/shared/constructeurUtils'
|
||||
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
|
||||
import { useMachineCustomFieldDefs } from '~/composables/useMachineCustomFieldDefs'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
|
||||
const props = defineProps<{
|
||||
isEditMode: boolean
|
||||
@@ -213,6 +196,7 @@ const props = defineProps<{
|
||||
machineConstructeurIds: string[]
|
||||
machineConstructeursDisplay: any[]
|
||||
hasMachineConstructeur: boolean
|
||||
constructeurLinks: ConstructeurLinkEntry[]
|
||||
visibleCustomFields: any[]
|
||||
getMachineFieldId: (fieldName: string) => string
|
||||
machineId: string
|
||||
@@ -224,9 +208,9 @@ const emit = defineEmits<{
|
||||
'update:machine-reference': [value: string]
|
||||
'update:machine-site-id': [value: string]
|
||||
'update:constructeur-ids': [ids: unknown]
|
||||
'blur-field': []
|
||||
'update:constructeur-links': [links: ConstructeurLinkEntry[]]
|
||||
'remove-constructeur-link': [constructeurId: string]
|
||||
'set-custom-field-value': [field: any, value: unknown]
|
||||
'update-custom-field': [field: any]
|
||||
'custom-fields-saved': []
|
||||
}>()
|
||||
|
||||
@@ -239,4 +223,8 @@ const fieldDefs = useMachineCustomFieldDefs({
|
||||
watch(() => props.machineCustomFieldDefs, (newDefs) => {
|
||||
fieldDefs.reinit(newDefs)
|
||||
}, { deep: true })
|
||||
|
||||
defineExpose({
|
||||
saveFieldDefinitions: () => fieldDefs.saveDefinitions(),
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -108,6 +108,13 @@
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<ReferenceFormulaBuilder
|
||||
v-if="form.category === 'PIECE' || form.category === 'COMPONENT'"
|
||||
v-model="form.referenceFormula"
|
||||
:custom-fields="formulaBuilderCustomFields"
|
||||
:disabled="isReadonly"
|
||||
/>
|
||||
|
||||
<footer class="flex flex-col gap-3 border-t border-base-300 pt-4 sm:flex-row sm:justify-end">
|
||||
<button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
|
||||
Annuler
|
||||
@@ -177,14 +184,33 @@ const componentSubcomponentMaxDepth = computed(() =>
|
||||
)
|
||||
const isReadonly = computed(() => props.readonly === true)
|
||||
|
||||
const form = reactive<ModelTypePayload>({
|
||||
const form = reactive<ModelTypePayload & { referenceFormula?: string | null }>({
|
||||
name: '',
|
||||
code: '',
|
||||
category: props.initialCategory,
|
||||
notes: '',
|
||||
structure: undefined,
|
||||
referenceFormula: null,
|
||||
})
|
||||
|
||||
const formulaBuilderCustomFields = computed(() => {
|
||||
if (form.category === 'PIECE') {
|
||||
const fields = pieceStructure.value?.customFields
|
||||
return Array.isArray(fields) ? fields : []
|
||||
}
|
||||
if (form.category === 'COMPONENT') {
|
||||
const fields = componentStructure.value?.customFields
|
||||
return Array.isArray(fields) ? fields : []
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const extractFormulaFields = (formula: string | null | undefined): string[] => {
|
||||
if (!formula) return []
|
||||
const matches = [...formula.matchAll(/\{(\w+)\}/g)]
|
||||
return [...new Set(matches.map(m => m[1]).filter((n): n is string => n !== undefined))]
|
||||
}
|
||||
|
||||
const errors = reactive<{ name?: string }>({})
|
||||
const nameInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
@@ -248,6 +274,9 @@ const resetForm = () => {
|
||||
|
||||
errors.name = undefined
|
||||
|
||||
const incomingAny = incoming as Record<string, unknown>
|
||||
form.referenceFormula = typeof incomingAny.referenceFormula === 'string' ? incomingAny.referenceFormula : null
|
||||
|
||||
resetStructures(incoming.structure, form.category)
|
||||
}
|
||||
|
||||
@@ -286,20 +315,28 @@ const handleSubmit = () => {
|
||||
}
|
||||
|
||||
if (form.category === 'COMPONENT') {
|
||||
const formula = form.referenceFormula || null
|
||||
const requiredFields = extractFormulaFields(formula)
|
||||
emit('submit', {
|
||||
...common,
|
||||
category: 'COMPONENT',
|
||||
structure: normalizeStructureForSave(cloneStructure(componentStructure.value)),
|
||||
})
|
||||
referenceFormula: formula,
|
||||
requiredFieldsForReference: requiredFields.length ? requiredFields : null,
|
||||
} as ModelTypePayload)
|
||||
return
|
||||
}
|
||||
|
||||
if (form.category === 'PIECE') {
|
||||
const formula = form.referenceFormula || null
|
||||
const requiredFields = extractFormulaFields(formula)
|
||||
emit('submit', {
|
||||
...common,
|
||||
category: 'PIECE',
|
||||
structure: normalizePieceStructureForSave(clonePieceStructure(pieceStructure.value)),
|
||||
})
|
||||
referenceFormula: formula,
|
||||
requiredFieldsForReference: requiredFields.length ? requiredFields : null,
|
||||
} as ModelTypePayload)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
115
app/components/model-types/ReferenceFormulaBuilder.vue
Normal file
115
app/components/model-types/ReferenceFormulaBuilder.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<header>
|
||||
<h3 class="text-lg font-semibold text-base-content">Génération de référence automatique</h3>
|
||||
<p class="mt-1 text-sm text-base-content/70">
|
||||
Cliquez sur un champ pour l'insérer dans la formule. Vous pouvez aussi taper du texte libre (séparateurs, préfixes…).
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="rounded-lg border border-base-300 p-4 space-y-4">
|
||||
<div v-if="fieldNames.length" class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="name in fieldNames"
|
||||
:key="name"
|
||||
type="button"
|
||||
class="btn btn-xs btn-outline btn-primary font-mono"
|
||||
:disabled="disabled"
|
||||
@click="insertField(name)"
|
||||
>
|
||||
{{ name }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="text-sm text-base-content/50 italic">
|
||||
Aucun champ personnalisé défini dans la structure.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label class="label" for="reference-formula">
|
||||
<span class="label-text">Formule</span>
|
||||
</label>
|
||||
<input
|
||||
id="reference-formula"
|
||||
ref="inputRef"
|
||||
:value="modelValue"
|
||||
type="text"
|
||||
class="input input-bordered w-full font-mono"
|
||||
placeholder="Ex: SNU {serie}-{diametre}/{type}"
|
||||
:disabled="disabled"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value || null)"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-base-content/60">
|
||||
Laissez vide si ce type n'utilise pas de référence automatique.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="modelValue" class="rounded bg-base-200 px-3 py-2 text-sm">
|
||||
<span class="text-base-content/70">Aperçu :</span>
|
||||
<span class="ml-1 font-mono font-semibold">{{ preview }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
interface CustomField {
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | null | undefined
|
||||
customFields: CustomField[]
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | null): void
|
||||
}>()
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const fieldNames = computed(() =>
|
||||
props.customFields.map(f => f.name).filter((n): n is string => Boolean(n)),
|
||||
)
|
||||
|
||||
const previewExamples: Record<string, string> = {
|
||||
text: 'VALEUR',
|
||||
number: '123',
|
||||
select: 'OPTION',
|
||||
boolean: 'OUI',
|
||||
date: '2026-01-01',
|
||||
}
|
||||
|
||||
const preview = computed(() => {
|
||||
if (!props.modelValue) return ''
|
||||
const fieldMap = new Map<string, string>()
|
||||
for (const f of props.customFields) {
|
||||
if (f.name) {
|
||||
fieldMap.set(f.name, previewExamples[f.type] ?? 'VALEUR')
|
||||
}
|
||||
}
|
||||
return props.modelValue.replace(/\{(\w+)\}/g, (_, name) => fieldMap.get(name) ?? '???')
|
||||
})
|
||||
|
||||
const insertField = (fieldName: string) => {
|
||||
const placeholder = `{${fieldName}}`
|
||||
const input = inputRef.value
|
||||
const current = props.modelValue ?? ''
|
||||
if (!input) {
|
||||
emit('update:modelValue', current + placeholder)
|
||||
return
|
||||
}
|
||||
const start = input.selectionStart ?? current.length
|
||||
const end = input.selectionEnd ?? start
|
||||
const updated = current.slice(0, start) + placeholder + current.slice(end)
|
||||
emit('update:modelValue', updated)
|
||||
nextTick(() => {
|
||||
const newPos = start + placeholder.length
|
||||
input.focus()
|
||||
input.setSelectionRange(newPos, newPos)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -3,6 +3,18 @@ import { useApi } from './useApi'
|
||||
import { useToast } from './useToast'
|
||||
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 {
|
||||
id: string
|
||||
content: string
|
||||
@@ -17,6 +29,7 @@ export interface Comment {
|
||||
resolvedAt?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
documents?: CommentDocument[]
|
||||
}
|
||||
|
||||
interface CommentResult {
|
||||
@@ -33,7 +46,7 @@ interface CommentListResult {
|
||||
}
|
||||
|
||||
export function useComments() {
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
const { get, post, patch, postFormData, delete: del } = useApi()
|
||||
const { showSuccess, showError } = useToast()
|
||||
const loading = ref(false)
|
||||
|
||||
@@ -44,16 +57,9 @@ export function useComments() {
|
||||
): Promise<CommentListResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
entityType,
|
||||
entityId,
|
||||
status,
|
||||
'order[createdAt]': 'desc',
|
||||
itemsPerPage: '200',
|
||||
})
|
||||
const result = await get(`/comments?${params.toString()}`)
|
||||
const result = await get<Comment[]>(`/comments/by-entity/${entityType}/${entityId}?status=${status}`)
|
||||
if (result.success) {
|
||||
const items = extractCollection<Comment>(result.data)
|
||||
const items = (result.data ?? []) as Comment[]
|
||||
return { success: true, data: items }
|
||||
}
|
||||
return { success: false, error: result.error }
|
||||
@@ -80,18 +86,15 @@ export function useComments() {
|
||||
if (options.status) params.set('status', options.status)
|
||||
if (options.entityType) params.set('entityType', options.entityType)
|
||||
if (options.entityName) params.set('entityName', options.entityName)
|
||||
const sortField = options.orderBy || 'createdAt'
|
||||
const sortDir = options.orderDir || 'desc'
|
||||
params.set(`order[${sortField}]`, sortDir)
|
||||
params.set('sort', options.orderBy || 'createdAt')
|
||||
params.set('direction', options.orderDir || 'desc')
|
||||
params.set('itemsPerPage', String(options.itemsPerPage || 30))
|
||||
params.set('page', String(options.page || 1))
|
||||
|
||||
const result = await get(`/comments?${params.toString()}`)
|
||||
if (result.success) {
|
||||
const items = extractCollection<Comment>(result.data)
|
||||
const raw = result.data as Record<string, unknown> | null
|
||||
const total = Number(raw?.['hydra:totalItems'] ?? raw?.totalItems ?? items.length)
|
||||
return { success: true, data: items, total }
|
||||
const result = await get<{ items: Comment[]; total: number }>(`/comments/search/list?${params.toString()}`)
|
||||
if (result.success && result.data) {
|
||||
const data = result.data as { items: Comment[]; total: number }
|
||||
return { success: true, data: data.items, total: data.total }
|
||||
}
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
@@ -107,12 +110,26 @@ export function useComments() {
|
||||
entityId: string,
|
||||
content: string,
|
||||
entityName?: string,
|
||||
files?: File[],
|
||||
): Promise<CommentResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
let result
|
||||
if (files && files.length > 0) {
|
||||
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
|
||||
const result = await post('/comments', payload)
|
||||
result = await post('/comments', payload)
|
||||
}
|
||||
if (result.success) {
|
||||
showSuccess('Commentaire ajouté')
|
||||
return { success: true, data: result.data as Comment }
|
||||
|
||||
@@ -26,7 +26,9 @@ import {
|
||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||
saveCustomFieldValues as _saveCustomFieldValues,
|
||||
} from '~/shared/utils/customFieldFormUtils'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
import {
|
||||
getStructurePieces,
|
||||
resolvePieceLabel as _resolvePieceLabel,
|
||||
@@ -77,6 +79,7 @@ export function useComponentCreate() {
|
||||
const toast = useToast()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const { uploadDocuments } = useDocuments()
|
||||
const { syncLinks } = useConstructeurLinks()
|
||||
const { canEdit } = usePermissions()
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -92,6 +95,8 @@ export function useComponentCreate() {
|
||||
constructeurIds: [] as string[],
|
||||
prix: '' as string,
|
||||
})
|
||||
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
|
||||
const lastSuggestedName = ref('')
|
||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||
const structureAssignments = ref<StructureAssignmentNode | null>(null)
|
||||
@@ -106,7 +111,7 @@ export function useComponentCreate() {
|
||||
const availableProducts = computed(() => productCatalogRef.value ?? [])
|
||||
const availableComponents = computed(() => componentCatalogRef.value ?? [])
|
||||
const structureDataLoading = computed(
|
||||
() => piecesLoading.value || componentsLoading.value || productsLoading.value,
|
||||
() => !submitting.value && (piecesLoading.value || componentsLoading.value || productsLoading.value),
|
||||
)
|
||||
|
||||
const fetchedPieceTypeMap = ref<Record<string, string>>({})
|
||||
@@ -276,9 +281,7 @@ export function useComponentCreate() {
|
||||
payload.reference = reference
|
||||
}
|
||||
|
||||
if (creationForm.constructeurIds.length) {
|
||||
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
|
||||
}
|
||||
// constructeurIds are handled via link entities, not in the main payload
|
||||
|
||||
const rawPrice = typeof creationForm.prix === 'string'
|
||||
? creationForm.prix.trim()
|
||||
@@ -323,7 +326,7 @@ export function useComponentCreate() {
|
||||
await _saveCustomFieldValues(
|
||||
'composant',
|
||||
createdComponent.id,
|
||||
[createdComponent?.typeComposant?.customFields],
|
||||
[createdComponent?.typeComposant?.structure?.customFields],
|
||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||
)
|
||||
if (selectedDocuments.value.length && result.data?.id) {
|
||||
@@ -343,8 +346,12 @@ export function useComponentCreate() {
|
||||
}
|
||||
selectedDocuments.value = []
|
||||
}
|
||||
// Sync constructeur links after creation
|
||||
if (constructeurLinks.value.length) {
|
||||
await syncLinks('composant', createdComponent.id, [], constructeurLinks.value)
|
||||
}
|
||||
toast.showSuccess('Composant créé avec succès')
|
||||
await router.push('/component-catalog')
|
||||
await router.replace(`/component/${createdComponent.id}?edit=true`)
|
||||
}
|
||||
else if (result.error) {
|
||||
toast.showError(result.error)
|
||||
@@ -380,6 +387,8 @@ export function useComponentCreate() {
|
||||
selectedTypeId,
|
||||
submitting,
|
||||
creationForm,
|
||||
constructeurLinks,
|
||||
constructeurIdsFromForm,
|
||||
customFieldInputs,
|
||||
structureAssignments,
|
||||
selectedDocuments,
|
||||
|
||||
@@ -7,15 +7,16 @@ import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import type { SelectionEntry } from '~/shared/utils/structureSelectionUtils'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||
import { useComponentHistory } from '~/composables/useComponentHistory'
|
||||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
import {
|
||||
getStructurePieces,
|
||||
getStructureProducts,
|
||||
@@ -58,10 +59,11 @@ export function useComponentEdit(componentId: string) {
|
||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const { productTypes, loadProductTypes } = useProductTypes()
|
||||
const { updateComposant, loadComposants, composants: componentCatalogRef } = useComposants()
|
||||
const { pieces, loadPieces } = usePieces()
|
||||
const { products, loadProducts } = useProducts()
|
||||
const { updateComposant, composants: componentCatalogRef } = useComposants()
|
||||
const { pieces } = usePieces()
|
||||
const { products } = useProducts()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
const { fetchLinks, syncLinks } = useConstructeurLinks()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const toast = useToast()
|
||||
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
|
||||
@@ -90,6 +92,9 @@ export function useComponentEdit(componentId: string) {
|
||||
constructeurIds: [] as string[],
|
||||
prix: '' as string,
|
||||
})
|
||||
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
|
||||
|
||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||
const fetchedPieceTypeMap = ref<Record<string, string>>({})
|
||||
@@ -270,97 +275,81 @@ export function useComponentEdit(componentId: string) {
|
||||
}
|
||||
})
|
||||
|
||||
// --- Slot selection entries (for selectors) ---
|
||||
// --- Slot local edits (saved on submit, not auto-saved) ---
|
||||
|
||||
const slotEdits = reactive<{
|
||||
pieces: Record<string, { selectedPieceId?: string | null, quantity?: number }>
|
||||
products: Record<string, { selectedProductId?: string | null }>
|
||||
subcomponents: Record<string, { selectedComposantId?: string | null }>
|
||||
}>({ pieces: {}, products: {}, subcomponents: {} })
|
||||
|
||||
const pieceSlotEntries = computed(() => {
|
||||
const structure = component.value?.structure
|
||||
if (!structure?.pieces) return []
|
||||
return (structure.pieces as any[]).map((slot: any, i: number) => ({
|
||||
return (structure.pieces as any[]).map((slot: any, i: number) => {
|
||||
const edits = slotEdits.pieces[slot.slotId]
|
||||
return {
|
||||
slotId: slot.slotId,
|
||||
typePieceId: slot.typePieceId,
|
||||
selectedPieceId: slot.selectedPieceId ?? null,
|
||||
quantity: slot.quantity ?? 1,
|
||||
selectedPieceId: edits && 'selectedPieceId' in edits ? edits.selectedPieceId : (slot.selectedPieceId ?? null),
|
||||
selectedPieceName: slot.selectedPieceName ?? null,
|
||||
quantity: edits && 'quantity' in edits ? edits.quantity! : (slot.quantity ?? 1),
|
||||
position: slot.position ?? i,
|
||||
label: pieceTypeLabelMap.value[slot.typePieceId] || `Pièce #${i + 1}`,
|
||||
}))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const productSlotEntries = computed(() => {
|
||||
const structure = component.value?.structure
|
||||
if (!structure?.products) return []
|
||||
return (structure.products as any[]).map((slot: any, i: number) => ({
|
||||
return (structure.products as any[]).map((slot: any, i: number) => {
|
||||
const edits = slotEdits.products[slot.slotId]
|
||||
return {
|
||||
slotId: slot.slotId,
|
||||
typeProductId: slot.typeProductId,
|
||||
selectedProductId: slot.selectedProductId ?? null,
|
||||
selectedProductId: edits && 'selectedProductId' in edits ? edits.selectedProductId : (slot.selectedProductId ?? null),
|
||||
selectedProductName: slot.selectedProductName ?? null,
|
||||
familyCode: slot.familyCode,
|
||||
position: slot.position ?? i,
|
||||
label: productTypeLabelMap.value[slot.typeProductId] || `Produit #${i + 1}`,
|
||||
}))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const subcomponentSlotEntries = computed(() => {
|
||||
const structure = component.value?.structure
|
||||
if (!structure?.subcomponents) return []
|
||||
return (structure.subcomponents as any[]).map((slot: any, i: number) => ({
|
||||
return (structure.subcomponents as any[]).map((slot: any, i: number) => {
|
||||
const edits = slotEdits.subcomponents[slot.slotId]
|
||||
return {
|
||||
slotId: slot.slotId,
|
||||
typeComposantId: slot.typeComposantId,
|
||||
selectedComponentId: slot.selectedComponentId ?? null,
|
||||
selectedComponentId: edits && 'selectedComposantId' in edits ? edits.selectedComposantId : (slot.selectedComponentId ?? null),
|
||||
selectedComponentName: slot.selectedComponentName ?? null,
|
||||
alias: slot.alias,
|
||||
familyCode: slot.familyCode,
|
||||
position: slot.position ?? i,
|
||||
label: slot.alias || `Sous-composant #${i + 1}`,
|
||||
}))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const savePieceSlotSelection = async (slotId: string, selectedPieceId: string | null) => {
|
||||
const result = await patch(`/composant-piece-slots/${slotId}`, { selectedPieceId })
|
||||
if (result.success) {
|
||||
const structure = component.value?.structure
|
||||
if (structure?.pieces) {
|
||||
const slot = (structure.pieces as any[]).find((s: any) => s.slotId === slotId)
|
||||
if (slot) slot.selectedPieceId = selectedPieceId
|
||||
}
|
||||
toast.showSuccess('Pièce mise à jour')
|
||||
}
|
||||
const setPieceSlotSelection = (slotId: string, selectedPieceId: string | null) => {
|
||||
slotEdits.pieces[slotId] = { ...slotEdits.pieces[slotId], selectedPieceId }
|
||||
}
|
||||
|
||||
const saveProductSlotSelection = async (slotId: string, selectedProductId: string | null) => {
|
||||
const result = await patch(`/composant-product-slots/${slotId}`, { selectedProductId })
|
||||
if (result.success) {
|
||||
const structure = component.value?.structure
|
||||
if (structure?.products) {
|
||||
const slot = (structure.products as any[]).find((s: any) => s.slotId === slotId)
|
||||
if (slot) slot.selectedProductId = selectedProductId
|
||||
}
|
||||
toast.showSuccess('Produit mis à jour')
|
||||
}
|
||||
const setProductSlotSelection = (slotId: string, selectedProductId: string | null) => {
|
||||
slotEdits.products[slotId] = { ...slotEdits.products[slotId], selectedProductId }
|
||||
}
|
||||
|
||||
const saveSubcomponentSlotSelection = async (slotId: string, selectedComposantId: string | null) => {
|
||||
const result = await patch(`/composant-subcomponent-slots/${slotId}`, { selectedComposantId })
|
||||
if (result.success) {
|
||||
const structure = component.value?.structure
|
||||
if (structure?.subcomponents) {
|
||||
const slot = (structure.subcomponents as any[]).find((s: any) => s.slotId === slotId)
|
||||
if (slot) slot.selectedComponentId = selectedComposantId
|
||||
}
|
||||
toast.showSuccess('Sous-composant mis à jour')
|
||||
}
|
||||
const setSubcomponentSlotSelection = (slotId: string, selectedComposantId: string | null) => {
|
||||
slotEdits.subcomponents[slotId] = { ...slotEdits.subcomponents[slotId], selectedComposantId }
|
||||
}
|
||||
|
||||
const saveSlotQuantity = async (entry: SelectionEntry) => {
|
||||
const slotId = entry.slotId
|
||||
const quantity = typeof entry._definition?.quantity === 'number'
|
||||
? Math.max(1, entry._definition.quantity)
|
||||
: null
|
||||
if (!slotId || quantity === null) return
|
||||
try {
|
||||
await patch(`/composant-piece-slots/${slotId}`, { quantity })
|
||||
toast.showSuccess('Quantité mise à jour')
|
||||
}
|
||||
catch (error: any) {
|
||||
toast.showError(error?.message || 'Erreur lors de la mise à jour de la quantité')
|
||||
}
|
||||
const setSlotQuantity = (slotId: string, quantity: number) => {
|
||||
if (!slotId || quantity < 1) return
|
||||
slotEdits.pieces[slotId] = { ...slotEdits.pieces[slotId], quantity: Math.max(1, quantity) }
|
||||
}
|
||||
|
||||
const submitEdition = async () => {
|
||||
@@ -381,7 +370,6 @@ export function useComponentEdit(componentId: string) {
|
||||
|
||||
const reference = editionForm.reference.trim()
|
||||
payload.reference = reference || null
|
||||
payload.constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
|
||||
|
||||
if (rawPrice) {
|
||||
const parsed = Number(rawPrice)
|
||||
@@ -402,11 +390,62 @@ export function useComponentEdit(componentId: string) {
|
||||
'composant',
|
||||
updatedComponent.id,
|
||||
[
|
||||
updatedComponent?.typeComposant?.customFields,
|
||||
updatedComponent?.typeComposant?.structure?.customFields,
|
||||
],
|
||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||
)
|
||||
await router.push('/component-catalog')
|
||||
|
||||
// Save slot edits
|
||||
const slotPromises: Promise<any>[] = []
|
||||
for (const [slotId, edits] of Object.entries(slotEdits.pieces)) {
|
||||
if (Object.keys(edits).length) {
|
||||
slotPromises.push(patch(`/composant-piece-slots/${slotId}`, {
|
||||
...'selectedPieceId' in edits ? { selectedPieceId: edits.selectedPieceId } : {},
|
||||
...'quantity' in edits ? { quantity: edits.quantity } : {},
|
||||
}))
|
||||
}
|
||||
}
|
||||
for (const [slotId, edits] of Object.entries(slotEdits.products)) {
|
||||
if ('selectedProductId' in edits) {
|
||||
slotPromises.push(patch(`/composant-product-slots/${slotId}`, { selectedProductId: edits.selectedProductId }))
|
||||
}
|
||||
}
|
||||
for (const [slotId, edits] of Object.entries(slotEdits.subcomponents)) {
|
||||
if ('selectedComposantId' in edits) {
|
||||
slotPromises.push(patch(`/composant-subcomponent-slots/${slotId}`, { selectedComposantId: edits.selectedComposantId }))
|
||||
}
|
||||
}
|
||||
await Promise.all(slotPromises)
|
||||
|
||||
// Apply slot edits to local structure so UI reflects saved values
|
||||
const structure = component.value?.structure
|
||||
if (structure) {
|
||||
for (const [slotId, edits] of Object.entries(slotEdits.pieces)) {
|
||||
const slot = (structure.pieces as any[])?.find((s: any) => s.slotId === slotId)
|
||||
if (slot) {
|
||||
if ('selectedPieceId' in edits) slot.selectedPieceId = edits.selectedPieceId
|
||||
if ('quantity' in edits) slot.quantity = edits.quantity
|
||||
}
|
||||
}
|
||||
for (const [slotId, edits] of Object.entries(slotEdits.products)) {
|
||||
const slot = (structure.products as any[])?.find((s: any) => s.slotId === slotId)
|
||||
if (slot && 'selectedProductId' in edits) slot.selectedProductId = edits.selectedProductId
|
||||
}
|
||||
for (const [slotId, edits] of Object.entries(slotEdits.subcomponents)) {
|
||||
const slot = (structure.subcomponents as any[])?.find((s: any) => s.slotId === slotId)
|
||||
if (slot && 'selectedComposantId' in edits) slot.selectedComponentId = edits.selectedComposantId
|
||||
}
|
||||
}
|
||||
|
||||
// Reset local slot edits
|
||||
slotEdits.pieces = {}
|
||||
slotEdits.products = {}
|
||||
slotEdits.subcomponents = {}
|
||||
|
||||
await syncLinks('composant', component.value.id, originalConstructeurLinks.value, constructeurLinks.value)
|
||||
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
|
||||
|
||||
toast.showSuccess('Composant mis à jour avec succès.')
|
||||
}
|
||||
}
|
||||
catch (error: any) {
|
||||
@@ -440,15 +479,16 @@ export function useComponentEdit(componentId: string) {
|
||||
editionForm.name = currentComponent.name || ''
|
||||
editionForm.description = currentComponent.description || ''
|
||||
editionForm.reference = currentComponent.reference || ''
|
||||
editionForm.constructeurIds = uniqueConstructeurIds(
|
||||
currentComponent,
|
||||
Array.isArray(currentComponent.constructeurs) ? currentComponent.constructeurs : [],
|
||||
currentComponent.constructeur ? [currentComponent.constructeur] : [],
|
||||
)
|
||||
editionForm.prix = currentComponent.prix !== null && currentComponent.prix !== undefined ? String(currentComponent.prix) : ''
|
||||
// Load constructeur links
|
||||
fetchLinks('composant', componentId).then((links) => {
|
||||
constructeurLinks.value = links
|
||||
originalConstructeurLinks.value = links.map(l => ({ ...l }))
|
||||
editionForm.constructeurIds = constructeurIdsFromLinks(links)
|
||||
if (editionForm.constructeurIds.length) {
|
||||
void ensureConstructeurs(editionForm.constructeurIds)
|
||||
}
|
||||
})
|
||||
editionForm.prix = currentComponent.prix !== null && currentComponent.prix !== undefined ? String(currentComponent.prix) : ''
|
||||
|
||||
initialized.value = true
|
||||
}
|
||||
@@ -500,13 +540,6 @@ export function useComponentEdit(componentId: string) {
|
||||
fetchComponent(),
|
||||
])
|
||||
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 {
|
||||
@@ -522,6 +555,9 @@ export function useComponentEdit(componentId: string) {
|
||||
previewVisible,
|
||||
selectedTypeId,
|
||||
editionForm,
|
||||
constructeurLinks,
|
||||
originalConstructeurLinks,
|
||||
constructeurIdsFromForm,
|
||||
customFieldInputs,
|
||||
historyFieldLabels,
|
||||
|
||||
@@ -548,10 +584,11 @@ export function useComponentEdit(componentId: string) {
|
||||
handleFilesAdded,
|
||||
refreshDocuments,
|
||||
submitEdition,
|
||||
saveSlotQuantity,
|
||||
savePieceSlotSelection,
|
||||
saveProductSlotSelection,
|
||||
saveSubcomponentSlotSelection,
|
||||
fetchComponent,
|
||||
setSlotQuantity,
|
||||
setPieceSlotSelection,
|
||||
setProductSlotSelection,
|
||||
setSubcomponentSlotSelection,
|
||||
resolvePieceLabel,
|
||||
resolveProductLabel,
|
||||
resolveSubcomponentLabel,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { useConstructeurs, type Constructeur } from './useConstructeurs'
|
||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
@@ -42,6 +42,7 @@ interface LoadComposantsOptions {
|
||||
orderBy?: string
|
||||
orderDir?: 'asc' | 'desc'
|
||||
typeName?: string
|
||||
typeComposantId?: string
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
@@ -109,17 +110,18 @@ export function useComposants() {
|
||||
orderBy = 'name',
|
||||
orderDir = 'asc',
|
||||
typeName,
|
||||
typeComposantId,
|
||||
force = false,
|
||||
} = options
|
||||
|
||||
if (!force && loaded.value && !search && !typeName && page === 1) {
|
||||
if (!force && loaded.value && !search && !typeName && !typeComposantId && page === 1) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: composants.value, total: total.value, page, itemsPerPage },
|
||||
}
|
||||
}
|
||||
|
||||
if (loading.value) {
|
||||
if (!typeComposantId && loading.value) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: composants.value, total: total.value, page, itemsPerPage },
|
||||
@@ -128,33 +130,41 @@ export function useComposants() {
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', String(itemsPerPage))
|
||||
params.set('page', String(page))
|
||||
|
||||
if (search && search.trim()) {
|
||||
params.set('name', search.trim())
|
||||
params.set('search', search.trim())
|
||||
}
|
||||
|
||||
if (typeName && typeName.trim()) {
|
||||
params.set('typeComposant.name', typeName.trim())
|
||||
}
|
||||
|
||||
if (typeComposantId) {
|
||||
params.set('typeComposant', typeComposantId)
|
||||
}
|
||||
|
||||
params.set(`order[${orderBy}]`, orderDir)
|
||||
|
||||
const result = await get(`/composants?${params.toString()}`)
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||
const resultTotal = extractTotal(result.data, items.length)
|
||||
|
||||
if (!typeComposantId) {
|
||||
composants.value = enrichedItems
|
||||
total.value = extractTotal(result.data, items.length)
|
||||
total.value = resultTotal
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: enrichedItems,
|
||||
total: total.value,
|
||||
total: resultTotal,
|
||||
page,
|
||||
itemsPerPage,
|
||||
},
|
||||
@@ -172,7 +182,8 @@ export function useComposants() {
|
||||
const createComposant = async (composantData: Partial<Composant>): Promise<ComposantSingleResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(composantData))
|
||||
const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = composantData as any
|
||||
const normalizedPayload = normalizeRelationIds(cleanPayload)
|
||||
const result = await post('/composants', normalizedPayload)
|
||||
if (result.success && result.data) {
|
||||
const enriched = await withResolvedConstructeurs(result.data as Composant)
|
||||
@@ -199,7 +210,8 @@ export function useComposants() {
|
||||
const updateComposantData = async (id: string, composantData: Partial<Composant>): Promise<ComposantSingleResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(composantData))
|
||||
const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = composantData as any
|
||||
const normalizedPayload = normalizeRelationIds(cleanPayload)
|
||||
const result = await patch(`/composants/${id}`, normalizedPayload)
|
||||
if (result.success && result.data) {
|
||||
const updated = await withResolvedConstructeurs(result.data as Composant)
|
||||
|
||||
103
app/composables/useConstructeurLinks.ts
Normal file
103
app/composables/useConstructeurLinks.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
type EntityType = 'machine' | 'piece' | 'composant' | 'product'
|
||||
|
||||
const ENDPOINTS: Record<EntityType, string> = {
|
||||
machine: '/machine_constructeur_links',
|
||||
piece: '/piece_constructeur_links',
|
||||
composant: '/composant_constructeur_links',
|
||||
product: '/product_constructeur_links',
|
||||
}
|
||||
|
||||
const ENTITY_KEYS: Record<EntityType, string> = {
|
||||
machine: 'machine',
|
||||
piece: 'piece',
|
||||
composant: 'composant',
|
||||
product: 'product',
|
||||
}
|
||||
|
||||
const ENTITY_PLURALS: Record<EntityType, string> = {
|
||||
machine: 'machines',
|
||||
piece: 'pieces',
|
||||
composant: 'composants',
|
||||
product: 'products',
|
||||
}
|
||||
|
||||
export function useConstructeurLinks() {
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
|
||||
const fetchLinks = async (
|
||||
entityType: EntityType,
|
||||
entityId: string,
|
||||
): Promise<ConstructeurLinkEntry[]> => {
|
||||
const endpoint = ENDPOINTS[entityType]
|
||||
const key = ENTITY_KEYS[entityType]
|
||||
const plural = ENTITY_PLURALS[entityType]
|
||||
const url = `${endpoint}?${key}=/api/${plural}/${entityId}`
|
||||
const result = await get(url)
|
||||
if (!result.success || !result.data) return []
|
||||
|
||||
const members = extractCollection(result.data)
|
||||
if (!Array.isArray(members)) return []
|
||||
|
||||
return members.map((link: any) => ({
|
||||
linkId: link.id ?? (typeof link['@id'] === 'string' ? link['@id'].split('/').pop() : undefined),
|
||||
constructeurId: typeof link.constructeur === 'string'
|
||||
? link.constructeur.split('/').pop()!
|
||||
: link.constructeur?.id ?? '',
|
||||
constructeur: typeof link.constructeur === 'object' ? link.constructeur : null,
|
||||
supplierReference: link.supplierReference ?? null,
|
||||
}))
|
||||
}
|
||||
|
||||
const syncLinks = async (
|
||||
entityType: EntityType,
|
||||
entityId: string,
|
||||
originalLinks: ConstructeurLinkEntry[],
|
||||
formLinks: ConstructeurLinkEntry[],
|
||||
): Promise<void> => {
|
||||
const endpoint = ENDPOINTS[entityType]
|
||||
const key = ENTITY_KEYS[entityType]
|
||||
const plural = ENTITY_PLURALS[entityType]
|
||||
const entityIri = `/api/${plural}/${entityId}`
|
||||
|
||||
const originalMap = new Map(originalLinks.map(l => [l.constructeurId, l]))
|
||||
const formMap = new Map(formLinks.map(l => [l.constructeurId, l]))
|
||||
|
||||
const promises: Promise<any>[] = []
|
||||
|
||||
// Delete removed links
|
||||
for (const [cId, orig] of originalMap) {
|
||||
if (!formMap.has(cId) && orig.linkId) {
|
||||
promises.push(del(`${endpoint}/${orig.linkId}`))
|
||||
}
|
||||
}
|
||||
|
||||
// Create new links
|
||||
for (const [cId, form] of formMap) {
|
||||
if (!originalMap.has(cId)) {
|
||||
promises.push(post(endpoint, {
|
||||
[key]: entityIri,
|
||||
constructeur: `/api/constructeurs/${cId}`,
|
||||
supplierReference: form.supplierReference || null,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// Patch modified supplierReference
|
||||
for (const [cId, form] of formMap) {
|
||||
const orig = originalMap.get(cId)
|
||||
if (orig?.linkId && (orig.supplierReference ?? null) !== (form.supplierReference ?? null)) {
|
||||
promises.push(patch(`${endpoint}/${orig.linkId}`, {
|
||||
supplierReference: form.supplierReference || null,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.allSettled(promises)
|
||||
}
|
||||
|
||||
return { fetchLinks, syncLinks }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref, computed, type Ref, type ComputedRef } from 'vue'
|
||||
import { ref, computed, watch, type Ref, type ComputedRef } from 'vue'
|
||||
import { useUrlState } from './useUrlState'
|
||||
import type { DataTableSort, DataTablePagination, DataTableColumnFilters, SortDirection } from '~/shared/types/dataTable'
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface UseDataTableOptions {
|
||||
persistToUrl?: boolean
|
||||
/** Extra URL state params for page-specific filters */
|
||||
extraParams?: Record<string, { default: string | number; type?: 'string' | 'number' }>
|
||||
/** Column filter keys to persist in URL (prefixed with `f.` in query string) */
|
||||
columnFilterKeys?: string[]
|
||||
}
|
||||
|
||||
export interface UseDataTableReturn {
|
||||
@@ -56,6 +58,7 @@ export function useDataTable(
|
||||
searchDebounceMs = 300,
|
||||
persistToUrl = true,
|
||||
extraParams = {},
|
||||
columnFilterKeys = [],
|
||||
} = options
|
||||
|
||||
let searchTerm: Ref<string>
|
||||
@@ -64,6 +67,7 @@ export function useDataTable(
|
||||
let currentPage: Ref<number>
|
||||
let itemsPerPage: Ref<number>
|
||||
const filters: Record<string, Ref<string | number>> = {}
|
||||
const columnFilterRefs: Record<string, Ref<string>> = {}
|
||||
|
||||
if (persistToUrl) {
|
||||
const paramDefs: Record<string, { default: string | number; type?: 'string' | 'number'; debounce?: number }> = {
|
||||
@@ -75,6 +79,10 @@ export function useDataTable(
|
||||
...extraParams,
|
||||
}
|
||||
|
||||
for (const key of columnFilterKeys) {
|
||||
paramDefs[`f.${key}`] = { default: '', debounce: 300 }
|
||||
}
|
||||
|
||||
const state = useUrlState(paramDefs, {
|
||||
onRestore: () => deps.fetchData(),
|
||||
})
|
||||
@@ -88,6 +96,10 @@ export function useDataTable(
|
||||
for (const key of Object.keys(extraParams)) {
|
||||
filters[key] = (state as Record<string, Ref<string | number>>)[key]!
|
||||
}
|
||||
|
||||
for (const key of columnFilterKeys) {
|
||||
columnFilterRefs[key] = (state as Record<string, Ref<string>>)[`f.${key}`]!
|
||||
}
|
||||
}
|
||||
else {
|
||||
searchTerm = ref('')
|
||||
@@ -137,8 +149,31 @@ export function useDataTable(
|
||||
deps.fetchData()
|
||||
}
|
||||
|
||||
// Column filters
|
||||
const columnFilters = ref<DataTableColumnFilters>({})
|
||||
// Column filters — seed from URL-persisted refs
|
||||
const initialColumnFilters: DataTableColumnFilters = {}
|
||||
for (const [key, r] of Object.entries(columnFilterRefs)) {
|
||||
if (r.value) initialColumnFilters[key] = r.value
|
||||
}
|
||||
const columnFilters = ref<DataTableColumnFilters>(initialColumnFilters)
|
||||
|
||||
// Sync columnFilters → URL refs
|
||||
if (persistToUrl && columnFilterKeys.length > 0) {
|
||||
watch(columnFilters, (val) => {
|
||||
for (const key of columnFilterKeys) {
|
||||
columnFilterRefs[key]!.value = val[key] || ''
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// Sync URL refs → columnFilters (back/forward navigation)
|
||||
for (const key of columnFilterKeys) {
|
||||
watch(columnFilterRefs[key]!, (urlVal) => {
|
||||
const current = columnFilters.value[key] || ''
|
||||
if (current !== urlVal) {
|
||||
columnFilters.value = { ...columnFilters.value, [key]: urlVal }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleColumnFiltersChange = (newFilters: DataTableColumnFilters) => {
|
||||
columnFilters.value = newFilters
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface Document {
|
||||
size: number
|
||||
fileUrl: string
|
||||
downloadUrl: string
|
||||
type?: string
|
||||
/** @deprecated Legacy Base64 data URI — use fileUrl instead */
|
||||
path?: string
|
||||
createdAt?: string
|
||||
@@ -32,6 +33,7 @@ export interface UploadContext {
|
||||
composantId?: string
|
||||
productId?: string
|
||||
pieceId?: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
export interface DocumentResult {
|
||||
@@ -47,6 +49,7 @@ interface LoadDocumentsOptions {
|
||||
orderBy?: string
|
||||
orderDir?: 'asc' | 'desc'
|
||||
attachmentFilter?: string
|
||||
type?: string
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
@@ -63,7 +66,7 @@ const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
||||
}
|
||||
|
||||
export function useDocuments() {
|
||||
const { get, postFormData, delete: del } = useApi()
|
||||
const { get, patch, postFormData, delete: del } = useApi()
|
||||
const { showError, showSuccess } = useToast()
|
||||
|
||||
const loadFromEndpoint = async (
|
||||
@@ -103,10 +106,11 @@ export function useDocuments() {
|
||||
orderBy = 'createdAt',
|
||||
orderDir = 'desc',
|
||||
attachmentFilter = 'all',
|
||||
type = 'all',
|
||||
force = false,
|
||||
} = 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 }
|
||||
}
|
||||
|
||||
@@ -128,6 +132,10 @@ export function useDocuments() {
|
||||
params.set(`exists[${attachmentFilter}]`, 'true')
|
||||
}
|
||||
|
||||
if (type && type !== 'all') {
|
||||
params.set('type', type)
|
||||
}
|
||||
|
||||
params.set(`order[${orderBy}]`, orderDir)
|
||||
|
||||
const result = await get(`/documents?${params.toString()}`)
|
||||
@@ -218,6 +226,7 @@ export function useDocuments() {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('name', file.name)
|
||||
if (context.type) formData.append('type', context.type)
|
||||
|
||||
if (context.siteId) formData.append('siteId', context.siteId)
|
||||
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 {
|
||||
documents,
|
||||
total,
|
||||
@@ -292,6 +328,7 @@ export function useDocuments() {
|
||||
loadDocumentsByPiece,
|
||||
loadDocumentsByProduct,
|
||||
uploadDocuments,
|
||||
updateDocument,
|
||||
deleteDocument,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface EntityDocumentsDeps {
|
||||
|
||||
export function useEntityDocuments(deps: EntityDocumentsDeps) {
|
||||
const { entity, entityType } = deps
|
||||
const { uploadDocuments, deleteDocument } = useDocuments()
|
||||
const { uploadDocuments, deleteDocument, updateDocument } = useDocuments()
|
||||
|
||||
const loadDocumentsFn = entityType === 'composant'
|
||||
? 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 {
|
||||
documents,
|
||||
selectedFiles,
|
||||
@@ -118,5 +131,6 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
|
||||
ensureDocumentsLoaded,
|
||||
handleFilesAdded,
|
||||
removeDocument,
|
||||
editDocument,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export type EntityHistoryEntry = {
|
||||
}
|
||||
|
||||
const ENTITY_ENDPOINTS: Record<string, string> = {
|
||||
machine: '/machines',
|
||||
composant: '/composants',
|
||||
piece: '/pieces',
|
||||
product: '/products',
|
||||
@@ -35,7 +36,7 @@ const extractItems = (payload: any): EntityHistoryEntry[] => {
|
||||
return []
|
||||
}
|
||||
|
||||
export function useEntityHistory(entityType: 'composant' | 'piece' | 'product') {
|
||||
export function useEntityHistory(entityType: 'machine' | 'composant' | 'piece' | 'product') {
|
||||
const { get } = useApi()
|
||||
const basePath = ENTITY_ENDPOINTS[entityType]
|
||||
|
||||
|
||||
@@ -127,8 +127,8 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
||||
showSuccess(`Type de ${label} "${data.name}" créé`)
|
||||
return { success: true, data: normalized }
|
||||
} catch (error) {
|
||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||
const raw = err?.data?.message || err?.message
|
||||
const err = error as Error & { data?: { error?: string; message?: string }; message?: string }
|
||||
const raw = err?.data?.error || err?.data?.message || err?.message
|
||||
const message = humanizeError(raw)
|
||||
showError(`Impossible de créer le type de ${label} : ${message}`)
|
||||
return { success: false, error: message }
|
||||
@@ -153,8 +153,8 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
||||
showSuccess(`Type de ${label} "${data.name}" mis à jour`)
|
||||
return { success: true, data: normalized }
|
||||
} catch (error) {
|
||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||
const raw = err?.data?.message || err?.message
|
||||
const err = error as Error & { data?: { error?: string; message?: string }; message?: string }
|
||||
const raw = err?.data?.error || err?.data?.message || err?.message
|
||||
const message = humanizeError(raw)
|
||||
showError(`Impossible de mettre à jour le type de ${label} : ${message}`)
|
||||
return { success: false, error: message }
|
||||
@@ -171,8 +171,8 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
||||
showSuccess(`Type de ${label} supprimé`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||
const raw = err?.data?.message || err?.message
|
||||
const err = error as Error & { data?: { error?: string; message?: string }; message?: string }
|
||||
const raw = err?.data?.error || err?.data?.message || err?.message
|
||||
const message = humanizeError(raw)
|
||||
showError(`Impossible de supprimer le type de ${label} : ${message}`)
|
||||
return { success: false, error: message }
|
||||
|
||||
98
app/composables/useEntityVersions.ts
Normal file
98
app/composables/useEntityVersions.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { ref, toValue } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import type { MaybeRef } from 'vue'
|
||||
|
||||
export interface VersionEntry {
|
||||
version: number
|
||||
action: 'create' | 'update' | 'restore' | string
|
||||
createdAt: string
|
||||
actor: { id: string; label: string } | null
|
||||
diff: Record<string, { from: unknown; to: unknown }> | null
|
||||
}
|
||||
|
||||
export interface RestorePreview {
|
||||
version: number
|
||||
restoreMode: 'full' | 'partial'
|
||||
diff: Record<string, { current: unknown; restored: unknown }>
|
||||
warnings: Array<{
|
||||
field: string
|
||||
message: string
|
||||
missingEntityId: string | null
|
||||
missingEntityName: string | null
|
||||
}>
|
||||
snapshot: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface RestoreResult {
|
||||
success: boolean
|
||||
newVersion: number
|
||||
restoredFromVersion: number
|
||||
restoreMode: 'full' | 'partial'
|
||||
warnings: RestorePreview['warnings']
|
||||
}
|
||||
|
||||
const ENTITY_ENDPOINTS: Record<string, string> = {
|
||||
machine: '/machines',
|
||||
composant: '/composants',
|
||||
piece: '/pieces',
|
||||
product: '/products',
|
||||
}
|
||||
|
||||
interface Deps {
|
||||
entityType: MaybeRef<string>
|
||||
entityId: MaybeRef<string>
|
||||
}
|
||||
|
||||
export function useEntityVersions(deps: Deps) {
|
||||
const { get, post } = useApi()
|
||||
|
||||
const versions = ref<VersionEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const getPath = () => {
|
||||
const type = toValue(deps.entityType)
|
||||
const id = toValue(deps.entityId)
|
||||
const base = ENTITY_ENDPOINTS[type]
|
||||
return `${base}/${id}`
|
||||
}
|
||||
|
||||
const fetchVersions = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await get(`${getPath()}/versions`)
|
||||
if (!result.success) {
|
||||
error.value = result.error ?? 'Impossible de charger les versions.'
|
||||
versions.value = []
|
||||
return
|
||||
}
|
||||
versions.value = result.data?.items ?? []
|
||||
}
|
||||
catch (err: any) {
|
||||
error.value = err?.message ?? 'Erreur inconnue'
|
||||
versions.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPreview = async (version: number): Promise<RestorePreview | null> => {
|
||||
const result = await get<RestorePreview>(`${getPath()}/versions/${version}/preview`)
|
||||
if (!result.success || !result.data) {
|
||||
return null
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
const restore = async (version: number): Promise<RestoreResult | null> => {
|
||||
const result = await post<RestoreResult>(`${getPath()}/versions/${version}/restore`, {})
|
||||
if (!result.success || !result.data) {
|
||||
return null
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
return { versions, loading, error, fetchVersions, fetchPreview, restore }
|
||||
}
|
||||
@@ -376,6 +376,58 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
|
||||
}
|
||||
}
|
||||
|
||||
const saveAllMachineCustomFields = async () => {
|
||||
if (!machine.value) return
|
||||
|
||||
const fields = Array.isArray(machineCustomFields.value) ? machineCustomFields.value : []
|
||||
const fieldsToSave = fields.filter(
|
||||
(field) => field.value !== undefined && field.value !== null && String(field.value).trim() !== '',
|
||||
)
|
||||
|
||||
for (const field of fieldsToSave) {
|
||||
const { id: customFieldId, customFieldValueId } = field
|
||||
|
||||
try {
|
||||
if (customFieldValueId) {
|
||||
await updateCustomFieldValueApi(customFieldValueId as string, {
|
||||
value: field.value ?? '',
|
||||
} as any)
|
||||
} else if (customFieldId) {
|
||||
const result: any = await upsertCustomFieldValue(
|
||||
customFieldId as string,
|
||||
'machine',
|
||||
machine.value.id as string,
|
||||
field.value ?? '',
|
||||
)
|
||||
if (result.success) {
|
||||
const createdValue = result.data as AnyRecord
|
||||
if (createdValue?.id) {
|
||||
field.customFieldValueId = createdValue.id
|
||||
if (!createdValue.customField) {
|
||||
createdValue.customField = {
|
||||
id: customFieldId,
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
options: field.options,
|
||||
}
|
||||
}
|
||||
const existingValues = Array.isArray(machine.value.customFieldValues)
|
||||
? (machine.value.customFieldValues as AnyRecord[]).filter(
|
||||
(item) => item.id !== createdValue.id,
|
||||
)
|
||||
: []
|
||||
machine.value.customFieldValues = [...existingValues, createdValue]
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sauvegarde du champ personnalisé:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
machineCustomFields,
|
||||
@@ -392,5 +444,6 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
|
||||
setMachineCustomFieldValue,
|
||||
updateMachineCustomField,
|
||||
updatePieceCustomField,
|
||||
saveAllMachineCustomFields,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,11 @@ import {
|
||||
resolveConstructeurs,
|
||||
uniqueConstructeurIds,
|
||||
formatConstructeurContact as formatConstructeurContactSummary,
|
||||
parseConstructeurLinksFromApi,
|
||||
constructeurIdsFromLinks,
|
||||
} from '~/shared/constructeurUtils'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||
import { useMachineDetailDocuments } from '~/composables/useMachineDetailDocuments'
|
||||
import { useMachineDetailCustomFields } from '~/composables/useMachineDetailCustomFields'
|
||||
import { useMachineDetailHierarchy } from '~/composables/useMachineDetailHierarchy'
|
||||
@@ -62,6 +66,12 @@ export function useMachineDetailData(machineId: string) {
|
||||
const machine = ref<AnyRecord | null>(null)
|
||||
const productDocumentsMap = ref<Map<string, AnyRecord[]>>(new Map())
|
||||
const printAreaRef = ref<HTMLElement | null>(null)
|
||||
const saving = ref(false)
|
||||
|
||||
// Constructeur links
|
||||
const { fetchLinks, syncLinks } = useConstructeurLinks()
|
||||
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
|
||||
// Machine fields
|
||||
const machineName = ref('')
|
||||
@@ -77,20 +87,15 @@ export function useMachineDetailData(machineId: string) {
|
||||
})
|
||||
|
||||
const machineConstructeursDisplay = computed(() => {
|
||||
const ids = uniqueConstructeurIds(
|
||||
machineConstructeurIds.value,
|
||||
(machine.value as AnyRecord)?.constructeurIds,
|
||||
(machine.value as AnyRecord)?.constructeurs,
|
||||
(machine.value as AnyRecord)?.constructeur,
|
||||
)
|
||||
const ids = machineConstructeurIds.value
|
||||
if (!ids.length) return [] as any[]
|
||||
// Extract nested constructeur objects from link entries as candidate pool
|
||||
const linkConstructeurs = constructeurLinks.value
|
||||
.filter(l => l.constructeur && l.constructeur.id)
|
||||
.map(l => l.constructeur!) as any[]
|
||||
return resolveConstructeurs(
|
||||
ids,
|
||||
Array.isArray((machine.value as AnyRecord)?.constructeurs)
|
||||
? ((machine.value as AnyRecord).constructeurs as any[])
|
||||
: [],
|
||||
(machine.value as AnyRecord)?.constructeur
|
||||
? [(machine.value as AnyRecord).constructeur as any]
|
||||
: [],
|
||||
linkConstructeurs,
|
||||
constructeurs.value as any,
|
||||
) as any[]
|
||||
})
|
||||
@@ -108,6 +113,12 @@ export function useMachineDetailData(machineId: string) {
|
||||
|
||||
// UI state
|
||||
const isEditMode = ref(false)
|
||||
const canSubmit = computed(() => {
|
||||
if (!machine.value) return false
|
||||
if (saving.value) return false
|
||||
if (!machineName.value.trim()) return false
|
||||
return true
|
||||
})
|
||||
const debug = ref(false)
|
||||
|
||||
const componentsCollapsed = ref(true)
|
||||
@@ -146,6 +157,7 @@ export function useMachineDetailData(machineId: string) {
|
||||
setMachineCustomFieldValue,
|
||||
updateMachineCustomField,
|
||||
updatePieceCustomField,
|
||||
saveAllMachineCustomFields,
|
||||
} = useMachineDetailCustomFields({
|
||||
machine,
|
||||
isEditMode,
|
||||
@@ -227,11 +239,12 @@ export function useMachineDetailData(machineId: string) {
|
||||
if (machine.value) {
|
||||
machineName.value = (machine.value.name as string) || ''
|
||||
machineReference.value = (machine.value.reference as string) || ''
|
||||
machineConstructeurIds.value = uniqueConstructeurIds(
|
||||
machine.value.constructeurIds,
|
||||
machine.value.constructeurs,
|
||||
machine.value.constructeur,
|
||||
)
|
||||
// Parse constructeur links from structure response
|
||||
const rawLinks = Array.isArray(machine.value.constructeurs) ? machine.value.constructeurs as any[] : []
|
||||
const parsed = parseConstructeurLinksFromApi(rawLinks)
|
||||
constructeurLinks.value = parsed
|
||||
originalConstructeurLinks.value = parsed.map(l => ({ ...l }))
|
||||
machineConstructeurIds.value = constructeurIdsFromLinks(parsed)
|
||||
machineSiteId.value = (machine.value.siteId as string) || (machine.value.site as AnyRecord)?.id as string || ''
|
||||
}
|
||||
}
|
||||
@@ -261,6 +274,8 @@ export function useMachineDetailData(machineId: string) {
|
||||
machineReference,
|
||||
machineSiteId,
|
||||
machineConstructeurIds,
|
||||
constructeurLinks,
|
||||
originalConstructeurLinks,
|
||||
machineDocumentsLoaded,
|
||||
machineComponentLinks,
|
||||
machinePieceLinks,
|
||||
@@ -276,6 +291,7 @@ export function useMachineDetailData(machineId: string) {
|
||||
updatePieceApi,
|
||||
apiPatch,
|
||||
toast,
|
||||
syncLinks,
|
||||
})
|
||||
|
||||
// UI methods
|
||||
@@ -302,6 +318,39 @@ export function useMachineDetailData(machineId: string) {
|
||||
pieceCollapseToggleToken.value += 1
|
||||
}
|
||||
|
||||
const submitEdition = async () => {
|
||||
if (!machine.value || saving.value) return
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
// 1. Save machine info (name, reference, site, constructeurs)
|
||||
await updateMachineInfo()
|
||||
|
||||
// 2. Save all custom field values
|
||||
await saveAllMachineCustomFields()
|
||||
|
||||
// 3. Reload machine data to get fresh state
|
||||
await loadMachineData()
|
||||
|
||||
// 4. Exit edit mode
|
||||
isEditMode.value = false
|
||||
toast.showSuccess('Machine mise à jour avec succès')
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sauvegarde:', error)
|
||||
toast.showError('Erreur lors de la sauvegarde de la machine')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cancelEdition = () => {
|
||||
initMachineFields()
|
||||
syncMachineCustomFields()
|
||||
constructeurLinks.value = originalConstructeurLinks.value.map(l => ({ ...l }))
|
||||
machineConstructeurIds.value = constructeurIdsFromLinks(constructeurLinks.value)
|
||||
isEditMode.value = false
|
||||
}
|
||||
|
||||
// Print wrappers
|
||||
const ensurePrintSelectionEntries = () =>
|
||||
_ensurePrintEntries(components.value, machinePieces.value)
|
||||
@@ -428,6 +477,7 @@ export function useMachineDetailData(machineId: string) {
|
||||
// Machine fields
|
||||
machineName, machineReference, machineSiteId, machineConstructeurIds, machineConstructeurId,
|
||||
machineConstructeursDisplay, machineConstructeurContact, hasMachineConstructeur,
|
||||
constructeurLinks, originalConstructeurLinks,
|
||||
sites,
|
||||
|
||||
// UI state
|
||||
@@ -451,6 +501,7 @@ export function useMachineDetailData(machineId: string) {
|
||||
updateMachineInfo, updateComponent, updatePieceFromComponent,
|
||||
updatePieceInfo, handleMachineConstructeurChange, editComponent, editPiece,
|
||||
toggleEditMode, toggleAllComponents, collapseAllComponents, toggleAllPieces,
|
||||
saving, canSubmit, submitEdition, cancelEdition,
|
||||
|
||||
// Print
|
||||
printModalOpen, printSelection, ensurePrintSelectionEntries,
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
*/
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
|
||||
type AnyRecord = Record<string, unknown>
|
||||
|
||||
@@ -15,6 +16,8 @@ export interface UseMachineDetailUpdatesDeps {
|
||||
machineReference: Ref<string>
|
||||
machineSiteId: Ref<string>
|
||||
machineConstructeurIds: Ref<string[]>
|
||||
constructeurLinks: Ref<ConstructeurLinkEntry[]>
|
||||
originalConstructeurLinks: Ref<ConstructeurLinkEntry[]>
|
||||
machineDocumentsLoaded: Ref<boolean>
|
||||
machineComponentLinks: Ref<AnyRecord[]>
|
||||
machinePieceLinks: Ref<AnyRecord[]>
|
||||
@@ -35,6 +38,12 @@ export interface UseMachineDetailUpdatesDeps {
|
||||
updatePieceApi: (id: string, data: any) => Promise<unknown>
|
||||
apiPatch: (endpoint: string, data?: unknown) => Promise<any>
|
||||
toast: { showInfo: (msg: string) => void }
|
||||
syncLinks: (
|
||||
entityType: 'machine' | 'piece' | 'composant' | 'product',
|
||||
entityId: string,
|
||||
originalLinks: ConstructeurLinkEntry[],
|
||||
formLinks: ConstructeurLinkEntry[],
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
|
||||
@@ -44,6 +53,8 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
|
||||
machineReference,
|
||||
machineSiteId,
|
||||
machineConstructeurIds,
|
||||
constructeurLinks,
|
||||
originalConstructeurLinks,
|
||||
machineComponentLinks,
|
||||
machinePieceLinks,
|
||||
applyMachineLinks,
|
||||
@@ -56,19 +67,16 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
|
||||
updatePieceApi,
|
||||
apiPatch,
|
||||
toast,
|
||||
syncLinks,
|
||||
} = deps
|
||||
|
||||
const updateMachineInfo = async () => {
|
||||
if (!machine.value) return
|
||||
try {
|
||||
const cIds = uniqueConstructeurIds(machineConstructeurIds.value)
|
||||
machineConstructeurIds.value = cIds
|
||||
|
||||
const result: any = await updateMachineApi(machine.value.id as string, {
|
||||
name: machineName.value,
|
||||
reference: machineReference.value,
|
||||
siteId: machineSiteId.value || undefined,
|
||||
constructeurIds: cIds,
|
||||
} as any)
|
||||
if (result.success) {
|
||||
const machinePayload =
|
||||
@@ -82,11 +90,6 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
|
||||
documents: machinePayload.documents || machine.value.documents || [],
|
||||
customFieldValues: machinePayload.customFieldValues || machine.value.customFieldValues || [],
|
||||
}
|
||||
machineConstructeurIds.value = uniqueConstructeurIds(
|
||||
machine.value!.constructeurIds,
|
||||
machine.value!.constructeurs,
|
||||
machine.value!.constructeur,
|
||||
)
|
||||
const linksApplied = applyMachineLinks(result.data)
|
||||
if (linksApplied && machine.value) {
|
||||
machine.value.componentLinks = machineComponentLinks.value
|
||||
@@ -95,6 +98,9 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
|
||||
loadProductDocuments().catch(() => {})
|
||||
}
|
||||
}
|
||||
// Sync constructeur links after entity save
|
||||
await syncLinks('machine', machine.value!.id as string, originalConstructeurLinks.value, constructeurLinks.value)
|
||||
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la mise à jour de la machine:', error)
|
||||
}
|
||||
@@ -208,9 +214,14 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleMachineConstructeurChange = async (value: unknown) => {
|
||||
machineConstructeurIds.value = uniqueConstructeurIds(value)
|
||||
await updateMachineInfo()
|
||||
const handleMachineConstructeurChange = (value: unknown) => {
|
||||
const newIds = uniqueConstructeurIds(value)
|
||||
machineConstructeurIds.value = newIds
|
||||
// Sync constructeurLinks: keep existing entries, add new ones
|
||||
const existingMap = new Map(constructeurLinks.value.map(l => [l.constructeurId, l]))
|
||||
constructeurLinks.value = newIds.map(id =>
|
||||
existingMap.get(id) ?? { constructeurId: id, supplierReference: null },
|
||||
)
|
||||
}
|
||||
|
||||
const editComponent = () => {
|
||||
|
||||
@@ -273,6 +273,7 @@ export const buildMachineHierarchyFromLinks = (
|
||||
originalComposant: originalComponent,
|
||||
machineComponentLink: link,
|
||||
machineComponentLinkId,
|
||||
linkId: machineComponentLinkId,
|
||||
componentLinkId: machineComponentLinkId,
|
||||
parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedComponent.parentComponentLinkId),
|
||||
parentComposantId: resolveIdentifier(appliedComponent.parentComposantId, link.parentComponentId),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi, type ApiResponse } from './useApi'
|
||||
import { buildConstructeurRequestPayload } from '~/shared/constructeurUtils'
|
||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
@@ -92,7 +91,7 @@ export function useMachines() {
|
||||
const createMachine = async (machineData: Partial<Machine>): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(machineData))
|
||||
const normalizedPayload = normalizeRelationIds(machineData as Record<string, unknown>)
|
||||
const result = await post('/machines', normalizedPayload)
|
||||
if (result.success) {
|
||||
const createdMachine = normalizeMachineResponse(result.data) ||
|
||||
@@ -116,7 +115,7 @@ export function useMachines() {
|
||||
const updateMachineData = async (id: string, machineData: Partial<Machine>): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(machineData))
|
||||
const normalizedPayload = normalizeRelationIds(machineData as Record<string, unknown>)
|
||||
const result = await patch(`/machines/${id}`, normalizedPayload)
|
||||
if (result.success) {
|
||||
const updatedMachine = normalizeMachineResponse(result.data) ||
|
||||
|
||||
@@ -7,11 +7,13 @@ import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||
import { usePieceHistory } from '~/composables/usePieceHistory'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
import type { PieceModelStructure } from '~/shared/types/inventory'
|
||||
import type { ModelType } from '~/services/modelTypes'
|
||||
import {
|
||||
@@ -46,6 +48,7 @@ export function usePieceEdit(pieceId: string) {
|
||||
const toast = useToast()
|
||||
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
const { fetchLinks, syncLinks } = useConstructeurLinks()
|
||||
const {
|
||||
history,
|
||||
loading: historyLoading,
|
||||
@@ -82,6 +85,9 @@ export function usePieceEdit(pieceId: string) {
|
||||
constructeurIds: [] as string[],
|
||||
prix: '' as string,
|
||||
})
|
||||
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
|
||||
const productSelections = ref<(string | null)[]>([])
|
||||
|
||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||
@@ -303,15 +309,16 @@ export function usePieceEdit(pieceId: string) {
|
||||
editionForm.name = currentPiece.name || ''
|
||||
editionForm.description = currentPiece.description || ''
|
||||
editionForm.reference = currentPiece.reference || ''
|
||||
editionForm.constructeurIds = uniqueConstructeurIds(
|
||||
currentPiece,
|
||||
Array.isArray(currentPiece.constructeurs) ? currentPiece.constructeurs : [],
|
||||
currentPiece.constructeur ? [currentPiece.constructeur] : [],
|
||||
)
|
||||
editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : ''
|
||||
// Load constructeur links
|
||||
fetchLinks('piece', pieceId).then((links) => {
|
||||
constructeurLinks.value = links
|
||||
originalConstructeurLinks.value = links.map(l => ({ ...l }))
|
||||
editionForm.constructeurIds = constructeurIdsFromLinks(links)
|
||||
if (editionForm.constructeurIds.length) {
|
||||
void ensureConstructeurs(editionForm.constructeurIds)
|
||||
}
|
||||
})
|
||||
editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : ''
|
||||
|
||||
const existingProductIds = Array.isArray(currentPiece.productIds) && currentPiece.productIds.length
|
||||
? currentPiece.productIds.map((id: unknown) => String(id))
|
||||
@@ -370,12 +377,9 @@ export function usePieceEdit(pieceId: string) {
|
||||
? ''
|
||||
: String(editionForm.prix).trim()
|
||||
|
||||
const constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
name: editionForm.name.trim(),
|
||||
description: editionForm.description.trim() || null,
|
||||
constructeurIds,
|
||||
}
|
||||
|
||||
const reference = editionForm.reference.trim()
|
||||
@@ -408,11 +412,13 @@ export function usePieceEdit(pieceId: string) {
|
||||
'piece',
|
||||
updatedPiece.id,
|
||||
[
|
||||
updatedPiece?.typePiece?.pieceCustomFields,
|
||||
updatedPiece?.typePiece?.structure?.customFields,
|
||||
],
|
||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||
)
|
||||
await router.push('/pieces-catalog')
|
||||
await syncLinks('piece', piece.value.id, originalConstructeurLinks.value, constructeurLinks.value)
|
||||
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
|
||||
toast.showSuccess('Pièce mise à jour avec succès.')
|
||||
}
|
||||
}
|
||||
catch (error: any) {
|
||||
@@ -441,6 +447,9 @@ export function usePieceEdit(pieceId: string) {
|
||||
previewVisible,
|
||||
selectedTypeId,
|
||||
editionForm,
|
||||
constructeurLinks,
|
||||
originalConstructeurLinks,
|
||||
constructeurIdsFromForm,
|
||||
productSelections,
|
||||
customFieldInputs,
|
||||
canEdit,
|
||||
@@ -467,6 +476,7 @@ export function usePieceEdit(pieceId: string) {
|
||||
handleFilesAdded,
|
||||
setProductSelection,
|
||||
submitEdition,
|
||||
fetchPiece,
|
||||
formatPieceStructurePreview,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,12 @@ const toEditorField = (
|
||||
type: baseType as PieceModelCustomFieldType,
|
||||
required: Boolean(input?.required),
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -158,6 +164,16 @@ const buildPayload = (
|
||||
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') {
|
||||
const options = normalizeLineEndings(field.optionsText)
|
||||
.split('\n')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { useConstructeurs, type Constructeur } from './useConstructeurs'
|
||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
@@ -10,6 +10,7 @@ export interface Piece {
|
||||
id: string
|
||||
name: string
|
||||
reference?: string | null
|
||||
referenceAuto?: string | null
|
||||
description?: string | null
|
||||
typePieceId?: string | null
|
||||
typePiece?: { id: string; name?: string } | null
|
||||
@@ -43,6 +44,7 @@ interface LoadPiecesOptions {
|
||||
orderBy?: string
|
||||
orderDir?: 'asc' | 'desc'
|
||||
typeName?: string
|
||||
typePieceId?: string
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
@@ -119,17 +121,20 @@ export function usePieces() {
|
||||
orderBy = 'name',
|
||||
orderDir = 'asc',
|
||||
typeName,
|
||||
typePieceId,
|
||||
force = false,
|
||||
} = 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 {
|
||||
success: true,
|
||||
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 {
|
||||
success: true,
|
||||
data: { items: pieces.value, total: total.value, page, itemsPerPage },
|
||||
@@ -138,33 +143,42 @@ export function usePieces() {
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', String(itemsPerPage))
|
||||
params.set('page', String(page))
|
||||
|
||||
if (search && search.trim()) {
|
||||
params.set('name', search.trim())
|
||||
params.set('search', search.trim())
|
||||
}
|
||||
|
||||
if (typeName && typeName.trim()) {
|
||||
params.set('typePiece.name', typeName.trim())
|
||||
}
|
||||
|
||||
if (typePieceId) {
|
||||
params.set('typePiece', typePieceId)
|
||||
}
|
||||
|
||||
params.set(`order[${orderBy}]`, orderDir)
|
||||
|
||||
const result = await get(`/pieces?${params.toString()}`)
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||
const resultTotal = extractTotal(result.data, items.length)
|
||||
|
||||
// Only update global cache for unfiltered queries
|
||||
if (!typePieceId) {
|
||||
pieces.value = enrichedItems
|
||||
total.value = extractTotal(result.data, items.length)
|
||||
total.value = resultTotal
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: enrichedItems,
|
||||
total: total.value,
|
||||
total: resultTotal,
|
||||
page,
|
||||
itemsPerPage,
|
||||
},
|
||||
@@ -182,7 +196,8 @@ export function usePieces() {
|
||||
const createPiece = async (pieceData: Partial<Piece>): Promise<PieceSingleResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(pieceData))
|
||||
const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = pieceData as any
|
||||
const normalizedPayload = normalizeRelationIds(cleanPayload)
|
||||
const result = await post('/pieces', normalizedPayload)
|
||||
if (result.success && result.data) {
|
||||
const enriched = await withResolvedConstructeurs(result.data as Piece)
|
||||
@@ -209,7 +224,8 @@ export function usePieces() {
|
||||
const updatePieceData = async (id: string, pieceData: Partial<Piece>): Promise<PieceSingleResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(pieceData))
|
||||
const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = pieceData as any
|
||||
const normalizedPayload = normalizeRelationIds(cleanPayload)
|
||||
const result = await patch(`/pieces/${id}`, normalizedPayload)
|
||||
if (result.success && result.data) {
|
||||
const updated = await withResolvedConstructeurs(result.data as Piece)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { useConstructeurs, type Constructeur } from './useConstructeurs'
|
||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
@@ -41,6 +41,7 @@ interface LoadProductsOptions {
|
||||
orderBy?: string
|
||||
orderDir?: 'asc' | 'desc'
|
||||
typeName?: string
|
||||
typeProductId?: string
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
@@ -118,17 +119,18 @@ export function useProducts() {
|
||||
orderBy = 'name',
|
||||
orderDir = 'asc',
|
||||
typeName,
|
||||
typeProductId,
|
||||
force = false,
|
||||
} = options
|
||||
|
||||
if (!force && loaded.value && !search && !typeName && page === 1) {
|
||||
if (!force && loaded.value && !search && !typeName && !typeProductId && page === 1) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: products.value, total: total.value, page, itemsPerPage },
|
||||
}
|
||||
}
|
||||
|
||||
if (loading.value) {
|
||||
if (!typeProductId && loading.value) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: products.value, total: total.value, page, itemsPerPage },
|
||||
@@ -143,27 +145,36 @@ export function useProducts() {
|
||||
params.set('page', String(page))
|
||||
|
||||
if (search && search.trim()) {
|
||||
params.set('name', search.trim())
|
||||
params.set('search', search.trim())
|
||||
}
|
||||
|
||||
if (typeName && typeName.trim()) {
|
||||
params.set('typeProduct.name', typeName.trim())
|
||||
}
|
||||
|
||||
if (typeProductId) {
|
||||
params.set('typeProduct', typeProductId)
|
||||
}
|
||||
|
||||
params.set(`order[${orderBy}]`, orderDir)
|
||||
|
||||
const result = await get(`/products?${params.toString()}`)
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||
const resultTotal = extractTotal(result.data, items.length)
|
||||
|
||||
if (!typeProductId) {
|
||||
products.value = enrichedItems
|
||||
total.value = extractTotal(result.data, items.length)
|
||||
total.value = resultTotal
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: enrichedItems,
|
||||
total: total.value,
|
||||
total: resultTotal,
|
||||
page,
|
||||
itemsPerPage,
|
||||
},
|
||||
@@ -185,7 +196,8 @@ export function useProducts() {
|
||||
}
|
||||
|
||||
const createProduct = async (payload: Partial<Product>): Promise<ProductSingleResult> => {
|
||||
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(payload))
|
||||
const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = payload as any
|
||||
const normalizedPayload = normalizeRelationIds(cleanPayload)
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
@@ -214,7 +226,8 @@ export function useProducts() {
|
||||
}
|
||||
|
||||
const updateProduct = async (id: string, payload: Partial<Product>): Promise<ProductSingleResult> => {
|
||||
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(payload))
|
||||
const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = payload as any
|
||||
const normalizedPayload = normalizeRelationIds(cleanPayload)
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
|
||||
@@ -105,9 +105,9 @@ export function useStructureAssignmentFetch(deps: StructureAssignmentFetchDeps)
|
||||
definition.typeComposantId || definition.modelId || definition.typeComposant?.id || null
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', '50')
|
||||
params.set('itemsPerPage', '200')
|
||||
if (term.trim()) {
|
||||
params.set('name', term.trim())
|
||||
params.set('search', term.trim())
|
||||
}
|
||||
if (requiredTypeId) {
|
||||
params.set('typeComposant', typeIri(requiredTypeId))
|
||||
@@ -173,9 +173,9 @@ export function useStructureAssignmentFetch(deps: StructureAssignmentFetchDeps)
|
||||
definition.typePieceId || definition.typePiece?.id || null
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', '50')
|
||||
params.set('itemsPerPage', '200')
|
||||
if (term.trim()) {
|
||||
params.set('name', term.trim())
|
||||
params.set('search', term.trim())
|
||||
}
|
||||
if (requiredTypeId) {
|
||||
params.set('typePiece', typeIri(requiredTypeId))
|
||||
@@ -246,9 +246,9 @@ export function useStructureAssignmentFetch(deps: StructureAssignmentFetchDeps)
|
||||
definition.typeProductId || definition.typeProduct?.id || null
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', '50')
|
||||
params.set('itemsPerPage', '200')
|
||||
if (term.trim()) {
|
||||
params.set('name', term.trim())
|
||||
params.set('search', term.trim())
|
||||
}
|
||||
if (requiredTypeId) {
|
||||
params.set('typeProduct', typeIri(requiredTypeId))
|
||||
@@ -279,6 +279,11 @@ export function useStructureAssignmentFetch(deps: StructureAssignmentFetchDeps)
|
||||
if (deps.isRoot()) {
|
||||
return
|
||||
}
|
||||
// Only clear if we have loaded options (cache or catalog); skip when options are empty
|
||||
// because the fetch may not have completed yet.
|
||||
if (!options.length) {
|
||||
return
|
||||
}
|
||||
const hasMatch = options.some(
|
||||
(component) => component.id === deps.assignment.selectedComponentId,
|
||||
)
|
||||
@@ -293,6 +298,11 @@ export function useStructureAssignmentFetch(deps: StructureAssignmentFetchDeps)
|
||||
() => [deps.pieces, deps.assignment.pieces],
|
||||
() => {
|
||||
for (const pieceAssignment of deps.assignment.pieces) {
|
||||
const hasCachedOptions = !!pieceOptionsByPath.value[pieceAssignment.path]
|
||||
// Only clear selections when we have loaded options (cached or from catalog).
|
||||
// When no cache exists, a fetch is about to fire — clearing now would lose
|
||||
// user input before the real option list arrives.
|
||||
if (hasCachedOptions) {
|
||||
const options = getPieceOptions(pieceAssignment)
|
||||
if (
|
||||
pieceAssignment.selectedPieceId
|
||||
@@ -300,6 +310,7 @@ export function useStructureAssignmentFetch(deps: StructureAssignmentFetchDeps)
|
||||
) {
|
||||
pieceAssignment.selectedPieceId = ''
|
||||
}
|
||||
}
|
||||
if (!primedPiecePaths.has(pieceAssignment.path) && !pieceOptionsByPath.value[pieceAssignment.path]) {
|
||||
primedPiecePaths.add(pieceAssignment.path)
|
||||
fetchPieceOptions(pieceAssignment).catch(() => {})
|
||||
@@ -313,6 +324,8 @@ export function useStructureAssignmentFetch(deps: StructureAssignmentFetchDeps)
|
||||
() => [deps.products, deps.assignment.products],
|
||||
() => {
|
||||
for (const productAssignment of deps.assignment.products) {
|
||||
const hasCachedOptions = !!productOptionsByPath.value[productAssignment.path]
|
||||
if (hasCachedOptions) {
|
||||
const options = getProductOptions(productAssignment)
|
||||
if (
|
||||
productAssignment.selectedProductId
|
||||
@@ -320,6 +333,7 @@ export function useStructureAssignmentFetch(deps: StructureAssignmentFetchDeps)
|
||||
) {
|
||||
productAssignment.selectedProductId = ''
|
||||
}
|
||||
}
|
||||
if (!primedProductPaths.has(productAssignment.path) && !productOptionsByPath.value[productAssignment.path]) {
|
||||
primedProductPaths.add(productAssignment.path)
|
||||
fetchProductOptions(productAssignment).catch(() => {})
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
<header class="space-y-2">
|
||||
<h1 class="text-3xl font-bold text-base-content">Changelog</h1>
|
||||
<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
|
||||
l'application.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -17,7 +18,9 @@
|
||||
<h2 class="text-xl font-bold text-base-content">
|
||||
{{ release.version }}
|
||||
</h2>
|
||||
<span class="badge badge-ghost text-xs">{{ release.date }}</span>
|
||||
<span class="badge badge-ghost text-xs">{{
|
||||
release.date
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-2">
|
||||
@@ -41,220 +44,651 @@
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
type: ChangeType
|
||||
text: string
|
||||
type: ChangeType;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface Release {
|
||||
version: string
|
||||
date: string
|
||||
changes: Change[]
|
||||
version: string;
|
||||
date: string;
|
||||
changes: Change[];
|
||||
}
|
||||
|
||||
const badgeClass = (type: ChangeType) => {
|
||||
const map: Record<ChangeType, string> = {
|
||||
feat: 'badge-primary',
|
||||
fix: 'badge-error',
|
||||
perf: 'badge-warning',
|
||||
chore: 'badge-ghost',
|
||||
}
|
||||
return map[type] ?? 'badge-ghost'
|
||||
}
|
||||
feat: "badge-primary",
|
||||
fix: "badge-error",
|
||||
perf: "badge-warning",
|
||||
chore: "badge-ghost",
|
||||
};
|
||||
return map[type] ?? "badge-ghost";
|
||||
};
|
||||
|
||||
const releases: Release[] = [
|
||||
{
|
||||
version: 'v1.9.1',
|
||||
date: '2026-03-16',
|
||||
version: "v1.9.5",
|
||||
date: "2026-03-31",
|
||||
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', 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' },
|
||||
{ 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', text: 'Suite de tests étendue : 219 tests couvrant les stratégies de synchronisation, le contrôleur de sync et les nouvelles entités' },
|
||||
{ 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: "Référence automatique des pièces et composants : génération d'une référence technique à partir d'une formule configurable sur la catégorie, recalculée automatiquement à chaque modification des champs personnalisés",
|
||||
},
|
||||
{
|
||||
type: "feat",
|
||||
text: "Formula builder interactif : sélection des champs disponibles par clic (chips) avec insertion à la position du curseur, aperçu live avec valeurs d'exemple, et calcul automatique des champs requis",
|
||||
},
|
||||
{
|
||||
type: "feat",
|
||||
text: "Versioning des entités : numéro de version incrémenté automatiquement à chaque modification, avec historique des versions et possibilité de restaurer une version antérieure",
|
||||
},
|
||||
{
|
||||
type: "feat",
|
||||
text: "Bouton de sauvegarde unique sur la fiche machine : remplacement des sauvegardes automatiques par un bouton explicite, avec affichage des versions sur les liens composants/pièces/produits",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
version: 'v1.9.0',
|
||||
date: '2026-03-09',
|
||||
version: "v1.9.4",
|
||||
date: "2026-03-25",
|
||||
changes: [
|
||||
{ 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', text: 'Endpoint /api/health pour le monitoring applicatif' },
|
||||
{ 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: 'perf', text: 'Refactoring massif du frontend : extraction de 15+ composables et composants partagés, réduction de la taille des fichiers' },
|
||||
{ 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: "Pages de consultation détaillées pour les pièces, composants et produits : vue lecture seule avec affichage propre des informations, fournisseurs, champs personnalisés et documents",
|
||||
},
|
||||
{
|
||||
type: "feat",
|
||||
text: "Bouton bascule Modifier / Voir détails sur les fiches pièces, composants et produits, identique au fonctionnement de la fiche machine",
|
||||
},
|
||||
{
|
||||
type: "feat",
|
||||
text: "Bouton « Détails » sur les catalogues pièces, composants et produits pour accéder directement à la vue consultation",
|
||||
},
|
||||
{
|
||||
type: "feat",
|
||||
text: "Masquage automatique des champs vides et de la section documents en mode consultation",
|
||||
},
|
||||
{
|
||||
type: "feat",
|
||||
text: "Accès direct au mode édition via le paramètre ?edit=true dans l'URL",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
version: 'v1.8.1',
|
||||
date: '2026-03-05',
|
||||
version: "v1.9.2",
|
||||
date: "2026-03-23",
|
||||
changes: [
|
||||
{ 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: 'feat', text: 'Filtres SearchFilter ipartial sur les noms de types de modèles et commentaires côté API' },
|
||||
{ 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', 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' },
|
||||
{
|
||||
type: "feat",
|
||||
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: "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: "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",
|
||||
},
|
||||
{
|
||||
type: "feat",
|
||||
text: "Tri alphabétique automatique des machines sur le Parc Machines",
|
||||
},
|
||||
{
|
||||
type: "feat",
|
||||
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: "feat",
|
||||
text: "Quantité sur les slots pièces : ajout d'un champ quantité éditable directement depuis la page d'édition d'un composant",
|
||||
},
|
||||
{
|
||||
type: "feat",
|
||||
text: "Lien rapide vers la catégorie depuis la page d'édition d'un composant",
|
||||
},
|
||||
{
|
||||
type: "feat",
|
||||
text: "Redirection vers la page d'édition après création d'un composant, d'une pièce ou d'un produit",
|
||||
},
|
||||
{
|
||||
type: "fix",
|
||||
text: "Correction de la suppression de fournisseurs sur les pièces, composants et produits : la suppression est maintenant persistée correctement",
|
||||
},
|
||||
{
|
||||
type: "fix",
|
||||
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: "Correction de la perte de données lors de la sauvegarde d'une catégorie (champs personnalisés et structure)",
|
||||
},
|
||||
{
|
||||
type: "fix",
|
||||
text: "Correction de la suppression de composants depuis la fiche machine (utilisation du linkId au lieu du composantId)",
|
||||
},
|
||||
{
|
||||
type: "fix",
|
||||
text: "Amélioration de l'envoi des fournisseurs en PATCH : le tableau est toujours envoyé pour éviter les pertes",
|
||||
},
|
||||
{
|
||||
type: "fix",
|
||||
text: "Filtrage serveur des options dans les sélecteurs de slots au lieu du filtrage client",
|
||||
},
|
||||
{
|
||||
type: "fix",
|
||||
text: "Page d'édition pièce : rester sur la page après sauvegarde au lieu de rediriger",
|
||||
},
|
||||
{
|
||||
type: "fix",
|
||||
text: "Messages d'erreur 409 (conflit) : extraction du champ d'erreur pour un message compréhensible",
|
||||
},
|
||||
{
|
||||
type: "perf",
|
||||
text: "Suppression des chargements catalogue redondants sur la page d'édition composant",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
version: 'v1.8.0',
|
||||
date: '2026-03-03',
|
||||
version: "v1.9.1",
|
||||
date: "2026-03-16",
|
||||
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' },
|
||||
{
|
||||
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",
|
||||
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",
|
||||
},
|
||||
{
|
||||
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",
|
||||
text: "Suite de tests étendue : 219 tests couvrant les stratégies de synchronisation, le contrôleur de sync et les nouvelles entités",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
version: 'v1.7.0',
|
||||
date: '2026-03-02',
|
||||
version: "v1.9.0",
|
||||
date: "2026-03-09",
|
||||
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' },
|
||||
{
|
||||
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",
|
||||
text: "Endpoint /api/health pour le monitoring applicatif",
|
||||
},
|
||||
{
|
||||
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: "perf",
|
||||
text: "Refactoring massif du frontend : extraction de 15+ composables et composants partagés, réduction de la taille des fichiers",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
version: 'v1.6.1',
|
||||
date: '2026-02-12',
|
||||
version: "v1.8.1",
|
||||
date: "2026-03-05",
|
||||
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' },
|
||||
{
|
||||
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: "feat",
|
||||
text: "Filtres SearchFilter ipartial sur les noms de types de modèles et commentaires côté API",
|
||||
},
|
||||
{
|
||||
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",
|
||||
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.6.0',
|
||||
date: '2026-02-12',
|
||||
version: "v1.8.0",
|
||||
date: "2026-03-03",
|
||||
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' },
|
||||
{
|
||||
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.5.0',
|
||||
date: '2026-02-11',
|
||||
version: "v1.7.0",
|
||||
date: "2026-03-02",
|
||||
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' },
|
||||
{
|
||||
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.4.0',
|
||||
date: '2026-02-04',
|
||||
version: "v1.6.1",
|
||||
date: "2026-02-12",
|
||||
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' },
|
||||
{
|
||||
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.3.0',
|
||||
date: '2026-01-28',
|
||||
version: "v1.6.0",
|
||||
date: "2026-02-12",
|
||||
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' },
|
||||
{
|
||||
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.2.0',
|
||||
date: '2026-01-21',
|
||||
version: "v1.5.0",
|
||||
date: "2026-02-11",
|
||||
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' },
|
||||
{
|
||||
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.1.1',
|
||||
date: '2026-01-14',
|
||||
version: "v1.4.0",
|
||||
date: "2026-02-04",
|
||||
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: "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.1.0',
|
||||
date: '2026-01-07',
|
||||
version: "v1.3.0",
|
||||
date: "2026-01-28",
|
||||
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' },
|
||||
{
|
||||
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.0.0',
|
||||
date: '2025-12-15',
|
||||
version: "v1.2.0",
|
||||
date: "2026-01-21",
|
||||
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' },
|
||||
{
|
||||
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>
|
||||
|
||||
@@ -73,7 +73,10 @@
|
||||
</template>
|
||||
|
||||
<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 #cell-entityType="{ row }">
|
||||
@@ -132,7 +135,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, type Ref } from '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 { useDataTable } from '~/composables/useDataTable'
|
||||
import IconLucideCheck from '~icons/lucide/check'
|
||||
@@ -148,6 +152,9 @@ const comments = ref<Comment[]>([])
|
||||
const total = ref(0)
|
||||
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(
|
||||
{ fetchData: loadComments },
|
||||
{
|
||||
@@ -159,6 +166,7 @@ const table = useDataTable(
|
||||
status: { default: 'open' },
|
||||
entityType: { default: '' },
|
||||
},
|
||||
columnFilterKeys: ['entity'],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -236,9 +244,9 @@ const handleResolve = async (commentId: string) => {
|
||||
|
||||
const ENTITY_ROUTE_MAP: Record<string, (id: string) => string> = {
|
||||
machine: (id: string) => `/machine/${id}`,
|
||||
piece: (id: string) => `/pieces/${id}/edit`,
|
||||
composant: (id: string) => `/component/${id}/edit`,
|
||||
product: (id: string) => `/product/${id}/edit`,
|
||||
piece: (id: string) => `/piece/${id}`,
|
||||
composant: (id: string) => `/component/${id}`,
|
||||
product: (id: string) => `/product/${id}`,
|
||||
piece_category: (id: string) => `/piece-category/${id}/edit`,
|
||||
component_category: (id: string) => `/component-category/${id}/edit`,
|
||||
product_category: (id: string) => `/product-category/${id}/edit`,
|
||||
|
||||
@@ -96,12 +96,14 @@
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<NuxtLink
|
||||
:to="`/component/${row.component.id}/edit`"
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
@click="navigateTo(`/component/${row.component.id}?edit=true`)"
|
||||
>
|
||||
Modifier
|
||||
</NuxtLink>
|
||||
</button>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
@@ -111,6 +113,12 @@
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
<NuxtLink
|
||||
:to="`/component/${row.component.id}`"
|
||||
class="btn btn-primary btn-xs"
|
||||
>
|
||||
Détails
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
@@ -136,7 +144,7 @@ const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||
|
||||
const table = useDataTable(
|
||||
{ fetchData: fetchComposants },
|
||||
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true },
|
||||
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typeComposant'] },
|
||||
)
|
||||
|
||||
const columns = [
|
||||
|
||||
@@ -114,6 +114,8 @@ const loadCategory = async () => {
|
||||
category: response.category,
|
||||
notes: response.notes ?? response.description ?? '',
|
||||
structure: (response.structure as ComponentModelStructure | null) ?? undefined,
|
||||
referenceFormula: response.referenceFormula ?? null,
|
||||
requiredFieldsForReference: response.requiredFieldsForReference ?? null,
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
@@ -158,7 +160,6 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
|
||||
await loadComponentTypes({ force: true })
|
||||
showSuccess('Catégorie de composant mise à jour avec succès.')
|
||||
await navigateBackToList()
|
||||
}
|
||||
} catch (error) {
|
||||
showError(normalizeError(error))
|
||||
@@ -183,7 +184,6 @@ const handleSyncConfirm = async () => {
|
||||
})
|
||||
await loadComponentTypes({ force: true })
|
||||
showSuccess('Catégorie de composant mise à jour avec succès.')
|
||||
await navigateBackToList()
|
||||
} catch (error) {
|
||||
showError(normalizeError(error))
|
||||
} finally {
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
:documents="componentDocuments"
|
||||
@close="closePreview"
|
||||
/>
|
||||
<DocumentEditModal
|
||||
:visible="editModalVisible"
|
||||
:document="editingDocument"
|
||||
@close="editModalVisible = false"
|
||||
@updated="handleDocumentUpdated"
|
||||
/>
|
||||
<main class="container mx-auto px-6 py-10">
|
||||
<div v-if="loading" class="flex flex-col items-center gap-4 py-20 text-center">
|
||||
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
|
||||
@@ -45,9 +51,10 @@
|
||||
<label class="label">
|
||||
<span class="label-text">Catégorie de composant</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
v-model="selectedTypeId"
|
||||
class="select select-bordered select-sm md:select-md"
|
||||
class="select select-bordered select-sm md:select-md flex-1"
|
||||
disabled
|
||||
>
|
||||
<option value="">Sélectionner une catégorie</option>
|
||||
@@ -59,6 +66,18 @@
|
||||
{{ 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">
|
||||
La catégorie d'origine ne peut pas être modifiée depuis cette page.
|
||||
</p>
|
||||
@@ -122,6 +141,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConstructeurLinksTable
|
||||
v-if="constructeurLinks.length"
|
||||
v-model="constructeurLinks"
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
@@ -173,13 +197,28 @@
|
||||
<label class="label">
|
||||
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
|
||||
</label>
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex-1">
|
||||
<PieceSelect
|
||||
:model-value="slot.selectedPieceId"
|
||||
:disabled="!canEdit || saving"
|
||||
:type-piece-id="slot.typePieceId"
|
||||
@update:model-value="(value) => savePieceSlotSelection(slot.slotId, value)"
|
||||
@update:model-value="(value) => setPieceSlotSelection(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) => setSlotQuantity(slot.slotId, Number((e.target as HTMLInputElement).value))"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -198,7 +237,7 @@
|
||||
:model-value="slot.selectedProductId"
|
||||
:disabled="!canEdit || saving"
|
||||
:type-product-id="slot.typeProductId"
|
||||
@update:model-value="(value) => saveProductSlotSelection(slot.slotId, value)"
|
||||
@update:model-value="(value) => setProductSlotSelection(slot.slotId, value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -219,7 +258,7 @@
|
||||
:model-value="slot.selectedComponentId"
|
||||
:disabled="!canEdit || saving"
|
||||
:type-composant-id="slot.typeComposantId"
|
||||
@update:model-value="(value) => saveSubcomponentSlotSelection(slot.slotId, value)"
|
||||
@update:model-value="(value) => setSubcomponentSlotSelection(slot.slotId, value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -266,9 +305,11 @@
|
||||
v-else
|
||||
:documents="componentDocuments"
|
||||
:can-delete="canEdit"
|
||||
:can-edit="true"
|
||||
:delete-disabled="uploadingDocuments"
|
||||
empty-text="Aucun document n'est associé à ce composant pour le moment."
|
||||
@preview="openPreview"
|
||||
@edit="openEditModal"
|
||||
@delete="removeDocument"
|
||||
/>
|
||||
</div>
|
||||
@@ -280,11 +321,19 @@
|
||||
:field-labels="historyFieldLabels"
|
||||
/>
|
||||
|
||||
<EntityVersionList
|
||||
entity-type="composant"
|
||||
:entity-id="String(route.params.id)"
|
||||
:field-labels="historyFieldLabels"
|
||||
:refresh-key="versionRefreshKey"
|
||||
@restored="fetchComponent()"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
||||
Annuler
|
||||
</NuxtLink>
|
||||
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitEdition">
|
||||
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="async () => { await submitEdition(); versionRefreshKey++ }">
|
||||
<span v-if="saving" class="loading loading-spinner loading-sm mr-2" />
|
||||
Enregistrer les modifications
|
||||
</button>
|
||||
@@ -306,10 +355,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute } from '#imports'
|
||||
import { useComponentEdit } from '~/composables/useComponentEdit'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
|
||||
const route = useRoute()
|
||||
const { updateDocument } = useDocuments()
|
||||
const { getConstructeurById } = useConstructeurs()
|
||||
const versionRefreshKey = ref(0)
|
||||
|
||||
const {
|
||||
component,
|
||||
@@ -323,6 +378,8 @@ const {
|
||||
previewVisible,
|
||||
selectedTypeId,
|
||||
editionForm,
|
||||
constructeurLinks,
|
||||
constructeurIdsFromForm,
|
||||
customFieldInputs,
|
||||
historyFieldLabels,
|
||||
canEdit,
|
||||
@@ -342,13 +399,54 @@ const {
|
||||
removeDocument,
|
||||
handleFilesAdded,
|
||||
submitEdition,
|
||||
saveSlotQuantity,
|
||||
savePieceSlotSelection,
|
||||
saveProductSlotSelection,
|
||||
saveSubcomponentSlotSelection,
|
||||
setSlotQuantity,
|
||||
setPieceSlotSelection,
|
||||
setProductSlotSelection,
|
||||
setSubcomponentSlotSelection,
|
||||
resolvePieceLabel,
|
||||
resolveProductLabel,
|
||||
resolveSubcomponentLabel,
|
||||
formatStructurePreview,
|
||||
fetchComponent,
|
||||
} = useComponentEdit(String(route.params.id))
|
||||
|
||||
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
|
||||
watch(
|
||||
() => editionForm.constructeurIds,
|
||||
(ids) => {
|
||||
const currentIds = new Set(constructeurLinks.value.map(l => l.constructeurId))
|
||||
for (const id of ids) {
|
||||
if (!currentIds.has(id)) {
|
||||
const resolved = getConstructeurById(id)
|
||||
constructeurLinks.value.push({
|
||||
constructeurId: id,
|
||||
constructeur: resolved ? { id: resolved.id, name: resolved.name } : null,
|
||||
supplierReference: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Remove links whose ID was removed from the select
|
||||
constructeurLinks.value = constructeurLinks.value.filter(l => ids.includes(l.constructeurId))
|
||||
},
|
||||
)
|
||||
|
||||
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>
|
||||
|
||||
558
app/pages/component/[id]/index.vue
Normal file
558
app/pages/component/[id]/index.vue
Normal file
@@ -0,0 +1,558 @@
|
||||
<template>
|
||||
<div>
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
:documents="componentDocuments"
|
||||
@close="closePreview"
|
||||
/>
|
||||
<DocumentEditModal
|
||||
:visible="editModalVisible"
|
||||
:document="editingDocument"
|
||||
@close="editModalVisible = false"
|
||||
@updated="handleDocumentUpdated"
|
||||
/>
|
||||
<main class="container mx-auto px-6 py-10">
|
||||
<div v-if="loading" class="flex flex-col items-center gap-4 py-20 text-center">
|
||||
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
|
||||
<p class="text-sm text-base-content/70">Chargement du composant…</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!component" class="max-w-xl mx-auto">
|
||||
<div class="alert alert-error shadow-lg">
|
||||
<div>
|
||||
<h2 class="font-semibold text-lg">Composant introuvable</h2>
|
||||
<p class="text-sm text-base-content/80">
|
||||
Nous n'avons pas pu retrouver le composant demandé. Il a peut-être été supprimé.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
|
||||
Retour au catalogue
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
||||
<div class="card-body space-y-6">
|
||||
<DetailHeader
|
||||
:title="isEditMode ? 'Modifier le composant' : component.name"
|
||||
:subtitle="isEditMode ? 'Ajustez les informations du composant et ses champs personnalisés.' : undefined"
|
||||
:is-edit-mode="isEditMode"
|
||||
:can-edit="canEdit"
|
||||
back-link="/component-catalog"
|
||||
@toggle-edit="isEditMode = !isEditMode"
|
||||
/>
|
||||
|
||||
<!-- Catégorie (always shown) -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Catégorie de composant</span>
|
||||
</label>
|
||||
<template v-if="isEditMode">
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
v-model="selectedTypeId"
|
||||
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>
|
||||
</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">
|
||||
La catégorie d'origine ne peut pas être modifiée depuis cette page.
|
||||
</p>
|
||||
</template>
|
||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
||||
{{ selectedType?.name || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nom (always shown) -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Nom du composant</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="isEditMode"
|
||||
v-model="editionForm.name"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:disabled="!canEdit || saving"
|
||||
placeholder="Nom affiché dans le catalogue"
|
||||
required
|
||||
>
|
||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
||||
{{ component.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description (if value or edit mode) -->
|
||||
<div v-if="isEditMode || component.description" class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Description</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-if="isEditMode"
|
||||
v-model="editionForm.description"
|
||||
class="textarea textarea-bordered textarea-sm md:textarea-md"
|
||||
:disabled="!canEdit || saving"
|
||||
placeholder="Description du composant (optionnel)"
|
||||
rows="3"
|
||||
/>
|
||||
<div v-else class="textarea textarea-bordered textarea-sm md:textarea-md bg-base-200">
|
||||
{{ component.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Référence + Fournisseurs (if value or edit mode) -->
|
||||
<div
|
||||
v-if="isEditMode || component.reference || constructeurLinks.length"
|
||||
class="grid grid-cols-1 gap-4 md:grid-cols-2"
|
||||
>
|
||||
<div v-if="isEditMode || component.reference" class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Référence</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="isEditMode"
|
||||
v-model="editionForm.reference"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:disabled="!canEdit || saving"
|
||||
placeholder="Référence interne ou fournisseur"
|
||||
>
|
||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
||||
{{ component.reference }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditMode || constructeurLinks.length" class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Fournisseur</span>
|
||||
</label>
|
||||
<ConstructeurSelect
|
||||
v-if="isEditMode"
|
||||
v-model="editionForm.constructeurIds"
|
||||
class="w-full"
|
||||
:disabled="!canEdit || saving"
|
||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||
:initial-options="component?.constructeurs || []"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Constructeur links table -->
|
||||
<ConstructeurLinksTable
|
||||
v-if="isEditMode && constructeurLinks.length"
|
||||
v-model="constructeurLinks"
|
||||
/>
|
||||
<ConstructeurLinksTable
|
||||
v-else-if="!isEditMode && constructeurLinks.length"
|
||||
:model-value="constructeurLinks"
|
||||
:readonly="true"
|
||||
/>
|
||||
|
||||
<!-- Prix (if value or edit mode) -->
|
||||
<div v-if="isEditMode || component.prix" class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Prix indicatif (€)</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="isEditMode"
|
||||
v-model="editionForm.prix"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:disabled="!canEdit || saving"
|
||||
placeholder="Valeur indicatrice"
|
||||
>
|
||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
||||
{{ component.prix }} €
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skeleton preview (edit mode only) -->
|
||||
<StructureSkeletonPreview
|
||||
v-if="isEditMode && selectedType"
|
||||
:structure="selectedTypeStructure"
|
||||
:description="selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.'"
|
||||
:preview-badge="formatStructurePreview(selectedTypeStructure)"
|
||||
variant="component"
|
||||
show-empty-state
|
||||
:resolve-piece-label="resolvePieceLabel"
|
||||
:resolve-product-label="resolveProductLabel"
|
||||
:resolve-subcomponent-label="resolveSubcomponentLabel"
|
||||
/>
|
||||
|
||||
<!-- Skeleton slot selections -->
|
||||
<div
|
||||
v-if="pieceSlotEntries.length || productSlotEntries.length || subcomponentSlotEntries.length"
|
||||
class="space-y-3 rounded-lg border border-base-200 bg-base-200/30 p-4"
|
||||
>
|
||||
<header class="space-y-1">
|
||||
<h2 class="font-semibold text-base-content">{{ isEditMode ? 'Sélections du squelette' : 'Structure du composant' }}</h2>
|
||||
<p class="text-xs text-base-content/70">
|
||||
{{ isEditMode ? 'Choisissez les pièces, produits et sous-composants pour chaque emplacement requis par la catégorie.' : 'Pièces, produits et sous-composants associés à ce composant.' }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div v-if="pieceSlotEntries.length" class="space-y-2">
|
||||
<h3 class="font-semibold text-sm text-base-content">Pièces</h3>
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div
|
||||
v-for="slot in pieceSlotEntries"
|
||||
:key="`piece-slot-${slot.slotId}`"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
|
||||
</label>
|
||||
<template v-if="isEditMode">
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex-1">
|
||||
<PieceSelect
|
||||
:model-value="slot.selectedPieceId"
|
||||
:disabled="!canEdit || saving"
|
||||
:type-piece-id="slot.typePieceId"
|
||||
@update:model-value="(value) => setPieceSlotSelection(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) => setSlotQuantity(slot.slotId, Number((e.target as HTMLInputElement).value))"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center gap-2">
|
||||
{{ slot.selectedPieceName || '— Non sélectionné' }}
|
||||
<span v-if="slot.quantity > 1" class="badge badge-sm">x{{ slot.quantity }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="productSlotEntries.length" class="space-y-2">
|
||||
<h3 class="font-semibold text-sm text-base-content">Produits</h3>
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div
|
||||
v-for="slot in productSlotEntries"
|
||||
:key="`product-slot-${slot.slotId}`"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
|
||||
</label>
|
||||
<template v-if="isEditMode">
|
||||
<ProductSelect
|
||||
:model-value="slot.selectedProductId"
|
||||
:disabled="!canEdit || saving"
|
||||
:type-product-id="slot.typeProductId"
|
||||
@update:model-value="(value) => setProductSlotSelection(slot.slotId, value)"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
||||
{{ slot.selectedProductName || '— Non sélectionné' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="subcomponentSlotEntries.length" class="space-y-2">
|
||||
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div
|
||||
v-for="slot in subcomponentSlotEntries"
|
||||
:key="`sub-slot-${slot.slotId}`"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
|
||||
</label>
|
||||
<template v-if="isEditMode">
|
||||
<ComposantSelect
|
||||
:model-value="slot.selectedComponentId"
|
||||
:disabled="!canEdit || saving"
|
||||
:type-composant-id="slot.typeComposantId"
|
||||
@update:model-value="(value) => setSubcomponentSlotSelection(slot.slotId, value)"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
||||
{{ slot.selectedComponentName || '— Non sélectionné' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom fields -->
|
||||
<div v-if="visibleCustomFields.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
<header class="space-y-1">
|
||||
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
|
||||
<p v-if="isEditMode" class="text-xs text-base-content/70">
|
||||
Mettez à jour les valeurs propres à ce composant.
|
||||
</p>
|
||||
</header>
|
||||
<template v-if="isEditMode">
|
||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div
|
||||
v-for="field in visibleCustomFields"
|
||||
:key="field.customFieldValueId || field.id || field.name"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-sm">{{ field.name }}</span>
|
||||
</label>
|
||||
<div class="input input-bordered input-sm bg-base-200 flex items-center">
|
||||
{{ field.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Documents -->
|
||||
<div
|
||||
v-if="isEditMode || componentDocuments.length > 0"
|
||||
class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"
|
||||
>
|
||||
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 class="font-semibold text-base-content">Documents</h2>
|
||||
<p class="text-xs text-base-content/70">
|
||||
{{ isEditMode ? 'Gérez les documents associés à ce composant.' : 'Documents associés à ce composant.' }}
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
|
||||
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</header>
|
||||
<template v-if="isEditMode">
|
||||
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
|
||||
<DocumentUpload
|
||||
v-model="selectedFiles"
|
||||
title="Déposer vos fichiers"
|
||||
subtitle="Formats acceptés : PDF, images, documents…"
|
||||
@files-added="handleFilesAdded"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
|
||||
Téléversement des documents en cours…
|
||||
</p>
|
||||
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
|
||||
Chargement des documents en cours…
|
||||
</p>
|
||||
<DocumentListInline
|
||||
v-else
|
||||
:documents="componentDocuments"
|
||||
:can-delete="canEdit"
|
||||
:can-edit="true"
|
||||
:delete-disabled="uploadingDocuments"
|
||||
empty-text="Aucun document n'est associé à ce composant pour le moment."
|
||||
@preview="openPreview"
|
||||
@edit="openEditModal"
|
||||
@delete="removeDocument"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
|
||||
Chargement des documents en cours…
|
||||
</p>
|
||||
<DocumentListInline
|
||||
v-else
|
||||
:documents="componentDocuments"
|
||||
:can-delete="false"
|
||||
:can-edit="false"
|
||||
empty-text="Aucun document n'est associé à ce composant pour le moment."
|
||||
@preview="openPreview"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<EntityHistorySection
|
||||
:entries="history"
|
||||
:loading="historyLoading"
|
||||
:error="historyError"
|
||||
:field-labels="historyFieldLabels"
|
||||
/>
|
||||
|
||||
<!-- Save buttons (edit mode only) -->
|
||||
<div v-if="isEditMode" class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||
<button type="button" class="btn btn-ghost" :class="{ 'btn-disabled': saving }" @click="isEditMode = false">
|
||||
Annuler
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitEdition">
|
||||
<span v-if="saving" class="loading loading-spinner loading-sm mr-2" />
|
||||
Enregistrer les modifications
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="mt-4">
|
||||
<CommentSection
|
||||
entity-type="composant"
|
||||
:entity-id="String(route.params.id)"
|
||||
:entity-name="component?.name"
|
||||
show-resolved
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from '#imports'
|
||||
import { useComponentEdit } from '~/composables/useComponentEdit'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { usePermissions } from '~/composables/usePermissions'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
|
||||
const route = useRoute()
|
||||
const { canEdit } = usePermissions()
|
||||
const { getConstructeurById } = useConstructeurs()
|
||||
const { updateDocument } = useDocuments()
|
||||
|
||||
const isEditMode = ref(false)
|
||||
|
||||
const {
|
||||
component,
|
||||
loading,
|
||||
saving,
|
||||
selectedFiles,
|
||||
uploadingDocuments,
|
||||
loadingDocuments,
|
||||
componentDocuments,
|
||||
previewDocument,
|
||||
previewVisible,
|
||||
selectedTypeId,
|
||||
editionForm,
|
||||
constructeurLinks,
|
||||
constructeurIdsFromForm,
|
||||
customFieldInputs,
|
||||
historyFieldLabels,
|
||||
canSubmit,
|
||||
componentTypeList,
|
||||
selectedType,
|
||||
selectedTypeStructure,
|
||||
pieceSlotEntries,
|
||||
productSlotEntries,
|
||||
subcomponentSlotEntries,
|
||||
history,
|
||||
historyLoading,
|
||||
historyError,
|
||||
openPreview,
|
||||
closePreview,
|
||||
removeDocument,
|
||||
handleFilesAdded,
|
||||
submitEdition: _submitEdition,
|
||||
fetchComponent,
|
||||
setSlotQuantity,
|
||||
setPieceSlotSelection,
|
||||
setProductSlotSelection,
|
||||
setSubcomponentSlotSelection,
|
||||
resolvePieceLabel,
|
||||
resolveProductLabel,
|
||||
resolveSubcomponentLabel,
|
||||
formatStructurePreview,
|
||||
} = useComponentEdit(String(route.params.id))
|
||||
|
||||
const submitEdition = async () => {
|
||||
await _submitEdition()
|
||||
if (!saving.value) {
|
||||
await fetchComponent()
|
||||
isEditMode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
|
||||
watch(
|
||||
() => editionForm.constructeurIds,
|
||||
(ids) => {
|
||||
const currentIds = new Set(constructeurLinks.value.map(l => l.constructeurId))
|
||||
for (const id of ids) {
|
||||
if (!currentIds.has(id)) {
|
||||
const resolved = getConstructeurById(id)
|
||||
constructeurLinks.value.push({
|
||||
constructeurId: id,
|
||||
constructeur: resolved ? { id: resolved.id, name: resolved.name } : null,
|
||||
supplierReference: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Remove links whose ID was removed from the select
|
||||
constructeurLinks.value = constructeurLinks.value.filter(l => ids.includes(l.constructeurId))
|
||||
},
|
||||
)
|
||||
|
||||
const editingDocument = ref<any | null>(null)
|
||||
const editModalVisible = ref(false)
|
||||
|
||||
const visibleCustomFields = computed(() => {
|
||||
if (isEditMode.value) return customFieldInputs.value
|
||||
return customFieldInputs.value.filter(
|
||||
(f) => f.value !== null && f.value !== undefined && f.value !== '',
|
||||
)
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.edit === 'true' && canEdit.value) {
|
||||
isEditMode.value = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -92,6 +92,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConstructeurLinksTable
|
||||
v-if="constructeurLinks.length"
|
||||
v-model="constructeurLinks"
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
@@ -215,10 +220,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
|
||||
const { getConstructeurById } = useConstructeurs()
|
||||
|
||||
const {
|
||||
selectedTypeId,
|
||||
submitting,
|
||||
creationForm,
|
||||
constructeurLinks,
|
||||
customFieldInputs,
|
||||
structureAssignments,
|
||||
selectedDocuments,
|
||||
@@ -249,4 +260,23 @@ const {
|
||||
resolveSubcomponentLabel,
|
||||
submitCreation,
|
||||
} = useComponentCreate()
|
||||
|
||||
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
|
||||
watch(
|
||||
() => creationForm.constructeurIds,
|
||||
(ids) => {
|
||||
const currentIds = new Set(constructeurLinks.value.map(l => l.constructeurId))
|
||||
for (const id of ids) {
|
||||
if (!currentIds.has(id)) {
|
||||
const resolved = getConstructeurById(id)
|
||||
constructeurLinks.value.push({
|
||||
constructeurId: id,
|
||||
constructeur: resolved ? { id: resolved.id, name: resolved.name } : null,
|
||||
supplierReference: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
constructeurLinks.value = constructeurLinks.value.filter(l => ids.includes(l.constructeurId))
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
@close="closePreview"
|
||||
/>
|
||||
|
||||
<DocumentEditModal
|
||||
:visible="editModalVisible"
|
||||
:document="editingDocument"
|
||||
@close="editModalVisible = false"
|
||||
@updated="handleDocumentUpdated"
|
||||
/>
|
||||
|
||||
<section class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body space-y-6">
|
||||
<DataTable
|
||||
@@ -55,6 +62,26 @@
|
||||
<option value="product">Produits</option>
|
||||
</select>
|
||||
</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 #cell-name="{ row }">
|
||||
@@ -77,6 +104,10 @@
|
||||
{{ row.mimeType || 'Inconnu' }}
|
||||
</template>
|
||||
|
||||
<template #cell-type="{ row }">
|
||||
<span class="badge badge-sm badge-outline">{{ getDocumentTypeLabel(row.type || 'documentation') }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-size="{ row }">
|
||||
{{ formatSize(row.size) }}
|
||||
</template>
|
||||
@@ -98,6 +129,14 @@
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<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
|
||||
class="btn btn-ghost btn-xs"
|
||||
type="button"
|
||||
@@ -123,12 +162,15 @@ import { computed, onMounted, ref, type Ref } from 'vue'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useDataTable } from '~/composables/useDataTable'
|
||||
import { usePermissions } from '~/composables/usePermissions'
|
||||
import { getFileIcon } from '~/utils/fileIcons'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import { formatFrenchDate } from '~/utils/date'
|
||||
import { DOCUMENT_TYPES, getDocumentTypeLabel } from '~/shared/documentTypes'
|
||||
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(
|
||||
{ fetchData: fetchDocuments },
|
||||
@@ -139,21 +181,26 @@ const table = useDataTable(
|
||||
persistToUrl: true,
|
||||
extraParams: {
|
||||
filter: { default: 'all' },
|
||||
typeFilter: { default: 'all' },
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const attachmentFilter = table.filters.filter as Ref<string>
|
||||
const typeFilter = table.filters.typeFilter as Ref<string>
|
||||
|
||||
const previewDocument = ref<any>(null)
|
||||
const previewVisible = ref(false)
|
||||
const editingDocument = ref<any>(null)
|
||||
const editModalVisible = ref(false)
|
||||
|
||||
const documentsOnPage = computed(() => documents.value.length)
|
||||
const paginationState = table.pagination(total, documentsOnPage)
|
||||
|
||||
const columns = [
|
||||
{ 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: 'attachment', label: 'Rattaché à' },
|
||||
{ key: 'createdAt', label: 'Date', sortable: true, sortKey: 'createdAt' },
|
||||
@@ -168,6 +215,7 @@ async function fetchDocuments() {
|
||||
orderBy: table.sortField.value,
|
||||
orderDir: table.sortDirection.value as 'asc' | 'desc',
|
||||
attachmentFilter: attachmentFilter.value,
|
||||
type: typeFilter.value,
|
||||
force: true,
|
||||
})
|
||||
}
|
||||
@@ -198,6 +246,25 @@ const closePreview = () => {
|
||||
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(() => {
|
||||
fetchDocuments()
|
||||
})
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
|
||||
<!-- Machine Info Card -->
|
||||
<MachineInfoCard
|
||||
ref="machineInfoCardRef"
|
||||
:is-edit-mode="d.isEditMode.value"
|
||||
:machine-name="d.machineName.value"
|
||||
:machine-reference="d.machineReference.value"
|
||||
@@ -63,6 +64,7 @@
|
||||
:machine-constructeur-ids="d.machineConstructeurIds.value"
|
||||
:machine-constructeurs-display="d.machineConstructeursDisplay.value"
|
||||
:has-machine-constructeur="d.hasMachineConstructeur.value"
|
||||
:constructeur-links="d.constructeurLinks.value"
|
||||
:visible-custom-fields="d.visibleMachineCustomFields.value"
|
||||
:get-machine-field-id="d.getMachineFieldId"
|
||||
:machine-id="machineId"
|
||||
@@ -71,14 +73,15 @@
|
||||
@update:machine-reference="d.machineReference.value = $event"
|
||||
@update:machine-site-id="d.machineSiteId.value = $event"
|
||||
@update:constructeur-ids="d.handleMachineConstructeurChange"
|
||||
@blur-field="d.updateMachineInfo"
|
||||
@update:constructeur-links="d.constructeurLinks.value = $event"
|
||||
@remove-constructeur-link="handleRemoveConstructeurLink"
|
||||
@set-custom-field-value="d.setMachineCustomFieldValue"
|
||||
@update-custom-field="d.updateMachineCustomField"
|
||||
@custom-fields-saved="d.loadMachineData()"
|
||||
@custom-fields-saved="() => { d.loadMachineData(); refreshVersions() }"
|
||||
/>
|
||||
|
||||
<!-- Documents -->
|
||||
<MachineDocumentsCard
|
||||
v-if="d.isEditMode.value || d.machineDocumentsList.value.length > 0"
|
||||
:documents="d.machineDocumentsList.value"
|
||||
:is-edit-mode="d.isEditMode.value"
|
||||
:uploading="d.machineDocumentsUploading.value"
|
||||
@@ -92,14 +95,16 @@
|
||||
|
||||
<!-- Produits associés -->
|
||||
<MachineProductsCard
|
||||
v-if="d.isEditMode.value || d.machineDirectProducts.value.length > 0"
|
||||
:products="d.machineDirectProducts.value"
|
||||
:is-edit-mode="d.isEditMode.value"
|
||||
@add-product="openAddModal('product')"
|
||||
@remove-product="d.removeProductLink"
|
||||
@remove-product="async (id) => { await d.removeProductLink(id); refreshVersions() }"
|
||||
/>
|
||||
|
||||
<!-- Components Section -->
|
||||
<MachineComponentsCard
|
||||
v-if="d.isEditMode.value || d.components.value.length > 0"
|
||||
:components="d.components.value"
|
||||
:is-edit-mode="d.isEditMode.value"
|
||||
:collapsed="d.componentsCollapsed.value"
|
||||
@@ -109,11 +114,12 @@
|
||||
@edit-piece="d.updatePieceFromComponent"
|
||||
@custom-field-update="d.updatePieceCustomField"
|
||||
@add-component="openAddModal('component')"
|
||||
@remove-component="d.removeComponentLink"
|
||||
@remove-component="async (id) => { await d.removeComponentLink(id); refreshVersions() }"
|
||||
/>
|
||||
|
||||
<!-- Machine Pieces Section -->
|
||||
<MachinePiecesCard
|
||||
v-if="d.isEditMode.value || d.machinePieces.value.length > 0"
|
||||
:pieces="d.machinePieces.value"
|
||||
:is-edit-mode="d.isEditMode.value"
|
||||
:collapsed="d.piecesCollapsed.value"
|
||||
@@ -122,7 +128,7 @@
|
||||
@edit-piece="d.editPiece"
|
||||
@custom-field-update="d.updatePieceCustomField"
|
||||
@add-piece="openAddModal('piece')"
|
||||
@remove-piece="d.removePieceLink"
|
||||
@remove-piece="async (id) => { await d.removePieceLink(id); refreshVersions() }"
|
||||
@toggle-collapse="d.toggleAllPieces"
|
||||
/>
|
||||
|
||||
@@ -134,6 +140,46 @@
|
||||
@confirm="handleAddEntity"
|
||||
/>
|
||||
|
||||
<!-- Save / Cancel buttons -->
|
||||
<div v-if="d.isEditMode.value" class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
:class="{ 'btn-disabled': d.saving.value }"
|
||||
@click="d.cancelEdition()"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="!d.canSubmit.value"
|
||||
@click="submitMachineEdition"
|
||||
>
|
||||
<span v-if="d.saving.value" class="loading loading-spinner loading-sm mr-2" />
|
||||
Enregistrer les modifications
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Historique -->
|
||||
<EntityHistorySection
|
||||
:entries="history"
|
||||
:loading="historyLoading"
|
||||
:error="historyError"
|
||||
:field-labels="historyFieldLabels"
|
||||
/>
|
||||
|
||||
<!-- Versions -->
|
||||
<EntityVersionList
|
||||
ref="versionListRef"
|
||||
entity-type="machine"
|
||||
:entity-id="String(machineId)"
|
||||
:field-labels="historyFieldLabels"
|
||||
:refresh-key="versionRefreshKey"
|
||||
@restored="d.loadMachineData()"
|
||||
|
||||
/>
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="mt-4">
|
||||
<CommentSection
|
||||
@@ -153,9 +199,9 @@
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-base-content mb-1">Machine non trouvée</h3>
|
||||
<p class="text-sm text-base-content/50 mb-6">La machine avec l'ID "{{ machineId }}" n'existe pas ou a été supprimée.</p>
|
||||
<NuxtLink to="/machines" class="btn btn-primary">
|
||||
<button type="button" class="btn btn-primary" @click="$router.back()">
|
||||
Retour aux machines
|
||||
</NuxtLink>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -177,6 +223,7 @@
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useMachineDetailData } from '~/composables/useMachineDetailData'
|
||||
import { useEntityHistory } from '~/composables/useEntityHistory'
|
||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
import PageHero from '~/components/PageHero.vue'
|
||||
import MachinePrintSelectionModal from '~/components/MachinePrintSelectionModal.vue'
|
||||
@@ -187,6 +234,8 @@ import MachineProductsCard from '~/components/machine/MachineProductsCard.vue'
|
||||
import MachineComponentsCard from '~/components/machine/MachineComponentsCard.vue'
|
||||
import MachinePiecesCard from '~/components/machine/MachinePiecesCard.vue'
|
||||
import AddEntityToMachineModal from '~/components/machine/AddEntityToMachineModal.vue'
|
||||
import EntityHistorySection from '~/components/common/EntityHistorySection.vue'
|
||||
import EntityVersionList from '~/components/common/EntityVersionList.vue'
|
||||
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -198,6 +247,33 @@ if (!machineId) {
|
||||
}
|
||||
|
||||
const d = useMachineDetailData(machineId)
|
||||
const machineInfoCardRef = ref(null)
|
||||
const versionRefreshKey = ref(0)
|
||||
const refreshVersions = () => { versionRefreshKey.value++ }
|
||||
|
||||
const {
|
||||
history,
|
||||
loading: historyLoading,
|
||||
error: historyError,
|
||||
loadHistory,
|
||||
} = useEntityHistory('machine')
|
||||
|
||||
const historyFieldLabels = {
|
||||
name: 'Nom',
|
||||
reference: 'Référence',
|
||||
prix: 'Prix',
|
||||
site: 'Site',
|
||||
constructeurIds: 'Fournisseurs',
|
||||
addedComponent: 'Composant ajouté',
|
||||
removedComponent: 'Composant supprimé',
|
||||
addedPiece: 'Pièce ajoutée',
|
||||
removedPiece: 'Pièce supprimée',
|
||||
addedProduct: 'Produit ajouté',
|
||||
removedProduct: 'Produit supprimé',
|
||||
componentLinks: 'Composants liés',
|
||||
pieceLinks: 'Pièces liées',
|
||||
productLinks: 'Produits liés',
|
||||
}
|
||||
|
||||
const addModalOpen = ref(false)
|
||||
const addModalKind = ref('component')
|
||||
@@ -207,6 +283,11 @@ const openAddModal = (kind) => {
|
||||
addModalOpen.value = true
|
||||
}
|
||||
|
||||
const handleRemoveConstructeurLink = (constructeurId) => {
|
||||
const ids = d.machineConstructeurIds.value.filter(id => id !== constructeurId)
|
||||
d.handleMachineConstructeurChange(ids)
|
||||
}
|
||||
|
||||
const handleAddEntity = async (entityId) => {
|
||||
if (addModalKind.value === 'component') {
|
||||
await d.addComponentLink(entityId)
|
||||
@@ -215,15 +296,25 @@ const handleAddEntity = async (entityId) => {
|
||||
} else {
|
||||
await d.addProductLink(entityId)
|
||||
}
|
||||
refreshVersions()
|
||||
}
|
||||
|
||||
const machineViewTitle = computed(() => {
|
||||
return d.isEditMode.value ? 'Modification de la machine' : 'Détails de la machine'
|
||||
})
|
||||
|
||||
const submitMachineEdition = async () => {
|
||||
if (machineInfoCardRef.value?.saveFieldDefinitions) {
|
||||
await machineInfoCardRef.value.saveFieldDefinitions()
|
||||
}
|
||||
await d.submitEdition()
|
||||
refreshVersions()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
d.loadMachineData()
|
||||
d.loadInitialData()
|
||||
loadHistory(String(machineId)).catch(() => {})
|
||||
|
||||
if (route.query.edit === 'true' && canEdit.value) {
|
||||
d.isEditMode.value = true
|
||||
|
||||
@@ -16,16 +16,23 @@
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Site</span>
|
||||
<span class="label-text">Sites</span>
|
||||
</label>
|
||||
<select v-model="selectedSite" class="select select-bordered">
|
||||
<option value="">
|
||||
Tous les sites
|
||||
</option>
|
||||
<option v-for="site in sites" :key="site.id" :value="site.id">
|
||||
{{ site.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<label
|
||||
v-for="site in sites"
|
||||
:key="site.id"
|
||||
class="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
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 class="form-control">
|
||||
<label class="label">
|
||||
@@ -113,11 +120,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useMachines } from '~/composables/useMachines'
|
||||
import { useSites } from '~/composables/useSites'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { useUrlState } from '~/composables/useUrlState'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideFactory from '~icons/lucide/factory'
|
||||
import IconLucideMapPin from '~icons/lucide/map-pin'
|
||||
@@ -128,8 +136,28 @@ const { machines, loading, loadMachines, deleteMachine } = useMachines()
|
||||
const { sites, loadSites } = useSites()
|
||||
const toast = useToast()
|
||||
|
||||
const selectedSite = ref('')
|
||||
const searchQuery = ref('')
|
||||
const urlState = useUrlState({
|
||||
q: { default: '', debounce: 300 },
|
||||
sites: { default: '' },
|
||||
})
|
||||
|
||||
const searchQuery = urlState.q
|
||||
const selectedSites = reactive(new Set())
|
||||
|
||||
// Sync URL → selectedSites on load and back/forward
|
||||
watch(urlState.sites, (val) => {
|
||||
selectedSites.clear()
|
||||
if (val) {
|
||||
for (const id of String(val).split(',')) {
|
||||
if (id) selectedSites.add(id)
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Sync selectedSites → URL
|
||||
watch(() => [...selectedSites], (ids) => {
|
||||
urlState.sites.value = ids.join(',')
|
||||
})
|
||||
|
||||
// Enrichir les machines avec les objets site complets
|
||||
const enrichedMachines = computed(() => {
|
||||
@@ -145,8 +173,8 @@ const enrichedMachines = computed(() => {
|
||||
const filteredMachines = computed(() => {
|
||||
let filtered = enrichedMachines.value
|
||||
|
||||
if (selectedSite.value) {
|
||||
filtered = filtered.filter(machine => machine.siteId === selectedSite.value)
|
||||
if (selectedSites.size > 0) {
|
||||
filtered = filtered.filter(machine => selectedSites.has(machine.siteId))
|
||||
}
|
||||
|
||||
if (searchQuery.value.trim()) {
|
||||
@@ -157,6 +185,10 @@ const filteredMachines = computed(() => {
|
||||
)
|
||||
}
|
||||
|
||||
filtered = [...filtered].sort((a, b) =>
|
||||
(a.name || '').localeCompare(b.name || '', 'fr')
|
||||
)
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
|
||||
@@ -112,6 +112,8 @@ const loadCategory = async () => {
|
||||
category: response.category,
|
||||
notes: response.notes ?? response.description ?? '',
|
||||
structure: (response.structure as PieceModelStructure | null) ?? undefined,
|
||||
referenceFormula: response.referenceFormula ?? null,
|
||||
requiredFieldsForReference: response.requiredFieldsForReference ?? null,
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
@@ -156,7 +158,6 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
|
||||
await loadPieceTypes({ force: true })
|
||||
showSuccess('Catégorie de pièce mise à jour avec succès.')
|
||||
await navigateBackToList()
|
||||
}
|
||||
} catch (error) {
|
||||
showError(normalizeError(error))
|
||||
@@ -181,7 +182,6 @@ const handleSyncConfirm = async () => {
|
||||
})
|
||||
await loadPieceTypes({ force: true })
|
||||
showSuccess('Catégorie de pièce mise à jour avec succès.')
|
||||
await navigateBackToList()
|
||||
} catch (error) {
|
||||
showError(normalizeError(error))
|
||||
} finally {
|
||||
|
||||
506
app/pages/piece/[id].vue
Normal file
506
app/pages/piece/[id].vue
Normal file
@@ -0,0 +1,506 @@
|
||||
<template>
|
||||
<div>
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
:documents="pieceDocuments"
|
||||
@close="closePreview"
|
||||
/>
|
||||
<DocumentEditModal
|
||||
:visible="editModalVisible"
|
||||
:document="editingDocument"
|
||||
@close="editModalVisible = false"
|
||||
@updated="handleDocumentUpdated"
|
||||
/>
|
||||
<main class="container mx-auto px-6 py-10">
|
||||
<div v-if="loading" class="flex flex-col items-center gap-4 py-20 text-center">
|
||||
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
|
||||
<p class="text-sm text-base-content/70">Chargement de la pièce…</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!piece" class="max-w-xl mx-auto">
|
||||
<div class="alert alert-error shadow-lg">
|
||||
<div>
|
||||
<h2 class="font-semibold text-lg">Pièce introuvable</h2>
|
||||
<p class="text-sm text-base-content/80">
|
||||
Nous n'avons pas pu retrouver la pièce demandée. Elle a peut-être été supprimée.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
|
||||
Retour au catalogue
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
||||
<div class="card-body space-y-6">
|
||||
<DetailHeader
|
||||
:title="isEditMode ? 'Modifier la pièce' : piece.name"
|
||||
:subtitle="isEditMode ? 'Ajustez les informations de la pièce et ses champs personnalisés.' : undefined"
|
||||
:is-edit-mode="isEditMode"
|
||||
:can-edit="canEdit"
|
||||
back-link="/pieces-catalog"
|
||||
@toggle-edit="isEditMode = !isEditMode"
|
||||
/>
|
||||
|
||||
<!-- Catégorie (always shown) -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Catégorie de pièce</span>
|
||||
</label>
|
||||
<template v-if="isEditMode">
|
||||
<select
|
||||
v-model="selectedTypeId"
|
||||
class="select select-bordered select-sm md:select-md"
|
||||
disabled
|
||||
>
|
||||
<option value="">Sélectionner une catégorie</option>
|
||||
<option
|
||||
v-for="type in pieceTypeList"
|
||||
:key="type.id"
|
||||
:value="type.id"
|
||||
>
|
||||
{{ type.name }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="text-xs text-base-content/60 mt-1">
|
||||
La catégorie d'origine ne peut pas être modifiée depuis cette page.
|
||||
</p>
|
||||
</template>
|
||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
||||
{{ selectedType?.name || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nom (always shown) -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Nom de la pièce</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="isEditMode"
|
||||
v-model="editionForm.name"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:disabled="!canEdit || saving"
|
||||
placeholder="Nom affiché dans le catalogue"
|
||||
required
|
||||
>
|
||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
||||
{{ piece.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description (if value or edit mode) -->
|
||||
<div v-if="isEditMode || piece.description" class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Description</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-if="isEditMode"
|
||||
v-model="editionForm.description"
|
||||
class="textarea textarea-bordered textarea-sm md:textarea-md"
|
||||
:disabled="!canEdit || saving"
|
||||
placeholder="Description de la pièce (optionnel)"
|
||||
rows="3"
|
||||
/>
|
||||
<div v-else class="textarea textarea-bordered textarea-sm md:textarea-md bg-base-200">
|
||||
{{ piece.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Référence auto (read-only, shown only if computed) -->
|
||||
<div v-if="piece.referenceAuto" class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Référence auto</span>
|
||||
</label>
|
||||
<div class="input input-bordered input-sm md:input-md bg-base-200 flex items-center gap-2">
|
||||
<span class="font-mono font-semibold">{{ piece.referenceAuto }}</span>
|
||||
<span class="badge badge-sm badge-ghost">auto</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Référence + Fournisseurs (if value or edit mode) -->
|
||||
<div
|
||||
v-if="isEditMode || piece.reference || editionForm.constructeurIds.length"
|
||||
class="grid grid-cols-1 gap-4 md:grid-cols-2"
|
||||
>
|
||||
<div v-if="isEditMode || piece.reference" class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Référence</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="isEditMode"
|
||||
v-model="editionForm.reference"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:disabled="!canEdit || saving"
|
||||
placeholder="Référence interne ou fournisseur"
|
||||
>
|
||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
||||
{{ piece.reference }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditMode || constructeurLinks.length" class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Fournisseur</span>
|
||||
</label>
|
||||
<template v-if="isEditMode">
|
||||
<ConstructeurSelect
|
||||
v-model="editionForm.constructeurIds"
|
||||
class="w-full"
|
||||
:disabled="!canEdit || saving"
|
||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||
:initial-options="piece?.constructeurs || []"
|
||||
/>
|
||||
<ConstructeurLinksTable
|
||||
v-model="constructeurLinks"
|
||||
class="mt-2"
|
||||
@remove="handleConstructeurRemoved"
|
||||
/>
|
||||
</template>
|
||||
<ConstructeurLinksTable
|
||||
v-else
|
||||
:model-value="constructeurLinks"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prix (if value or edit mode) -->
|
||||
<div v-if="isEditMode || piece.prix" class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Prix indicatif (€)</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="isEditMode"
|
||||
v-model="editionForm.prix"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:disabled="!canEdit || saving"
|
||||
placeholder="Valeur indicatrice"
|
||||
>
|
||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
||||
{{ piece.prix }} €
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product requirements -->
|
||||
<div
|
||||
v-if="structureProducts.length"
|
||||
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
|
||||
>
|
||||
<header class="space-y-1">
|
||||
<h2 class="font-semibold text-base-content">
|
||||
Produits liés
|
||||
</h2>
|
||||
<p class="text-xs text-base-content/70">
|
||||
{{ isEditMode ? 'Cette pièce doit rester liée à un produit catalogue répondant aux critères suivants.' : 'Produits associés à cette pièce via le squelette.' }}
|
||||
</p>
|
||||
</header>
|
||||
<ul class="space-y-2 text-sm text-base-content/80">
|
||||
<li
|
||||
v-for="(description, index) in productRequirementDescriptions"
|
||||
:key="`edit-requirement-${index}`"
|
||||
class="flex items-start gap-2"
|
||||
>
|
||||
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
|
||||
<span>{{ description }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="isEditMode" class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div
|
||||
v-for="entry in productRequirementEntries"
|
||||
:key="entry.key"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-xs font-medium">
|
||||
{{ entry.label }}
|
||||
</span>
|
||||
</label>
|
||||
<ProductSelect
|
||||
:model-value="productSelections[entry.index] || null"
|
||||
:disabled="!canEdit || saving"
|
||||
:type-product-id="entry.typeProductId"
|
||||
helper-text="Un produit valide est requis pour cette pièce."
|
||||
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div
|
||||
v-for="(entry, index) in productRequirementEntries"
|
||||
:key="entry.key"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-xs font-medium">{{ entry.label }}</span>
|
||||
</label>
|
||||
<div class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
||||
{{ productSelectionLabels[index] || '— Non sélectionné' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skeleton preview (edit mode only) -->
|
||||
<StructureSkeletonPreview
|
||||
v-if="isEditMode && (selectedType || resolvedStructure)"
|
||||
:structure="resolvedStructure"
|
||||
:description="selectedType?.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.'"
|
||||
:preview-badge="formatPieceStructurePreview(resolvedStructure)"
|
||||
variant="piece"
|
||||
/>
|
||||
|
||||
<!-- Custom fields -->
|
||||
<div v-if="visibleCustomFields.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
<header class="space-y-1">
|
||||
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
|
||||
<p v-if="isEditMode" class="text-xs text-base-content/70">
|
||||
Mettez à jour les valeurs propres à cette pièce.
|
||||
</p>
|
||||
</header>
|
||||
<template v-if="isEditMode">
|
||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div
|
||||
v-for="field in visibleCustomFields"
|
||||
:key="field.customFieldValueId || field.id || field.name"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-sm">{{ field.name }}</span>
|
||||
</label>
|
||||
<div class="input input-bordered input-sm bg-base-200 flex items-center">
|
||||
{{ field.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Documents -->
|
||||
<div
|
||||
v-if="isEditMode || pieceDocuments.length > 0"
|
||||
class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"
|
||||
>
|
||||
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 class="font-semibold text-base-content">Documents</h2>
|
||||
<p class="text-xs text-base-content/70">
|
||||
{{ isEditMode ? 'Gérez les documents associés à cette pièce.' : 'Documents associés à cette pièce.' }}
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
|
||||
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</header>
|
||||
<template v-if="isEditMode">
|
||||
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
|
||||
<DocumentUpload
|
||||
v-model="selectedFiles"
|
||||
title="Déposer vos fichiers"
|
||||
subtitle="Formats acceptés : PDF, images, documents…"
|
||||
@files-added="handleFilesAdded"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
|
||||
Téléversement des documents en cours…
|
||||
</p>
|
||||
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
|
||||
Chargement des documents en cours…
|
||||
</p>
|
||||
<DocumentListInline
|
||||
v-else
|
||||
:documents="pieceDocuments"
|
||||
:can-delete="canEdit"
|
||||
:can-edit="true"
|
||||
:delete-disabled="uploadingDocuments"
|
||||
empty-text="Aucun document n'est associé à cette pièce pour le moment."
|
||||
@preview="openPreview"
|
||||
@edit="openEditModal"
|
||||
@delete="removeDocument"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
|
||||
Chargement des documents en cours…
|
||||
</p>
|
||||
<DocumentListInline
|
||||
v-else
|
||||
:documents="pieceDocuments"
|
||||
:can-delete="false"
|
||||
:can-edit="false"
|
||||
empty-text="Aucun document n'est associé à cette pièce pour le moment."
|
||||
@preview="openPreview"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<EntityHistorySection
|
||||
:entries="history"
|
||||
:loading="historyLoading"
|
||||
:error="historyError"
|
||||
:field-labels="historyFieldLabels"
|
||||
/>
|
||||
|
||||
<EntityVersionList
|
||||
entity-type="piece"
|
||||
:entity-id="String(route.params.id)"
|
||||
:field-labels="historyFieldLabels"
|
||||
:refresh-key="versionRefreshKey"
|
||||
@restored="fetchPiece()"
|
||||
/>
|
||||
|
||||
<!-- Save buttons (edit mode only) -->
|
||||
<div v-if="isEditMode" class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||
<button type="button" class="btn btn-ghost" :class="{ 'btn-disabled': saving }" @click="isEditMode = false">
|
||||
Annuler
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitEdition">
|
||||
<span v-if="saving" class="loading loading-spinner loading-sm mr-2" />
|
||||
Enregistrer les modifications
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="mt-4">
|
||||
<CommentSection
|
||||
entity-type="piece"
|
||||
:entity-id="String(route.params.id)"
|
||||
:entity-name="piece?.name"
|
||||
show-resolved
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from '#imports'
|
||||
import { usePieceEdit } from '~/composables/usePieceEdit'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { usePermissions } from '~/composables/usePermissions'
|
||||
|
||||
const route = useRoute()
|
||||
const { canEdit } = usePermissions()
|
||||
const { updateDocument } = useDocuments()
|
||||
|
||||
const isEditMode = ref(false)
|
||||
const versionRefreshKey = ref(0)
|
||||
|
||||
const {
|
||||
piece,
|
||||
loading,
|
||||
saving,
|
||||
selectedFiles,
|
||||
uploadingDocuments,
|
||||
loadingDocuments,
|
||||
pieceDocuments,
|
||||
previewDocument,
|
||||
previewVisible,
|
||||
selectedTypeId,
|
||||
editionForm,
|
||||
constructeurLinks,
|
||||
productSelections,
|
||||
customFieldInputs,
|
||||
pieceTypeList,
|
||||
selectedType,
|
||||
resolvedStructure,
|
||||
structureProducts,
|
||||
productRequirementDescriptions,
|
||||
productRequirementEntries,
|
||||
canSubmit,
|
||||
historyFieldLabels,
|
||||
history,
|
||||
historyLoading,
|
||||
historyError,
|
||||
openPreview,
|
||||
closePreview,
|
||||
removeDocument,
|
||||
handleFilesAdded,
|
||||
setProductSelection,
|
||||
submitEdition: _submitEdition,
|
||||
fetchPiece,
|
||||
formatPieceStructurePreview,
|
||||
} = usePieceEdit(String(route.params.id))
|
||||
|
||||
const submitEdition = async () => {
|
||||
await _submitEdition()
|
||||
if (!saving.value) {
|
||||
await fetchPiece()
|
||||
isEditMode.value = false
|
||||
versionRefreshKey.value++
|
||||
}
|
||||
}
|
||||
|
||||
// Sync ConstructeurSelect changes → constructeurLinks
|
||||
watch(() => editionForm.constructeurIds, (newIds) => {
|
||||
const existing = new Map(constructeurLinks.value.map(l => [l.constructeurId, l]))
|
||||
constructeurLinks.value = newIds.map(id =>
|
||||
existing.get(id) || { constructeurId: id, supplierReference: null },
|
||||
)
|
||||
})
|
||||
|
||||
const handleConstructeurRemoved = (constructeurId: string) => {
|
||||
editionForm.constructeurIds = editionForm.constructeurIds.filter(id => id !== constructeurId)
|
||||
}
|
||||
|
||||
const editingDocument = ref<any | null>(null)
|
||||
const editModalVisible = ref(false)
|
||||
|
||||
// Resolve product names for read-only display from piece data
|
||||
const productSelectionLabels = computed(() => {
|
||||
if (!piece.value) return []
|
||||
const p = piece.value as any
|
||||
// piece.product contains {id, name} for the legacy single product
|
||||
if (p.product?.name) return [p.product.name]
|
||||
return productSelections.value.map((id: string | null) => id || null)
|
||||
})
|
||||
|
||||
const visibleCustomFields = computed(() => {
|
||||
if (isEditMode.value) return customFieldInputs.value
|
||||
return customFieldInputs.value.filter(
|
||||
(f) => f.value !== null && f.value !== undefined && f.value !== '',
|
||||
)
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.edit === 'true' && canEdit.value) {
|
||||
isEditMode.value = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -69,6 +69,10 @@
|
||||
{{ row.piece.reference || '—' }}
|
||||
</template>
|
||||
|
||||
<template #cell-referenceAuto="{ row }">
|
||||
{{ row.piece.referenceAuto || '—' }}
|
||||
</template>
|
||||
|
||||
<template #cell-description="{ row }">
|
||||
<div v-if="row.piece.description" class="group relative">
|
||||
<span class="block cursor-help truncate">{{ row.piece.description }}</span>
|
||||
@@ -119,12 +123,14 @@
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<NuxtLink
|
||||
:to="`/pieces/${row.piece.id}/edit`"
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
@click="navigateTo(`/piece/${row.piece.id}?edit=true`)"
|
||||
>
|
||||
Modifier
|
||||
</NuxtLink>
|
||||
</button>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
@@ -134,6 +140,12 @@
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
<NuxtLink
|
||||
:to="`/piece/${row.piece.id}`"
|
||||
class="btn btn-primary btn-xs"
|
||||
>
|
||||
Détails
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
@@ -159,13 +171,14 @@ const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
|
||||
const table = useDataTable(
|
||||
{ fetchData: fetchPieces },
|
||||
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true },
|
||||
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typePiece'] },
|
||||
)
|
||||
|
||||
const columns = [
|
||||
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
|
||||
{ key: 'name', label: 'Nom', sortable: true },
|
||||
{ key: 'reference', label: 'Référence' },
|
||||
{ key: 'referenceAuto', label: 'Réf. auto' },
|
||||
{ key: 'description', label: 'Description' },
|
||||
{ key: 'suppliers', label: 'Fournisseurs' },
|
||||
{ key: 'typePiece', label: 'Type de pièce', filterable: true, filterPlaceholder: 'Filtrer…' },
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
:documents="pieceDocuments"
|
||||
@close="closePreview"
|
||||
/>
|
||||
<DocumentEditModal
|
||||
:visible="editModalVisible"
|
||||
:document="editingDocument"
|
||||
@close="editModalVisible = false"
|
||||
@updated="handleDocumentUpdated"
|
||||
/>
|
||||
<main class="container mx-auto px-6 py-10">
|
||||
<div v-if="loading" class="flex flex-col items-center gap-4 py-20 text-center">
|
||||
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
|
||||
@@ -108,6 +114,19 @@
|
||||
>
|
||||
</div>
|
||||
|
||||
<div v-if="piece?.referenceAuto" class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Référence auto</span>
|
||||
</label>
|
||||
<input
|
||||
:value="piece.referenceAuto"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md bg-base-200"
|
||||
disabled
|
||||
title="Générée automatiquement à partir du type et des champs personnalisés"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Fournisseur</span>
|
||||
@@ -231,9 +250,11 @@
|
||||
v-else
|
||||
:documents="pieceDocuments"
|
||||
:can-delete="canEdit"
|
||||
:can-edit="true"
|
||||
:delete-disabled="uploadingDocuments"
|
||||
empty-text="Aucun document n'est associé à cette pièce pour le moment."
|
||||
@preview="openPreview"
|
||||
@edit="openEditModal"
|
||||
@delete="removeDocument"
|
||||
/>
|
||||
</div>
|
||||
@@ -271,10 +292,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from '#imports'
|
||||
import { usePieceEdit } from '~/composables/usePieceEdit'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
|
||||
const route = useRoute()
|
||||
const { updateDocument } = useDocuments()
|
||||
|
||||
const {
|
||||
piece,
|
||||
@@ -310,4 +334,24 @@ const {
|
||||
submitEdition,
|
||||
formatPieceStructurePreview,
|
||||
} = 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>
|
||||
|
||||
@@ -91,6 +91,10 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ConstructeurLinksTable
|
||||
v-if="constructeurLinks.length"
|
||||
v-model="constructeurLinks"
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="form-control">
|
||||
@@ -213,6 +217,7 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from '#imports'
|
||||
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
||||
import ConstructeurLinksTable from '~/components/ConstructeurLinksTable.vue'
|
||||
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||
import ProductSelect from '~/components/ProductSelect.vue'
|
||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||
@@ -222,8 +227,11 @@ import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
import type { PieceModelStructure } from '~/shared/types/inventory'
|
||||
import type { ModelType } from '~/services/modelTypes'
|
||||
import {
|
||||
@@ -255,6 +263,8 @@ const { createPiece } = usePieces()
|
||||
const toast = useToast()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const { uploadDocuments } = useDocuments()
|
||||
const { syncLinks } = useConstructeurLinks()
|
||||
const { getConstructeurById } = useConstructeurs()
|
||||
const { canEdit } = usePermissions()
|
||||
|
||||
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
||||
@@ -267,6 +277,7 @@ const creationForm = reactive({
|
||||
constructeurIds: [] as string[],
|
||||
prix: '' as string,
|
||||
})
|
||||
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
const productSelections = ref<(string | null)[]>([])
|
||||
|
||||
const lastSuggestedName = ref('')
|
||||
@@ -380,6 +391,7 @@ const clearCreationForm = () => {
|
||||
creationForm.description = ''
|
||||
creationForm.reference = ''
|
||||
creationForm.constructeurIds = []
|
||||
constructeurLinks.value = []
|
||||
creationForm.prix = ''
|
||||
productSelections.value = []
|
||||
lastSuggestedName.value = ''
|
||||
@@ -411,8 +423,6 @@ const submitCreation = async () => {
|
||||
payload.reference = reference
|
||||
}
|
||||
|
||||
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
|
||||
|
||||
const normalizedProductIds = collectNormalizedProductIds(
|
||||
productRequirementEntries.value,
|
||||
productSelections.value,
|
||||
@@ -444,10 +454,14 @@ const submitCreation = async () => {
|
||||
'piece',
|
||||
createdPiece.id,
|
||||
[
|
||||
createdPiece?.typePiece?.pieceCustomFields,
|
||||
createdPiece?.typePiece?.structure?.customFields,
|
||||
],
|
||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||
)
|
||||
// Sync constructeur links after creation
|
||||
if (constructeurLinks.value.length) {
|
||||
await syncLinks('piece', createdPiece.id, [], constructeurLinks.value)
|
||||
}
|
||||
if (selectedDocuments.value.length && createdPiece.id) {
|
||||
uploadingDocuments.value = true
|
||||
const uploadResult = await uploadDocuments(
|
||||
@@ -466,7 +480,7 @@ const submitCreation = async () => {
|
||||
selectedDocuments.value = []
|
||||
}
|
||||
toast.showSuccess('Pièce créée avec succès')
|
||||
await router.push('/pieces-catalog')
|
||||
await router.replace(`/piece/${createdPiece.id}?edit=true`)
|
||||
} else if (result.error) {
|
||||
toast.showError(result.error)
|
||||
}
|
||||
@@ -478,6 +492,26 @@ const submitCreation = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
|
||||
watch(
|
||||
() => creationForm.constructeurIds,
|
||||
(ids) => {
|
||||
const currentIds = new Set(constructeurLinks.value.map(l => l.constructeurId))
|
||||
for (const id of ids) {
|
||||
if (!currentIds.has(id)) {
|
||||
const resolved = getConstructeurById(id)
|
||||
constructeurLinks.value.push({
|
||||
constructeurId: id,
|
||||
constructeur: resolved ? { id: resolved.id, name: resolved.name } : null,
|
||||
supplierReference: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
constructeurLinks.value = constructeurLinks.value.filter(l => ids.includes(l.constructeurId))
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadPieceTypes()
|
||||
})
|
||||
|
||||
@@ -116,12 +116,14 @@
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<NuxtLink
|
||||
:to="`/product/${row.product.id}/edit`"
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
@click="navigateTo(`/product/${row.product.id}?edit=true`)"
|
||||
>
|
||||
Modifier
|
||||
</NuxtLink>
|
||||
</button>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
@@ -130,6 +132,12 @@
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
<NuxtLink
|
||||
:to="`/product/${row.product.id}`"
|
||||
class="btn btn-primary btn-xs"
|
||||
>
|
||||
Détails
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
@@ -167,7 +175,7 @@ const toast = useToast()
|
||||
|
||||
const table = useDataTable(
|
||||
{ fetchData: fetchProducts },
|
||||
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true },
|
||||
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typeProduct'] },
|
||||
)
|
||||
|
||||
const errorMessage = computed(() => (typeof error.value === 'string' && error.value.length ? error.value : null))
|
||||
|
||||
@@ -156,7 +156,6 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
|
||||
await loadProductTypes({ force: true })
|
||||
showSuccess('Catégorie de produit mise à jour avec succès.')
|
||||
await navigateBackToList()
|
||||
}
|
||||
} catch (error) {
|
||||
showError(normalizeError(error))
|
||||
@@ -181,7 +180,6 @@ const handleSyncConfirm = async () => {
|
||||
})
|
||||
await loadProductTypes({ force: true })
|
||||
showSuccess('Catégorie de produit mise à jour avec succès.')
|
||||
await navigateBackToList()
|
||||
} catch (error) {
|
||||
showError(normalizeError(error))
|
||||
} finally {
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
:documents="productDocuments"
|
||||
@close="closePreview"
|
||||
/>
|
||||
<DocumentEditModal
|
||||
:visible="editModalVisible"
|
||||
:document="editingDocument"
|
||||
@close="editModalVisible = false"
|
||||
@updated="handleDocumentUpdated"
|
||||
/>
|
||||
<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">
|
||||
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
|
||||
@@ -99,6 +105,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConstructeurLinksTable
|
||||
v-if="constructeurLinks.length"
|
||||
v-model="constructeurLinks"
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
@@ -167,9 +178,11 @@
|
||||
v-else
|
||||
:documents="productDocuments"
|
||||
:can-delete="canEdit"
|
||||
:can-edit="true"
|
||||
:delete-disabled="uploadingDocuments || saving"
|
||||
empty-text="Aucun document n'est associé à ce produit pour le moment."
|
||||
@preview="openPreview"
|
||||
@edit="openEditModal"
|
||||
@delete="removeDocument"
|
||||
/>
|
||||
</div>
|
||||
@@ -181,6 +194,14 @@
|
||||
:field-labels="historyFieldLabels"
|
||||
/>
|
||||
|
||||
<EntityVersionList
|
||||
entity-type="product"
|
||||
:entity-id="String(route.params.id)"
|
||||
:field-labels="historyFieldLabels"
|
||||
:refresh-key="versionRefreshKey"
|
||||
@restored="loadProduct()"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
||||
Annuler
|
||||
@@ -221,9 +242,11 @@ import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||
import { useProductHistory } from '~/composables/useProductHistory'
|
||||
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
import { getModelType } from '~/services/modelTypes'
|
||||
import type { ProductModelStructure } from '~/shared/types/inventory'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
@@ -235,6 +258,7 @@ import {
|
||||
} from '~/shared/utils/customFieldFormUtils'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
const versionRefreshKey = ref(0)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
@@ -244,8 +268,10 @@ const {
|
||||
loadDocumentsByProduct,
|
||||
uploadDocuments: uploadProductDocuments,
|
||||
deleteDocument: deleteProductDocument,
|
||||
updateDocument,
|
||||
} = useDocuments()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
const { ensureConstructeurs, getConstructeurById } = useConstructeurs()
|
||||
const { fetchLinks, syncLinks } = useConstructeurLinks()
|
||||
const {
|
||||
history,
|
||||
loading: historyLoading,
|
||||
@@ -265,6 +291,11 @@ const loadingDocuments = ref(false)
|
||||
const productDocuments = ref<any[]>([])
|
||||
const previewDocument = ref<any | null>(null)
|
||||
const previewVisible = ref(false)
|
||||
const editingDocument = ref<any | null>(null)
|
||||
const editModalVisible = ref(false)
|
||||
|
||||
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
|
||||
const historyFieldLabels: Record<string, string> = {
|
||||
name: 'Nom',
|
||||
@@ -307,6 +338,23 @@ const openPreview = (doc: any) => {
|
||||
}
|
||||
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 id = route.params.id
|
||||
if (!id || typeof id !== 'string') {
|
||||
@@ -420,18 +468,19 @@ const hydrateForm = () => {
|
||||
}
|
||||
editionForm.name = product.value.name || ''
|
||||
editionForm.reference = product.value.reference || ''
|
||||
editionForm.constructeurIds = uniqueConstructeurIds(
|
||||
product.value,
|
||||
Array.isArray(product.value.constructeurs) ? product.value.constructeurs : [],
|
||||
)
|
||||
// Load constructeur links
|
||||
fetchLinks('product', String(route.params.id)).then((links) => {
|
||||
constructeurLinks.value = links
|
||||
originalConstructeurLinks.value = links.map(l => ({ ...l }))
|
||||
editionForm.constructeurIds = constructeurIdsFromLinks(links)
|
||||
if (editionForm.constructeurIds.length) {
|
||||
void ensureConstructeurs(editionForm.constructeurIds)
|
||||
}
|
||||
})
|
||||
editionForm.supplierPrice = product.value.supplierPrice !== null && product.value.supplierPrice !== undefined
|
||||
? String(product.value.supplierPrice)
|
||||
: ''
|
||||
refreshCustomFieldInputs(structure.value, product.value.customFieldValues)
|
||||
if (editionForm.constructeurIds.length) {
|
||||
// Smart-cached + deduped — fire-and-forget, ConstructeurSelect handles its own loading
|
||||
ensureConstructeurs(editionForm.constructeurIds).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -449,12 +498,9 @@ const submitEdition = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
const constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
name: editionForm.name.trim(),
|
||||
reference: editionForm.reference.trim() || null,
|
||||
constructeurIds,
|
||||
}
|
||||
|
||||
const rawPrice = typeof editionForm.supplierPrice === 'string'
|
||||
@@ -474,15 +520,17 @@ const submitEdition = async () => {
|
||||
const failedFields = await _saveCustomFieldValues(
|
||||
'product',
|
||||
result.data.id,
|
||||
[],
|
||||
[result.data?.typeProduct?.structure?.customFields],
|
||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||
)
|
||||
if (failedFields.length) {
|
||||
toast.showError(`Impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
|
||||
return
|
||||
}
|
||||
await syncLinks('product', product.value.id, originalConstructeurLinks.value, constructeurLinks.value)
|
||||
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
|
||||
toast.showSuccess('Produit mis à jour avec succès')
|
||||
await router.push('/product-catalog')
|
||||
versionRefreshKey.value++
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')
|
||||
@@ -491,6 +539,25 @@ const submitEdition = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
|
||||
watch(
|
||||
() => editionForm.constructeurIds,
|
||||
(ids) => {
|
||||
const currentIds = new Set(constructeurLinks.value.map(l => l.constructeurId))
|
||||
for (const id of ids) {
|
||||
if (!currentIds.has(id)) {
|
||||
const resolved = getConstructeurById(id)
|
||||
constructeurLinks.value.push({
|
||||
constructeurId: id,
|
||||
constructeur: resolved ? { id: resolved.id, name: resolved.name } : null,
|
||||
supplierReference: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
constructeurLinks.value = constructeurLinks.value.filter(l => ids.includes(l.constructeurId))
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProduct()
|
||||
})
|
||||
|
||||
646
app/pages/product/[id]/index.vue
Normal file
646
app/pages/product/[id]/index.vue
Normal file
@@ -0,0 +1,646 @@
|
||||
<template>
|
||||
<div>
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
:documents="productDocuments"
|
||||
@close="closePreview"
|
||||
/>
|
||||
<DocumentEditModal
|
||||
:visible="editModalVisible"
|
||||
:document="editingDocument"
|
||||
@close="editModalVisible = false"
|
||||
@updated="handleDocumentUpdated"
|
||||
/>
|
||||
<main class="container mx-auto px-6 py-10">
|
||||
<div v-if="loading" class="flex flex-col items-center gap-4 py-20 text-center">
|
||||
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
|
||||
<p class="text-sm text-base-content/70">Chargement du produit…</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!product" class="max-w-xl mx-auto">
|
||||
<div class="alert alert-error shadow-lg">
|
||||
<div>
|
||||
<h2 class="font-semibold text-lg">Produit introuvable</h2>
|
||||
<p class="text-sm text-base-content/80">
|
||||
Nous n'avons pas pu trouver le produit demandé. Il a peut-être été supprimé.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
|
||||
Retour au catalogue
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
||||
<div class="card-body space-y-6">
|
||||
<DetailHeader
|
||||
:title="isEditMode ? 'Modifier le produit' : product.name"
|
||||
:subtitle="isEditMode ? 'Ajustez les informations du produit et ses champs personnalisés.' : undefined"
|
||||
:is-edit-mode="isEditMode"
|
||||
:can-edit="canEdit"
|
||||
back-link="/product-catalog"
|
||||
@toggle-edit="isEditMode = !isEditMode"
|
||||
/>
|
||||
|
||||
<!-- Catégorie (always shown) -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Catégorie de produit</span>
|
||||
</label>
|
||||
<template v-if="isEditMode">
|
||||
<input
|
||||
:value="product?.typeProduct?.name || 'Catégorie inconnue'"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md bg-base-200"
|
||||
disabled
|
||||
>
|
||||
<p class="text-xs text-base-content/60 mt-1">
|
||||
La catégorie d'origine ne peut pas être modifiée depuis cette page.
|
||||
</p>
|
||||
</template>
|
||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
||||
{{ product?.typeProduct?.name || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nom (always shown) -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Nom du produit</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="isEditMode"
|
||||
v-model="editionForm.name"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:disabled="!canEdit || saving"
|
||||
placeholder="Nom affiché dans le catalogue"
|
||||
required
|
||||
>
|
||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
||||
{{ product.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Référence + Fournisseurs (if value or edit mode) -->
|
||||
<div
|
||||
v-if="isEditMode || product.reference || editionForm.constructeurIds.length"
|
||||
class="grid grid-cols-1 gap-4 md:grid-cols-2"
|
||||
>
|
||||
<div v-if="isEditMode || product.reference" class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Référence</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="isEditMode"
|
||||
v-model="editionForm.reference"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:disabled="!canEdit || saving"
|
||||
placeholder="Référence interne ou fournisseur"
|
||||
>
|
||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
||||
{{ product.reference }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditMode || editionForm.constructeurIds.length" class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Fournisseurs</span>
|
||||
</label>
|
||||
<ConstructeurSelect
|
||||
v-if="isEditMode"
|
||||
v-model="editionForm.constructeurIds"
|
||||
class="w-full"
|
||||
:disabled="!canEdit || saving"
|
||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||
:initial-options="product?.constructeurs || []"
|
||||
/>
|
||||
<div v-else class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="id in editionForm.constructeurIds"
|
||||
:key="id"
|
||||
class="badge badge-outline"
|
||||
>
|
||||
{{ getConstructeurById(id)?.name || id }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Constructeur links table -->
|
||||
<ConstructeurLinksTable
|
||||
v-if="isEditMode && constructeurLinks.length"
|
||||
v-model="constructeurLinks"
|
||||
/>
|
||||
<ConstructeurLinksTable
|
||||
v-else-if="!isEditMode && constructeurLinks.length"
|
||||
:model-value="constructeurLinks"
|
||||
:readonly="true"
|
||||
/>
|
||||
|
||||
<!-- Prix fournisseur (if value or edit mode) -->
|
||||
<div v-if="isEditMode || product.supplierPrice" class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Prix fournisseur indicatif (€)</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="isEditMode"
|
||||
v-model="editionForm.supplierPrice"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:disabled="!canEdit || saving"
|
||||
placeholder="Valeur indicatrice"
|
||||
>
|
||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
||||
{{ product.supplierPrice }} €
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Structure preview (edit mode only) -->
|
||||
<div v-if="isEditMode && structurePreview" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="font-semibold text-base-content">Champs définis par la catégorie</h2>
|
||||
<p class="text-xs text-base-content/70">
|
||||
{{ productType?.description || 'Le squelette de catégorie contrôle les champs personnalisés disponibles.' }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="badge badge-outline">{{ structurePreview }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom fields -->
|
||||
<div v-if="visibleCustomFields.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
<header class="space-y-1">
|
||||
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
|
||||
<p v-if="isEditMode" class="text-xs text-base-content/70">
|
||||
Mettez à jour les valeurs propres à ce produit.
|
||||
</p>
|
||||
</header>
|
||||
<template v-if="isEditMode">
|
||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div
|
||||
v-for="field in visibleCustomFields"
|
||||
:key="field.customFieldValueId || field.id || field.name"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-sm">{{ field.name }}</span>
|
||||
</label>
|
||||
<div class="input input-bordered input-sm bg-base-200 flex items-center">
|
||||
{{ field.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Documents -->
|
||||
<div
|
||||
v-if="isEditMode || productDocuments.length > 0"
|
||||
class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"
|
||||
>
|
||||
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 class="font-semibold text-base-content">Documents</h2>
|
||||
<p class="text-xs text-base-content/70">
|
||||
{{ isEditMode ? 'Gérez les documents associés à ce produit.' : 'Documents associés à ce produit.' }}
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
|
||||
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</header>
|
||||
<template v-if="isEditMode">
|
||||
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
|
||||
<DocumentUpload
|
||||
v-model="selectedFiles"
|
||||
title="Déposer vos fichiers"
|
||||
subtitle="Formats acceptés : PDF, images, documents…"
|
||||
@files-added="handleFilesAdded"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
|
||||
Téléversement des documents en cours…
|
||||
</p>
|
||||
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
|
||||
Chargement des documents en cours…
|
||||
</p>
|
||||
<DocumentListInline
|
||||
v-else
|
||||
:documents="productDocuments"
|
||||
:can-delete="canEdit"
|
||||
:can-edit="true"
|
||||
:delete-disabled="uploadingDocuments || saving"
|
||||
empty-text="Aucun document n'est associé à ce produit pour le moment."
|
||||
@preview="openPreview"
|
||||
@edit="openEditModal"
|
||||
@delete="removeDocument"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
|
||||
Chargement des documents en cours…
|
||||
</p>
|
||||
<DocumentListInline
|
||||
v-else
|
||||
:documents="productDocuments"
|
||||
:can-delete="false"
|
||||
:can-edit="false"
|
||||
empty-text="Aucun document n'est associé à ce produit pour le moment."
|
||||
@preview="openPreview"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<EntityHistorySection
|
||||
:entries="history"
|
||||
:loading="historyLoading"
|
||||
:error="historyError"
|
||||
:field-labels="historyFieldLabels"
|
||||
/>
|
||||
|
||||
<!-- Save buttons (edit mode only) -->
|
||||
<div v-if="isEditMode" class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||
<button type="button" class="btn btn-ghost" :class="{ 'btn-disabled': saving }" @click="isEditMode = false">
|
||||
Annuler
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitEdition">
|
||||
<span v-if="saving" class="loading loading-spinner loading-sm mr-2" />
|
||||
Enregistrer les modifications
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="isEditMode && product && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
||||
Merci de renseigner tous les champs personnalisés obligatoires.
|
||||
</p>
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="mt-4">
|
||||
<CommentSection
|
||||
entity-type="product"
|
||||
:entity-id="String(route.params.id)"
|
||||
:entity-name="product?.name"
|
||||
show-resolved
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from '#imports'
|
||||
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
||||
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||
import { useProductHistory } from '~/composables/useProductHistory'
|
||||
import { usePermissions } from '~/composables/usePermissions'
|
||||
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
||||
import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
import { getModelType } from '~/services/modelTypes'
|
||||
import type { ProductModelStructure } from '~/shared/types/inventory'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import {
|
||||
type CustomFieldInput,
|
||||
buildCustomFieldInputs,
|
||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||
saveCustomFieldValues as _saveCustomFieldValues,
|
||||
} from '~/shared/utils/customFieldFormUtils'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { getProduct, updateProduct } = useProducts()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const {
|
||||
loadDocumentsByProduct,
|
||||
uploadDocuments: uploadProductDocuments,
|
||||
deleteDocument: deleteProductDocument,
|
||||
updateDocument,
|
||||
} = useDocuments()
|
||||
const { ensureConstructeurs, getConstructeurById } = useConstructeurs()
|
||||
const { fetchLinks, syncLinks } = useConstructeurLinks()
|
||||
const {
|
||||
history,
|
||||
loading: historyLoading,
|
||||
error: historyError,
|
||||
loadHistory,
|
||||
} = useProductHistory()
|
||||
|
||||
const isEditMode = ref(false)
|
||||
|
||||
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
|
||||
const product = ref<any | null>(null)
|
||||
const productType = ref<any | null>(null)
|
||||
const structure = ref<ProductModelStructure | null>(null)
|
||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const selectedFiles = ref<File[]>([])
|
||||
const uploadingDocuments = ref(false)
|
||||
const loadingDocuments = ref(false)
|
||||
const productDocuments = ref<any[]>([])
|
||||
const previewDocument = ref<any | null>(null)
|
||||
const previewVisible = ref(false)
|
||||
const editingDocument = ref<any | null>(null)
|
||||
const editModalVisible = ref(false)
|
||||
|
||||
const historyFieldLabels: Record<string, string> = {
|
||||
name: 'Nom',
|
||||
reference: 'Référence',
|
||||
supplierPrice: 'Prix fournisseur',
|
||||
typeProduct: 'Catégorie',
|
||||
constructeurIds: 'Fournisseurs',
|
||||
}
|
||||
|
||||
const refreshCustomFieldInputs = (
|
||||
structureOverride?: ProductModelStructure | null,
|
||||
valuesOverride?: any[] | null,
|
||||
) => {
|
||||
const nextStructure = structureOverride ?? structure.value ?? null
|
||||
const nextValues = valuesOverride ?? product.value?.customFieldValues ?? null
|
||||
customFieldInputs.value = buildCustomFieldInputs(nextStructure, nextValues)
|
||||
}
|
||||
|
||||
const editionForm = reactive({
|
||||
name: '' as string,
|
||||
reference: '' as string,
|
||||
constructeurIds: [] as string[],
|
||||
supplierPrice: '' as string,
|
||||
})
|
||||
|
||||
const requiredCustomFieldsFilled = computed(() =>
|
||||
_requiredCustomFieldsFilled(customFieldInputs.value),
|
||||
)
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
Boolean(canEdit.value && product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),
|
||||
)
|
||||
|
||||
const structurePreview = computed(() => formatProductStructurePreview(structure.value))
|
||||
|
||||
const visibleCustomFields = computed(() => {
|
||||
if (isEditMode.value) return customFieldInputs.value
|
||||
return customFieldInputs.value.filter(
|
||||
(f) => f.value !== null && f.value !== undefined && f.value !== '',
|
||||
)
|
||||
})
|
||||
|
||||
const openPreview = (doc: any) => {
|
||||
if (!doc || !canPreviewDocument(doc)) return
|
||||
previewDocument.value = doc
|
||||
previewVisible.value = true
|
||||
}
|
||||
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 id = route.params.id
|
||||
if (!id || typeof id !== 'string') {
|
||||
product.value = null
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
const result = await getProduct(id)
|
||||
if (result.success && result.data) {
|
||||
product.value = result.data
|
||||
productDocuments.value = Array.isArray(result.data.documents) ? result.data.documents : []
|
||||
|
||||
await loadProductType()
|
||||
|
||||
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
||||
refreshCustomFieldInputs(undefined, customValues)
|
||||
|
||||
hydrateForm()
|
||||
|
||||
loadHistory(result.data.id).catch(() => {})
|
||||
} else {
|
||||
product.value = null
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const refreshDocuments = async () => {
|
||||
if (!product.value?.id) {
|
||||
return
|
||||
}
|
||||
loadingDocuments.value = true
|
||||
try {
|
||||
const result = await loadDocumentsByProduct(product.value.id, { updateStore: false })
|
||||
if (result.success) {
|
||||
productDocuments.value = Array.isArray(result.data) ? result.data : []
|
||||
}
|
||||
} finally {
|
||||
loadingDocuments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const removeDocument = async (documentId: string | number | null | undefined) => {
|
||||
if (!documentId) {
|
||||
return
|
||||
}
|
||||
const result = await deleteProductDocument(documentId, { updateStore: false })
|
||||
if (result.success) {
|
||||
productDocuments.value = productDocuments.value.filter((doc) => doc.id !== documentId)
|
||||
toast.showSuccess('Document supprimé')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilesAdded = async (files: File[]) => {
|
||||
if (!files?.length || !product.value?.id) {
|
||||
return
|
||||
}
|
||||
uploadingDocuments.value = true
|
||||
try {
|
||||
const result = await uploadProductDocuments(
|
||||
{
|
||||
files,
|
||||
context: { productId: product.value.id },
|
||||
},
|
||||
{ updateStore: false },
|
||||
)
|
||||
if (result.success) {
|
||||
selectedFiles.value = []
|
||||
await refreshDocuments()
|
||||
toast.showSuccess('Document(s) ajouté(s)')
|
||||
} else if (result.error) {
|
||||
toast.showError(result.error)
|
||||
}
|
||||
} finally {
|
||||
uploadingDocuments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadProductType = async () => {
|
||||
const embedded = product.value?.typeProduct
|
||||
if (embedded && typeof embedded === 'object' && embedded.id) {
|
||||
const embeddedStructure = embedded.structure ?? null
|
||||
if (embeddedStructure) {
|
||||
productType.value = embedded
|
||||
structure.value = normalizeProductStructureForSave(embeddedStructure)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!product.value?.typeProductId) {
|
||||
productType.value = embedded ?? null
|
||||
structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const type = await getModelType(product.value.typeProductId)
|
||||
productType.value = type
|
||||
structure.value = normalizeProductStructureForSave(type?.structure ?? null)
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement du type de produit:', error)
|
||||
productType.value = embedded ?? null
|
||||
structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
const hydrateForm = () => {
|
||||
if (!product.value) {
|
||||
return
|
||||
}
|
||||
editionForm.name = product.value.name || ''
|
||||
editionForm.reference = product.value.reference || ''
|
||||
// Load constructeur links
|
||||
fetchLinks('product', String(route.params.id)).then((links) => {
|
||||
constructeurLinks.value = links
|
||||
originalConstructeurLinks.value = links.map(l => ({ ...l }))
|
||||
editionForm.constructeurIds = constructeurIdsFromLinks(links)
|
||||
if (editionForm.constructeurIds.length) {
|
||||
void ensureConstructeurs(editionForm.constructeurIds)
|
||||
}
|
||||
})
|
||||
editionForm.supplierPrice = product.value.supplierPrice !== null && product.value.supplierPrice !== undefined
|
||||
? String(product.value.supplierPrice)
|
||||
: ''
|
||||
refreshCustomFieldInputs(structure.value, product.value.customFieldValues)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => product.value?.documents,
|
||||
(docs) => {
|
||||
if (Array.isArray(docs)) {
|
||||
productDocuments.value = docs
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const submitEdition = async () => {
|
||||
if (!product.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
name: editionForm.name.trim(),
|
||||
reference: editionForm.reference.trim() || null,
|
||||
}
|
||||
|
||||
const rawPrice = typeof editionForm.supplierPrice === 'string'
|
||||
? editionForm.supplierPrice.trim()
|
||||
: editionForm.supplierPrice
|
||||
payload.supplierPrice = rawPrice !== '' && rawPrice !== null && rawPrice !== undefined
|
||||
? Number.isNaN(Number(rawPrice))
|
||||
? null
|
||||
: String(Number(rawPrice))
|
||||
: null
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const result = await updateProduct(product.value.id, payload)
|
||||
if (result.success && result.data?.id) {
|
||||
product.value = result.data
|
||||
const failedFields = await _saveCustomFieldValues(
|
||||
'product',
|
||||
result.data.id,
|
||||
[result.data?.typeProduct?.structure?.customFields],
|
||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||
)
|
||||
if (failedFields.length) {
|
||||
toast.showError(`Impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
|
||||
return
|
||||
}
|
||||
await syncLinks('product', product.value.id, originalConstructeurLinks.value, constructeurLinks.value)
|
||||
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
|
||||
toast.showSuccess('Produit mis à jour avec succès')
|
||||
await loadProduct()
|
||||
isEditMode.value = false
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
|
||||
watch(
|
||||
() => editionForm.constructeurIds,
|
||||
(ids) => {
|
||||
const currentIds = new Set(constructeurLinks.value.map(l => l.constructeurId))
|
||||
for (const id of ids) {
|
||||
if (!currentIds.has(id)) {
|
||||
const resolved = getConstructeurById(id)
|
||||
constructeurLinks.value.push({
|
||||
constructeurId: id,
|
||||
constructeur: resolved ? { id: resolved.id, name: resolved.name } : null,
|
||||
supplierReference: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
constructeurLinks.value = constructeurLinks.value.filter(l => ids.includes(l.constructeurId))
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProduct()
|
||||
if (route.query.edit === 'true' && canEdit.value) {
|
||||
isEditMode.value = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -79,6 +79,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConstructeurLinksTable
|
||||
v-if="constructeurLinks.length"
|
||||
v-model="constructeurLinks"
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
@@ -175,7 +180,10 @@ import { useToast } from '~/composables/useToast'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import type { ProductModelStructure } from '~/shared/types/inventory'
|
||||
import type { ModelType } from '~/services/modelTypes'
|
||||
import {
|
||||
@@ -197,6 +205,8 @@ const toast = useToast()
|
||||
const { upsertCustomFieldValue } = useCustomFields()
|
||||
const { uploadDocuments } = useDocuments()
|
||||
const { canEdit } = usePermissions()
|
||||
const { syncLinks } = useConstructeurLinks()
|
||||
const { getConstructeurById } = useConstructeurs()
|
||||
|
||||
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
||||
const selectedTypeId = ref<string>(initialTypeId.value)
|
||||
@@ -207,6 +217,7 @@ const creationForm = reactive({
|
||||
constructeurIds: [] as string[],
|
||||
supplierPrice: '' as string,
|
||||
})
|
||||
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
const selectedDocuments = ref<File[]>([])
|
||||
const uploadingDocuments = ref(false)
|
||||
|
||||
@@ -300,8 +311,6 @@ const buildPayload = () => {
|
||||
payload.reference = reference
|
||||
}
|
||||
|
||||
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
|
||||
|
||||
const rawPrice = typeof creationForm.supplierPrice === 'string'
|
||||
? creationForm.supplierPrice.trim()
|
||||
: creationForm.supplierPrice
|
||||
@@ -330,9 +339,13 @@ const submitCreation = async () => {
|
||||
const failedFields = await saveCustomFieldValues(result.data.id)
|
||||
if (failedFields.length) {
|
||||
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=true`)
|
||||
return
|
||||
}
|
||||
// Sync constructeur links after creation
|
||||
if (constructeurLinks.value.length) {
|
||||
await syncLinks('product', productId, [], constructeurLinks.value)
|
||||
}
|
||||
if (selectedDocuments.value.length) {
|
||||
uploadingDocuments.value = true
|
||||
const uploadResult = await uploadDocuments(
|
||||
@@ -352,7 +365,7 @@ const submitCreation = async () => {
|
||||
}
|
||||
}
|
||||
toast.showSuccess('Produit créé avec succès')
|
||||
await router.push('/product-catalog')
|
||||
await router.replace(`/product/${productId}?edit=true`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.showError(error?.message || 'Erreur lors de la création du produit')
|
||||
@@ -395,6 +408,25 @@ const saveCustomFieldValues = async (productId: string) => {
|
||||
return failed
|
||||
}
|
||||
|
||||
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
|
||||
watch(
|
||||
() => creationForm.constructeurIds,
|
||||
(ids) => {
|
||||
const currentIds = new Set(constructeurLinks.value.map(l => l.constructeurId))
|
||||
for (const id of ids) {
|
||||
if (!currentIds.has(id)) {
|
||||
const resolved = getConstructeurById(id)
|
||||
constructeurLinks.value.push({
|
||||
constructeurId: id,
|
||||
constructeur: resolved ? { id: resolved.id, name: resolved.name } : null,
|
||||
supplierReference: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
constructeurLinks.value = constructeurLinks.value.filter(l => ids.includes(l.constructeurId))
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProductTypes()
|
||||
if (selectedTypeId.value && !selectedType.value) {
|
||||
|
||||
@@ -23,11 +23,15 @@ export interface BaseModelTypePayload {
|
||||
export interface ComponentModelTypePayload extends BaseModelTypePayload {
|
||||
category: 'COMPONENT';
|
||||
structure?: ComponentModelStructure | null;
|
||||
referenceFormula?: string | null;
|
||||
requiredFieldsForReference?: string[] | null;
|
||||
}
|
||||
|
||||
export interface PieceModelTypePayload extends BaseModelTypePayload {
|
||||
category: 'PIECE';
|
||||
structure?: PieceModelStructure | null;
|
||||
referenceFormula?: string | null;
|
||||
requiredFieldsForReference?: string[] | null;
|
||||
}
|
||||
|
||||
export interface ProductModelTypePayload extends BaseModelTypePayload {
|
||||
@@ -46,6 +50,8 @@ export interface ModelType extends BaseModelTypePayload {
|
||||
updatedAt: string;
|
||||
category: ModelCategory;
|
||||
structure: ModelTypeStructure;
|
||||
referenceFormula?: string | null;
|
||||
requiredFieldsForReference?: string[] | null;
|
||||
}
|
||||
|
||||
export interface ModelTypeListParams {
|
||||
|
||||
@@ -7,6 +7,32 @@ export interface ConstructeurSummary {
|
||||
phone?: string | null;
|
||||
}
|
||||
|
||||
export interface ConstructeurLinkEntry {
|
||||
linkId?: string;
|
||||
constructeurId: string;
|
||||
constructeur?: ConstructeurSummary | null;
|
||||
supplierReference: string | null;
|
||||
}
|
||||
|
||||
export const constructeurIdsFromLinks = (links: ConstructeurLinkEntry[]): string[] =>
|
||||
links.map(l => l.constructeurId).filter(Boolean);
|
||||
|
||||
export const parseConstructeurLinksFromApi = (
|
||||
apiLinks: any[],
|
||||
): ConstructeurLinkEntry[] => {
|
||||
if (!Array.isArray(apiLinks)) return [];
|
||||
return apiLinks
|
||||
.filter(link => link && typeof link === 'object')
|
||||
.map(link => ({
|
||||
linkId: link.id || (typeof link['@id'] === 'string' ? link['@id'].split('/').pop() : undefined),
|
||||
constructeurId: typeof link.constructeur === 'string'
|
||||
? link.constructeur.split('/').pop()!
|
||||
: link.constructeur?.id || '',
|
||||
constructeur: typeof link.constructeur === 'object' ? link.constructeur : null,
|
||||
supplierReference: link.supplierReference ?? null,
|
||||
}));
|
||||
};
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
@@ -113,51 +139,3 @@ export const formatConstructeurContact = (
|
||||
return [constructeur.email, phone].filter(Boolean).join(' • ');
|
||||
};
|
||||
|
||||
export const buildConstructeurRequestPayload = <T extends Record<string, any>>(
|
||||
payload: T,
|
||||
): T & { constructeurs?: string[] } => {
|
||||
const collected = new Set(uniqueConstructeurIds(
|
||||
payload?.constructeurIds,
|
||||
payload?.constructeurId,
|
||||
payload?.constructeur,
|
||||
payload?.constructeurs,
|
||||
));
|
||||
|
||||
if (!collected.size) {
|
||||
const fallbackLists = [
|
||||
payload?.constructeurIds,
|
||||
payload?.constructeurs,
|
||||
];
|
||||
fallbackLists.forEach((list) => {
|
||||
if (!Array.isArray(list)) {
|
||||
return;
|
||||
}
|
||||
list.forEach((item) => {
|
||||
if (typeof item === 'string') {
|
||||
const id = toStringId(item);
|
||||
if (id) {
|
||||
collected.add(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isObject(item) && typeof item.id === 'string') {
|
||||
collected.add(item.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const ids = Array.from(collected);
|
||||
|
||||
const next = { ...payload } as Record<string, any>;
|
||||
delete next.constructeurId;
|
||||
delete next.constructeur;
|
||||
delete next.constructeurs;
|
||||
delete next.constructeurIds;
|
||||
|
||||
if (ids.length) {
|
||||
next.constructeurs = ids.map((id) => `/api/constructeurs/${id}`);
|
||||
}
|
||||
|
||||
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) {
|
||||
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
|
||||
result.orderIndex = orderIndex
|
||||
return result
|
||||
@@ -125,6 +138,8 @@ const hydratePieceCustomFields = (fields: any[]): PieceModelStructureEditorField
|
||||
? field.options.join('\n')
|
||||
: '',
|
||||
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
|
||||
value?: unknown
|
||||
defaultValue?: string | null
|
||||
id?: string
|
||||
customFieldId?: string
|
||||
}
|
||||
|
||||
export interface PieceModelProduct {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
export const historyActionLabel = (action: string): string => {
|
||||
if (action === 'create') return 'Création'
|
||||
if (action === 'delete') return 'Suppression'
|
||||
if (action === 'restore') return 'Restauration'
|
||||
return 'Modification'
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,8 @@ export const componentOptionLabel = (component?: ComponentOption | null): string
|
||||
if (!component) {
|
||||
return 'Composant sans nom'
|
||||
}
|
||||
return component.name || 'Composant sans nom'
|
||||
const name = component.name || 'Composant sans nom'
|
||||
return component.reference ? `${name} — ${component.reference}` : name
|
||||
}
|
||||
|
||||
export const componentOptionDescription = (component?: ComponentOption | null): string => {
|
||||
@@ -110,9 +111,10 @@ export const componentOptionDescription = (component?: ComponentOption | null):
|
||||
|
||||
export const pieceOptionLabel = (piece?: PieceOption | null): string => {
|
||||
if (!piece) {
|
||||
return 'Pi\u00e8ce'
|
||||
return 'Pièce'
|
||||
}
|
||||
return piece.name || 'Pi\u00e8ce'
|
||||
const name = piece.name || 'Pièce'
|
||||
return piece.reference ? `${name} — ${piece.reference}` : name
|
||||
}
|
||||
|
||||
export const pieceOptionDescription = (piece?: PieceOption | null): string => {
|
||||
@@ -139,7 +141,8 @@ export const productOptionLabel = (product?: ProductOption | null): string => {
|
||||
if (!product) {
|
||||
return 'Produit'
|
||||
}
|
||||
return product.name || product.reference || 'Produit'
|
||||
const name = product.name || 'Produit'
|
||||
return product.reference ? `${name} — ${product.reference}` : name
|
||||
}
|
||||
|
||||
export const productOptionDescription = (product?: ProductOption | null): string => {
|
||||
|
||||
@@ -52,10 +52,10 @@ export default defineNuxtConfig({
|
||||
appVersion: appVersion,
|
||||
apiTimeout: process.env.NUXT_PUBLIC_API_TIMEOUT || '30000',
|
||||
requestTimeout: process.env.NUXT_PUBLIC_REQUEST_TIMEOUT || '10000',
|
||||
enableDebug: process.env.NUXT_PUBLIC_ENABLE_DEBUG || 'true',
|
||||
enableDebug: process.env.NUXT_PUBLIC_ENABLE_DEBUG || 'false',
|
||||
enableAnalytics: process.env.NUXT_PUBLIC_ENABLE_ANALYTICS || 'false',
|
||||
csrfToken: process.env.NUXT_PUBLIC_CSRF_TOKEN || '',
|
||||
logLevel: process.env.NUXT_PUBLIC_LOG_LEVEL || 'debug'
|
||||
logLevel: process.env.NUXT_PUBLIC_LOG_LEVEL || 'warn'
|
||||
}
|
||||
},
|
||||
vite: {
|
||||
|
||||
Reference in New Issue
Block a user