42 Commits

Author SHA1 Message Date
Matthieu
a76f25321a docs(changelog) : update changelog
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:10:29 +01:00
Matthieu
2410ebb7dc fix(custom-fields) : preserve defaultValue and IDs in piece structure editor
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:10:26 +01:00
Matthieu
1d6c520945 fix(navigation) : use router.replace after entity creation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:10:22 +01:00
Matthieu
10ad7b7f41 feat(comments) : add file attachments UI for comments
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:10:19 +01:00
Matthieu
aebe7ed586 fix(machine-detail) : hide empty sections in consultation mode
Documents, products, components and pieces cards are now hidden when
empty in consultation mode. They remain visible in edit mode so users
can still add items. Addresses Geoffrey's feedback (INV-7).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:08:43 +01:00
Matthieu
5b42bf1504 fix(custom-fields) : use structure.customFields for definition lookup
The definitionSources passed to saveCustomFieldValues were pointing at
properties not serialized by the API (typeComposant.customFields,
typePiece.pieceCustomFields). Changed to structure.customFields which
is the correct serialized path, preventing orphan custom field creation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:26:38 +01:00
Matthieu
5ab63e8b27 docs(changelog) : add v1.10.0 release notes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:14:08 +01:00
Matthieu
4db832bc8c feat(documents) : add type column, filter, and edit to documents page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:51:26 +01:00
Matthieu
736a8bccf9 feat(documents) : wire DocumentEditModal and type select in all entity pages
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:38:30 +01:00
Matthieu
bd69b37524 feat(documents) : add type badge and edit button to DocumentListInline
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:33:59 +01:00
Matthieu
e7402dda4d feat(documents) : add DocumentEditModal component
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:32:09 +01:00
Matthieu
6b0d2d1b0a feat(documents) : add type select to DocumentUpload component
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:32:09 +01:00
Matthieu
7a4a77e3fc feat(documents) : add document type constants and updateDocument method
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:31:52 +01:00
Matthieu
2e82e854bf feat(machines) : multi-select site checkboxes, alphabetical sort, OR search param
- Replace site dropdown with inline checkboxes for multi-site filtering
- Sort machines alphabetically (localeCompare fr)
- Switch catalog search from ?name= to ?q= for OR search on name/reference

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:15:16 +01:00
Matthieu
ac860d3165 fix(constructeurs) : always send constructeurs array in PATCH payload 2026-03-23 13:52:39 +01:00
Matthieu
8176635eb8 fix(machine) : use linkId instead of composantId when deleting a component from machine
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:35:43 +01:00
Matthieu
a730a18794 fix(creation) : redirect to edit page after creating composant, piece, or product
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:26:18 +01:00
Matthieu
40d0753637 fix(model-types) : extract error field from 409 response for user-friendly messages
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 11:47:51 +01:00
Matthieu
db630e315b fix(custom-fields) : preserve CustomField ID in piece structure payload
Prevents data loss when saving ModelType: the frontend now sends existing
CustomField IDs so the backend can match them instead of deleting and recreating.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:07:37 +01:00
Matthieu
53530dc16d fix(piece-edit) : stay on page after saving piece
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 16:12:15 +01:00
Matthieu
974b74ee9f fix(SearchSelect) : render option-description slot even without optionDescription prop
The v-if on resolveDescription() was hiding the entire slot when
optionDescription prop was not provided. Now checks for slot presence
first, allowing custom formatDescription in PieceSelect/ProductSelect/
ComposantSelect to render properly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 15:54:24 +01:00
Matthieu
ab05ce589d fix(ui) : show type name and ref in slot selects, stay on page after save
- PieceSelect, ProductSelect, ComposantSelect: show type name and
  "Ref." prefix in dropdown descriptions (matching create page format)
- Category edit pages (component, piece, product): stay on page after
  successful save instead of navigating back to list
- Component and product edit pages: same — stay on page after save

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 15:52:02 +01:00
Matthieu
ce3f081a0a refactor(category) : remove quantity field from category structure editor
Quantity is now managed per-component on the component edit page,
not at the category level.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:52:49 +01:00
Matthieu
63fba4138e perf(component-edit) : remove redundant full-catalog loads on mount
The 3 loadPieces/loadProducts/loadComposants(200) calls on mount were
redundant since select components now load filtered data server-side.
Removing them eliminates ~3 heavy API calls + constructeur resolution
per page load.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:50:34 +01:00
Matthieu
d58a8c2479 feat(component-edit) : add inline quantity input for piece slots
Quantity can now be edited directly on the component edit page next to
each piece selector, instead of only being defined in the category.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:34:51 +01:00
Matthieu
81f7b1a9ac feat(component-edit) : add link to category edit page from component editor
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:32:54 +01:00
Matthieu
9e303426a7 fix(slots) : filter slot select options server-side instead of client-side
PieceSelect, ProductSelect and ComposantSelect were loading up to 200
items then filtering client-side by typeId. If the matching items were
not in the first 200, the dropdown appeared empty.

Now each select component uses API Platform filters (typePiece,
typeProduct, typeComposant) to fetch only relevant items server-side,
with local state to avoid overwriting the global catalog cache.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 11:59:51 +01:00
Matthieu
d4fc0f1fee fix(slots) : check API response before updating local state on slot selection
The save functions (savePieceSlotSelection, saveProductSlotSelection,
saveSubcomponentSlotSelection) were not checking result.success before
updating local state and showing success toast. Since useApi.patch()
never throws, the catch block was dead code and errors were silently
ignored while the UI showed success.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 11:31:19 +01:00
Matthieu
f8403ddfbc docs(changelog) : add v1.9.1 release notes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 10:53:21 +01:00
Matthieu
428da471d1 fix(component-edit) : force reload catalog to display pre-selected slot items
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 10:32:07 +01:00
Matthieu
271844efb1 feat(component-edit) : add interactive slot selectors for pieces, products and subcomponents
Replace read-only selections display with PieceSelect, ProductSelect, ComposantSelect
components that allow changing the assigned item in each slot directly from the edit page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:40:11 +01:00
Matthieu
07cad19988 feat(sync) : wire sync flow into category edit pages with confirmation modal 2026-03-13 13:57:58 +01:00
Matthieu
8dacad7a59 refactor(sync) : remove restrictedMode and add sync service + confirmation modal 2026-03-13 13:49:24 +01:00
Matthieu
5912216a89 fix(piece) : persist slot quantity on blur and send prix as string
- Save composant piece slot quantity via PATCH on blur
- Pass slotId through hierarchy and selection entries
- Send prix as string (not number) to match backend expectation
- Show quantity in view mode when > 1
- Allow quantity edit for all pieces (not just root-level)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:19:09 +01:00
Matthieu
139ba183de fix(custom-fields) : include orphan values with embedded definitions in edit pages
After JSON-to-tables migration, custom field definitions not linked to
a ModelType were invisible on edit pages because buildCustomFieldInputs
only mapped over structure definitions. Now also includes values whose
embedded customField definition has no matching structure entry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:18:47 +01:00
Matthieu
9fef009610 feat(skeleton) : remove skeleton JSON field references — use structure API field directly
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:11:07 +01:00
Matthieu
4a3bceffa1 feat(machine) : afficher quantité pièces + pièces incluses des composants
- MachinePiecesCard : passer isEditMode au PieceItem + forward event update
- useMachineHierarchy : mapper quantity depuis le backend + construire
  les pièces de structure du composant en lecture seule
- useMachineDetailUpdates : PATCH MachinePieceLink.quantity + fix reference null
- ComponentItem : séparer pièces liées / pièces incluses par défaut
- useEntityDocuments : skip chargement documents pour pièces de structure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:22:20 +01:00
Matthieu
50d8dde6d5 fix(piece) : include structure in composant edit PATCH payload for quantity persistence 2026-03-12 15:02:09 +01:00
Matthieu
9b40f9f2c7 feat(piece) : add quantity display and input to composant edit page 2026-03-12 14:40:55 +01:00
Matthieu
721963449b feat(piece) : display and edit quantity on machine piece items 2026-03-12 14:32:50 +01:00
Matthieu
22ba9a8d05 feat(piece) : add quantity input to composant structure editor
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:11:58 +01:00
Matthieu
695d56a6d3 feat(piece) : add quantity field to piece types, sanitization and hydration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:08:43 +01:00
64 changed files with 2402 additions and 1346 deletions

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

View File

@@ -19,24 +19,55 @@
</div>
<!-- Formulaire d'ajout -->
<div class="flex gap-2">
<textarea
v-model="newContent"
class="textarea textarea-bordered flex-1 text-sm"
rows="2"
placeholder="Ajouter un commentaire..."
:disabled="submitting"
@keydown.ctrl.enter="handleSubmit"
/>
<button
type="button"
class="btn btn-primary btn-sm self-end"
:disabled="!newContent.trim() || submitting"
@click="handleSubmit"
>
<span v-if="submitting" class="loading loading-spinner loading-xs" />
<IconLucideSend v-else class="w-4 h-4" />
</button>
<div class="space-y-2">
<div class="flex gap-2">
<textarea
v-model="newContent"
class="textarea textarea-bordered flex-1 text-sm"
rows="2"
placeholder="Ajouter un commentaire..."
: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 btn-square"
:disabled="!newContent.trim() || submitting"
@click="handleSubmit"
>
<span v-if="submitting" class="loading loading-spinner loading-xs" />
<IconLucideSend v-else class="w-4 h-4" />
</button>
</div>
</div>
<!-- Selected files preview -->
<div v-if="selectedFiles.length" class="flex flex-wrap gap-1">
<span
v-for="(file, i) in selectedFiles"
:key="i"
class="badge badge-sm badge-outline gap-1"
>
<IconLucideFile class="w-3 h-3" />
{{ file.name }}
<button type="button" class="ml-1" @click="removeFile(i)">
<IconLucideX class="w-3 h-3" />
</button>
</span>
</div>
</div>
<!-- Liste des commentaires ouverts -->
@@ -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()
}
}

View File

@@ -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">
@@ -208,21 +214,23 @@
<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>
<!-- Component Pieces -->
<div v-if="component.pieces && component.pieces.length > 0" class="space-y-2">
<!-- Component Pieces (real MachinePieceLinks) -->
<div v-if="linkedPieces.length > 0" class="space-y-2">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
Pièces du composant
</p>
<div class="space-y-2">
<PieceItem
v-for="piece in component.pieces"
v-for="piece in linkedPieces"
:key="piece.id"
:piece="piece"
:is-edit-mode="isEditMode"
@@ -233,6 +241,21 @@
</div>
</div>
<!-- Structure pieces (read-only, from composant definition) -->
<div v-if="structurePieces.length > 0" class="space-y-2">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
Pièces incluses par défaut
</p>
<div class="space-y-2">
<PieceItem
v-for="piece in structurePieces"
:key="piece.id"
:piece="piece"
:is-edit-mode="false"
/>
</div>
</div>
<!-- Sub Components -->
<div v-if="childComponents.length > 0" class="space-y-2">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
@@ -304,6 +327,7 @@ const {
ensureDocumentsLoaded,
handleFilesAdded,
removeDocument,
editDocument,
} = useEntityDocuments({ entity: () => props.component, entityType: 'composant' })
const {
@@ -318,6 +342,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)
@@ -341,6 +380,14 @@ const childComponents = computed(() => {
return Array.isArray(list) ? list : []
})
// --- Pieces split: real links vs structure definitions ---
const allPieces = computed(() => {
const list = props.component.pieces
return Array.isArray(list) ? list : []
})
const linkedPieces = computed(() => allPieces.value.filter((p) => !p._structurePiece))
const structurePieces = computed(() => allPieces.value.filter((p) => p._structurePiece))
// --- Constructeurs ---
const { constructeurs } = useConstructeurs()

View File

