45 Commits

Author SHA1 Message Date
Matthieu
958a00c8fc WIP 2026-03-31 17:53:30 +02:00
Matthieu
e0f761da2b feat(constructeur) : update product edit flow with supplier references
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:02:32 +02:00
Matthieu
80739a4528 feat(constructeur) : update composant edit flow with supplier references
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:52:50 +02:00
Matthieu
c5988ec7a6 feat(constructeur) : update piece edit flow with supplier references
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:55:18 +02:00
Matthieu
63a56c47ba feat(constructeur) : add ConstructeurLinkEntry type, useConstructeurLinks composable, and ConstructeurLinksTable component
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:33:54 +02:00
Matthieu
c82c21c0cd feat(reference-auto) : formula builder component + composant support + changelog v1.9.5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:51:22 +02:00
Matthieu
a339e722a6 feat(reference-auto) : display referenceAuto in piece views + formula config in ModelTypeForm
- Piece interface: add referenceAuto field
- piece/[id].vue: read-only display with auto badge
- pieces/[id]/edit.vue: disabled input when referenceAuto is set
- pieces-catalog.vue: new column "Réf. auto"
- PieceItem.vue: badge + detail line for referenceAuto
- ModelTypeForm.vue: formula + required fields config for PIECE category
- modelTypes.ts: add referenceFormula/requiredFieldsForReference to types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:33:33 +01:00
Matthieu
a7415964a7 feat(machine) : single save button + link versioning display
- Replace auto-save-on-blur with single "Enregistrer" button
- Add Cancel button that resets local state
- Expose saveFieldDefinitions via defineExpose on MachineInfoCard
- Remove standalone save button from MachineCustomFieldDefEditor
- Add saveAllMachineCustomFields batch method
- Add submitEdition/cancelEdition/saving/canSubmit to orchestrator
- Show diff summary badges in version list entries
- Show link changes in restore modal description

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:51:34 +01:00
Matthieu
767c9a7424 feat(versioning) : add entity versioning frontend with restore flow
- useEntityVersions composable (list, preview, restore API calls)
- EntityVersionList component with auto-refresh after save
- VersionRestoreModal with context-aware messages per entity type
- Integrate into machine, composant, piece, product detail pages
- Add restore action label to historyDisplayUtils
- Show structure slots in composant/piece consultation mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:58:39 +01:00
Matthieu
d197d30eb0 fix(composant) : preserve skeleton selections on form validation error
Shared module-level loading ref in useComposants caused structureDataLoading
to toggle during submission, unmounting the skeleton assignment UI. On remount,
watchers cleared selections not found in the limited local catalog.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:36:07 +01:00
Matthieu
452de8b069 feat(changelog) : add v1.9.4 release notes (detail views)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 10:12:25 +01:00
Matthieu
92141c6564 feat(detail) : add consultation pages for piece, composant, product
Add read-only detail pages with edit/view toggle for piece, composant and
product, matching the existing machine detail pattern. Empty fields and
documents section are hidden in consultation mode. Catalogs and cross-links
updated to point to the new detail pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 10:09:44 +01:00
Matthieu
9e1504ddb7 feat(machine) : add entity history section to machine detail page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:31:25 +01:00
Matthieu
a72279f978 refactor(component-edit) : replace slot auto-save with deferred save on submit
Slot selections (piece, product, subcomponent, quantity) are no longer
saved immediately on change. Instead, edits are stored locally and
persisted together with base fields and custom fields when the user
clicks "Enregistrer les modifications".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:45:18 +01:00
Matthieu
9cc8b28122 feat(search) : display reference alongside name in all entity select components
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:21:37 +01:00
Matthieu
02ca3549d5 fix(search) : disable client-side filtering when server-search is active
SearchSelect was filtering results client-side on label only, hiding
server results matched by reference. Add serverSearch prop to bypass
client filter when the API already handles search.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:19:13 +01:00
Matthieu
5485bac339 feat(search) : add server-side search on name + reference in PieceSelect, ProductSelect and ComposantSelect
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:03:01 +01:00
Matthieu
d0dc01deb1 feat(search) : add server-side multi-field search (name + reference) for pieces, components and products
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:57:23 +01:00
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
72 changed files with 5126 additions and 835 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> </div>
<!-- Formulaire d'ajout --> <!-- Formulaire d'ajout -->
<div class="flex gap-2"> <div class="space-y-2">
<textarea <div class="flex gap-2">
v-model="newContent" <textarea
class="textarea textarea-bordered flex-1 text-sm" v-model="newContent"
rows="2" class="textarea textarea-bordered flex-1 text-sm"
placeholder="Ajouter un commentaire..." rows="2"
:disabled="submitting" placeholder="Ajouter un commentaire..."
@keydown.ctrl.enter="handleSubmit" :disabled="submitting"
/> @keydown.ctrl.enter="handleSubmit"
<button />
type="button" <div class="flex flex-col gap-1 self-end">
class="btn btn-primary btn-sm self-end" <label
:disabled="!newContent.trim() || submitting" class="btn btn-ghost btn-sm btn-square tooltip tooltip-left"
@click="handleSubmit" data-tip="Joindre des fichiers"
> >
<span v-if="submitting" class="loading loading-spinner loading-xs" /> <IconLucidePaperclip class="w-4 h-4" />
<IconLucideSend v-else class="w-4 h-4" /> <input
</button> ref="fileInputRef"
type="file"
multiple
class="hidden"
@change="handleFilesSelected"
/>
</label>
<button
type="button"
class="btn btn-primary btn-sm btn-square"
:disabled="!newContent.trim() || submitting"
@click="handleSubmit"
>
<span v-if="submitting" class="loading loading-spinner loading-xs" />
<IconLucideSend v-else class="w-4 h-4" />
</button>
</div>
</div>
<!-- Selected files preview -->
<div v-if="selectedFiles.length" class="flex flex-wrap gap-1">
<span
v-for="(file, i) in selectedFiles"
:key="i"
class="badge badge-sm badge-outline gap-1"
>
<IconLucideFile class="w-3 h-3" />
{{ file.name }}
<button type="button" class="ml-1" @click="removeFile(i)">
<IconLucideX class="w-3 h-3" />
</button>
</span>
</div>
</div> </div>
<!-- Liste des commentaires ouverts --> <!-- Liste des commentaires ouverts -->
@@ -57,6 +88,8 @@
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<div class="flex-1"> <div class="flex-1">
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p> <p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
<!-- Documents attachés -->
<CommentDocumentList :documents="getDocuments(comment)" />
</div> </div>
</div> </div>
<div class="flex items-center justify-between text-xs text-base-content/60"> <div class="flex items-center justify-between text-xs text-base-content/60">
@@ -97,6 +130,8 @@
class="bg-base-200/50 rounded-lg p-3 opacity-60 space-y-1" class="bg-base-200/50 rounded-lg p-3 opacity-60 space-y-1"
> >
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p> <p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
<!-- Documents attachés (résolus) -->
<CommentDocumentList :documents="getDocuments(comment)" />
<div class="flex items-center justify-between text-xs text-base-content/50"> <div class="flex items-center justify-between text-xs text-base-content/50">
<span>{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}</span> <span>{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}</span>
<span v-if="comment.resolvedByName"> <span v-if="comment.resolvedByName">
@@ -110,12 +145,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useComments, type Comment } from '~/composables/useComments' import { useComments, type Comment, type CommentDocument } from '~/composables/useComments'
import { usePermissions } from '~/composables/usePermissions' import { usePermissions } from '~/composables/usePermissions'
import CommentDocumentList from '~/components/CommentDocumentList.vue'
import IconLucideMessageSquare from '~icons/lucide/message-square' import IconLucideMessageSquare from '~icons/lucide/message-square'
import IconLucideSend from '~icons/lucide/send' import IconLucideSend from '~icons/lucide/send'
import IconLucideCheck from '~icons/lucide/check' import IconLucideCheck from '~icons/lucide/check'
import IconLucideTrash2 from '~icons/lucide/trash-2' import IconLucideTrash2 from '~icons/lucide/trash-2'
import IconLucidePaperclip from '~icons/lucide/paperclip'
import IconLucideFile from '~icons/lucide/file'
import IconLucideX from '~icons/lucide/x'
const props = defineProps<{ const props = defineProps<{
entityType: string entityType: string
@@ -138,6 +177,11 @@ const newContent = ref('')
const submitting = ref(false) const submitting = ref(false)
const loadingComments = ref(false) const loadingComments = ref(false)
const showResolvedList = ref(false) const showResolvedList = ref(false)
const selectedFiles = ref<File[]>([])
const fileInputRef = ref<HTMLInputElement | null>(null)
const getDocuments = (comment: Comment): CommentDocument[] =>
comment.documents?.filter((d): d is CommentDocument => typeof d === 'object' && d !== null && 'id' in d) ?? []
const openComments = computed(() => const openComments = computed(() =>
comments.value.filter(c => c.status === 'open'), comments.value.filter(c => c.status === 'open'),
@@ -159,6 +203,18 @@ const formatCommentDate = (dateStr: string): string => {
}).format(date) }).format(date)
} }
const handleFilesSelected = (e: Event) => {
const input = e.target as HTMLInputElement
if (input.files) {
selectedFiles.value.push(...Array.from(input.files))
}
input.value = ''
}
const removeFile = (index: number) => {
selectedFiles.value.splice(index, 1)
}
const loadComments = async () => { const loadComments = async () => {
loadingComments.value = true loadingComments.value = true
const [openResult, resolvedResult] = await Promise.all([ const [openResult, resolvedResult] = await Promise.all([
@@ -182,10 +238,12 @@ const handleSubmit = async () => {
props.entityId, props.entityId,
content, content,
props.entityName, props.entityName,
selectedFiles.value.length > 0 ? selectedFiles.value : undefined,
) )
submitting.value = false submitting.value = false
if (result.success) { if (result.success) {
newContent.value = '' newContent.value = ''
selectedFiles.value = []
await loadComments() await loadComments()
} }
} }

View File

@@ -6,6 +6,12 @@
:documents="componentDocuments" :documents="componentDocuments"
@close="closePreview" @close="closePreview"
/> />
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<!-- Component Header --> <!-- Component Header -->
<div class="flex items-center gap-3 p-3 bg-base-200 rounded-lg cursor-pointer" @click="toggleCollapse"> <div class="flex items-center gap-3 p-3 bg-base-200 rounded-lg cursor-pointer" @click="toggleCollapse">
@@ -29,6 +35,7 @@
class="text-xs text-base-content/50" class="text-xs text-base-content/50"
> >
{{ constructeur.name }} {{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="opacity-70">({{ supplierReferenceMap.get(constructeur.id) }})</span>
</span> </span>
<span v-if="displayProductName" class="badge badge-info badge-xs"> <span v-if="displayProductName" class="badge badge-info badge-xs">
{{ displayProductName }} {{ displayProductName }}
@@ -96,6 +103,9 @@
class="text-base-content" class="text-base-content"
> >
{{ constructeur.name }} {{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-sm text-base-content/60">
Réf. {{ supplierReferenceMap.get(constructeur.id) }}
</span>
<span v-if="formatConstructeurContact(constructeur)" class="text-xs text-base-content/50 block"> <span v-if="formatConstructeurContact(constructeur)" class="text-xs text-base-content/50 block">
{{ formatConstructeurContact(constructeur) }} {{ formatConstructeurContact(constructeur) }}
</span> </span>
@@ -121,7 +131,7 @@
</div> </div>
<NuxtLink <NuxtLink
v-if="component.product?.id" v-if="component.product?.id"
:to="`/product/${component.product.id}/edit`" :to="`/product/${component.product.id}`"
class="btn btn-ghost btn-xs shrink-0" class="btn btn-ghost btn-xs shrink-0"
> >
Voir le produit Voir le produit
@@ -208,9 +218,11 @@
<DocumentListInline <DocumentListInline
:documents="componentDocuments" :documents="componentDocuments"
:can-delete="isEditMode" :can-delete="isEditMode"
:can-edit="isEditMode"
:delete-disabled="uploadingDocuments" :delete-disabled="uploadingDocuments"
empty-text="Aucun document lié à ce composant." empty-text="Aucun document lié à ce composant."
@preview="openPreview" @preview="openPreview"
@edit="openEditModal"
@delete="removeDocument" @delete="removeDocument"
/> />
</div> </div>
@@ -284,6 +296,7 @@ import {
formatConstructeurContact as formatConstructeurContactSummary, formatConstructeurContact as formatConstructeurContactSummary,
resolveConstructeurs, resolveConstructeurs,
uniqueConstructeurIds, uniqueConstructeurIds,
parseConstructeurLinksFromApi,
} from '~/shared/constructeurUtils' } from '~/shared/constructeurUtils'
import { import {
formatSize, formatSize,
@@ -319,6 +332,7 @@ const {
ensureDocumentsLoaded, ensureDocumentsLoaded,
handleFilesAdded, handleFilesAdded,
removeDocument, removeDocument,
editDocument,
} = useEntityDocuments({ entity: () => props.component, entityType: 'composant' }) } = useEntityDocuments({ entity: () => props.component, entityType: 'composant' })
const { const {
@@ -333,6 +347,21 @@ const {
updateCustomField: updateComponentCustomField, updateCustomField: updateComponentCustomField,
} = useEntityCustomFields({ entity: () => props.component, entityType: 'composant' }) } = useEntityCustomFields({ entity: () => props.component, entityType: 'composant' })
// --- Document edit modal ---
const editingDocument = ref(null)
const editModalVisible = ref(false)
const openEditModal = (doc) => {
editingDocument.value = doc
editModalVisible.value = true
}
const handleDocumentUpdated = async (data) => {
if (!editingDocument.value?.id) return
await editDocument(editingDocument.value.id, data)
editModalVisible.value = false
editingDocument.value = null
}
// --- Collapse state --- // --- Collapse state ---
const isCollapsed = ref(true) const isCollapsed = ref(true)
@@ -367,23 +396,36 @@ const structurePieces = computed(() => allPieces.value.filter((p) => p._structur
// --- Constructeurs --- // --- Constructeurs ---
const { constructeurs } = useConstructeurs() const { constructeurs } = useConstructeurs()
const componentConstructeurIds = computed(() => const componentConstructeurLinks = computed(() =>
uniqueConstructeurIds( parseConstructeurLinksFromApi(
props.component,
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [], Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [],
props.component.constructeur ? [props.component.constructeur] : [],
), ),
) )
const componentConstructeursDisplay = computed(() => const supplierReferenceMap = computed(() => {
resolveConstructeurs( const map = new Map()
componentConstructeurIds.value, componentConstructeurLinks.value.forEach(l => {
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [], if (l.supplierReference) map.set(l.constructeurId, l.supplierReference)
props.component.constructeur ? [props.component.constructeur] : [], })
constructeurs.value, return map
), })
const componentConstructeurIds = computed(() =>
componentConstructeurLinks.value.map(l => l.constructeurId).filter(Boolean),
) )
const componentConstructeursDisplay = computed(() => {
// Extract nested constructeur objects from link entries
const linkConstructeurs = componentConstructeurLinks.value
.filter(l => l.constructeur && l.constructeur.id)
.map(l => l.constructeur)
return resolveConstructeurs(
componentConstructeurIds.value,
linkConstructeurs,
constructeurs.value,
)
})
const formatConstructeurContact = (constructeur) => const formatConstructeurContact = (constructeur) =>
formatConstructeurContactSummary(constructeur) formatConstructeurContactSummary(constructeur)

View File

@@ -23,6 +23,7 @@
:empty-text="componentOptions.length ? 'Aucun résultat' : 'Aucun composant disponible'" :empty-text="componentOptions.length ? 'Aucun résultat' : 'Aucun composant disponible'"
:option-label="componentOptionLabel" :option-label="componentOptionLabel"
:option-description="componentOptionDescription" :option-description="componentOptionDescription"
server-search
@search="fetchComponentOptions" @search="fetchComponentOptions"
@update:modelValue="(value) => { assignment.selectedComponentId = normalizeSelectionValue(value); }" @update:modelValue="(value) => { assignment.selectedComponentId = normalizeSelectionValue(value); }"
/> />
@@ -62,6 +63,7 @@
:empty-text="getPieceOptions(pieceAssignment).length ? 'Aucun résultat' : 'Aucune pièce disponible'" :empty-text="getPieceOptions(pieceAssignment).length ? 'Aucun résultat' : 'Aucune pièce disponible'"
:option-label="pieceOptionLabel" :option-label="pieceOptionLabel"
:option-description="pieceOptionDescription" :option-description="pieceOptionDescription"
server-search
@search="(term) => fetchPieceOptions(pieceAssignment, term)" @search="(term) => fetchPieceOptions(pieceAssignment, term)"
@update:modelValue="(value) => { pieceAssignment.selectedPieceId = normalizeSelectionValue(value); }" @update:modelValue="(value) => { pieceAssignment.selectedPieceId = normalizeSelectionValue(value); }"
/> />
@@ -101,6 +103,7 @@
:empty-text="getProductOptions(productAssignment).length ? 'Aucun résultat' : 'Aucun produit disponible'" :empty-text="getProductOptions(productAssignment).length ? 'Aucun résultat' : 'Aucun produit disponible'"
:option-label="productOptionLabel" :option-label="productOptionLabel"
:option-description="productOptionDescription" :option-description="productOptionDescription"
server-search
@search="(term) => fetchProductOptions(productAssignment, term)" @search="(term) => fetchProductOptions(productAssignment, term)"
@update:modelValue="(value) => { productAssignment.selectedProductId = normalizeSelectionValue(value); }" @update:modelValue="(value) => { productAssignment.selectedProductId = normalizeSelectionValue(value); }"
/> />

View File

@@ -8,9 +8,11 @@
:empty-text="emptyText" :empty-text="emptyText"
size="sm" size="sm"
option-value="id" option-value="id"
option-label="name" :option-label="formatLabel"
:disabled="disabled" :disabled="disabled"
server-search
@update:modelValue="updateValue" @update:modelValue="updateValue"
@search="handleSearch"
> >
<template #option-description="{ option }"> <template #option-description="{ option }">
<span class="text-xs text-base-content/60"> <span class="text-xs text-base-content/60">
@@ -25,7 +27,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import SearchSelect from '~/components/common/SearchSelect.vue' import SearchSelect from '~/components/common/SearchSelect.vue'
import { useComposants } from '~/composables/useComposants' import { useComposants } from '~/composables/useComposants'
@@ -52,43 +54,45 @@ const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void (e: 'update:modelValue', value: string | null): void
}>() }>()
const { composants, loading, loadComposants } = useComposants() const { loading: globalLoading, loadComposants } = useComposants()
const composantOptions = computed(() => { const localComposants = ref<any[]>([])
const baseOptions = Array.isArray(composants.value) ? composants.value : [] const localLoading = ref(false)
if (!props.typeComposantId) { const loading = computed(() => localLoading.value || globalLoading.value)
return baseOptions
const composantOptions = computed(() => localComposants.value)
const loadFilteredComposants = async (search = '') => {
if (!props.typeComposantId) return
localLoading.value = true
try {
const result = await loadComposants({ typeComposantId: props.typeComposantId, search, itemsPerPage: 200, force: true })
if (result.success && result.data?.items) {
localComposants.value = result.data.items
}
} }
catch (error: unknown) {
console.error('Erreur lors du chargement des composants:', error)
}
finally {
localLoading.value = false
}
}
const allowedTypeId = String(props.typeComposantId) let searchDebounce: ReturnType<typeof setTimeout> | null = null
return baseOptions.filter((composant: any) => { const handleSearch = (term: string) => {
const typeId = if (searchDebounce) clearTimeout(searchDebounce)
composant?.typeComposantId || searchDebounce = setTimeout(() => loadFilteredComposants(term.trim()), 300)
composant?.typeComposant?.id || }
null
return typeId ? String(typeId) === allowedTypeId : false
})
})
onMounted(() => { onMounted(() => {
if (composantOptions.value.length === 0) { loadFilteredComposants()
loadComposants({ itemsPerPage: 200 }).catch((error: unknown) => {
console.error('Erreur lors du chargement des composants:', error)
})
}
}) })
watch( watch(
() => props.modelValue, () => props.typeComposantId,
(value) => { () => {
if (typeof value === 'string' && value) { loadFilteredComposants()
const exists = composantOptions.value.some((c: any) => c.id === value)
if (!exists && !loading.value) {
loadComposants({ itemsPerPage: 200, force: true }).catch((error: unknown) => {
console.error('Erreur lors du chargement des composants:', error)
})
}
}
}, },
) )
@@ -100,10 +104,20 @@ const updateValue = (value: string | number | null | undefined) => {
emit('update:modelValue', String(value)) emit('update:modelValue', String(value))
} }
const formatLabel = (option: any) => {
if (!option) return ''
const name = option.name || 'Composant'
return option.reference ? `${name}${option.reference}` : name
}
const formatDescription = (option: any) => { const formatDescription = (option: any) => {
const parts: string[] = [] const parts: string[] = []
const typeName = option?.typeComposant?.name
if (typeName) {
parts.push(typeName)
}
if (option?.reference) { if (option?.reference) {
parts.push(option.reference) parts.push(`Ref. ${option.reference}`)
} }
if (option?.prix !== undefined && option.prix !== null) { if (option?.prix !== undefined && option.prix !== null) {
const price = Number(option.prix) const price = Number(option.prix)

View File

@@ -0,0 +1,93 @@
<template>
<div v-if="modelValue.length" class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Fournisseur</th>
<th>Réf. fournisseur</th>
<th v-if="!readonly" class="w-10" />
</tr>
</thead>
<tbody>
<tr v-for="(link, index) in modelValue" :key="link.constructeurId">
<td class="font-medium">
{{ getConstructeurName(link) }}
<div v-if="getConstructeurContact(link)" class="text-xs text-gray-500">
{{ getConstructeurContact(link) }}
</div>
</td>
<td>
<input
v-if="!readonly"
:value="link.supplierReference || ''"
type="text"
class="input input-bordered input-sm w-full"
placeholder="Réf. fournisseur"
@input="updateReference(index, ($event.target as HTMLInputElement).value)"
>
<span v-else>{{ link.supplierReference || '' }}</span>
</td>
<td v-if="!readonly">
<button
type="button"
class="btn btn-ghost btn-xs text-error"
aria-label="Retirer"
@click="removeLink(index)"
>
<IconLucideX class="w-4 h-4" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import type { PropType } from 'vue'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
import { formatConstructeurContact } from '~/shared/constructeurUtils'
import { useConstructeurs } from '~/composables/useConstructeurs'
import IconLucideX from '~icons/lucide/x'
const props = defineProps({
modelValue: {
type: Array as PropType<ConstructeurLinkEntry[]>,
default: () => [],
},
readonly: {
type: Boolean,
default: false,
},
})
const emit = defineEmits<{
(e: 'update:modelValue', value: ConstructeurLinkEntry[]): void
(e: 'remove', constructeurId: string): void
}>()
const { getConstructeurById } = useConstructeurs()
const getConstructeurName = (link: ConstructeurLinkEntry): string =>
link.constructeur?.name || getConstructeurById(link.constructeurId)?.name || link.constructeurId
const getConstructeurContact = (link: ConstructeurLinkEntry): string => {
const c = link.constructeur || getConstructeurById(link.constructeurId)
return formatConstructeurContact(c as any)
}
const updateReference = (index: number, value: string) => {
const updated = [...props.modelValue]
const entry = updated[index]
if (!entry) return
updated[index] = { ...entry, supplierReference: value || null }
emit('update:modelValue', updated)
}
const removeLink = (index: number) => {
const removed = props.modelValue[index]
const updated = props.modelValue.filter((_, i) => i !== index)
emit('update:modelValue', updated)
if (removed) emit('remove', removed.constructeurId)
}
</script>

View File

@@ -0,0 +1,51 @@
<template>
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div class="flex flex-col gap-2">
<h1 class="text-3xl font-bold">{{ title }}</h1>
<p v-if="subtitle" class="text-sm text-base-content/70">{{ subtitle }}</p>
</div>
<div class="flex items-center gap-2">
<button
v-if="canEdit"
class="btn btn-primary"
:class="{ 'btn-outline': isEditMode }"
@click="$emit('toggle-edit')"
>
<IconLucideSquarePen v-if="!isEditMode" class="w-5 h-5 mr-2" aria-hidden="true" />
<IconLucideEye v-else class="w-5 h-5 mr-2" aria-hidden="true" />
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
</button>
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
Retour au catalogue
</button>
</div>
</div>
</template>
<script setup lang="ts">
import IconLucideSquarePen from '~icons/lucide/square-pen'
import IconLucideEye from '~icons/lucide/eye'
const router = useRouter()
const props = defineProps<{
title: string
subtitle?: string
isEditMode: boolean
canEdit: boolean
backLink: string
}>()
defineEmits<{
'toggle-edit': []
}>()
function goBack() {
if (window.history.length > 1) {
router.back()
}
else {
navigateTo(props.backLink)
}
}
</script>

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" @change="onFileChange"
> >
<div class="w-full max-w-xs mt-2">
<label class="text-xs font-semibold uppercase tracking-wide text-base-content/70">
Type de document
</label>
<select
class="select select-bordered select-sm w-full mt-1"
:value="documentType"
@change="emit('update:documentType', $event.target.value)"
>
<option v-for="t in DOCUMENT_TYPES" :key="t.value" :value="t.value">
{{ t.label }}
</option>
</select>
</div>
<ul v-if="selectedFiles.length" class="mt-4 w-full space-y-2 text-left"> <ul v-if="selectedFiles.length" class="mt-4 w-full space-y-2 text-left">
<li v-for="file in selectedFiles" :key="file.name" class="flex items-center justify-between text-sm"> <li v-for="file in selectedFiles" :key="file.name" class="flex items-center justify-between text-sm">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -69,6 +84,7 @@
<script setup> <script setup>
import { ref, computed, watch, onBeforeUnmount } from 'vue' import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { DOCUMENT_TYPES } from '~/shared/documentTypes'
import { getFileIcon } from '~/utils/fileIcons' import { getFileIcon } from '~/utils/fileIcons'
import IconLucideCloudUpload from '~icons/lucide/cloud-upload' import IconLucideCloudUpload from '~icons/lucide/cloud-upload'
@@ -96,10 +112,14 @@ const props = defineProps({
maxFileSizeMb: { maxFileSizeMb: {
type: Number, type: Number,
default: 200 default: 200
},
documentType: {
type: String,
default: 'documentation'
} }
}) })
const emit = defineEmits(['update:modelValue', 'files-added']) const emit = defineEmits(['update:modelValue', 'files-added', 'update:documentType'])
const dragActive = ref(false) const dragActive = ref(false)
const fileInput = ref(null) const fileInput = ref(null)

View File

@@ -6,6 +6,12 @@
:documents="pieceDocuments" :documents="pieceDocuments"
@close="closePreview" @close="closePreview"
/> />
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<!-- Piece Header (collapsible, same pattern as ComponentItem) --> <!-- Piece Header (collapsible, same pattern as ComponentItem) -->
<div class="flex items-start justify-between p-4 bg-base-200 rounded-lg"> <div class="flex items-start justify-between p-4 bg-base-200 rounded-lg">
@@ -36,6 +42,7 @@
Rattachée à {{ piece.parentComponentName }} Rattachée à {{ piece.parentComponentName }}
</span> </span>
<span v-if="pieceData.reference" class="badge badge-outline badge-sm">{{ pieceData.reference }}</span> <span v-if="pieceData.reference" class="badge badge-outline badge-sm">{{ pieceData.reference }}</span>
<span v-if="pieceData.referenceAuto" class="badge badge-secondary badge-sm" title="Référence auto">{{ pieceData.referenceAuto }}</span>
<template v-if="pieceConstructeursDisplay.length"> <template v-if="pieceConstructeursDisplay.length">
<span <span
v-for="constructeur in pieceConstructeursDisplay" v-for="constructeur in pieceConstructeursDisplay"
@@ -43,6 +50,9 @@
class="badge badge-outline badge-sm" class="badge badge-outline badge-sm"
> >
{{ constructeur.name }} {{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-xs opacity-60 ml-0.5">
({{ supplierReferenceMap.get(constructeur.id) }})
</span>
</span> </span>
</template> </template>
<span v-if="pieceData.prix" class="badge badge-primary badge-sm">{{ pieceData.prix }}</span> <span v-if="pieceData.prix" class="badge badge-primary badge-sm">{{ pieceData.prix }}</span>
@@ -100,6 +110,10 @@
pieceData.reference || "Non définie" pieceData.reference || "Non définie"
}}</span> }}</span>
</div> </div>
<div v-if="pieceData.referenceAuto">
<span class="font-medium">Référence auto:</span>
<span class="ml-2">{{ pieceData.referenceAuto }}</span>
</div>
<div> <div>
<span class="font-medium">Fournisseur:</span> <span class="font-medium">Fournisseur:</span>
<div v-if="!isEditMode" class="ml-2"> <div v-if="!isEditMode" class="ml-2">
@@ -111,6 +125,9 @@
> >
<span class="font-medium"> <span class="font-medium">
{{ constructeur.name }} {{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-sm font-normal text-base-content/60">
Réf. {{ supplierReferenceMap.get(constructeur.id) }}
</span>
</span> </span>
<span <span
v-if="formatConstructeurContact(constructeur)" v-if="formatConstructeurContact(constructeur)"
@@ -174,7 +191,7 @@
</p> </p>
<NuxtLink <NuxtLink
v-if="selectedProduct.id" v-if="selectedProduct.id"
:to="`/product/${selectedProduct.id}/edit`" :to="`/product/${selectedProduct.id}`"
class="link link-primary text-xs" class="link link-primary text-xs"
> >
Ouvrir la fiche produit Ouvrir la fiche produit
@@ -247,9 +264,11 @@
<DocumentListInline <DocumentListInline
:documents="pieceDocuments" :documents="pieceDocuments"
:can-delete="isEditMode" :can-delete="isEditMode"
:can-edit="isEditMode"
:delete-disabled="uploadingDocuments" :delete-disabled="uploadingDocuments"
empty-text="Aucun document lié à cette pièce." empty-text="Aucun document lié à cette pièce."
@preview="openPreview" @preview="openPreview"
@edit="openEditModal"
@delete="removeDocument" @delete="removeDocument"
/> />
</div> </div>
@@ -270,6 +289,7 @@ import {
formatConstructeurContact as formatConstructeurContactSummary, formatConstructeurContact as formatConstructeurContactSummary,
resolveConstructeurs, resolveConstructeurs,
uniqueConstructeurIds, uniqueConstructeurIds,
parseConstructeurLinksFromApi,
} from '~/shared/constructeurUtils' } from '~/shared/constructeurUtils'
import { import {
resolveFieldId, resolveFieldId,
@@ -293,6 +313,7 @@ const emit = defineEmits(['update', 'edit', 'custom-field-update', 'delete'])
const pieceData = reactive({ const pieceData = reactive({
name: props.piece.name || '', name: props.piece.name || '',
reference: props.piece.reference || '', reference: props.piece.reference || '',
referenceAuto: props.piece.referenceAuto || null,
prix: props.piece.prix || '', prix: props.piece.prix || '',
productId: props.piece.product?.id || props.piece.productId || null, productId: props.piece.product?.id || props.piece.productId || null,
quantity: props.piece.quantity ?? 1, quantity: props.piece.quantity ?? 1,
@@ -329,6 +350,7 @@ const {
refreshDocuments, refreshDocuments,
handleFilesAdded, handleFilesAdded,
removeDocument, removeDocument,
editDocument,
} = useEntityDocuments({ entity: () => props.piece, entityType: 'piece' }) } = useEntityDocuments({ entity: () => props.piece, entityType: 'piece' })
const { const {
@@ -343,6 +365,21 @@ const {
updateCustomField, updateCustomField,
} = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' }) } = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' })
// --- Document edit modal ---
const editingDocument = ref(null)
const editModalVisible = ref(false)
const openEditModal = (doc) => {
editingDocument.value = doc
editModalVisible.value = true
}
const handleDocumentUpdated = async (data) => {
if (!editingDocument.value?.id) return
await editDocument(editingDocument.value.id, data)
editModalVisible.value = false
editingDocument.value = null
}
// --- Collapse state --- // --- Collapse state ---
const isCollapsed = ref(true) const isCollapsed = ref(true)
@@ -363,23 +400,36 @@ const toggleCollapse = () => {
// --- Constructeurs --- // --- Constructeurs ---
const { constructeurs } = useConstructeurs() const { constructeurs } = useConstructeurs()
const pieceConstructeurIds = computed(() => const pieceConstructeurLinks = computed(() =>
uniqueConstructeurIds( parseConstructeurLinksFromApi(
props.piece,
Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [], Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [],
props.piece.constructeur ? [props.piece.constructeur] : [],
), ),
) )
const pieceConstructeursDisplay = computed(() => const supplierReferenceMap = computed(() => {
resolveConstructeurs( const map = new Map()
pieceConstructeurIds.value, pieceConstructeurLinks.value.forEach(l => {
Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [], if (l.supplierReference) map.set(l.constructeurId, l.supplierReference)
props.piece.constructeur ? [props.piece.constructeur] : [], })
constructeurs.value, return map
), })
const pieceConstructeurIds = computed(() =>
pieceConstructeurLinks.value.map(l => l.constructeurId).filter(Boolean),
) )
const pieceConstructeursDisplay = computed(() => {
// Extract nested constructeur objects from link entries
const linkConstructeurs = pieceConstructeurLinks.value
.filter(l => l.constructeur && l.constructeur.id)
.map(l => l.constructeur)
return resolveConstructeurs(
pieceConstructeurIds.value,
linkConstructeurs,
constructeurs.value,
)
})
const formatConstructeurContact = (constructeur) => const formatConstructeurContact = (constructeur) =>
formatConstructeurContactSummary(constructeur) formatConstructeurContactSummary(constructeur)

View File

@@ -8,9 +8,11 @@
:empty-text="emptyText" :empty-text="emptyText"
size="sm" size="sm"
option-value="id" option-value="id"
option-label="name" :option-label="formatLabel"
:disabled="disabled" :disabled="disabled"
server-search
@update:modelValue="updateValue" @update:modelValue="updateValue"
@search="handleSearch"
> >
<template #option-description="{ option }"> <template #option-description="{ option }">
<span class="text-xs text-base-content/60"> <span class="text-xs text-base-content/60">
@@ -25,7 +27,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import SearchSelect from '~/components/common/SearchSelect.vue' import SearchSelect from '~/components/common/SearchSelect.vue'
import { usePieces } from '~/composables/usePieces' import { usePieces } from '~/composables/usePieces'
@@ -52,43 +54,45 @@ const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void (e: 'update:modelValue', value: string | null): void
}>() }>()
const { pieces, loading, loadPieces } = usePieces() const { loading: globalLoading, loadPieces } = usePieces()
const pieceOptions = computed(() => { const localPieces = ref<any[]>([])
const baseOptions = Array.isArray(pieces.value) ? pieces.value : [] const localLoading = ref(false)
if (!props.typePieceId) { const loading = computed(() => localLoading.value || globalLoading.value)
return baseOptions
const pieceOptions = computed(() => localPieces.value)
const loadFilteredPieces = async (search = '') => {
if (!props.typePieceId) return
localLoading.value = true
try {
const result = await loadPieces({ typePieceId: props.typePieceId, search, itemsPerPage: 200, force: true })
if (result.success && result.data?.items) {
localPieces.value = result.data.items
}
} }
catch (error: unknown) {
console.error('Erreur lors du chargement des pièces:', error)
}
finally {
localLoading.value = false
}
}
const allowedTypeId = String(props.typePieceId) let searchDebounce: ReturnType<typeof setTimeout> | null = null
return baseOptions.filter((piece: any) => { const handleSearch = (term: string) => {
const typeId = if (searchDebounce) clearTimeout(searchDebounce)
piece?.typePieceId || searchDebounce = setTimeout(() => loadFilteredPieces(term.trim()), 300)
piece?.typePiece?.id || }
null
return typeId ? String(typeId) === allowedTypeId : false
})
})
onMounted(() => { onMounted(() => {
if (pieceOptions.value.length === 0) { loadFilteredPieces()
loadPieces({ itemsPerPage: 200 }).catch((error: unknown) => {
console.error('Erreur lors du chargement des pièces:', error)
})
}
}) })
watch( watch(
() => props.modelValue, () => props.typePieceId,
(value) => { () => {
if (typeof value === 'string' && value) { loadFilteredPieces()
const exists = pieceOptions.value.some((piece: any) => piece.id === value)
if (!exists && !loading.value) {
loadPieces({ itemsPerPage: 200, force: true }).catch((error: unknown) => {
console.error('Erreur lors du chargement des pièces:', error)
})
}
}
}, },
) )
@@ -100,10 +104,20 @@ const updateValue = (value: string | number | null | undefined) => {
emit('update:modelValue', String(value)) emit('update:modelValue', String(value))
} }
const formatLabel = (option: any) => {
if (!option) return ''
const name = option.name || 'Pièce'
return option.reference ? `${name}${option.reference}` : name
}
const formatDescription = (option: any) => { const formatDescription = (option: any) => {
const parts: string[] = [] const parts: string[] = []
const typeName = option?.typePiece?.name
if (typeName) {
parts.push(typeName)
}
if (option?.reference) { if (option?.reference) {
parts.push(option.reference) parts.push(`Ref. ${option.reference}`)
} }
if (option?.prix !== undefined && option.prix !== null) { if (option?.prix !== undefined && option.prix !== null) {
const price = Number(option.prix) const price = Number(option.prix)

View File

@@ -8,9 +8,11 @@
:empty-text="emptyText" :empty-text="emptyText"
size="sm" size="sm"
option-value="id" option-value="id"
option-label="name" :option-label="formatLabel"
:disabled="disabled" :disabled="disabled"
server-search
@update:modelValue="updateValue" @update:modelValue="updateValue"
@search="handleSearch"
> >
<template #option-description="{ option }"> <template #option-description="{ option }">
<span class="text-xs text-base-content/60"> <span class="text-xs text-base-content/60">
@@ -25,7 +27,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import SearchSelect from '~/components/common/SearchSelect.vue' import SearchSelect from '~/components/common/SearchSelect.vue'
import { useProducts } from '~/composables/useProducts' import { useProducts } from '~/composables/useProducts'
@@ -52,43 +54,45 @@ const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void (e: 'update:modelValue', value: string | null): void
}>() }>()
const { products, loading, loadProducts } = useProducts() const { loading: globalLoading, loadProducts } = useProducts()
const productOptions = computed(() => { const localProducts = ref<any[]>([])
const baseOptions = Array.isArray(products.value) ? products.value : [] const localLoading = ref(false)
if (!props.typeProductId) { const loading = computed(() => localLoading.value || globalLoading.value)
return baseOptions
const productOptions = computed(() => localProducts.value)
const loadFilteredProducts = async (search = '') => {
if (!props.typeProductId) return
localLoading.value = true
try {
const result = await loadProducts({ typeProductId: props.typeProductId, search, itemsPerPage: 200, force: true })
if (result.success && result.data?.items) {
localProducts.value = result.data.items
}
} }
catch (error: unknown) {
console.error('Erreur lors du chargement des produits:', error)
}
finally {
localLoading.value = false
}
}
const allowedTypeId = String(props.typeProductId) let searchDebounce: ReturnType<typeof setTimeout> | null = null
return baseOptions.filter((product) => { const handleSearch = (term: string) => {
const typeId = if (searchDebounce) clearTimeout(searchDebounce)
product?.typeProductId || searchDebounce = setTimeout(() => loadFilteredProducts(term.trim()), 300)
product?.typeProduct?.id || }
null
return typeId ? String(typeId) === allowedTypeId : false
})
})
onMounted(() => { onMounted(() => {
if (productOptions.value.length === 0) { loadFilteredProducts()
loadProducts().catch((error) => {
console.error('Erreur lors du chargement des produits:', error)
})
}
}) })
watch( watch(
() => props.modelValue, () => props.typeProductId,
(value) => { () => {
if (typeof value === 'string' && value) { loadFilteredProducts()
const exists = productOptions.value.some((product) => product.id === value)
if (!exists && !loading.value) {
loadProducts({ force: true }).catch((error) => {
console.error('Erreur lors du chargement des produits:', error)
})
}
}
}, },
) )
@@ -100,10 +104,20 @@ const updateValue = (value: string | number | null | undefined) => {
emit('update:modelValue', String(value)) emit('update:modelValue', String(value))
} }
const formatLabel = (option: any) => {
if (!option) return ''
const name = option.name || 'Produit'
return option.reference ? `${name}${option.reference}` : name
}
const formatDescription = (option: any) => { const formatDescription = (option: any) => {
const parts: string[] = [] const parts: string[] = []
const typeName = option?.typeProduct?.name
if (typeName) {
parts.push(typeName)
}
if (option?.reference) { if (option?.reference) {
parts.push(option.reference) parts.push(`Ref. ${option.reference}`)
} }
if (option?.supplierPrice !== undefined && option.supplierPrice !== null) { if (option?.supplierPrice !== undefined && option.supplierPrice !== null) {
const price = Number(option.supplierPrice) const price = Number(option.supplierPrice)

View File

@@ -258,18 +258,7 @@
{{ piece.typePieceId ? `Sélection : ${getPieceTypeLabel(piece.typePieceId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }} {{ piece.typePieceId ? `Sélection : ${getPieceTypeLabel(piece.typePieceId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
</p> </p>
</div> </div>
<div class="form-control"> <!-- Quantity is set per-component on the component edit page -->
<label class="label py-1"><span class="label-text text-xs">Quantité</span></label>
<input
v-model.number="piece.quantity"
type="number"
:min="1"
step="1"
placeholder="Qté"
class="input input-bordered input-sm md:input-md w-20"
@input="piece.quantity = Math.max(1, piece.quantity || 1)"
/>
</div>
</div> </div>
<button type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)"> <button type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)">
<IconLucideTrash class="w-4 h-4" aria-hidden="true" /> <IconLucideTrash class="w-4 h-4" aria-hidden="true" />

View File

@@ -31,8 +31,9 @@
/> />
</div> </div>
<div> <div>
<div class="font-medium"> <div class="font-medium flex items-center gap-2">
{{ document.name }} {{ document.name }}
<span class="badge badge-sm badge-outline">{{ getDocumentTypeLabel(document.type || 'documentation') }}</span>
</div> </div>
<div class="text-xs text-base-content/70"> <div class="text-xs text-base-content/70">
{{ document.mimeType || 'Inconnu' }} {{ formatSize(document.size) }} {{ document.mimeType || 'Inconnu' }} {{ formatSize(document.size) }}
@@ -40,6 +41,15 @@
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs"
title="Modifier"
@click="$emit('edit', document)"
>
Modifier
</button>
<button <button
type="button" type="button"
class="btn btn-ghost btn-xs" class="btn btn-ghost btn-xs"
@@ -74,6 +84,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getDocumentTypeLabel } from '~/shared/documentTypes'
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview' import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
import { import {
documentIcon, documentIcon,
@@ -89,10 +100,12 @@ import type { Document } from '~/composables/useDocuments'
withDefaults(defineProps<{ withDefaults(defineProps<{
documents: Document[] documents: Document[]
canDelete?: boolean canDelete?: boolean
canEdit?: boolean
deleteDisabled?: boolean deleteDisabled?: boolean
emptyText?: string emptyText?: string
}>(), { }>(), {
canDelete: false, canDelete: false,
canEdit: false,
deleteDisabled: false, deleteDisabled: false,
emptyText: 'Aucun document.', emptyText: 'Aucun document.',
}) })
@@ -100,5 +113,6 @@ withDefaults(defineProps<{
defineEmits<{ defineEmits<{
(e: 'preview', document: Document): void (e: 'preview', document: Document): void
(e: 'delete', documentId: string): void (e: 'delete', documentId: string): void
(e: 'edit', document: Document): void
}>() }>()
</script> </script>

View File

@@ -0,0 +1,170 @@
<template>
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex items-center justify-between gap-3">
<div>
<h2 class="font-semibold text-base-content">Versions</h2>
<p class="text-xs text-base-content/70">
Historique des versions avec possibilite de restauration.
</p>
</div>
<span v-if="versions.length" class="badge badge-outline">
{{ versions.length }} version{{ versions.length > 1 ? 's' : '' }}
</span>
</header>
<div v-if="loading" class="flex items-center gap-2 text-sm text-base-content/70">
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
Chargement des versions...
</div>
<div v-else-if="error" class="alert alert-warning">
<span>{{ error }}</span>
</div>
<p v-else-if="versions.length === 0" class="text-xs text-base-content/70">
Aucune version enregistree.
</p>
<ul v-else class="max-h-96 space-y-2 overflow-y-auto pr-1">
<li
v-for="entry in versions"
:key="entry.version"
class="flex items-center justify-between rounded-md border border-base-200 bg-base-100 px-3 py-2"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="font-mono text-sm font-semibold">v{{ entry.version }}</span>
<span
v-if="entry.version === currentVersion"
class="badge badge-primary badge-sm"
>
actuelle
</span>
<span
v-if="entry.action === 'restore'"
class="badge badge-warning badge-sm"
>
restauration
</span>
</div>
<div class="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-base-content/60">
<span>{{ actionLabel(entry.action) }}</span>
<span>&middot;</span>
<span>{{ formatDate(entry.createdAt) }}</span>
<span v-if="entry.actor">&middot; {{ entry.actor.label }}</span>
</div>
<div v-if="entry.diff && Object.keys(entry.diff).length" class="mt-1 flex flex-wrap gap-1">
<span
v-for="(change, field) in entry.diff"
:key="field"
class="badge badge-ghost badge-xs"
>
{{ formatDiffEntry(String(field), change) }}
</span>
</div>
</div>
<button
v-if="canRestore && entry.version !== currentVersion"
class="btn btn-ghost btn-xs"
:disabled="restoring"
@click="handleRestore(entry.version)"
>
Restaurer
</button>
</li>
</ul>
<VersionRestoreModal
:visible="modalVisible"
:preview="previewData"
:restoring="restoring"
:field-labels="fieldLabels"
:entity-type="entityType"
@close="modalVisible = false"
@confirm="confirmRestore"
/>
</section>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, toRef } from 'vue'
import { useEntityVersions, type RestorePreview } from '~/composables/useEntityVersions'
import { usePermissions } from '~/composables/usePermissions'
import { formatHistoryDate, historyActionLabel } from '~/shared/utils/historyDisplayUtils'
import VersionRestoreModal from './VersionRestoreModal.vue'
const props = defineProps<{
entityType: 'machine' | 'composant' | 'piece' | 'product'
entityId: string
fieldLabels: Record<string, string>
/** Increment this value to force a refresh of the versions list */
refreshKey?: number
}>()
const emit = defineEmits<{
restored: []
}>()
const { canEdit } = usePermissions()
const canRestore = computed(() => canEdit.value)
const { versions, loading, error, fetchVersions, fetchPreview, restore } = useEntityVersions({
entityType: props.entityType,
entityId: props.entityId,
})
const currentVersion = computed(() => {
if (versions.value.length === 0) return null
return versions.value[0]?.version ?? null
})
const modalVisible = ref(false)
const previewData = ref<RestorePreview | null>(null)
const restoring = ref(false)
const targetVersion = ref<number | null>(null)
const actionLabel = (action: string) => historyActionLabel(action)
const formatDate = (date: string) => formatHistoryDate(date)
const formatDiffEntry = (field: string, change: { from: unknown; to: unknown }): string => {
const label = props.fieldLabels[field] || field
// Link changes (addedComponent, removedPiece, etc.) have {id, name} as value
const val = change.to ?? change.from
if (val && typeof val === 'object' && 'name' in (val as Record<string, unknown>)) {
return `${label}: ${(val as Record<string, unknown>).name}`
}
return label
}
const handleRestore = async (version: number) => {
targetVersion.value = version
previewData.value = null
modalVisible.value = true
previewData.value = await fetchPreview(version)
}
const confirmRestore = async () => {
if (!targetVersion.value) return
restoring.value = true
const result = await restore(targetVersion.value)
restoring.value = false
if (result?.success) {
modalVisible.value = false
await fetchVersions()
emit('restored')
}
else {
error.value = 'La restauration a echoue.'
modalVisible.value = false
}
}
onMounted(() => {
fetchVersions()
})
// Auto-refresh when parent signals a data change
watch(toRef(props, 'refreshKey'), () => {
fetchVersions()
})
</script>

View File

@@ -69,7 +69,7 @@
{{ resolveLabel(option) }} {{ resolveLabel(option) }}
</slot> </slot>
</span> </span>
<span v-if="resolveDescription(option)" class="text-xs text-base-content/50"> <span v-if="$slots['option-description'] || resolveDescription(option)" class="text-xs text-base-content/50">
<slot name="option-description" :option="option"> <slot name="option-description" :option="option">
{{ resolveDescription(option) }} {{ resolveDescription(option) }}
</slot> </slot>
@@ -133,6 +133,10 @@ const props = defineProps({
maxVisible: { maxVisible: {
type: Number, type: Number,
default: 50 default: 50
},
serverSearch: {
type: Boolean,
default: false
} }
}) })
@@ -150,11 +154,11 @@ const selectedOption = computed(() => {
}) })
const displayedOptions = computed(() => { const displayedOptions = computed(() => {
const term = searchTerm.value.trim().toLowerCase()
const items = baseOptions.value.slice() const items = baseOptions.value.slice()
const filtered = term const filtered = (!props.serverSearch && searchTerm.value.trim())
? items.filter((option) => { ? items.filter((option) => {
const term = searchTerm.value.trim().toLowerCase()
const label = resolveLabel(option).toLowerCase() const label = resolveLabel(option).toLowerCase()
const description = resolveDescription(option)?.toLowerCase() || '' const description = resolveDescription(option)?.toLowerCase() || ''
return label.includes(term) || description.includes(term) return label.includes(term) || description.includes(term)

View File

@@ -0,0 +1,198 @@
<template>
<dialog ref="dialogRef" class="modal" :class="{ 'modal-open': visible }">
<div class="modal-box max-w-lg">
<h3 class="text-lg font-bold">Restaurer la version {{ preview?.version }}</h3>
<div v-if="!preview" class="flex justify-center py-8">
<span class="loading loading-spinner loading-md" />
</div>
<template v-else>
<div class="mt-4 space-y-4">
<!-- Restore mode explanation -->
<div
class="alert text-sm"
:class="preview.restoreMode === 'full' ? 'alert-info' : 'alert-warning'"
>
<div class="flex flex-col gap-1">
<!-- FULL MODE -->
<template v-if="preview.restoreMode === 'full'">
<span class="font-semibold">Restauration complete</span>
<!-- Machine: always full, no category -->
<template v-if="entityType === 'machine'">
<span>Tous les elements de la machine seront restaures :</span>
<ul class="ml-4 list-disc text-xs">
<li>Nom, reference, prix</li>
<li>Site</li>
<li>Fournisseurs</li>
<li>Composants, pieces et produits lies</li>
<li>Champs personnalises</li>
</ul>
</template>
<!-- Composant -->
<template v-else-if="entityType === 'composant'">
<span>La categorie est identique. Tous les elements du composant seront restaures :</span>
<ul class="ml-4 list-disc text-xs">
<li>Nom, reference, description, prix</li>
<li>Fournisseurs</li>
<li>Structure : pieces, sous-composants et produits lies</li>
<li>Champs personnalises</li>
</ul>
</template>
<!-- Piece -->
<template v-else-if="entityType === 'piece'">
<span>La categorie est identique. Tous les elements de la piece seront restaures :</span>
<ul class="ml-4 list-disc text-xs">
<li>Nom, reference, description, prix</li>
<li>Fournisseurs</li>
<li>Produits lies</li>
<li>Champs personnalises</li>
</ul>
</template>
<!-- Product -->
<template v-else-if="entityType === 'product'">
<span>La categorie est identique. Tous les elements du produit seront restaures :</span>
<ul class="ml-4 list-disc text-xs">
<li>Nom, reference, prix fournisseur</li>
<li>Fournisseurs</li>
<li>Champs personnalises</li>
</ul>
</template>
</template>
<!-- PARTIAL MODE (never for machines) -->
<template v-else>
<span class="font-semibold">Restauration partielle</span>
<!-- Composant -->
<template v-if="entityType === 'composant'">
<span>La categorie du composant a change depuis cette version. Seuls les champs de base seront restaures :</span>
<ul class="ml-4 list-disc text-xs">
<li>Nom, reference, description, prix</li>
<li>Fournisseurs</li>
</ul>
<span class="mt-1 text-xs font-medium">Ne seront PAS modifies :</span>
<ul class="ml-4 list-disc text-xs opacity-70">
<li>Structure actuelle (pieces, sous-composants, produits lies)</li>
<li>Champs personnalises actuels</li>
</ul>
</template>
<!-- Piece -->
<template v-else-if="entityType === 'piece'">
<span>La categorie de la piece a change depuis cette version. Seuls les champs de base seront restaures :</span>
<ul class="ml-4 list-disc text-xs">
<li>Nom, reference, description, prix</li>
<li>Fournisseurs</li>
</ul>
<span class="mt-1 text-xs font-medium">Ne seront PAS modifies :</span>
<ul class="ml-4 list-disc text-xs opacity-70">
<li>Produits lies actuels</li>
<li>Champs personnalises actuels</li>
</ul>
</template>
<!-- Product -->
<template v-else-if="entityType === 'product'">
<span>La categorie du produit a change depuis cette version. Seuls les champs de base seront restaures :</span>
<ul class="ml-4 list-disc text-xs">
<li>Nom, reference, prix fournisseur</li>
<li>Fournisseurs</li>
</ul>
<span class="mt-1 text-xs font-medium">Ne seront PAS modifies :</span>
<ul class="ml-4 list-disc text-xs opacity-70">
<li>Champs personnalises actuels</li>
</ul>
</template>
</template>
</div>
</div>
<!-- Diff -->
<div v-if="Object.keys(preview.diff).length" class="space-y-2">
<h4 class="text-sm font-semibold">Changements qui seront appliques</h4>
<ul class="space-y-1 text-sm">
<li
v-for="(change, field) in preview.diff"
:key="field"
class="flex flex-col rounded-md border border-base-200 px-3 py-2"
>
<span class="font-medium text-base-content">{{ fieldLabels[field] || formatFieldLabel(String(field)) }}</span>
<span class="text-xs text-error line-through">{{ formatValue(change.current) }}</span>
<span class="text-xs text-success">{{ formatValue(change.restored) }}</span>
</li>
</ul>
</div>
<div v-else class="text-sm text-base-content/60">
Aucune difference detectee l'entite est deja dans l'etat de cette version.
</div>
<!-- Warnings -->
<div v-if="preview.warnings.length" class="space-y-1">
<h4 class="text-sm font-semibold text-warning">Avertissements</h4>
<ul class="space-y-1">
<li
v-for="(warning, i) in preview.warnings"
:key="i"
class="alert alert-warning py-2 text-xs"
>
{{ warning.message }}
</li>
</ul>
</div>
</div>
<div class="modal-action">
<button class="btn btn-ghost btn-sm md:btn-md" :disabled="restoring" @click="$emit('close')">
Annuler
</button>
<button class="btn btn-primary btn-sm md:btn-md" :disabled="restoring" @click="$emit('confirm')">
<span v-if="restoring" class="loading loading-spinner loading-sm mr-2" />
Confirmer la restauration
</button>
</div>
</template>
</div>
<form method="dialog" class="modal-backdrop" @click="$emit('close')">
<button type="button">close</button>
</form>
</dialog>
</template>
<script setup lang="ts">
import type { RestorePreview } from '~/composables/useEntityVersions'
defineProps<{
visible: boolean
preview: RestorePreview | null
restoring: boolean
fieldLabels: Record<string, string>
entityType: 'machine' | 'composant' | 'piece' | 'product'
}>()
defineEmits<{
close: []
confirm: []
}>()
const formatFieldLabel = (field: string): string => {
if (field.startsWith('customField:')) {
return `Champ perso : ${field.replace('customField:', '')}`
}
return field
}
const formatValue = (value: unknown): string => {
if (value === null || value === undefined) return '—'
if (Array.isArray(value)) {
return value.map((v) => (typeof v === 'object' && v !== null ? (v as any).name || (v as any).id || JSON.stringify(v) : String(v))).join(', ') || '—'
}
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
</script>

View File

@@ -44,6 +44,8 @@
:empty-text="`Aucun ${entityLabelLower} disponible dans cette catégorie`" :empty-text="`Aucun ${entityLabelLower} disponible dans cette catégorie`"
:option-label="entityOptionLabel" :option-label="entityOptionLabel"
:option-description="entityOptionDescription" :option-description="entityOptionDescription"
server-search
@search="handleEntitySearch"
/> />
</div> </div>
@@ -145,7 +147,10 @@ const selectedTypeName = computed(() => {
return found?.name || '' return found?.name || ''
}) })
const entityOptionLabel = (e: any) => e.name || '(sans nom)' const entityOptionLabel = (e: any) => {
const name = e.name || '(sans nom)'
return e.reference ? `${name}${e.reference}` : name
}
const entityOptionDescription = (e: any) => e.reference || '' const entityOptionDescription = (e: any) => e.reference || ''
const selectedEntitySummary = computed(() => { const selectedEntitySummary = computed(() => {
@@ -187,6 +192,30 @@ watch(selectedTypeId, async () => {
} }
}) })
let searchDebounce: ReturnType<typeof setTimeout> | null = null
const handleEntitySearch = (term: string) => {
if (searchDebounce) clearTimeout(searchDebounce)
searchDebounce = setTimeout(async () => {
if (!selectedTypeName.value) return
loadingEntities.value = true
try {
if (props.entityKind === 'component') {
const result = await loadComposants({ typeName: selectedTypeName.value, search: term.trim(), itemsPerPage: 200 })
entities.value = result?.data?.items || []
} else if (props.entityKind === 'piece') {
const result = await loadPieces({ typeName: selectedTypeName.value, search: term.trim(), itemsPerPage: 200 })
entities.value = result?.data?.items || []
} else {
const result = await loadProducts({ typeName: selectedTypeName.value, search: term.trim(), itemsPerPage: 200 })
entities.value = result?.data?.items || []
}
} finally {
loadingEntities.value = false
}
}, 300)
}
const handleClose = () => { const handleClose = () => {
resetState() resetState()
emit('close') emit('close')

View File

@@ -1,19 +1,8 @@
<template> <template>
<section class="space-y-3"> <section class="space-y-3">
<div class="flex items-center justify-between"> <h3 class="text-sm font-semibold">
<h3 class="text-sm font-semibold"> Définitions des champs personnalisés
Définitions des champs personnalisés </h3>
</h3>
<button
type="button"
class="btn btn-primary btn-sm"
:disabled="saving"
@click="$emit('save')"
>
<span v-if="saving" class="loading loading-spinner loading-xs" />
Enregistrer les champs
</button>
</div>
<p v-if="!fields.length" class="text-xs text-gray-500"> <p v-if="!fields.length" class="text-xs text-gray-500">
Aucun champ personnalisé défini. Cliquez sur « Ajouter » pour en créer un. Aucun champ personnalisé défini. Cliquez sur « Ajouter » pour en créer un.
@@ -117,7 +106,6 @@ defineProps<{
}>() }>()
defineEmits<{ defineEmits<{
save: []
'add-field': [] 'add-field': []
'remove-field': [index: number] 'remove-field': [index: number]
}>() }>()

View File

@@ -32,6 +32,9 @@
<IconLucidePrinter class="w-5 h-5 mr-2" aria-hidden="true" /> <IconLucidePrinter class="w-5 h-5 mr-2" aria-hidden="true" />
Imprimer Imprimer
</button> </button>
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
Retour aux machines
</button>
</div> </div>
</div> </div>
</template> </template>
@@ -41,6 +44,8 @@ import IconLucideSquarePen from '~icons/lucide/square-pen'
import IconLucideEye from '~icons/lucide/eye' import IconLucideEye from '~icons/lucide/eye'
import IconLucidePrinter from '~icons/lucide/printer' import IconLucidePrinter from '~icons/lucide/printer'
const router = useRouter()
defineProps<{ defineProps<{
title: string title: string
isEditMode: boolean isEditMode: boolean
@@ -50,4 +55,13 @@ defineEmits<{
'toggle-edit': [] 'toggle-edit': []
'open-print': [] 'open-print': []
}>() }>()
function goBack() {
if (window.history.length > 1) {
router.back()
}
else {
navigateTo('/machines')
}
}
</script> </script>

View File

@@ -14,7 +14,6 @@
type="text" type="text"
class="input input-bordered" class="input input-bordered"
@input="$emit('update:machine-name', ($event.target as HTMLInputElement).value)" @input="$emit('update:machine-name', ($event.target as HTMLInputElement).value)"
@blur="$emit('blur-field')"
/> />
<div v-else class="input input-bordered bg-base-200"> <div v-else class="input input-bordered bg-base-200">
{{ machineName }} {{ machineName }}
@@ -28,7 +27,7 @@
v-if="isEditMode" v-if="isEditMode"
:value="machineSiteId" :value="machineSiteId"
class="select select-bordered" class="select select-bordered"
@change="$emit('update:machine-site-id', ($event.target as HTMLSelectElement).value); $emit('blur-field')" @change="$emit('update:machine-site-id', ($event.target as HTMLSelectElement).value)"
> >
<option value="">Sélectionner un site</option> <option value="">Sélectionner un site</option>
<option <option
@@ -54,13 +53,12 @@
type="text" type="text"
class="input input-bordered" class="input input-bordered"
@input="$emit('update:machine-reference', ($event.target as HTMLInputElement).value)" @input="$emit('update:machine-reference', ($event.target as HTMLInputElement).value)"
@blur="$emit('blur-field')"
/> />
<div v-else class="input input-bordered bg-base-200"> <div v-else class="input input-bordered bg-base-200">
{{ machineReference }} {{ machineReference }}
</div> </div>
</div> </div>
<div v-if="isEditMode || hasMachineConstructeur" class="form-control"> <div v-if="isEditMode || hasMachineConstructeur" class="form-control md:col-span-2">
<label class="label"> <label class="label">
<span class="label-text">Fournisseur</span> <span class="label-text">Fournisseur</span>
</label> </label>
@@ -72,23 +70,15 @@
placeholder="Rechercher un ou plusieurs fournisseurs..." placeholder="Rechercher un ou plusieurs fournisseurs..."
@update:modelValue="$emit('update:constructeur-ids', $event)" @update:modelValue="$emit('update:constructeur-ids', $event)"
/> />
<div v-else class="border border-base-300 rounded-btn bg-base-200 px-4 py-2 min-h-12 flex items-center"> <ConstructeurLinksTable
<div v-if="machineConstructeursDisplay.length" class="flex flex-wrap gap-2"> v-if="constructeurLinks.length"
<span :model-value="constructeurLinks"
v-for="constructeur in machineConstructeursDisplay" :readonly="!isEditMode"
:key="constructeur.id" @update:model-value="$emit('update:constructeur-links', $event)"
class="badge badge-ghost gap-1" @remove="$emit('remove-constructeur-link', $event)"
> />
{{ constructeur.name }} <div v-else-if="!isEditMode" class="border border-base-300 rounded-btn bg-base-200 px-4 py-2 min-h-12 flex items-center">
<span <span class="text-base-content/50">Non défini</span>
v-if="formatConstructeurContactSummary(constructeur)"
class="text-xs opacity-60"
>
· {{ formatConstructeurContactSummary(constructeur) }}
</span>
</span>
</div>
<span v-else class="text-base-content/50">Non défini</span>
</div> </div>
</div> </div>
</div> </div>
@@ -115,7 +105,6 @@
class="input input-bordered input-sm" class="input input-bordered input-sm"
:required="field.required" :required="field.required"
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)" @input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
@blur="$emit('update-custom-field', field)"
/> />
<input <input
v-else-if="field.type === 'number'" v-else-if="field.type === 'number'"
@@ -124,7 +113,6 @@
class="input input-bordered input-sm" class="input input-bordered input-sm"
:required="field.required" :required="field.required"
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)" @input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
@blur="$emit('update-custom-field', field)"
/> />
<select <select
v-else-if="field.type === 'select'" v-else-if="field.type === 'select'"
@@ -132,7 +120,6 @@
class="select select-bordered select-sm" class="select select-bordered select-sm"
:required="field.required" :required="field.required"
@change="$emit('set-custom-field-value', field, ($event.target as HTMLSelectElement).value)" @change="$emit('set-custom-field-value', field, ($event.target as HTMLSelectElement).value)"
@blur="$emit('update-custom-field', field)"
> >
<option value="">Sélectionner...</option> <option value="">Sélectionner...</option>
<option <option
@@ -149,7 +136,6 @@
class="toggle toggle-primary toggle-sm" class="toggle toggle-primary toggle-sm"
:checked="String(field.value).toLowerCase() === 'true'" :checked="String(field.value).toLowerCase() === 'true'"
@change="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).checked ? 'true' : 'false')" @change="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).checked ? 'true' : 'false')"
@blur="$emit('update-custom-field', field)"
> >
<span class="text-sm" :class="String(field.value).toLowerCase() === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ String(field.value).toLowerCase() === 'true' ? 'Oui' : 'Non' }}</span> <span class="text-sm" :class="String(field.value).toLowerCase() === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ String(field.value).toLowerCase() === 'true' ? 'Oui' : 'Non' }}</span>
</label> </label>
@@ -160,7 +146,6 @@
class="input input-bordered input-sm" class="input input-bordered input-sm"
:required="field.required" :required="field.required"
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)" @input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
@blur="$emit('update-custom-field', field)"
/> />
<div v-else class="text-xs text-error"> <div v-else class="text-xs text-error">
Type de champ non pris en charge Type de champ non pris en charge
@@ -184,7 +169,6 @@
:on-drag-enter="fieldDefs.onDragEnter" :on-drag-enter="fieldDefs.onDragEnter"
:on-drop="fieldDefs.onDrop" :on-drop="fieldDefs.onDrop"
:on-drag-end="fieldDefs.onDragEnd" :on-drag-end="fieldDefs.onDragEnd"
@save="fieldDefs.saveDefinitions()"
@add-field="fieldDefs.addField()" @add-field="fieldDefs.addField()"
@remove-field="fieldDefs.removeField($event)" @remove-field="fieldDefs.removeField($event)"
/> />
@@ -196,12 +180,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { watch } from 'vue' import { watch } from 'vue'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue' import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import ConstructeurLinksTable from '~/components/ConstructeurLinksTable.vue'
import MachineCustomFieldDefEditor from '~/components/machine/MachineCustomFieldDefEditor.vue' import MachineCustomFieldDefEditor from '~/components/machine/MachineCustomFieldDefEditor.vue'
import {
formatConstructeurContact as formatConstructeurContactSummary,
} from '~/shared/constructeurUtils'
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils' import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
import { useMachineCustomFieldDefs } from '~/composables/useMachineCustomFieldDefs' import { useMachineCustomFieldDefs } from '~/composables/useMachineCustomFieldDefs'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
const props = defineProps<{ const props = defineProps<{
isEditMode: boolean isEditMode: boolean
@@ -213,6 +196,7 @@ const props = defineProps<{
machineConstructeurIds: string[] machineConstructeurIds: string[]
machineConstructeursDisplay: any[] machineConstructeursDisplay: any[]
hasMachineConstructeur: boolean hasMachineConstructeur: boolean
constructeurLinks: ConstructeurLinkEntry[]
visibleCustomFields: any[] visibleCustomFields: any[]
getMachineFieldId: (fieldName: string) => string getMachineFieldId: (fieldName: string) => string
machineId: string machineId: string
@@ -224,9 +208,9 @@ const emit = defineEmits<{
'update:machine-reference': [value: string] 'update:machine-reference': [value: string]
'update:machine-site-id': [value: string] 'update:machine-site-id': [value: string]
'update:constructeur-ids': [ids: unknown] 'update:constructeur-ids': [ids: unknown]
'blur-field': [] 'update:constructeur-links': [links: ConstructeurLinkEntry[]]
'remove-constructeur-link': [constructeurId: string]
'set-custom-field-value': [field: any, value: unknown] 'set-custom-field-value': [field: any, value: unknown]
'update-custom-field': [field: any]
'custom-fields-saved': [] 'custom-fields-saved': []
}>() }>()
@@ -239,4 +223,8 @@ const fieldDefs = useMachineCustomFieldDefs({
watch(() => props.machineCustomFieldDefs, (newDefs) => { watch(() => props.machineCustomFieldDefs, (newDefs) => {
fieldDefs.reinit(newDefs) fieldDefs.reinit(newDefs)
}, { deep: true }) }, { deep: true })
defineExpose({
saveFieldDefinitions: () => fieldDefs.saveDefinitions(),
})
</script> </script>

View File

@@ -108,6 +108,13 @@
</template> </template>
</section> </section>
<ReferenceFormulaBuilder
v-if="form.category === 'PIECE' || form.category === 'COMPONENT'"
v-model="form.referenceFormula"
:custom-fields="formulaBuilderCustomFields"
:disabled="isReadonly"
/>
<footer class="flex flex-col gap-3 border-t border-base-300 pt-4 sm:flex-row sm:justify-end"> <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')"> <button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
Annuler Annuler
@@ -177,14 +184,33 @@ const componentSubcomponentMaxDepth = computed(() =>
) )
const isReadonly = computed(() => props.readonly === true) const isReadonly = computed(() => props.readonly === true)
const form = reactive<ModelTypePayload>({ const form = reactive<ModelTypePayload & { referenceFormula?: string | null }>({
name: '', name: '',
code: '', code: '',
category: props.initialCategory, category: props.initialCategory,
notes: '', notes: '',
structure: undefined, structure: undefined,
referenceFormula: null,
}) })
const formulaBuilderCustomFields = computed(() => {
if (form.category === 'PIECE') {
const fields = pieceStructure.value?.customFields
return Array.isArray(fields) ? fields : []
}
if (form.category === 'COMPONENT') {
const fields = componentStructure.value?.customFields
return Array.isArray(fields) ? fields : []
}
return []
})
const extractFormulaFields = (formula: string | null | undefined): string[] => {
if (!formula) return []
const matches = [...formula.matchAll(/\{(\w+)\}/g)]
return [...new Set(matches.map(m => m[1]).filter((n): n is string => n !== undefined))]
}
const errors = reactive<{ name?: string }>({}) const errors = reactive<{ name?: string }>({})
const nameInput = ref<HTMLInputElement | null>(null) const nameInput = ref<HTMLInputElement | null>(null)
@@ -248,6 +274,9 @@ const resetForm = () => {
errors.name = undefined errors.name = undefined
const incomingAny = incoming as Record<string, unknown>
form.referenceFormula = typeof incomingAny.referenceFormula === 'string' ? incomingAny.referenceFormula : null
resetStructures(incoming.structure, form.category) resetStructures(incoming.structure, form.category)
} }
@@ -286,20 +315,28 @@ const handleSubmit = () => {
} }
if (form.category === 'COMPONENT') { if (form.category === 'COMPONENT') {
const formula = form.referenceFormula || null
const requiredFields = extractFormulaFields(formula)
emit('submit', { emit('submit', {
...common, ...common,
category: 'COMPONENT', category: 'COMPONENT',
structure: normalizeStructureForSave(cloneStructure(componentStructure.value)), structure: normalizeStructureForSave(cloneStructure(componentStructure.value)),
}) referenceFormula: formula,
requiredFieldsForReference: requiredFields.length ? requiredFields : null,
} as ModelTypePayload)
return return
} }
if (form.category === 'PIECE') { if (form.category === 'PIECE') {
const formula = form.referenceFormula || null
const requiredFields = extractFormulaFields(formula)
emit('submit', { emit('submit', {
...common, ...common,
category: 'PIECE', category: 'PIECE',
structure: normalizePieceStructureForSave(clonePieceStructure(pieceStructure.value)), structure: normalizePieceStructureForSave(clonePieceStructure(pieceStructure.value)),
}) referenceFormula: formula,
requiredFieldsForReference: requiredFields.length ? requiredFields : null,
} as ModelTypePayload)
return return
} }

View File

@@ -0,0 +1,115 @@
<template>
<section class="space-y-4">
<header>
<h3 class="text-lg font-semibold text-base-content">Génération de référence automatique</h3>
<p class="mt-1 text-sm text-base-content/70">
Cliquez sur un champ pour l'insérer dans la formule. Vous pouvez aussi taper du texte libre (séparateurs, préfixes…).
</p>
</header>
<div class="rounded-lg border border-base-300 p-4 space-y-4">
<div v-if="fieldNames.length" class="flex flex-wrap gap-2">
<button
v-for="name in fieldNames"
:key="name"
type="button"
class="btn btn-xs btn-outline btn-primary font-mono"
:disabled="disabled"
@click="insertField(name)"
>
{{ name }}
</button>
</div>
<p v-else class="text-sm text-base-content/50 italic">
Aucun champ personnalisé défini dans la structure.
</p>
<div>
<label class="label" for="reference-formula">
<span class="label-text">Formule</span>
</label>
<input
id="reference-formula"
ref="inputRef"
:value="modelValue"
type="text"
class="input input-bordered w-full font-mono"
placeholder="Ex: SNU {serie}-{diametre}/{type}"
:disabled="disabled"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value || null)"
/>
<p class="mt-1 text-xs text-base-content/60">
Laissez vide si ce type n'utilise pas de référence automatique.
</p>
</div>
<div v-if="modelValue" class="rounded bg-base-200 px-3 py-2 text-sm">
<span class="text-base-content/70">Aperçu :</span>
<span class="ml-1 font-mono font-semibold">{{ preview }}</span>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
interface CustomField {
name: string
type: string
}
const props = defineProps<{
modelValue: string | null | undefined
customFields: CustomField[]
disabled?: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void
}>()
const inputRef = ref<HTMLInputElement | null>(null)
const fieldNames = computed(() =>
props.customFields.map(f => f.name).filter((n): n is string => Boolean(n)),
)
const previewExamples: Record<string, string> = {
text: 'VALEUR',
number: '123',
select: 'OPTION',
boolean: 'OUI',
date: '2026-01-01',
}
const preview = computed(() => {
if (!props.modelValue) return ''
const fieldMap = new Map<string, string>()
for (const f of props.customFields) {
if (f.name) {
fieldMap.set(f.name, previewExamples[f.type] ?? 'VALEUR')
}
}
return props.modelValue.replace(/\{(\w+)\}/g, (_, name) => fieldMap.get(name) ?? '???')
})
const insertField = (fieldName: string) => {
const placeholder = `{${fieldName}}`
const input = inputRef.value
const current = props.modelValue ?? ''
if (!input) {
emit('update:modelValue', current + placeholder)
return
}
const start = input.selectionStart ?? current.length
const end = input.selectionEnd ?? start
const updated = current.slice(0, start) + placeholder + current.slice(end)
emit('update:modelValue', updated)
nextTick(() => {
const newPos = start + placeholder.length
input.focus()
input.setSelectionRange(newPos, newPos)
})
}
</script>

View File

@@ -3,6 +3,18 @@ import { useApi } from './useApi'
import { useToast } from './useToast' import { useToast } from './useToast'
import { extractCollection } from '~/shared/utils/apiHelpers' import { extractCollection } from '~/shared/utils/apiHelpers'
export interface CommentDocument {
id: string
name: string
filename: string
mimeType: string
size: number
type: string
fileUrl: string
downloadUrl: string
createdAt: string
}
export interface Comment { export interface Comment {
id: string id: string
content: string content: string
@@ -17,6 +29,7 @@ export interface Comment {
resolvedAt?: string | null resolvedAt?: string | null
createdAt: string createdAt: string
updatedAt: string updatedAt: string
documents?: CommentDocument[]
} }
interface CommentResult { interface CommentResult {
@@ -33,7 +46,7 @@ interface CommentListResult {
} }
export function useComments() { export function useComments() {
const { get, post, patch, delete: del } = useApi() const { get, post, patch, postFormData, delete: del } = useApi()
const { showSuccess, showError } = useToast() const { showSuccess, showError } = useToast()
const loading = ref(false) const loading = ref(false)
@@ -44,16 +57,9 @@ export function useComments() {
): Promise<CommentListResult> => { ): Promise<CommentListResult> => {
loading.value = true loading.value = true
try { try {
const params = new URLSearchParams({ const result = await get<Comment[]>(`/comments/by-entity/${entityType}/${entityId}?status=${status}`)
entityType,
entityId,
status,
'order[createdAt]': 'desc',
itemsPerPage: '200',
})
const result = await get(`/comments?${params.toString()}`)
if (result.success) { if (result.success) {
const items = extractCollection<Comment>(result.data) const items = (result.data ?? []) as Comment[]
return { success: true, data: items } return { success: true, data: items }
} }
return { success: false, error: result.error } return { success: false, error: result.error }
@@ -80,18 +86,15 @@ export function useComments() {
if (options.status) params.set('status', options.status) if (options.status) params.set('status', options.status)
if (options.entityType) params.set('entityType', options.entityType) if (options.entityType) params.set('entityType', options.entityType)
if (options.entityName) params.set('entityName', options.entityName) if (options.entityName) params.set('entityName', options.entityName)
const sortField = options.orderBy || 'createdAt' params.set('sort', options.orderBy || 'createdAt')
const sortDir = options.orderDir || 'desc' params.set('direction', options.orderDir || 'desc')
params.set(`order[${sortField}]`, sortDir)
params.set('itemsPerPage', String(options.itemsPerPage || 30)) params.set('itemsPerPage', String(options.itemsPerPage || 30))
params.set('page', String(options.page || 1)) params.set('page', String(options.page || 1))
const result = await get(`/comments?${params.toString()}`) const result = await get<{ items: Comment[]; total: number }>(`/comments/search/list?${params.toString()}`)
if (result.success) { if (result.success && result.data) {
const items = extractCollection<Comment>(result.data) const data = result.data as { items: Comment[]; total: number }
const raw = result.data as Record<string, unknown> | null return { success: true, data: data.items, total: data.total }
const total = Number(raw?.['hydra:totalItems'] ?? raw?.totalItems ?? items.length)
return { success: true, data: items, total }
} }
return { success: false, error: result.error } return { success: false, error: result.error }
} catch (error) { } catch (error) {
@@ -107,12 +110,26 @@ export function useComments() {
entityId: string, entityId: string,
content: string, content: string,
entityName?: string, entityName?: string,
files?: File[],
): Promise<CommentResult> => { ): Promise<CommentResult> => {
loading.value = true loading.value = true
try { try {
const payload: Record<string, string> = { entityType, entityId, content } let result
if (entityName) payload.entityName = entityName if (files && files.length > 0) {
const result = await post('/comments', payload) const formData = new FormData()
formData.append('content', content)
formData.append('entityType', entityType)
formData.append('entityId', entityId)
if (entityName) formData.append('entityName', entityName)
for (const file of files) {
formData.append('files[]', file)
}
result = await postFormData('/comments', formData)
} else {
const payload: Record<string, string> = { entityType, entityId, content }
if (entityName) payload.entityName = entityName
result = await post('/comments', payload)
}
if (result.success) { if (result.success) {
showSuccess('Commentaire ajouté') showSuccess('Commentaire ajouté')
return { success: true, data: result.data as Comment } return { success: true, data: result.data as Comment }

View File

@@ -26,7 +26,9 @@ import {
requiredCustomFieldsFilled as _requiredCustomFieldsFilled, requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues, saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils' } from '~/shared/utils/customFieldFormUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils' import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
import { import {
getStructurePieces, getStructurePieces,
resolvePieceLabel as _resolvePieceLabel, resolvePieceLabel as _resolvePieceLabel,
@@ -77,6 +79,7 @@ export function useComponentCreate() {
const toast = useToast() const toast = useToast()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields() const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const { uploadDocuments } = useDocuments() const { uploadDocuments } = useDocuments()
const { syncLinks } = useConstructeurLinks()
const { canEdit } = usePermissions() const { canEdit } = usePermissions()
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -92,6 +95,8 @@ export function useComponentCreate() {
constructeurIds: [] as string[], constructeurIds: [] as string[],
prix: '' as string, prix: '' as string,
}) })
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
const lastSuggestedName = ref('') const lastSuggestedName = ref('')
const customFieldInputs = ref<CustomFieldInput[]>([]) const customFieldInputs = ref<CustomFieldInput[]>([])
const structureAssignments = ref<StructureAssignmentNode | null>(null) const structureAssignments = ref<StructureAssignmentNode | null>(null)
@@ -106,7 +111,7 @@ export function useComponentCreate() {
const availableProducts = computed(() => productCatalogRef.value ?? []) const availableProducts = computed(() => productCatalogRef.value ?? [])
const availableComponents = computed(() => componentCatalogRef.value ?? []) const availableComponents = computed(() => componentCatalogRef.value ?? [])
const structureDataLoading = computed( const structureDataLoading = computed(
() => piecesLoading.value || componentsLoading.value || productsLoading.value, () => !submitting.value && (piecesLoading.value || componentsLoading.value || productsLoading.value),
) )
const fetchedPieceTypeMap = ref<Record<string, string>>({}) const fetchedPieceTypeMap = ref<Record<string, string>>({})
@@ -276,9 +281,7 @@ export function useComponentCreate() {
payload.reference = reference payload.reference = reference
} }
if (creationForm.constructeurIds.length) { // constructeurIds are handled via link entities, not in the main payload
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
}
const rawPrice = typeof creationForm.prix === 'string' const rawPrice = typeof creationForm.prix === 'string'
? creationForm.prix.trim() ? creationForm.prix.trim()
@@ -323,7 +326,7 @@ export function useComponentCreate() {
await _saveCustomFieldValues( await _saveCustomFieldValues(
'composant', 'composant',
createdComponent.id, createdComponent.id,
[createdComponent?.typeComposant?.customFields], [createdComponent?.typeComposant?.structure?.customFields],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast }, { customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
) )
if (selectedDocuments.value.length && result.data?.id) { if (selectedDocuments.value.length && result.data?.id) {
@@ -343,8 +346,12 @@ export function useComponentCreate() {
} }
selectedDocuments.value = [] selectedDocuments.value = []
} }
// Sync constructeur links after creation
if (constructeurLinks.value.length) {
await syncLinks('composant', createdComponent.id, [], constructeurLinks.value)
}
toast.showSuccess('Composant créé avec succès') toast.showSuccess('Composant créé avec succès')
await router.push('/component-catalog') await router.replace(`/component/${createdComponent.id}?edit=true`)
} }
else if (result.error) { else if (result.error) {
toast.showError(result.error) toast.showError(result.error)
@@ -380,6 +387,8 @@ export function useComponentCreate() {
selectedTypeId, selectedTypeId,
submitting, submitting,
creationForm, creationForm,
constructeurLinks,
constructeurIdsFromForm,
customFieldInputs, customFieldInputs,
structureAssignments, structureAssignments,
selectedDocuments, selectedDocuments,

View File

@@ -7,15 +7,16 @@ import { useProductTypes } from '~/composables/useProductTypes'
import { usePieces } from '~/composables/usePieces' import { usePieces } from '~/composables/usePieces'
import { useProducts } from '~/composables/useProducts' import { useProducts } from '~/composables/useProducts'
import { useCustomFields } from '~/composables/useCustomFields' import { useCustomFields } from '~/composables/useCustomFields'
import type { SelectionEntry } from '~/shared/utils/structureSelectionUtils'
import { useApi } from '~/composables/useApi' import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { extractRelationId } from '~/shared/apiRelations' import { extractRelationId } from '~/shared/apiRelations'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs' import { useConstructeurs } from '~/composables/useConstructeurs'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { useComponentHistory } from '~/composables/useComponentHistory' import { useComponentHistory } from '~/composables/useComponentHistory'
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils' import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils' import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
import { import {
getStructurePieces, getStructurePieces,
getStructureProducts, getStructureProducts,
@@ -58,10 +59,11 @@ export function useComponentEdit(componentId: string) {
const { componentTypes, loadComponentTypes } = useComponentTypes() const { componentTypes, loadComponentTypes } = useComponentTypes()
const { pieceTypes, loadPieceTypes } = usePieceTypes() const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { productTypes, loadProductTypes } = useProductTypes() const { productTypes, loadProductTypes } = useProductTypes()
const { updateComposant, loadComposants, composants: componentCatalogRef } = useComposants() const { updateComposant, composants: componentCatalogRef } = useComposants()
const { pieces, loadPieces } = usePieces() const { pieces } = usePieces()
const { products, loadProducts } = useProducts() const { products } = useProducts()
const { ensureConstructeurs } = useConstructeurs() const { ensureConstructeurs } = useConstructeurs()
const { fetchLinks, syncLinks } = useConstructeurLinks()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields() const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const toast = useToast() const toast = useToast()
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments() const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
@@ -90,6 +92,9 @@ export function useComponentEdit(componentId: string) {
constructeurIds: [] as string[], constructeurIds: [] as string[],
prix: '' as string, prix: '' as string,
}) })
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
const customFieldInputs = ref<CustomFieldInput[]>([]) const customFieldInputs = ref<CustomFieldInput[]>([])
const fetchedPieceTypeMap = ref<Record<string, string>>({}) const fetchedPieceTypeMap = ref<Record<string, string>>({})
@@ -270,97 +275,81 @@ export function useComponentEdit(componentId: string) {
} }
}) })
// --- Slot selection entries (for selectors) --- // --- Slot local edits (saved on submit, not auto-saved) ---
const slotEdits = reactive<{
pieces: Record<string, { selectedPieceId?: string | null, quantity?: number }>
products: Record<string, { selectedProductId?: string | null }>
subcomponents: Record<string, { selectedComposantId?: string | null }>
}>({ pieces: {}, products: {}, subcomponents: {} })
const pieceSlotEntries = computed(() => { const pieceSlotEntries = computed(() => {
const structure = component.value?.structure const structure = component.value?.structure
if (!structure?.pieces) return [] if (!structure?.pieces) return []
return (structure.pieces as any[]).map((slot: any, i: number) => ({ return (structure.pieces as any[]).map((slot: any, i: number) => {
slotId: slot.slotId, const edits = slotEdits.pieces[slot.slotId]
typePieceId: slot.typePieceId, return {
selectedPieceId: slot.selectedPieceId ?? null, slotId: slot.slotId,
quantity: slot.quantity ?? 1, typePieceId: slot.typePieceId,
position: slot.position ?? i, selectedPieceId: edits && 'selectedPieceId' in edits ? edits.selectedPieceId : (slot.selectedPieceId ?? null),
label: pieceTypeLabelMap.value[slot.typePieceId] || `Pièce #${i + 1}`, selectedPieceName: slot.selectedPieceName ?? null,
})) quantity: edits && 'quantity' in edits ? edits.quantity! : (slot.quantity ?? 1),
position: slot.position ?? i,
label: pieceTypeLabelMap.value[slot.typePieceId] || `Pièce #${i + 1}`,
}
})
}) })
const productSlotEntries = computed(() => { const productSlotEntries = computed(() => {
const structure = component.value?.structure const structure = component.value?.structure
if (!structure?.products) return [] if (!structure?.products) return []
return (structure.products as any[]).map((slot: any, i: number) => ({ return (structure.products as any[]).map((slot: any, i: number) => {
slotId: slot.slotId, const edits = slotEdits.products[slot.slotId]
typeProductId: slot.typeProductId, return {
selectedProductId: slot.selectedProductId ?? null, slotId: slot.slotId,
familyCode: slot.familyCode, typeProductId: slot.typeProductId,
position: slot.position ?? i, selectedProductId: edits && 'selectedProductId' in edits ? edits.selectedProductId : (slot.selectedProductId ?? null),
label: productTypeLabelMap.value[slot.typeProductId] || `Produit #${i + 1}`, selectedProductName: slot.selectedProductName ?? null,
})) familyCode: slot.familyCode,
position: slot.position ?? i,
label: productTypeLabelMap.value[slot.typeProductId] || `Produit #${i + 1}`,
}
})
}) })
const subcomponentSlotEntries = computed(() => { const subcomponentSlotEntries = computed(() => {
const structure = component.value?.structure const structure = component.value?.structure
if (!structure?.subcomponents) return [] if (!structure?.subcomponents) return []
return (structure.subcomponents as any[]).map((slot: any, i: number) => ({ return (structure.subcomponents as any[]).map((slot: any, i: number) => {
slotId: slot.slotId, const edits = slotEdits.subcomponents[slot.slotId]
typeComposantId: slot.typeComposantId, return {
selectedComponentId: slot.selectedComponentId ?? null, slotId: slot.slotId,
alias: slot.alias, typeComposantId: slot.typeComposantId,
familyCode: slot.familyCode, selectedComponentId: edits && 'selectedComposantId' in edits ? edits.selectedComposantId : (slot.selectedComponentId ?? null),
position: slot.position ?? i, selectedComponentName: slot.selectedComponentName ?? null,
label: slot.alias || `Sous-composant #${i + 1}`, 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 setPieceSlotSelection = (slotId: string, selectedPieceId: string | null) => {
const result = await patch(`/composant-piece-slots/${slotId}`, { selectedPieceId }) slotEdits.pieces[slotId] = { ...slotEdits.pieces[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 setProductSlotSelection = (slotId: string, selectedProductId: string | null) => {
const result = await patch(`/composant-product-slots/${slotId}`, { selectedProductId }) slotEdits.products[slotId] = { ...slotEdits.products[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 setSubcomponentSlotSelection = (slotId: string, selectedComposantId: string | null) => {
const result = await patch(`/composant-subcomponent-slots/${slotId}`, { selectedComposantId }) slotEdits.subcomponents[slotId] = { ...slotEdits.subcomponents[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 (entry: SelectionEntry) => { const setSlotQuantity = (slotId: string, quantity: number) => {
const slotId = entry.slotId if (!slotId || quantity < 1) return
const quantity = typeof entry._definition?.quantity === 'number' slotEdits.pieces[slotId] = { ...slotEdits.pieces[slotId], quantity: Math.max(1, quantity) }
? Math.max(1, entry._definition.quantity)
: null
if (!slotId || quantity === null) return
try {
await patch(`/composant-piece-slots/${slotId}`, { quantity })
toast.showSuccess('Quantité mise à jour')
}
catch (error: any) {
toast.showError(error?.message || 'Erreur lors de la mise à jour de la quantité')
}
} }
const submitEdition = async () => { const submitEdition = async () => {
@@ -381,7 +370,6 @@ export function useComponentEdit(componentId: string) {
const reference = editionForm.reference.trim() const reference = editionForm.reference.trim()
payload.reference = reference || null payload.reference = reference || null
payload.constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
if (rawPrice) { if (rawPrice) {
const parsed = Number(rawPrice) const parsed = Number(rawPrice)
@@ -402,11 +390,62 @@ export function useComponentEdit(componentId: string) {
'composant', 'composant',
updatedComponent.id, updatedComponent.id,
[ [
updatedComponent?.typeComposant?.customFields, updatedComponent?.typeComposant?.structure?.customFields,
], ],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast }, { customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
) )
await router.push('/component-catalog')
// Save slot edits
const slotPromises: Promise<any>[] = []
for (const [slotId, edits] of Object.entries(slotEdits.pieces)) {
if (Object.keys(edits).length) {
slotPromises.push(patch(`/composant-piece-slots/${slotId}`, {
...'selectedPieceId' in edits ? { selectedPieceId: edits.selectedPieceId } : {},
...'quantity' in edits ? { quantity: edits.quantity } : {},
}))
}
}
for (const [slotId, edits] of Object.entries(slotEdits.products)) {
if ('selectedProductId' in edits) {
slotPromises.push(patch(`/composant-product-slots/${slotId}`, { selectedProductId: edits.selectedProductId }))
}
}
for (const [slotId, edits] of Object.entries(slotEdits.subcomponents)) {
if ('selectedComposantId' in edits) {
slotPromises.push(patch(`/composant-subcomponent-slots/${slotId}`, { selectedComposantId: edits.selectedComposantId }))
}
}
await Promise.all(slotPromises)
// Apply slot edits to local structure so UI reflects saved values
const structure = component.value?.structure
if (structure) {
for (const [slotId, edits] of Object.entries(slotEdits.pieces)) {
const slot = (structure.pieces as any[])?.find((s: any) => s.slotId === slotId)
if (slot) {
if ('selectedPieceId' in edits) slot.selectedPieceId = edits.selectedPieceId
if ('quantity' in edits) slot.quantity = edits.quantity
}
}
for (const [slotId, edits] of Object.entries(slotEdits.products)) {
const slot = (structure.products as any[])?.find((s: any) => s.slotId === slotId)
if (slot && 'selectedProductId' in edits) slot.selectedProductId = edits.selectedProductId
}
for (const [slotId, edits] of Object.entries(slotEdits.subcomponents)) {
const slot = (structure.subcomponents as any[])?.find((s: any) => s.slotId === slotId)
if (slot && 'selectedComposantId' in edits) slot.selectedComponentId = edits.selectedComposantId
}
}
// Reset local slot edits
slotEdits.pieces = {}
slotEdits.products = {}
slotEdits.subcomponents = {}
await syncLinks('composant', component.value.id, originalConstructeurLinks.value, constructeurLinks.value)
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
toast.showSuccess('Composant mis à jour avec succès.')
} }
} }
catch (error: any) { catch (error: any) {
@@ -440,15 +479,16 @@ export function useComponentEdit(componentId: string) {
editionForm.name = currentComponent.name || '' editionForm.name = currentComponent.name || ''
editionForm.description = currentComponent.description || '' editionForm.description = currentComponent.description || ''
editionForm.reference = currentComponent.reference || '' editionForm.reference = currentComponent.reference || ''
editionForm.constructeurIds = uniqueConstructeurIds( // Load constructeur links
currentComponent, fetchLinks('composant', componentId).then((links) => {
Array.isArray(currentComponent.constructeurs) ? currentComponent.constructeurs : [], constructeurLinks.value = links
currentComponent.constructeur ? [currentComponent.constructeur] : [], originalConstructeurLinks.value = links.map(l => ({ ...l }))
) editionForm.constructeurIds = constructeurIdsFromLinks(links)
if (editionForm.constructeurIds.length) {
void ensureConstructeurs(editionForm.constructeurIds)
}
})
editionForm.prix = currentComponent.prix !== null && currentComponent.prix !== undefined ? String(currentComponent.prix) : '' editionForm.prix = currentComponent.prix !== null && currentComponent.prix !== undefined ? String(currentComponent.prix) : ''
if (editionForm.constructeurIds.length) {
void ensureConstructeurs(editionForm.constructeurIds)
}
initialized.value = true initialized.value = true
} }
@@ -500,13 +540,6 @@ export function useComponentEdit(componentId: string) {
fetchComponent(), fetchComponent(),
]) ])
loading.value = false loading.value = false
// Load catalogs for slot selectors (force: true to bypass cache from list pages that load fewer items)
Promise.allSettled([
loadPieces({ itemsPerPage: 200, force: true }),
loadProducts({ itemsPerPage: 200, force: true }),
loadComposants({ itemsPerPage: 200, force: true }),
]).catch(() => {})
}) })
return { return {
@@ -522,6 +555,9 @@ export function useComponentEdit(componentId: string) {
previewVisible, previewVisible,
selectedTypeId, selectedTypeId,
editionForm, editionForm,
constructeurLinks,
originalConstructeurLinks,
constructeurIdsFromForm,
customFieldInputs, customFieldInputs,
historyFieldLabels, historyFieldLabels,
@@ -548,10 +584,11 @@ export function useComponentEdit(componentId: string) {
handleFilesAdded, handleFilesAdded,
refreshDocuments, refreshDocuments,
submitEdition, submitEdition,
saveSlotQuantity, fetchComponent,
savePieceSlotSelection, setSlotQuantity,
saveProductSlotSelection, setPieceSlotSelection,
saveSubcomponentSlotSelection, setProductSlotSelection,
setSubcomponentSlotSelection,
resolvePieceLabel, resolvePieceLabel,
resolveProductLabel, resolveProductLabel,
resolveSubcomponentLabel, resolveSubcomponentLabel,

View File

@@ -1,7 +1,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useToast } from './useToast' import { useToast } from './useToast'
import { useApi } from './useApi' import { useApi } from './useApi'
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils' import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { useConstructeurs, type Constructeur } from './useConstructeurs' import { useConstructeurs, type Constructeur } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations' import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers' import { extractCollection } from '~/shared/utils/apiHelpers'
@@ -42,6 +42,7 @@ interface LoadComposantsOptions {
orderBy?: string orderBy?: string
orderDir?: 'asc' | 'desc' orderDir?: 'asc' | 'desc'
typeName?: string typeName?: string
typeComposantId?: string
force?: boolean force?: boolean
} }
@@ -109,17 +110,18 @@ export function useComposants() {
orderBy = 'name', orderBy = 'name',
orderDir = 'asc', orderDir = 'asc',
typeName, typeName,
typeComposantId,
force = false, force = false,
} = options } = options
if (!force && loaded.value && !search && !typeName && page === 1) { if (!force && loaded.value && !search && !typeName && !typeComposantId && page === 1) {
return { return {
success: true, success: true,
data: { items: composants.value, total: total.value, page, itemsPerPage }, data: { items: composants.value, total: total.value, page, itemsPerPage },
} }
} }
if (loading.value) { if (!typeComposantId && loading.value) {
return { return {
success: true, success: true,
data: { items: composants.value, total: total.value, page, itemsPerPage }, data: { items: composants.value, total: total.value, page, itemsPerPage },
@@ -128,33 +130,41 @@ export function useComposants() {
loading.value = true loading.value = true
try { try {
const params = new URLSearchParams() const params = new URLSearchParams()
params.set('itemsPerPage', String(itemsPerPage)) params.set('itemsPerPage', String(itemsPerPage))
params.set('page', String(page)) params.set('page', String(page))
if (search && search.trim()) { if (search && search.trim()) {
params.set('name', search.trim()) params.set('search', search.trim())
} }
if (typeName && typeName.trim()) { if (typeName && typeName.trim()) {
params.set('typeComposant.name', typeName.trim()) params.set('typeComposant.name', typeName.trim())
} }
if (typeComposantId) {
params.set('typeComposant', typeComposantId)
}
params.set(`order[${orderBy}]`, orderDir) params.set(`order[${orderBy}]`, orderDir)
const result = await get(`/composants?${params.toString()}`) const result = await get(`/composants?${params.toString()}`)
if (result.success) { if (result.success) {
const items = extractCollection(result.data) const items = extractCollection(result.data)
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item))) const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
composants.value = enrichedItems const resultTotal = extractTotal(result.data, items.length)
total.value = extractTotal(result.data, items.length)
loaded.value = true if (!typeComposantId) {
composants.value = enrichedItems
total.value = resultTotal
loaded.value = true
}
return { return {
success: true, success: true,
data: { data: {
items: enrichedItems, items: enrichedItems,
total: total.value, total: resultTotal,
page, page,
itemsPerPage, itemsPerPage,
}, },
@@ -172,7 +182,8 @@ export function useComposants() {
const createComposant = async (composantData: Partial<Composant>): Promise<ComposantSingleResult> => { const createComposant = async (composantData: Partial<Composant>): Promise<ComposantSingleResult> => {
loading.value = true loading.value = true
try { try {
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(composantData)) const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = composantData as any
const normalizedPayload = normalizeRelationIds(cleanPayload)
const result = await post('/composants', normalizedPayload) const result = await post('/composants', normalizedPayload)
if (result.success && result.data) { if (result.success && result.data) {
const enriched = await withResolvedConstructeurs(result.data as Composant) const enriched = await withResolvedConstructeurs(result.data as Composant)
@@ -199,7 +210,8 @@ export function useComposants() {
const updateComposantData = async (id: string, composantData: Partial<Composant>): Promise<ComposantSingleResult> => { const updateComposantData = async (id: string, composantData: Partial<Composant>): Promise<ComposantSingleResult> => {
loading.value = true loading.value = true
try { try {
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(composantData)) const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = composantData as any
const normalizedPayload = normalizeRelationIds(cleanPayload)
const result = await patch(`/composants/${id}`, normalizedPayload) const result = await patch(`/composants/${id}`, normalizedPayload)
if (result.success && result.data) { if (result.success && result.data) {
const updated = await withResolvedConstructeurs(result.data as Composant) const updated = await withResolvedConstructeurs(result.data as Composant)

View File

@@ -0,0 +1,103 @@
import { useApi } from '~/composables/useApi'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
import { extractCollection } from '~/shared/utils/apiHelpers'
type EntityType = 'machine' | 'piece' | 'composant' | 'product'
const ENDPOINTS: Record<EntityType, string> = {
machine: '/machine_constructeur_links',
piece: '/piece_constructeur_links',
composant: '/composant_constructeur_links',
product: '/product_constructeur_links',
}
const ENTITY_KEYS: Record<EntityType, string> = {
machine: 'machine',
piece: 'piece',
composant: 'composant',
product: 'product',
}
const ENTITY_PLURALS: Record<EntityType, string> = {
machine: 'machines',
piece: 'pieces',
composant: 'composants',
product: 'products',
}
export function useConstructeurLinks() {
const { get, post, patch, delete: del } = useApi()
const fetchLinks = async (
entityType: EntityType,
entityId: string,
): Promise<ConstructeurLinkEntry[]> => {
const endpoint = ENDPOINTS[entityType]
const key = ENTITY_KEYS[entityType]
const plural = ENTITY_PLURALS[entityType]
const url = `${endpoint}?${key}=/api/${plural}/${entityId}`
const result = await get(url)
if (!result.success || !result.data) return []
const members = extractCollection(result.data)
if (!Array.isArray(members)) return []
return members.map((link: any) => ({
linkId: link.id ?? (typeof link['@id'] === 'string' ? link['@id'].split('/').pop() : undefined),
constructeurId: typeof link.constructeur === 'string'
? link.constructeur.split('/').pop()!
: link.constructeur?.id ?? '',
constructeur: typeof link.constructeur === 'object' ? link.constructeur : null,
supplierReference: link.supplierReference ?? null,
}))
}
const syncLinks = async (
entityType: EntityType,
entityId: string,
originalLinks: ConstructeurLinkEntry[],
formLinks: ConstructeurLinkEntry[],
): Promise<void> => {
const endpoint = ENDPOINTS[entityType]
const key = ENTITY_KEYS[entityType]
const plural = ENTITY_PLURALS[entityType]
const entityIri = `/api/${plural}/${entityId}`
const originalMap = new Map(originalLinks.map(l => [l.constructeurId, l]))
const formMap = new Map(formLinks.map(l => [l.constructeurId, l]))
const promises: Promise<any>[] = []
// Delete removed links
for (const [cId, orig] of originalMap) {
if (!formMap.has(cId) && orig.linkId) {
promises.push(del(`${endpoint}/${orig.linkId}`))
}
}
// Create new links
for (const [cId, form] of formMap) {
if (!originalMap.has(cId)) {
promises.push(post(endpoint, {
[key]: entityIri,
constructeur: `/api/constructeurs/${cId}`,
supplierReference: form.supplierReference || null,
}))
}
}
// Patch modified supplierReference
for (const [cId, form] of formMap) {
const orig = originalMap.get(cId)
if (orig?.linkId && (orig.supplierReference ?? null) !== (form.supplierReference ?? null)) {
promises.push(patch(`${endpoint}/${orig.linkId}`, {
supplierReference: form.supplierReference || null,
}))
}
}
await Promise.allSettled(promises)
}
return { fetchLinks, syncLinks }
}

View File

@@ -1,4 +1,4 @@
import { ref, computed, type Ref, type ComputedRef } from 'vue' import { ref, computed, watch, type Ref, type ComputedRef } from 'vue'
import { useUrlState } from './useUrlState' import { useUrlState } from './useUrlState'
import type { DataTableSort, DataTablePagination, DataTableColumnFilters, SortDirection } from '~/shared/types/dataTable' import type { DataTableSort, DataTablePagination, DataTableColumnFilters, SortDirection } from '~/shared/types/dataTable'
@@ -22,6 +22,8 @@ export interface UseDataTableOptions {
persistToUrl?: boolean persistToUrl?: boolean
/** Extra URL state params for page-specific filters */ /** Extra URL state params for page-specific filters */
extraParams?: Record<string, { default: string | number; type?: 'string' | 'number' }> extraParams?: Record<string, { default: string | number; type?: 'string' | 'number' }>
/** Column filter keys to persist in URL (prefixed with `f.` in query string) */
columnFilterKeys?: string[]
} }
export interface UseDataTableReturn { export interface UseDataTableReturn {
@@ -56,6 +58,7 @@ export function useDataTable(
searchDebounceMs = 300, searchDebounceMs = 300,
persistToUrl = true, persistToUrl = true,
extraParams = {}, extraParams = {},
columnFilterKeys = [],
} = options } = options
let searchTerm: Ref<string> let searchTerm: Ref<string>
@@ -64,6 +67,7 @@ export function useDataTable(
let currentPage: Ref<number> let currentPage: Ref<number>
let itemsPerPage: Ref<number> let itemsPerPage: Ref<number>
const filters: Record<string, Ref<string | number>> = {} const filters: Record<string, Ref<string | number>> = {}
const columnFilterRefs: Record<string, Ref<string>> = {}
if (persistToUrl) { if (persistToUrl) {
const paramDefs: Record<string, { default: string | number; type?: 'string' | 'number'; debounce?: number }> = { const paramDefs: Record<string, { default: string | number; type?: 'string' | 'number'; debounce?: number }> = {
@@ -75,6 +79,10 @@ export function useDataTable(
...extraParams, ...extraParams,
} }
for (const key of columnFilterKeys) {
paramDefs[`f.${key}`] = { default: '', debounce: 300 }
}
const state = useUrlState(paramDefs, { const state = useUrlState(paramDefs, {
onRestore: () => deps.fetchData(), onRestore: () => deps.fetchData(),
}) })
@@ -88,6 +96,10 @@ export function useDataTable(
for (const key of Object.keys(extraParams)) { for (const key of Object.keys(extraParams)) {
filters[key] = (state as Record<string, Ref<string | number>>)[key]! filters[key] = (state as Record<string, Ref<string | number>>)[key]!
} }
for (const key of columnFilterKeys) {
columnFilterRefs[key] = (state as Record<string, Ref<string>>)[`f.${key}`]!
}
} }
else { else {
searchTerm = ref('') searchTerm = ref('')
@@ -137,8 +149,31 @@ export function useDataTable(
deps.fetchData() deps.fetchData()
} }
// Column filters // Column filters — seed from URL-persisted refs
const columnFilters = ref<DataTableColumnFilters>({}) const initialColumnFilters: DataTableColumnFilters = {}
for (const [key, r] of Object.entries(columnFilterRefs)) {
if (r.value) initialColumnFilters[key] = r.value
}
const columnFilters = ref<DataTableColumnFilters>(initialColumnFilters)
// Sync columnFilters → URL refs
if (persistToUrl && columnFilterKeys.length > 0) {
watch(columnFilters, (val) => {
for (const key of columnFilterKeys) {
columnFilterRefs[key]!.value = val[key] || ''
}
}, { deep: true })
// Sync URL refs → columnFilters (back/forward navigation)
for (const key of columnFilterKeys) {
watch(columnFilterRefs[key]!, (urlVal) => {
const current = columnFilters.value[key] || ''
if (current !== urlVal) {
columnFilters.value = { ...columnFilters.value, [key]: urlVal }
}
})
}
}
const handleColumnFiltersChange = (newFilters: DataTableColumnFilters) => { const handleColumnFiltersChange = (newFilters: DataTableColumnFilters) => {
columnFilters.value = newFilters columnFilters.value = newFilters

View File

@@ -11,6 +11,7 @@ export interface Document {
size: number size: number
fileUrl: string fileUrl: string
downloadUrl: string downloadUrl: string
type?: string
/** @deprecated Legacy Base64 data URI — use fileUrl instead */ /** @deprecated Legacy Base64 data URI — use fileUrl instead */
path?: string path?: string
createdAt?: string createdAt?: string
@@ -32,6 +33,7 @@ export interface UploadContext {
composantId?: string composantId?: string
productId?: string productId?: string
pieceId?: string pieceId?: string
type?: string
} }
export interface DocumentResult { export interface DocumentResult {
@@ -47,6 +49,7 @@ interface LoadDocumentsOptions {
orderBy?: string orderBy?: string
orderDir?: 'asc' | 'desc' orderDir?: 'asc' | 'desc'
attachmentFilter?: string attachmentFilter?: string
type?: string
force?: boolean force?: boolean
} }
@@ -63,7 +66,7 @@ const extractTotal = (payload: unknown, fallbackLength: number): number => {
} }
export function useDocuments() { export function useDocuments() {
const { get, postFormData, delete: del } = useApi() const { get, patch, postFormData, delete: del } = useApi()
const { showError, showSuccess } = useToast() const { showError, showSuccess } = useToast()
const loadFromEndpoint = async ( const loadFromEndpoint = async (
@@ -103,10 +106,11 @@ export function useDocuments() {
orderBy = 'createdAt', orderBy = 'createdAt',
orderDir = 'desc', orderDir = 'desc',
attachmentFilter = 'all', attachmentFilter = 'all',
type = 'all',
force = false, force = false,
} = options } = options
if (!force && loaded.value && !search && page === 1 && attachmentFilter === 'all') { if (!force && loaded.value && !search && page === 1 && attachmentFilter === 'all' && type === 'all') {
return { success: true, data: documents.value } return { success: true, data: documents.value }
} }
@@ -128,6 +132,10 @@ export function useDocuments() {
params.set(`exists[${attachmentFilter}]`, 'true') params.set(`exists[${attachmentFilter}]`, 'true')
} }
if (type && type !== 'all') {
params.set('type', type)
}
params.set(`order[${orderBy}]`, orderDir) params.set(`order[${orderBy}]`, orderDir)
const result = await get(`/documents?${params.toString()}`) const result = await get(`/documents?${params.toString()}`)
@@ -218,6 +226,7 @@ export function useDocuments() {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
formData.append('name', file.name) formData.append('name', file.name)
if (context.type) formData.append('type', context.type)
if (context.siteId) formData.append('siteId', context.siteId) if (context.siteId) formData.append('siteId', context.siteId)
if (context.machineId) formData.append('machineId', context.machineId) if (context.machineId) formData.append('machineId', context.machineId)
@@ -280,6 +289,33 @@ export function useDocuments() {
} }
} }
const updateDocument = async (
id: string,
data: { name?: string; type?: string },
): Promise<DocumentResult> => {
loading.value = true
try {
const result = await patch(`/documents/${id}`, data)
if (result.success && result.data) {
const updated = result.data as Document
const index = documents.value.findIndex((doc) => doc.id === id)
if (index !== -1) {
documents.value[index] = { ...documents.value[index], ...updated }
}
showSuccess('Document mis à jour')
return { success: true, data: updated }
}
if (result.error) showError(result.error)
return result as DocumentResult
} catch (error) {
const err = error as Error
showError('Impossible de mettre à jour le document')
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
return { return {
documents, documents,
total, total,
@@ -292,6 +328,7 @@ export function useDocuments() {
loadDocumentsByPiece, loadDocumentsByPiece,
loadDocumentsByProduct, loadDocumentsByProduct,
uploadDocuments, uploadDocuments,
updateDocument,
deleteDocument, deleteDocument,
} }
} }

View File

@@ -17,7 +17,7 @@ export interface EntityDocumentsDeps {
export function useEntityDocuments(deps: EntityDocumentsDeps) { export function useEntityDocuments(deps: EntityDocumentsDeps) {
const { entity, entityType } = deps const { entity, entityType } = deps
const { uploadDocuments, deleteDocument } = useDocuments() const { uploadDocuments, deleteDocument, updateDocument } = useDocuments()
const loadDocumentsFn = entityType === 'composant' const loadDocumentsFn = entityType === 'composant'
? useDocuments().loadDocumentsByComponent ? useDocuments().loadDocumentsByComponent
@@ -104,6 +104,19 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
} }
} }
const editDocument = async (id: string, data: { name?: string; type?: string }) => {
const result: any = await updateDocument(id, data)
if (result.success) {
const e = entity()
const docs = e.documents || []
const index = docs.findIndex((doc: any) => doc.id === id)
if (index !== -1) {
docs[index] = { ...docs[index], ...data }
}
}
return result
}
return { return {
documents, documents,
selectedFiles, selectedFiles,
@@ -118,5 +131,6 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
ensureDocumentsLoaded, ensureDocumentsLoaded,
handleFilesAdded, handleFilesAdded,
removeDocument, removeDocument,
editDocument,
} }
} }

View File

@@ -23,6 +23,7 @@ export type EntityHistoryEntry = {
} }
const ENTITY_ENDPOINTS: Record<string, string> = { const ENTITY_ENDPOINTS: Record<string, string> = {
machine: '/machines',
composant: '/composants', composant: '/composants',
piece: '/pieces', piece: '/pieces',
product: '/products', product: '/products',
@@ -35,7 +36,7 @@ const extractItems = (payload: any): EntityHistoryEntry[] => {
return [] return []
} }
export function useEntityHistory(entityType: 'composant' | 'piece' | 'product') { export function useEntityHistory(entityType: 'machine' | 'composant' | 'piece' | 'product') {
const { get } = useApi() const { get } = useApi()
const basePath = ENTITY_ENDPOINTS[entityType] const basePath = ENTITY_ENDPOINTS[entityType]

View File

@@ -127,8 +127,8 @@ export function useEntityTypes(config: EntityTypeConfig) {
showSuccess(`Type de ${label} "${data.name}" créé`) showSuccess(`Type de ${label} "${data.name}" créé`)
return { success: true, data: normalized } return { success: true, data: normalized }
} catch (error) { } catch (error) {
const err = error as Error & { data?: { message?: string }; message?: string } const err = error as Error & { data?: { error?: string; message?: string }; message?: string }
const raw = err?.data?.message || err?.message const raw = err?.data?.error || err?.data?.message || err?.message
const message = humanizeError(raw) const message = humanizeError(raw)
showError(`Impossible de créer le type de ${label} : ${message}`) showError(`Impossible de créer le type de ${label} : ${message}`)
return { success: false, error: message } return { success: false, error: message }
@@ -153,8 +153,8 @@ export function useEntityTypes(config: EntityTypeConfig) {
showSuccess(`Type de ${label} "${data.name}" mis à jour`) showSuccess(`Type de ${label} "${data.name}" mis à jour`)
return { success: true, data: normalized } return { success: true, data: normalized }
} catch (error) { } catch (error) {
const err = error as Error & { data?: { message?: string }; message?: string } const err = error as Error & { data?: { error?: string; message?: string }; message?: string }
const raw = err?.data?.message || err?.message const raw = err?.data?.error || err?.data?.message || err?.message
const message = humanizeError(raw) const message = humanizeError(raw)
showError(`Impossible de mettre à jour le type de ${label} : ${message}`) showError(`Impossible de mettre à jour le type de ${label} : ${message}`)
return { success: false, error: message } return { success: false, error: message }
@@ -171,8 +171,8 @@ export function useEntityTypes(config: EntityTypeConfig) {
showSuccess(`Type de ${label} supprimé`) showSuccess(`Type de ${label} supprimé`)
return { success: true } return { success: true }
} catch (error) { } catch (error) {
const err = error as Error & { data?: { message?: string }; message?: string } const err = error as Error & { data?: { error?: string; message?: string }; message?: string }
const raw = err?.data?.message || err?.message const raw = err?.data?.error || err?.data?.message || err?.message
const message = humanizeError(raw) const message = humanizeError(raw)
showError(`Impossible de supprimer le type de ${label} : ${message}`) showError(`Impossible de supprimer le type de ${label} : ${message}`)
return { success: false, error: message } return { success: false, error: message }

View File

@@ -0,0 +1,98 @@
import { ref, toValue } from 'vue'
import { useApi } from '~/composables/useApi'
import type { MaybeRef } from 'vue'
export interface VersionEntry {
version: number
action: 'create' | 'update' | 'restore' | string
createdAt: string
actor: { id: string; label: string } | null
diff: Record<string, { from: unknown; to: unknown }> | null
}
export interface RestorePreview {
version: number
restoreMode: 'full' | 'partial'
diff: Record<string, { current: unknown; restored: unknown }>
warnings: Array<{
field: string
message: string
missingEntityId: string | null
missingEntityName: string | null
}>
snapshot: Record<string, unknown>
}
export interface RestoreResult {
success: boolean
newVersion: number
restoredFromVersion: number
restoreMode: 'full' | 'partial'
warnings: RestorePreview['warnings']
}
const ENTITY_ENDPOINTS: Record<string, string> = {
machine: '/machines',
composant: '/composants',
piece: '/pieces',
product: '/products',
}
interface Deps {
entityType: MaybeRef<string>
entityId: MaybeRef<string>
}
export function useEntityVersions(deps: Deps) {
const { get, post } = useApi()
const versions = ref<VersionEntry[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const getPath = () => {
const type = toValue(deps.entityType)
const id = toValue(deps.entityId)
const base = ENTITY_ENDPOINTS[type]
return `${base}/${id}`
}
const fetchVersions = async () => {
loading.value = true
error.value = null
try {
const result = await get(`${getPath()}/versions`)
if (!result.success) {
error.value = result.error ?? 'Impossible de charger les versions.'
versions.value = []
return
}
versions.value = result.data?.items ?? []
}
catch (err: any) {
error.value = err?.message ?? 'Erreur inconnue'
versions.value = []
}
finally {
loading.value = false
}
}
const fetchPreview = async (version: number): Promise<RestorePreview | null> => {
const result = await get<RestorePreview>(`${getPath()}/versions/${version}/preview`)
if (!result.success || !result.data) {
return null
}
return result.data
}
const restore = async (version: number): Promise<RestoreResult | null> => {
const result = await post<RestoreResult>(`${getPath()}/versions/${version}/restore`, {})
if (!result.success || !result.data) {
return null
}
return result.data
}
return { versions, loading, error, fetchVersions, fetchPreview, restore }
}

View File

@@ -376,6 +376,58 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
} }
} }
const saveAllMachineCustomFields = async () => {
if (!machine.value) return
const fields = Array.isArray(machineCustomFields.value) ? machineCustomFields.value : []
const fieldsToSave = fields.filter(
(field) => field.value !== undefined && field.value !== null && String(field.value).trim() !== '',
)
for (const field of fieldsToSave) {
const { id: customFieldId, customFieldValueId } = field
try {
if (customFieldValueId) {
await updateCustomFieldValueApi(customFieldValueId as string, {
value: field.value ?? '',
} as any)
} else if (customFieldId) {
const result: any = await upsertCustomFieldValue(
customFieldId as string,
'machine',
machine.value.id as string,
field.value ?? '',
)
if (result.success) {
const createdValue = result.data as AnyRecord
if (createdValue?.id) {
field.customFieldValueId = createdValue.id
if (!createdValue.customField) {
createdValue.customField = {
id: customFieldId,
name: field.name,
type: field.type,
required: field.required,
options: field.options,
}
}
const existingValues = Array.isArray(machine.value.customFieldValues)
? (machine.value.customFieldValues as AnyRecord[]).filter(
(item) => item.id !== createdValue.id,
)
: []
machine.value.customFieldValues = [...existingValues, createdValue]
}
}
}
} catch (error) {
console.error('Erreur lors de la sauvegarde du champ personnalisé:', error)
throw error
}
}
}
return { return {
// State // State
machineCustomFields, machineCustomFields,
@@ -392,5 +444,6 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
setMachineCustomFieldValue, setMachineCustomFieldValue,
updateMachineCustomField, updateMachineCustomField,
updatePieceCustomField, updatePieceCustomField,
saveAllMachineCustomFields,
} }
} }

View File

@@ -21,7 +21,11 @@ import {
resolveConstructeurs, resolveConstructeurs,
uniqueConstructeurIds, uniqueConstructeurIds,
formatConstructeurContact as formatConstructeurContactSummary, formatConstructeurContact as formatConstructeurContactSummary,
parseConstructeurLinksFromApi,
constructeurIdsFromLinks,
} from '~/shared/constructeurUtils' } from '~/shared/constructeurUtils'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { useMachineDetailDocuments } from '~/composables/useMachineDetailDocuments' import { useMachineDetailDocuments } from '~/composables/useMachineDetailDocuments'
import { useMachineDetailCustomFields } from '~/composables/useMachineDetailCustomFields' import { useMachineDetailCustomFields } from '~/composables/useMachineDetailCustomFields'
import { useMachineDetailHierarchy } from '~/composables/useMachineDetailHierarchy' import { useMachineDetailHierarchy } from '~/composables/useMachineDetailHierarchy'
@@ -62,6 +66,12 @@ export function useMachineDetailData(machineId: string) {
const machine = ref<AnyRecord | null>(null) const machine = ref<AnyRecord | null>(null)
const productDocumentsMap = ref<Map<string, AnyRecord[]>>(new Map()) const productDocumentsMap = ref<Map<string, AnyRecord[]>>(new Map())
const printAreaRef = ref<HTMLElement | null>(null) const printAreaRef = ref<HTMLElement | null>(null)
const saving = ref(false)
// Constructeur links
const { fetchLinks, syncLinks } = useConstructeurLinks()
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
// Machine fields // Machine fields
const machineName = ref('') const machineName = ref('')
@@ -77,20 +87,15 @@ export function useMachineDetailData(machineId: string) {
}) })
const machineConstructeursDisplay = computed(() => { const machineConstructeursDisplay = computed(() => {
const ids = uniqueConstructeurIds( const ids = machineConstructeurIds.value
machineConstructeurIds.value, if (!ids.length) return [] as any[]
(machine.value as AnyRecord)?.constructeurIds, // Extract nested constructeur objects from link entries as candidate pool
(machine.value as AnyRecord)?.constructeurs, const linkConstructeurs = constructeurLinks.value
(machine.value as AnyRecord)?.constructeur, .filter(l => l.constructeur && l.constructeur.id)
) .map(l => l.constructeur!) as any[]
return resolveConstructeurs( return resolveConstructeurs(
ids, ids,
Array.isArray((machine.value as AnyRecord)?.constructeurs) linkConstructeurs,
? ((machine.value as AnyRecord).constructeurs as any[])
: [],
(machine.value as AnyRecord)?.constructeur
? [(machine.value as AnyRecord).constructeur as any]
: [],
constructeurs.value as any, constructeurs.value as any,
) as any[] ) as any[]
}) })
@@ -108,6 +113,12 @@ export function useMachineDetailData(machineId: string) {
// UI state // UI state
const isEditMode = ref(false) const isEditMode = ref(false)
const canSubmit = computed(() => {
if (!machine.value) return false
if (saving.value) return false
if (!machineName.value.trim()) return false
return true
})
const debug = ref(false) const debug = ref(false)
const componentsCollapsed = ref(true) const componentsCollapsed = ref(true)
@@ -146,6 +157,7 @@ export function useMachineDetailData(machineId: string) {
setMachineCustomFieldValue, setMachineCustomFieldValue,
updateMachineCustomField, updateMachineCustomField,
updatePieceCustomField, updatePieceCustomField,
saveAllMachineCustomFields,
} = useMachineDetailCustomFields({ } = useMachineDetailCustomFields({
machine, machine,
isEditMode, isEditMode,
@@ -227,11 +239,12 @@ export function useMachineDetailData(machineId: string) {
if (machine.value) { if (machine.value) {
machineName.value = (machine.value.name as string) || '' machineName.value = (machine.value.name as string) || ''
machineReference.value = (machine.value.reference as string) || '' machineReference.value = (machine.value.reference as string) || ''
machineConstructeurIds.value = uniqueConstructeurIds( // Parse constructeur links from structure response
machine.value.constructeurIds, const rawLinks = Array.isArray(machine.value.constructeurs) ? machine.value.constructeurs as any[] : []
machine.value.constructeurs, const parsed = parseConstructeurLinksFromApi(rawLinks)
machine.value.constructeur, constructeurLinks.value = parsed
) originalConstructeurLinks.value = parsed.map(l => ({ ...l }))
machineConstructeurIds.value = constructeurIdsFromLinks(parsed)
machineSiteId.value = (machine.value.siteId as string) || (machine.value.site as AnyRecord)?.id as string || '' machineSiteId.value = (machine.value.siteId as string) || (machine.value.site as AnyRecord)?.id as string || ''
} }
} }
@@ -261,6 +274,8 @@ export function useMachineDetailData(machineId: string) {
machineReference, machineReference,
machineSiteId, machineSiteId,
machineConstructeurIds, machineConstructeurIds,
constructeurLinks,
originalConstructeurLinks,
machineDocumentsLoaded, machineDocumentsLoaded,
machineComponentLinks, machineComponentLinks,
machinePieceLinks, machinePieceLinks,
@@ -276,6 +291,7 @@ export function useMachineDetailData(machineId: string) {
updatePieceApi, updatePieceApi,
apiPatch, apiPatch,
toast, toast,
syncLinks,
}) })
// UI methods // UI methods
@@ -302,6 +318,39 @@ export function useMachineDetailData(machineId: string) {
pieceCollapseToggleToken.value += 1 pieceCollapseToggleToken.value += 1
} }
const submitEdition = async () => {
if (!machine.value || saving.value) return
saving.value = true
try {
// 1. Save machine info (name, reference, site, constructeurs)
await updateMachineInfo()
// 2. Save all custom field values
await saveAllMachineCustomFields()
// 3. Reload machine data to get fresh state
await loadMachineData()
// 4. Exit edit mode
isEditMode.value = false
toast.showSuccess('Machine mise à jour avec succès')
} catch (error) {
console.error('Erreur lors de la sauvegarde:', error)
toast.showError('Erreur lors de la sauvegarde de la machine')
} finally {
saving.value = false
}
}
const cancelEdition = () => {
initMachineFields()
syncMachineCustomFields()
constructeurLinks.value = originalConstructeurLinks.value.map(l => ({ ...l }))
machineConstructeurIds.value = constructeurIdsFromLinks(constructeurLinks.value)
isEditMode.value = false
}
// Print wrappers // Print wrappers
const ensurePrintSelectionEntries = () => const ensurePrintSelectionEntries = () =>
_ensurePrintEntries(components.value, machinePieces.value) _ensurePrintEntries(components.value, machinePieces.value)
@@ -428,6 +477,7 @@ export function useMachineDetailData(machineId: string) {
// Machine fields // Machine fields
machineName, machineReference, machineSiteId, machineConstructeurIds, machineConstructeurId, machineName, machineReference, machineSiteId, machineConstructeurIds, machineConstructeurId,
machineConstructeursDisplay, machineConstructeurContact, hasMachineConstructeur, machineConstructeursDisplay, machineConstructeurContact, hasMachineConstructeur,
constructeurLinks, originalConstructeurLinks,
sites, sites,
// UI state // UI state
@@ -451,6 +501,7 @@ export function useMachineDetailData(machineId: string) {
updateMachineInfo, updateComponent, updatePieceFromComponent, updateMachineInfo, updateComponent, updatePieceFromComponent,
updatePieceInfo, handleMachineConstructeurChange, editComponent, editPiece, updatePieceInfo, handleMachineConstructeurChange, editComponent, editPiece,
toggleEditMode, toggleAllComponents, collapseAllComponents, toggleAllPieces, toggleEditMode, toggleAllComponents, collapseAllComponents, toggleAllPieces,
saving, canSubmit, submitEdition, cancelEdition,
// Print // Print
printModalOpen, printSelection, ensurePrintSelectionEntries, printModalOpen, printSelection, ensurePrintSelectionEntries,

View File

@@ -5,7 +5,8 @@
*/ */
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils' import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
type AnyRecord = Record<string, unknown> type AnyRecord = Record<string, unknown>
@@ -15,6 +16,8 @@ export interface UseMachineDetailUpdatesDeps {
machineReference: Ref<string> machineReference: Ref<string>
machineSiteId: Ref<string> machineSiteId: Ref<string>
machineConstructeurIds: Ref<string[]> machineConstructeurIds: Ref<string[]>
constructeurLinks: Ref<ConstructeurLinkEntry[]>
originalConstructeurLinks: Ref<ConstructeurLinkEntry[]>
machineDocumentsLoaded: Ref<boolean> machineDocumentsLoaded: Ref<boolean>
machineComponentLinks: Ref<AnyRecord[]> machineComponentLinks: Ref<AnyRecord[]>
machinePieceLinks: Ref<AnyRecord[]> machinePieceLinks: Ref<AnyRecord[]>
@@ -35,6 +38,12 @@ export interface UseMachineDetailUpdatesDeps {
updatePieceApi: (id: string, data: any) => Promise<unknown> updatePieceApi: (id: string, data: any) => Promise<unknown>
apiPatch: (endpoint: string, data?: unknown) => Promise<any> apiPatch: (endpoint: string, data?: unknown) => Promise<any>
toast: { showInfo: (msg: string) => void } toast: { showInfo: (msg: string) => void }
syncLinks: (
entityType: 'machine' | 'piece' | 'composant' | 'product',
entityId: string,
originalLinks: ConstructeurLinkEntry[],
formLinks: ConstructeurLinkEntry[],
) => Promise<void>
} }
export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) { export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
@@ -44,6 +53,8 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
machineReference, machineReference,
machineSiteId, machineSiteId,
machineConstructeurIds, machineConstructeurIds,
constructeurLinks,
originalConstructeurLinks,
machineComponentLinks, machineComponentLinks,
machinePieceLinks, machinePieceLinks,
applyMachineLinks, applyMachineLinks,
@@ -56,19 +67,16 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
updatePieceApi, updatePieceApi,
apiPatch, apiPatch,
toast, toast,
syncLinks,
} = deps } = deps
const updateMachineInfo = async () => { const updateMachineInfo = async () => {
if (!machine.value) return if (!machine.value) return
try { try {
const cIds = uniqueConstructeurIds(machineConstructeurIds.value)
machineConstructeurIds.value = cIds
const result: any = await updateMachineApi(machine.value.id as string, { const result: any = await updateMachineApi(machine.value.id as string, {
name: machineName.value, name: machineName.value,
reference: machineReference.value, reference: machineReference.value,
siteId: machineSiteId.value || undefined, siteId: machineSiteId.value || undefined,
constructeurIds: cIds,
} as any) } as any)
if (result.success) { if (result.success) {
const machinePayload = const machinePayload =
@@ -82,11 +90,6 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
documents: machinePayload.documents || machine.value.documents || [], documents: machinePayload.documents || machine.value.documents || [],
customFieldValues: machinePayload.customFieldValues || machine.value.customFieldValues || [], customFieldValues: machinePayload.customFieldValues || machine.value.customFieldValues || [],
} }
machineConstructeurIds.value = uniqueConstructeurIds(
machine.value!.constructeurIds,
machine.value!.constructeurs,
machine.value!.constructeur,
)
const linksApplied = applyMachineLinks(result.data) const linksApplied = applyMachineLinks(result.data)
if (linksApplied && machine.value) { if (linksApplied && machine.value) {
machine.value.componentLinks = machineComponentLinks.value machine.value.componentLinks = machineComponentLinks.value
@@ -95,6 +98,9 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
loadProductDocuments().catch(() => {}) loadProductDocuments().catch(() => {})
} }
} }
// Sync constructeur links after entity save
await syncLinks('machine', machine.value!.id as string, originalConstructeurLinks.value, constructeurLinks.value)
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
} catch (error) { } catch (error) {
console.error('Erreur lors de la mise à jour de la machine:', error) console.error('Erreur lors de la mise à jour de la machine:', error)
} }
@@ -208,9 +214,14 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
} }
} }
const handleMachineConstructeurChange = async (value: unknown) => { const handleMachineConstructeurChange = (value: unknown) => {
machineConstructeurIds.value = uniqueConstructeurIds(value) const newIds = uniqueConstructeurIds(value)
await updateMachineInfo() machineConstructeurIds.value = newIds
// Sync constructeurLinks: keep existing entries, add new ones
const existingMap = new Map(constructeurLinks.value.map(l => [l.constructeurId, l]))
constructeurLinks.value = newIds.map(id =>
existingMap.get(id) ?? { constructeurId: id, supplierReference: null },
)
} }
const editComponent = () => { const editComponent = () => {

View File

@@ -273,6 +273,7 @@ export const buildMachineHierarchyFromLinks = (
originalComposant: originalComponent, originalComposant: originalComponent,
machineComponentLink: link, machineComponentLink: link,
machineComponentLinkId, machineComponentLinkId,
linkId: machineComponentLinkId,
componentLinkId: machineComponentLinkId, componentLinkId: machineComponentLinkId,
parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedComponent.parentComponentLinkId), parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedComponent.parentComponentLinkId),
parentComposantId: resolveIdentifier(appliedComponent.parentComposantId, link.parentComponentId), parentComposantId: resolveIdentifier(appliedComponent.parentComposantId, link.parentComponentId),

View File

@@ -1,7 +1,6 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useToast } from './useToast' import { useToast } from './useToast'
import { useApi, type ApiResponse } from './useApi' import { useApi, type ApiResponse } from './useApi'
import { buildConstructeurRequestPayload } from '~/shared/constructeurUtils'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations' import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers' import { extractCollection } from '~/shared/utils/apiHelpers'
@@ -92,7 +91,7 @@ export function useMachines() {
const createMachine = async (machineData: Partial<Machine>): Promise<ApiResponse> => { const createMachine = async (machineData: Partial<Machine>): Promise<ApiResponse> => {
loading.value = true loading.value = true
try { try {
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(machineData)) const normalizedPayload = normalizeRelationIds(machineData as Record<string, unknown>)
const result = await post('/machines', normalizedPayload) const result = await post('/machines', normalizedPayload)
if (result.success) { if (result.success) {
const createdMachine = normalizeMachineResponse(result.data) || const createdMachine = normalizeMachineResponse(result.data) ||
@@ -116,7 +115,7 @@ export function useMachines() {
const updateMachineData = async (id: string, machineData: Partial<Machine>): Promise<ApiResponse> => { const updateMachineData = async (id: string, machineData: Partial<Machine>): Promise<ApiResponse> => {
loading.value = true loading.value = true
try { try {
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(machineData)) const normalizedPayload = normalizeRelationIds(machineData as Record<string, unknown>)
const result = await patch(`/machines/${id}`, normalizedPayload) const result = await patch(`/machines/${id}`, normalizedPayload)
if (result.success) { if (result.success) {
const updatedMachine = normalizeMachineResponse(result.data) || const updatedMachine = normalizeMachineResponse(result.data) ||

View File

@@ -7,11 +7,13 @@ import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs' import { useConstructeurs } from '~/composables/useConstructeurs'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { usePieceHistory } from '~/composables/usePieceHistory' import { usePieceHistory } from '~/composables/usePieceHistory'
import { extractRelationId } from '~/shared/apiRelations' import { extractRelationId } from '~/shared/apiRelations'
import { canPreviewDocument } from '~/utils/documentPreview' import { canPreviewDocument } from '~/utils/documentPreview'
import { formatPieceStructurePreview } from '~/shared/modelUtils' import { formatPieceStructurePreview } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils' import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
import type { PieceModelStructure } from '~/shared/types/inventory' import type { PieceModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes' import type { ModelType } from '~/services/modelTypes'
import { import {
@@ -46,6 +48,7 @@ export function usePieceEdit(pieceId: string) {
const toast = useToast() const toast = useToast()
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments() const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
const { ensureConstructeurs } = useConstructeurs() const { ensureConstructeurs } = useConstructeurs()
const { fetchLinks, syncLinks } = useConstructeurLinks()
const { const {
history, history,
loading: historyLoading, loading: historyLoading,
@@ -82,6 +85,9 @@ export function usePieceEdit(pieceId: string) {
constructeurIds: [] as string[], constructeurIds: [] as string[],
prix: '' as string, prix: '' as string,
}) })
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
const productSelections = ref<(string | null)[]>([]) const productSelections = ref<(string | null)[]>([])
const customFieldInputs = ref<CustomFieldInput[]>([]) const customFieldInputs = ref<CustomFieldInput[]>([])
@@ -303,15 +309,16 @@ export function usePieceEdit(pieceId: string) {
editionForm.name = currentPiece.name || '' editionForm.name = currentPiece.name || ''
editionForm.description = currentPiece.description || '' editionForm.description = currentPiece.description || ''
editionForm.reference = currentPiece.reference || '' editionForm.reference = currentPiece.reference || ''
editionForm.constructeurIds = uniqueConstructeurIds( // Load constructeur links
currentPiece, fetchLinks('piece', pieceId).then((links) => {
Array.isArray(currentPiece.constructeurs) ? currentPiece.constructeurs : [], constructeurLinks.value = links
currentPiece.constructeur ? [currentPiece.constructeur] : [], originalConstructeurLinks.value = links.map(l => ({ ...l }))
) editionForm.constructeurIds = constructeurIdsFromLinks(links)
if (editionForm.constructeurIds.length) {
void ensureConstructeurs(editionForm.constructeurIds)
}
})
editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : '' editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : ''
if (editionForm.constructeurIds.length) {
void ensureConstructeurs(editionForm.constructeurIds)
}
const existingProductIds = Array.isArray(currentPiece.productIds) && currentPiece.productIds.length const existingProductIds = Array.isArray(currentPiece.productIds) && currentPiece.productIds.length
? currentPiece.productIds.map((id: unknown) => String(id)) ? currentPiece.productIds.map((id: unknown) => String(id))
@@ -370,12 +377,9 @@ export function usePieceEdit(pieceId: string) {
? '' ? ''
: String(editionForm.prix).trim() : String(editionForm.prix).trim()
const constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
const payload: Record<string, any> = { const payload: Record<string, any> = {
name: editionForm.name.trim(), name: editionForm.name.trim(),
description: editionForm.description.trim() || null, description: editionForm.description.trim() || null,
constructeurIds,
} }
const reference = editionForm.reference.trim() const reference = editionForm.reference.trim()
@@ -408,11 +412,13 @@ export function usePieceEdit(pieceId: string) {
'piece', 'piece',
updatedPiece.id, updatedPiece.id,
[ [
updatedPiece?.typePiece?.pieceCustomFields, updatedPiece?.typePiece?.structure?.customFields,
], ],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast }, { customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
) )
await router.push('/pieces-catalog') await syncLinks('piece', piece.value.id, originalConstructeurLinks.value, constructeurLinks.value)
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
toast.showSuccess('Pièce mise à jour avec succès.')
} }
} }
catch (error: any) { catch (error: any) {
@@ -441,6 +447,9 @@ export function usePieceEdit(pieceId: string) {
previewVisible, previewVisible,
selectedTypeId, selectedTypeId,
editionForm, editionForm,
constructeurLinks,
originalConstructeurLinks,
constructeurIdsFromForm,
productSelections, productSelections,
customFieldInputs, customFieldInputs,
canEdit, canEdit,
@@ -467,6 +476,7 @@ export function usePieceEdit(pieceId: string) {
handleFilesAdded, handleFilesAdded,
setProductSelection, setProductSelection,
submitEdition, submitEdition,
fetchPiece,
formatPieceStructurePreview, formatPieceStructurePreview,
} }
} }

View File

@@ -81,6 +81,12 @@ const toEditorField = (
type: baseType as PieceModelCustomFieldType, type: baseType as PieceModelCustomFieldType,
required: Boolean(input?.required), required: Boolean(input?.required),
optionsText, optionsText,
defaultValue:
input?.defaultValue !== undefined && input.defaultValue !== null && input.defaultValue !== ''
? String(input.defaultValue)
: null,
...(typeof input?.id === 'string' && input.id ? { id: input.id } : {}),
...(typeof input?.customFieldId === 'string' && input.customFieldId ? { customFieldId: input.customFieldId } : {}),
orderIndex: typeof input?.orderIndex === 'number' ? input.orderIndex : index, orderIndex: typeof input?.orderIndex === 'number' ? input.orderIndex : index,
} }
} }
@@ -158,6 +164,16 @@ const buildPayload = (
orderIndex: index, orderIndex: index,
} }
if (field.id) {
payload.id = field.id
}
if (field.customFieldId) {
payload.customFieldId = field.customFieldId
}
if (field.defaultValue !== undefined && field.defaultValue !== null && field.defaultValue !== '') {
payload.defaultValue = String(field.defaultValue)
}
if (type === 'select') { if (type === 'select') {
const options = normalizeLineEndings(field.optionsText) const options = normalizeLineEndings(field.optionsText)
.split('\n') .split('\n')

View File

@@ -1,7 +1,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useToast } from './useToast' import { useToast } from './useToast'
import { useApi } from './useApi' import { useApi } from './useApi'
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils' import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { useConstructeurs, type Constructeur } from './useConstructeurs' import { useConstructeurs, type Constructeur } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations' import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers' import { extractCollection } from '~/shared/utils/apiHelpers'
@@ -10,6 +10,7 @@ export interface Piece {
id: string id: string
name: string name: string
reference?: string | null reference?: string | null
referenceAuto?: string | null
description?: string | null description?: string | null
typePieceId?: string | null typePieceId?: string | null
typePiece?: { id: string; name?: string } | null typePiece?: { id: string; name?: string } | null
@@ -43,6 +44,7 @@ interface LoadPiecesOptions {
orderBy?: string orderBy?: string
orderDir?: 'asc' | 'desc' orderDir?: 'asc' | 'desc'
typeName?: string typeName?: string
typePieceId?: string
force?: boolean force?: boolean
} }
@@ -119,17 +121,20 @@ export function usePieces() {
orderBy = 'name', orderBy = 'name',
orderDir = 'asc', orderDir = 'asc',
typeName, typeName,
typePieceId,
force = false, force = false,
} = options } = options
if (!force && loaded.value && !search && !typeName && page === 1) { // Only use cache for unfiltered full-catalog loads
if (!force && loaded.value && !search && !typeName && !typePieceId && page === 1) {
return { return {
success: true, success: true,
data: { items: pieces.value, total: total.value, page, itemsPerPage }, data: { items: pieces.value, total: total.value, page, itemsPerPage },
} }
} }
if (loading.value) { // For filtered queries, don't block on global loading state
if (!typePieceId && loading.value) {
return { return {
success: true, success: true,
data: { items: pieces.value, total: total.value, page, itemsPerPage }, data: { items: pieces.value, total: total.value, page, itemsPerPage },
@@ -138,33 +143,42 @@ export function usePieces() {
loading.value = true loading.value = true
try { try {
const params = new URLSearchParams() const params = new URLSearchParams()
params.set('itemsPerPage', String(itemsPerPage)) params.set('itemsPerPage', String(itemsPerPage))
params.set('page', String(page)) params.set('page', String(page))
if (search && search.trim()) { if (search && search.trim()) {
params.set('name', search.trim()) params.set('search', search.trim())
} }
if (typeName && typeName.trim()) { if (typeName && typeName.trim()) {
params.set('typePiece.name', typeName.trim()) params.set('typePiece.name', typeName.trim())
} }
if (typePieceId) {
params.set('typePiece', typePieceId)
}
params.set(`order[${orderBy}]`, orderDir) params.set(`order[${orderBy}]`, orderDir)
const result = await get(`/pieces?${params.toString()}`) const result = await get(`/pieces?${params.toString()}`)
if (result.success) { if (result.success) {
const items = extractCollection(result.data) const items = extractCollection(result.data)
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item))) const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
pieces.value = enrichedItems const resultTotal = extractTotal(result.data, items.length)
total.value = extractTotal(result.data, items.length)
loaded.value = true // Only update global cache for unfiltered queries
if (!typePieceId) {
pieces.value = enrichedItems
total.value = resultTotal
loaded.value = true
}
return { return {
success: true, success: true,
data: { data: {
items: enrichedItems, items: enrichedItems,
total: total.value, total: resultTotal,
page, page,
itemsPerPage, itemsPerPage,
}, },
@@ -182,7 +196,8 @@ export function usePieces() {
const createPiece = async (pieceData: Partial<Piece>): Promise<PieceSingleResult> => { const createPiece = async (pieceData: Partial<Piece>): Promise<PieceSingleResult> => {
loading.value = true loading.value = true
try { try {
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(pieceData)) const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = pieceData as any
const normalizedPayload = normalizeRelationIds(cleanPayload)
const result = await post('/pieces', normalizedPayload) const result = await post('/pieces', normalizedPayload)
if (result.success && result.data) { if (result.success && result.data) {
const enriched = await withResolvedConstructeurs(result.data as Piece) const enriched = await withResolvedConstructeurs(result.data as Piece)
@@ -209,7 +224,8 @@ export function usePieces() {
const updatePieceData = async (id: string, pieceData: Partial<Piece>): Promise<PieceSingleResult> => { const updatePieceData = async (id: string, pieceData: Partial<Piece>): Promise<PieceSingleResult> => {
loading.value = true loading.value = true
try { try {
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(pieceData)) const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = pieceData as any
const normalizedPayload = normalizeRelationIds(cleanPayload)
const result = await patch(`/pieces/${id}`, normalizedPayload) const result = await patch(`/pieces/${id}`, normalizedPayload)
if (result.success && result.data) { if (result.success && result.data) {
const updated = await withResolvedConstructeurs(result.data as Piece) const updated = await withResolvedConstructeurs(result.data as Piece)

View File

@@ -2,7 +2,7 @@ import { ref } from 'vue'
import { useToast } from './useToast' import { useToast } from './useToast'
import { useApi } from './useApi' import { useApi } from './useApi'
import { humanizeError } from '~/shared/utils/errorMessages' import { humanizeError } from '~/shared/utils/errorMessages'
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils' import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { useConstructeurs, type Constructeur } from './useConstructeurs' import { useConstructeurs, type Constructeur } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations' import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers' import { extractCollection } from '~/shared/utils/apiHelpers'
@@ -41,6 +41,7 @@ interface LoadProductsOptions {
orderBy?: string orderBy?: string
orderDir?: 'asc' | 'desc' orderDir?: 'asc' | 'desc'
typeName?: string typeName?: string
typeProductId?: string
force?: boolean force?: boolean
} }
@@ -118,17 +119,18 @@ export function useProducts() {
orderBy = 'name', orderBy = 'name',
orderDir = 'asc', orderDir = 'asc',
typeName, typeName,
typeProductId,
force = false, force = false,
} = options } = options
if (!force && loaded.value && !search && !typeName && page === 1) { if (!force && loaded.value && !search && !typeName && !typeProductId && page === 1) {
return { return {
success: true, success: true,
data: { items: products.value, total: total.value, page, itemsPerPage }, data: { items: products.value, total: total.value, page, itemsPerPage },
} }
} }
if (loading.value) { if (!typeProductId && loading.value) {
return { return {
success: true, success: true,
data: { items: products.value, total: total.value, page, itemsPerPage }, data: { items: products.value, total: total.value, page, itemsPerPage },
@@ -143,27 +145,36 @@ export function useProducts() {
params.set('page', String(page)) params.set('page', String(page))
if (search && search.trim()) { if (search && search.trim()) {
params.set('name', search.trim()) params.set('search', search.trim())
} }
if (typeName && typeName.trim()) { if (typeName && typeName.trim()) {
params.set('typeProduct.name', typeName.trim()) params.set('typeProduct.name', typeName.trim())
} }
if (typeProductId) {
params.set('typeProduct', typeProductId)
}
params.set(`order[${orderBy}]`, orderDir) params.set(`order[${orderBy}]`, orderDir)
const result = await get(`/products?${params.toString()}`) const result = await get(`/products?${params.toString()}`)
if (result.success) { if (result.success) {
const items = extractCollection(result.data) const items = extractCollection(result.data)
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item))) const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
products.value = enrichedItems const resultTotal = extractTotal(result.data, items.length)
total.value = extractTotal(result.data, items.length)
loaded.value = true if (!typeProductId) {
products.value = enrichedItems
total.value = resultTotal
loaded.value = true
}
return { return {
success: true, success: true,
data: { data: {
items: enrichedItems, items: enrichedItems,
total: total.value, total: resultTotal,
page, page,
itemsPerPage, itemsPerPage,
}, },
@@ -185,7 +196,8 @@ export function useProducts() {
} }
const createProduct = async (payload: Partial<Product>): Promise<ProductSingleResult> => { const createProduct = async (payload: Partial<Product>): Promise<ProductSingleResult> => {
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(payload)) const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = payload as any
const normalizedPayload = normalizeRelationIds(cleanPayload)
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
@@ -214,7 +226,8 @@ export function useProducts() {
} }
const updateProduct = async (id: string, payload: Partial<Product>): Promise<ProductSingleResult> => { const updateProduct = async (id: string, payload: Partial<Product>): Promise<ProductSingleResult> => {
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(payload)) const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = payload as any
const normalizedPayload = normalizeRelationIds(cleanPayload)
loading.value = true loading.value = true
error.value = null error.value = null
try { try {

View File

@@ -105,9 +105,9 @@ export function useStructureAssignmentFetch(deps: StructureAssignmentFetchDeps)
definition.typeComposantId || definition.modelId || definition.typeComposant?.id || null definition.typeComposantId || definition.modelId || definition.typeComposant?.id || null
const params = new URLSearchParams() const params = new URLSearchParams()
params.set('itemsPerPage', '50') params.set('itemsPerPage', '200')
if (term.trim()) { if (term.trim()) {
params.set('name', term.trim()) params.set('search', term.trim())
} }
if (requiredTypeId) { if (requiredTypeId) {
params.set('typeComposant', typeIri(requiredTypeId)) params.set('typeComposant', typeIri(requiredTypeId))
@@ -173,9 +173,9 @@ export function useStructureAssignmentFetch(deps: StructureAssignmentFetchDeps)
definition.typePieceId || definition.typePiece?.id || null definition.typePieceId || definition.typePiece?.id || null
const params = new URLSearchParams() const params = new URLSearchParams()
params.set('itemsPerPage', '50') params.set('itemsPerPage', '200')
if (term.trim()) { if (term.trim()) {
params.set('name', term.trim()) params.set('search', term.trim())
} }
if (requiredTypeId) { if (requiredTypeId) {
params.set('typePiece', typeIri(requiredTypeId)) params.set('typePiece', typeIri(requiredTypeId))
@@ -246,9 +246,9 @@ export function useStructureAssignmentFetch(deps: StructureAssignmentFetchDeps)
definition.typeProductId || definition.typeProduct?.id || null definition.typeProductId || definition.typeProduct?.id || null
const params = new URLSearchParams() const params = new URLSearchParams()
params.set('itemsPerPage', '50') params.set('itemsPerPage', '200')
if (term.trim()) { if (term.trim()) {
params.set('name', term.trim()) params.set('search', term.trim())
} }
if (requiredTypeId) { if (requiredTypeId) {
params.set('typeProduct', typeIri(requiredTypeId)) params.set('typeProduct', typeIri(requiredTypeId))
@@ -279,6 +279,11 @@ export function useStructureAssignmentFetch(deps: StructureAssignmentFetchDeps)
if (deps.isRoot()) { if (deps.isRoot()) {
return return
} }
// Only clear if we have loaded options (cache or catalog); skip when options are empty
// because the fetch may not have completed yet.
if (!options.length) {
return
}
const hasMatch = options.some( const hasMatch = options.some(
(component) => component.id === deps.assignment.selectedComponentId, (component) => component.id === deps.assignment.selectedComponentId,
) )
@@ -293,12 +298,18 @@ export function useStructureAssignmentFetch(deps: StructureAssignmentFetchDeps)
() => [deps.pieces, deps.assignment.pieces], () => [deps.pieces, deps.assignment.pieces],
() => { () => {
for (const pieceAssignment of deps.assignment.pieces) { for (const pieceAssignment of deps.assignment.pieces) {
const options = getPieceOptions(pieceAssignment) const hasCachedOptions = !!pieceOptionsByPath.value[pieceAssignment.path]
if ( // Only clear selections when we have loaded options (cached or from catalog).
pieceAssignment.selectedPieceId // When no cache exists, a fetch is about to fire — clearing now would lose
&& !options.some((piece) => piece.id === pieceAssignment.selectedPieceId) // user input before the real option list arrives.
) { if (hasCachedOptions) {
pieceAssignment.selectedPieceId = '' const options = getPieceOptions(pieceAssignment)
if (
pieceAssignment.selectedPieceId
&& !options.some((piece) => piece.id === pieceAssignment.selectedPieceId)
) {
pieceAssignment.selectedPieceId = ''
}
} }
if (!primedPiecePaths.has(pieceAssignment.path) && !pieceOptionsByPath.value[pieceAssignment.path]) { if (!primedPiecePaths.has(pieceAssignment.path) && !pieceOptionsByPath.value[pieceAssignment.path]) {
primedPiecePaths.add(pieceAssignment.path) primedPiecePaths.add(pieceAssignment.path)
@@ -313,12 +324,15 @@ export function useStructureAssignmentFetch(deps: StructureAssignmentFetchDeps)
() => [deps.products, deps.assignment.products], () => [deps.products, deps.assignment.products],
() => { () => {
for (const productAssignment of deps.assignment.products) { for (const productAssignment of deps.assignment.products) {
const options = getProductOptions(productAssignment) const hasCachedOptions = !!productOptionsByPath.value[productAssignment.path]
if ( if (hasCachedOptions) {
productAssignment.selectedProductId const options = getProductOptions(productAssignment)
&& !options.some((product) => product.id === productAssignment.selectedProductId) if (
) { productAssignment.selectedProductId
productAssignment.selectedProductId = '' && !options.some((product) => product.id === productAssignment.selectedProductId)
) {
productAssignment.selectedProductId = ''
}
} }
if (!primedProductPaths.has(productAssignment.path) && !productOptionsByPath.value[productAssignment.path]) { if (!primedProductPaths.has(productAssignment.path) && !productOptionsByPath.value[productAssignment.path]) {
primedProductPaths.add(productAssignment.path) primedProductPaths.add(productAssignment.path)

View File

@@ -1,260 +1,694 @@
<template> <template>
<main class="container mx-auto max-w-4xl px-6 py-10 space-y-8"> <main class="container mx-auto max-w-4xl px-6 py-10 space-y-8">
<header class="space-y-2"> <header class="space-y-2">
<h1 class="text-3xl font-bold text-base-content">Changelog</h1> <h1 class="text-3xl font-bold text-base-content">Changelog</h1>
<p class="text-sm text-base-content/70"> <p class="text-sm text-base-content/70">
Historique des modifications et nouvelles fonctionnalités de l'application. Historique des modifications et nouvelles fonctionnalités de
</p> l'application.
</header> </p>
</header>
<section <section
v-for="release in releases" v-for="release in releases"
:key="release.version" :key="release.version"
class="card border border-base-200 bg-base-100 shadow-sm" class="card border border-base-200 bg-base-100 shadow-sm"
> >
<div class="card-body space-y-3"> <div class="card-body space-y-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<h2 class="text-xl font-bold text-base-content"> <h2 class="text-xl font-bold text-base-content">
{{ release.version }} {{ release.version }}
</h2> </h2>
<span class="badge badge-ghost text-xs">{{ release.date }}</span> <span class="badge badge-ghost text-xs">{{
</div> release.date
}}</span>
</div>
<ul class="space-y-2"> <ul class="space-y-2">
<li <li
v-for="(item, i) in release.changes" v-for="(item, i) in release.changes"
:key="i" :key="i"
class="flex items-start gap-2 text-sm text-base-content/80" class="flex items-start gap-2 text-sm text-base-content/80"
> >
<span <span
class="badge badge-sm mt-0.5 shrink-0" class="badge badge-sm mt-0.5 shrink-0"
:class="badgeClass(item.type)" :class="badgeClass(item.type)"
> >
{{ item.type }} {{ item.type }}
</span> </span>
<span>{{ item.text }}</span> <span>{{ item.text }}</span>
</li> </li>
</ul> </ul>
</div> </div>
</section> </section>
</main> </main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useHead } from '#imports' import { useHead } from "#imports";
useHead({ title: 'Changelog' }) useHead({ title: "Changelog" });
type ChangeType = 'feat' | 'fix' | 'perf' | 'chore' type ChangeType = "feat" | "fix" | "perf" | "chore";
interface Change { interface Change {
type: ChangeType type: ChangeType;
text: string text: string;
} }
interface Release { interface Release {
version: string version: string;
date: string date: string;
changes: Change[] changes: Change[];
} }
const badgeClass = (type: ChangeType) => { const badgeClass = (type: ChangeType) => {
const map: Record<ChangeType, string> = { const map: Record<ChangeType, string> = {
feat: 'badge-primary', feat: "badge-primary",
fix: 'badge-error', fix: "badge-error",
perf: 'badge-warning', perf: "badge-warning",
chore: 'badge-ghost', chore: "badge-ghost",
} };
return map[type] ?? 'badge-ghost' return map[type] ?? "badge-ghost";
} };
const releases: Release[] = [ const releases: Release[] = [
{ {
version: 'v1.9.1', version: "v1.9.5",
date: '2026-03-16', date: "2026-03-31",
changes: [ changes: [
{ type: 'feat', text: 'Normalisation JSON tables relationnelles : les structures des composants (pièces, produits, sous-composants) et les squelettes des catégories sont désormais stockés dans des tables dédiées au lieu de colonnes JSON, améliorant la fiabilité et les performances des requêtes' }, {
{ type: 'feat', text: 'Synchronisation des catégories (ModelType Sync) : la modification d\'une catégorie (ajout/suppression de slots ou champs personnalisés) peut être propagée automatiquement à tous les éléments existants de cette catégorie, avec prévisualisation des changements avant application' }, type: "feat",
{ type: 'feat', text: 'Sélection interactive des items dans les slots : sur la page d\'édition d\'un composant, il est maintenant possible de choisir directement la pièce, le produit ou le sous-composant assigné à chaque emplacement du squelette via des sélecteurs avec recherche' }, text: "Référence automatique des pièces et composants : génération d'une référence technique à partir d'une formule configurable sur la catégorie, recalculée automatiquement à chaque modification des champs personnalisés",
{ type: 'feat', text: 'Endpoints PATCH pour les slots composant : modification de la quantité et de l\'item sélectionné sur les slots pièce, produit et sous-composant' }, },
{ type: 'feat', text: 'Table de relation pièce ↔ produit (PieceProductSlot) avec versioning pour le suivi des modifications de structure' }, {
{ type: 'feat', text: 'Gestion des champs personnalisés sur les catégories : synchronisation automatique des définitions de champs (ajout, modification, suppression) lors de la sauvegarde d\'une catégorie' }, type: "feat",
{ type: 'feat', text: 'Suite de tests étendue : 219 tests couvrant les stratégies de synchronisation, le contrôleur de sync et les nouvelles entités' }, text: "Formula builder interactif : sélection des champs disponibles par clic (chips) avec insertion à la position du curseur, aperçu live avec valeurs d'exemple, et calcul automatique des champs requis",
{ type: 'fix', text: 'Correction de l\'affichage des sélections pré-existantes dans les slots : les pièces, produits et sous-composants déjà assignés sont maintenant correctement affichés à l\'ouverture de la page d\'édition (correction du cache catalogue)' }, },
{ type: 'fix', text: 'Fallback position/orderIndex sur index de tableau dans les stratégies de sync pour éviter les erreurs quand le champ est absent' }, {
], type: "feat",
}, text: "Versioning des entités : numéro de version incrémenté automatiquement à chaque modification, avec historique des versions et possibilité de restaurer une version antérieure",
{ },
version: 'v1.9.0', {
date: '2026-03-09', type: "feat",
changes: [ text: "Bouton de sauvegarde unique sur la fiche machine : remplacement des sauvegardes automatiques par un bouton explicite, avec affichage des versions sur les liens composants/pièces/produits",
{ 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' }, version: "v1.9.4",
{ type: 'fix', text: 'Confirmation de suppression avec impact sur le catalogue produits (documents, liaisons machines en cascade)' }, date: "2026-03-25",
{ type: 'fix', text: 'Correction du débordement des dropdowns dans les DataTable' }, changes: [
{ 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: "feat",
{ type: 'chore', text: 'Ajout de DAMA DoctrineTestBundle pour l\'isolation des tests par transaction' }, text: "Pages de consultation détaillées pour les pièces, composants et produits : vue lecture seule avec affichage propre des informations, fournisseurs, champs personnalisés et documents",
], },
}, {
{ type: "feat",
version: 'v1.8.1', text: "Bouton bascule Modifier / Voir détails sur les fiches pièces, composants et produits, identique au fonctionnement de la fiche machine",
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",
{ type: 'feat', text: 'Messages d\'erreur humanisés : les erreurs backend sont traduites en messages compréhensibles pour l\'utilisateur final' }, text: "Bouton « Détails » sur les catalogues pièces, composants et produits pour accéder directement à la vue consultation",
{ 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",
{ type: 'feat', text: 'Simplification de la création de machines : plus besoin de sélectionner un squelette, ajout direct des entités' }, text: "Masquage automatique des champs vides et de la section documents en mode consultation",
{ 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: "feat",
{ type: 'chore', text: 'Suppression des pages squelettes machines (/machine-skeleton, /type) et composants associés' }, text: "Accès direct au mode édition via le paramètre ?edit=true dans l'URL",
], },
}, ],
{ },
version: 'v1.8.0', {
date: '2026-03-03', version: "v1.9.2",
changes: [ date: "2026-03-23",
{ 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' }, changes: [
{ 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",
{ type: 'feat', text: 'Champ description sur les pièces et composants, visible dans les catalogues avec popover au survol' }, 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: '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: "feat",
{ type: 'fix', text: 'Sites : ajout de l\'opération PATCH et correction de la migration de contrainte' }, 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: 'chore', text: 'Réorganisation de la navbar avec nouvelles icônes Lucide' }, },
], {
}, type: "feat",
{ text: "Filtre sites multi-sélection sur le Parc Machines : remplacement du menu déroulant par des cases à cocher permettant de filtrer sur un ou plusieurs sites simultanément",
version: 'v1.7.0', },
date: '2026-03-02', {
changes: [ type: "feat",
{ type: 'feat', text: 'Système de commentaires / tickets : possibilité de laisser des commentaires sur les fiches (machines, pièces, composants, produits, catégories, squelettes) avec résolution par les gestionnaires' }, text: "Tri alphabétique automatique des machines sur le Parc Machines",
{ 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",
{ type: 'feat', text: 'Journal d\'audit étendu : suivi des opérations sur machines, fournisseurs, types de modèles, documents et conversions' }, 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: '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: "feat",
{ type: 'fix', text: 'Prévention des doublons de noms de fournisseurs et de références de pièces (contraintes unique)' }, text: "Quantité sur les slots pièces : ajout d'un champ quantité éditable directement depuis la page d'édition d'un composant",
{ type: 'fix', text: 'Correction de la création de squelettes machines : pagination, duplication, champs personnalisés' }, },
], {
}, type: "feat",
{ text: "Lien rapide vers la catégorie depuis la page d'édition d'un composant",
version: 'v1.6.1', },
date: '2026-02-12', {
changes: [ type: "feat",
{ type: 'feat', text: 'Suivi d\'audit étendu : enregistrement des opérations CRUD sur les machines, fournisseurs, catégories (ModelType) et documents' }, text: "Redirection vers la page d'édition après création d'un composant, d'une pièce ou d'un produit",
{ type: 'feat', text: 'Traçabilité des conversions de catégories dans le journal d\'activité (action « convert » avec direction, nombre et noms des éléments)' }, },
{ type: 'feat', text: 'Endpoint historique machine : GET /api/machines/{id}/history' }, {
], type: "fix",
}, text: "Correction de la suppression de fournisseurs sur les pièces, composants et produits : la suppression est maintenant persistée correctement",
{ },
version: 'v1.6.0', {
date: '2026-02-12', type: "fix",
changes: [ 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: '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: "fix",
{ type: 'chore', text: 'Passage php-cs-fixer sur l\'ensemble des contrôleurs et entités du backend' }, text: "Correction de la perte de données lors de la sauvegarde d'une catégorie (champs personnalisés et structure)",
], },
}, {
{ type: "fix",
version: 'v1.5.0', text: "Correction de la suppression de composants depuis la fiche machine (utilisation du linkId au lieu du composantId)",
date: '2026-02-11', },
changes: [ {
{ type: 'feat', text: 'Page de journal d\'activité globale avec filtres par entité, par acteur et pagination serveur' }, type: "fix",
{ type: 'feat', text: 'Suivi d\'audit : enregistrement des noms de fournisseurs et des modifications de champs personnalisés' }, text: "Amélioration de l'envoi des fournisseurs en PATCH : le tableau est toujours envoyé pour éviter les pertes",
{ 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: "fix",
{ type: 'feat', text: 'Les types de catégories dans les tableaux des catalogues sont maintenant cliquables (lien vers la fiche d\'édition)' }, text: "Filtrage serveur des options dans les sélecteurs de slots au lieu du filtrage client",
{ 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",
{ type: 'fix', text: 'Correction du filtre par rattachement (site, machine, composant, pièce) sur la page documents' }, text: "Page d'édition pièce : rester sur la page après sauvegarde au lieu de rediriger",
{ 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: "fix",
{ type: 'perf', text: 'Réduction des appels API bloquants sur les pages d\'édition' }, text: "Messages d'erreur 409 (conflit) : extraction du champ d'erreur pour un message compréhensible",
], },
}, {
{ type: "perf",
version: 'v1.4.0', text: "Suppression des chargements catalogue redondants sur la page d'édition composant",
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.9.1",
}, date: "2026-03-16",
{ changes: [
version: 'v1.3.0', {
date: '2026-01-28', type: "feat",
changes: [ 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: '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",
{ type: 'feat', text: 'Extraction de 4 modules utilitaires partagés (champs personnalisés, affichage produits, documents, fournisseurs)' }, 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: '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",
{ type: 'feat', text: 'Suite de 54 tests unitaires avec Vitest couvrant les utilitaires et composables' }, 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: 'perf', text: 'Optimisations API : helper extractCollection partagé, invalidation de cache ciblée' }, },
{ type: 'chore', text: 'Migration des composables JavaScript vers TypeScript strict' }, {
{ type: 'chore', text: 'Activation de règles ESLint strictes et suppression de 19 console.log de débogage' }, type: "feat",
], text: "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",
}, },
{ {
version: 'v1.2.0', type: "feat",
date: '2026-01-21', text: "Table de relation pièce ↔ produit (PieceProductSlot) avec versioning pour le suivi des modifications de structure",
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",
{ 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' }, 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: 'Possibilité d\'ajouter des champs personnalisés en mode restreint sur les catégories' }, },
], {
}, 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",
version: 'v1.1.1', },
date: '2026-01-14', {
changes: [ type: "fix",
{ type: 'feat', text: 'Compression automatique des fichiers PDF à l\'upload via qpdf, réduisant l\'espace de stockage' }, 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: 'chore', text: 'Ajout de qpdf dans l\'image Docker pour le support de la compression PDF' }, },
], {
}, 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.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' }, version: "v1.9.0",
{ type: 'chore', text: 'Mise à jour des fixtures avec les données courantes de la base' }, date: "2026-03-09",
], changes: [
}, {
{ type: "feat",
version: 'v1.0.0', text: "Gestion des champs personnalisés sur les machines : ajout, modification et suppression de définitions de champs directement depuis la fiche machine",
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",
{ type: 'feat', text: 'Catalogues composants, pièces et produits avec recherche serveur, tri et pagination' }, text: "Refonte UI globale : amélioration du styling, des layouts et du responsive sur l'ensemble des composants et pages",
{ 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",
{ type: 'feat', text: 'Exigences produit sur les pièces : support de liaisons multiples' }, text: "Suite de tests API complète : 167 tests couvrant toutes les entités, la sécurité et les validations",
{ 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",
{ type: 'feat', text: 'Formatage automatique des contacts et des montants en format français' }, text: "Endpoint /api/health pour le monitoring applicatif",
{ type: 'feat', text: 'Protection contre les suppressions : affichage des dépendances bloquantes avant confirmation' }, },
{ type: 'chore', text: 'Infrastructure Docker complète avec PostgreSQL, PHP 8.4, API Platform et pgAdmin' }, {
], type: "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> </script>

View File

@@ -73,7 +73,10 @@
</template> </template>
<template #cell-content="{ row }"> <template #cell-content="{ row }">
<span class="line-clamp-2 text-sm">{{ row.content }}</span> <div class="tooltip tooltip-top max-w-xs" :data-tip="row.content">
<span class="line-clamp-2 text-sm text-left">{{ row.content }}</span>
</div>
<CommentDocumentList :documents="getDocuments(row)" />
</template> </template>
<template #cell-entityType="{ row }"> <template #cell-entityType="{ row }">
@@ -132,7 +135,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, type Ref } from 'vue' import { ref, computed, onMounted, type Ref } from 'vue'
import DataTable from '~/components/common/DataTable.vue' import DataTable from '~/components/common/DataTable.vue'
import { useComments, type Comment } from '~/composables/useComments' import { useComments, type Comment, type CommentDocument } from '~/composables/useComments'
import CommentDocumentList from '~/components/CommentDocumentList.vue'
import { usePermissions } from '~/composables/usePermissions' import { usePermissions } from '~/composables/usePermissions'
import { useDataTable } from '~/composables/useDataTable' import { useDataTable } from '~/composables/useDataTable'
import IconLucideCheck from '~icons/lucide/check' import IconLucideCheck from '~icons/lucide/check'
@@ -148,6 +152,9 @@ const comments = ref<Comment[]>([])
const total = ref(0) const total = ref(0)
const loadingList = ref(true) const loadingList = ref(true)
const getDocuments = (comment: Comment): CommentDocument[] =>
comment.documents?.filter((d): d is CommentDocument => typeof d === 'object' && d !== null && 'id' in d) ?? []
const table = useDataTable( const table = useDataTable(
{ fetchData: loadComments }, { fetchData: loadComments },
{ {
@@ -159,6 +166,7 @@ const table = useDataTable(
status: { default: 'open' }, status: { default: 'open' },
entityType: { default: '' }, entityType: { default: '' },
}, },
columnFilterKeys: ['entity'],
}, },
) )
@@ -236,9 +244,9 @@ const handleResolve = async (commentId: string) => {
const ENTITY_ROUTE_MAP: Record<string, (id: string) => string> = { const ENTITY_ROUTE_MAP: Record<string, (id: string) => string> = {
machine: (id: string) => `/machine/${id}`, machine: (id: string) => `/machine/${id}`,
piece: (id: string) => `/pieces/${id}/edit`, piece: (id: string) => `/piece/${id}`,
composant: (id: string) => `/component/${id}/edit`, composant: (id: string) => `/component/${id}`,
product: (id: string) => `/product/${id}/edit`, product: (id: string) => `/product/${id}`,
piece_category: (id: string) => `/piece-category/${id}/edit`, piece_category: (id: string) => `/piece-category/${id}/edit`,
component_category: (id: string) => `/component-category/${id}/edit`, component_category: (id: string) => `/component-category/${id}/edit`,
product_category: (id: string) => `/product-category/${id}/edit`, product_category: (id: string) => `/product-category/${id}/edit`,

View File

@@ -96,12 +96,14 @@
<template #cell-actions="{ row }"> <template #cell-actions="{ row }">
<div class="flex items-center justify-end gap-2"> <div class="flex items-center justify-end gap-2">
<NuxtLink <button
:to="`/component/${row.component.id}/edit`" v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs" class="btn btn-ghost btn-xs"
@click="navigateTo(`/component/${row.component.id}?edit=true`)"
> >
Modifier Modifier
</NuxtLink> </button>
<button <button
v-if="canEdit" v-if="canEdit"
type="button" type="button"
@@ -111,6 +113,12 @@
> >
Supprimer Supprimer
</button> </button>
<NuxtLink
:to="`/component/${row.component.id}`"
class="btn btn-primary btn-xs"
>
Détails
</NuxtLink>
</div> </div>
</template> </template>
</DataTable> </DataTable>
@@ -136,7 +144,7 @@ const { componentTypes, loadComponentTypes } = useComponentTypes()
const table = useDataTable( const table = useDataTable(
{ fetchData: fetchComposants }, { fetchData: fetchComposants },
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true }, { defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typeComposant'] },
) )
const columns = [ const columns = [

View File

@@ -114,6 +114,8 @@ const loadCategory = async () => {
category: response.category, category: response.category,
notes: response.notes ?? response.description ?? '', notes: response.notes ?? response.description ?? '',
structure: (response.structure as ComponentModelStructure | null) ?? undefined, structure: (response.structure as ComponentModelStructure | null) ?? undefined,
referenceFormula: response.referenceFormula ?? null,
requiredFieldsForReference: response.requiredFieldsForReference ?? null,
} }
} catch (error) { } catch (error) {
@@ -158,7 +160,6 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false }) await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
await loadComponentTypes({ force: true }) await loadComponentTypes({ force: true })
showSuccess('Catégorie de composant mise à jour avec succès.') showSuccess('Catégorie de composant mise à jour avec succès.')
await navigateBackToList()
} }
} catch (error) { } catch (error) {
showError(normalizeError(error)) showError(normalizeError(error))
@@ -183,7 +184,6 @@ const handleSyncConfirm = async () => {
}) })
await loadComponentTypes({ force: true }) await loadComponentTypes({ force: true })
showSuccess('Catégorie de composant mise à jour avec succès.') showSuccess('Catégorie de composant mise à jour avec succès.')
await navigateBackToList()
} catch (error) { } catch (error) {
showError(normalizeError(error)) showError(normalizeError(error))
} finally { } finally {

View File

@@ -6,6 +6,12 @@
:documents="componentDocuments" :documents="componentDocuments"
@close="closePreview" @close="closePreview"
/> />
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<main class="container mx-auto px-6 py-10"> <main class="container mx-auto px-6 py-10">
<div v-if="loading" class="flex flex-col items-center gap-4 py-20 text-center"> <div v-if="loading" class="flex flex-col items-center gap-4 py-20 text-center">
<span class="loading loading-spinner loading-lg" aria-hidden="true" /> <span class="loading loading-spinner loading-lg" aria-hidden="true" />
@@ -45,20 +51,33 @@
<label class="label"> <label class="label">
<span class="label-text">Catégorie de composant</span> <span class="label-text">Catégorie de composant</span>
</label> </label>
<select <div class="flex items-center gap-2">
v-model="selectedTypeId" <select
class="select select-bordered select-sm md:select-md" v-model="selectedTypeId"
disabled class="select select-bordered select-sm md:select-md flex-1"
> disabled
<option value="">Sélectionner une catégorie</option>
<option
v-for="type in componentTypeList"
:key="type.id"
:value="type.id"
> >
{{ type.name }} <option value="">Sélectionner une catégorie</option>
</option> <option
</select> v-for="type in componentTypeList"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
<NuxtLink
v-if="selectedTypeId"
:to="`/component-category/${selectedTypeId}/edit`"
class="btn btn-ghost btn-sm"
title="Voir la catégorie"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
</svg>
</NuxtLink>
</div>
<p class="text-xs text-base-content/60 mt-1"> <p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page. La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p> </p>
@@ -122,6 +141,11 @@
</div> </div>
</div> </div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
@@ -173,12 +197,27 @@
<label class="label"> <label class="label">
<span class="label-text text-xs font-medium">{{ slot.label }}</span> <span class="label-text text-xs font-medium">{{ slot.label }}</span>
</label> </label>
<PieceSelect <div class="flex items-start gap-2">
:model-value="slot.selectedPieceId" <div class="flex-1">
:disabled="!canEdit || saving" <PieceSelect
:type-piece-id="slot.typePieceId" :model-value="slot.selectedPieceId"
@update:model-value="(value) => savePieceSlotSelection(slot.slotId, value)" :disabled="!canEdit || saving"
/> :type-piece-id="slot.typePieceId"
@update:model-value="(value) => setPieceSlotSelection(slot.slotId, value)"
/>
</div>
<div class="w-20 shrink-0">
<input
type="number"
:value="slot.quantity"
min="1"
class="input input-bordered input-sm w-full text-center"
:disabled="!canEdit || saving"
title="Quantité"
@change="(e) => setSlotQuantity(slot.slotId, Number((e.target as HTMLInputElement).value))"
>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -198,7 +237,7 @@
:model-value="slot.selectedProductId" :model-value="slot.selectedProductId"
:disabled="!canEdit || saving" :disabled="!canEdit || saving"
:type-product-id="slot.typeProductId" :type-product-id="slot.typeProductId"
@update:model-value="(value) => saveProductSlotSelection(slot.slotId, value)" @update:model-value="(value) => setProductSlotSelection(slot.slotId, value)"
/> />
</div> </div>
</div> </div>
@@ -219,7 +258,7 @@
:model-value="slot.selectedComponentId" :model-value="slot.selectedComponentId"
:disabled="!canEdit || saving" :disabled="!canEdit || saving"
:type-composant-id="slot.typeComposantId" :type-composant-id="slot.typeComposantId"
@update:model-value="(value) => saveSubcomponentSlotSelection(slot.slotId, value)" @update:model-value="(value) => setSubcomponentSlotSelection(slot.slotId, value)"
/> />
</div> </div>
</div> </div>
@@ -266,9 +305,11 @@
v-else v-else
:documents="componentDocuments" :documents="componentDocuments"
:can-delete="canEdit" :can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments" :delete-disabled="uploadingDocuments"
empty-text="Aucun document n'est associé à ce composant pour le moment." empty-text="Aucun document n'est associé à ce composant pour le moment."
@preview="openPreview" @preview="openPreview"
@edit="openEditModal"
@delete="removeDocument" @delete="removeDocument"
/> />
</div> </div>
@@ -280,11 +321,19 @@
:field-labels="historyFieldLabels" :field-labels="historyFieldLabels"
/> />
<EntityVersionList
entity-type="composant"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="fetchComponent()"
/>
<div class="flex flex-col gap-3 md:flex-row md:justify-end"> <div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }"> <NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
Annuler Annuler
</NuxtLink> </NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitEdition"> <button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="async () => { await submitEdition(); versionRefreshKey++ }">
<span v-if="saving" class="loading loading-spinner loading-sm mr-2" /> <span v-if="saving" class="loading loading-spinner loading-sm mr-2" />
Enregistrer les modifications Enregistrer les modifications
</button> </button>
@@ -306,10 +355,16 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'
import { useRoute } from '#imports' import { useRoute } from '#imports'
import { useComponentEdit } from '~/composables/useComponentEdit' import { useComponentEdit } from '~/composables/useComponentEdit'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
const route = useRoute() const route = useRoute()
const { updateDocument } = useDocuments()
const { getConstructeurById } = useConstructeurs()
const versionRefreshKey = ref(0)
const { const {
component, component,
@@ -323,6 +378,8 @@ const {
previewVisible, previewVisible,
selectedTypeId, selectedTypeId,
editionForm, editionForm,
constructeurLinks,
constructeurIdsFromForm,
customFieldInputs, customFieldInputs,
historyFieldLabels, historyFieldLabels,
canEdit, canEdit,
@@ -342,13 +399,54 @@ const {
removeDocument, removeDocument,
handleFilesAdded, handleFilesAdded,
submitEdition, submitEdition,
saveSlotQuantity, setSlotQuantity,
savePieceSlotSelection, setPieceSlotSelection,
saveProductSlotSelection, setProductSlotSelection,
saveSubcomponentSlotSelection, setSubcomponentSlotSelection,
resolvePieceLabel, resolvePieceLabel,
resolveProductLabel, resolveProductLabel,
resolveSubcomponentLabel, resolveSubcomponentLabel,
formatStructurePreview, formatStructurePreview,
fetchComponent,
} = useComponentEdit(String(route.params.id)) } = useComponentEdit(String(route.params.id))
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
watch(
() => editionForm.constructeurIds,
(ids) => {
const currentIds = new Set(constructeurLinks.value.map(l => l.constructeurId))
for (const id of ids) {
if (!currentIds.has(id)) {
const resolved = getConstructeurById(id)
constructeurLinks.value.push({
constructeurId: id,
constructeur: resolved ? { id: resolved.id, name: resolved.name } : null,
supplierReference: null,
})
}
}
// Remove links whose ID was removed from the select
constructeurLinks.value = constructeurLinks.value.filter(l => ids.includes(l.constructeurId))
},
)
const editingDocument = ref<any | null>(null)
const editModalVisible = ref(false)
const openEditModal = (doc: any) => {
editingDocument.value = doc
editModalVisible.value = true
}
const handleDocumentUpdated = async (data: { name?: string; type?: string }) => {
if (!editingDocument.value?.id) return
const result = await updateDocument(editingDocument.value.id, data)
if (result.success) {
const idx = componentDocuments.value.findIndex((d: any) => d.id === editingDocument.value?.id)
if (idx !== -1) {
componentDocuments.value[idx] = { ...componentDocuments.value[idx], ...data }
}
}
editModalVisible.value = false
editingDocument.value = null
}
</script> </script>

View File

@@ -0,0 +1,558 @@
<template>
<div>
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="componentDocuments"
@close="closePreview"
/>
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<main class="container mx-auto px-6 py-10">
<div v-if="loading" class="flex flex-col items-center gap-4 py-20 text-center">
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
<p class="text-sm text-base-content/70">Chargement du composant</p>
</div>
<div v-else-if="!component" class="max-w-xl mx-auto">
<div class="alert alert-error shadow-lg">
<div>
<h2 class="font-semibold text-lg">Composant introuvable</h2>
<p class="text-sm text-base-content/80">
Nous n'avons pas pu retrouver le composant demandé. Il a peut-être été supprimé.
</p>
</div>
</div>
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
Retour au catalogue
</button>
</div>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
<div class="card-body space-y-6">
<DetailHeader
:title="isEditMode ? 'Modifier le composant' : component.name"
:subtitle="isEditMode ? 'Ajustez les informations du composant et ses champs personnalisés.' : undefined"
:is-edit-mode="isEditMode"
:can-edit="canEdit"
back-link="/component-catalog"
@toggle-edit="isEditMode = !isEditMode"
/>
<!-- Catégorie (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de composant</span>
</label>
<template v-if="isEditMode">
<div class="flex items-center gap-2">
<select
v-model="selectedTypeId"
class="select select-bordered select-sm md:select-md flex-1"
disabled
>
<option value="">Sélectionner une catégorie</option>
<option
v-for="type in componentTypeList"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
<NuxtLink
v-if="selectedTypeId"
:to="`/component-category/${selectedTypeId}/edit`"
class="btn btn-ghost btn-sm"
title="Voir la catégorie"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
</svg>
</NuxtLink>
</div>
<p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p>
</template>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ selectedType?.name || '—' }}
</div>
</div>
</div>
<!-- Nom (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du composant</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue"
required
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ component.name }}
</div>
</div>
</div>
<!-- Description (if value or edit mode) -->
<div v-if="isEditMode || component.description" class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-if="isEditMode"
v-model="editionForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || saving"
placeholder="Description du composant (optionnel)"
rows="3"
/>
<div v-else class="textarea textarea-bordered textarea-sm md:textarea-md bg-base-200">
{{ component.description }}
</div>
</div>
<!-- Référence + Fournisseurs (if value or edit mode) -->
<div
v-if="isEditMode || component.reference || constructeurLinks.length"
class="grid grid-cols-1 gap-4 md:grid-cols-2"
>
<div v-if="isEditMode || component.reference" class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur"
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ component.reference }}
</div>
</div>
<div v-if="isEditMode || constructeurLinks.length" class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-if="isEditMode"
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="component?.constructeurs || []"
/>
</div>
</div>
<!-- Constructeur links table -->
<ConstructeurLinksTable
v-if="isEditMode && constructeurLinks.length"
v-model="constructeurLinks"
/>
<ConstructeurLinksTable
v-else-if="!isEditMode && constructeurLinks.length"
:model-value="constructeurLinks"
:readonly="true"
/>
<!-- Prix (if value or edit mode) -->
<div v-if="isEditMode || component.prix" class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif ()</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Valeur indicatrice"
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ component.prix }}
</div>
</div>
</div>
<!-- Skeleton preview (edit mode only) -->
<StructureSkeletonPreview
v-if="isEditMode && selectedType"
:structure="selectedTypeStructure"
:description="selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.'"
:preview-badge="formatStructurePreview(selectedTypeStructure)"
variant="component"
show-empty-state
:resolve-piece-label="resolvePieceLabel"
:resolve-product-label="resolveProductLabel"
:resolve-subcomponent-label="resolveSubcomponentLabel"
/>
<!-- Skeleton slot selections -->
<div
v-if="pieceSlotEntries.length || productSlotEntries.length || subcomponentSlotEntries.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/30 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">{{ isEditMode ? 'Sélections du squelette' : 'Structure du composant' }}</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Choisissez les pièces, produits et sous-composants pour chaque emplacement requis par la catégorie.' : 'Pièces, produits et sous-composants associés à ce composant.' }}
</p>
</header>
<div v-if="pieceSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Pièces</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="slot in pieceSlotEntries"
:key="`piece-slot-${slot.slotId}`"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
</label>
<template v-if="isEditMode">
<div class="flex items-start gap-2">
<div class="flex-1">
<PieceSelect
:model-value="slot.selectedPieceId"
:disabled="!canEdit || saving"
:type-piece-id="slot.typePieceId"
@update:model-value="(value) => setPieceSlotSelection(slot.slotId, value)"
/>
</div>
<div class="w-20 shrink-0">
<input
type="number"
:value="slot.quantity"
min="1"
class="input input-bordered input-sm w-full text-center"
:disabled="!canEdit || saving"
title="Quantité"
@change="(e) => setSlotQuantity(slot.slotId, Number((e.target as HTMLInputElement).value))"
>
</div>
</div>
</template>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center gap-2">
{{ slot.selectedPieceName || '— Non sélectionné' }}
<span v-if="slot.quantity > 1" class="badge badge-sm">x{{ slot.quantity }}</span>
</div>
</div>
</div>
</div>
<div v-if="productSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Produits</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="slot in productSlotEntries"
:key="`product-slot-${slot.slotId}`"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
</label>
<template v-if="isEditMode">
<ProductSelect
:model-value="slot.selectedProductId"
:disabled="!canEdit || saving"
:type-product-id="slot.typeProductId"
@update:model-value="(value) => setProductSlotSelection(slot.slotId, value)"
/>
</template>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ slot.selectedProductName || '— Non sélectionné' }}
</div>
</div>
</div>
</div>
<div v-if="subcomponentSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="slot in subcomponentSlotEntries"
:key="`sub-slot-${slot.slotId}`"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
</label>
<template v-if="isEditMode">
<ComposantSelect
:model-value="slot.selectedComponentId"
:disabled="!canEdit || saving"
:type-composant-id="slot.typeComposantId"
@update:model-value="(value) => setSubcomponentSlotSelection(slot.slotId, value)"
/>
</template>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ slot.selectedComponentName || '— Non sélectionné' }}
</div>
</div>
</div>
</div>
</div>
<!-- Custom fields -->
<div v-if="visibleCustomFields.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p v-if="isEditMode" class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à ce composant.
</p>
</header>
<template v-if="isEditMode">
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</template>
<template v-else>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="field in visibleCustomFields"
:key="field.customFieldValueId || field.id || field.name"
class="form-control"
>
<label class="label">
<span class="label-text text-sm">{{ field.name }}</span>
</label>
<div class="input input-bordered input-sm bg-base-200 flex items-center">
{{ field.value }}
</div>
</div>
</div>
</template>
</div>
<!-- Documents -->
<div
v-if="isEditMode || componentDocuments.length > 0"
class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Gérez les documents associés à ce composant.' : 'Documents associés à ce composant.' }}
</p>
</div>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<template v-if="isEditMode">
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents"
@files-added="handleFilesAdded"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours…
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="componentDocuments"
:can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document n'est associé à ce composant pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</template>
<template v-else>
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="componentDocuments"
:can-delete="false"
:can-edit="false"
empty-text="Aucun document n'est associé à ce composant pour le moment."
@preview="openPreview"
/>
</template>
</div>
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<!-- Save buttons (edit mode only) -->
<div v-if="isEditMode" class="flex flex-col gap-3 md:flex-row md:justify-end">
<button type="button" class="btn btn-ghost" :class="{ 'btn-disabled': saving }" @click="isEditMode = false">
Annuler
</button>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitEdition">
<span v-if="saving" class="loading loading-spinner loading-sm mr-2" />
Enregistrer les modifications
</button>
</div>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="composant"
:entity-id="String(route.params.id)"
:entity-name="component?.name"
show-resolved
/>
</div>
</div>
</section>
</main>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from '#imports'
import { useComponentEdit } from '~/composables/useComponentEdit'
import { useDocuments } from '~/composables/useDocuments'
import { usePermissions } from '~/composables/usePermissions'
import { useConstructeurs } from '~/composables/useConstructeurs'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
const route = useRoute()
const { canEdit } = usePermissions()
const { getConstructeurById } = useConstructeurs()
const { updateDocument } = useDocuments()
const isEditMode = ref(false)
const {
component,
loading,
saving,
selectedFiles,
uploadingDocuments,
loadingDocuments,
componentDocuments,
previewDocument,
previewVisible,
selectedTypeId,
editionForm,
constructeurLinks,
constructeurIdsFromForm,
customFieldInputs,
historyFieldLabels,
canSubmit,
componentTypeList,
selectedType,
selectedTypeStructure,
pieceSlotEntries,
productSlotEntries,
subcomponentSlotEntries,
history,
historyLoading,
historyError,
openPreview,
closePreview,
removeDocument,
handleFilesAdded,
submitEdition: _submitEdition,
fetchComponent,
setSlotQuantity,
setPieceSlotSelection,
setProductSlotSelection,
setSubcomponentSlotSelection,
resolvePieceLabel,
resolveProductLabel,
resolveSubcomponentLabel,
formatStructurePreview,
} = useComponentEdit(String(route.params.id))
const submitEdition = async () => {
await _submitEdition()
if (!saving.value) {
await fetchComponent()
isEditMode.value = false
}
}
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
watch(
() => editionForm.constructeurIds,
(ids) => {
const currentIds = new Set(constructeurLinks.value.map(l => l.constructeurId))
for (const id of ids) {
if (!currentIds.has(id)) {
const resolved = getConstructeurById(id)
constructeurLinks.value.push({
constructeurId: id,
constructeur: resolved ? { id: resolved.id, name: resolved.name } : null,
supplierReference: null,
})
}
}
// Remove links whose ID was removed from the select
constructeurLinks.value = constructeurLinks.value.filter(l => ids.includes(l.constructeurId))
},
)
const editingDocument = ref<any | null>(null)
const editModalVisible = ref(false)
const visibleCustomFields = computed(() => {
if (isEditMode.value) return customFieldInputs.value
return customFieldInputs.value.filter(
(f) => f.value !== null && f.value !== undefined && f.value !== '',
)
})
const openEditModal = (doc: any) => {
editingDocument.value = doc
editModalVisible.value = true
}
const handleDocumentUpdated = async (data: { name?: string; type?: string }) => {
if (!editingDocument.value?.id) return
const result = await updateDocument(editingDocument.value.id, data)
if (result.success) {
const idx = componentDocuments.value.findIndex((d: any) => d.id === editingDocument.value?.id)
if (idx !== -1) {
componentDocuments.value[idx] = { ...componentDocuments.value[idx], ...data }
}
}
editModalVisible.value = false
editingDocument.value = null
}
onMounted(() => {
if (route.query.edit === 'true' && canEdit.value) {
isEditMode.value = true
}
})
</script>

View File

@@ -92,6 +92,11 @@
</div> </div>
</div> </div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
@@ -215,10 +220,16 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { watch } from 'vue'
import { useConstructeurs } from '~/composables/useConstructeurs'
const { getConstructeurById } = useConstructeurs()
const { const {
selectedTypeId, selectedTypeId,
submitting, submitting,
creationForm, creationForm,
constructeurLinks,
customFieldInputs, customFieldInputs,
structureAssignments, structureAssignments,
selectedDocuments, selectedDocuments,
@@ -249,4 +260,23 @@ const {
resolveSubcomponentLabel, resolveSubcomponentLabel,
submitCreation, submitCreation,
} = useComponentCreate() } = useComponentCreate()
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
watch(
() => creationForm.constructeurIds,
(ids) => {
const currentIds = new Set(constructeurLinks.value.map(l => l.constructeurId))
for (const id of ids) {
if (!currentIds.has(id)) {
const resolved = getConstructeurById(id)
constructeurLinks.value.push({
constructeurId: id,
constructeur: resolved ? { id: resolved.id, name: resolved.name } : null,
supplierReference: null,
})
}
}
constructeurLinks.value = constructeurLinks.value.filter(l => ids.includes(l.constructeurId))
},
)
</script> </script>

View File

@@ -7,6 +7,13 @@
@close="closePreview" @close="closePreview"
/> />
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<section class="card bg-base-100 shadow-sm"> <section class="card bg-base-100 shadow-sm">
<div class="card-body space-y-6"> <div class="card-body space-y-6">
<DataTable <DataTable
@@ -55,6 +62,26 @@
<option value="product">Produits</option> <option value="product">Produits</option>
</select> </select>
</div> </div>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="doc-type-filter"
>
Type
</label>
<select
id="doc-type-filter"
v-model="typeFilter"
class="select select-bordered select-sm"
@change="table.handleFilterChange"
>
<option value="all">Tous</option>
<option v-for="t in DOCUMENT_TYPES" :key="t.value" :value="t.value">
{{ t.label }}
</option>
</select>
</div>
</template> </template>
<template #cell-name="{ row }"> <template #cell-name="{ row }">
@@ -77,6 +104,10 @@
{{ row.mimeType || 'Inconnu' }} {{ row.mimeType || 'Inconnu' }}
</template> </template>
<template #cell-type="{ row }">
<span class="badge badge-sm badge-outline">{{ getDocumentTypeLabel(row.type || 'documentation') }}</span>
</template>
<template #cell-size="{ row }"> <template #cell-size="{ row }">
{{ formatSize(row.size) }} {{ formatSize(row.size) }}
</template> </template>
@@ -98,6 +129,14 @@
<template #cell-actions="{ row }"> <template #cell-actions="{ row }">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button
v-if="canEdit"
class="btn btn-ghost btn-xs"
type="button"
@click="openEditModal(row)"
>
Modifier
</button>
<button <button
class="btn btn-ghost btn-xs" class="btn btn-ghost btn-xs"
type="button" type="button"
@@ -123,12 +162,15 @@ import { computed, onMounted, ref, type Ref } from 'vue'
import DataTable from '~/components/common/DataTable.vue' import DataTable from '~/components/common/DataTable.vue'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { useDataTable } from '~/composables/useDataTable' import { useDataTable } from '~/composables/useDataTable'
import { usePermissions } from '~/composables/usePermissions'
import { getFileIcon } from '~/utils/fileIcons' import { getFileIcon } from '~/utils/fileIcons'
import { canPreviewDocument } from '~/utils/documentPreview' import { canPreviewDocument } from '~/utils/documentPreview'
import { formatFrenchDate } from '~/utils/date' import { formatFrenchDate } from '~/utils/date'
import { DOCUMENT_TYPES, getDocumentTypeLabel } from '~/shared/documentTypes'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue' import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
const { documents, total, loading, loadDocuments } = useDocuments() const { documents, total, loading, loadDocuments, updateDocument } = useDocuments()
const { canEdit } = usePermissions()
const table = useDataTable( const table = useDataTable(
{ fetchData: fetchDocuments }, { fetchData: fetchDocuments },
@@ -139,21 +181,26 @@ const table = useDataTable(
persistToUrl: true, persistToUrl: true,
extraParams: { extraParams: {
filter: { default: 'all' }, filter: { default: 'all' },
typeFilter: { default: 'all' },
}, },
}, },
) )
const attachmentFilter = table.filters.filter as Ref<string> const attachmentFilter = table.filters.filter as Ref<string>
const typeFilter = table.filters.typeFilter as Ref<string>
const previewDocument = ref<any>(null) const previewDocument = ref<any>(null)
const previewVisible = ref(false) const previewVisible = ref(false)
const editingDocument = ref<any>(null)
const editModalVisible = ref(false)
const documentsOnPage = computed(() => documents.value.length) const documentsOnPage = computed(() => documents.value.length)
const paginationState = table.pagination(total, documentsOnPage) const paginationState = table.pagination(total, documentsOnPage)
const columns = [ const columns = [
{ key: 'name', label: 'Nom', sortable: true, sortKey: 'name' }, { key: 'name', label: 'Nom', sortable: true, sortKey: 'name' },
{ key: 'mimeType', label: 'Type' }, { key: 'mimeType', label: 'Type MIME' },
{ key: 'type', label: 'Type' },
{ key: 'size', label: 'Taille', sortable: true, sortKey: 'size' }, { key: 'size', label: 'Taille', sortable: true, sortKey: 'size' },
{ key: 'attachment', label: 'Rattaché à' }, { key: 'attachment', label: 'Rattaché à' },
{ key: 'createdAt', label: 'Date', sortable: true, sortKey: 'createdAt' }, { key: 'createdAt', label: 'Date', sortable: true, sortKey: 'createdAt' },
@@ -168,6 +215,7 @@ async function fetchDocuments() {
orderBy: table.sortField.value, orderBy: table.sortField.value,
orderDir: table.sortDirection.value as 'asc' | 'desc', orderDir: table.sortDirection.value as 'asc' | 'desc',
attachmentFilter: attachmentFilter.value, attachmentFilter: attachmentFilter.value,
type: typeFilter.value,
force: true, force: true,
}) })
} }
@@ -198,6 +246,25 @@ const closePreview = () => {
previewDocument.value = null previewDocument.value = null
} }
const openEditModal = (doc: any) => {
editingDocument.value = doc
editModalVisible.value = true
}
const handleDocumentUpdated = async (data: { name: string; type: string }) => {
if (!editingDocument.value?.id) return
const result = await updateDocument(editingDocument.value.id, data)
if (result.success) {
const doc = documents.value.find((d) => d.id === editingDocument.value.id)
if (doc) {
doc.name = data.name
doc.type = data.type
}
}
editModalVisible.value = false
editingDocument.value = null
}
onMounted(() => { onMounted(() => {
fetchDocuments() fetchDocuments()
}) })

View File

@@ -54,6 +54,7 @@
<!-- Machine Info Card --> <!-- Machine Info Card -->
<MachineInfoCard <MachineInfoCard
ref="machineInfoCardRef"
:is-edit-mode="d.isEditMode.value" :is-edit-mode="d.isEditMode.value"
:machine-name="d.machineName.value" :machine-name="d.machineName.value"
:machine-reference="d.machineReference.value" :machine-reference="d.machineReference.value"
@@ -63,6 +64,7 @@
:machine-constructeur-ids="d.machineConstructeurIds.value" :machine-constructeur-ids="d.machineConstructeurIds.value"
:machine-constructeurs-display="d.machineConstructeursDisplay.value" :machine-constructeurs-display="d.machineConstructeursDisplay.value"
:has-machine-constructeur="d.hasMachineConstructeur.value" :has-machine-constructeur="d.hasMachineConstructeur.value"
:constructeur-links="d.constructeurLinks.value"
:visible-custom-fields="d.visibleMachineCustomFields.value" :visible-custom-fields="d.visibleMachineCustomFields.value"
:get-machine-field-id="d.getMachineFieldId" :get-machine-field-id="d.getMachineFieldId"
:machine-id="machineId" :machine-id="machineId"
@@ -71,14 +73,15 @@
@update:machine-reference="d.machineReference.value = $event" @update:machine-reference="d.machineReference.value = $event"
@update:machine-site-id="d.machineSiteId.value = $event" @update:machine-site-id="d.machineSiteId.value = $event"
@update:constructeur-ids="d.handleMachineConstructeurChange" @update:constructeur-ids="d.handleMachineConstructeurChange"
@blur-field="d.updateMachineInfo" @update:constructeur-links="d.constructeurLinks.value = $event"
@remove-constructeur-link="handleRemoveConstructeurLink"
@set-custom-field-value="d.setMachineCustomFieldValue" @set-custom-field-value="d.setMachineCustomFieldValue"
@update-custom-field="d.updateMachineCustomField" @custom-fields-saved="() => { d.loadMachineData(); refreshVersions() }"
@custom-fields-saved="d.loadMachineData()"
/> />
<!-- Documents --> <!-- Documents -->
<MachineDocumentsCard <MachineDocumentsCard
v-if="d.isEditMode.value || d.machineDocumentsList.value.length > 0"
:documents="d.machineDocumentsList.value" :documents="d.machineDocumentsList.value"
:is-edit-mode="d.isEditMode.value" :is-edit-mode="d.isEditMode.value"
:uploading="d.machineDocumentsUploading.value" :uploading="d.machineDocumentsUploading.value"
@@ -92,14 +95,16 @@
<!-- Produits associés --> <!-- Produits associés -->
<MachineProductsCard <MachineProductsCard
v-if="d.isEditMode.value || d.machineDirectProducts.value.length > 0"
:products="d.machineDirectProducts.value" :products="d.machineDirectProducts.value"
:is-edit-mode="d.isEditMode.value" :is-edit-mode="d.isEditMode.value"
@add-product="openAddModal('product')" @add-product="openAddModal('product')"
@remove-product="d.removeProductLink" @remove-product="async (id) => { await d.removeProductLink(id); refreshVersions() }"
/> />
<!-- Components Section --> <!-- Components Section -->
<MachineComponentsCard <MachineComponentsCard
v-if="d.isEditMode.value || d.components.value.length > 0"
:components="d.components.value" :components="d.components.value"
:is-edit-mode="d.isEditMode.value" :is-edit-mode="d.isEditMode.value"
:collapsed="d.componentsCollapsed.value" :collapsed="d.componentsCollapsed.value"
@@ -109,11 +114,12 @@
@edit-piece="d.updatePieceFromComponent" @edit-piece="d.updatePieceFromComponent"
@custom-field-update="d.updatePieceCustomField" @custom-field-update="d.updatePieceCustomField"
@add-component="openAddModal('component')" @add-component="openAddModal('component')"
@remove-component="d.removeComponentLink" @remove-component="async (id) => { await d.removeComponentLink(id); refreshVersions() }"
/> />
<!-- Machine Pieces Section --> <!-- Machine Pieces Section -->
<MachinePiecesCard <MachinePiecesCard
v-if="d.isEditMode.value || d.machinePieces.value.length > 0"
:pieces="d.machinePieces.value" :pieces="d.machinePieces.value"
:is-edit-mode="d.isEditMode.value" :is-edit-mode="d.isEditMode.value"
:collapsed="d.piecesCollapsed.value" :collapsed="d.piecesCollapsed.value"
@@ -122,7 +128,7 @@
@edit-piece="d.editPiece" @edit-piece="d.editPiece"
@custom-field-update="d.updatePieceCustomField" @custom-field-update="d.updatePieceCustomField"
@add-piece="openAddModal('piece')" @add-piece="openAddModal('piece')"
@remove-piece="d.removePieceLink" @remove-piece="async (id) => { await d.removePieceLink(id); refreshVersions() }"
@toggle-collapse="d.toggleAllPieces" @toggle-collapse="d.toggleAllPieces"
/> />
@@ -134,6 +140,46 @@
@confirm="handleAddEntity" @confirm="handleAddEntity"
/> />
<!-- Save / Cancel buttons -->
<div v-if="d.isEditMode.value" class="flex flex-col gap-3 md:flex-row md:justify-end">
<button
type="button"
class="btn btn-ghost"
:class="{ 'btn-disabled': d.saving.value }"
@click="d.cancelEdition()"
>
Annuler
</button>
<button
type="button"
class="btn btn-primary"
:disabled="!d.canSubmit.value"
@click="submitMachineEdition"
>
<span v-if="d.saving.value" class="loading loading-spinner loading-sm mr-2" />
Enregistrer les modifications
</button>
</div>
<!-- Historique -->
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<!-- Versions -->
<EntityVersionList
ref="versionListRef"
entity-type="machine"
:entity-id="String(machineId)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="d.loadMachineData()"
/>
<!-- Comments --> <!-- Comments -->
<div class="mt-4"> <div class="mt-4">
<CommentSection <CommentSection
@@ -153,9 +199,9 @@
</div> </div>
<h3 class="text-lg font-semibold text-base-content mb-1">Machine non trouvée</h3> <h3 class="text-lg font-semibold text-base-content mb-1">Machine non trouvée</h3>
<p class="text-sm text-base-content/50 mb-6">La machine avec l'ID "{{ machineId }}" n'existe pas ou a été supprimée.</p> <p class="text-sm text-base-content/50 mb-6">La machine avec l'ID "{{ machineId }}" n'existe pas ou a été supprimée.</p>
<NuxtLink to="/machines" class="btn btn-primary"> <button type="button" class="btn btn-primary" @click="$router.back()">
Retour aux machines Retour aux machines
</NuxtLink> </button>
</div> </div>
</div> </div>
</main> </main>
@@ -177,6 +223,7 @@
import { computed, ref, onMounted } from 'vue' import { computed, ref, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useMachineDetailData } from '~/composables/useMachineDetailData' import { useMachineDetailData } from '~/composables/useMachineDetailData'
import { useEntityHistory } from '~/composables/useEntityHistory'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue' import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import PageHero from '~/components/PageHero.vue' import PageHero from '~/components/PageHero.vue'
import MachinePrintSelectionModal from '~/components/MachinePrintSelectionModal.vue' import MachinePrintSelectionModal from '~/components/MachinePrintSelectionModal.vue'
@@ -187,6 +234,8 @@ import MachineProductsCard from '~/components/machine/MachineProductsCard.vue'
import MachineComponentsCard from '~/components/machine/MachineComponentsCard.vue' import MachineComponentsCard from '~/components/machine/MachineComponentsCard.vue'
import MachinePiecesCard from '~/components/machine/MachinePiecesCard.vue' import MachinePiecesCard from '~/components/machine/MachinePiecesCard.vue'
import AddEntityToMachineModal from '~/components/machine/AddEntityToMachineModal.vue' import AddEntityToMachineModal from '~/components/machine/AddEntityToMachineModal.vue'
import EntityHistorySection from '~/components/common/EntityHistorySection.vue'
import EntityVersionList from '~/components/common/EntityVersionList.vue'
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle' import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
const route = useRoute() const route = useRoute()
@@ -198,6 +247,33 @@ if (!machineId) {
} }
const d = useMachineDetailData(machineId) const d = useMachineDetailData(machineId)
const machineInfoCardRef = ref(null)
const versionRefreshKey = ref(0)
const refreshVersions = () => { versionRefreshKey.value++ }
const {
history,
loading: historyLoading,
error: historyError,
loadHistory,
} = useEntityHistory('machine')
const historyFieldLabels = {
name: 'Nom',
reference: 'Référence',
prix: 'Prix',
site: 'Site',
constructeurIds: 'Fournisseurs',
addedComponent: 'Composant ajouté',
removedComponent: 'Composant supprimé',
addedPiece: 'Pièce ajoutée',
removedPiece: 'Pièce supprimée',
addedProduct: 'Produit ajouté',
removedProduct: 'Produit supprimé',
componentLinks: 'Composants liés',
pieceLinks: 'Pièces liées',
productLinks: 'Produits liés',
}
const addModalOpen = ref(false) const addModalOpen = ref(false)
const addModalKind = ref('component') const addModalKind = ref('component')
@@ -207,6 +283,11 @@ const openAddModal = (kind) => {
addModalOpen.value = true addModalOpen.value = true
} }
const handleRemoveConstructeurLink = (constructeurId) => {
const ids = d.machineConstructeurIds.value.filter(id => id !== constructeurId)
d.handleMachineConstructeurChange(ids)
}
const handleAddEntity = async (entityId) => { const handleAddEntity = async (entityId) => {
if (addModalKind.value === 'component') { if (addModalKind.value === 'component') {
await d.addComponentLink(entityId) await d.addComponentLink(entityId)
@@ -215,15 +296,25 @@ const handleAddEntity = async (entityId) => {
} else { } else {
await d.addProductLink(entityId) await d.addProductLink(entityId)
} }
refreshVersions()
} }
const machineViewTitle = computed(() => { const machineViewTitle = computed(() => {
return d.isEditMode.value ? 'Modification de la machine' : 'Détails de la machine' return d.isEditMode.value ? 'Modification de la machine' : 'Détails de la machine'
}) })
const submitMachineEdition = async () => {
if (machineInfoCardRef.value?.saveFieldDefinitions) {
await machineInfoCardRef.value.saveFieldDefinitions()
}
await d.submitEdition()
refreshVersions()
}
onMounted(() => { onMounted(() => {
d.loadMachineData() d.loadMachineData()
d.loadInitialData() d.loadInitialData()
loadHistory(String(machineId)).catch(() => {})
if (route.query.edit === 'true' && canEdit.value) { if (route.query.edit === 'true' && canEdit.value) {
d.isEditMode.value = true d.isEditMode.value = true

View File

@@ -16,16 +16,23 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
<span class="label-text">Site</span> <span class="label-text">Sites</span>
</label> </label>
<select v-model="selectedSite" class="select select-bordered"> <div class="flex flex-wrap gap-3">
<option value=""> <label
Tous les sites v-for="site in sites"
</option> :key="site.id"
<option v-for="site in sites" :key="site.id" :value="site.id"> class="flex items-center gap-2 cursor-pointer"
{{ site.name }} >
</option> <input
</select> type="checkbox"
class="checkbox checkbox-sm"
:checked="selectedSites.has(site.id)"
@change="selectedSites.has(site.id) ? selectedSites.delete(site.id) : selectedSites.add(site.id)"
>
<span class="text-sm">{{ site.name }}</span>
</label>
</div>
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
@@ -113,11 +120,12 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useMachines } from '~/composables/useMachines' import { useMachines } from '~/composables/useMachines'
import { useSites } from '~/composables/useSites' import { useSites } from '~/composables/useSites'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages' import { humanizeError } from '~/shared/utils/errorMessages'
import { useUrlState } from '~/composables/useUrlState'
import IconLucidePlus from '~icons/lucide/plus' import IconLucidePlus from '~icons/lucide/plus'
import IconLucideFactory from '~icons/lucide/factory' import IconLucideFactory from '~icons/lucide/factory'
import IconLucideMapPin from '~icons/lucide/map-pin' import IconLucideMapPin from '~icons/lucide/map-pin'
@@ -128,8 +136,28 @@ const { machines, loading, loadMachines, deleteMachine } = useMachines()
const { sites, loadSites } = useSites() const { sites, loadSites } = useSites()
const toast = useToast() const toast = useToast()
const selectedSite = ref('') const urlState = useUrlState({
const searchQuery = ref('') q: { default: '', debounce: 300 },
sites: { default: '' },
})
const searchQuery = urlState.q
const selectedSites = reactive(new Set())
// Sync URL → selectedSites on load and back/forward
watch(urlState.sites, (val) => {
selectedSites.clear()
if (val) {
for (const id of String(val).split(',')) {
if (id) selectedSites.add(id)
}
}
}, { immediate: true })
// Sync selectedSites → URL
watch(() => [...selectedSites], (ids) => {
urlState.sites.value = ids.join(',')
})
// Enrichir les machines avec les objets site complets // Enrichir les machines avec les objets site complets
const enrichedMachines = computed(() => { const enrichedMachines = computed(() => {
@@ -145,8 +173,8 @@ const enrichedMachines = computed(() => {
const filteredMachines = computed(() => { const filteredMachines = computed(() => {
let filtered = enrichedMachines.value let filtered = enrichedMachines.value
if (selectedSite.value) { if (selectedSites.size > 0) {
filtered = filtered.filter(machine => machine.siteId === selectedSite.value) filtered = filtered.filter(machine => selectedSites.has(machine.siteId))
} }
if (searchQuery.value.trim()) { if (searchQuery.value.trim()) {
@@ -157,6 +185,10 @@ const filteredMachines = computed(() => {
) )
} }
filtered = [...filtered].sort((a, b) =>
(a.name || '').localeCompare(b.name || '', 'fr')
)
return filtered return filtered
}) })

View File

@@ -112,6 +112,8 @@ const loadCategory = async () => {
category: response.category, category: response.category,
notes: response.notes ?? response.description ?? '', notes: response.notes ?? response.description ?? '',
structure: (response.structure as PieceModelStructure | null) ?? undefined, structure: (response.structure as PieceModelStructure | null) ?? undefined,
referenceFormula: response.referenceFormula ?? null,
requiredFieldsForReference: response.requiredFieldsForReference ?? null,
} }
} catch (error) { } catch (error) {
@@ -156,7 +158,6 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false }) await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
await loadPieceTypes({ force: true }) await loadPieceTypes({ force: true })
showSuccess('Catégorie de pièce mise à jour avec succès.') showSuccess('Catégorie de pièce mise à jour avec succès.')
await navigateBackToList()
} }
} catch (error) { } catch (error) {
showError(normalizeError(error)) showError(normalizeError(error))
@@ -181,7 +182,6 @@ const handleSyncConfirm = async () => {
}) })
await loadPieceTypes({ force: true }) await loadPieceTypes({ force: true })
showSuccess('Catégorie de pièce mise à jour avec succès.') showSuccess('Catégorie de pièce mise à jour avec succès.')
await navigateBackToList()
} catch (error) { } catch (error) {
showError(normalizeError(error)) showError(normalizeError(error))
} finally { } finally {

506
app/pages/piece/[id].vue Normal file
View File

@@ -0,0 +1,506 @@
<template>
<div>
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="pieceDocuments"
@close="closePreview"
/>
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<main class="container mx-auto px-6 py-10">
<div v-if="loading" class="flex flex-col items-center gap-4 py-20 text-center">
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
<p class="text-sm text-base-content/70">Chargement de la pièce</p>
</div>
<div v-else-if="!piece" class="max-w-xl mx-auto">
<div class="alert alert-error shadow-lg">
<div>
<h2 class="font-semibold text-lg">Pièce introuvable</h2>
<p class="text-sm text-base-content/80">
Nous n'avons pas pu retrouver la pièce demandée. Elle a peut-être été supprimée.
</p>
</div>
</div>
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
Retour au catalogue
</button>
</div>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
<div class="card-body space-y-6">
<DetailHeader
:title="isEditMode ? 'Modifier la pièce' : piece.name"
:subtitle="isEditMode ? 'Ajustez les informations de la pièce et ses champs personnalisés.' : undefined"
:is-edit-mode="isEditMode"
:can-edit="canEdit"
back-link="/pieces-catalog"
@toggle-edit="isEditMode = !isEditMode"
/>
<!-- Catégorie (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de pièce</span>
</label>
<template v-if="isEditMode">
<select
v-model="selectedTypeId"
class="select select-bordered select-sm md:select-md"
disabled
>
<option value="">Sélectionner une catégorie</option>
<option
v-for="type in pieceTypeList"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
<p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p>
</template>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ selectedType?.name || '—' }}
</div>
</div>
</div>
<!-- Nom (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom de la pièce</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue"
required
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ piece.name }}
</div>
</div>
</div>
<!-- Description (if value or edit mode) -->
<div v-if="isEditMode || piece.description" class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-if="isEditMode"
v-model="editionForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || saving"
placeholder="Description de la pièce (optionnel)"
rows="3"
/>
<div v-else class="textarea textarea-bordered textarea-sm md:textarea-md bg-base-200">
{{ piece.description }}
</div>
</div>
<!-- Référence auto (read-only, shown only if computed) -->
<div v-if="piece.referenceAuto" class="form-control">
<label class="label">
<span class="label-text">Référence auto</span>
</label>
<div class="input input-bordered input-sm md:input-md bg-base-200 flex items-center gap-2">
<span class="font-mono font-semibold">{{ piece.referenceAuto }}</span>
<span class="badge badge-sm badge-ghost">auto</span>
</div>
</div>
<!-- Référence + Fournisseurs (if value or edit mode) -->
<div
v-if="isEditMode || piece.reference || editionForm.constructeurIds.length"
class="grid grid-cols-1 gap-4 md:grid-cols-2"
>
<div v-if="isEditMode || piece.reference" class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur"
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ piece.reference }}
</div>
</div>
<div v-if="isEditMode || constructeurLinks.length" class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<template v-if="isEditMode">
<ConstructeurSelect
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="piece?.constructeurs || []"
/>
<ConstructeurLinksTable
v-model="constructeurLinks"
class="mt-2"
@remove="handleConstructeurRemoved"
/>
</template>
<ConstructeurLinksTable
v-else
:model-value="constructeurLinks"
readonly
/>
</div>
</div>
<!-- Prix (if value or edit mode) -->
<div v-if="isEditMode || piece.prix" class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif ()</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Valeur indicatrice"
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ piece.prix }}
</div>
</div>
</div>
<!-- Product requirements -->
<div
v-if="structureProducts.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">
Produits liés
</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Cette pièce doit rester liée à un produit catalogue répondant aux critères suivants.' : 'Produits associés à cette pièce via le squelette.' }}
</p>
</header>
<ul class="space-y-2 text-sm text-base-content/80">
<li
v-for="(description, index) in productRequirementDescriptions"
:key="`edit-requirement-${index}`"
class="flex items-start gap-2"
>
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
<span>{{ description }}</span>
</li>
</ul>
<div v-if="isEditMode" class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="entry in productRequirementEntries"
:key="entry.key"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">
{{ entry.label }}
</span>
</label>
<ProductSelect
:model-value="productSelections[entry.index] || null"
:disabled="!canEdit || saving"
:type-product-id="entry.typeProductId"
helper-text="Un produit valide est requis pour cette pièce."
@update:model-value="(value) => setProductSelection(entry.index, value)"
/>
</div>
</div>
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="(entry, index) in productRequirementEntries"
:key="entry.key"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">{{ entry.label }}</span>
</label>
<div class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ productSelectionLabels[index] || '— Non sélectionné' }}
</div>
</div>
</div>
</div>
<!-- Skeleton preview (edit mode only) -->
<StructureSkeletonPreview
v-if="isEditMode && (selectedType || resolvedStructure)"
:structure="resolvedStructure"
:description="selectedType?.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.'"
:preview-badge="formatPieceStructurePreview(resolvedStructure)"
variant="piece"
/>
<!-- Custom fields -->
<div v-if="visibleCustomFields.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p v-if="isEditMode" class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à cette pièce.
</p>
</header>
<template v-if="isEditMode">
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</template>
<template v-else>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="field in visibleCustomFields"
:key="field.customFieldValueId || field.id || field.name"
class="form-control"
>
<label class="label">
<span class="label-text text-sm">{{ field.name }}</span>
</label>
<div class="input input-bordered input-sm bg-base-200 flex items-center">
{{ field.value }}
</div>
</div>
</div>
</template>
</div>
<!-- Documents -->
<div
v-if="isEditMode || pieceDocuments.length > 0"
class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Gérez les documents associés à cette pièce.' : 'Documents associés à cette pièce.' }}
</p>
</div>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<template v-if="isEditMode">
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents"
@files-added="handleFilesAdded"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours…
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="pieceDocuments"
:can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document n'est associé à cette pièce pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</template>
<template v-else>
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="pieceDocuments"
:can-delete="false"
:can-edit="false"
empty-text="Aucun document n'est associé à cette pièce pour le moment."
@preview="openPreview"
/>
</template>
</div>
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<EntityVersionList
entity-type="piece"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="fetchPiece()"
/>
<!-- Save buttons (edit mode only) -->
<div v-if="isEditMode" class="flex flex-col gap-3 md:flex-row md:justify-end">
<button type="button" class="btn btn-ghost" :class="{ 'btn-disabled': saving }" @click="isEditMode = false">
Annuler
</button>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitEdition">
<span v-if="saving" class="loading loading-spinner loading-sm mr-2" />
Enregistrer les modifications
</button>
</div>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="piece"
:entity-id="String(route.params.id)"
:entity-name="piece?.name"
show-resolved
/>
</div>
</div>
</section>
</main>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from '#imports'
import { usePieceEdit } from '~/composables/usePieceEdit'
import { useDocuments } from '~/composables/useDocuments'
import { usePermissions } from '~/composables/usePermissions'
const route = useRoute()
const { canEdit } = usePermissions()
const { updateDocument } = useDocuments()
const isEditMode = ref(false)
const versionRefreshKey = ref(0)
const {
piece,
loading,
saving,
selectedFiles,
uploadingDocuments,
loadingDocuments,
pieceDocuments,
previewDocument,
previewVisible,
selectedTypeId,
editionForm,
constructeurLinks,
productSelections,
customFieldInputs,
pieceTypeList,
selectedType,
resolvedStructure,
structureProducts,
productRequirementDescriptions,
productRequirementEntries,
canSubmit,
historyFieldLabels,
history,
historyLoading,
historyError,
openPreview,
closePreview,
removeDocument,
handleFilesAdded,
setProductSelection,
submitEdition: _submitEdition,
fetchPiece,
formatPieceStructurePreview,
} = usePieceEdit(String(route.params.id))
const submitEdition = async () => {
await _submitEdition()
if (!saving.value) {
await fetchPiece()
isEditMode.value = false
versionRefreshKey.value++
}
}
// Sync ConstructeurSelect changes → constructeurLinks
watch(() => editionForm.constructeurIds, (newIds) => {
const existing = new Map(constructeurLinks.value.map(l => [l.constructeurId, l]))
constructeurLinks.value = newIds.map(id =>
existing.get(id) || { constructeurId: id, supplierReference: null },
)
})
const handleConstructeurRemoved = (constructeurId: string) => {
editionForm.constructeurIds = editionForm.constructeurIds.filter(id => id !== constructeurId)
}
const editingDocument = ref<any | null>(null)
const editModalVisible = ref(false)
// Resolve product names for read-only display from piece data
const productSelectionLabels = computed(() => {
if (!piece.value) return []
const p = piece.value as any
// piece.product contains {id, name} for the legacy single product
if (p.product?.name) return [p.product.name]
return productSelections.value.map((id: string | null) => id || null)
})
const visibleCustomFields = computed(() => {
if (isEditMode.value) return customFieldInputs.value
return customFieldInputs.value.filter(
(f) => f.value !== null && f.value !== undefined && f.value !== '',
)
})
const openEditModal = (doc: any) => {
editingDocument.value = doc
editModalVisible.value = true
}
const handleDocumentUpdated = async (data: { name?: string; type?: string }) => {
if (!editingDocument.value?.id) return
const result = await updateDocument(editingDocument.value.id, data)
if (result.success) {
const idx = pieceDocuments.value.findIndex((d: any) => d.id === editingDocument.value?.id)
if (idx !== -1) {
pieceDocuments.value[idx] = { ...pieceDocuments.value[idx], ...data }
}
}
editModalVisible.value = false
editingDocument.value = null
}
onMounted(() => {
if (route.query.edit === 'true' && canEdit.value) {
isEditMode.value = true
}
})
</script>

View File

@@ -69,6 +69,10 @@
{{ row.piece.reference || '—' }} {{ row.piece.reference || '—' }}
</template> </template>
<template #cell-referenceAuto="{ row }">
{{ row.piece.referenceAuto || '—' }}
</template>
<template #cell-description="{ row }"> <template #cell-description="{ row }">
<div v-if="row.piece.description" class="group relative"> <div v-if="row.piece.description" class="group relative">
<span class="block cursor-help truncate">{{ row.piece.description }}</span> <span class="block cursor-help truncate">{{ row.piece.description }}</span>
@@ -119,12 +123,14 @@
<template #cell-actions="{ row }"> <template #cell-actions="{ row }">
<div class="flex items-center justify-end gap-2"> <div class="flex items-center justify-end gap-2">
<NuxtLink <button
:to="`/pieces/${row.piece.id}/edit`" v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs" class="btn btn-ghost btn-xs"
@click="navigateTo(`/piece/${row.piece.id}?edit=true`)"
> >
Modifier Modifier
</NuxtLink> </button>
<button <button
v-if="canEdit" v-if="canEdit"
type="button" type="button"
@@ -134,6 +140,12 @@
> >
Supprimer Supprimer
</button> </button>
<NuxtLink
:to="`/piece/${row.piece.id}`"
class="btn btn-primary btn-xs"
>
Détails
</NuxtLink>
</div> </div>
</template> </template>
</DataTable> </DataTable>
@@ -159,13 +171,14 @@ const { pieceTypes, loadPieceTypes } = usePieceTypes()
const table = useDataTable( const table = useDataTable(
{ fetchData: fetchPieces }, { fetchData: fetchPieces },
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true }, { defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typePiece'] },
) )
const columns = [ const columns = [
{ key: 'preview', label: 'Aperçu', width: 'w-24' }, { key: 'preview', label: 'Aperçu', width: 'w-24' },
{ key: 'name', label: 'Nom', sortable: true }, { key: 'name', label: 'Nom', sortable: true },
{ key: 'reference', label: 'Référence' }, { key: 'reference', label: 'Référence' },
{ key: 'referenceAuto', label: 'Réf. auto' },
{ key: 'description', label: 'Description' }, { key: 'description', label: 'Description' },
{ key: 'suppliers', label: 'Fournisseurs' }, { key: 'suppliers', label: 'Fournisseurs' },
{ key: 'typePiece', label: 'Type de pièce', filterable: true, filterPlaceholder: 'Filtrer…' }, { key: 'typePiece', label: 'Type de pièce', filterable: true, filterPlaceholder: 'Filtrer…' },

View File

@@ -6,6 +6,12 @@
:documents="pieceDocuments" :documents="pieceDocuments"
@close="closePreview" @close="closePreview"
/> />
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<main class="container mx-auto px-6 py-10"> <main class="container mx-auto px-6 py-10">
<div v-if="loading" class="flex flex-col items-center gap-4 py-20 text-center"> <div v-if="loading" class="flex flex-col items-center gap-4 py-20 text-center">
<span class="loading loading-spinner loading-lg" aria-hidden="true" /> <span class="loading loading-spinner loading-lg" aria-hidden="true" />
@@ -108,6 +114,19 @@
> >
</div> </div>
<div v-if="piece?.referenceAuto" class="form-control">
<label class="label">
<span class="label-text">Référence auto</span>
</label>
<input
:value="piece.referenceAuto"
type="text"
class="input input-bordered input-sm md:input-md bg-base-200"
disabled
title="Générée automatiquement à partir du type et des champs personnalisés"
>
</div>
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
<span class="label-text">Fournisseur</span> <span class="label-text">Fournisseur</span>
@@ -231,9 +250,11 @@
v-else v-else
:documents="pieceDocuments" :documents="pieceDocuments"
:can-delete="canEdit" :can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments" :delete-disabled="uploadingDocuments"
empty-text="Aucun document n'est associé à cette pièce pour le moment." empty-text="Aucun document n'est associé à cette pièce pour le moment."
@preview="openPreview" @preview="openPreview"
@edit="openEditModal"
@delete="removeDocument" @delete="removeDocument"
/> />
</div> </div>
@@ -271,10 +292,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'
import { useRoute } from '#imports' import { useRoute } from '#imports'
import { usePieceEdit } from '~/composables/usePieceEdit' import { usePieceEdit } from '~/composables/usePieceEdit'
import { useDocuments } from '~/composables/useDocuments'
const route = useRoute() const route = useRoute()
const { updateDocument } = useDocuments()
const { const {
piece, piece,
@@ -310,4 +334,24 @@ const {
submitEdition, submitEdition,
formatPieceStructurePreview, formatPieceStructurePreview,
} = usePieceEdit(String(route.params.id)) } = usePieceEdit(String(route.params.id))
const editingDocument = ref<any | null>(null)
const editModalVisible = ref(false)
const openEditModal = (doc: any) => {
editingDocument.value = doc
editModalVisible.value = true
}
const handleDocumentUpdated = async (data: { name?: string; type?: string }) => {
if (!editingDocument.value?.id) return
const result = await updateDocument(editingDocument.value.id, data)
if (result.success) {
const idx = pieceDocuments.value.findIndex((d: any) => d.id === editingDocument.value?.id)
if (idx !== -1) {
pieceDocuments.value[idx] = { ...pieceDocuments.value[idx], ...data }
}
}
editModalVisible.value = false
editingDocument.value = null
}
</script> </script>

View File

@@ -91,6 +91,10 @@
/> />
</div> </div>
</div> </div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control"> <div class="form-control">
@@ -213,6 +217,7 @@
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from '#imports' import { useRoute, useRouter } from '#imports'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue' import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import ConstructeurLinksTable from '~/components/ConstructeurLinksTable.vue'
import DocumentUpload from '~/components/DocumentUpload.vue' import DocumentUpload from '~/components/DocumentUpload.vue'
import ProductSelect from '~/components/ProductSelect.vue' import ProductSelect from '~/components/ProductSelect.vue'
import SearchSelect from '~/components/common/SearchSelect.vue' import SearchSelect from '~/components/common/SearchSelect.vue'
@@ -222,8 +227,11 @@ import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages' import { humanizeError } from '~/shared/utils/errorMessages'
import { useCustomFields } from '~/composables/useCustomFields' import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { formatPieceStructurePreview } from '~/shared/modelUtils' import { formatPieceStructurePreview } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils' import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
import type { PieceModelStructure } from '~/shared/types/inventory' import type { PieceModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes' import type { ModelType } from '~/services/modelTypes'
import { import {
@@ -255,6 +263,8 @@ const { createPiece } = usePieces()
const toast = useToast() const toast = useToast()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields() const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const { uploadDocuments } = useDocuments() const { uploadDocuments } = useDocuments()
const { syncLinks } = useConstructeurLinks()
const { getConstructeurById } = useConstructeurs()
const { canEdit } = usePermissions() const { canEdit } = usePermissions()
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '') const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
@@ -267,6 +277,7 @@ const creationForm = reactive({
constructeurIds: [] as string[], constructeurIds: [] as string[],
prix: '' as string, prix: '' as string,
}) })
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
const productSelections = ref<(string | null)[]>([]) const productSelections = ref<(string | null)[]>([])
const lastSuggestedName = ref('') const lastSuggestedName = ref('')
@@ -380,6 +391,7 @@ const clearCreationForm = () => {
creationForm.description = '' creationForm.description = ''
creationForm.reference = '' creationForm.reference = ''
creationForm.constructeurIds = [] creationForm.constructeurIds = []
constructeurLinks.value = []
creationForm.prix = '' creationForm.prix = ''
productSelections.value = [] productSelections.value = []
lastSuggestedName.value = '' lastSuggestedName.value = ''
@@ -411,8 +423,6 @@ const submitCreation = async () => {
payload.reference = reference payload.reference = reference
} }
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
const normalizedProductIds = collectNormalizedProductIds( const normalizedProductIds = collectNormalizedProductIds(
productRequirementEntries.value, productRequirementEntries.value,
productSelections.value, productSelections.value,
@@ -444,10 +454,14 @@ const submitCreation = async () => {
'piece', 'piece',
createdPiece.id, createdPiece.id,
[ [
createdPiece?.typePiece?.pieceCustomFields, createdPiece?.typePiece?.structure?.customFields,
], ],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast }, { customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
) )
// Sync constructeur links after creation
if (constructeurLinks.value.length) {
await syncLinks('piece', createdPiece.id, [], constructeurLinks.value)
}
if (selectedDocuments.value.length && createdPiece.id) { if (selectedDocuments.value.length && createdPiece.id) {
uploadingDocuments.value = true uploadingDocuments.value = true
const uploadResult = await uploadDocuments( const uploadResult = await uploadDocuments(
@@ -466,7 +480,7 @@ const submitCreation = async () => {
selectedDocuments.value = [] selectedDocuments.value = []
} }
toast.showSuccess('Pièce créée avec succès') toast.showSuccess('Pièce créée avec succès')
await router.push('/pieces-catalog') await router.replace(`/piece/${createdPiece.id}?edit=true`)
} else if (result.error) { } else if (result.error) {
toast.showError(result.error) toast.showError(result.error)
} }
@@ -478,6 +492,26 @@ const submitCreation = async () => {
} }
} }
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
watch(
() => creationForm.constructeurIds,
(ids) => {
const currentIds = new Set(constructeurLinks.value.map(l => l.constructeurId))
for (const id of ids) {
if (!currentIds.has(id)) {
const resolved = getConstructeurById(id)
constructeurLinks.value.push({
constructeurId: id,
constructeur: resolved ? { id: resolved.id, name: resolved.name } : null,
supplierReference: null,
})
}
}
constructeurLinks.value = constructeurLinks.value.filter(l => ids.includes(l.constructeurId))
},
{ deep: true },
)
onMounted(async () => { onMounted(async () => {
await loadPieceTypes() await loadPieceTypes()
}) })

View File

@@ -116,12 +116,14 @@
<template #cell-actions="{ row }"> <template #cell-actions="{ row }">
<div class="flex items-center justify-end gap-2"> <div class="flex items-center justify-end gap-2">
<NuxtLink <button
:to="`/product/${row.product.id}/edit`" v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs" class="btn btn-ghost btn-xs"
@click="navigateTo(`/product/${row.product.id}?edit=true`)"
> >
Modifier Modifier
</NuxtLink> </button>
<button <button
v-if="canEdit" v-if="canEdit"
type="button" type="button"
@@ -130,6 +132,12 @@
> >
Supprimer Supprimer
</button> </button>
<NuxtLink
:to="`/product/${row.product.id}`"
class="btn btn-primary btn-xs"
>
Détails
</NuxtLink>
</div> </div>
</template> </template>
</DataTable> </DataTable>
@@ -167,7 +175,7 @@ const toast = useToast()
const table = useDataTable( const table = useDataTable(
{ fetchData: fetchProducts }, { fetchData: fetchProducts },
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true }, { defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typeProduct'] },
) )
const errorMessage = computed(() => (typeof error.value === 'string' && error.value.length ? error.value : null)) const errorMessage = computed(() => (typeof error.value === 'string' && error.value.length ? error.value : null))

View File

@@ -156,7 +156,6 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false }) await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
await loadProductTypes({ force: true }) await loadProductTypes({ force: true })
showSuccess('Catégorie de produit mise à jour avec succès.') showSuccess('Catégorie de produit mise à jour avec succès.')
await navigateBackToList()
} }
} catch (error) { } catch (error) {
showError(normalizeError(error)) showError(normalizeError(error))
@@ -181,7 +180,6 @@ const handleSyncConfirm = async () => {
}) })
await loadProductTypes({ force: true }) await loadProductTypes({ force: true })
showSuccess('Catégorie de produit mise à jour avec succès.') showSuccess('Catégorie de produit mise à jour avec succès.')
await navigateBackToList()
} catch (error) { } catch (error) {
showError(normalizeError(error)) showError(normalizeError(error))
} finally { } finally {

View File

@@ -6,6 +6,12 @@
:documents="productDocuments" :documents="productDocuments"
@close="closePreview" @close="closePreview"
/> />
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<main class="container mx-auto px-6 py-10"> <main class="container mx-auto px-6 py-10">
<div v-if="loading" class="flex flex-col items-center gap-4 py-16 text-center"> <div v-if="loading" class="flex flex-col items-center gap-4 py-16 text-center">
<span class="loading loading-spinner loading-lg" aria-hidden="true" /> <span class="loading loading-spinner loading-lg" aria-hidden="true" />
@@ -99,6 +105,11 @@
</div> </div>
</div> </div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
@@ -167,9 +178,11 @@
v-else v-else
:documents="productDocuments" :documents="productDocuments"
:can-delete="canEdit" :can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments || saving" :delete-disabled="uploadingDocuments || saving"
empty-text="Aucun document n'est associé à ce produit pour le moment." empty-text="Aucun document n'est associé à ce produit pour le moment."
@preview="openPreview" @preview="openPreview"
@edit="openEditModal"
@delete="removeDocument" @delete="removeDocument"
/> />
</div> </div>
@@ -181,6 +194,14 @@
:field-labels="historyFieldLabels" :field-labels="historyFieldLabels"
/> />
<EntityVersionList
entity-type="product"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="loadProduct()"
/>
<div class="flex flex-col gap-3 md:flex-row md:justify-end"> <div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }"> <NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
Annuler Annuler
@@ -221,9 +242,11 @@ import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages' import { humanizeError } from '~/shared/utils/errorMessages'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs' import { useConstructeurs } from '~/composables/useConstructeurs'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { useProductHistory } from '~/composables/useProductHistory' import { useProductHistory } from '~/composables/useProductHistory'
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils' import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils' import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
import { getModelType } from '~/services/modelTypes' import { getModelType } from '~/services/modelTypes'
import type { ProductModelStructure } from '~/shared/types/inventory' import type { ProductModelStructure } from '~/shared/types/inventory'
import { canPreviewDocument } from '~/utils/documentPreview' import { canPreviewDocument } from '~/utils/documentPreview'
@@ -235,6 +258,7 @@ import {
} from '~/shared/utils/customFieldFormUtils' } from '~/shared/utils/customFieldFormUtils'
const { canEdit } = usePermissions() const { canEdit } = usePermissions()
const versionRefreshKey = ref(0)
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const toast = useToast() const toast = useToast()
@@ -244,8 +268,10 @@ const {
loadDocumentsByProduct, loadDocumentsByProduct,
uploadDocuments: uploadProductDocuments, uploadDocuments: uploadProductDocuments,
deleteDocument: deleteProductDocument, deleteDocument: deleteProductDocument,
updateDocument,
} = useDocuments() } = useDocuments()
const { ensureConstructeurs } = useConstructeurs() const { ensureConstructeurs, getConstructeurById } = useConstructeurs()
const { fetchLinks, syncLinks } = useConstructeurLinks()
const { const {
history, history,
loading: historyLoading, loading: historyLoading,
@@ -265,6 +291,11 @@ const loadingDocuments = ref(false)
const productDocuments = ref<any[]>([]) const productDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null) const previewDocument = ref<any | null>(null)
const previewVisible = ref(false) const previewVisible = ref(false)
const editingDocument = ref<any | null>(null)
const editModalVisible = ref(false)
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
const historyFieldLabels: Record<string, string> = { const historyFieldLabels: Record<string, string> = {
name: 'Nom', name: 'Nom',
@@ -307,6 +338,23 @@ const openPreview = (doc: any) => {
} }
const closePreview = () => { previewVisible.value = false; previewDocument.value = null } const closePreview = () => { previewVisible.value = false; previewDocument.value = null }
const openEditModal = (doc: any) => {
editingDocument.value = doc
editModalVisible.value = true
}
const handleDocumentUpdated = async (data: { name?: string; type?: string }) => {
if (!editingDocument.value?.id) return
const result = await updateDocument(editingDocument.value.id, data)
if (result.success) {
const idx = productDocuments.value.findIndex((d: any) => d.id === editingDocument.value?.id)
if (idx !== -1) {
productDocuments.value[idx] = { ...productDocuments.value[idx], ...data }
}
}
editModalVisible.value = false
editingDocument.value = null
}
const loadProduct = async () => { const loadProduct = async () => {
const id = route.params.id const id = route.params.id
if (!id || typeof id !== 'string') { if (!id || typeof id !== 'string') {
@@ -420,18 +468,19 @@ const hydrateForm = () => {
} }
editionForm.name = product.value.name || '' editionForm.name = product.value.name || ''
editionForm.reference = product.value.reference || '' editionForm.reference = product.value.reference || ''
editionForm.constructeurIds = uniqueConstructeurIds( // Load constructeur links
product.value, fetchLinks('product', String(route.params.id)).then((links) => {
Array.isArray(product.value.constructeurs) ? product.value.constructeurs : [], constructeurLinks.value = links
) originalConstructeurLinks.value = links.map(l => ({ ...l }))
editionForm.constructeurIds = constructeurIdsFromLinks(links)
if (editionForm.constructeurIds.length) {
void ensureConstructeurs(editionForm.constructeurIds)
}
})
editionForm.supplierPrice = product.value.supplierPrice !== null && product.value.supplierPrice !== undefined editionForm.supplierPrice = product.value.supplierPrice !== null && product.value.supplierPrice !== undefined
? String(product.value.supplierPrice) ? String(product.value.supplierPrice)
: '' : ''
refreshCustomFieldInputs(structure.value, product.value.customFieldValues) refreshCustomFieldInputs(structure.value, product.value.customFieldValues)
if (editionForm.constructeurIds.length) {
// Smart-cached + deduped — fire-and-forget, ConstructeurSelect handles its own loading
ensureConstructeurs(editionForm.constructeurIds).catch(() => {})
}
} }
watch( watch(
@@ -449,12 +498,9 @@ const submitEdition = async () => {
return return
} }
const constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
const payload: Record<string, any> = { const payload: Record<string, any> = {
name: editionForm.name.trim(), name: editionForm.name.trim(),
reference: editionForm.reference.trim() || null, reference: editionForm.reference.trim() || null,
constructeurIds,
} }
const rawPrice = typeof editionForm.supplierPrice === 'string' const rawPrice = typeof editionForm.supplierPrice === 'string'
@@ -474,15 +520,17 @@ const submitEdition = async () => {
const failedFields = await _saveCustomFieldValues( const failedFields = await _saveCustomFieldValues(
'product', 'product',
result.data.id, result.data.id,
[], [result.data?.typeProduct?.structure?.customFields],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast }, { customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
) )
if (failedFields.length) { if (failedFields.length) {
toast.showError(`Impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`) toast.showError(`Impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
return return
} }
await syncLinks('product', product.value.id, originalConstructeurLinks.value, constructeurLinks.value)
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
toast.showSuccess('Produit mis à jour avec succès') toast.showSuccess('Produit mis à jour avec succès')
await router.push('/product-catalog') versionRefreshKey.value++
} }
} catch (error: any) { } catch (error: any) {
toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit') toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')
@@ -491,6 +539,25 @@ const submitEdition = async () => {
} }
} }
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
watch(
() => editionForm.constructeurIds,
(ids) => {
const currentIds = new Set(constructeurLinks.value.map(l => l.constructeurId))
for (const id of ids) {
if (!currentIds.has(id)) {
const resolved = getConstructeurById(id)
constructeurLinks.value.push({
constructeurId: id,
constructeur: resolved ? { id: resolved.id, name: resolved.name } : null,
supplierReference: null,
})
}
}
constructeurLinks.value = constructeurLinks.value.filter(l => ids.includes(l.constructeurId))
},
)
onMounted(async () => { onMounted(async () => {
await loadProduct() await loadProduct()
}) })

View File

@@ -0,0 +1,646 @@
<template>
<div>
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="productDocuments"
@close="closePreview"
/>
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<main class="container mx-auto px-6 py-10">
<div v-if="loading" class="flex flex-col items-center gap-4 py-20 text-center">
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
<p class="text-sm text-base-content/70">Chargement du produit</p>
</div>
<div v-else-if="!product" class="max-w-xl mx-auto">
<div class="alert alert-error shadow-lg">
<div>
<h2 class="font-semibold text-lg">Produit introuvable</h2>
<p class="text-sm text-base-content/80">
Nous n'avons pas pu trouver le produit demandé. Il a peut-être été supprimé.
</p>
</div>
</div>
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
Retour au catalogue
</button>
</div>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
<div class="card-body space-y-6">
<DetailHeader
:title="isEditMode ? 'Modifier le produit' : product.name"
:subtitle="isEditMode ? 'Ajustez les informations du produit et ses champs personnalisés.' : undefined"
:is-edit-mode="isEditMode"
:can-edit="canEdit"
back-link="/product-catalog"
@toggle-edit="isEditMode = !isEditMode"
/>
<!-- Catégorie (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de produit</span>
</label>
<template v-if="isEditMode">
<input
:value="product?.typeProduct?.name || 'Catégorie inconnue'"
type="text"
class="input input-bordered input-sm md:input-md bg-base-200"
disabled
>
<p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p>
</template>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ product?.typeProduct?.name || '—' }}
</div>
</div>
</div>
<!-- Nom (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du produit</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue"
required
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ product.name }}
</div>
</div>
</div>
<!-- Référence + Fournisseurs (if value or edit mode) -->
<div
v-if="isEditMode || product.reference || editionForm.constructeurIds.length"
class="grid grid-cols-1 gap-4 md:grid-cols-2"
>
<div v-if="isEditMode || product.reference" class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur"
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ product.reference }}
</div>
</div>
<div v-if="isEditMode || editionForm.constructeurIds.length" class="form-control">
<label class="label">
<span class="label-text">Fournisseurs</span>
</label>
<ConstructeurSelect
v-if="isEditMode"
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="product?.constructeurs || []"
/>
<div v-else class="flex flex-wrap gap-2">
<span
v-for="id in editionForm.constructeurIds"
:key="id"
class="badge badge-outline"
>
{{ getConstructeurById(id)?.name || id }}
</span>
</div>
</div>
</div>
<!-- Constructeur links table -->
<ConstructeurLinksTable
v-if="isEditMode && constructeurLinks.length"
v-model="constructeurLinks"
/>
<ConstructeurLinksTable
v-else-if="!isEditMode && constructeurLinks.length"
:model-value="constructeurLinks"
:readonly="true"
/>
<!-- Prix fournisseur (if value or edit mode) -->
<div v-if="isEditMode || product.supplierPrice" class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix fournisseur indicatif ()</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.supplierPrice"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Valeur indicatrice"
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ product.supplierPrice }}
</div>
</div>
</div>
<!-- Structure preview (edit mode only) -->
<div v-if="isEditMode && structurePreview" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-semibold text-base-content">Champs définis par la catégorie</h2>
<p class="text-xs text-base-content/70">
{{ productType?.description || 'Le squelette de catégorie contrôle les champs personnalisés disponibles.' }}
</p>
</div>
<span class="badge badge-outline">{{ structurePreview }}</span>
</div>
</div>
<!-- Custom fields -->
<div v-if="visibleCustomFields.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p v-if="isEditMode" class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à ce produit.
</p>
</header>
<template v-if="isEditMode">
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</template>
<template v-else>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="field in visibleCustomFields"
:key="field.customFieldValueId || field.id || field.name"
class="form-control"
>
<label class="label">
<span class="label-text text-sm">{{ field.name }}</span>
</label>
<div class="input input-bordered input-sm bg-base-200 flex items-center">
{{ field.value }}
</div>
</div>
</div>
</template>
</div>
<!-- Documents -->
<div
v-if="isEditMode || productDocuments.length > 0"
class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Gérez les documents associés à ce produit.' : 'Documents associés à ce produit.' }}
</p>
</div>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<template v-if="isEditMode">
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
@files-added="handleFilesAdded"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours
</p>
<DocumentListInline
v-else
:documents="productDocuments"
:can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments || saving"
empty-text="Aucun document n'est associé à ce produit pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</template>
<template v-else>
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours
</p>
<DocumentListInline
v-else
:documents="productDocuments"
:can-delete="false"
:can-edit="false"
empty-text="Aucun document n'est associé à ce produit pour le moment."
@preview="openPreview"
/>
</template>
</div>
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<!-- Save buttons (edit mode only) -->
<div v-if="isEditMode" class="flex flex-col gap-3 md:flex-row md:justify-end">
<button type="button" class="btn btn-ghost" :class="{ 'btn-disabled': saving }" @click="isEditMode = false">
Annuler
</button>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitEdition">
<span v-if="saving" class="loading loading-spinner loading-sm mr-2" />
Enregistrer les modifications
</button>
</div>
<p v-if="isEditMode && product && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
Merci de renseigner tous les champs personnalisés obligatoires.
</p>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="product"
:entity-id="String(route.params.id)"
:entity-name="product?.name"
show-resolved
/>
</div>
</div>
</section>
</main>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from '#imports'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import { useProducts } from '~/composables/useProducts'
import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { useProductHistory } from '~/composables/useProductHistory'
import { usePermissions } from '~/composables/usePermissions'
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
import { getModelType } from '~/services/modelTypes'
import type { ProductModelStructure } from '~/shared/types/inventory'
import { canPreviewDocument } from '~/utils/documentPreview'
import {
type CustomFieldInput,
buildCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils'
const { canEdit } = usePermissions()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { getProduct, updateProduct } = useProducts()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const {
loadDocumentsByProduct,
uploadDocuments: uploadProductDocuments,
deleteDocument: deleteProductDocument,
updateDocument,
} = useDocuments()
const { ensureConstructeurs, getConstructeurById } = useConstructeurs()
const { fetchLinks, syncLinks } = useConstructeurLinks()
const {
history,
loading: historyLoading,
error: historyError,
loadHistory,
} = useProductHistory()
const isEditMode = ref(false)
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
const product = ref<any | null>(null)
const productType = ref<any | null>(null)
const structure = ref<ProductModelStructure | null>(null)
const customFieldInputs = ref<CustomFieldInput[]>([])
const loading = ref(true)
const saving = ref(false)
const selectedFiles = ref<File[]>([])
const uploadingDocuments = ref(false)
const loadingDocuments = ref(false)
const productDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null)
const previewVisible = ref(false)
const editingDocument = ref<any | null>(null)
const editModalVisible = ref(false)
const historyFieldLabels: Record<string, string> = {
name: 'Nom',
reference: 'Référence',
supplierPrice: 'Prix fournisseur',
typeProduct: 'Catégorie',
constructeurIds: 'Fournisseurs',
}
const refreshCustomFieldInputs = (
structureOverride?: ProductModelStructure | null,
valuesOverride?: any[] | null,
) => {
const nextStructure = structureOverride ?? structure.value ?? null
const nextValues = valuesOverride ?? product.value?.customFieldValues ?? null
customFieldInputs.value = buildCustomFieldInputs(nextStructure, nextValues)
}
const editionForm = reactive({
name: '' as string,
reference: '' as string,
constructeurIds: [] as string[],
supplierPrice: '' as string,
})
const requiredCustomFieldsFilled = computed(() =>
_requiredCustomFieldsFilled(customFieldInputs.value),
)
const canSubmit = computed(() =>
Boolean(canEdit.value && product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),
)
const structurePreview = computed(() => formatProductStructurePreview(structure.value))
const visibleCustomFields = computed(() => {
if (isEditMode.value) return customFieldInputs.value
return customFieldInputs.value.filter(
(f) => f.value !== null && f.value !== undefined && f.value !== '',
)
})
const openPreview = (doc: any) => {
if (!doc || !canPreviewDocument(doc)) return
previewDocument.value = doc
previewVisible.value = true
}
const closePreview = () => { previewVisible.value = false; previewDocument.value = null }
const openEditModal = (doc: any) => {
editingDocument.value = doc
editModalVisible.value = true
}
const handleDocumentUpdated = async (data: { name?: string; type?: string }) => {
if (!editingDocument.value?.id) return
const result = await updateDocument(editingDocument.value.id, data)
if (result.success) {
const idx = productDocuments.value.findIndex((d: any) => d.id === editingDocument.value?.id)
if (idx !== -1) {
productDocuments.value[idx] = { ...productDocuments.value[idx], ...data }
}
}
editModalVisible.value = false
editingDocument.value = null
}
const loadProduct = async () => {
const id = route.params.id
if (!id || typeof id !== 'string') {
product.value = null
loading.value = false
return
}
const result = await getProduct(id)
if (result.success && result.data) {
product.value = result.data
productDocuments.value = Array.isArray(result.data.documents) ? result.data.documents : []
await loadProductType()
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
refreshCustomFieldInputs(undefined, customValues)
hydrateForm()
loadHistory(result.data.id).catch(() => {})
} else {
product.value = null
}
loading.value = false
}
const refreshDocuments = async () => {
if (!product.value?.id) {
return
}
loadingDocuments.value = true
try {
const result = await loadDocumentsByProduct(product.value.id, { updateStore: false })
if (result.success) {
productDocuments.value = Array.isArray(result.data) ? result.data : []
}
} finally {
loadingDocuments.value = false
}
}
const removeDocument = async (documentId: string | number | null | undefined) => {
if (!documentId) {
return
}
const result = await deleteProductDocument(documentId, { updateStore: false })
if (result.success) {
productDocuments.value = productDocuments.value.filter((doc) => doc.id !== documentId)
toast.showSuccess('Document supprimé')
}
}
const handleFilesAdded = async (files: File[]) => {
if (!files?.length || !product.value?.id) {
return
}
uploadingDocuments.value = true
try {
const result = await uploadProductDocuments(
{
files,
context: { productId: product.value.id },
},
{ updateStore: false },
)
if (result.success) {
selectedFiles.value = []
await refreshDocuments()
toast.showSuccess('Document(s) ajouté(s)')
} else if (result.error) {
toast.showError(result.error)
}
} finally {
uploadingDocuments.value = false
}
}
const loadProductType = async () => {
const embedded = product.value?.typeProduct
if (embedded && typeof embedded === 'object' && embedded.id) {
const embeddedStructure = embedded.structure ?? null
if (embeddedStructure) {
productType.value = embedded
structure.value = normalizeProductStructureForSave(embeddedStructure)
return
}
}
if (!product.value?.typeProductId) {
productType.value = embedded ?? null
structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
return
}
try {
const type = await getModelType(product.value.typeProductId)
productType.value = type
structure.value = normalizeProductStructureForSave(type?.structure ?? null)
} catch (error) {
console.error('Erreur lors du chargement du type de produit:', error)
productType.value = embedded ?? null
structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
}
}
const hydrateForm = () => {
if (!product.value) {
return
}
editionForm.name = product.value.name || ''
editionForm.reference = product.value.reference || ''
// Load constructeur links
fetchLinks('product', String(route.params.id)).then((links) => {
constructeurLinks.value = links
originalConstructeurLinks.value = links.map(l => ({ ...l }))
editionForm.constructeurIds = constructeurIdsFromLinks(links)
if (editionForm.constructeurIds.length) {
void ensureConstructeurs(editionForm.constructeurIds)
}
})
editionForm.supplierPrice = product.value.supplierPrice !== null && product.value.supplierPrice !== undefined
? String(product.value.supplierPrice)
: ''
refreshCustomFieldInputs(structure.value, product.value.customFieldValues)
}
watch(
() => product.value?.documents,
(docs) => {
if (Array.isArray(docs)) {
productDocuments.value = docs
}
},
{ immediate: true },
)
const submitEdition = async () => {
if (!product.value) {
return
}
const payload: Record<string, any> = {
name: editionForm.name.trim(),
reference: editionForm.reference.trim() || null,
}
const rawPrice = typeof editionForm.supplierPrice === 'string'
? editionForm.supplierPrice.trim()
: editionForm.supplierPrice
payload.supplierPrice = rawPrice !== '' && rawPrice !== null && rawPrice !== undefined
? Number.isNaN(Number(rawPrice))
? null
: String(Number(rawPrice))
: null
saving.value = true
try {
const result = await updateProduct(product.value.id, payload)
if (result.success && result.data?.id) {
product.value = result.data
const failedFields = await _saveCustomFieldValues(
'product',
result.data.id,
[result.data?.typeProduct?.structure?.customFields],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
if (failedFields.length) {
toast.showError(`Impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
return
}
await syncLinks('product', product.value.id, originalConstructeurLinks.value, constructeurLinks.value)
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
toast.showSuccess('Produit mis à jour avec succès')
await loadProduct()
isEditMode.value = false
}
} catch (error: any) {
toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')
} finally {
saving.value = false
}
}
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
watch(
() => editionForm.constructeurIds,
(ids) => {
const currentIds = new Set(constructeurLinks.value.map(l => l.constructeurId))
for (const id of ids) {
if (!currentIds.has(id)) {
const resolved = getConstructeurById(id)
constructeurLinks.value.push({
constructeurId: id,
constructeur: resolved ? { id: resolved.id, name: resolved.name } : null,
supplierReference: null,
})
}
}
constructeurLinks.value = constructeurLinks.value.filter(l => ids.includes(l.constructeurId))
},
)
onMounted(async () => {
await loadProduct()
if (route.query.edit === 'true' && canEdit.value) {
isEditMode.value = true
}
})
</script>

View File

@@ -79,6 +79,11 @@
</div> </div>
</div> </div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
@@ -175,7 +180,10 @@ import { useToast } from '~/composables/useToast'
import { useCustomFields } from '~/composables/useCustomFields' import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils' import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils' import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { useConstructeurs } from '~/composables/useConstructeurs'
import type { ProductModelStructure } from '~/shared/types/inventory' import type { ProductModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes' import type { ModelType } from '~/services/modelTypes'
import { import {
@@ -197,6 +205,8 @@ const toast = useToast()
const { upsertCustomFieldValue } = useCustomFields() const { upsertCustomFieldValue } = useCustomFields()
const { uploadDocuments } = useDocuments() const { uploadDocuments } = useDocuments()
const { canEdit } = usePermissions() const { canEdit } = usePermissions()
const { syncLinks } = useConstructeurLinks()
const { getConstructeurById } = useConstructeurs()
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '') const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
const selectedTypeId = ref<string>(initialTypeId.value) const selectedTypeId = ref<string>(initialTypeId.value)
@@ -207,6 +217,7 @@ const creationForm = reactive({
constructeurIds: [] as string[], constructeurIds: [] as string[],
supplierPrice: '' as string, supplierPrice: '' as string,
}) })
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
const selectedDocuments = ref<File[]>([]) const selectedDocuments = ref<File[]>([])
const uploadingDocuments = ref(false) const uploadingDocuments = ref(false)
@@ -300,8 +311,6 @@ const buildPayload = () => {
payload.reference = reference payload.reference = reference
} }
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
const rawPrice = typeof creationForm.supplierPrice === 'string' const rawPrice = typeof creationForm.supplierPrice === 'string'
? creationForm.supplierPrice.trim() ? creationForm.supplierPrice.trim()
: creationForm.supplierPrice : creationForm.supplierPrice
@@ -330,9 +339,13 @@ const submitCreation = async () => {
const failedFields = await saveCustomFieldValues(result.data.id) const failedFields = await saveCustomFieldValues(result.data.id)
if (failedFields.length) { if (failedFields.length) {
toast.showError(`Produit créé, mais impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`) toast.showError(`Produit créé, mais impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
await router.push(`/product/${result.data.id}/edit`) await router.replace(`/product/${result.data.id}?edit=true`)
return return
} }
// Sync constructeur links after creation
if (constructeurLinks.value.length) {
await syncLinks('product', productId, [], constructeurLinks.value)
}
if (selectedDocuments.value.length) { if (selectedDocuments.value.length) {
uploadingDocuments.value = true uploadingDocuments.value = true
const uploadResult = await uploadDocuments( const uploadResult = await uploadDocuments(
@@ -352,7 +365,7 @@ const submitCreation = async () => {
} }
} }
toast.showSuccess('Produit créé avec succès') toast.showSuccess('Produit créé avec succès')
await router.push('/product-catalog') await router.replace(`/product/${productId}?edit=true`)
} }
} catch (error: any) { } catch (error: any) {
toast.showError(error?.message || 'Erreur lors de la création du produit') toast.showError(error?.message || 'Erreur lors de la création du produit')
@@ -395,6 +408,25 @@ const saveCustomFieldValues = async (productId: string) => {
return failed return failed
} }
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
watch(
() => creationForm.constructeurIds,
(ids) => {
const currentIds = new Set(constructeurLinks.value.map(l => l.constructeurId))
for (const id of ids) {
if (!currentIds.has(id)) {
const resolved = getConstructeurById(id)
constructeurLinks.value.push({
constructeurId: id,
constructeur: resolved ? { id: resolved.id, name: resolved.name } : null,
supplierReference: null,
})
}
}
constructeurLinks.value = constructeurLinks.value.filter(l => ids.includes(l.constructeurId))
},
)
onMounted(async () => { onMounted(async () => {
await loadProductTypes() await loadProductTypes()
if (selectedTypeId.value && !selectedType.value) { if (selectedTypeId.value && !selectedType.value) {

View File

@@ -23,11 +23,15 @@ export interface BaseModelTypePayload {
export interface ComponentModelTypePayload extends BaseModelTypePayload { export interface ComponentModelTypePayload extends BaseModelTypePayload {
category: 'COMPONENT'; category: 'COMPONENT';
structure?: ComponentModelStructure | null; structure?: ComponentModelStructure | null;
referenceFormula?: string | null;
requiredFieldsForReference?: string[] | null;
} }
export interface PieceModelTypePayload extends BaseModelTypePayload { export interface PieceModelTypePayload extends BaseModelTypePayload {
category: 'PIECE'; category: 'PIECE';
structure?: PieceModelStructure | null; structure?: PieceModelStructure | null;
referenceFormula?: string | null;
requiredFieldsForReference?: string[] | null;
} }
export interface ProductModelTypePayload extends BaseModelTypePayload { export interface ProductModelTypePayload extends BaseModelTypePayload {
@@ -46,6 +50,8 @@ export interface ModelType extends BaseModelTypePayload {
updatedAt: string; updatedAt: string;
category: ModelCategory; category: ModelCategory;
structure: ModelTypeStructure; structure: ModelTypeStructure;
referenceFormula?: string | null;
requiredFieldsForReference?: string[] | null;
} }
export interface ModelTypeListParams { export interface ModelTypeListParams {

View File

@@ -7,6 +7,32 @@ export interface ConstructeurSummary {
phone?: string | null; phone?: string | null;
} }
export interface ConstructeurLinkEntry {
linkId?: string;
constructeurId: string;
constructeur?: ConstructeurSummary | null;
supplierReference: string | null;
}
export const constructeurIdsFromLinks = (links: ConstructeurLinkEntry[]): string[] =>
links.map(l => l.constructeurId).filter(Boolean);
export const parseConstructeurLinksFromApi = (
apiLinks: any[],
): ConstructeurLinkEntry[] => {
if (!Array.isArray(apiLinks)) return [];
return apiLinks
.filter(link => link && typeof link === 'object')
.map(link => ({
linkId: link.id || (typeof link['@id'] === 'string' ? link['@id'].split('/').pop() : undefined),
constructeurId: typeof link.constructeur === 'string'
? link.constructeur.split('/').pop()!
: link.constructeur?.id || '',
constructeur: typeof link.constructeur === 'object' ? link.constructeur : null,
supplierReference: link.supplierReference ?? null,
}));
};
const isObject = (value: unknown): value is Record<string, unknown> => const isObject = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value); Boolean(value) && typeof value === 'object' && !Array.isArray(value);
@@ -113,51 +139,3 @@ export const formatConstructeurContact = (
return [constructeur.email, phone].filter(Boolean).join(' • '); return [constructeur.email, phone].filter(Boolean).join(' • ');
}; };
export const buildConstructeurRequestPayload = <T extends Record<string, any>>(
payload: T,
): T & { constructeurs?: string[] } => {
const collected = new Set(uniqueConstructeurIds(
payload?.constructeurIds,
payload?.constructeurId,
payload?.constructeur,
payload?.constructeurs,
));
if (!collected.size) {
const fallbackLists = [
payload?.constructeurIds,
payload?.constructeurs,
];
fallbackLists.forEach((list) => {
if (!Array.isArray(list)) {
return;
}
list.forEach((item) => {
if (typeof item === 'string') {
const id = toStringId(item);
if (id) {
collected.add(id);
}
return;
}
if (isObject(item) && typeof item.id === 'string') {
collected.add(item.id);
}
});
});
}
const ids = Array.from(collected);
const next = { ...payload } as Record<string, any>;
delete next.constructeurId;
delete next.constructeur;
delete next.constructeurs;
delete next.constructeurIds;
if (ids.length) {
next.constructeurs = ids.map((id) => `/api/constructeurs/${id}`);
}
return next as T & { constructeurs?: string[] };
};

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

@@ -86,6 +86,19 @@ const sanitizePieceCustomFields = (fields: any[]): PieceModelCustomField[] => {
if (options) { if (options) {
result.options = options result.options = options
} }
const defaultValue =
field?.defaultValue !== undefined && field?.defaultValue !== null && field?.defaultValue !== ''
? String(field.defaultValue)
: null
if (defaultValue !== null) {
result.defaultValue = defaultValue
}
if (typeof field?.id === 'string' && field.id) {
result.id = field.id
}
if (typeof field?.customFieldId === 'string' && field.customFieldId) {
result.customFieldId = field.customFieldId
}
const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index
result.orderIndex = orderIndex result.orderIndex = orderIndex
return result return result
@@ -125,6 +138,8 @@ const hydratePieceCustomFields = (fields: any[]): PieceModelStructureEditorField
? field.options.join('\n') ? field.options.join('\n')
: '', : '',
orderIndex: typeof field?.orderIndex === 'number' ? field.orderIndex : index, orderIndex: typeof field?.orderIndex === 'number' ? field.orderIndex : index,
...(field?.id ? { id: field.id } : {}),
...(field?.customFieldId ? { customFieldId: field.customFieldId } : {}),
})) }))
} }

View File

@@ -61,6 +61,8 @@ export interface PieceModelCustomField {
key?: string key?: string
value?: unknown value?: unknown
defaultValue?: string | null defaultValue?: string | null
id?: string
customFieldId?: string
} }
export interface PieceModelProduct { export interface PieceModelProduct {

View File

@@ -12,6 +12,7 @@
export const historyActionLabel = (action: string): string => { export const historyActionLabel = (action: string): string => {
if (action === 'create') return 'Création' if (action === 'create') return 'Création'
if (action === 'delete') return 'Suppression' if (action === 'delete') return 'Suppression'
if (action === 'restore') return 'Restauration'
return 'Modification' return 'Modification'
} }

View File

@@ -85,7 +85,8 @@ export const componentOptionLabel = (component?: ComponentOption | null): string
if (!component) { if (!component) {
return 'Composant sans nom' return 'Composant sans nom'
} }
return component.name || 'Composant sans nom' const name = component.name || 'Composant sans nom'
return component.reference ? `${name}${component.reference}` : name
} }
export const componentOptionDescription = (component?: ComponentOption | null): string => { export const componentOptionDescription = (component?: ComponentOption | null): string => {
@@ -110,9 +111,10 @@ export const componentOptionDescription = (component?: ComponentOption | null):
export const pieceOptionLabel = (piece?: PieceOption | null): string => { export const pieceOptionLabel = (piece?: PieceOption | null): string => {
if (!piece) { if (!piece) {
return 'Pi\u00e8ce' return 'Pièce'
} }
return piece.name || 'Pi\u00e8ce' const name = piece.name || 'Pièce'
return piece.reference ? `${name}${piece.reference}` : name
} }
export const pieceOptionDescription = (piece?: PieceOption | null): string => { export const pieceOptionDescription = (piece?: PieceOption | null): string => {
@@ -139,7 +141,8 @@ export const productOptionLabel = (product?: ProductOption | null): string => {
if (!product) { if (!product) {
return 'Produit' return 'Produit'
} }
return product.name || product.reference || 'Produit' const name = product.name || 'Produit'
return product.reference ? `${name}${product.reference}` : name
} }
export const productOptionDescription = (product?: ProductOption | null): string => { export const productOptionDescription = (product?: ProductOption | null): string => {

View File

@@ -52,10 +52,10 @@ export default defineNuxtConfig({
appVersion: appVersion, appVersion: appVersion,
apiTimeout: process.env.NUXT_PUBLIC_API_TIMEOUT || '30000', apiTimeout: process.env.NUXT_PUBLIC_API_TIMEOUT || '30000',
requestTimeout: process.env.NUXT_PUBLIC_REQUEST_TIMEOUT || '10000', requestTimeout: process.env.NUXT_PUBLIC_REQUEST_TIMEOUT || '10000',
enableDebug: process.env.NUXT_PUBLIC_ENABLE_DEBUG || 'true', enableDebug: process.env.NUXT_PUBLIC_ENABLE_DEBUG || 'false',
enableAnalytics: process.env.NUXT_PUBLIC_ENABLE_ANALYTICS || 'false', enableAnalytics: process.env.NUXT_PUBLIC_ENABLE_ANALYTICS || 'false',
csrfToken: process.env.NUXT_PUBLIC_CSRF_TOKEN || '', csrfToken: process.env.NUXT_PUBLIC_CSRF_TOKEN || '',
logLevel: process.env.NUXT_PUBLIC_LOG_LEVEL || 'debug' logLevel: process.env.NUXT_PUBLIC_LOG_LEVEL || 'warn'
} }
}, },
vite: { vite: {