@@ -10,7 +10,6 @@
:locked-type-label="displayedRootTypeLabel"
:allow-subcomponents="allowSubcomponents"
:max-subcomponent-depth="maxSubcomponentDepth"
:restricted-mode="restrictedMode"
is-root
/>
</div>
@@ -56,10 +55,6 @@ const props = defineProps({
type: Number,
default: Infinity,
},
restrictedMode: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue'])

View File

@@ -0,0 +1,116 @@
<template>
<div class="space-y-1">
<SearchSelect
:model-value="modelValue ?? undefined"
:options="composantOptions"
:loading="loading"
:placeholder="placeholder"
:empty-text="emptyText"
size="sm"
option-value="id"
option-label="name"
:disabled="disabled"
@update:modelValue="updateValue"
>
<template #option-description="{ option }">
<span class="text-xs text-base-content/60">
{{ formatDescription(option) }}
</span>
</template>
</SearchSelect>
<p v-if="helperText" class="text-xs text-base-content/60">
{{ helperText }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import SearchSelect from '~/components/common/SearchSelect.vue'
import { useComposants } from '~/composables/useComposants'
const props = withDefaults(
defineProps<{
modelValue?: string | null
placeholder?: string
emptyText?: string
helperText?: string
disabled?: boolean
typeComposantId?: string | null
}>(),
{
modelValue: '',
placeholder: 'Sélectionner un composant…',
emptyText: 'Aucun composant disponible',
helperText: '',
disabled: false,
typeComposantId: null,
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void
}>()
const { loading: globalLoading, loadComposants } = useComposants()
const localComposants = ref<any[]>([])
const localLoading = ref(false)
const loading = computed(() => localLoading.value || globalLoading.value)
const composantOptions = computed(() => localComposants.value)
const loadFilteredComposants = async () => {
if (!props.typeComposantId) return
localLoading.value = true
try {
const result = await loadComposants({ typeComposantId: props.typeComposantId, itemsPerPage: 500, force: true })
if (result.success && result.data?.items) {
localComposants.value = result.data.items
}
}
catch (error: unknown) {
console.error('Erreur lors du chargement des composants:', error)
}
finally {
localLoading.value = false
}
}
onMounted(() => {
loadFilteredComposants()
})
watch(
() => props.typeComposantId,
() => {
loadFilteredComposants()
},
)
const updateValue = (value: string | number | null | undefined) => {
if (value === undefined || value === null || value === '') {
emit('update:modelValue', null)
return
}
emit('update:modelValue', String(value))
}
const formatDescription = (option: any) => {
const parts: string[] = []
const typeName = option?.typeComposant?.name
if (typeName) {
parts.push(typeName)
}
if (option?.reference) {
parts.push(`Ref. ${option.reference}`)
}
if (option?.prix !== undefined && option.prix !== null) {
const price = Number(option.prix)
if (!Number.isNaN(price)) {
parts.push(`${price.toFixed(2)}`)
}
}
return parts.length ? parts.join(' • ') : 'Sans référence'
}
</script>

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

View File

@@ -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)

View File

@@ -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">
@@ -24,6 +30,12 @@
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold">
{{ pieceData.name }}
<span
v-if="displayQuantity > 1"
class="text-sm font-normal text-base-content/60 ml-1"
>
×{{ displayQuantity }}
</span>
</h3>
<div class="flex flex-wrap gap-2 mt-2">
<span v-if="piece.parentComponentName" class="badge badge-ghost badge-sm">
@@ -63,6 +75,23 @@
<div v-show="!isCollapsed" class="space-y-4">
<div class="p-4 bg-base-100 border border-base-200 rounded-lg">
<div class="space-y-2 text-sm">
<div v-if="isEditMode" class="form-control">
<label class="label">
<span class="label-text text-sm">Quantité</span>
</label>
<input
v-model.number="pieceData.quantity"
type="number"
min="1"
step="1"
class="input input-bordered input-sm md:input-md w-24"
@blur="updatePiece"
/>
</div>
<div v-else-if="displayQuantity > 1">
<span class="font-medium">Quantité:</span>
<span class="ml-2">{{ displayQuantity }}</span>
</div>
<div>
<span class="font-medium">Référence:</span>
<input
@@ -224,9 +253,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>
@@ -272,6 +303,11 @@ const pieceData = reactive({
reference: props.piece.reference || '',
prix: props.piece.prix || '',
productId: props.piece.product?.id || props.piece.productId || null,
quantity: props.piece.quantity ?? 1,
})
const displayQuantity = computed(() => {
return pieceData.quantity ?? 1
})
// --- Products ---
@@ -301,6 +337,7 @@ const {
refreshDocuments,
handleFilesAdded,
removeDocument,
editDocument,
} = useEntityDocuments({ entity: () => props.piece, entityType: 'piece' })
const {
@@ -315,6 +352,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)
@@ -432,13 +484,14 @@ const updatePiece = () => {
let parsedPrice = null
if (prixValue !== null && prixValue !== undefined && String(prixValue).trim().length > 0) {
const numeric = Number(prixValue)
if (!Number.isNaN(numeric)) parsedPrice = numeric
if (!Number.isNaN(numeric)) parsedPrice = String(numeric)
}
const product = selectedProduct.value ? { ...selectedProduct.value } : null
emit('update', {
...props.piece,
...pieceData,
prix: parsedPrice,
quantity: pieceData.quantity ?? 1,
productId: pieceData.productId || null,
product,
constructeurIds: pieceConstructeurIds.value,
@@ -478,11 +531,12 @@ watch(
)
watch(
() => [props.piece.name, props.piece.reference, props.piece.prix],
() => [props.piece.name, props.piece.reference, props.piece.prix, props.piece.quantity],
() => {
pieceData.name = props.piece.name || ''
pieceData.reference = props.piece.reference || ''
pieceData.prix = props.piece.prix || ''
pieceData.quantity = props.piece.quantity ?? 1
},
)
@@ -490,6 +544,7 @@ onMounted(() => {
pieceData.name = props.piece.name || ''
pieceData.reference = props.piece.reference || ''
pieceData.prix = props.piece.prix || ''
pieceData.quantity = props.piece.quantity ?? 1
loadProducts().catch(() => {})
if (pieceData.productId) ensureProductLoaded(pieceData.productId)
if (!props.piece.documents?.length) refreshDocuments()

View File

@@ -29,7 +29,6 @@
<select
v-model="product.typeProductId"
class="select select-bordered select-xs"
:disabled="isProductLocked(product)"
@change="handleProductTypeSelect(product)"
>
<option value="">
@@ -46,26 +45,16 @@
</div>
</div>
<button
v-if="!isProductLocked(product)"
type="button"
class="btn btn-error btn-xs btn-square"
@click="removeProduct(index)"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
<div v-else class="tooltip tooltip-left" data-tip="Ce produit ne peut pas être supprimé car des éléments utilisent cette catégorie">
<button
type="button"
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
disabled
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</li>
</ul>
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addProduct">
<button type="button" class="btn btn-outline btn-xs" @click="addProduct">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
@@ -111,7 +100,7 @@
class="input input-bordered input-xs"
placeholder="Nom du champ"
>
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isFieldLocked(field)">
<select v-model="field.type" class="select select-bordered select-xs">
<option value="text">
Texte
</option>
@@ -131,7 +120,7 @@
</div>
<div class="flex items-center gap-2 text-xs">
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" :disabled="isFieldLocked(field)">
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs">
Obligatoire
</div>
@@ -140,27 +129,16 @@
v-model="field.optionsText"
class="textarea textarea-bordered textarea-xs h-20"
placeholder="Option 1&#10;Option 2"
:disabled="isFieldLocked(field)"
/>
</div>
<button
v-if="!isFieldLocked(field)"
type="button"
class="btn btn-error btn-xs btn-square"
@click="removeField(index)"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
<div v-else class="tooltip tooltip-left" data-tip="Ce champ ne peut pas être supprimé car des éléments utilisent cette catégorie">
<button
type="button"
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
disabled
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</li>
</ul>
@@ -183,7 +161,6 @@ defineOptions({ name: 'PieceModelStructureEditor' })
const props = defineProps<{
modelValue?: PieceModelStructure | null
restrictedMode?: boolean
}>()
const emit = defineEmits<{
@@ -194,9 +171,6 @@ const {
fields,
products,
productTypeOptions,
restrictedMode,
isFieldLocked,
isProductLocked,
formatProductTypeOption,
handleProductTypeSelect,
addProduct,

View File

@@ -0,0 +1,116 @@
<template>
<div class="space-y-1">
<SearchSelect
:model-value="modelValue ?? undefined"
:options="pieceOptions"
:loading="loading"
:placeholder="placeholder"
:empty-text="emptyText"
size="sm"
option-value="id"
option-label="name"
:disabled="disabled"
@update:modelValue="updateValue"
>
<template #option-description="{ option }">
<span class="text-xs text-base-content/60">
{{ formatDescription(option) }}
</span>
</template>
</SearchSelect>
<p v-if="helperText" class="text-xs text-base-content/60">
{{ helperText }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import SearchSelect from '~/components/common/SearchSelect.vue'
import { usePieces } from '~/composables/usePieces'
const props = withDefaults(
defineProps<{
modelValue?: string | null
placeholder?: string
emptyText?: string
helperText?: string
disabled?: boolean
typePieceId?: string | null
}>(),
{
modelValue: '',
placeholder: 'Sélectionner une pièce…',
emptyText: 'Aucune pièce disponible',
helperText: '',
disabled: false,
typePieceId: null,
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void
}>()
const { loading: globalLoading, loadPieces } = usePieces()
const localPieces = ref<any[]>([])
const localLoading = ref(false)
const loading = computed(() => localLoading.value || globalLoading.value)
const pieceOptions = computed(() => localPieces.value)
const loadFilteredPieces = async () => {
if (!props.typePieceId) return
localLoading.value = true
try {
const result = await loadPieces({ typePieceId: props.typePieceId, itemsPerPage: 500, force: true })
if (result.success && result.data?.items) {
localPieces.value = result.data.items
}
}
catch (error: unknown) {
console.error('Erreur lors du chargement des pièces:', error)
}
finally {
localLoading.value = false
}
}
onMounted(() => {
loadFilteredPieces()
})
watch(
() => props.typePieceId,
() => {
loadFilteredPieces()
},
)
const updateValue = (value: string | number | null | undefined) => {
if (value === undefined || value === null || value === '') {
emit('update:modelValue', null)
return
}
emit('update:modelValue', String(value))
}
const formatDescription = (option: any) => {
const parts: string[] = []
const typeName = option?.typePiece?.name
if (typeName) {
parts.push(typeName)
}
if (option?.reference) {
parts.push(`Ref. ${option.reference}`)
}
if (option?.prix !== undefined && option.prix !== null) {
const price = Number(option.prix)
if (!Number.isNaN(price)) {
parts.push(`${price.toFixed(2)}`)
}
}
return parts.length ? parts.join(' • ') : 'Sans référence'
}
</script>

View File

@@ -25,7 +25,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 +52,39 @@ 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 () => {
if (!props.typeProductId) return
localLoading.value = true
try {
const result = await loadProducts({ typeProductId: props.typeProductId, itemsPerPage: 500, force: true })
if (result.success && result.data?.items) {
localProducts.value = result.data.items
}
}
const allowedTypeId = String(props.typeProductId)
return baseOptions.filter((product) => {
const typeId =
product?.typeProductId ||
product?.typeProduct?.id ||
null
return typeId ? String(typeId) === allowedTypeId : false
})
})
catch (error: unknown) {
console.error('Erreur lors du chargement des produits:', error)
}
finally {
localLoading.value = false
}
}
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') {
const exists = productOptions.value.some((product) => product.id === value)
if (!exists && productOptions.value.length === 0 && !loading.value) {
loadProducts().catch((error) => {
console.error('Erreur lors du chargement des produits:', error)
})
}
}
() => props.typeProductId,
() => {
loadFilteredProducts()
},
)
@@ -102,8 +98,12 @@ const updateValue = (value: string | number | null | undefined) => {
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)

View File

@@ -109,7 +109,7 @@
class="input input-bordered input-xs"
placeholder="Nom du champ"
/>
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isCustomFieldLocked(index)">
<select v-model="field.type" class="select select-bordered select-xs">
<option value="text">Texte</option>
<option value="number">Nombre</option>
<option value="select">Liste</option>
@@ -118,7 +118,7 @@
</select>
</div>
<div class="flex items-center gap-2 text-xs">
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" :disabled="isCustomFieldLocked(index)" />
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" />
Obligatoire
</div>
<textarea
@@ -126,26 +126,15 @@
v-model="field.optionsText"
class="textarea textarea-bordered textarea-xs h-20"
placeholder="Option 1&#10;Option 2"
:disabled="isCustomFieldLocked(index)"
></textarea>
</div>
<button
v-if="!isCustomFieldLocked(index)"
type="button"
class="btn btn-error btn-xs btn-square"
@click="removeCustomField(index)"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
<div v-else class="tooltip tooltip-left" data-tip="Ce champ ne peut pas être supprimé car des éléments utilisent cette catégorie">
<button
type="button"
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
disabled
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
@@ -189,7 +178,6 @@
<select
v-model="product.typeProductId"
class="select select-bordered select-xs"
:disabled="isProductLocked(index)"
@change="handleProductTypeSelect(product)"
>
<option value="">
@@ -205,22 +193,13 @@
</select>
</div>
</div>
<button v-if="!isProductLocked(index)" type="button" class="btn btn-error btn-xs btn-square" @click="removeProduct(index)">
<button type="button" class="btn btn-error btn-xs btn-square" @click="removeProduct(index)">
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
<div v-else class="tooltip tooltip-left" data-tip="Ce produit ne peut pas être supprimé car des éléments utilisent cette catégorie">
<button
type="button"
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
disabled
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addProduct">
<button type="button" class="btn btn-outline btn-xs" @click="addProduct">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
@@ -261,7 +240,6 @@
<select
v-model="piece.typePieceId"
class="select select-bordered select-xs"
:disabled="isPieceLocked(index)"
@change="handlePieceTypeSelect(piece)"
>
<option value="">
@@ -280,19 +258,15 @@
{{ piece.typePieceId ? `Sélection : ${getPieceTypeLabel(piece.typePieceId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
</p>
</div>
<!-- Quantity is set per-component on the component edit page -->
</div>
<button v-if="!isPieceLocked(index)" type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)">
<button type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)">
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
<div v-else class="tooltip tooltip-left" data-tip="Cette pièce ne peut pas être supprimée">
<button type="button" class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed" disabled>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addPiece">
<button type="button" class="btn btn-outline btn-xs" @click="addPiece">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
@@ -334,14 +308,12 @@
:product-types="productTypes"
:allow-subcomponents="childAllowSubcomponents"
:max-subcomponent-depth="maxSubcomponentDepth"
:restricted-mode="restrictedMode"
:is-locked="isSubcomponentLocked(index)"
@remove="removeSubComponent(index)"
/>
</div>
</div>
<button
v-if="canManageSubcomponents && !restrictedMode"
v-if="canManageSubcomponents"
type="button"
class="btn btn-outline btn-xs"
@click="addSubComponent"
@@ -374,7 +346,6 @@ const props = withDefaults(defineProps<{
lockedTypeLabel?: string
allowSubcomponents?: boolean
maxSubcomponentDepth?: number
restrictedMode?: boolean
isLocked?: boolean
}>(), {
depth: 0,
@@ -386,19 +357,13 @@ const props = withDefaults(defineProps<{
lockedTypeLabel: '',
allowSubcomponents: true,
maxSubcomponentDepth: Infinity,
restrictedMode: false,
isLocked: false,
})
const emit = defineEmits(['remove'])
const {
isCustomFieldLocked,
isPieceLocked,
isProductLocked,
isSubcomponentLocked,
isLocked,
restrictedMode,
componentTypes,
pieceTypes,
productTypes,

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
import type { SyncPreviewResult } from '~/services/modelTypes';
const props = defineProps<{
preview: SyncPreviewResult | null;
open: boolean;
loading: boolean;
}>();
const emit = defineEmits<{
confirm: [];
cancel: [];
}>();
const dialogRef = ref<HTMLDialogElement>();
watch(() => props.open, (isOpen) => {
if (isOpen) {
dialogRef.value?.showModal();
}
else {
dialogRef.value?.close();
}
});
const hasDeletions = computed(() => {
if (!props.preview) return false;
return Object.values(props.preview.deletions).some(v => v > 0);
});
const hasModifications = computed(() => {
if (!props.preview) return false;
return Object.values(props.preview.modifications).some(v => v > 0);
});
const totalAdditions = computed(() => {
if (!props.preview) return 0;
return Object.values(props.preview.additions).reduce((sum, v) => sum + v, 0);
});
const totalDeletions = computed(() => {
if (!props.preview) return 0;
return Object.values(props.preview.deletions).reduce((sum, v) => sum + v, 0);
});
const totalModifications = computed(() => {
if (!props.preview) return 0;
return Object.values(props.preview.modifications).reduce((sum, v) => sum + v, 0);
});
function handleCancel() {
emit('cancel');
}
function handleConfirm() {
emit('confirm');
}
</script>
<template>
<dialog ref="dialogRef" class="modal" @close="handleCancel">
<div class="modal-box">
<h3 class="text-lg font-bold">
Synchronisation des éléments liés
</h3>
<div v-if="preview" class="py-4 space-y-3">
<p>
Cette modification impactera
<strong>{{ preview.itemCount }}</strong>
élément(s) lié(s).
</p>
<ul class="list-disc list-inside space-y-1">
<li v-if="totalAdditions > 0" class="text-success">
{{ totalAdditions }} ajout(s)
</li>
<li v-if="totalDeletions > 0" class="text-error">
{{ totalDeletions }} suppression(s)
</li>
<li v-if="totalModifications > 0" class="text-warning">
{{ totalModifications }} modification(s)
</li>
</ul>
<div v-if="hasDeletions" role="alert" class="alert alert-warning">
<span>Des éléments seront supprimés. Cette action est irréversible.</span>
</div>
<div v-if="hasModifications" role="alert" class="alert alert-info">
<span>Des valeurs de champs personnalisés seront réinitialisées.</span>
</div>
</div>
<div class="modal-action">
<button class="btn btn-ghost" :disabled="loading" @click="handleCancel">
Annuler
</button>
<button class="btn btn-primary" :disabled="loading" @click="handleConfirm">
<span v-if="loading" class="loading loading-spinner loading-sm" />
Confirmer la synchronisation
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button @click="handleCancel">
close
</button>
</form>
</dialog>
</template>

View File

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

View File

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

View File

@@ -28,10 +28,11 @@
<div v-for="piece in pieces" :key="piece.id">
<PieceItem
:piece="piece"
:is-edit-mode="false"
:is-edit-mode="isEditMode"
:show-delete="isEditMode"
:collapse-all="collapsed"
:toggle-token="collapseToggleToken"
@update="$emit('update-piece', $event)"
@edit="$emit('edit-piece', $event)"
@delete="$emit('remove-piece', piece.linkId || piece.id)"
/>
@@ -62,6 +63,7 @@ defineProps<{
defineEmits<{
'toggle-collapse': []
'update-piece': [piece: any]
'edit-piece': [piece: any]
'add-piece': []
'remove-piece': [linkId: string]

View File

@@ -15,8 +15,7 @@
minlength="2"
maxlength="120"
required
:disabled="restrictedMode"
/>
/>
<p v-if="errors.name" class="mt-1 text-sm text-error">{{ errors.name }}</p>
</div>
<div>
@@ -48,7 +47,6 @@
rows="4"
name="notes"
maxlength="2000"
:disabled="restrictedMode"
></textarea>
<p class="mt-1 text-xs text-base-content/70">Saisissez des informations complémentaires (facultatif).</p>
</div>
@@ -83,7 +81,6 @@
v-model="componentStructure"
:allow-subcomponents="allowComponentSubcomponents"
:max-subcomponent-depth="componentSubcomponentMaxDepth"
:restricted-mode="restrictedMode"
/>
</div>
@@ -95,7 +92,7 @@
Aperçu :
<span class="font-medium text-base-content">{{ pieceStructurePreview }}</span>
</p>
<PieceModelStructureEditor v-model="pieceStructure" :restricted-mode="restrictedMode" />
<PieceModelStructureEditor v-model="pieceStructure" />
</div>
<div
@@ -106,30 +103,11 @@
Aperçu :
<span class="font-medium text-base-content">{{ productStructurePreview }}</span>
</p>
<PieceModelStructureEditor v-model="productStructure" :restricted-mode="restrictedMode" />
<PieceModelStructureEditor v-model="productStructure" />
</div>
</template>
</section>
<div
v-if="restrictedMode && restrictedModeMessage"
class="alert alert-info"
role="status"
aria-live="polite"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<span>{{ restrictedModeMessage }}</span>
</div>
<div
v-if="disableSubmit"
class="alert alert-warning"
role="alert"
aria-live="polite"
>
<span>{{ disableSubmitMessage }}</span>
</div>
<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
@@ -172,10 +150,6 @@ const props = withDefaults(defineProps<{
structureLoading?: boolean
allowComponentSubcomponents?: boolean
componentSubcomponentMaxDepth?: number
disableSubmit?: boolean
disableSubmitMessage?: string
restrictedMode?: boolean
restrictedModeMessage?: string
readonly?: boolean
}>(), {
initialData: null,
@@ -184,10 +158,6 @@ const props = withDefaults(defineProps<{
structureLoading: false,
allowComponentSubcomponents: true,
componentSubcomponentMaxDepth: 1,
disableSubmit: false,
disableSubmitMessage: '',
restrictedMode: false,
restrictedModeMessage: '',
readonly: false,
})
@@ -205,19 +175,7 @@ const componentSubcomponentMaxDepth = computed(() =>
? props.componentSubcomponentMaxDepth
: 1,
)
const disableSubmit = computed(() => props.disableSubmit === true)
const disableSubmitMessage = computed(() =>
(props.disableSubmitMessage && props.disableSubmitMessage.trim())
? props.disableSubmitMessage
: 'Cette catégorie ne peut pas être modifiée car des éléments y sont déjà liés.',
)
const isReadonly = computed(() => props.readonly === true)
const restrictedMode = computed(() => props.restrictedMode === true || isReadonly.value)
const restrictedModeMessage = computed(() =>
(props.restrictedModeMessage && props.restrictedModeMessage.trim())
? props.restrictedModeMessage
: '',
)
const form = reactive<ModelTypePayload>({
name: '',
@@ -294,7 +252,7 @@ const resetForm = () => {
}
const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer'))
const isSubmitDisabled = computed(() => saving.value || structureLoading.value || disableSubmit.value || isReadonly.value)
const isSubmitDisabled = computed(() => saving.value || structureLoading.value || isReadonly.value)
const validate = () => {
errors.name = undefined

View File

@@ -1,114 +0,0 @@
import { computed, ref } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
type GuardLabels = {
singular: string
plural: string
verifying: string
}
type GuardConfig = {
endpoint: string
filterKey: string
labels: GuardLabels
}
const extractTotal = (payload: any, fallbackLength: number) => {
if (typeof payload?.totalItems === 'number') {
return payload.totalItems
}
if (typeof payload?.['hydra:totalItems'] === 'number') {
return payload['hydra:totalItems']
}
if (Array.isArray(payload?.member)) {
return payload.member.length
}
if (Array.isArray(payload?.['hydra:member'])) {
return payload['hydra:member'].length
}
return fallbackLength
}
export function useCategoryEditGuard (config: GuardConfig) {
const { get } = useApi()
const { showInfo } = useToast()
const linkedCount = ref(0)
const linkedLoading = ref(false)
const loadLinkedCount = async (modelTypeId: string) => {
linkedLoading.value = true
try {
const params = new URLSearchParams()
params.set('itemsPerPage', '1')
params.set(config.filterKey, `/api/model_types/${modelTypeId}`)
const result = await get(`${config.endpoint}?${params.toString()}`)
if (!result.success) {
linkedCount.value = 0
return
}
const fallbackLength = Array.isArray(result.data?.member)
? result.data.member.length
: Array.isArray(result.data?.['hydra:member'])
? result.data['hydra:member'].length
: 0
linkedCount.value = extractTotal(result.data, fallbackLength)
} catch (_error) {
linkedCount.value = 0
} finally {
linkedLoading.value = false
}
}
const isRestrictedMode = computed(
() => !linkedLoading.value && linkedCount.value > 0,
)
const isSubmitBlocked = computed(
() => linkedLoading.value,
)
const restrictedModeMessage = computed(() => {
if (linkedLoading.value) {
return config.labels.verifying
}
if (linkedCount.value <= 0) {
return ''
}
if (linkedCount.value === 1) {
return `Mode restreint : 1 ${config.labels.singular} est déjà lié à cette catégorie. Vous pouvez ajouter de nouveaux champs personnalisés et renommer les existants, mais pas modifier leur type ou les supprimer.`
}
return `Mode restreint : ${linkedCount.value} ${config.labels.plural} sont déjà liés à cette catégorie. Vous pouvez ajouter de nouveaux champs personnalisés et renommer les existants, mais pas modifier leur type ou les supprimer.`
})
const submitBlockMessage = computed(() => {
if (linkedLoading.value) {
return config.labels.verifying
}
return ''
})
const guardSubmitOrNotify = () => {
if (!isSubmitBlocked.value) {
return false
}
showInfo(submitBlockMessage.value || 'Veuillez patienter...')
return true
}
return {
linkedCount,
linkedLoading,
isRestrictedMode,
isSubmitBlocked,
restrictedModeMessage,
submitBlockMessage,
loadLinkedCount,
guardSubmitOrNotify,
}
}

View File

@@ -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 {
const payload: Record<string, string> = { entityType, entityId, content }
if (entityName) payload.entityName = entityName
const result = await post('/comments', payload)
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
result = await post('/comments', payload)
}
if (result.success) {
showSuccess('Commentaire ajouté')
return { success: true, data: result.data as Comment }

View File

@@ -323,7 +323,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) {
@@ -344,7 +344,7 @@ export function useComponentCreate() {
selectedDocuments.value = []
}
toast.showSuccess('Composant créé avec succès')
await router.push('/component-catalog')
await router.replace(`/component/${createdComponent.id}/edit`)
}
else if (result.error) {
toast.showError(result.error)

View File

@@ -53,13 +53,13 @@ const historyFieldLabels: Record<string, string> = {
export function useComponentEdit(componentId: string) {
const { canEdit } = usePermissions()
const router = useRouter()
const { get } = useApi()
const { get, patch } = useApi()
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 { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const toast = useToast()
@@ -269,6 +269,97 @@ export function useComponentEdit(componentId: string) {
}
})
// --- Slot selection entries (for selectors) ---
const pieceSlotEntries = computed(() => {
const structure = component.value?.structure
if (!structure?.pieces) return []
return (structure.pieces as any[]).map((slot: any, i: number) => ({
slotId: slot.slotId,
typePieceId: slot.typePieceId,
selectedPieceId: slot.selectedPieceId ?? null,
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) => ({
slotId: slot.slotId,
typeProductId: slot.typeProductId,
selectedProductId: slot.selectedProductId ?? 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) => ({
slotId: slot.slotId,
typeComposantId: slot.typeComposantId,
selectedComponentId: slot.selectedComponentId ?? 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 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 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 saveSlotQuantity = async (slotId: string, quantity: number) => {
if (!slotId || quantity < 1) return
const result = await patch(`/composant-piece-slots/${slotId}`, { quantity: Math.max(1, quantity) })
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.quantity = quantity
}
toast.showSuccess('Quantité mise à jour')
}
}
const submitEdition = async () => {
if (!component.value) {
return
@@ -308,11 +399,11 @@ export function useComponentEdit(componentId: string) {
'composant',
updatedComponent.id,
[
updatedComponent?.typeComposant?.customFields,
updatedComponent?.typeComposant?.structure?.customFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
await router.push('/component-catalog')
toast.showSuccess('Composant mis à jour avec succès.')
}
}
catch (error: any) {
@@ -406,15 +497,6 @@ export function useComponentEdit(componentId: string) {
fetchComponent(),
])
loading.value = false
// Defer bulk catalog loads — only needed when component has structure selections
if (component.value?.structure) {
Promise.allSettled([
loadPieces({ itemsPerPage: 200 }),
loadProducts({ itemsPerPage: 200 }),
loadComposants({ itemsPerPage: 200 }),
]).catch(() => {})
}
})
return {
@@ -440,6 +522,9 @@ export function useComponentEdit(componentId: string) {
selectedType,
selectedTypeStructure,
structureSelections,
pieceSlotEntries,
productSlotEntries,
subcomponentSlotEntries,
// History
history,
@@ -453,6 +538,10 @@ export function useComponentEdit(componentId: string) {
handleFilesAdded,
refreshDocuments,
submitEdition,
saveSlotQuantity,
savePieceSlotSelection,
saveProductSlotSelection,
saveSubcomponentSlotSelection,
resolvePieceLabel,
resolveProductLabel,
resolveSubcomponentLabel,

View File

@@ -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('q', 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)))
composants.value = enrichedItems
total.value = extractTotal(result.data, items.length)
loaded.value = true
const resultTotal = extractTotal(result.data, items.length)
if (!typeComposantId) {
composants.value = enrichedItems
total.value = resultTotal
loaded.value = true
}
return {
success: true,
data: {
items: enrichedItems,
total: total.value,
total: resultTotal,
page,
itemsPerPage,
},

View File

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

View File

@@ -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
@@ -56,7 +56,7 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
// CRUD operations
const refreshDocuments = async () => {
const e = entity()
if (!e?.id) return
if (!e?.id || e._structurePiece) return
loadingDocuments.value = true
try {
const result: any = await loadDocumentsFn(e.id, { updateStore: false })
@@ -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,
}
}

View File

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

View File

@@ -42,7 +42,7 @@ export function useMachineDetailData(machineId: string) {
const { componentTypes, loadComponentTypes } = useComponentTypes()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { upsertCustomFieldValue } = useCustomFields()
const { get } = useApi()
const { get, patch: apiPatch } = useApi()
const toast = useToast()
const { constructeurs, loadConstructeurs } = useConstructeurs()
const { sites, loadSites } = useSites()
@@ -274,6 +274,7 @@ export function useMachineDetailData(machineId: string) {
updateMachineApi,
updateComposantApi: updateComposantApi,
updatePieceApi,
apiPatch,
toast,
})

View File

@@ -33,6 +33,7 @@ export interface UseMachineDetailUpdatesDeps {
updateMachineApi: (id: string, data: any) => Promise<unknown>
updateComposantApi: (id: string, data: any) => Promise<unknown>
updatePieceApi: (id: string, data: any) => Promise<unknown>
apiPatch: (endpoint: string, data?: unknown) => Promise<any>
toast: { showInfo: (msg: string) => void }
}
@@ -53,6 +54,7 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
updateMachineApi,
updateComposantApi,
updatePieceApi,
apiPatch,
toast,
} = deps
@@ -108,18 +110,18 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
const productId = updatedComponent.productId
? String(updatedComponent.productId)
: null
const prix =
const prixStr =
updatedComponent.prix !== null &&
updatedComponent.prix !== undefined &&
String(updatedComponent.prix).trim() !== ''
? Number(updatedComponent.prix)
? String(updatedComponent.prix)
: null
const result: any = await updateComposantApi(updatedComponent.id as string, {
name: updatedComponent.name,
reference: updatedComponent.reference,
constructeurIds: cIds,
prix: Number.isNaN(prix) ? null : prix,
prix: prixStr,
productId,
} as any)
if (result.success) {
@@ -138,18 +140,18 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
updatedPiece.constructeur,
)
const productId = updatedPiece.productId ? String(updatedPiece.productId) : null
const prix =
const prixStr =
updatedPiece.prix !== null &&
updatedPiece.prix !== undefined &&
String(updatedPiece.prix).trim() !== ''
? Number(updatedPiece.prix)
? String(updatedPiece.prix)
: null
const result: any = await updatePieceApi(updatedPiece.id as string, {
name: updatedPiece.name,
reference: updatedPiece.reference,
reference: updatedPiece.reference || null,
constructeurIds: cIds,
prix: Number.isNaN(prix) ? null : prix,
prix: prixStr,
productId,
} as any)
if (result.success) {
@@ -179,6 +181,13 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
)
}
}
// Update slot quantity if this is a composant structure piece
const slotId = updatedPiece.slotId as string | null
const quantity = typeof updatedPiece.quantity === 'number' ? Math.max(1, updatedPiece.quantity) : null
if (slotId && quantity !== null) {
await apiPatch(`/composant-piece-slots/${slotId}`, { quantity })
}
} catch (error) {
console.error('Erreur lors de la mise à jour de la pièce:', error)
}
@@ -187,6 +196,13 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
const updatePieceInfo = async (updatedPiece: AnyRecord) => {
try {
await _buildAndUpdatePiece(updatedPiece)
// Update link quantity if this is a direct machine piece
const linkId = updatedPiece.linkId || updatedPiece.machinePieceLinkId
const quantity = typeof updatedPiece.quantity === 'number' ? Math.max(1, updatedPiece.quantity) : null
if (linkId && quantity !== null) {
await apiPatch(`/machine_piece_links/${linkId}`, { quantity })
}
} catch (error) {
console.error('Erreur lors de la mise à jour de la pièce:', error)
}

View File

@@ -181,6 +181,7 @@ export const buildMachineHierarchyFromLinks = (
parentLinkId: resolveIdentifier(link.parentLinkId, link.parentMachinePieceLinkId, appliedPiece.parentLinkId),
parentPieceLinkId: resolveIdentifier(link.parentPieceLinkId, appliedPiece.parentPieceLinkId),
parentPieceId: resolveIdentifier(appliedPiece.parentPieceId, link.parentPieceId),
quantity: typeof link.quantity === 'number' ? link.quantity : 1,
definition: appliedPiece.definition || originalPiece?.definition || {},
customFields: appliedPiece.customFields || [],
}
@@ -214,10 +215,39 @@ export const buildMachineHierarchyFromLinks = (
const componentName = (compOverrides?.name || appliedComponent.name || (appliedComponent.definition as AnyRecord)?.alias || (appliedComponent.definition as AnyRecord)?.name || originalComponent?.name || 'Composant') as string
const pieces = Array.isArray(link.pieceLinks)
const linkedPieces = Array.isArray(link.pieceLinks)
? (link.pieceLinks as AnyRecord[]).map((pl) => createPieceNode(pl, componentName)).filter(Boolean) as AnyRecord[]
: []
// If no linked pieces exist, build read-only entries from the composant's structure
const structurePieceDefs = (!linkedPieces.length && appliedComponent.structure && typeof appliedComponent.structure === 'object')
? (Array.isArray((appliedComponent.structure as AnyRecord).pieces) ? (appliedComponent.structure as AnyRecord).pieces as AnyRecord[] : [])
: []
const structurePieces = structurePieceDefs.map((def, index) => {
const definition = (def.definition && typeof def.definition === 'object' ? def.definition : def) as AnyRecord
const resolved = (def.resolvedPiece && typeof def.resolvedPiece === 'object' ? def.resolvedPiece : null) as AnyRecord | null
const quantity = typeof definition.quantity === 'number' ? definition.quantity : (typeof def.quantity === 'number' ? def.quantity : 1)
return {
...(resolved || {}),
id: resolved?.id || `structure-piece-${composantId}-${index}`,
pieceId: resolved?.id || null,
name: resolved?.name || definition.role || definition.name || def.role || def.name || `Pièce ${index + 1}`,
reference: resolved?.reference || definition.reference || def.reference || null,
prix: resolved?.prix ?? null,
constructeurs: resolved?.constructeurs || [],
documents: [],
quantity,
slotId: def.slotId || definition.slotId || null,
typePieceId: resolved?.typePieceId || definition.typePieceId || def.typePieceId || null,
typePiece: resolved?.typePiece || null,
parentComponentLinkId: machineComponentLinkId,
parentComponentName: componentName,
_structurePiece: true,
}
}) as AnyRecord[]
const pieces = linkedPieces.length ? linkedPieces : structurePieces
const subComponents = Array.isArray(link.childLinks)
? (link.childLinks as AnyRecord[]).map(createComponentNode).filter(Boolean) as AnyRecord[]
: []
@@ -243,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),

View File

@@ -408,11 +408,11 @@ export function usePieceEdit(pieceId: string) {
'piece',
updatedPiece.id,
[
updatedPiece?.typePiece?.pieceCustomFields,
updatedPiece?.typePiece?.structure?.customFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
await router.push('/pieces-catalog')
toast.showSuccess('Pièce mise à jour avec succès.')
}
}
catch (error: any) {

View File

@@ -20,7 +20,6 @@ export type EditorProduct = {
interface Deps {
props: {
modelValue?: PieceModelStructure | null
restrictedMode?: boolean
}
emit: (event: 'update:modelValue', value: PieceModelStructure) => void
}
@@ -82,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,
}
}
@@ -159,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')
@@ -202,8 +217,6 @@ export function usePieceStructureEditorLogic(deps: Deps) {
const products = ref<EditorProduct[]>(hydrateProducts(props.modelValue))
const restState = ref<Record<string, unknown>>(extractRest(props.modelValue))
const initialFieldUids = ref<Set<string>>(new Set(fields.value.map(f => f.uid)))
const initialProductUids = ref<Set<string>>(new Set(products.value.map(p => p.uid)))
// --- Product types ---
@@ -250,18 +263,6 @@ export function usePieceStructureEditorLogic(deps: Deps) {
}
}
// --- Locked state ---
const isFieldLocked = (field: EditorField): boolean => {
return props.restrictedMode === true && initialFieldUids.value.has(field.uid)
}
const isProductLocked = (product: EditorProduct): boolean => {
return props.restrictedMode === true && initialProductUids.value.has(product.uid)
}
const restrictedMode = computed(() => props.restrictedMode === true)
// --- CRUD ---
const createEmptyProduct = (): EditorProduct => ({
@@ -407,8 +408,6 @@ export function usePieceStructureEditorLogic(deps: Deps) {
products.value = hydrateProducts(value)
products.value.forEach(product => updateProductTypeMetadata(product))
lastEmitted = incomingSerialized
initialFieldUids.value = new Set(fields.value.map(f => f.uid))
initialProductUids.value = new Set(products.value.map(p => p.uid))
},
{ deep: true },
)
@@ -426,9 +425,6 @@ export function usePieceStructureEditorLogic(deps: Deps) {
fields,
products,
productTypeOptions,
restrictedMode,
isFieldLocked,
isProductLocked,
formatProductTypeOption,
handleProductTypeSelect,
addProduct,

View File

@@ -43,6 +43,7 @@ interface LoadPiecesOptions {
orderBy?: string
orderDir?: 'asc' | 'desc'
typeName?: string
typePieceId?: string
force?: boolean
}
@@ -119,17 +120,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 +142,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('q', 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)))
pieces.value = enrichedItems
total.value = extractTotal(result.data, items.length)
loaded.value = true
const resultTotal = extractTotal(result.data, items.length)
// Only update global cache for unfiltered queries
if (!typePieceId) {
pieces.value = enrichedItems
total.value = resultTotal
loaded.value = true
}
return {
success: true,
data: {
items: enrichedItems,
total: total.value,
total: resultTotal,
page,
itemsPerPage,
},

View File

@@ -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('q', 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)))
products.value = enrichedItems
total.value = extractTotal(result.data, items.length)
loaded.value = true
const resultTotal = extractTotal(result.data, items.length)
if (!typeProductId) {
products.value = enrichedItems
total.value = resultTotal
loaded.value = true
}
return {
success: true,
data: {
items: enrichedItems,
total: total.value,
total: resultTotal,
page,
itemsPerPage,
},

View File

@@ -1,51 +1,11 @@
import { ref } from 'vue'
import type { EditableStructureNode } from '~/composables/useStructureNodeLogic'
export interface StructureNodeCrudDeps {
node: EditableStructureNode
restrictedMode: boolean
canManageSubcomponents: () => boolean
}
export function useStructureNodeCrud(props: StructureNodeCrudDeps) {
// --- Lock state ---
const initialCustomFieldIndices = ref<Set<number>>(new Set())
const initialPieceIndices = ref<Set<number>>(new Set())
const initialProductIndices = ref<Set<number>>(new Set())
const initialSubcomponentIndices = ref<Set<number>>(new Set())
const initializeLockedIndices = () => {
if (props.restrictedMode) {
const customFieldsLength = Array.isArray(props.node.customFields) ? props.node.customFields.length : 0
const piecesLength = Array.isArray(props.node.pieces) ? props.node.pieces.length : 0
const productsLength = Array.isArray(props.node.products) ? props.node.products.length : 0
const subcomponentsLength = Array.isArray(props.node.subcomponents) ? props.node.subcomponents.length : 0
initialCustomFieldIndices.value = new Set(Array.from({ length: customFieldsLength }, (_, i) => i))
initialPieceIndices.value = new Set(Array.from({ length: piecesLength }, (_, i) => i))
initialProductIndices.value = new Set(Array.from({ length: productsLength }, (_, i) => i))
initialSubcomponentIndices.value = new Set(Array.from({ length: subcomponentsLength }, (_, i) => i))
}
}
initializeLockedIndices()
const isCustomFieldLocked = (index: number): boolean => {
return props.restrictedMode === true && initialCustomFieldIndices.value.has(index)
}
const isPieceLocked = (index: number): boolean => {
return props.restrictedMode === true && initialPieceIndices.value.has(index)
}
const isProductLocked = (index: number): boolean => {
return props.restrictedMode === true && initialProductIndices.value.has(index)
}
const isSubcomponentLocked = (index: number): boolean => {
return props.restrictedMode === true && initialSubcomponentIndices.value.has(index)
}
// --- Helpers ---
const ensureArray = (key: 'customFields' | 'pieces' | 'products' | 'subcomponents') => {
if (!Array.isArray((props.node as any)[key])) {
@@ -115,6 +75,7 @@ export function useStructureNodeCrud(props: StructureNodeCrudDeps) {
reference: '',
familyCode: '',
role: '',
quantity: 1,
})
}
@@ -158,11 +119,6 @@ export function useStructureNodeCrud(props: StructureNodeCrudDeps) {
}
return {
// Lock checks
isCustomFieldLocked,
isPieceLocked,
isProductLocked,
isSubcomponentLocked,
// Helpers exposed for watchers
reindexCustomFields,
// CRUD

View File

@@ -25,14 +25,12 @@ export interface StructureNodeLogicDeps {
lockedTypeLabel: string
allowSubcomponents: boolean
maxSubcomponentDepth: number
restrictedMode: boolean
isLocked: boolean
}
export function useStructureNodeLogic(props: StructureNodeLogicDeps) {
// --- Computed props ---
const isLocked = computed(() => props.isLocked === true)
const restrictedMode = computed(() => props.restrictedMode === true)
const componentTypes = computed(() => props.componentTypes ?? [])
const pieceTypes = computed(() => props.pieceTypes ?? [])
@@ -310,7 +308,6 @@ export function useStructureNodeLogic(props: StructureNodeLogicDeps) {
// --- CRUD & Lock (delegated to useStructureNodeCrud) ---
const crud = useStructureNodeCrud({
node: props.node,
restrictedMode: props.restrictedMode,
canManageSubcomponents: () => canManageSubcomponents.value,
})
@@ -395,14 +392,8 @@ export function useStructureNodeLogic(props: StructureNodeLogicDeps) {
)
return {
// Lock checks
isCustomFieldLocked: crud.isCustomFieldLocked,
isPieceLocked: crud.isPieceLocked,
isProductLocked: crud.isProductLocked,
isSubcomponentLocked: crud.isSubcomponentLocked,
// Computed state
isLocked,
restrictedMode,
componentTypes,
pieceTypes,
productTypes,

View File

@@ -1,245 +1,646 @@
<template>
<main class="container mx-auto max-w-4xl px-6 py-10 space-y-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.
</p>
</header>
<main class="container mx-auto max-w-4xl px-6 py-10 space-y-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.
</p>
</header>
<section
v-for="release in releases"
:key="release.version"
class="card border border-base-200 bg-base-100 shadow-sm"
>
<div class="card-body space-y-3">
<div class="flex items-center gap-3">
<h2 class="text-xl font-bold text-base-content">
{{ release.version }}
</h2>
<span class="badge badge-ghost text-xs">{{ release.date }}</span>
</div>
<section
v-for="release in releases"
:key="release.version"
class="card border border-base-200 bg-base-100 shadow-sm"
>
<div class="card-body space-y-3">
<div class="flex items-center gap-3">
<h2 class="text-xl font-bold text-base-content">
{{ release.version }}
</h2>
<span class="badge badge-ghost text-xs">{{
release.date
}}</span>
</div>
<ul class="space-y-2">
<li
v-for="(item, i) in release.changes"
:key="i"
class="flex items-start gap-2 text-sm text-base-content/80"
>
<span
class="badge badge-sm mt-0.5 shrink-0"
:class="badgeClass(item.type)"
>
{{ item.type }}
</span>
<span>{{ item.text }}</span>
</li>
</ul>
</div>
</section>
</main>
<ul class="space-y-2">
<li
v-for="(item, i) in release.changes"
:key="i"
class="flex items-start gap-2 text-sm text-base-content/80"
>
<span
class="badge badge-sm mt-0.5 shrink-0"
:class="badgeClass(item.type)"
>
{{ item.type }}
</span>
<span>{{ item.text }}</span>
</li>
</ul>
</div>
</section>
</main>
</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'
}
const map: Record<ChangeType, string> = {
feat: "badge-primary",
fix: "badge-error",
perf: "badge-warning",
chore: "badge-ghost",
};
return map[type] ?? "badge-ghost";
};
const releases: Release[] = [
{
version: 'v1.9.0',
date: '2026-03-09',
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' },
],
},
{
version: 'v1.8.1',
date: '2026-03-05',
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' },
],
},
{
version: 'v1.8.0',
date: '2026-03-03',
changes: [
{ type: 'feat', text: 'Stockage des documents sur le système de fichiers au lieu de Base64 en base de données, avec endpoints dédiés pour servir et télécharger les fichiers' },
{ type: 'feat', text: 'Pagination serveur sur la page Documents avec recherche, tri (date/nom/taille), filtre par rattachement et sélecteur d\'éléments par page' },
{ type: 'feat', text: 'Compression PDF automatique à l\'upload via Ghostscript, avec commande pour compresser les PDFs existants' },
{ type: 'feat', text: 'Champ description sur les pièces et composants, visible dans les catalogues avec popover au survol' },
{ type: 'feat', text: 'Commande de migration app:migrate-documents-to-filesystem pour migrer les documents existants (Base64 → fichiers)' },
{ type: 'fix', text: 'Normalisation des documents : fileUrl et downloadUrl toujours exposés dans l\'API' },
{ type: 'fix', text: 'Édition de squelettes machines : correction du conflit UniqueEntity et de l\'interférence du désérialiseur' },
{ type: 'fix', text: 'Sites : ajout de l\'opération PATCH et correction de la migration de contrainte' },
{ type: 'chore', text: 'Réorganisation de la navbar avec nouvelles icônes Lucide' },
],
},
{
version: 'v1.7.0',
date: '2026-03-02',
changes: [
{ type: 'feat', text: 'Système de commentaires / tickets : possibilité de laisser des commentaires sur les fiches (machines, pièces, composants, produits, catégories, squelettes) avec résolution par les gestionnaires' },
{ type: 'feat', text: 'Page commentaires centralisée (/comments) avec filtres par statut, type d\'entité, pagination et liens cliquables vers les fiches' },
{ type: 'feat', text: 'Badge notifications : compteur de commentaires ouverts sur l\'avatar utilisateur et dans le menu profil (polling 60s)' },
{ type: 'feat', text: 'Contrôle d\'accès par rôles : ROLE_ADMIN, ROLE_GESTIONNAIRE, ROLE_VIEWER avec permissions granulaires sur toutes les pages' },
{ type: 'feat', text: 'Journal d\'audit étendu : suivi des opérations sur machines, fournisseurs, types de modèles, documents et conversions' },
{ type: 'feat', text: 'Commande app:init-profile-passwords pour l\'initialisation en masse des mots de passe et rôles' },
{ type: 'fix', text: 'Toggle switch pour les champs personnalisés booléens (remplace les checkboxes)' },
{ type: 'fix', text: 'Recherche fournisseur : filtrage côté client au lieu d\'appels API debounce' },
{ type: 'fix', text: 'Prévention des doublons de noms de fournisseurs et de références de pièces (contraintes unique)' },
{ type: 'fix', text: 'Correction de la création de squelettes machines : pagination, duplication, champs personnalisés' },
],
},
{
version: 'v1.6.1',
date: '2026-02-12',
changes: [
{ type: 'feat', text: 'Suivi d\'audit étendu : enregistrement des opérations CRUD sur les machines, fournisseurs, catégories (ModelType) et documents' },
{ type: 'feat', text: 'Traçabilité des conversions de catégories dans le journal d\'activité (action « convert » avec direction, nombre et noms des éléments)' },
{ type: 'feat', text: 'Endpoint historique machine : GET /api/machines/{id}/history' },
],
},
{
version: 'v1.6.0',
date: '2026-02-12',
changes: [
{ type: 'feat', text: 'Conversion bidirectionnelle des catégories : possibilité de convertir une catégorie de pièce en catégorie de composant (et inversement) avec transfert automatique de tous les éléments, documents, champs personnalisés et fournisseurs' },
{ type: 'feat', text: 'Vérification des conditions de blocage avant conversion : liaisons machines, templates de type machine, sous-composants dans la structure, collisions de noms' },
{ type: 'feat', text: 'Bouton « Convertir » sur les listes de catégories pièce et composant avec modale de confirmation détaillée' },
{ type: 'chore', text: 'Passage php-cs-fixer sur l\'ensemble des contrôleurs et entités du backend' },
],
},
{
version: 'v1.5.0',
date: '2026-02-11',
changes: [
{ type: 'feat', text: 'Page de journal d\'activité globale avec filtres par entité, par acteur et pagination serveur' },
{ type: 'feat', text: 'Suivi d\'audit : enregistrement des noms de fournisseurs et des modifications de champs personnalisés' },
{ type: 'feat', text: 'Préservation de l\'état des listes dans l\'URL (page courante, recherche, tri, direction, filtres) — le retour navigateur restaure exactement la position précédente' },
{ type: 'feat', text: 'Boutons « Retour » sur toutes les pages de création et d\'édition utilisent désormais l\'historique du navigateur au lieu de liens fixes' },
{ type: 'feat', text: 'Première lettre automatiquement en majuscule lors de la création de catégories et de composants' },
{ type: 'feat', text: 'Les types de catégories dans les tableaux des catalogues sont maintenant cliquables (lien vers la fiche d\'édition)' },
{ type: 'feat', text: 'Application des couleurs de marque Malio sur l\'ensemble du thème (navbar, boutons, badges)' },
{ type: 'feat', text: 'Page changelog accessible depuis le footer' },
{ type: 'fix', text: 'Correction des filtres de tri et de recherche cassés sur les catalogues composants, pièces et produits' },
{ type: 'fix', text: 'Correction du filtre par rattachement (site, machine, composant, pièce) sur la page documents' },
{ type: 'fix', text: 'Correction de l\'affichage des champs personnalisés sur les pages d\'édition (condition de concurrence)' },
{ type: 'fix', text: 'Plafonnement de la pagination à 200 éléments par page pour éviter les erreurs mémoire en production' },
{ type: 'perf', text: 'Cache intelligent sur les composables usePieces et useComposants : les données déjà chargées ne sont plus re-téléchargées inutilement' },
{ type: 'perf', text: 'Réduction des appels API bloquants sur les pages d\'édition' },
],
},
{
version: 'v1.4.0',
date: '2026-02-04',
changes: [
{ type: 'perf', text: 'Optimisation de la sérialisation API : ajout de groupes dédiés pour CustomFieldValue et CustomField, réduisant significativement la taille des réponses' },
{ type: 'perf', text: 'Pages d\'édition machines/composants/pièces : chargement parallèle des données au lieu de séquentiel' },
],
},
{
version: 'v1.3.0',
date: '2026-01-28',
changes: [
{ type: 'feat', text: 'Refactoring complet du frontend : découpage des méga-composants en modules réutilisables (7 chantiers F1-F7)' },
{ type: 'feat', text: 'Page détail machine découpée de 2989 à 219 lignes avec 2 composables et 7 sous-composants' },
{ type: 'feat', text: 'Page création machine découpée de 1231 à 196 lignes avec 1 composable et 5 sous-composants' },
{ type: 'feat', text: 'Extraction de 4 modules utilitaires partagés (champs personnalisés, affichage produits, documents, fournisseurs)' },
{ type: 'feat', text: 'Fusion des composables dupliqués : 3 composables d\'historique et 3 composables de types fusionnés en versions génériques' },
{ type: 'feat', text: 'Remplacement de confirm() natif par une modale DaisyUI personnalisée sur l\'ensemble de l\'application' },
{ type: 'feat', text: 'Extraction de la navbar dans un composant AppNavbar dédié' },
{ type: 'feat', text: 'Suite de 54 tests unitaires avec Vitest couvrant les utilitaires et composables' },
{ type: 'perf', text: 'Optimisations API : helper extractCollection partagé, invalidation de cache ciblée' },
{ type: 'chore', text: 'Migration des composables JavaScript vers TypeScript strict' },
{ type: 'chore', text: 'Activation de règles ESLint strictes et suppression de 19 console.log de débogage' },
],
},
{
version: 'v1.2.0',
date: '2026-01-21',
changes: [
{ type: 'feat', text: 'Système de suivi d\'historique (audit) avec enregistrement automatique des modifications sur toutes les entités' },
{ type: 'feat', text: 'Interface dédiée à l\'historique sur les fiches produits, pièces et composants' },
{ type: 'feat', text: 'Modale d\'éléments liés sur les pages de gestion des catégories avec navigation directe vers la fiche d\'édition' },
{ type: 'feat', text: 'Possibilité d\'ajouter des champs personnalisés en mode restreint sur les catégories' },
],
},
{
version: 'v1.1.1',
date: '2026-01-14',
changes: [
{ type: 'feat', text: 'Compression automatique des fichiers PDF à l\'upload via qpdf, réduisant l\'espace de stockage' },
{ type: 'chore', text: 'Ajout de qpdf dans l\'image Docker pour le support de la compression PDF' },
],
},
{
version: 'v1.1.0',
date: '2026-01-07',
changes: [
{ type: 'fix', text: 'Recherche insensible à la casse sur l\'ensemble des filtres de toutes les entités (machines, composants, pièces, produits)' },
{ type: 'chore', text: 'Réinitialisation des migrations vers un schéma initial unique avec guide de déploiement' },
{ type: 'chore', text: 'Mise à jour des fixtures avec les données courantes de la base' },
],
},
{
version: 'v1.0.0',
date: '2025-12-15',
changes: [
{ type: 'feat', text: 'Gestion complète des machines : création, édition, vue détaillée avec liaisons composants et pièces' },
{ type: 'feat', text: 'Catalogues composants, pièces et produits avec recherche serveur, tri et pagination' },
{ type: 'feat', text: 'Système de catégories (types) avec squelettes de champs personnalisés et drag & drop pour réordonner' },
{ type: 'feat', text: 'Upload de documents avec prévisualisation PDF et images, miniatures dans les tableaux' },
{ type: 'feat', text: 'Gestion des fournisseurs multiples avec résolution automatique des noms' },
{ type: 'feat', text: 'Exigences produit sur les pièces : support de liaisons multiples' },
{ type: 'feat', text: 'Sélections de composants sur les pièces avec recherche dynamique' },
{ type: 'feat', text: 'Système de sessions utilisateurs avec authentification par cookie' },
{ type: 'feat', text: 'Mémorisation des préférences de tri par catalogue (cookies)' },
{ type: 'feat', text: 'Formatage automatique des contacts et des montants en format français' },
{ type: 'feat', text: 'Protection contre les suppressions : affichage des dépendances bloquantes avant confirmation' },
{ type: 'chore', text: 'Infrastructure Docker complète avec PostgreSQL, PHP 8.4, API Platform et pgAdmin' },
],
},
]
{
version: "v1.9.2",
date: "2026-03-23",
changes: [
{
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.9.1",
date: "2026-03-16",
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",
},
],
},
{
version: "v1.9.0",
date: "2026-03-09",
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",
},
],
},
{
version: "v1.8.1",
date: "2026-03-05",
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",
},
],
},
{
version: "v1.8.0",
date: "2026-03-03",
changes: [
{
type: "feat",
text: "Stockage des documents sur le système de fichiers au lieu de Base64 en base de données, avec endpoints dédiés pour servir et télécharger les fichiers",
},
{
type: "feat",
text: "Pagination serveur sur la page Documents avec recherche, tri (date/nom/taille), filtre par rattachement et sélecteur d'éléments par page",
},
{
type: "feat",
text: "Compression PDF automatique à l'upload via Ghostscript, avec commande pour compresser les PDFs existants",
},
{
type: "feat",
text: "Champ description sur les pièces et composants, visible dans les catalogues avec popover au survol",
},
{
type: "feat",
text: "Commande de migration app:migrate-documents-to-filesystem pour migrer les documents existants (Base64 → fichiers)",
},
{
type: "fix",
text: "Normalisation des documents : fileUrl et downloadUrl toujours exposés dans l'API",
},
{
type: "fix",
text: "Édition de squelettes machines : correction du conflit UniqueEntity et de l'interférence du désérialiseur",
},
{
type: "fix",
text: "Sites : ajout de l'opération PATCH et correction de la migration de contrainte",
},
{
type: "chore",
text: "Réorganisation de la navbar avec nouvelles icônes Lucide",
},
],
},
{
version: "v1.7.0",
date: "2026-03-02",
changes: [
{
type: "feat",
text: "Système de commentaires / tickets : possibilité de laisser des commentaires sur les fiches (machines, pièces, composants, produits, catégories, squelettes) avec résolution par les gestionnaires",
},
{
type: "feat",
text: "Page commentaires centralisée (/comments) avec filtres par statut, type d'entité, pagination et liens cliquables vers les fiches",
},
{
type: "feat",
text: "Badge notifications : compteur de commentaires ouverts sur l'avatar utilisateur et dans le menu profil (polling 60s)",
},
{
type: "feat",
text: "Contrôle d'accès par rôles : ROLE_ADMIN, ROLE_GESTIONNAIRE, ROLE_VIEWER avec permissions granulaires sur toutes les pages",
},
{
type: "feat",
text: "Journal d'audit étendu : suivi des opérations sur machines, fournisseurs, types de modèles, documents et conversions",
},
{
type: "feat",
text: "Commande app:init-profile-passwords pour l'initialisation en masse des mots de passe et rôles",
},
{
type: "fix",
text: "Toggle switch pour les champs personnalisés booléens (remplace les checkboxes)",
},
{
type: "fix",
text: "Recherche fournisseur : filtrage côté client au lieu d'appels API debounce",
},
{
type: "fix",
text: "Prévention des doublons de noms de fournisseurs et de références de pièces (contraintes unique)",
},
{
type: "fix",
text: "Correction de la création de squelettes machines : pagination, duplication, champs personnalisés",
},
],
},
{
version: "v1.6.1",
date: "2026-02-12",
changes: [
{
type: "feat",
text: "Suivi d'audit étendu : enregistrement des opérations CRUD sur les machines, fournisseurs, catégories (ModelType) et documents",
},
{
type: "feat",
text: "Traçabilité des conversions de catégories dans le journal d'activité (action « convert » avec direction, nombre et noms des éléments)",
},
{
type: "feat",
text: "Endpoint historique machine : GET /api/machines/{id}/history",
},
],
},
{
version: "v1.6.0",
date: "2026-02-12",
changes: [
{
type: "feat",
text: "Conversion bidirectionnelle des catégories : possibilité de convertir une catégorie de pièce en catégorie de composant (et inversement) avec transfert automatique de tous les éléments, documents, champs personnalisés et fournisseurs",
},
{
type: "feat",
text: "Vérification des conditions de blocage avant conversion : liaisons machines, templates de type machine, sous-composants dans la structure, collisions de noms",
},
{
type: "feat",
text: "Bouton « Convertir » sur les listes de catégories pièce et composant avec modale de confirmation détaillée",
},
{
type: "chore",
text: "Passage php-cs-fixer sur l'ensemble des contrôleurs et entités du backend",
},
],
},
{
version: "v1.5.0",
date: "2026-02-11",
changes: [
{
type: "feat",
text: "Page de journal d'activité globale avec filtres par entité, par acteur et pagination serveur",
},
{
type: "feat",
text: "Suivi d'audit : enregistrement des noms de fournisseurs et des modifications de champs personnalisés",
},
{
type: "feat",
text: "Préservation de l'état des listes dans l'URL (page courante, recherche, tri, direction, filtres) — le retour navigateur restaure exactement la position précédente",
},
{
type: "feat",
text: "Boutons « Retour » sur toutes les pages de création et d'édition utilisent désormais l'historique du navigateur au lieu de liens fixes",
},
{
type: "feat",
text: "Première lettre automatiquement en majuscule lors de la création de catégories et de composants",
},
{
type: "feat",
text: "Les types de catégories dans les tableaux des catalogues sont maintenant cliquables (lien vers la fiche d'édition)",
},
{
type: "feat",
text: "Application des couleurs de marque Malio sur l'ensemble du thème (navbar, boutons, badges)",
},
{
type: "feat",
text: "Page changelog accessible depuis le footer",
},
{
type: "fix",
text: "Correction des filtres de tri et de recherche cassés sur les catalogues composants, pièces et produits",
},
{
type: "fix",
text: "Correction du filtre par rattachement (site, machine, composant, pièce) sur la page documents",
},
{
type: "fix",
text: "Correction de l'affichage des champs personnalisés sur les pages d'édition (condition de concurrence)",
},
{
type: "fix",
text: "Plafonnement de la pagination à 200 éléments par page pour éviter les erreurs mémoire en production",
},
{
type: "perf",
text: "Cache intelligent sur les composables usePieces et useComposants : les données déjà chargées ne sont plus re-téléchargées inutilement",
},
{
type: "perf",
text: "Réduction des appels API bloquants sur les pages d'édition",
},
],
},
{
version: "v1.4.0",
date: "2026-02-04",
changes: [
{
type: "perf",
text: "Optimisation de la sérialisation API : ajout de groupes dédiés pour CustomFieldValue et CustomField, réduisant significativement la taille des réponses",
},
{
type: "perf",
text: "Pages d'édition machines/composants/pièces : chargement parallèle des données au lieu de séquentiel",
},
],
},
{
version: "v1.3.0",
date: "2026-01-28",
changes: [
{
type: "feat",
text: "Refactoring complet du frontend : découpage des méga-composants en modules réutilisables (7 chantiers F1-F7)",
},
{
type: "feat",
text: "Page détail machine découpée de 2989 à 219 lignes avec 2 composables et 7 sous-composants",
},
{
type: "feat",
text: "Page création machine découpée de 1231 à 196 lignes avec 1 composable et 5 sous-composants",
},
{
type: "feat",
text: "Extraction de 4 modules utilitaires partagés (champs personnalisés, affichage produits, documents, fournisseurs)",
},
{
type: "feat",
text: "Fusion des composables dupliqués : 3 composables d'historique et 3 composables de types fusionnés en versions génériques",
},
{
type: "feat",
text: "Remplacement de confirm() natif par une modale DaisyUI personnalisée sur l'ensemble de l'application",
},
{
type: "feat",
text: "Extraction de la navbar dans un composant AppNavbar dédié",
},
{
type: "feat",
text: "Suite de 54 tests unitaires avec Vitest couvrant les utilitaires et composables",
},
{
type: "perf",
text: "Optimisations API : helper extractCollection partagé, invalidation de cache ciblée",
},
{
type: "chore",
text: "Migration des composables JavaScript vers TypeScript strict",
},
{
type: "chore",
text: "Activation de règles ESLint strictes et suppression de 19 console.log de débogage",
},
],
},
{
version: "v1.2.0",
date: "2026-01-21",
changes: [
{
type: "feat",
text: "Système de suivi d'historique (audit) avec enregistrement automatique des modifications sur toutes les entités",
},
{
type: "feat",
text: "Interface dédiée à l'historique sur les fiches produits, pièces et composants",
},
{
type: "feat",
text: "Modale d'éléments liés sur les pages de gestion des catégories avec navigation directe vers la fiche d'édition",
},
{
type: "feat",
text: "Possibilité d'ajouter des champs personnalisés en mode restreint sur les catégories",
},
],
},
{
version: "v1.1.1",
date: "2026-01-14",
changes: [
{
type: "feat",
text: "Compression automatique des fichiers PDF à l'upload via qpdf, réduisant l'espace de stockage",
},
{
type: "chore",
text: "Ajout de qpdf dans l'image Docker pour le support de la compression PDF",
},
],
},
{
version: "v1.1.0",
date: "2026-01-07",
changes: [
{
type: "fix",
text: "Recherche insensible à la casse sur l'ensemble des filtres de toutes les entités (machines, composants, pièces, produits)",
},
{
type: "chore",
text: "Réinitialisation des migrations vers un schéma initial unique avec guide de déploiement",
},
{
type: "chore",
text: "Mise à jour des fixtures avec les données courantes de la base",
},
],
},
{
version: "v1.0.0",
date: "2025-12-15",
changes: [
{
type: "feat",
text: "Gestion complète des machines : création, édition, vue détaillée avec liaisons composants et pièces",
},
{
type: "feat",
text: "Catalogues composants, pièces et produits avec recherche serveur, tri et pagination",
},
{
type: "feat",
text: "Système de catégories (types) avec squelettes de champs personnalisés et drag & drop pour réordonner",
},
{
type: "feat",
text: "Upload de documents avec prévisualisation PDF et images, miniatures dans les tableaux",
},
{
type: "feat",
text: "Gestion des fournisseurs multiples avec résolution automatique des noms",
},
{
type: "feat",
text: "Exigences produit sur les pièces : support de liaisons multiples",
},
{
type: "feat",
text: "Sélections de composants sur les pièces avec recherche dynamique",
},
{
type: "feat",
text: "Système de sessions utilisateurs avec authentification par cookie",
},
{
type: "feat",
text: "Mémorisation des préférences de tri par catalogue (cookies)",
},
{
type: "feat",
text: "Formatage automatique des contacts et des montants en format français",
},
{
type: "feat",
text: "Protection contre les suppressions : affichage des dépendances bloquantes avant confirmation",
},
{
type: "chore",
text: "Infrastructure Docker complète avec PostgreSQL, PHP 8.4, API Platform et pgAdmin",
},
],
},
];
</script>

View File

@@ -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 },
{

View File

@@ -27,10 +27,6 @@
:lock-category="true"
:saving="saving"
:readonly="!canEdit"
:disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage"
:restricted-mode="isRestrictedMode"
:restricted-mode-message="restrictedModeMessage"
@submit="handleSubmit"
@cancel="handleCancel"
/>
@@ -45,6 +41,14 @@
show-resolved
/>
</div>
<SyncConfirmationModal
:preview="syncPreviewData"
:open="showSyncModal"
:loading="syncLoading"
@confirm="handleSyncConfirm"
@cancel="handleSyncCancel"
/>
</main>
</template>
@@ -52,9 +56,8 @@
import { computed, onMounted, ref } from 'vue'
import { useHead, useRoute, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
import { getModelType, updateModelType, syncPreview, syncExecute, type ModelTypePayload, type SyncPreviewResult } from '~/services/modelTypes'
import type { ComponentModelStructure } from '~/shared/types/inventory'
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useToast } from '~/composables/useToast'
@@ -67,23 +70,10 @@ const { loadComponentTypes } = useComponentTypes()
const loading = ref(true)
const saving = ref(false)
const initialData = ref<Partial<ModelTypePayload> | null>(null)
const {
isRestrictedMode,
isSubmitBlocked,
restrictedModeMessage,
submitBlockMessage,
loadLinkedCount,
guardSubmitOrNotify,
} = useCategoryEditGuard({
endpoint: '/composants',
filterKey: 'typeComposant',
labels: {
singular: 'composant',
plural: 'composants',
verifying: 'Vérification des composants liés en cours…',
},
})
const showSyncModal = ref(false)
const syncLoading = ref(false)
const syncPreviewData = ref<SyncPreviewResult | null>(null)
const pendingPayload = ref<Partial<ModelTypePayload> | null>(null)
const title = computed(() =>
initialData.value?.name
@@ -126,7 +116,6 @@ const loadCategory = async () => {
structure: (response.structure as ComponentModelStructure | null) ?? undefined,
}
await loadLinkedCount(id)
} catch (error) {
showError(normalizeError(error))
await navigateBackToList()
@@ -141,9 +130,6 @@ const handleCancel = () => {
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
if (!canEdit.value) return
if (guardSubmitOrNotify()) {
return
}
const id = String(route.params.id)
saving.value = true
try {
@@ -151,10 +137,28 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
...payload,
description: payload?.notes ?? null,
}
await updateModelType(id, enrichedPayload)
await loadComponentTypes({ force: true })
showSuccess('Catégorie de composant mise à jour avec succès.')
await navigateBackToList()
// Get sync preview BEFORE saving
const preview = await syncPreview(id, enrichedPayload.structure || {})
const hasImpact = preview && (
Object.values(preview.additions || {}).some(v => v > 0)
|| Object.values(preview.deletions || {}).some(v => v > 0)
|| Object.values(preview.modifications || {}).some(v => v > 0)
)
if (hasImpact) {
// Show modal for confirmation
pendingPayload.value = enrichedPayload
syncPreviewData.value = preview
showSyncModal.value = true
} else {
// No impact — save directly + sync
await updateModelType(id, enrichedPayload)
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
await loadComponentTypes({ force: true })
showSuccess('Catégorie de composant mise à jour avec succès.')
}
} catch (error) {
showError(normalizeError(error))
} finally {
@@ -162,6 +166,38 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
}
}
const handleSyncConfirm = async () => {
if (!pendingPayload.value) return
const id = String(route.params.id)
syncLoading.value = true
try {
const hasDeletions = syncPreviewData.value && Object.values(syncPreviewData.value.deletions || {}).some(v => v > 0)
const hasModifications = syncPreviewData.value && Object.values(syncPreviewData.value.modifications || {}).some(v => v > 0)
await updateModelType(id, pendingPayload.value)
await syncExecute(id, {
confirmDeletions: !!hasDeletions,
confirmTypeChanges: !!hasModifications,
})
await loadComponentTypes({ force: true })
showSuccess('Catégorie de composant mise à jour avec succès.')
} catch (error) {
showError(normalizeError(error))
} finally {
syncLoading.value = false
showSyncModal.value = false
pendingPayload.value = null
syncPreviewData.value = null
}
}
const handleSyncCancel = () => {
showSyncModal.value = false
pendingPayload.value = null
syncPreviewData.value = null
}
onMounted(() => {
loadCategory()
})

View File

@@ -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,20 +51,33 @@
<label class="label">
<span class="label-text">Catégorie de composant</span>
</label>
<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 componentTypeList"
:key="type.id"
:value="type.id"
<div class="flex items-center gap-2">
<select
v-model="selectedTypeId"
class="select select-bordered select-sm md:select-md flex-1"
disabled
>
{{ type.name }}
</option>
</select>
<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>
@@ -152,45 +171,91 @@
/>
<div
v-if="structureSelections.hasAny"
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">Sélections actuelles</h2>
<h2 class="font-semibold text-base-content">Sélections du squelette</h2>
<p class="text-xs text-base-content/70">
Voici les pièces, produits et sous-composants réellement choisis pour ce composant.
Choisissez les pièces, produits et sous-composants pour chaque emplacement requis par la catégorie.
</p>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div v-if="structureSelections.pieces.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Pièces choisies</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<li v-for="entry in structureSelections.pieces" :key="`selected-piece-${entry.path}-${entry.id}`">
<span class="font-medium">{{ entry.resolvedName }}</span>
<span class="text-xs text-base-content/70"> {{ entry.requirementLabel }}</span>
</li>
</ul>
<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>
<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)"
/>
</div>
<div class="w-20 shrink-0">
<input
type="number"
:value="slot.quantity"
min="1"
class="input input-bordered input-sm w-full text-center"
:disabled="!canEdit || saving"
title="Quantité"
@change="(e) => saveSlotQuantity(slot.slotId, Number((e.target as HTMLInputElement).value))"
>
</div>
</div>
</div>
</div>
</div>
<div v-if="structureSelections.products.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Produits choisis</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<li v-for="entry in structureSelections.products" :key="`selected-product-${entry.path}-${entry.id}`">
<span class="font-medium">{{ entry.resolvedName }}</span>
<span class="text-xs text-base-content/70"> {{ entry.requirementLabel }}</span>
</li>
</ul>
<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>
<ProductSelect
:model-value="slot.selectedProductId"
:disabled="!canEdit || saving"
:type-product-id="slot.typeProductId"
@update:model-value="(value) => saveProductSlotSelection(slot.slotId, value)"
/>
</div>
</div>
</div>
<div v-if="structureSelections.components.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Sous-composants choisis</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<li v-for="entry in structureSelections.components" :key="`selected-component-${entry.path}-${entry.id}`">
<span class="font-medium">{{ entry.resolvedName }}</span>
<span class="text-xs text-base-content/70"> {{ entry.requirementLabel }}</span>
</li>
</ul>
<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>
<ComposantSelect
:model-value="slot.selectedComponentId"
:disabled="!canEdit || saving"
:type-composant-id="slot.typeComposantId"
@update:model-value="(value) => saveSubcomponentSlotSelection(slot.slotId, value)"
/>
</div>
</div>
</div>
</div>
@@ -235,9 +300,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>
@@ -275,10 +342,13 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute } from '#imports'
import { useComponentEdit } from '~/composables/useComponentEdit'
import { useDocuments } from '~/composables/useDocuments'
const route = useRoute()
const { updateDocument } = useDocuments()
const {
component,
@@ -300,6 +370,9 @@ const {
selectedType,
selectedTypeStructure,
structureSelections,
pieceSlotEntries,
productSlotEntries,
subcomponentSlotEntries,
history,
historyLoading,
historyError,
@@ -308,9 +381,33 @@ const {
removeDocument,
handleFilesAdded,
submitEdition,
saveSlotQuantity,
savePieceSlotSelection,
saveProductSlotSelection,
saveSubcomponentSlotSelection,
resolvePieceLabel,
resolveProductLabel,
resolveSubcomponentLabel,
formatStructurePreview,
} = useComponentEdit(String(route.params.id))
const editingDocument = ref<any | null>(null)
const editModalVisible = ref(false)
const openEditModal = (doc: any) => {
editingDocument.value = doc
editModalVisible.value = true
}
const handleDocumentUpdated = async (data: { name?: string; type?: string }) => {
if (!editingDocument.value?.id) return
const result = await updateDocument(editingDocument.value.id, data)
if (result.success) {
const idx = componentDocuments.value.findIndex((d: any) => d.id === editingDocument.value?.id)
if (idx !== -1) {
componentDocuments.value[idx] = { ...componentDocuments.value[idx], ...data }
}
}
editModalVisible.value = false
editingDocument.value = null
}
</script>

View File

@@ -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()
})

View File

@@ -79,6 +79,7 @@
<!-- 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,6 +93,7 @@
<!-- 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')"
@@ -100,6 +102,7 @@
<!-- 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"
@@ -114,6 +117,7 @@
<!-- 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"

View File

@@ -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,7 +120,7 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { useMachines } from '~/composables/useMachines'
import { useSites } from '~/composables/useSites'
import { useToast } from '~/composables/useToast'
@@ -128,7 +135,7 @@ const { machines, loading, loadMachines, deleteMachine } = useMachines()
const { sites, loadSites } = useSites()
const toast = useToast()
const selectedSite = ref('')
const selectedSites = reactive(new Set())
const searchQuery = ref('')
// Enrichir les machines avec les objets site complets
@@ -145,8 +152,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 +164,10 @@ const filteredMachines = computed(() => {
)
}
filtered = [...filtered].sort((a, b) =>
(a.name || '').localeCompare(b.name || '', 'fr')
)
return filtered
})

View File

@@ -27,10 +27,6 @@
:lock-category="true"
:saving="saving"
:readonly="!canEdit"
:disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage"
:restricted-mode="isRestrictedMode"
:restricted-mode-message="restrictedModeMessage"
@submit="handleSubmit"
@cancel="handleCancel"
/>
@@ -45,6 +41,14 @@
show-resolved
/>
</div>
<SyncConfirmationModal
:preview="syncPreviewData"
:open="showSyncModal"
:loading="syncLoading"
@confirm="handleSyncConfirm"
@cancel="handleSyncCancel"
/>
</main>
</template>
@@ -52,9 +56,8 @@
import { computed, onMounted, ref } from 'vue'
import { useHead, useRoute, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
import { getModelType, updateModelType, syncPreview, syncExecute, type ModelTypePayload, type SyncPreviewResult } from '~/services/modelTypes'
import type { PieceModelStructure } from '~/shared/types/inventory'
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useToast } from '~/composables/useToast'
@@ -67,23 +70,10 @@ const { loadPieceTypes } = usePieceTypes()
const loading = ref(true)
const saving = ref(false)
const initialData = ref<Partial<ModelTypePayload> | null>(null)
const {
isRestrictedMode,
isSubmitBlocked,
restrictedModeMessage,
submitBlockMessage,
loadLinkedCount,
guardSubmitOrNotify,
} = useCategoryEditGuard({
endpoint: '/pieces',
filterKey: 'typePiece',
labels: {
singular: 'pièce',
plural: 'pièces',
verifying: 'Vérification des pièces liées en cours…',
},
})
const showSyncModal = ref(false)
const syncLoading = ref(false)
const syncPreviewData = ref<SyncPreviewResult | null>(null)
const pendingPayload = ref<Partial<ModelTypePayload> | null>(null)
const title = computed(() =>
initialData.value?.name ? `Modifier « ${initialData.value.name} »` : 'Modifier une catégorie de pièce',
@@ -124,7 +114,6 @@ const loadCategory = async () => {
structure: (response.structure as PieceModelStructure | null) ?? undefined,
}
await loadLinkedCount(id)
} catch (error) {
showError(normalizeError(error))
await navigateBackToList()
@@ -139,9 +128,6 @@ const handleCancel = () => {
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
if (!canEdit.value) return
if (guardSubmitOrNotify()) {
return
}
const id = String(route.params.id)
saving.value = true
try {
@@ -149,10 +135,28 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
...payload,
description: payload?.notes ?? null,
}
await updateModelType(id, enrichedPayload)
await loadPieceTypes({ force: true })
showSuccess('Catégorie de pièce mise à jour avec succès.')
await navigateBackToList()
// Get sync preview BEFORE saving
const preview = await syncPreview(id, enrichedPayload.structure || {})
const hasImpact = preview && (
Object.values(preview.additions || {}).some(v => v > 0)
|| Object.values(preview.deletions || {}).some(v => v > 0)
|| Object.values(preview.modifications || {}).some(v => v > 0)
)
if (hasImpact) {
// Show modal for confirmation
pendingPayload.value = enrichedPayload
syncPreviewData.value = preview
showSyncModal.value = true
} else {
// No impact — save directly + sync
await updateModelType(id, enrichedPayload)
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
await loadPieceTypes({ force: true })
showSuccess('Catégorie de pièce mise à jour avec succès.')
}
} catch (error) {
showError(normalizeError(error))
} finally {
@@ -160,6 +164,38 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
}
}
const handleSyncConfirm = async () => {
if (!pendingPayload.value) return
const id = String(route.params.id)
syncLoading.value = true
try {
const hasDeletions = syncPreviewData.value && Object.values(syncPreviewData.value.deletions || {}).some(v => v > 0)
const hasModifications = syncPreviewData.value && Object.values(syncPreviewData.value.modifications || {}).some(v => v > 0)
await updateModelType(id, pendingPayload.value)
await syncExecute(id, {
confirmDeletions: !!hasDeletions,
confirmTypeChanges: !!hasModifications,
})
await loadPieceTypes({ force: true })
showSuccess('Catégorie de pièce mise à jour avec succès.')
} catch (error) {
showError(normalizeError(error))
} finally {
syncLoading.value = false
showSyncModal.value = false
pendingPayload.value = null
syncPreviewData.value = null
}
}
const handleSyncCancel = () => {
showSyncModal.value = false
pendingPayload.value = null
syncPreviewData.value = null
}
onMounted(() => {
loadCategory()
})

View File

@@ -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" />
@@ -231,9 +237,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 +279,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 +321,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>

View File

@@ -444,7 +444,7 @@ const submitCreation = async () => {
'piece',
createdPiece.id,
[
createdPiece?.typePiece?.pieceCustomFields,
createdPiece?.typePiece?.structure?.customFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
@@ -466,7 +466,7 @@ const submitCreation = async () => {
selectedDocuments.value = []
}
toast.showSuccess('Pièce créée avec succès')
await router.push('/pieces-catalog')
await router.replace(`/pieces/${createdPiece.id}/edit`)
} else if (result.error) {
toast.showError(result.error)
}

View File

@@ -27,10 +27,6 @@
:lock-category="true"
:saving="saving"
:readonly="!canEdit"
:disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage"
:restricted-mode="isRestrictedMode"
:restricted-mode-message="restrictedModeMessage"
@submit="handleSubmit"
@cancel="handleCancel"
/>
@@ -45,6 +41,14 @@
show-resolved
/>
</div>
<SyncConfirmationModal
:preview="syncPreviewData"
:open="showSyncModal"
:loading="syncLoading"
@confirm="handleSyncConfirm"
@cancel="handleSyncCancel"
/>
</main>
</template>
@@ -52,9 +56,8 @@
import { computed, onMounted, ref } from 'vue'
import { useHead, useRoute, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
import { getModelType, updateModelType, syncPreview, syncExecute, type ModelTypePayload, type SyncPreviewResult } from '~/services/modelTypes'
import type { ProductModelStructure } from '~/shared/types/inventory'
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { useProductTypes } from '~/composables/useProductTypes'
import { useToast } from '~/composables/useToast'
@@ -67,23 +70,10 @@ const { loadProductTypes } = useProductTypes()
const loading = ref(true)
const saving = ref(false)
const initialData = ref<Partial<ModelTypePayload> | null>(null)
const {
isRestrictedMode,
isSubmitBlocked,
restrictedModeMessage,
submitBlockMessage,
loadLinkedCount,
guardSubmitOrNotify,
} = useCategoryEditGuard({
endpoint: '/products',
filterKey: 'typeProduct',
labels: {
singular: 'produit',
plural: 'produits',
verifying: 'Vérification des produits liés en cours…',
},
})
const showSyncModal = ref(false)
const syncLoading = ref(false)
const syncPreviewData = ref<SyncPreviewResult | null>(null)
const pendingPayload = ref<Partial<ModelTypePayload> | null>(null)
const title = computed(() =>
initialData.value?.name ? `Modifier « ${initialData.value.name} »` : 'Modifier une catégorie de produit',
@@ -124,7 +114,6 @@ const loadCategory = async () => {
structure: (response.structure as ProductModelStructure | null) ?? undefined,
}
await loadLinkedCount(id)
} catch (error) {
showError(normalizeError(error))
await navigateBackToList()
@@ -139,9 +128,6 @@ const handleCancel = () => {
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
if (!canEdit.value) return
if (guardSubmitOrNotify()) {
return
}
const id = String(route.params.id)
saving.value = true
try {
@@ -149,10 +135,28 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
...payload,
description: payload?.notes ?? null,
}
await updateModelType(id, enrichedPayload)
await loadProductTypes({ force: true })
showSuccess('Catégorie de produit mise à jour avec succès.')
await navigateBackToList()
// Get sync preview BEFORE saving
const preview = await syncPreview(id, enrichedPayload.structure || {})
const hasImpact = preview && (
Object.values(preview.additions || {}).some(v => v > 0)
|| Object.values(preview.deletions || {}).some(v => v > 0)
|| Object.values(preview.modifications || {}).some(v => v > 0)
)
if (hasImpact) {
// Show modal for confirmation
pendingPayload.value = enrichedPayload
syncPreviewData.value = preview
showSyncModal.value = true
} else {
// No impact — save directly + sync
await updateModelType(id, enrichedPayload)
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
await loadProductTypes({ force: true })
showSuccess('Catégorie de produit mise à jour avec succès.')
}
} catch (error) {
showError(normalizeError(error))
} finally {
@@ -160,6 +164,38 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
}
}
const handleSyncConfirm = async () => {
if (!pendingPayload.value) return
const id = String(route.params.id)
syncLoading.value = true
try {
const hasDeletions = syncPreviewData.value && Object.values(syncPreviewData.value.deletions || {}).some(v => v > 0)
const hasModifications = syncPreviewData.value && Object.values(syncPreviewData.value.modifications || {}).some(v => v > 0)
await updateModelType(id, pendingPayload.value)
await syncExecute(id, {
confirmDeletions: !!hasDeletions,
confirmTypeChanges: !!hasModifications,
})
await loadProductTypes({ force: true })
showSuccess('Catégorie de produit mise à jour avec succès.')
} catch (error) {
showError(normalizeError(error))
} finally {
syncLoading.value = false
showSyncModal.value = false
pendingPayload.value = null
syncPreviewData.value = null
}
}
const handleSyncCancel = () => {
showSyncModal.value = false
pendingPayload.value = null
syncPreviewData.value = null
}
onMounted(() => {
loadCategory()
})

View File

@@ -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" />
@@ -167,9 +173,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>
@@ -244,6 +252,7 @@ const {
loadDocumentsByProduct,
uploadDocuments: uploadProductDocuments,
deleteDocument: deleteProductDocument,
updateDocument,
} = useDocuments()
const { ensureConstructeurs } = useConstructeurs()
const {
@@ -265,6 +274,8 @@ 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',
@@ -307,6 +318,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') {
@@ -390,7 +418,7 @@ const loadProductType = async () => {
// Try using the expanded typeProduct from entity response first
const embedded = product.value?.typeProduct
if (embedded && typeof embedded === 'object' && embedded.id) {
const embeddedStructure = embedded.structure ?? embedded.productSkeleton ?? null
const embeddedStructure = embedded.structure ?? null
if (embeddedStructure) {
productType.value = embedded
structure.value = normalizeProductStructureForSave(embeddedStructure)
@@ -406,7 +434,7 @@ const loadProductType = async () => {
try {
const type = await getModelType(product.value.typeProductId)
productType.value = type
structure.value = normalizeProductStructureForSave(type?.structure ?? type?.productSkeleton ?? null)
structure.value = normalizeProductStructureForSave(type?.structure ?? null)
} catch (error) {
console.error('Erreur lors du chargement du type de produit:', error)
productType.value = embedded ?? null
@@ -474,7 +502,7 @@ const submitEdition = async () => {
const failedFields = await _saveCustomFieldValues(
'product',
result.data.id,
[],
[result.data?.typeProduct?.structure?.customFields],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
if (failedFields.length) {
@@ -482,7 +510,6 @@ const submitEdition = async () => {
return
}
toast.showSuccess('Produit mis à jour avec succès')
await router.push('/product-catalog')
}
} catch (error: any) {
toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')

View File

@@ -330,7 +330,7 @@ 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`)
return
}
if (selectedDocuments.value.length) {
@@ -352,7 +352,7 @@ const submitCreation = async () => {
}
}
toast.showSuccess('Produit créé avec succès')
await router.push('/product-catalog')
await router.replace(`/product/${productId}/edit`)
}
} catch (error: any) {
toast.showError(error?.message || 'Erreur lors de la création du produit')

View File

@@ -46,9 +46,6 @@ export interface ModelType extends BaseModelTypePayload {
updatedAt: string;
category: ModelCategory;
structure: ModelTypeStructure;
componentSkeleton?: ComponentModelStructure | null;
pieceSkeleton?: PieceModelStructure | null;
productSkeleton?: ProductModelStructure | null;
}
export interface ModelTypeListParams {
@@ -86,42 +83,9 @@ const normalizeModelType = (item: any): ModelType => {
if (!item || typeof item !== 'object') {
return item as ModelType;
}
if (!item.structure) {
if (item.category === 'COMPONENT' && item.componentSkeleton) {
item.structure = item.componentSkeleton;
} else if (item.category === 'PIECE' && item.pieceSkeleton) {
item.structure = item.pieceSkeleton;
} else if (item.category === 'PRODUCT' && item.productSkeleton) {
item.structure = item.productSkeleton;
}
}
return item as ModelType;
};
const mapStructureToSkeleton = <T extends Record<string, any>>(payload: T): T => {
if (!payload || typeof payload !== 'object') {
return payload;
}
if (!('structure' in payload)) {
return payload;
}
const structure = (payload as any).structure;
if (!structure) {
return payload;
}
const category = (payload as any).category;
const next = { ...payload } as Record<string, any>;
if (category === 'COMPONENT') {
next.componentSkeleton = structure;
} else if (category === 'PIECE') {
next.pieceSkeleton = structure;
} else if (category === 'PRODUCT') {
next.productSkeleton = structure;
}
delete next.structure;
return next as T;
};
export async function listModelTypes(params: ModelTypeListParams = {}, opts: { signal?: AbortSignal } = {}) {
const requestFetch = useRequestFetch();
const query: Record<string, string | number> = {};
@@ -178,28 +142,26 @@ export async function listModelTypes(params: ModelTypeListParams = {}, opts: { s
export function createModelType(payload: ModelTypePayload, opts: { signal?: AbortSignal } = {}) {
const requestFetch = useRequestFetch();
const mappedPayload = mapStructureToSkeleton(payload);
return requestFetch<ModelType>(ENDPOINT, createOptions({
method: 'POST',
headers: {
'Content-Type': 'application/ld+json',
Accept: 'application/ld+json',
},
body: mappedPayload,
body: payload,
signal: opts.signal,
})).then(normalizeModelType);
}
export function updateModelType(id: string, payload: Partial<ModelTypePayload>, opts: { signal?: AbortSignal } = {}) {
const requestFetch = useRequestFetch();
const mappedPayload = mapStructureToSkeleton(payload);
return requestFetch<ModelType>(`${ENDPOINT}/${id}`, createOptions({
method: 'PATCH',
headers: {
'Content-Type': 'application/merge-patch+json',
Accept: 'application/ld+json',
},
body: mappedPayload,
body: payload,
signal: opts.signal,
})).then(normalizeModelType);
}
@@ -249,3 +211,45 @@ export function convertCategory(id: string, opts: { signal?: AbortSignal } = {})
signal: opts.signal,
}));
}
export interface SyncPreviewResult {
modelTypeId: string;
category: string;
itemCount: number;
additions: Record<string, number>;
deletions: Record<string, number>;
modifications: Record<string, number>;
}
export interface SyncExecuteResult {
itemsUpdated: number;
additions: Record<string, number>;
deletions: Record<string, number>;
modifications: Record<string, number>;
}
export function syncPreview(id: string, structure: unknown, opts: { signal?: AbortSignal } = {}) {
const requestFetch = useRequestFetch();
return requestFetch<SyncPreviewResult>(`${ENDPOINT}/${id}/sync-preview`, createOptions({
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: { structure },
signal: opts.signal,
}));
}
export function syncExecute(id: string, confirmation: { confirmDeletions: boolean; confirmTypeChanges: boolean }, opts: { signal?: AbortSignal } = {}) {
const requestFetch = useRequestFetch();
return requestFetch<SyncExecuteResult>(`${ENDPOINT}/${id}/sync`, createOptions({
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: confirmation,
signal: opts.signal,
}));
}

View File

@@ -155,9 +155,7 @@ export const buildConstructeurRequestPayload = <T extends Record<string, any>>(
delete next.constructeurs;
delete next.constructeurIds;
if (ids.length) {
next.constructeurs = ids.map((id) => `/api/constructeurs/${id}`);
}
next.constructeurs = ids.map((id) => `/api/constructeurs/${id}`);
return next as T & { constructeurs?: string[] };
};

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

View File

@@ -175,6 +175,9 @@ export const normalizeStructureForSave = (input: any): any => {
if (piece.reference) {
payload.reference = piece.reference
}
if ((piece as any).quantity !== undefined && (piece as any).quantity >= 1) {
payload.quantity = (piece as any).quantity
}
return payload
}) as any

View File

@@ -103,6 +103,7 @@ export const hydratePieces = (pieces: any[]): ComponentModelPiece[] => {
reference: piece?.reference ?? '',
familyCode: piece?.familyCode ?? piece?.typePiece?.code ?? '',
role: piece?.role ?? '',
...(piece?.quantity !== undefined && piece.quantity >= 1 ? { quantity: piece.quantity } : {}),
}))
}
@@ -175,6 +176,7 @@ export const mapComponentPieces = (pieces: any[]): ComponentModelPiece[] => {
typePieceLabel: piece?.typePieceLabel ?? piece?.typePiece?.name ?? '',
familyCode: piece?.familyCode ?? piece?.typePiece?.code ?? '',
role: piece?.role ?? '',
...(piece?.quantity !== undefined && piece.quantity >= 1 ? { quantity: piece.quantity } : {}),
}))
}

View File

@@ -162,6 +162,8 @@ export const sanitizePieces = (pieces: any[]): ComponentModelPiece[] => {
const rawRole = typeof piece?.role === 'string' ? piece.role.trim() : ''
const role = rawRole.length > 0 ? rawRole : undefined
const quantity = typeof piece?.quantity === 'number' && piece.quantity >= 1 ? piece.quantity : undefined
if (!typePieceId && !typePieceLabel && !reference && !familyCode) {
return null
}
@@ -182,6 +184,9 @@ export const sanitizePieces = (pieces: any[]): ComponentModelPiece[] => {
if (typePieceLabel) {
result.typePieceLabel = typePieceLabel
}
if (quantity !== undefined) {
result.quantity = quantity
}
return result
})
.filter((piece): piece is ComponentModelPiece => !!piece)

View File

@@ -4,7 +4,7 @@ export interface DefinitionOverridePayload {
name?: string
reference?: string
constructeurIds?: string[]
prix?: number
prix?: string
}
export const sanitizeDefinitionOverrides = (definition: any): DefinitionOverridePayload | null => {
@@ -41,7 +41,7 @@ export const sanitizeDefinitionOverrides = (definition: any): DefinitionOverride
if (definition.prix !== undefined && definition.prix !== null && definition.prix !== '') {
const parsed = Number(definition.prix)
if (!Number.isNaN(parsed)) {
payload.prix = parsed
payload.prix = String(parsed)
}
}

View File

@@ -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 } : {}),
}))
}

View File

@@ -21,6 +21,7 @@ export interface ComponentModelPiece {
reference?: string
familyCode?: string
role?: string
quantity?: number
}
export interface ComponentModelProduct {
@@ -60,6 +61,8 @@ export interface PieceModelCustomField {
key?: string
value?: unknown
defaultValue?: string | null
id?: string
customFieldId?: string
}
export interface PieceModelProduct {
@@ -156,6 +159,7 @@ const validatePiece = (
const reference = ensureString(value.reference)
const familyCode = ensureString(value.familyCode)
const role = ensureString(value.role)
const quantity = typeof value.quantity === 'number' && value.quantity >= 1 ? value.quantity : undefined
if (!typePieceId && !typePieceLabel && !reference && !familyCode) {
issues.push(`${path}: au moins un identifiant, une famille ou une référence de pièce est requis`)
@@ -168,6 +172,7 @@ const validatePiece = (
...(reference ? { reference } : {}),
...(familyCode ? { familyCode } : {}),
...(role ? { role } : {}),
...(quantity ? { quantity } : {}),
}
}

View File

@@ -225,7 +225,10 @@ export const buildCustomFieldInputs = (
if (fieldName) mapByName.set(fieldName, entry)
})
return definitions
const matchedIds = new Set<string>()
const matchedNames = new Set<string>()
const result = definitions
.map((definition) => {
const definitionId = definition.customFieldId || definition.id || null
const matched = (definitionId ? mapById.get(definitionId) : null) || mapByName.get(definition.name)
@@ -239,6 +242,11 @@ export const buildCustomFieldInputs = (
}
}
const matchedFieldId = matched.customField?.id || matched.customFieldId || null
if (matchedFieldId) matchedIds.add(matchedFieldId)
const matchedFieldName = matched.customField?.name || matched.name || null
if (matchedFieldName) matchedNames.add(matchedFieldName)
const resolvedValue = extractStoredCustomFieldValue(matched)
return {
...definition,
@@ -253,7 +261,36 @@ export const buildCustomFieldInputs = (
),
}
})
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
// Include values with embedded definitions that didn't match any structure definition
valueList.forEach((entry, index) => {
if (!entry || typeof entry !== 'object') return
const cf = entry.customField
if (!cf || typeof cf !== 'object') return
const fieldId = cf.id || entry.customFieldId || null
const fieldName = cf.name || entry.name || null
if (fieldId && matchedIds.has(fieldId)) return
if (fieldName && matchedNames.has(fieldName)) return
const name = resolveFieldName(cf)
if (!name) return
const type = resolveFieldType(cf)
const resolvedValue = extractStoredCustomFieldValue(entry)
result.push({
id: fieldId,
name,
type,
required: resolveRequiredFlag(cf),
options: resolveOptions(cf),
value: formatDefaultValue(type, resolvedValue),
customFieldId: fieldId,
customFieldValueId: entry.id ?? null,
orderIndex: typeof cf.orderIndex === 'number' ? cf.orderIndex : definitions.length + index,
})
})
return result.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
}
// ---------------------------------------------------------------------------

View File

@@ -196,34 +196,19 @@ export const getProductDisplay = (
const structuralCandidates = [
source.products,
source.productSkeleton,
(source.definition as AnyRecord)?.products,
(source.definition as AnyRecord)?.productSkeleton,
((source.definition as AnyRecord)?.structure as AnyRecord)?.products,
((source.definition as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
(source.structure as AnyRecord)?.products,
(source.structure as AnyRecord)?.productSkeleton,
(source.requirement as AnyRecord)?.products,
(source.requirement as AnyRecord)?.productSkeleton,
((source.requirement as AnyRecord)?.structure as AnyRecord)?.products,
((source.requirement as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
((source.requirement as AnyRecord)?.componentSkeleton as AnyRecord)?.products,
(source.typeComposant as AnyRecord)?.products,
(source.typeComposant as AnyRecord)?.productSkeleton,
((source.typeComposant as AnyRecord)?.structure as AnyRecord)?.products,
((source.typeComposant as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
(source.originalComposant as AnyRecord)?.products,
(source.originalComposant as AnyRecord)?.productSkeleton,
((source.originalComposant as AnyRecord)?.definition as AnyRecord)?.products,
((source.originalComposant as AnyRecord)?.definition as AnyRecord)?.productSkeleton,
(((source.originalComposant as AnyRecord)?.definition as AnyRecord)?.structure as AnyRecord)?.products,
(((source.originalComposant as AnyRecord)?.definition as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
(source.originalComponent as AnyRecord)?.products,
(source.originalComponent as AnyRecord)?.productSkeleton,
((source.originalComponent as AnyRecord)?.definition as AnyRecord)?.products,
((source.originalComponent as AnyRecord)?.definition as AnyRecord)?.productSkeleton,
(((source.originalComponent as AnyRecord)?.definition as AnyRecord)?.structure as AnyRecord)?.products,
(((source.originalComponent as AnyRecord)?.definition as AnyRecord)?.structure as AnyRecord)?.productSkeleton,
]
const structuralProducts = structuralCandidates

View File

@@ -176,6 +176,7 @@ export function sanitizePieceDefinition(definition: ComponentModelPiece) {
typePieceLabel: definition.typePieceLabel ?? null,
reference: definition.reference ?? null,
familyCode: (definition as any).familyCode ?? null,
quantity: typeof (definition as any).quantity === 'number' ? (definition as any).quantity : null,
})
}

View File

@@ -3,6 +3,9 @@ export type SelectionEntry = {
path: string
requirementLabel: string
resolvedName: string
quantity?: number
slotId?: string
_definition?: Record<string, any>
}
export type StructureSelectionResult = {
@@ -59,6 +62,9 @@ export function collectStructureSelections(
path: isNonEmptyString(entry?.path) ? entry.path : `${nodePath}:piece-${index + 1}`,
requirementLabel: resolvers.resolvePieceLabel(definition),
resolvedName: catalogPiece?.name || selectedId,
quantity: typeof definition?.quantity === 'number' ? definition.quantity : undefined,
slotId: isNonEmptyString(entry?.slotId) ? entry.slotId : undefined,
_definition: definition,
})
})

View File

@@ -225,56 +225,6 @@ describe('category lock', () => {
})
})
// ---------------------------------------------------------------------------
// Restricted mode
// ---------------------------------------------------------------------------
describe('restricted mode', () => {
it('shows restricted mode message', () => {
const wrapper = mountForm({
restrictedMode: true,
restrictedModeMessage: 'Mode restreint actif',
})
expect(wrapper.text()).toContain('Mode restreint actif')
expect(wrapper.find('.alert-info').exists()).toBe(true)
})
it('does not show restricted mode message when not restricted', () => {
const wrapper = mountForm({
restrictedMode: false,
})
expect(wrapper.find('.alert-info').exists()).toBe(false)
})
it('disables name input in restricted mode', () => {
const wrapper = mountForm({ restrictedMode: true })
expect((getNameInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
})
})
// ---------------------------------------------------------------------------
// Submit disabled
// ---------------------------------------------------------------------------
describe('submit disabled', () => {
it('disables submit button when disableSubmit is true', () => {
const wrapper = mountForm({ disableSubmit: true })
expect((getSubmitButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
})
it('shows warning alert when disableSubmit is true', () => {
const wrapper = mountForm({
disableSubmit: true,
disableSubmitMessage: 'Cannot save now',
})
expect(wrapper.find('.alert-warning').exists()).toBe(true)
expect(wrapper.text()).toContain('Cannot save now')
})
it('does not show warning when disableSubmit is false', () => {
const wrapper = mountForm({ disableSubmit: false })
expect(wrapper.find('.alert-warning').exists()).toBe(false)
})
})
// ---------------------------------------------------------------------------
// Saving state
// ---------------------------------------------------------------------------

View File

@@ -227,98 +227,6 @@ describe('required checkbox', () => {
})
})
// ---------------------------------------------------------------------------
// Restricted mode
// ---------------------------------------------------------------------------
describe('restricted mode', () => {
it('allows editing name of pre-existing field', () => {
const wrapper = mountEditor({
restrictedMode: true,
modelValue: {
customFields: [{ name: 'Locked Field', type: 'text', required: false, orderIndex: 0 }],
products: [],
},
})
const nameInput = wrapper.find('input[type="text"]')
expect((nameInput.element as HTMLInputElement).disabled).toBe(false)
})
it('disables type select for pre-existing field', () => {
const wrapper = mountEditor({
restrictedMode: true,
modelValue: {
customFields: [{ name: 'Locked', type: 'text', required: false, orderIndex: 0 }],
products: [],
},
})
const selects = wrapper.findAll('select')
const typeSelect = selects[selects.length - 1]
expect((typeSelect.element as HTMLSelectElement).disabled).toBe(true)
})
it('disables required checkbox for pre-existing field', () => {
const wrapper = mountEditor({
restrictedMode: true,
modelValue: {
customFields: [{ name: 'Locked', type: 'text', required: false, orderIndex: 0 }],
products: [],
},
})
const checkbox = wrapper.find('input[type="checkbox"]')
expect((checkbox.element as HTMLInputElement).disabled).toBe(true)
})
it('hides delete button for pre-existing field', () => {
const wrapper = mountEditor({
restrictedMode: true,
modelValue: {
customFields: [{ name: 'Locked', type: 'text', required: false, orderIndex: 0 }],
products: [],
},
})
// btn-error should not exist for locked fields
const deleteBtn = wrapper.find('button.btn-error')
expect(deleteBtn.exists()).toBe(false)
})
it('allows full editing of newly added field', async () => {
const wrapper = mountEditor({
restrictedMode: true,
modelValue: {
customFields: [],
products: [],
},
})
await getAddFieldButton(wrapper).trigger('click')
await nextTick()
// New field should have an editable type select (not disabled)
const selects = wrapper.findAll('select')
const typeSelect = selects[selects.length - 1]
expect((typeSelect.element as HTMLSelectElement).disabled).toBe(false)
// Delete button should exist for new field
const deleteBtn = wrapper.find('button.btn-error')
expect(deleteBtn.exists()).toBe(true)
})
it('hides product add button in restricted mode', () => {
const wrapper = mountEditor({
restrictedMode: true,
modelValue: { customFields: [], products: [] },
})
const addButtons = wrapper.findAll('button').filter(b => b.text().includes('Ajouter'))
// Only the "add field" button should be visible, not the product one
expect(addButtons.length).toBe(1)
})
})
// ---------------------------------------------------------------------------
// Add product
// ---------------------------------------------------------------------------

View File

@@ -1,269 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockShowInfo = vi.fn()
vi.mock('~/composables/useApi', () => ({
useApi: () => ({
get: mockGet,
post: vi.fn(),
patch: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
apiCall: vi.fn(),
}),
}))
vi.mock('~/composables/useToast', () => ({
useToast: () => ({
showInfo: mockShowInfo,
showSuccess: vi.fn(),
showError: vi.fn(),
showToast: vi.fn(),
toasts: { value: [] },
clearAll: vi.fn(),
}),
}))
const GUARD_CONFIG = {
endpoint: '/composants',
filterKey: 'typeComposant',
labels: {
singular: 'composant',
plural: 'composants',
verifying: 'Vérification des composants liés en cours…',
},
}
beforeEach(() => {
vi.clearAllMocks()
})
// ---------------------------------------------------------------------------
// Initial state
// ---------------------------------------------------------------------------
describe('initial state', () => {
it('has linkedCount 0 and restrictedMode false', () => {
const guard = useCategoryEditGuard(GUARD_CONFIG)
expect(guard.linkedCount.value).toBe(0)
expect(guard.isRestrictedMode.value).toBe(false)
expect(guard.isSubmitBlocked.value).toBe(false)
expect(guard.linkedLoading.value).toBe(false)
})
it('has empty messages when no linked items', () => {
const guard = useCategoryEditGuard(GUARD_CONFIG)
expect(guard.restrictedModeMessage.value).toBe('')
expect(guard.submitBlockMessage.value).toBe('')
})
})
// ---------------------------------------------------------------------------
// loadLinkedCount
// ---------------------------------------------------------------------------
describe('loadLinkedCount', () => {
it('sets linkedCount from API totalItems', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 5, member: [] },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(5)
expect(guard.isRestrictedMode.value).toBe(true)
expect(mockGet).toHaveBeenCalledWith(
expect.stringContaining('/composants?'),
)
})
it('sets linkedCount 0 when API returns 0 items', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 0, member: [] },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(0)
expect(guard.isRestrictedMode.value).toBe(false)
})
it('extracts totalItems from hydra:totalItems format', async () => {
mockGet.mockResolvedValue({
success: true,
data: { 'hydra:totalItems': 3, 'hydra:member': [{}, {}, {}] },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(3)
})
it('falls back to member.length when no totalItems', async () => {
mockGet.mockResolvedValue({
success: true,
data: { member: [{}, {}] },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(2)
})
it('falls back to hydra:member.length', async () => {
mockGet.mockResolvedValue({
success: true,
data: { 'hydra:member': [{}] },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(1)
})
it('sets linkedCount 0 on API failure', async () => {
mockGet.mockResolvedValue({ success: false })
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(0)
expect(guard.isRestrictedMode.value).toBe(false)
})
it('sets linkedCount 0 on exception', async () => {
mockGet.mockRejectedValue(new Error('Network error'))
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.linkedCount.value).toBe(0)
})
it('sends correct filter parameters', async () => {
mockGet.mockResolvedValue({ success: true, data: { totalItems: 0 } })
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('abc-123')
const callUrl = mockGet.mock.calls[0][0] as string
expect(callUrl).toContain('itemsPerPage=1')
expect(callUrl).toContain('typeComposant=%2Fapi%2Fmodel_types%2Fabc-123')
})
})
// ---------------------------------------------------------------------------
// restrictedModeMessage
// ---------------------------------------------------------------------------
describe('restrictedModeMessage', () => {
it('shows singular message for 1 linked item', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 1 },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.restrictedModeMessage.value).toContain('1 composant')
expect(guard.restrictedModeMessage.value).toContain('Mode restreint')
})
it('shows plural message for multiple linked items', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 5 },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.restrictedModeMessage.value).toContain('5 composants')
expect(guard.restrictedModeMessage.value).toContain('renommer les existants')
})
it('uses custom labels from config', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 3 },
})
const guard = useCategoryEditGuard({
endpoint: '/pieces',
filterKey: 'typePiece',
labels: { singular: 'pièce', plural: 'pièces', verifying: 'Vérification...' },
})
await guard.loadLinkedCount('mt-1')
expect(guard.restrictedModeMessage.value).toContain('3 pièces')
})
})
// ---------------------------------------------------------------------------
// isSubmitBlocked & submitBlockMessage
// ---------------------------------------------------------------------------
describe('submit blocking', () => {
it('blocks submit during loading', () => {
const guard = useCategoryEditGuard(GUARD_CONFIG)
// Simulate loading state by starting a load without awaiting
mockGet.mockReturnValue(new Promise(() => {})) // Never resolves
guard.loadLinkedCount('mt-1')
expect(guard.linkedLoading.value).toBe(true)
expect(guard.isSubmitBlocked.value).toBe(true)
expect(guard.submitBlockMessage.value).toBe(GUARD_CONFIG.labels.verifying)
})
it('unblocks submit after loading completes', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 5 },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.isSubmitBlocked.value).toBe(false)
expect(guard.submitBlockMessage.value).toBe('')
})
})
// ---------------------------------------------------------------------------
// guardSubmitOrNotify
// ---------------------------------------------------------------------------
describe('guardSubmitOrNotify', () => {
it('returns false when not blocked', async () => {
mockGet.mockResolvedValue({
success: true,
data: { totalItems: 0 },
})
const guard = useCategoryEditGuard(GUARD_CONFIG)
await guard.loadLinkedCount('mt-1')
expect(guard.guardSubmitOrNotify()).toBe(false)
expect(mockShowInfo).not.toHaveBeenCalled()
})
it('returns true and shows info when blocked', () => {
const guard = useCategoryEditGuard(GUARD_CONFIG)
// Simulate loading
mockGet.mockReturnValue(new Promise(() => {}))
guard.loadLinkedCount('mt-1')
expect(guard.guardSubmitOrNotify()).toBe(true)
expect(mockShowInfo).toHaveBeenCalled()
})
})

View File

@@ -50,60 +50,55 @@ beforeEach(() => {
// normalizeModelType (tested via getModelType which calls .then(normalizeModelType))
// ---------------------------------------------------------------------------
describe('normalizeModelType (via getModelType)', () => {
it('maps componentSkeleton to structure for COMPONENT', async () => {
const skeleton = { customFields: [{ name: 'Weight' }] }
it('returns structure as-is for COMPONENT', async () => {
const structure = { customFields: [{ name: 'Weight' }] }
mockFetch.mockResolvedValue(fakeModelType({
category: 'COMPONENT',
structure: null,
componentSkeleton: skeleton as any,
structure: structure as any,
}))
const result = await getModelType('mt-1')
expect(result.structure).toEqual(skeleton)
expect(result.structure).toEqual(structure)
})
it('maps pieceSkeleton to structure for PIECE', async () => {
const skeleton = { customFields: [{ name: 'Size' }] }
it('returns structure as-is for PIECE', async () => {
const structure = { customFields: [{ name: 'Size' }] }
mockFetch.mockResolvedValue(fakeModelType({
category: 'PIECE',
structure: null,
pieceSkeleton: skeleton as any,
structure: structure as any,
}))
const result = await getModelType('mt-1')
expect(result.structure).toEqual(skeleton)
expect(result.structure).toEqual(structure)
})
it('maps productSkeleton to structure for PRODUCT', async () => {
const skeleton = { customFields: [{ name: 'Brand' }] }
it('returns structure as-is for PRODUCT', async () => {
const structure = { customFields: [{ name: 'Brand' }] }
mockFetch.mockResolvedValue(fakeModelType({
category: 'PRODUCT',
structure: null,
productSkeleton: skeleton as any,
structure: structure as any,
}))
const result = await getModelType('mt-1')
expect(result.structure).toEqual(skeleton)
expect(result.structure).toEqual(structure)
})
it('does not override existing structure', async () => {
const existing = { customFields: [{ name: 'Existing' }] }
it('preserves null structure', async () => {
mockFetch.mockResolvedValue(fakeModelType({
category: 'COMPONENT',
structure: existing as any,
componentSkeleton: { customFields: [{ name: 'Skeleton' }] } as any,
structure: null,
}))
const result = await getModelType('mt-1')
expect(result.structure).toEqual(existing)
expect(result.structure).toBeNull()
})
})
// ---------------------------------------------------------------------------
// createModelType — maps structure to skeleton
// createModelType — sends structure directly
// ---------------------------------------------------------------------------
describe('createModelType', () => {
it('sends POST with componentSkeleton for COMPONENT', async () => {
it('sends POST with structure for COMPONENT', async () => {
const structure = { customFields: [] }
mockFetch.mockResolvedValue(fakeModelType())
@@ -120,11 +115,10 @@ describe('createModelType', () => {
const [endpoint, options] = mockFetch.mock.calls[0]
expect(endpoint).toBe('/model_types')
expect(options.method).toBe('POST')
expect(options.body.componentSkeleton).toEqual(structure)
expect(options.body.structure).toBeUndefined()
expect(options.body.structure).toEqual(structure)
})
it('sends POST with pieceSkeleton for PIECE', async () => {
it('sends POST with structure for PIECE', async () => {
const structure = { customFields: [], products: [] }
mockFetch.mockResolvedValue(fakeModelType({ category: 'PIECE' }))
@@ -136,11 +130,10 @@ describe('createModelType', () => {
})
const [, options] = mockFetch.mock.calls[0]
expect(options.body.pieceSkeleton).toEqual(structure)
expect(options.body.structure).toBeUndefined()
expect(options.body.structure).toEqual(structure)
})
it('sends POST with productSkeleton for PRODUCT', async () => {
it('sends POST with structure for PRODUCT', async () => {
const structure = { customFields: [] }
mockFetch.mockResolvedValue(fakeModelType({ category: 'PRODUCT' }))
@@ -152,15 +145,15 @@ describe('createModelType', () => {
})
const [, options] = mockFetch.mock.calls[0]
expect(options.body.productSkeleton).toEqual(structure)
expect(options.body.structure).toEqual(structure)
})
})
// ---------------------------------------------------------------------------
// updateModelType — maps structure to skeleton
// updateModelType — sends structure directly
// ---------------------------------------------------------------------------
describe('updateModelType', () => {
it('sends PATCH with correct endpoint and skeleton', async () => {
it('sends PATCH with correct endpoint and structure', async () => {
const structure = { customFields: [{ name: 'Updated' }] }
mockFetch.mockResolvedValue(fakeModelType())
@@ -176,10 +169,10 @@ describe('updateModelType', () => {
expect(endpoint).toBe('/model_types/mt-1')
expect(options.method).toBe('PATCH')
expect(options.headers['Content-Type']).toBe('application/merge-patch+json')
expect(options.body.componentSkeleton).toEqual(structure)
expect(options.body.structure).toEqual(structure)
})
it('sends payload without skeleton when no structure', async () => {
it('sends payload without structure when not provided', async () => {
mockFetch.mockResolvedValue(fakeModelType())
await updateModelType('mt-1', {
@@ -189,7 +182,7 @@ describe('updateModelType', () => {
})
const [, options] = mockFetch.mock.calls[0]
expect(options.body.componentSkeleton).toBeUndefined()
expect(options.body.structure).toBeUndefined()
expect(options.body.name).toBe('Just Name')
})
})