26 Commits

Author SHA1 Message Date
98f5d983b3 feat(machine) : add custom field definition editor on machine detail page
Adds UI to create, edit, reorder and delete custom field definitions
directly from the machine detail page in edit mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:34:35 +01:00
cda872a057 fix(config) : disable pathPrefix for component auto-imports 2026-03-08 17:48:11 +01:00
84970a352d refactor(frontend) : extract ProductDocumentsInline to reduce PieceItem under 500 lines
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 17:30:45 +01:00
c1d14124ff refactor(frontend) : trim product edit page under 500 lines
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 17:30:41 +01:00
a83a4428c2 refactor(frontend) : extract piece edit page logic into composable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 17:28:04 +01:00
a1998d7966 refactor(frontend) : extract component create page logic into composable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 17:24:50 +01:00
6add558725 refactor(frontend) : extract component edit page logic into composable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 17:23:46 +01:00
e18ce984e7 refactor(frontend) : extract shared piece product selection utils
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 17:18:55 +01:00
d00e5c058b refactor(frontend) : extract RelatedItemsModal from ManagementView
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 17:13:27 +01:00
3b24dc128a refactor(frontend) : extract PieceModelStructureEditor logic into composable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 17:09:02 +01:00
c188bd7e8b refactor(frontend) : extract home page modals into components
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 15:14:31 +01:00
e911f169ce refactor(frontend) : extract assignment fetch logic into composable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 15:10:22 +01:00
9f9ad80c61 refactor(frontend) : extract StructureNodeEditor logic into composable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 15:04:48 +01:00
c831f65ef3 refactor(frontend) : split useMachineDetailData into focused composables
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 14:58:32 +01:00
81eb181000 refactor(frontend) : split componentStructure.ts into focused modules
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 14:43:15 +01:00
a3fde7a191 refactor(frontend) : extract CustomFieldDisplay shared component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 14:39:54 +01:00
b696b5aa1f refactor(frontend) : extract StructureSkeletonPreview shared component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 14:35:05 +01:00
c6db96dc76 refactor(frontend) : extract DocumentListInline shared component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 14:26:14 +01:00
165e0a6341 fix(ui) : prevent dropdown overflow clipping in DataTable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:34:52 +01:00
de7be1b9d0 refactor(frontend) : extract shared components and reduce file sizes
- Extract CustomFieldInputGrid.vue from 6 duplicated template blocks (~70 lines each)
- Extract EntityHistorySection.vue from 3 identical history sections in edit pages
- Extract useDragReorder composable from 4 identical drag-and-drop implementations in StructureNodeEditor (~330 lines → ~30)
- Extract catalogDisplayUtils.ts (resolvePrimaryDocument, resolveSupplierNames, buildSuppliersDisplay)
- Remove redundant computed wrappers (historyEntries, loadingTypes, selectedFiles)
- Remove unused imports (fieldKey, historyActionLabel, formatHistoryDate, *HistoryEntry types)
- Move Intl.DateTimeFormat to module-level in date.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 02:28:26 +01:00
7b3eb1c5fc refactor(catalog) : extract shared delete impact logic and cleanup dead code
Extract duplicated resolveDeleteImpact/buildDeleteMessage into shared utility,
remove redundant computed wrappers, fix indentation, and remove dead code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 01:35:21 +01:00
Matthieu
592beb0fa7 fix(ui) : move add buttons below last element in structure editors
Place "Ajouter" buttons after the items list instead of in the section
header, so they always appear below the last added element.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:28:07 +01:00
Matthieu
e732585e63 fix(catalog) : add delete impact confirmation to product catalog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:06:06 +01:00
Matthieu
f1cc21c31b docs(changelog) : add delete confirmation dialog entry
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:59:43 +01:00
Matthieu
6c2f84dd3a fix(catalog) : replace blocking delete guard with confirmation dialog
Show cascade-delete impact (documents, machine links, custom fields)
in a confirmation modal instead of blocking deletion entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:58:41 +01:00
Matthieu
032b3b33c9 docs(changelog) : add v1.8.1 release notes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:39:01 +01:00
65 changed files with 8678 additions and 7644 deletions

View File

@@ -1,6 +1,6 @@
<template>
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
<LayoutAppNavbar
<AppNavbar
@open-settings="displaySettingsOpen = true"
@logout="handleLogout"
/>
@@ -9,7 +9,7 @@
<ToastContainer />
<CommonConfirmModal />
<ConfirmModal />
<DisplaySettings
:is-open="displaySettingsOpen"

View File

@@ -213,79 +213,12 @@
</div>
<!-- Custom Fields Display - Editable or Read-only -->
<div v-if="displayedCustomFields.length" class="mt-4 pt-4 border-t border-gray-200">
<h4 class="font-semibold text-sm text-gray-700 mb-3">
Champs personnalisés
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="(field, index) in displayedCustomFields"
:key="resolveFieldKey(field, index)"
class="form-control"
>
<label class="label">
<span class="label-text text-sm">{{ resolveFieldName(field) }}</span>
<span v-if="resolveFieldRequired(field)" class="label-text-alt text-error">*</span>
</label>
<template v-if="isEditMode && !resolveFieldReadOnly(field)">
<input
v-if="resolveFieldType(field) === 'text'"
v-model="field.value"
type="text"
class="input input-bordered input-sm"
:required="resolveFieldRequired(field)"
@blur="updateComponentCustomField(field)"
>
<input
v-else-if="resolveFieldType(field) === 'number'"
v-model="field.value"
type="number"
class="input input-bordered input-sm"
:required="resolveFieldRequired(field)"
@blur="updateComponentCustomField(field)"
>
<select
v-else-if="resolveFieldType(field) === 'select'"
v-model="field.value"
class="select select-bordered select-sm"
:required="resolveFieldRequired(field)"
@change="updateComponentCustomField(field)"
>
<option value="">
Sélectionner...
</option>
<option v-for="option in resolveFieldOptions(field)" :key="option" :value="option">
{{ option }}
</option>
</select>
<div v-else-if="resolveFieldType(field) === 'boolean'" class="flex items-center gap-2">
<input
v-model="field.value"
type="checkbox"
class="checkbox checkbox-sm"
true-value="true"
false-value="false"
@change="updateComponentCustomField(field)"
>
<span class="text-sm">{{ String(field.value).toLowerCase() === 'true' ? 'Oui' : 'Non' }}</span>
</div>
<input
v-else-if="resolveFieldType(field) === 'date'"
v-model="field.value"
type="date"
class="input input-bordered input-sm"
:required="resolveFieldRequired(field)"
@blur="updateComponentCustomField(field)"
>
</template>
<template v-else>
<div class="input input-bordered input-sm bg-base-200">
{{ formatFieldDisplayValue(field) }}
</div>
</template>
</div>
</div>
</div>
<CustomFieldDisplay
:fields="displayedCustomFields"
:is-edit-mode="isEditMode"
:columns="2"
@field-blur="updateComponentCustomField"
/>
<div class="mt-4 pt-4 border-t border-gray-200 space-y-3">
<div class="flex items-center justify-between">
@@ -309,74 +242,14 @@
@files-added="handleFilesAdded"
/>
<div v-if="componentDocuments.length" class="space-y-2">
<div
v-for="document in componentDocuments"
:key="document.id"
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex items-center gap-3 text-sm">
<div
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center"
:class="documentThumbnailClass(document)"
>
<img
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-6 w-6"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<div>
<div class="font-medium">
{{ document.name }}
</div>
<div class="text-xs text-gray-500">
{{ document.mimeType || 'Inconnu' }} {{ formatSize(document.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
@click="openPreview(document)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
Télécharger
</button>
<button
v-if="isEditMode"
type="button"
class="btn btn-error btn-xs"
:disabled="uploadingDocuments"
@click="removeDocument(document.id)"
>
Supprimer
</button>
</div>
</div>
</div>
<p v-else-if="!loadingDocuments" class="text-xs text-gray-500">
Aucun document lié à ce composant.
</p>
<DocumentListInline
:documents="componentDocuments"
:can-delete="isEditMode"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document lié à ce composant."
@preview="openPreview"
@delete="removeDocument"
/>
</div>
<!-- Component Pieces -->
@@ -438,19 +311,9 @@ import {
formatSize,
shouldInlinePdf,
documentPreviewSrc,
documentThumbnailClass,
documentIcon,
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
import {
resolveFieldKey,
resolveFieldName,
resolveFieldType,
resolveFieldOptions,
resolveFieldRequired,
resolveFieldReadOnly,
formatFieldDisplayValue,
} from '~/shared/utils/entityCustomFieldLogic'
import { useEntityDocuments } from '~/composables/useEntityDocuments'
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'

View File

@@ -134,76 +134,24 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { computed } from 'vue';
import SearchSelect from '~/components/common/SearchSelect.vue';
import { useApi } from '~/composables/useApi';
import { extractCollection } from '~/shared/utils/apiHelpers';
import { useStructureAssignmentFetch } from '~/composables/useStructureAssignmentFetch';
import type {
ComponentModelPiece,
ComponentModelProduct,
ComponentModelStructureNode,
} from '~/shared/types/inventory';
ComponentOption,
PieceOption,
ProductOption,
} from '~/composables/useStructureAssignmentFetch';
interface ComponentOption {
id: string;
name?: string | null;
reference?: string | null;
typeComposantId?: string | null;
typeComposant?: {
id: string;
name?: string | null;
code?: string | null;
} | null;
}
interface PieceOption {
id: string;
name?: string | null;
reference?: string | null;
typePieceId?: string | null;
typePiece?: {
id: string;
name?: string | null;
code?: string | null;
} | null;
}
interface ProductOption {
id: string;
name?: string | null;
reference?: string | null;
typeProductId?: string | null;
typeProduct?: {
id: string;
name?: string | null;
code?: string | null;
} | null;
}
export interface StructurePieceAssignment {
path: string;
definition: ComponentModelPiece;
selectedPieceId: string;
}
export interface StructureProductAssignment {
path: string;
definition: ComponentModelProduct;
selectedProductId: string;
}
export interface StructureAssignmentNode {
path: string;
definition: ComponentModelStructureNode;
selectedComponentId: string;
pieces: StructurePieceAssignment[];
products: StructureProductAssignment[];
subcomponents: StructureAssignmentNode[];
}
export type {
StructureAssignmentNode,
StructurePieceAssignment,
StructureProductAssignment,
} from '~/composables/useStructureAssignmentFetch';
const props = withDefaults(
defineProps<{
assignment: StructureAssignmentNode;
assignment: import('~/composables/useStructureAssignmentFetch').StructureAssignmentNode;
pieces: PieceOption[] | null;
products: ProductOption[] | null;
components: ComponentOption[] | null;
@@ -236,331 +184,46 @@ const wrapperClass = computed(() =>
depth.value === 0 ? 'space-y-6' : 'space-y-6 border-l border-base-300 pl-4',
);
const { get } = useApi();
const pieceOptionsByPath = ref<Record<string, PieceOption[]>>({});
const productOptionsByPath = ref<Record<string, ProductOption[]>>({});
const componentOptionsByPath = ref<Record<string, ComponentOption[]>>({});
const pieceLoadingByPath = ref<Record<string, boolean>>({});
const productLoadingByPath = ref<Record<string, boolean>>({});
const componentLoadingByPath = ref<Record<string, boolean>>({});
const setLoading = (target: Record<string, boolean>, key: string, value: boolean) => {
target[key] = value;
};
const componentOptions = computed(() => {
if (isRoot.value) {
return [];
}
const cached = componentOptionsByPath.value[props.assignment.path];
if (cached) {
return cached;
}
const definition = props.assignment.definition || {};
const requiredTypeId =
definition.typeComposantId || definition.modelId || null;
const requiredFamilyCode = definition.familyCode || null;
return (props.components || []).filter((component) => {
if (!component || typeof component !== 'object') {
return false;
}
if (requiredTypeId) {
return component.typeComposantId === requiredTypeId;
}
if (requiredFamilyCode) {
return (
component.typeComposant?.code === requiredFamilyCode ||
component.typeComposantId === requiredFamilyCode
);
}
return true;
});
const {
pieceLoadingByPath,
productLoadingByPath,
componentLoadingByPath,
componentOptions,
componentOptionLabel,
componentOptionDescription,
fetchComponentOptions,
getPieceOptions,
pieceOptionLabel,
pieceOptionDescription,
fetchPieceOptions,
describePieceRequirement,
getProductOptions,
productOptionLabel,
productOptionDescription,
fetchProductOptions,
describeProductRequirement,
} = useStructureAssignmentFetch({
assignment: props.assignment,
pieces: props.pieces,
products: props.products,
components: props.components,
isRoot: () => isRoot.value,
pieceTypeLabelMap: props.pieceTypeLabelMap ?? {},
productTypeLabelMap: props.productTypeLabelMap ?? {},
componentTypeLabelMap: props.componentTypeLabelMap ?? {},
});
const componentOptionLabel = (component?: ComponentOption | null) => {
if (!component) {
return 'Composant sans nom';
}
return component.name || 'Composant sans nom';
};
const componentOptionDescription = (component?: ComponentOption | null) => {
if (!component) {
const normalizeSelectionValue = (value: unknown) => {
if (value === null || value === undefined || value === '') {
return '';
}
const parts: string[] = [];
const typeLabel =
component.typeComposant?.name || component.typeComposant?.code || null;
if (typeLabel) {
parts.push(typeLabel);
if (typeof value === 'string') {
return value;
}
if (component.reference) {
parts.push(`Ref. ${component.reference}`);
if (typeof value === 'number') {
return String(value);
}
return parts.join(' • ');
};
const typeIri = (id: string) => `/api/model_types/${id}`;
const primedPiecePaths = new Set<string>();
const primedProductPaths = new Set<string>();
const primedComponentPaths = new Set<string>();
const fetchComponentOptions = async (term = '') => {
if (isRoot.value) {
return;
}
const key = props.assignment.path;
if (componentLoadingByPath.value[key]) {
return;
}
const definition = props.assignment.definition || {};
const requiredTypeId =
definition.typeComposantId || definition.modelId || definition.typeComposant?.id || null;
const params = new URLSearchParams();
params.set('itemsPerPage', '50');
if (term.trim()) {
params.set('name', term.trim());
}
if (requiredTypeId) {
params.set('typeComposant', typeIri(requiredTypeId));
}
setLoading(componentLoadingByPath.value, key, true);
try {
const result = await get(`/composants?${params.toString()}`);
if (result.success) {
componentOptionsByPath.value[key] = extractCollection(result.data);
}
} finally {
setLoading(componentLoadingByPath.value, key, false);
}
};
const fetchPieceOptions = async (assignment: StructurePieceAssignment, term = '') => {
const key = assignment.path;
if (pieceLoadingByPath.value[key]) {
return;
}
const definition = assignment.definition || {};
const requiredTypeId =
definition.typePieceId || definition.typePiece?.id || null;
const params = new URLSearchParams();
params.set('itemsPerPage', '50');
if (term.trim()) {
params.set('name', term.trim());
}
if (requiredTypeId) {
params.set('typePiece', typeIri(requiredTypeId));
}
setLoading(pieceLoadingByPath.value, key, true);
try {
const result = await get(`/pieces?${params.toString()}`);
if (result.success) {
pieceOptionsByPath.value[key] = extractCollection(result.data);
}
} finally {
setLoading(pieceLoadingByPath.value, key, false);
}
};
const fetchProductOptions = async (assignment: StructureProductAssignment, term = '') => {
const key = assignment.path;
if (productLoadingByPath.value[key]) {
return;
}
const definition = assignment.definition || {};
const requiredTypeId =
definition.typeProductId || definition.typeProduct?.id || null;
const params = new URLSearchParams();
params.set('itemsPerPage', '50');
if (term.trim()) {
params.set('name', term.trim());
}
if (requiredTypeId) {
params.set('typeProduct', typeIri(requiredTypeId));
}
setLoading(productLoadingByPath.value, key, true);
try {
const result = await get(`/products?${params.toString()}`);
if (result.success) {
productOptionsByPath.value[key] = extractCollection(result.data);
}
} finally {
setLoading(productLoadingByPath.value, key, false);
}
};
watch(
componentOptions,
(options) => {
if (isRoot.value) {
return;
}
const hasMatch = options.some(
(component) => component.id === props.assignment.selectedComponentId,
);
if (!hasMatch) {
props.assignment.selectedComponentId = '';
}
},
{ immediate: true },
);
const describePieceRequirement = (assignment: StructurePieceAssignment) => {
const definition = assignment.definition;
const parts: string[] = [];
const addPart = (value?: string | null) => {
const trimmed = typeof value === 'string' ? value.trim() : '';
if (trimmed && !parts.includes(trimmed)) {
parts.push(trimmed);
}
};
const options = getPieceOptions(assignment);
const fallbackPiece = options[0] || null;
const fallbackType = fallbackPiece?.typePiece || null;
addPart(definition.role);
const explicitLabel =
definition.typePieceLabel ||
definition.typePiece?.name ||
(definition.typePieceId ? props.pieceTypeLabelMap[definition.typePieceId] : null) ||
fallbackType?.name;
addPart(explicitLabel);
const family =
definition.familyCode ||
definition.typePiece?.code ||
fallbackType?.code ||
null;
if (family) {
addPart(`Famille ${family}`);
}
if (parts.length === 0) {
addPart(fallbackType?.name);
if (fallbackType?.code) {
addPart(`Famille ${fallbackType.code}`);
}
}
if (parts.length === 0 && definition.typePieceId) {
addPart(`#${definition.typePieceId}`);
}
return parts.length ? parts.join(' • ') : 'Pièce du squelette';
};
const getProductOptions = (assignment: StructureProductAssignment) => {
const cached = productOptionsByPath.value[assignment.path];
if (cached) {
return cached;
}
const definition = assignment.definition;
const requiredTypeId =
definition.typeProductId ||
definition.typeProduct?.id ||
definition.familyCode ||
null;
return (props.products || []).filter((product) => {
if (!product || typeof product !== 'object') {
return false;
}
if (!requiredTypeId) {
return true;
}
if (definition.typeProductId || definition.typeProduct?.id) {
return (
product.typeProductId === requiredTypeId ||
product.typeProduct?.id === requiredTypeId
);
}
if (definition.familyCode) {
return (
product.typeProduct?.code === requiredTypeId ||
product.typeProductId === requiredTypeId
);
}
return false;
});
};
const productOptionLabel = (product?: ProductOption | null) => {
if (!product) {
return 'Produit';
}
return product.name || product.reference || 'Produit';
};
const productOptionDescription = (product?: ProductOption | null) => {
if (!product) {
return '';
}
const parts: string[] = [];
const typeLabel =
product.typeProduct?.name || product.typeProduct?.code || null;
if (typeLabel) {
parts.push(typeLabel);
}
if (product.reference) {
parts.push(`Ref. ${product.reference}`);
}
return parts.join(' • ');
};
const describeProductRequirement = (assignment: StructureProductAssignment) => {
const definition = assignment.definition;
const parts: string[] = [];
const addPart = (value?: string | null) => {
const trimmed = typeof value === 'string' ? value.trim() : '';
if (trimmed && !parts.includes(trimmed)) {
parts.push(trimmed);
}
};
const options = getProductOptions(assignment);
const fallbackProduct = options[0] || null;
const fallbackType = fallbackProduct?.typeProduct || null;
addPart(definition.role);
const explicitLabel =
definition.typeProductLabel ||
definition.typeProduct?.name ||
(definition.typeProductId ? props.productTypeLabelMap[definition.typeProductId] : null) ||
fallbackType?.name;
addPart(explicitLabel);
const family =
definition.familyCode ||
definition.typeProduct?.code ||
fallbackType?.code ||
null;
if (family) {
addPart(`Famille ${family}`);
}
if (parts.length === 0) {
addPart(fallbackType?.name);
if (fallbackType?.code) {
addPart(`Famille ${fallbackType.code}`);
}
}
if (parts.length === 0 && definition.typeProductId) {
addPart(`#${definition.typeProductId}`);
}
return parts.length ? parts.join(' • ') : 'Produit du squelette';
return '';
};
const requirementLabel = computed(() => {
@@ -584,139 +247,13 @@ const requirementLabel = computed(() => {
const requirementDescription = computed(() => {
const definition = props.assignment.definition || {};
const family =
definition.typeComposantLabel ||
(definition.typeComposantId ? props.componentTypeLabelMap[definition.typeComposantId] : null) ||
definition.typeComposant?.name ||
definition.familyCode;
definition.typeComposantLabel
|| (definition.typeComposantId ? props.componentTypeLabelMap[definition.typeComposantId] : null)
|| definition.typeComposant?.name
|| definition.familyCode;
if (family) {
return `Doit appartenir à la famille "${family}".`;
}
return 'Sélectionnez un composant enfant conforme à cette position.';
});
const getPieceOptions = (assignment: StructurePieceAssignment) => {
const cached = pieceOptionsByPath.value[assignment.path];
if (cached) {
return cached;
}
const definition = assignment.definition;
const requiredTypeId =
definition.typePieceId ||
definition.typePiece?.id ||
definition.familyCode ||
null;
return (props.pieces || []).filter((piece) => {
if (!piece || typeof piece !== 'object') {
return false;
}
if (!requiredTypeId) {
return true;
}
if (definition.typePieceId || definition.typePiece?.id) {
return (
piece.typePieceId === requiredTypeId ||
piece.typePiece?.id === requiredTypeId
);
}
if (definition.familyCode) {
return (
piece.typePiece?.code === requiredTypeId ||
piece.typePieceId === requiredTypeId
);
}
return false;
});
};
const pieceOptionLabel = (piece?: PieceOption | null) => {
if (!piece) {
return 'Pièce';
}
return piece.name || 'Pièce';
};
const pieceOptionDescription = (piece?: PieceOption | null) => {
if (!piece) {
return '';
}
const parts: string[] = [];
const typeLabel =
piece.typePiece?.name || piece.typePiece?.code || null;
if (typeLabel) {
parts.push(typeLabel);
}
if (piece.reference) {
parts.push(`Ref. ${piece.reference}`);
}
return parts.join(' • ');
};
const normalizeSelectionValue = (value: unknown) => {
if (value === null || value === undefined || value === '') {
return '';
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number') {
return String(value);
}
return '';
};
watch(
() => [props.pieces, props.assignment.pieces],
() => {
for (const pieceAssignment of props.assignment.pieces) {
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]) {
primedPiecePaths.add(pieceAssignment.path);
fetchPieceOptions(pieceAssignment).catch(() => {});
}
}
},
{ deep: true, immediate: true },
);
watch(
() => [props.products, props.assignment.products],
() => {
for (const productAssignment of props.assignment.products) {
const options = getProductOptions(productAssignment);
if (
productAssignment.selectedProductId &&
!options.some((product) => product.id === productAssignment.selectedProductId)
) {
productAssignment.selectedProductId = '';
}
if (!primedProductPaths.has(productAssignment.path) && !productOptionsByPath.value[productAssignment.path]) {
primedProductPaths.add(productAssignment.path);
fetchProductOptions(productAssignment).catch(() => {});
}
}
},
{ deep: true, immediate: true },
);
watch(
() => props.assignment.definition,
() => {
if (isRoot.value) {
return;
}
const key = props.assignment.path;
if (!primedComponentPaths.has(key) && !componentOptionsByPath.value[key]) {
primedComponentPaths.add(key);
fetchComponentOptions().catch(() => {});
}
},
{ immediate: true },
);
</script>

View File

@@ -130,7 +130,7 @@ const cleanupRemovedPreviews = (previousFiles = [], nextFiles = []) => {
})
}
const selectedFiles = computed(() => internalFiles.value)
const selectedFiles = internalFiles
watch(
() => props.modelValue,

View File

@@ -165,65 +165,10 @@
<span class="font-semibold">{{ info.label }} :</span>
<span class="ml-1">{{ info.value }}</span>
</p>
<div
v-if="productDocuments.length"
class="mt-2 space-y-2 rounded-md border border-base-200 bg-base-100 p-3 text-xs"
>
<h5 class="font-medium text-base-content">Documents du produit</h5>
<div
v-for="document in productDocuments"
:key="document.id || document.path || document.name"
class="flex items-center justify-between gap-3 rounded border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex items-center gap-3">
<div
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-10 w-8"
>
<img
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-5 w-5"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<div>
<div class="font-medium text-base-content">
{{ document.name }}
</div>
<div class="text-xs text-base-content/70">
{{ document.mimeType || 'Inconnu' }} {{ formatSize(document.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
@click="openPreview(document)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
Télécharger
</button>
</div>
</div>
</div>
<ProductDocumentsInline
:documents="productDocuments"
@preview="openPreview"
/>
</div>
<span v-else class="font-medium">
Non défini
@@ -234,143 +179,12 @@
</div>
<!-- Champs personnalisés de la pièce -->
<div
v-if="displayedCustomFields.length"
class="mt-4 pt-4 border-t border-gray-200"
>
<h5 class="text-sm font-medium text-gray-700 mb-3">
Champs personnalisés
</h5>
<div class="space-y-3">
<div
v-for="(field, index) in displayedCustomFields"
:key="resolveFieldKey(field, index)"
class="form-control"
>
<label class="label">
<span class="label-text text-sm">{{
resolveFieldName(field)
}}</span>
<span
v-if="resolveFieldRequired(field)"
class="label-text-alt text-error"
>*</span
>
</label>
<!-- Mode édition -->
<template v-if="isEditMode && !resolveFieldReadOnly(field)">
<!-- Champ de type TEXT -->
<input
v-if="resolveFieldType(field) === 'text'"
:value="field.value ?? ''"
type="text"
class="input input-bordered input-sm"
:required="resolveFieldRequired(field)"
@input="
setCustomFieldValue(
resolveFieldId(field),
$event.target.value,
field
)
"
@blur="updateCustomFieldValue(resolveFieldId(field), field)"
/>
<!-- Champ de type NUMBER -->
<input
v-else-if="resolveFieldType(field) === 'number'"
:value="field.value ?? ''"
type="number"
class="input input-bordered input-sm"
:required="resolveFieldRequired(field)"
@input="
setCustomFieldValue(
resolveFieldId(field),
$event.target.value,
field
)
"
@blur="updateCustomFieldValue(resolveFieldId(field), field)"
/>
<!-- Champ de type SELECT -->
<select
v-else-if="resolveFieldType(field) === 'select'"
:value="field.value ?? ''"
class="select select-bordered select-sm"
:required="resolveFieldRequired(field)"
@change="
(event) =>
setCustomFieldValue(
resolveFieldId(field),
event.target.value,
field
)
"
@blur="updateCustomFieldValue(resolveFieldId(field), field)"
>
<option value="">Sélectionner...</option>
<option
v-for="option in resolveFieldOptions(field)"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<!-- Champ de type BOOLEAN -->
<div
v-else-if="resolveFieldType(field) === 'boolean'"
class="flex items-center gap-2"
>
<input
:value="field.value ?? ''"
type="checkbox"
class="checkbox checkbox-sm"
:checked="String(field.value).toLowerCase() === 'true'"
@change="
setCustomFieldValue(
resolveFieldId(field),
$event.target.checked ? 'true' : 'false',
field
)
"
@blur="updateCustomFieldValue(resolveFieldId(field), field)"
/>
<span class="text-sm">{{
String(field.value).toLowerCase() === "true" ? "Oui" : "Non"
}}</span>
</div>
<!-- Champ de type DATE -->
<input
v-else-if="resolveFieldType(field) === 'date'"
:value="field.value ?? ''"
type="date"
class="input input-bordered input-sm"
:required="resolveFieldRequired(field)"
@input="
setCustomFieldValue(
resolveFieldId(field),
$event.target.value,
field
)
"
@blur="updateCustomFieldValue(resolveFieldId(field), field)"
/>
</template>
<!-- Mode lecture seule -->
<template v-else>
<div class="input input-bordered input-sm bg-base-200">
{{ formatFieldDisplayValue(field) }}
</div>
</template>
</div>
</div>
</div>
<CustomFieldDisplay
:fields="displayedCustomFields"
:is-edit-mode="isEditMode"
@field-input="handleCustomFieldInput"
@field-blur="handleCustomFieldBlur"
/>
<div class="mt-4 pt-4 border-t border-gray-200 space-y-3">
<div class="flex items-center justify-between">
@@ -398,83 +212,14 @@
@files-added="handleFilesAdded"
/>
<div v-if="pieceDocuments.length" class="space-y-2">
<div
v-for="document in pieceDocuments"
:key="document.id"
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex items-center gap-3 text-sm">
<div
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center"
:class="documentThumbnailClass(document)"
>
<img
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-6 w-6"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<div>
<div class="font-medium">
{{ document.name }}
</div>
<div class="text-xs text-gray-500">
{{ document.mimeType || "Inconnu" }}
{{ formatSize(document.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
:title="
canPreviewDocument(document)
? 'Consulter le document'
: 'Aucun aperçu disponible pour ce type'
"
@click="openPreview(document)"
>
Consulter
</button>
<button
type="button"
class="btn btn-ghost btn-xs"
@click="downloadDocument(document)"
>
Télécharger
</button>
<button
v-if="isEditMode"
type="button"
class="btn btn-error btn-xs"
:disabled="uploadingDocuments"
@click="removeDocument(document.id)"
>
Supprimer
</button>
</div>
</div>
</div>
<p v-else-if="!loadingDocuments" class="text-xs text-gray-500">
Aucun document lié à cette pièce.
</p>
<DocumentListInline
:documents="pieceDocuments"
:can-delete="isEditMode"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document lié à cette pièce."
@preview="openPreview"
@delete="removeDocument"
/>
</div>
</div>
</div>
@@ -487,7 +232,6 @@ import ProductSelect from '~/components/ProductSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useProducts } from '~/composables/useProducts'
import {
@@ -496,22 +240,8 @@ import {
uniqueConstructeurIds,
} from '~/shared/constructeurUtils'
import {
formatSize,
shouldInlinePdf,
documentPreviewSrc,
documentThumbnailClass,
documentIcon,
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
import {
resolveFieldKey,
resolveFieldId,
resolveFieldName,
resolveFieldType,
resolveFieldOptions,
resolveFieldRequired,
resolveFieldReadOnly,
formatFieldDisplayValue,
} from '~/shared/utils/entityCustomFieldLogic'
import { useEntityDocuments } from '~/composables/useEntityDocuments'
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
@@ -665,16 +395,16 @@ const handleProductChange = async (value) => {
updatePiece()
}
// --- Custom field local helpers ---
const setCustomFieldValue = (fieldValueId, value, field) => {
// --- Custom field event handlers ---
const handleCustomFieldInput = (field, value) => {
if (resolveFieldReadOnly(field)) return
if (field && typeof field === 'object') field.value = value
const fieldValueId = resolveFieldId(field)
if (!fieldValueId) return
const fieldValue = props.piece.customFieldValues?.find((fv) => fv.id === fieldValueId)
if (fieldValue) fieldValue.value = value
}
const updateCustomFieldValue = async (_fieldValueId, field) => {
const handleCustomFieldBlur = async (field) => {
await updateCustomField(field)
const cfId = field?.customFieldId || field?.customField?.id || null
if (cfId || field?.customFieldValueId) {

View File

@@ -1,19 +1,13 @@
<template>
<div class="space-y-6">
<section class="space-y-3">
<header class="flex items-center justify-between">
<div>
<h3 class="text-sm font-semibold">
Produits inclus par défaut
</h3>
<p class="text-xs text-base-content/70">
Ces produits s'afficheront lors de la création d'une pièce basée sur cette catégorie.
</p>
</div>
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addProduct">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
<header>
<h3 class="text-sm font-semibold">
Produits inclus par défaut
</h3>
<p class="text-xs text-base-content/70">
Ces produits s'afficheront lors de la création d'une pièce basée sur cette catégorie.
</p>
</header>
<p v-if="!products.length" class="text-xs text-gray-500">
@@ -71,18 +65,16 @@
</div>
</li>
</ul>
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addProduct">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</section>
<section class="space-y-3">
<header class="flex items-center justify-between">
<h3 class="text-sm font-semibold">
Champs personnalisés
</h3>
<button type="button" class="btn btn-outline btn-xs" @click="addField">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</header>
<h3 class="text-sm font-semibold">
Champs personnalisés
</h3>
<p v-if="!fields.length" class="text-xs text-gray-500">
Aucun champ personnalisé n'a encore été défini.
@@ -101,106 +93,94 @@
@drop.prevent="onDrop(index)"
@dragend="onDragEnd"
>
<div class="flex items-start gap-3">
<button
type="button"
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing mt-1"
title="Réordonner"
draggable="false"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
<div class="flex-1 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<input
v-model="field.name"
type="text"
class="input input-bordered input-xs"
placeholder="Nom du champ"
>
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isFieldLocked(field)">
<option value="text">
Texte
</option>
<option value="number">
Nombre
</option>
<option value="select">
Liste
</option>
<option value="boolean">
Oui/Non
</option>
<option value="date">
Date
</option>
</select>
</div>
<div class="flex items-center gap-2 text-xs">
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" :disabled="isFieldLocked(field)">
Obligatoire
</div>
<textarea
v-if="field.type === 'select'"
v-model="field.optionsText"
class="textarea textarea-bordered textarea-xs h-20"
placeholder="Option 1&#10;Option 2"
:disabled="isFieldLocked(field)"
/>
</div>
<button
v-if="!isFieldLocked(field)"
type="button"
class="btn btn-error btn-xs btn-square"
@click="removeField(index)"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
<div v-else class="tooltip tooltip-left" data-tip="Ce champ ne peut pas être supprimé car des éléments utilisent cette catégorie">
<div class="flex items-start gap-3">
<button
type="button"
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
disabled
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing mt-1"
title="Réordonner"
draggable="false"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
<div class="flex-1 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<input
v-model="field.name"
type="text"
class="input input-bordered input-xs"
placeholder="Nom du champ"
>
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isFieldLocked(field)">
<option value="text">
Texte
</option>
<option value="number">
Nombre
</option>
<option value="select">
Liste
</option>
<option value="boolean">
Oui/Non
</option>
<option value="date">
Date
</option>
</select>
</div>
<div class="flex items-center gap-2 text-xs">
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" :disabled="isFieldLocked(field)">
Obligatoire
</div>
<textarea
v-if="field.type === 'select'"
v-model="field.optionsText"
class="textarea textarea-bordered textarea-xs h-20"
placeholder="Option 1&#10;Option 2"
:disabled="isFieldLocked(field)"
/>
</div>
<button
v-if="!isFieldLocked(field)"
type="button"
class="btn btn-error btn-xs btn-square"
@click="removeField(index)"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
<div v-else class="tooltip tooltip-left" data-tip="Ce champ ne peut pas être supprimé car des éléments utilisent cette catégorie">
<button
type="button"
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
disabled
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
</li>
</ul>
</li>
</ul>
<button type="button" class="btn btn-outline btn-xs" @click="addField">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash from '~icons/lucide/trash'
import type {
PieceModelCustomField,
PieceModelCustomFieldType,
PieceModelProduct,
PieceModelStructure,
PieceModelStructureEditorField,
} from '~/shared/types/inventory'
import { normalizePieceStructureForSave } from '~/shared/modelUtils'
import { useProductTypes } from '~/composables/useProductTypes'
import type { PieceModelStructure } from '~/shared/types/inventory'
import { usePieceStructureEditorLogic } from '~/composables/usePieceStructureEditorLogic'
defineOptions({ name: 'PieceModelStructureEditor' })
type EditorField = PieceModelStructureEditorField & { uid: string }
type EditorProduct = {
uid: string
typeProductId: string
typeProductLabel: string
familyCode: string
}
const props = defineProps<{
modelValue?: PieceModelStructure | null
restrictedMode?: boolean
@@ -210,373 +190,23 @@ const emit = defineEmits<{
(event: 'update:modelValue', value: PieceModelStructure): void
}>()
const { productTypes, loadProductTypes } = useProductTypes()
const ensureArray = <T,>(value: T[] | null | undefined): T[] =>
Array.isArray(value) ? value : []
const normalizeLineEndings = (value: string): string =>
value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
const safeClone = <T,>(value: T, fallback: T): T => {
try {
return JSON.parse(JSON.stringify(value ?? fallback)) as T
} catch {
return JSON.parse(JSON.stringify(fallback)) as T
}
}
const extractRest = (structure?: PieceModelStructure | null): Record<string, unknown> => {
if (!structure || typeof structure !== 'object') {
return {}
}
const entries = Object.entries(structure).filter(
([key]) => key !== 'customFields' && key !== 'products',
)
return safeClone(Object.fromEntries(entries), {})
}
let uidCounter = 0
const createUid = (scope: 'field' | 'product'): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
uidCounter += 1
return `piece-${scope}-${Date.now().toString(36)}-${uidCounter}`
}
const toEditorField = (
input: Partial<PieceModelStructureEditorField> | null | undefined,
index: number,
): EditorField => {
const baseType = typeof input?.type === 'string' && input.type ? input.type : 'text'
const optionsText = normalizeLineEndings(
typeof input?.optionsText === 'string'
? input.optionsText
: Array.isArray(input?.options)
? input.options.join('\n')
: '',
)
return {
uid: createUid('field'),
name: typeof input?.name === 'string' ? input.name : '',
type: baseType as PieceModelCustomFieldType,
required: Boolean(input?.required),
optionsText,
orderIndex: typeof input?.orderIndex === 'number' ? input.orderIndex : index,
}
}
const hydrateFields = (structure?: PieceModelStructure | null): EditorField[] => {
const source = ensureArray(structure?.customFields)
return source
.map((field, index) => toEditorField(field, index))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((field, index) => ({ ...field, orderIndex: index }))
}
const toEditorProduct = (
input: Partial<PieceModelProduct> | null | undefined,
): EditorProduct => ({
uid: createUid('product'),
typeProductId: typeof input?.typeProductId === 'string' ? input.typeProductId : '',
typeProductLabel:
typeof input?.typeProductLabel === 'string' ? input.typeProductLabel : '',
familyCode: typeof input?.familyCode === 'string' ? input.familyCode : '',
})
const hydrateProducts = (structure?: PieceModelStructure | null): EditorProduct[] => {
const source = Array.isArray(structure?.products) ? structure?.products : []
return source.map((product) => toEditorProduct(product))
}
const productTypeOptions = computed(() => productTypes.value ?? [])
const productTypeMap = computed(() => {
const map = new Map<string, any>()
productTypeOptions.value.forEach((type: any) => {
if (type?.id) {
map.set(type.id, type)
}
})
return map
})
const formatProductTypeOption = (type: any) => {
if (!type) {
return ''
}
const parts: string[] = []
if (type.code) {
parts.push(type.code)
}
if (type.name) {
parts.push(type.name)
}
return parts.length ? parts.join(' • ') : type.id || ''
}
const updateProductTypeMetadata = (product: EditorProduct) => {
const option = product.typeProductId
? productTypeMap.value.get(product.typeProductId)
: null
product.typeProductLabel = option?.name ?? ''
}
const handleProductTypeSelect = (product: EditorProduct) => {
const option = product.typeProductId
? productTypeMap.value.get(product.typeProductId)
: null
product.typeProductLabel = option?.name ?? ''
if (option?.code) {
product.familyCode = option.code
}
}
const createEmptyProduct = (): EditorProduct => ({
uid: createUid('product'),
typeProductId: '',
typeProductLabel: '',
familyCode: '',
})
const addProduct = () => {
products.value.push(createEmptyProduct())
}
const removeProduct = (index: number) => {
products.value = products.value.filter((_, idx) => idx !== index)
}
const fields = ref<EditorField[]>(hydrateFields(props.modelValue))
const products = ref<EditorProduct[]>(hydrateProducts(props.modelValue))
const restState = ref<Record<string, unknown>>(extractRest(props.modelValue))
const initialFieldUids = ref<Set<string>>(new Set(fields.value.map(f => f.uid)))
const initialProductUids = ref<Set<string>>(new Set(products.value.map(p => p.uid)))
const isFieldLocked = (field: EditorField): boolean => {
return props.restrictedMode === true && initialFieldUids.value.has(field.uid)
}
const isProductLocked = (product: EditorProduct): boolean => {
return props.restrictedMode === true && initialProductUids.value.has(product.uid)
}
const restrictedMode = computed(() => props.restrictedMode === true)
const applyOrderIndex = (list: EditorField[]): EditorField[] =>
list.map((field, index) => ({
...field,
orderIndex: index,
}))
const normalizeProductEntry = (product: EditorProduct): PieceModelProduct | null => {
const typeProductId = typeof product.typeProductId === 'string' ? product.typeProductId.trim() : ''
const familyCode = typeof product.familyCode === 'string' ? product.familyCode.trim() : ''
if (!typeProductId && !familyCode) {
return null
}
const payload: PieceModelProduct = {}
if (typeProductId) {
payload.typeProductId = typeProductId
}
if (familyCode) {
payload.familyCode = familyCode
}
if (product.typeProductLabel) {
payload.typeProductLabel = product.typeProductLabel
}
return payload
}
const buildPayload = (
fieldsSource: EditorField[],
productsSource: EditorProduct[],
restSource: Record<string, unknown>,
): PieceModelStructure => {
const normalizedFields = fieldsSource
.map<PieceModelCustomField | null>((field, index) => {
const name = field.name.trim()
if (!name) {
return null
}
const type = (field.type || 'text') as PieceModelCustomFieldType
const required = Boolean(field.required)
const payload: PieceModelCustomField = {
name,
type,
required,
orderIndex: index,
}
if (type === 'select') {
const options = normalizeLineEndings(field.optionsText)
.split('\n')
.map((option) => option.trim())
.filter((option) => option.length > 0)
if (options.length > 0) {
payload.options = options
}
}
return payload
})
.filter((field): field is PieceModelCustomField => Boolean(field))
const normalizedProducts = productsSource
.map((product) => normalizeProductEntry(product))
.filter((product): product is PieceModelProduct => Boolean(product))
const draft: PieceModelStructure = {
...safeClone(restSource, {}),
products: normalizedProducts,
customFields: normalizedFields,
}
return normalizePieceStructureForSave(draft)
}
const serializeStructure = (structure?: PieceModelStructure | null): string => {
return JSON.stringify(normalizePieceStructureForSave(structure ?? { customFields: [] }))
}
let lastEmitted = serializeStructure(props.modelValue)
const emitUpdate = () => {
const payload = buildPayload(fields.value, products.value, restState.value)
const serialized = JSON.stringify(payload)
if (serialized !== lastEmitted) {
lastEmitted = serialized
emit('update:modelValue', payload)
}
}
watch(fields, emitUpdate, { deep: true })
watch(products, emitUpdate, { deep: true })
watch(productTypeOptions, () => {
products.value.forEach((product) => updateProductTypeMetadata(product))
})
watch(
() => props.modelValue,
(value) => {
const incomingSerialized = serializeStructure(value)
if (incomingSerialized === lastEmitted) {
return
}
restState.value = extractRest(value)
fields.value = hydrateFields(value)
products.value = hydrateProducts(value)
products.value.forEach((product) => updateProductTypeMetadata(product))
lastEmitted = incomingSerialized
initialFieldUids.value = new Set(fields.value.map(f => f.uid))
initialProductUids.value = new Set(products.value.map(p => p.uid))
},
{ deep: true },
)
onMounted(async () => {
if (!productTypeOptions.value.length) {
await loadProductTypes()
}
products.value.forEach((product) => updateProductTypeMetadata(product))
})
const dragState = reactive({
draggingIndex: null as number | null,
dropTargetIndex: null as number | null,
})
const resetDragState = () => {
dragState.draggingIndex = null
dragState.dropTargetIndex = null
}
const reorderFields = (from: number, to: number) => {
if (from === to) {
resetDragState()
return
}
const list = fields.value.slice()
if (from < 0 || to < 0 || from >= list.length || to >= list.length) {
resetDragState()
return
}
const [moved] = list.splice(from, 1)
if (!moved) {
resetDragState()
return
}
list.splice(to, 0, moved)
fields.value = applyOrderIndex(list)
resetDragState()
}
const onDragStart = (index: number, event: DragEvent) => {
dragState.draggingIndex = index
dragState.dropTargetIndex = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onDragEnter = (index: number) => {
if (dragState.draggingIndex === null) {
return
}
dragState.dropTargetIndex = index
}
const onDrop = (index: number) => {
if (dragState.draggingIndex === null) {
resetDragState()
return
}
reorderFields(dragState.draggingIndex, index)
}
const onDragEnd = () => {
resetDragState()
}
const reorderClass = (index: number) => {
if (dragState.draggingIndex === index) {
return 'border-dashed border-primary bg-primary/5'
}
if (
dragState.draggingIndex !== null &&
dragState.dropTargetIndex === index &&
dragState.draggingIndex !== index
) {
return 'border-primary border-dashed bg-primary/10'
}
return ''
}
const createEmptyField = (orderIndex: number): EditorField => ({
uid: createUid('field'),
name: '',
type: 'text',
required: false,
optionsText: '',
orderIndex,
})
const addField = () => {
const next = fields.value.slice()
next.push(createEmptyField(next.length))
fields.value = applyOrderIndex(next)
}
const removeField = (index: number) => {
const next = fields.value.filter((_, i) => i !== index)
fields.value = applyOrderIndex(next)
}
const {
fields,
products,
productTypeOptions,
restrictedMode,
isFieldLocked,
isProductLocked,
formatProductTypeOption,
handleProductTypeSelect,
addProduct,
removeProduct,
addField,
removeField,
reorderClass,
onDragStart,
onDragEnter,
onDrop,
onDragEnd,
} = usePieceStructureEditorLogic({ props, emit })
</script>

View File

@@ -0,0 +1,78 @@
<template>
<div
v-if="documents.length"
class="mt-2 space-y-2 rounded-md border border-base-200 bg-base-100 p-3 text-xs"
>
<h5 class="font-medium text-base-content">Documents du produit</h5>
<div
v-for="document in documents"
:key="document.id || document.path || document.name"
class="flex items-center justify-between gap-3 rounded border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex items-center gap-3">
<div
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-10 w-8"
>
<img
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-5 w-5"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<div>
<div class="font-medium text-base-content">
{{ document.name }}
</div>
<div class="text-xs text-base-content/70">
{{ document.mimeType || 'Inconnu' }} {{ formatSize(document.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
@click="$emit('preview', document)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
Télécharger
</button>
</div>
</div>
</div>
</template>
<script setup>
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
import {
formatSize,
shouldInlinePdf,
documentPreviewSrc,
documentIcon,
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
defineProps({
documents: { type: Array, required: true },
})
defineEmits(['preview'])
</script>

View File

@@ -70,15 +70,9 @@
<div class="px-4 py-4 space-y-5">
<section v-if="isRoot" class="space-y-3">
<div class="flex items-center justify-between gap-2">
<h4 :class="headingClass">
{{ isRoot ? 'Champs personnalisés du composant' : 'Champs personnalisés' }}
</h4>
<button type="button" class="btn btn-outline btn-xs" @click="addCustomField">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</div>
<h4 :class="headingClass">
{{ isRoot ? 'Champs personnalisés du composant' : 'Champs personnalisés' }}
</h4>
<p v-if="!(node.customFields?.length)" class="text-xs text-gray-500">
Aucun champ n'a encore été défini.
</p>
@@ -155,18 +149,16 @@
</div>
</div>
</div>
<button type="button" class="btn btn-outline btn-xs" @click="addCustomField">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</section>
<section v-if="isRoot" class="space-y-3">
<div class="flex items-center justify-between gap-2">
<h4 :class="headingClass">
{{ isRoot ? 'Produits inclus par défaut' : 'Produits' }}
</h4>
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addProduct">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</div>
<h4 :class="headingClass">
{{ isRoot ? 'Produits inclus par défaut' : 'Produits' }}
</h4>
<p v-if="!(node.products?.length)" class="text-xs text-gray-500">
Aucun produit défini.
</p>
@@ -228,18 +220,16 @@
</div>
</div>
</div>
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addProduct">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</section>
<section v-if="isRoot" class="space-y-3">
<div class="flex items-center justify-between gap-2">
<h4 :class="headingClass">
{{ isRoot ? 'Pièces incluses par défaut' : 'Pièces' }}
</h4>
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addPiece">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</div>
<h4 :class="headingClass">
{{ isRoot ? 'Pièces incluses par défaut' : 'Pièces' }}
</h4>
<p v-if="!(node.pieces?.length)" class="text-xs text-gray-500">
Aucune pièce définie.
</p>
@@ -302,21 +292,14 @@
</div>
</div>
</div>
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addPiece">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</section>
<section v-if="canManageSubcomponents || hasSubcomponents" class="space-y-3">
<div class="flex items-center justify-between gap-2">
<h4 :class="headingClass">Sous-composants</h4>
<button
v-if="canManageSubcomponents && !restrictedMode"
type="button"
class="btn btn-outline btn-xs"
@click="addSubComponent"
>
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</div>
<h4 :class="headingClass">Sous-composants</h4>
<p v-if="!isRoot && canManageSubcomponents" class="text-[11px] text-gray-500">
Sélectionnez uniquement la famille de ce sous-composant ; il sera configuré via son propre modèle.
</p>
@@ -357,6 +340,15 @@
/>
</div>
</div>
<button
v-if="canManageSubcomponents && !restrictedMode"
type="button"
class="btn btn-outline btn-xs"
@click="addSubComponent"
>
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</section>
</div>
</div>
@@ -364,26 +356,13 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash from '~icons/lucide/trash'
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
import type { ComponentModelPiece, ComponentModelProduct, ComponentModelStructureNode } from '~/shared/types/inventory'
import type { EditableStructureNode, ModelTypeOption } from '~/composables/useStructureNodeLogic'
defineOptions({ name: 'StructureNodeEditor' })
type ModelTypeOption = {
id: string
name: string
code?: string | null
}
type EditableStructureNode = ComponentModelStructureNode & {
customFields?: any[]
pieces?: ComponentModelPiece[]
products?: ComponentModelProduct[]
}
const props = withDefaults(defineProps<{
node: EditableStructureNode
depth?: number
@@ -413,754 +392,60 @@ const props = withDefaults(defineProps<{
const emit = defineEmits(['remove'])
const initialCustomFieldIndices = ref<Set<number>>(new Set())
const initialPieceIndices = ref<Set<number>>(new Set())
const initialProductIndices = ref<Set<number>>(new Set())
const initialSubcomponentIndices = ref<Set<number>>(new Set())
const initializeLockedIndices = () => {
if (props.restrictedMode) {
const customFieldsLength = Array.isArray(props.node.customFields) ? props.node.customFields.length : 0
const piecesLength = Array.isArray(props.node.pieces) ? props.node.pieces.length : 0
const productsLength = Array.isArray(props.node.products) ? props.node.products.length : 0
const subcomponentsLength = Array.isArray(props.node.subcomponents) ? props.node.subcomponents.length : 0
initialCustomFieldIndices.value = new Set(Array.from({ length: customFieldsLength }, (_, i) => i))
initialPieceIndices.value = new Set(Array.from({ length: piecesLength }, (_, i) => i))
initialProductIndices.value = new Set(Array.from({ length: productsLength }, (_, i) => i))
initialSubcomponentIndices.value = new Set(Array.from({ length: subcomponentsLength }, (_, i) => i))
}
}
initializeLockedIndices()
const isCustomFieldLocked = (index: number): boolean => {
return props.restrictedMode === true && initialCustomFieldIndices.value.has(index)
}
const isPieceLocked = (index: number): boolean => {
return props.restrictedMode === true && initialPieceIndices.value.has(index)
}
const isProductLocked = (index: number): boolean => {
return props.restrictedMode === true && initialProductIndices.value.has(index)
}
const isSubcomponentLocked = (index: number): boolean => {
return props.restrictedMode === true && initialSubcomponentIndices.value.has(index)
}
const isLocked = computed(() => props.isLocked === true)
const restrictedMode = computed(() => props.restrictedMode === true)
const componentTypes = computed(() => props.componentTypes ?? [])
const pieceTypes = computed(() => props.pieceTypes ?? [])
const productTypes = computed(() => props.productTypes ?? [])
const allowSubcomponents = computed(() => props.allowSubcomponents !== false)
const maxSubcomponentDepth = computed(() =>
typeof props.maxSubcomponentDepth === 'number' ? props.maxSubcomponentDepth : Infinity,
)
const currentDepth = computed(() => Math.max(0, props.depth ?? 0))
const canManageSubcomponents = computed(
() => allowSubcomponents.value && currentDepth.value < maxSubcomponentDepth.value,
)
const childAllowSubcomponents = computed(
() => allowSubcomponents.value && currentDepth.value + 1 < maxSubcomponentDepth.value,
)
const hasSubcomponents = computed(
() => Array.isArray(props.node?.subcomponents) && props.node.subcomponents.length > 0,
)
const depthClasses = ['', 'ml-4', 'ml-8', 'ml-12', 'ml-16', 'ml-20']
const containerClass = computed(() => {
const level = currentDepth.value
const index = Math.min(level, depthClasses.length - 1)
return level === 0 ? 'space-y-4' : `${depthClasses[index]} space-y-4`
})
const headingClass = computed(() => (props.isRoot ? 'text-sm font-semibold' : 'text-xs font-semibold'))
const lockedTypeDisplay = computed(() => {
if (props.lockedTypeLabel) {
return props.lockedTypeLabel
}
return getComponentTypeLabel(props.node?.typeComposantId) || 'Famille non définie'
})
const formatModelTypeOption = (type: ModelTypeOption | undefined | null) =>
type?.name ?? ''
const componentTypeMap = computed(() => {
const map = new Map<string, ModelTypeOption>()
componentTypes.value.forEach((type) => {
if (type && typeof type.id === 'string') {
map.set(type.id, type)
}
})
return map
})
const componentTypeCodeMap = computed(() => {
const map = new Map<string, ModelTypeOption>()
componentTypes.value.forEach((type) => {
const code = typeof type?.code === 'string' ? type.code.trim() : ''
if (code) {
map.set(code, type)
}
})
return map
})
const pieceTypeMap = computed(() => {
const map = new Map<string, ModelTypeOption>()
pieceTypes.value.forEach((type) => {
if (type && typeof type.id === 'string') {
map.set(type.id, type)
}
})
return map
})
const productTypeMap = computed(() => {
const map = new Map<string, ModelTypeOption>()
productTypes.value.forEach((type) => {
if (type && typeof type.id === 'string') {
map.set(type.id, type)
}
})
return map
})
const getComponentTypeLabel = (id?: string) => {
if (!id) return ''
return formatModelTypeOption(componentTypeMap.value.get(id))
}
const getPieceTypeLabel = (id?: string) => {
if (!id) return ''
return formatModelTypeOption(pieceTypeMap.value.get(id))
}
const _getProductTypeLabel = (id?: string) => {
if (!id) return ''
return formatModelTypeOption(productTypeMap.value.get(id))
}
const formatComponentTypeOption = (type: ModelTypeOption | undefined | null) =>
formatModelTypeOption(type)
const formatPieceTypeOption = (type: ModelTypeOption | undefined | null) =>
formatModelTypeOption(type)
const formatProductTypeOption = (type: ModelTypeOption | undefined | null) =>
formatModelTypeOption(type)
const ensureArray = (key: 'customFields' | 'pieces' | 'products' | 'subcomponents') => {
if (!Array.isArray((props.node as any)[key])) {
if (key === 'subcomponents') {
props.node.subcomponents = []
} else if (key === 'products') {
props.node.products = []
} else {
(props.node as any)[key] = []
}
}
}
const syncComponentType = (component: EditableStructureNode) => {
if (!component) {
return
}
if (props.isRoot) {
component.typeComposantId = ''
component.typeComposantLabel = ''
component.familyCode = ''
if (component.alias) {
component.alias = ''
}
return
}
if (props.lockType && props.isRoot) {
if (props.lockedTypeLabel) {
component.typeComposantLabel = props.lockedTypeLabel
if (!component.alias || component.alias === component.typeComposantLabel) {
component.alias = props.lockedTypeLabel
}
}
if (component.typeComposantId) {
const option = componentTypeMap.value.get(component.typeComposantId)
component.familyCode = option?.code ?? component.familyCode
}
return
}
const id = typeof component.typeComposantId === 'string'
? component.typeComposantId
: ''
if (!id) {
const code =
typeof component.familyCode === 'string' && component.familyCode
? component.familyCode
: ''
if (code) {
const codeMatch = componentTypeCodeMap.value.get(code)
if (codeMatch?.id) {
component.typeComposantId = codeMatch.id
component.typeComposantLabel = formatModelTypeOption(codeMatch)
component.familyCode = codeMatch.code ?? component.familyCode
if (!component.alias || component.alias === '' || component.alias === lockedTypeDisplay.value) {
component.alias = codeMatch.name || component.typeComposantLabel
}
return
}
}
component.typeComposantLabel = ''
component.familyCode = ''
return
}
const option = componentTypeMap.value.get(id)
if (!option) {
component.typeComposantLabel = ''
component.familyCode = ''
return
}
component.typeComposantLabel = formatModelTypeOption(option)
component.familyCode = option.code ?? component.familyCode
if (!component.alias || component.alias === '' || component.alias === lockedTypeDisplay.value) {
component.alias = option.name || component.typeComposantLabel
}
}
const updatePieceTypeLabel = (piece: ComponentModelPiece & Record<string, any>) => {
if (!piece) return
if (piece.typePieceId) {
const option = pieceTypeMap.value.get(piece.typePieceId)
if (option) {
piece.typePieceLabel = formatPieceTypeOption(option)
return
}
}
if (piece.typePieceLabel) {
const normalized = piece.typePieceLabel.trim().toLowerCase()
if (normalized) {
const match = pieceTypes.value.find((type) => {
const formatted = formatPieceTypeOption(type).toLowerCase()
const name = (type?.name ?? '').toLowerCase()
const code = (type?.code ?? '').toLowerCase()
return formatted === normalized || name === normalized || (!!code && code === normalized)
})
if (match) {
piece.typePieceId = match.id
piece.typePieceLabel = formatPieceTypeOption(match)
return
}
}
}
}
const updateProductTypeLabel = (product: ComponentModelProduct & Record<string, any>) => {
if (!product) return
if (product.typeProductId) {
const option = productTypeMap.value.get(product.typeProductId)
if (option) {
product.typeProductLabel = formatProductTypeOption(option)
product.familyCode = option.code ?? product.familyCode ?? ''
return
}
}
if (product.typeProductLabel) {
const normalized = product.typeProductLabel.trim().toLowerCase()
if (normalized) {
const match = productTypes.value.find((type) => {
const formatted = formatProductTypeOption(type).toLowerCase()
const name = (type?.name ?? '').toLowerCase()
const code = (type?.code ?? '').toLowerCase()
return formatted === normalized || name === normalized || (!!code && code === normalized)
})
if (match) {
product.typeProductId = match.id
product.typeProductLabel = formatProductTypeOption(match)
product.familyCode = match.code ?? product.familyCode ?? ''
return
}
}
}
}
const syncPieceLabels = (pieces?: any[]) => {
if (!Array.isArray(pieces)) {
return
}
pieces.forEach((piece) => {
updatePieceTypeLabel(piece)
})
}
const syncProductLabels = (products?: any[]) => {
if (!Array.isArray(products)) {
return
}
products.forEach((product) => {
updateProductTypeLabel(product)
})
}
const handleComponentTypeSelect = (component: any) => {
syncComponentType(component)
}
const handlePieceTypeSelect = (piece: ComponentModelPiece & Record<string, any>) => {
if (!piece) {
return
}
const id = typeof piece.typePieceId === 'string' ? piece.typePieceId : ''
if (!id) {
piece.typePieceLabel = ''
return
}
const option = pieceTypeMap.value.get(id)
if (!option) {
piece.typePieceId = ''
piece.typePieceLabel = ''
return
}
piece.typePieceLabel = formatPieceTypeOption(option)
}
const handleProductTypeSelect = (product: ComponentModelProduct & Record<string, any>) => {
if (!product) {
return
}
const id = typeof product.typeProductId === 'string' ? product.typeProductId : ''
if (!id) {
product.typeProductLabel = ''
return
}
const option = productTypeMap.value.get(id)
if (!option) {
product.typeProductId = ''
product.typeProductLabel = ''
return
}
product.typeProductLabel = formatProductTypeOption(option)
product.familyCode = option.code ?? product.familyCode ?? ''
}
const customFieldDragState = ref({
draggingIndex: null as number | null,
dropTargetIndex: null as number | null,
})
const reindexCustomFields = () => {
if (!Array.isArray(props.node.customFields)) {
return
}
props.node.customFields.forEach((field: any, index: number) => {
if (!field || typeof field !== 'object') {
return
}
field.orderIndex = index
})
}
const resetCustomFieldDragState = () => {
customFieldDragState.value.draggingIndex = null
customFieldDragState.value.dropTargetIndex = null
}
const onCustomFieldDragStart = (index: number, event: DragEvent) => {
customFieldDragState.value.draggingIndex = index
customFieldDragState.value.dropTargetIndex = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onCustomFieldDragEnter = (index: number) => {
if (customFieldDragState.value.draggingIndex === null) {
return
}
customFieldDragState.value.dropTargetIndex = index
}
const onCustomFieldDrop = (index: number) => {
if (!Array.isArray(props.node.customFields)) {
resetCustomFieldDragState()
return
}
const from = customFieldDragState.value.draggingIndex
const to = index
if (from === null || to === null) {
resetCustomFieldDragState()
return
}
moveItemInPlace(props.node.customFields, from, to)
reindexCustomFields()
resetCustomFieldDragState()
}
const onCustomFieldDragEnd = () => {
resetCustomFieldDragState()
}
const customFieldReorderClass = (index: number) => {
if (customFieldDragState.value.draggingIndex === index) {
return 'border-dashed border-primary'
}
if (
customFieldDragState.value.draggingIndex !== null &&
customFieldDragState.value.dropTargetIndex === index &&
customFieldDragState.value.draggingIndex !== index
) {
return 'border-primary border-dashed bg-primary/5'
}
return ''
}
const addCustomField = () => {
ensureArray('customFields')
const fields = props.node.customFields!
const nextIndex = fields.length
fields.push({
name: '',
type: 'text',
required: false,
optionsText: '',
options: [],
orderIndex: nextIndex,
})
reindexCustomFields()
}
const removeCustomField = (index: number) => {
if (!Array.isArray(props.node.customFields)) return
props.node.customFields.splice(index, 1)
reindexCustomFields()
}
const addPiece = () => {
ensureArray('pieces')
props.node.pieces!.push({
typePieceId: '',
typePieceLabel: '',
reference: '',
familyCode: '',
role: '',
})
}
const removePiece = (index: number) => {
if (!Array.isArray(props.node.pieces)) return
props.node.pieces.splice(index, 1)
}
const addProduct = () => {
ensureArray('products')
props.node.products!.push({
typeProductId: '',
typeProductLabel: '',
familyCode: '',
})
}
const removeProduct = (index: number) => {
if (!Array.isArray(props.node.products)) return
props.node.products.splice(index, 1)
}
const addSubComponent = () => {
if (!canManageSubcomponents.value) {
return
}
ensureArray('subcomponents')
props.node.subcomponents.push({
typeComposantId: '',
typeComposantLabel: '',
modelId: '',
familyCode: '',
alias: '',
subcomponents: [],
})
}
const removeSubComponent = (index: number) => {
if (!Array.isArray(props.node.subcomponents)) return
props.node.subcomponents.splice(index, 1)
}
const draggingPieceIndex = ref<number | null>(null)
const pieceDropTargetIndex = ref<number | null>(null)
const draggingProductIndex = ref<number | null>(null)
const productDropTargetIndex = ref<number | null>(null)
const draggingSubcomponentIndex = ref<number | null>(null)
const subcomponentDropTargetIndex = ref<number | null>(null)
const moveItemInPlace = <T,>(list: T[], from: number, to: number) => {
if (from === to) {
return
}
if (from < 0 || to < 0 || from >= list.length || to >= list.length) {
return
}
const updated = list.slice()
const [item] = updated.splice(from, 1)
if (item === undefined) return
updated.splice(to, 0, item)
list.splice(0, list.length, ...updated)
}
const resetPieceDragState = () => {
draggingPieceIndex.value = null
pieceDropTargetIndex.value = null
}
const resetProductDragState = () => {
draggingProductIndex.value = null
productDropTargetIndex.value = null
}
const onPieceDragStart = (index: number, event: DragEvent) => {
draggingPieceIndex.value = index
pieceDropTargetIndex.value = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onPieceDragEnter = (index: number) => {
if (draggingPieceIndex.value === null) {
return
}
pieceDropTargetIndex.value = index
}
const onPieceDragOver = (event: DragEvent) => {
event.preventDefault()
}
const onPieceDrop = (index: number) => {
if (!Array.isArray(props.node.pieces)) {
resetPieceDragState()
return
}
const from = draggingPieceIndex.value
const to = index
if (from === null || to === null) {
resetPieceDragState()
return
}
moveItemInPlace(props.node.pieces, from, to)
resetPieceDragState()
}
const onPieceDragEnd = () => {
resetPieceDragState()
}
const pieceReorderClass = (index: number) => {
if (draggingPieceIndex.value === index) {
return 'border-dashed border-primary'
}
if (
draggingPieceIndex.value !== null &&
pieceDropTargetIndex.value === index &&
draggingPieceIndex.value !== index
) {
return 'border-primary border-dashed bg-primary/5'
}
return ''
}
const onProductDragStart = (index: number, event: DragEvent) => {
draggingProductIndex.value = index
productDropTargetIndex.value = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onProductDragEnter = (index: number) => {
if (draggingProductIndex.value === null) {
return
}
productDropTargetIndex.value = index
}
const onProductDragOver = (event: DragEvent) => {
event.preventDefault()
}
const onProductDrop = (index: number) => {
if (!Array.isArray(props.node.products)) {
resetProductDragState()
return
}
const from = draggingProductIndex.value
const to = index
if (from === null || to === null) {
resetProductDragState()
return
}
moveItemInPlace(props.node.products, from, to)
resetProductDragState()
}
const onProductDragEnd = () => {
resetProductDragState()
}
const productReorderClass = (index: number) => {
if (draggingProductIndex.value === index) {
return 'border-dashed border-primary'
}
if (
draggingProductIndex.value !== null &&
productDropTargetIndex.value === index &&
draggingProductIndex.value !== index
) {
return 'border-primary border-dashed bg-primary/5'
}
return ''
}
const resetSubcomponentDragState = () => {
draggingSubcomponentIndex.value = null
subcomponentDropTargetIndex.value = null
}
const onSubcomponentDragStart = (index: number, event: DragEvent) => {
draggingSubcomponentIndex.value = index
subcomponentDropTargetIndex.value = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onSubcomponentDragEnter = (index: number) => {
if (draggingSubcomponentIndex.value === null) {
return
}
subcomponentDropTargetIndex.value = index
}
const onSubcomponentDragOver = (event: DragEvent) => {
event.preventDefault()
}
const onSubcomponentDrop = (index: number) => {
if (!Array.isArray(props.node.subcomponents)) {
resetSubcomponentDragState()
return
}
const from = draggingSubcomponentIndex.value
const to = index
if (from === null || to === null) {
resetSubcomponentDragState()
return
}
moveItemInPlace(props.node.subcomponents, from, to)
resetSubcomponentDragState()
}
const onSubcomponentDragEnd = () => {
resetSubcomponentDragState()
}
const subcomponentReorderClass = (index: number) => {
if (draggingSubcomponentIndex.value === index) {
return 'ring-2 ring-primary'
}
if (
draggingSubcomponentIndex.value !== null &&
subcomponentDropTargetIndex.value === index &&
draggingSubcomponentIndex.value !== index
) {
return 'ring-2 ring-primary/70'
}
return ''
}
watch(
const {
isCustomFieldLocked,
isPieceLocked,
isProductLocked,
isSubcomponentLocked,
isLocked,
restrictedMode,
componentTypes,
pieceTypes,
productTypes,
canManageSubcomponents,
(allowed) => {
if (!allowed && Array.isArray(props.node.subcomponents) && props.node.subcomponents.length) {
props.node.subcomponents.splice(0, props.node.subcomponents.length)
}
},
{ immediate: true }
)
watch(componentTypes, () => {
syncComponentType(props.node)
}, { deep: true, immediate: true })
watch(
() => props.node.typeComposantId,
() => {
syncComponentType(props.node)
},
)
watch(pieceTypes, () => {
syncPieceLabels(props.node?.pieces)
}, { deep: true, immediate: true })
watch(
() => props.node.pieces,
(value) => {
syncPieceLabels(value)
},
{ deep: true }
)
watch(productTypes, () => {
syncProductLabels(props.node?.products)
}, { deep: true, immediate: true })
watch(
() => props.node.products,
(value) => {
syncProductLabels(value)
},
{ deep: true }
)
watch(
() => props.node.customFields,
(value) => {
if (!Array.isArray(value)) {
return
}
value.sort((a: any, b: any) => {
const left = typeof a?.orderIndex === 'number' ? a.orderIndex : 0
const right = typeof b?.orderIndex === 'number' ? b.orderIndex : 0
return left - right
})
reindexCustomFields()
},
{ deep: true }
)
watch(
() => [props.lockedTypeLabel, props.lockType],
() => {
if (props.lockType && props.isRoot) {
const label = props.lockedTypeLabel || lockedTypeDisplay.value
props.node.typeComposantLabel = label
if (label && (!props.node.alias || props.node.alias === lockedTypeDisplay.value)) {
props.node.alias = label
}
if (props.node.typeComposantId) {
const option = componentTypeMap.value.get(props.node.typeComposantId)
props.node.familyCode = option?.code ?? props.node.familyCode
}
}
},
{ immediate: true }
)
childAllowSubcomponents,
hasSubcomponents,
containerClass,
headingClass,
lockedTypeDisplay,
getComponentTypeLabel,
getPieceTypeLabel,
formatComponentTypeOption,
formatPieceTypeOption,
formatProductTypeOption,
handleComponentTypeSelect,
handlePieceTypeSelect,
handleProductTypeSelect,
addCustomField,
removeCustomField,
addPiece,
removePiece,
addProduct,
removeProduct,
addSubComponent,
removeSubComponent,
onCustomFieldDragStart,
onCustomFieldDragEnter,
onCustomFieldDrop,
onCustomFieldDragEnd,
customFieldReorderClass,
onPieceDragStart,
onPieceDragEnter,
onPieceDragOver,
onPieceDrop,
onPieceDragEnd,
pieceReorderClass,
onProductDragStart,
onProductDragEnter,
onProductDragOver,
onProductDrop,
onProductDragEnd,
productReorderClass,
onSubcomponentDragStart,
onSubcomponentDragEnter,
onSubcomponentDragOver,
onSubcomponentDrop,
onSubcomponentDragEnd,
subcomponentReorderClass,
} = useStructureNodeLogic(props)
</script>

View File

@@ -0,0 +1,173 @@
<template>
<div
v-if="fields.length"
class="mt-4 pt-4 border-t border-gray-200"
>
<h5 class="text-sm font-medium text-gray-700 mb-3">
Champs personnalisés
</h5>
<div :class="layoutClass">
<div
v-for="(field, index) in fields"
:key="resolveFieldKey(field, index)"
class="form-control"
>
<label class="label">
<span class="label-text text-sm">{{
resolveFieldName(field)
}}</span>
<span
v-if="resolveFieldRequired(field)"
class="label-text-alt text-error"
>*</span>
</label>
<!-- Mode édition -->
<template v-if="isEditMode && !resolveFieldReadOnly(field)">
<!-- Champ de type TEXT -->
<input
v-if="resolveFieldType(field) === 'text'"
:value="field.value ?? ''"
type="text"
class="input input-bordered input-sm"
:required="resolveFieldRequired(field)"
@input="onInput(field, ($event.target as HTMLInputElement).value)"
@blur="onBlur(field)"
>
<!-- Champ de type NUMBER -->
<input
v-else-if="resolveFieldType(field) === 'number'"
:value="field.value ?? ''"
type="number"
class="input input-bordered input-sm"
:required="resolveFieldRequired(field)"
@input="onInput(field, ($event.target as HTMLInputElement).value)"
@blur="onBlur(field)"
>
<!-- Champ de type SELECT -->
<select
v-else-if="resolveFieldType(field) === 'select'"
:value="field.value ?? ''"
class="select select-bordered select-sm"
:required="resolveFieldRequired(field)"
@change="onInput(field, ($event.target as HTMLSelectElement).value)"
@blur="onBlur(field)"
>
<option value="">
Sélectionner...
</option>
<option
v-for="option in resolveFieldOptions(field)"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<!-- Champ de type BOOLEAN -->
<div
v-else-if="resolveFieldType(field) === 'boolean'"
class="flex items-center gap-2"
>
<input
type="checkbox"
class="checkbox checkbox-sm"
:checked="String(field.value).toLowerCase() === 'true'"
@change="onBooleanChange(field, ($event.target as HTMLInputElement).checked)"
>
<span class="text-sm">{{
String(field.value).toLowerCase() === "true" ? "Oui" : "Non"
}}</span>
</div>
<!-- Champ de type DATE -->
<input
v-else-if="resolveFieldType(field) === 'date'"
:value="field.value ?? ''"
type="date"
class="input input-bordered input-sm"
:required="resolveFieldRequired(field)"
@input="onInput(field, ($event.target as HTMLInputElement).value)"
@blur="onBlur(field)"
>
<!-- Champ de type TEXTAREA -->
<textarea
v-else-if="resolveFieldType(field) === 'textarea'"
:value="field.value ?? ''"
class="textarea textarea-bordered textarea-sm"
:required="resolveFieldRequired(field)"
@input="onInput(field, ($event.target as HTMLTextAreaElement).value)"
@blur="onBlur(field)"
/>
<!-- Fallback: input text -->
<input
v-else
:value="field.value ?? ''"
type="text"
class="input input-bordered input-sm"
:required="resolveFieldRequired(field)"
@input="onInput(field, ($event.target as HTMLInputElement).value)"
@blur="onBlur(field)"
>
</template>
<!-- Mode lecture seule -->
<template v-else>
<div class="input input-bordered input-sm bg-base-200">
{{ formatFieldDisplayValue(field) }}
</div>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
resolveFieldKey,
resolveFieldName,
resolveFieldType,
resolveFieldOptions,
resolveFieldRequired,
resolveFieldReadOnly,
formatFieldDisplayValue,
} from '~/shared/utils/entityCustomFieldLogic'
const props = defineProps<{
fields: any[]
isEditMode: boolean
columns?: 1 | 2
}>()
const emit = defineEmits<{
'field-input': [field: any, value: string]
'field-blur': [field: any]
}>()
const layoutClass = computed(() =>
props.columns === 2
? 'grid grid-cols-1 md:grid-cols-2 gap-4'
: 'space-y-3',
)
function onInput(field: any, value: string) {
field.value = value
emit('field-input', field, value)
}
function onBooleanChange(field: any, checked: boolean) {
const value = checked ? 'true' : 'false'
field.value = value
emit('field-input', field, value)
emit('field-blur', field)
}
function onBlur(field: any) {
emit('field-blur', field)
}
</script>

View File

@@ -0,0 +1,83 @@
<template>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="(field, index) in fields"
:key="fieldKey(field, index)"
class="form-control"
>
<label class="label">
<span class="label-text">{{ field.name }}</span>
<span v-if="field.required" class="label-text-alt text-error">*</span>
</label>
<input
v-if="field.type === 'text'"
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="disabled"
>
<input
v-else-if="field.type === 'number'"
v-model="field.value"
type="number"
step="0.01"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="disabled"
>
<select
v-else-if="field.type === 'select'"
v-model="field.value"
class="select select-bordered select-sm md:select-md"
:required="field.required"
:disabled="disabled"
>
<option value="">Sélectionner...</option>
<option
v-for="option in field.options"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
<input
v-model="field.value"
type="checkbox"
class="toggle toggle-primary toggle-sm md:toggle-md"
true-value="true"
false-value="false"
:disabled="disabled"
>
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</label>
<input
v-else-if="field.type === 'date'"
v-model="field.value"
type="date"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="disabled"
>
<input
v-else
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="disabled"
>
</div>
</div>
</template>
<script setup lang="ts">
import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFieldFormUtils'
defineProps<{
fields: CustomFieldInput[]
disabled?: boolean
}>()
</script>

View File

@@ -63,7 +63,7 @@
<!-- Table (always shown when there are filterable columns, even during loading or with 0 rows) -->
<template v-else>
<div class="overflow-x-auto relative">
<div class="overflow-x-auto overflow-y-clip relative">
<!-- Loading overlay (keeps table & filter inputs visible) -->
<div
v-if="loading && hasFilterableColumns"

View File

@@ -0,0 +1,104 @@
<template>
<div v-if="documents.length" class="space-y-2">
<div
v-for="document in documents"
:key="document.id || document.path || document.name"
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex items-center gap-3 text-sm">
<div
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center"
:class="documentThumbnailClass(document)"
>
<img
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-6 w-6"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<div>
<div class="font-medium">
{{ document.name }}
</div>
<div class="text-xs text-base-content/70">
{{ document.mimeType || 'Inconnu' }} {{ formatSize(document.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
@click="$emit('preview', document)"
>
Consulter
</button>
<button
type="button"
class="btn btn-ghost btn-xs"
@click="downloadDocument(document)"
>
Télécharger
</button>
<button
v-if="canDelete"
type="button"
class="btn btn-error btn-xs"
:disabled="deleteDisabled"
@click="$emit('delete', document.id)"
>
Supprimer
</button>
</div>
</div>
</div>
<p v-else class="text-xs text-base-content/70">
{{ emptyText }}
</p>
</template>
<script setup lang="ts">
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
import {
documentIcon,
formatSize,
shouldInlinePdf,
documentPreviewSrc,
documentThumbnailClass,
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
import type { Document } from '~/composables/useDocuments'
withDefaults(defineProps<{
documents: Document[]
canDelete?: boolean
deleteDisabled?: boolean
emptyText?: string
}>(), {
canDelete: false,
deleteDisabled: false,
emptyText: 'Aucun document.',
})
defineEmits<{
(e: 'preview', document: Document): void
(e: 'delete', documentId: string): void
}>()
</script>

View File

@@ -0,0 +1,97 @@
<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">Historique</h2>
<p class="text-xs text-base-content/70">
Qui a changé quoi, et quand.
</p>
</div>
<span v-if="entries.length" class="badge badge-outline">
{{ entries.length }} entrée{{ entries.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 de l'historique…
</div>
<div v-else-if="error" class="alert alert-warning">
<span>{{ error }}</span>
</div>
<p v-else-if="entries.length === 0" class="text-xs text-base-content/70">
Aucun changement enregistré pour le moment.
</p>
<ul v-else class="max-h-80 space-y-2 overflow-y-auto pr-1">
<li
v-for="entry in entries"
:key="entry.id"
class="rounded-md border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-base-content/70">
<span class="font-medium text-base-content">
{{ historyActionLabel(entry.action) }}
</span>
<span>{{ formatHistoryDate(entry.createdAt) }}</span>
</div>
<p class="mt-1 text-xs text-base-content/60">
Par {{ entry.actor?.label || 'Inconnu' }}
</p>
<ul
v-if="diffEntries(entry).length"
class="mt-2 space-y-1 text-xs"
>
<li
v-for="diffEntry in diffEntries(entry)"
:key="`${entry.id}-${diffEntry.field}`"
class="flex flex-col gap-0.5"
>
<span class="font-medium text-base-content/80">{{ diffEntry.label }}</span>
<span class="text-base-content/60">
{{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
</span>
</li>
</ul>
<p
v-else-if="entry.snapshot?.name"
class="mt-2 text-xs text-base-content/70"
>
{{ entry.snapshot.name }}
</p>
</li>
</ul>
</section>
</template>
<script setup lang="ts">
import {
historyActionLabel,
formatHistoryDate,
historyDiffEntries,
type HistoryDiffEntry,
} from '~/shared/utils/historyDisplayUtils'
interface HistoryEntry {
id: string
action: string
createdAt: string
actor?: { label?: string } | null
diff?: Record<string, { from?: unknown; to?: unknown }> | null
snapshot?: { name?: string } | null
}
const props = defineProps<{
entries: HistoryEntry[]
loading?: boolean
error?: string | null
fieldLabels: Record<string, string>
}>()
const diffEntries = (entry: HistoryEntry): HistoryDiffEntry[] =>
historyDiffEntries(entry, props.fieldLabels)
</script>

View File

@@ -0,0 +1,162 @@
<template>
<div 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">Squelette sélectionné</h2>
<p class="text-xs text-base-content/70">
{{ description }}
</p>
</div>
<span class="badge badge-outline">{{ previewBadge }}</span>
</div>
<details v-if="structure" class="collapse collapse-arrow bg-base-100">
<summary class="collapse-title text-sm font-medium">
Consulter le détail du squelette
</summary>
<div class="collapse-content text-sm text-base-content/80" :class="variant === 'component' ? 'space-y-4' : 'space-y-2'">
<!-- Custom fields: component variant (rich display) -->
<div v-if="variant === 'component' && customFields.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Champs personnalisés</h3>
<ul class="space-y-2">
<li
v-for="field in customFields"
:key="field.customFieldId || field.id || field.name"
class="rounded bg-base-200/60 px-3 py-2"
>
<p class="font-medium text-sm text-base-content">
{{ field.name || field.key }}
</p>
<p class="text-xs text-base-content/70 mt-1">
Type : {{ field.type || 'text' }}<span v-if="field.required"> &bull; Obligatoire</span>
<span v-if="Array.isArray(field.options) && field.options.length">
&bull; Options : {{ field.options.join(', ') }}
</span>
<span v-if="field.defaultValue">
&bull; Défaut : {{ field.defaultValue }}
</span>
</p>
</li>
</ul>
</div>
<!-- Custom fields: piece variant (simple display) -->
<div v-if="variant === 'piece' && customFields.length" class="space-y-1">
<h3 class="font-semibold text-sm text-base-content">Champs personnalisés</h3>
<ul class="list-disc list-inside space-y-1">
<li v-for="field in customFields" :key="field.name">
<span class="font-medium">{{ field.name }}</span>
<span v-if="field.value !== undefined && field.value !== null"> : {{ field.value }}</span>
</li>
</ul>
</div>
<!-- Pieces: component variant only -->
<div v-if="variant === 'component' && pieces.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Pièces imposées</h3>
<ul class="list-disc list-inside space-y-1">
<li
v-for="(piece, index) in pieces"
:key="piece.role || piece.typePieceId || piece.familyCode || index"
>
{{ resolvePieceLabelFn(piece) }}
</li>
</ul>
</div>
<!-- Products: component variant only -->
<div v-if="variant === 'component' && products.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Produits imposés</h3>
<ul class="list-disc list-inside space-y-1">
<li
v-for="(product, index) in products"
:key="product.role || product.typeProductId || product.familyCode || index"
>
{{ resolveProductLabelFn(product) }}
</li>
</ul>
</div>
<!-- Subcomponents: component variant only -->
<div v-if="variant === 'component' && subcomponents.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
<ul class="list-disc list-inside space-y-1">
<li
v-for="(subcomponent, index) in subcomponents"
:key="subcomponent.alias || subcomponent.typeComposantId || subcomponent.familyCode || index"
>
{{ resolveSubcomponentLabelFn(subcomponent) }}
</li>
</ul>
</div>
<!-- Empty state: component variant -->
<p
v-if="variant === 'component' && showEmptyState && !customFields.length && !pieces.length && !products.length && !subcomponents.length"
class="text-xs text-gray-500"
>
Ce squelette ne définit pas encore de pièces, sous-composants ou valeurs par défaut.
</p>
<!-- Empty state: piece variant -->
<p v-if="variant === 'piece' && !customFields.length" class="text-xs text-base-content/70">
Ce squelette ne définit pas encore de champs personnalisés.
</p>
</div>
</details>
</div>
</template>
<script setup lang="ts">
import {
getStructureCustomFields,
getStructurePieces,
getStructureProducts,
getStructureSubcomponents,
} from '~/shared/utils/structureDisplayUtils'
const props = withDefaults(defineProps<{
structure: Record<string, any> | null
description?: string
previewBadge: string
variant: 'component' | 'piece'
showEmptyState?: boolean
resolvePieceLabel?: (piece: Record<string, any>) => string
resolveProductLabel?: (product: Record<string, any>) => string
resolveSubcomponentLabel?: (subcomponent: Record<string, any>) => string
}>(), {
description: '',
showEmptyState: false,
resolvePieceLabel: undefined,
resolveProductLabel: undefined,
resolveSubcomponentLabel: undefined,
})
const customFields = computed(() =>
getStructureCustomFields(props.structure),
)
const pieces = computed(() =>
props.variant === 'component' ? getStructurePieces(props.structure) : [],
)
const products = computed(() =>
props.variant === 'component' ? getStructureProducts(props.structure) : [],
)
const subcomponents = computed(() =>
props.variant === 'component' ? getStructureSubcomponents(props.structure) : [],
)
const fallbackLabel = (item: Record<string, any>) =>
item?.name || item?.label || item?.role || item?.alias || 'N/A'
const resolvePieceLabelFn = (piece: Record<string, any>) =>
props.resolvePieceLabel ? props.resolvePieceLabel(piece) : fallbackLabel(piece)
const resolveProductLabelFn = (product: Record<string, any>) =>
props.resolveProductLabel ? props.resolveProductLabel(product) : fallbackLabel(product)
const resolveSubcomponentLabelFn = (subcomponent: Record<string, any>) =>
props.resolveSubcomponentLabel ? props.resolveSubcomponentLabel(subcomponent) : fallbackLabel(subcomponent)
</script>

View File

@@ -0,0 +1,108 @@
<template>
<div v-if="open" class="modal modal-open">
<div class="modal-box max-w-2xl">
<h3 class="font-bold text-lg mb-4">
Ajouter une nouvelle machine
</h3>
<form @submit.prevent="handleSubmit">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="form-control">
<label class="label">
<span class="label-text">Nom de la machine</span>
</label>
<input
v-model="form.name"
type="text"
placeholder="Ex: Presse hydraulique #1"
class="input input-bordered"
:disabled="disabled"
required
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Site</span>
</label>
<select
v-model="form.siteId"
class="select select-bordered"
:disabled="disabled"
required
>
<option value="">
Sélectionner un site
</option>
<option v-for="site in sites" :key="site.id" :value="site.id">
{{ site.name }}
</option>
</select>
</div>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="form.reference"
type="text"
placeholder="Ex: PRESS-001"
class="input input-bordered"
:disabled="disabled"
>
</div>
<div class="modal-action">
<button
type="button"
class="btn btn-outline"
@click="$emit('close')"
>
Annuler
</button>
<button type="submit" class="btn btn-primary" :disabled="disabled">
Créer la machine
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, watch } from 'vue'
const props = defineProps<{
open: boolean
sites: Array<{ id: string, name: string }>
disabled: boolean
preselectedSiteId?: string
}>()
const emit = defineEmits<{
close: []
create: [data: { name: string, siteId: string, reference: string }]
}>()
const form = reactive({
name: '',
siteId: '',
reference: '',
})
function handleSubmit() {
emit('create', { ...form })
}
watch(() => props.open, (isOpen) => {
if (isOpen && props.preselectedSiteId) {
form.siteId = props.preselectedSiteId
}
if (!isOpen) {
form.name = ''
form.siteId = ''
form.reference = ''
}
})
</script>

View File

@@ -0,0 +1,78 @@
<template>
<div v-if="open" class="modal modal-open">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">
Ajouter un nouveau site
</h3>
<form class="space-y-4" @submit.prevent="handleSubmit">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du site</span>
</label>
<input
v-model="form.name"
type="text"
placeholder="Ex: Usine de production"
class="input input-bordered"
:disabled="disabled"
required
>
</div>
<SiteContactFormFields :form="form" :disabled="disabled" />
<div class="modal-action">
<button
type="button"
class="btn btn-outline"
@click="$emit('close')"
>
Annuler
</button>
<button type="submit" class="btn btn-primary" :disabled="disabled">
Créer le site
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, watch } from 'vue'
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
const props = defineProps<{
open: boolean
disabled: boolean
}>()
const emit = defineEmits<{
close: []
create: [data: { name: string, contactName: string, contactPhone: string, contactAddress: string, contactPostalCode: string, contactCity: string }]
}>()
const form = reactive({
name: '',
contactName: '',
contactPhone: '',
contactAddress: '',
contactPostalCode: '',
contactCity: '',
})
function handleSubmit() {
emit('create', { ...form })
}
watch(() => props.open, (isOpen) => {
if (!isOpen) {
form.name = ''
form.contactName = ''
form.contactPhone = ''
form.contactAddress = ''
form.contactPostalCode = ''
form.contactCity = ''
}
})
</script>

View File

@@ -3,31 +3,21 @@
<div class="card-body">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title">Composants</h2>
<div class="flex items-center gap-2">
<button
v-if="isEditMode"
type="button"
class="btn btn-sm md:btn-md btn-primary"
@click="$emit('add-component')"
>
Ajouter un composant
</button>
<button
type="button"
class="btn btn-ghost btn-sm gap-2"
@click="$emit('toggle-collapse')"
:title="collapsed ? 'Déplier tous les composants' : 'Replier tous les composants'"
>
<IconLucideChevronRight
class="w-5 h-5 transition-transform"
:class="collapsed ? 'rotate-0' : 'rotate-90'"
aria-hidden="true"
/>
<span class="text-sm">
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
</span>
</button>
</div>
<button
type="button"
class="btn btn-ghost btn-sm gap-2"
@click="$emit('toggle-collapse')"
:title="collapsed ? 'Déplier tous les composants' : 'Replier tous les composants'"
>
<IconLucideChevronRight
class="w-5 h-5 transition-transform"
:class="collapsed ? 'rotate-0' : 'rotate-90'"
aria-hidden="true"
/>
<span class="text-sm">
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
</span>
</button>
</div>
<div v-if="components.length === 0" class="text-sm text-gray-500 py-4">
@@ -54,6 +44,15 @@
/>
</div>
</div>
<button
v-if="isEditMode"
type="button"
class="btn btn-sm md:btn-md btn-primary"
@click="$emit('add-component')"
>
Ajouter un composant
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,124 @@
<template>
<section class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">
Définitions des champs personnalisés
</h3>
<button
type="button"
class="btn btn-primary btn-sm"
:disabled="saving"
@click="$emit('save')"
>
<span v-if="saving" class="loading loading-spinner loading-xs" />
Enregistrer les champs
</button>
</div>
<p v-if="!fields.length" class="text-xs text-gray-500">
Aucun champ personnalisé défini. Cliquez sur « Ajouter » pour en créer un.
</p>
<ul v-else class="space-y-2" role="list">
<li
v-for="(field, index) in fields"
:key="field.uid"
class="border border-base-200 rounded-md p-3 space-y-2 bg-base-100 transition-colors"
:class="reorderClass(index)"
draggable="true"
@dragstart="onDragStart(index, $event)"
@dragenter="onDragEnter(index)"
@dragover.prevent="onDragEnter(index)"
@drop.prevent="onDrop(index)"
@dragend="onDragEnd"
>
<div class="flex items-start gap-3">
<button
type="button"
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing mt-1"
title="Réordonner"
draggable="false"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
<div class="flex-1 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<input
v-model="field.name"
type="text"
class="input input-bordered input-sm"
placeholder="Nom du champ"
>
<select v-model="field.type" class="select select-bordered select-sm">
<option value="text">
Texte
</option>
<option value="number">
Nombre
</option>
<option value="select">
Liste
</option>
<option value="boolean">
Oui/Non
</option>
<option value="date">
Date
</option>
</select>
</div>
<div class="flex items-center gap-2 text-xs">
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs">
Obligatoire
</div>
<textarea
v-if="field.type === 'select'"
v-model="field.optionsText"
class="textarea textarea-bordered textarea-sm h-20"
placeholder="Option 1&#10;Option 2"
/>
</div>
<button
type="button"
class="btn btn-error btn-xs btn-square"
@click="$emit('remove-field', index)"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</li>
</ul>
<button type="button" class="btn btn-outline btn-sm" @click="$emit('add-field')">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter un champ
</button>
</section>
</template>
<script setup lang="ts">
import type { MachineCustomFieldEditorField } from '~/composables/useMachineCustomFieldDefs'
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash from '~icons/lucide/trash'
defineProps<{
fields: MachineCustomFieldEditorField[]
saving: boolean
reorderClass: (index: number) => string
onDragStart: (index: number, event: DragEvent) => void
onDragEnter: (index: number) => void
onDrop: (index: number) => void
onDragEnd: () => void
}>()
defineEmits<{
save: []
'add-field': []
'remove-field': [index: number]
}>()
</script>

View File

@@ -151,18 +151,36 @@
</div>
</div>
</div>
<div v-if="isEditMode" class="mt-6 pt-4 border-t border-base-200">
<MachineCustomFieldDefEditor
:fields="fieldDefs.fields.value"
:saving="fieldDefs.saving.value"
:reorder-class="fieldDefs.reorderClass"
:on-drag-start="fieldDefs.onDragStart"
:on-drag-enter="fieldDefs.onDragEnter"
:on-drop="fieldDefs.onDrop"
:on-drag-end="fieldDefs.onDragEnd"
@save="fieldDefs.saveDefinitions()"
@add-field="fieldDefs.addField()"
@remove-field="fieldDefs.removeField($event)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { watch } from 'vue'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import MachineCustomFieldDefEditor from '~/components/machine/MachineCustomFieldDefEditor.vue'
import {
formatConstructeurContact as formatConstructeurContactSummary,
} from '~/shared/constructeurUtils'
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
import { useMachineCustomFieldDefs } from '~/composables/useMachineCustomFieldDefs'
defineProps<{
const props = defineProps<{
isEditMode: boolean
machineName: string
machineReference: string
@@ -171,14 +189,27 @@ defineProps<{
hasMachineConstructeur: boolean
visibleCustomFields: any[]
getMachineFieldId: (fieldName: string) => string
machineId: string
machineCustomFieldDefs: any[]
}>()
defineEmits<{
const emit = defineEmits<{
'update:machine-name': [value: string]
'update:machine-reference': [value: string]
'update:constructeur-ids': [ids: unknown]
'blur-field': []
'set-custom-field-value': [field: any, value: unknown]
'update-custom-field': [field: any]
'custom-fields-saved': []
}>()
const fieldDefs = useMachineCustomFieldDefs({
machineId: props.machineId,
initialDefs: props.machineCustomFieldDefs,
onSaved: () => emit('custom-fields-saved'),
})
watch(() => props.machineCustomFieldDefs, (newDefs) => {
fieldDefs.reinit(newDefs)
}, { deep: true })
</script>

View File

@@ -3,31 +3,21 @@
<div class="card-body">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title">Pièces de la machine</h2>
<div class="flex items-center gap-2">
<button
v-if="isEditMode"
type="button"
class="btn btn-sm md:btn-md btn-primary"
@click="$emit('add-piece')"
>
Ajouter une pièce
</button>
<button
type="button"
class="btn btn-ghost btn-sm gap-2"
@click="$emit('toggle-collapse')"
:title="collapsed ? 'Déplier toutes les pièces' : 'Replier toutes les pièces'"
>
<IconLucideChevronRight
class="w-5 h-5 transition-transform"
:class="collapsed ? 'rotate-0' : 'rotate-90'"
aria-hidden="true"
/>
<span class="text-sm">
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
</span>
</button>
</div>
<button
type="button"
class="btn btn-ghost btn-sm gap-2"
@click="$emit('toggle-collapse')"
:title="collapsed ? 'Déplier toutes les pièces' : 'Replier toutes les pièces'"
>
<IconLucideChevronRight
class="w-5 h-5 transition-transform"
:class="collapsed ? 'rotate-0' : 'rotate-90'"
aria-hidden="true"
/>
<span class="text-sm">
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
</span>
</button>
</div>
<div v-if="pieces.length === 0" class="text-sm text-gray-500 py-4">
@@ -54,6 +44,15 @@
/>
</div>
</div>
<button
v-if="isEditMode"
type="button"
class="btn btn-sm md:btn-md btn-primary"
@click="$emit('add-piece')"
>
Ajouter une pièce
</button>
</div>
</div>
</template>

View File

@@ -15,19 +15,9 @@
Produits sélectionnés directement pour cette machine.
</p>
</div>
<div class="flex items-center gap-2">
<button
v-if="isEditMode"
type="button"
class="btn btn-sm md:btn-md btn-primary"
@click="$emit('add-product')"
>
Ajouter un produit
</button>
<span class="badge badge-outline" v-if="products.length">
{{ products.length }} produit{{ products.length > 1 ? 's' : '' }}
</span>
</div>
<span class="badge badge-outline" v-if="products.length">
{{ products.length }} produit{{ products.length > 1 ? 's' : '' }}
</span>
</div>
<div v-if="products.length" class="space-y-3">
@@ -117,6 +107,15 @@
<p v-else class="text-xs text-gray-500">
Aucun produit n'a été associé directement à cette machine.
</p>
<button
v-if="isEditMode"
type="button"
class="btn btn-sm md:btn-md btn-primary"
@click="$emit('add-product')"
>
Ajouter un produit
</button>
</div>
</div>
</template>

View File

@@ -99,66 +99,19 @@
</template>
</DataTable>
<ModelTypesConversionModal
<ConversionModal
:open="conversionModalOpen"
:model-type="conversionTarget"
@close="closeConversionModal"
@converted="onConverted"
/>
<dialog class="modal" :class="{ 'modal-open': relatedModalOpen }">
<div class="modal-box max-w-3xl">
<h3 class="text-lg font-bold text-base-content">
{{ relatedModalTitle }}
</h3>
<p class="mt-1 text-sm text-base-content/70">
{{ relatedModalSubtitle }}
</p>
<div class="mt-4 rounded-xl border border-base-200 bg-base-100">
<div v-if="relatedLoading" class="flex items-center gap-2 px-4 py-6 text-sm text-info">
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
Chargement des éléments liés
</div>
<div v-else-if="relatedError" class="px-4 py-6 text-sm text-error">
{{ relatedError }}
</div>
<div
v-else-if="relatedItems.length === 0"
class="px-4 py-6 text-sm text-base-content/60"
>
Aucun élément lié à cette catégorie.
</div>
<ul v-else class="max-h-96 divide-y divide-base-200 overflow-y-auto">
<li
v-for="entry in relatedItems"
:key="entry.id"
class="px-2 py-1"
>
<button
type="button"
class="flex w-full flex-col gap-1 rounded-lg px-2 py-2 text-left hover:bg-base-200 focus:bg-base-200 focus:outline-none"
@click="openRelatedEdit(entry)"
>
<span class="font-medium text-base-content">{{ entry.name }}</span>
<span v-if="entry.reference" class="text-xs text-base-content/60">
Référence: {{ entry.reference }}
</span>
</button>
</li>
</ul>
</div>
<div class="modal-action">
<button type="button" class="btn" @click="closeRelatedModal">
Fermer
</button>
</div>
</div>
</dialog>
<RelatedItemsModal
:open="relatedModalOpen"
:model-type="relatedType"
@close="relatedModalOpen = false"
@open-edit="openRelatedEdit"
/>
</main>
</template>
@@ -166,10 +119,8 @@
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
import { useHead, useRouter } from '#imports'
import DataTable from '~/components/common/DataTable.vue'
import ModelTypesConversionModal from '~/components/model-types/ConversionModal.vue'
import { useApi } from '~/composables/useApi'
import ConversionModal from '~/components/model-types/ConversionModal.vue'
import { useUrlState } from '~/composables/useUrlState'
import { extractCollection } from '~/shared/utils/apiHelpers'
import type { DataTableSort } from '~/shared/types/dataTable'
import {
deleteModelType,
@@ -233,7 +184,6 @@ let activeController: AbortController | null = null
const router = useRouter()
const { showError, showSuccess } = useToast()
const { get } = useApi()
const { canEdit } = usePermissions()
const headingText = computed(() => props.heading)
@@ -422,106 +372,21 @@ const confirmDelete = async (item: ModelType) => {
}
}
type RelatedEntry = {
id: string
name: string
reference?: string | null
}
const relatedModalOpen = ref(false)
const relatedLoading = ref(false)
const relatedError = ref<string | null>(null)
const relatedItems = ref<RelatedEntry[]>([])
const relatedType = ref<ModelType | null>(null)
const relatedCategoryLabels: Record<ModelCategory, { plural: string, singular: string }> = {
COMPONENT: { plural: 'composants', singular: 'composant' },
PIECE: { plural: 'pièces', singular: 'pièce' },
PRODUCT: { plural: 'produits', singular: 'produit' },
}
const relatedModalTitle = computed(() => {
const current = relatedType.value
if (!current) return 'Éléments liés'
return `Éléments liés à « ${current.name} »`
})
const relatedModalSubtitle = computed(() => {
const current = relatedType.value
if (!current) return ''
const labels = relatedCategoryLabels[current.category] ?? relatedCategoryLabels.COMPONENT
const count = relatedItems.value.length
if (relatedLoading.value) return `Chargement des ${labels.plural}`
if (count === 0) return `Aucun ${labels.singular} lié.`
if (count === 1) return `1 ${labels.singular} lié.`
return `${count} ${labels.plural} liés.`
})
const buildModelTypeIri = (id: string) => `/api/model_types/${id}`
const resolveRelatedConfig = (category: ModelCategory) => {
if (category === 'COMPONENT') return { endpoint: '/composants', filterKey: 'typeComposant' }
if (category === 'PIECE') return { endpoint: '/pieces', filterKey: 'typePiece' }
return { endpoint: '/products', filterKey: 'typeProduct' }
}
const resolveRelatedEditBasePath = (category: ModelCategory) => {
if (category === 'COMPONENT') return '/component'
if (category === 'PIECE') return '/pieces'
return '/product'
}
const mapRelatedEntry = (item: unknown): RelatedEntry | null => {
if (!item || typeof item !== 'object') return null
const record = item as Record<string, unknown>
if (typeof record.id !== 'string') return null
const name = typeof record.name === 'string' && record.name.trim() ? record.name : 'Sans nom'
const reference
= typeof record.reference === 'string' && record.reference.trim()
? record.reference
: typeof record.code === 'string' && record.code.trim()
? record.code
: null
return { id: record.id, name, reference }
}
const loadRelatedItems = async (item: ModelType) => {
const { endpoint, filterKey } = resolveRelatedConfig(item.category)
const params = new URLSearchParams()
params.set('itemsPerPage', '200')
params.set(filterKey, buildModelTypeIri(item.id))
params.set('order[name]', 'asc')
relatedLoading.value = true
relatedError.value = null
relatedItems.value = []
try {
const result = await get(`${endpoint}?${params.toString()}`)
if (!result.success) {
relatedError.value = result.error ?? 'Impossible de charger les éléments liés.'
return
}
const collection = extractCollection(result.data)
relatedItems.value = collection
.map(mapRelatedEntry)
.filter((entry): entry is RelatedEntry => Boolean(entry))
}
catch (error) {
relatedError.value = extractErrorMessage(error)
}
finally {
relatedLoading.value = false
}
}
const openRelatedModal = (item: ModelType) => {
relatedType.value = item
relatedModalOpen.value = true
void loadRelatedItems(item)
}
const openRelatedEdit = (entry: RelatedEntry) => {
const openRelatedEdit = (entry: { id: string }) => {
const current = relatedType.value
if (!current) return
const basePath = resolveRelatedEditBasePath(current.category)
@@ -531,10 +396,6 @@ const openRelatedEdit = (entry: RelatedEntry) => {
})
}
const closeRelatedModal = () => {
relatedModalOpen.value = false
}
const conversionModalOpen = ref(false)
const conversionTarget = ref<ModelType | null>(null)

View File

@@ -0,0 +1,182 @@
<template>
<dialog class="modal" :class="{ 'modal-open': open }">
<div class="modal-box max-w-3xl">
<h3 class="text-lg font-bold text-base-content">
{{ modalTitle }}
</h3>
<p class="mt-1 text-sm text-base-content/70">
{{ modalSubtitle }}
</p>
<div class="mt-4 rounded-xl border border-base-200 bg-base-100">
<div v-if="loading" class="flex items-center gap-2 px-4 py-6 text-sm text-info">
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
Chargement des éléments liés
</div>
<div v-else-if="error" class="px-4 py-6 text-sm text-error">
{{ error }}
</div>
<div
v-else-if="items.length === 0"
class="px-4 py-6 text-sm text-base-content/60"
>
Aucun élément lié à cette catégorie.
</div>
<ul v-else class="max-h-96 divide-y divide-base-200 overflow-y-auto">
<li
v-for="entry in items"
:key="entry.id"
class="px-2 py-1"
>
<button
type="button"
class="flex w-full flex-col gap-1 rounded-lg px-2 py-2 text-left hover:bg-base-200 focus:bg-base-200 focus:outline-none"
@click="onOpenEdit(entry)"
>
<span class="font-medium text-base-content">{{ entry.name }}</span>
<span v-if="entry.reference" class="text-xs text-base-content/60">
Référence: {{ entry.reference }}
</span>
</button>
</li>
</ul>
</div>
<div class="modal-action">
<button type="button" class="btn" @click="emit('close')">
Fermer
</button>
</div>
</div>
</dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useApi } from '~/composables/useApi'
import { extractCollection } from '~/shared/utils/apiHelpers'
import { humanizeError } from '~/shared/utils/errorMessages'
import type { ModelCategory, ModelType } from '~/services/modelTypes'
type RelatedEntry = {
id: string
name: string
reference?: string | null
}
const props = defineProps<{
open: boolean
modelType: ModelType | null
}>()
const emit = defineEmits<{
close: []
'open-edit': [entry: RelatedEntry]
}>()
const { get } = useApi()
const loading = ref(false)
const error = ref<string | null>(null)
const items = ref<RelatedEntry[]>([])
const categoryLabels: Record<ModelCategory, { plural: string, singular: string }> = {
COMPONENT: { plural: 'composants', singular: 'composant' },
PIECE: { plural: 'pièces', singular: 'pièce' },
PRODUCT: { plural: 'produits', singular: 'produit' },
}
const modalTitle = computed(() => {
if (!props.modelType) return 'Éléments liés'
return `Éléments liés à « ${props.modelType.name} »`
})
const modalSubtitle = computed(() => {
if (!props.modelType) return ''
const labels = categoryLabels[props.modelType.category] ?? categoryLabels.COMPONENT
const count = items.value.length
if (loading.value) return `Chargement des ${labels.plural}`
if (count === 0) return `Aucun ${labels.singular} lié.`
if (count === 1) return `1 ${labels.singular} lié.`
return `${count} ${labels.plural} liés.`
})
const resolveRelatedConfig = (category: ModelCategory) => {
if (category === 'COMPONENT') return { endpoint: '/composants', filterKey: 'typeComposant' }
if (category === 'PIECE') return { endpoint: '/pieces', filterKey: 'typePiece' }
return { endpoint: '/products', filterKey: 'typeProduct' }
}
const mapRelatedEntry = (item: unknown): RelatedEntry | null => {
if (!item || typeof item !== 'object') return null
const record = item as Record<string, unknown>
if (typeof record.id !== 'string') return null
const name = typeof record.name === 'string' && record.name.trim() ? record.name : 'Sans nom'
const reference
= typeof record.reference === 'string' && record.reference.trim()
? record.reference
: typeof record.code === 'string' && record.code.trim()
? record.code
: null
return { id: record.id, name, reference }
}
const loadRelatedItems = async (modelType: ModelType) => {
const { endpoint, filterKey } = resolveRelatedConfig(modelType.category)
const params = new URLSearchParams()
params.set('itemsPerPage', '200')
params.set(filterKey, `/api/model_types/${modelType.id}`)
params.set('order[name]', 'asc')
loading.value = true
error.value = null
items.value = []
try {
const result = await get(`${endpoint}?${params.toString()}`)
if (!result.success) {
error.value = result.error ?? 'Impossible de charger les éléments liés.'
return
}
const collection = extractCollection(result.data)
items.value = collection
.map(mapRelatedEntry)
.filter((entry): entry is RelatedEntry => Boolean(entry))
}
catch (err) {
let raw: string | null = null
if (err && typeof err === 'object') {
const e = err as { data?: Record<string, unknown>, statusMessage?: string, message?: string }
if (e.data) {
const data = e.data
if (typeof data['hydra:description'] === 'string') raw = data['hydra:description']
else if (typeof data.detail === 'string') raw = data.detail
else if (typeof data.message === 'string') raw = data.message
else if (typeof data.error === 'string') raw = data.error
}
if (!raw && typeof e.statusMessage === 'string') raw = e.statusMessage
if (!raw && typeof e.message === 'string') raw = e.message
}
error.value = humanizeError(raw)
}
finally {
loading.value = false
}
}
const onOpenEdit = (entry: RelatedEntry) => {
emit('open-edit', entry)
}
watch(
() => props.open,
(isOpen) => {
if (isOpen && props.modelType) {
void loadRelatedItems(props.modelType)
}
},
)
</script>

View File

@@ -0,0 +1,417 @@
/**
* Component creation page orchestration composable.
*
* Pure structure-assignment helpers live in
* `~/shared/utils/structureAssignmentHelpers.ts`.
*/
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from '#imports'
import type { StructureAssignmentNode } from '~/components/ComponentStructureAssignmentNode.vue'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useComposants } from '~/composables/useComposants'
import { usePieces } from '~/composables/usePieces'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useProducts } from '~/composables/useProducts'
import { useProductTypes } from '~/composables/useProductTypes'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments'
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
import {
type CustomFieldInput,
normalizeCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import {
getStructurePieces,
resolvePieceLabel as _resolvePieceLabel,
resolveProductLabel as _resolveProductLabel,
resolveSubcomponentLabel,
fetchModelTypeNames,
buildTypeLabelMap,
} from '~/shared/utils/structureDisplayUtils'
import {
hasAssignments,
initializeStructureAssignments,
isAssignmentNodeComplete,
serializeStructureAssignments,
} from '~/shared/utils/structureAssignmentHelpers'
import type { ComponentModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes'
interface ComponentCatalogType extends ModelType {
structure: ComponentModelStructure | null
customFields?: Array<Record<string, any>>
}
// ---------------------------------------------------------------------------
// Main composable
// ---------------------------------------------------------------------------
export function useComponentCreate() {
const route = useRoute()
const router = useRouter()
const { get } = useApi()
const { componentTypes, loadComponentTypes, loadingComponentTypes: loadingTypes } = useComponentTypes()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { productTypes, loadProductTypes } = useProductTypes()
const {
createComposant,
composants: componentCatalogRef,
loading: componentsLoading,
} = useComposants()
const {
pieces: pieceCatalogRef,
loading: piecesLoading,
} = usePieces()
const {
products: productCatalogRef,
loading: productsLoading,
} = useProducts()
const toast = useToast()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const { uploadDocuments } = useDocuments()
const { canEdit } = usePermissions()
// -------------------------------------------------------------------------
// Local state
// -------------------------------------------------------------------------
const selectedTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
const submitting = ref(false)
const creationForm = reactive({
name: '' as string,
description: '' as string,
reference: '' as string,
constructeurIds: [] as string[],
prix: '' as string,
})
const lastSuggestedName = ref('')
const customFieldInputs = ref<CustomFieldInput[]>([])
const structureAssignments = ref<StructureAssignmentNode | null>(null)
const selectedDocuments = ref<File[]>([])
const uploadingDocuments = ref(false)
// -------------------------------------------------------------------------
// Computed
// -------------------------------------------------------------------------
const availablePieces = computed(() => pieceCatalogRef.value ?? [])
const availableProducts = computed(() => productCatalogRef.value ?? [])
const availableComponents = computed(() => componentCatalogRef.value ?? [])
const structureDataLoading = computed(
() => piecesLoading.value || componentsLoading.value || productsLoading.value,
)
const fetchedPieceTypeMap = ref<Record<string, string>>({})
const pieceTypeLabelMap = computed(() =>
buildTypeLabelMap(pieceTypes.value, fetchedPieceTypeMap.value),
)
const productTypeLabelMap = computed(() =>
buildTypeLabelMap(productTypes.value),
)
const componentTypeLabelMap = computed(() =>
buildTypeLabelMap(componentTypes.value),
)
const componentTypeList = computed<ComponentCatalogType[]>(() =>
(componentTypes.value || [])
.filter((item: any) => item?.category === 'COMPONENT') as ComponentCatalogType[],
)
const typeOptionLabel = (type?: ComponentCatalogType) =>
type?.name || 'Catégorie'
const typeOptionDescription = (type?: ComponentCatalogType) =>
type?.description ? String(type.description) : ''
const selectedType = computed(() => {
if (!selectedTypeId.value) {
return null
}
return componentTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
})
const selectedTypeStructure = computed<ComponentModelStructure | null>(() => {
const structure = selectedType.value?.structure ?? null
return structure ? normalizeStructureForEditor(structure) : null
})
const structureHasRequirements = computed(() =>
hasAssignments(structureAssignments.value),
)
const structureSelectionsComplete = computed(() => {
if (!structureHasRequirements.value) {
return true
}
if (structureDataLoading.value) {
return false
}
if (!structureAssignments.value) {
return false
}
return isAssignmentNodeComplete(structureAssignments.value, true)
})
const requiredCustomFieldsFilled = computed(() =>
_requiredCustomFieldsFilled(customFieldInputs.value),
)
const canSubmit = computed(() => Boolean(
canEdit.value
&& selectedType.value
&& creationForm.name
&& requiredCustomFieldsFilled.value
&& structureSelectionsComplete.value
&& !submitting.value,
))
const resolvePieceLabel = (piece: Record<string, any>) =>
_resolvePieceLabel(piece, pieceTypeLabelMap.value)
const resolveProductLabel = (product: Record<string, any>) =>
_resolveProductLabel(product, productTypeLabelMap.value)
// -------------------------------------------------------------------------
// Watchers
// -------------------------------------------------------------------------
watch(
() => route.query.typeId,
(value) => {
if (typeof value === 'string') {
selectedTypeId.value = value
}
},
)
watch(selectedTypeId, (id) => {
const current = typeof route.query.typeId === 'string' ? route.query.typeId : ''
if ((id || '') === current) {
return
}
const nextQuery = { ...route.query }
if (id) {
nextQuery.typeId = id
}
else {
delete nextQuery.typeId
}
router.replace({ path: route.path, query: nextQuery }).catch(() => {})
})
const clearCreationForm = () => {
creationForm.name = ''
creationForm.description = ''
creationForm.reference = ''
creationForm.constructeurIds = []
creationForm.prix = ''
lastSuggestedName.value = ''
structureAssignments.value = null
}
watch(selectedType, (type) => {
if (!type) {
clearCreationForm()
customFieldInputs.value = []
structureAssignments.value = null
return
}
if (!creationForm.name || creationForm.name === lastSuggestedName.value) {
creationForm.name = type.name
}
lastSuggestedName.value = creationForm.name
customFieldInputs.value = normalizeCustomFieldInputs(selectedTypeStructure.value)
structureAssignments.value = initializeStructureAssignments(selectedTypeStructure.value)
})
watch(
selectedTypeStructure,
(structure) => {
const ids = getStructurePieces(structure)
.map((piece: any) => piece?.typePieceId)
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
if (!ids.length) {
return
}
fetchModelTypeNames(Array.from(new Set(ids)), pieceTypeLabelMap.value, get)
.then((additions) => {
if (Object.keys(additions).length) {
fetchedPieceTypeMap.value = { ...fetchedPieceTypeMap.value, ...additions }
}
})
.catch(() => {})
},
{ immediate: true },
)
// -------------------------------------------------------------------------
// Submission
// -------------------------------------------------------------------------
const submitCreation = async () => {
if (!selectedType.value) {
toast.showError('Sélectionnez une catégorie de composant.')
return
}
const payload: Record<string, any> = {
name: creationForm.name.trim(),
typeComposantId: selectedType.value.id,
}
const description = creationForm.description.trim()
if (description) {
payload.description = description
}
const reference = creationForm.reference.trim()
if (reference) {
payload.reference = reference
}
if (creationForm.constructeurIds.length) {
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
}
const rawPrice = typeof creationForm.prix === 'string'
? creationForm.prix.trim()
: creationForm.prix === null || creationForm.prix === undefined
? ''
: String(creationForm.prix).trim()
if (rawPrice) {
const parsed = Number(rawPrice)
if (!Number.isNaN(parsed)) {
payload.prix = String(parsed)
}
}
const rootProductSelection
= structureAssignments.value?.products?.find(
(product) => typeof product.selectedProductId === 'string' && product.selectedProductId.trim().length > 0,
) ?? null
if (rootProductSelection?.selectedProductId) {
payload.productId = rootProductSelection.selectedProductId.trim()
}
if (structureHasRequirements.value && !structureSelectionsComplete.value) {
toast.showError('Complétez la sélection des pièces, produits et sous-composants.')
return
}
const serializedStructure = structureHasRequirements.value
? serializeStructureAssignments(structureAssignments.value)
: null
if (serializedStructure) {
payload.structure = serializedStructure
}
submitting.value = true
try {
const result = await createComposant(payload)
if (result.success) {
const createdComponent = result.data as Record<string, any>
await _saveCustomFieldValues(
'composant',
createdComponent.id,
[createdComponent?.typeComposant?.customFields],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
if (selectedDocuments.value.length && result.data?.id) {
uploadingDocuments.value = true
const uploadResult = await uploadDocuments(
{
files: selectedDocuments.value,
context: { composantId: result.data.id },
},
{ updateStore: false },
)
if (!uploadResult.success) {
const message = uploadResult.error
? `Documents non ajoutés : ${uploadResult.error}`
: 'Documents non ajoutés : une erreur est survenue.'
toast.showError(message)
}
selectedDocuments.value = []
}
toast.showSuccess('Composant créé avec succès')
await router.push('/component-catalog')
}
else if (result.error) {
toast.showError(result.error)
}
}
catch (error: any) {
toast.showError(humanizeError(error?.message) || 'Impossible de créer le composant')
}
finally {
submitting.value = false
uploadingDocuments.value = false
}
}
// -------------------------------------------------------------------------
// Initialization
// -------------------------------------------------------------------------
onMounted(async () => {
await Promise.allSettled([
loadComponentTypes(),
loadPieceTypes(),
loadProductTypes(),
])
})
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
return {
// State
selectedTypeId,
submitting,
creationForm,
customFieldInputs,
structureAssignments,
selectedDocuments,
uploadingDocuments,
// Computed
loadingTypes,
componentTypeList,
selectedType,
selectedTypeStructure,
availablePieces,
availableProducts,
availableComponents,
piecesLoading,
productsLoading,
componentsLoading,
structureDataLoading,
pieceTypeLabelMap,
productTypeLabelMap,
componentTypeLabelMap,
structureHasRequirements,
structureSelectionsComplete,
canEdit,
canSubmit,
// Functions
typeOptionLabel,
typeOptionDescription,
formatStructurePreview,
resolvePieceLabel,
resolveProductLabel,
resolveSubcomponentLabel,
submitCreation,
}
}

View File

@@ -0,0 +1,461 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from '#imports'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useComposants } from '~/composables/useComposants'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useProductTypes } from '~/composables/useProductTypes'
import { usePieces } from '~/composables/usePieces'
import { useProducts } from '~/composables/useProducts'
import { useCustomFields } from '~/composables/useCustomFields'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { extractRelationId } from '~/shared/apiRelations'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useComponentHistory } from '~/composables/useComponentHistory'
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import {
getStructurePieces,
getStructureProducts,
resolvePieceLabel as _resolvePieceLabel,
resolveProductLabel as _resolveProductLabel,
resolveSubcomponentLabel,
fetchModelTypeNames,
buildTypeLabelMap,
} from '~/shared/utils/structureDisplayUtils'
import type { ComponentModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes'
import { canPreviewDocument } from '~/utils/documentPreview'
import {
type CustomFieldInput,
buildCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils'
import { collectStructureSelections } from '~/shared/utils/structureSelectionUtils'
interface ComponentCatalogType extends ModelType {
structure: ComponentModelStructure | null
customFields?: Array<Record<string, any>>
}
const historyFieldLabels: Record<string, string> = {
name: 'Nom',
reference: 'Référence',
prix: 'Prix',
structure: 'Structure',
typeComposant: 'Catégorie',
product: 'Produit lié',
constructeurIds: 'Fournisseurs',
}
export function useComponentEdit(componentId: string) {
const { canEdit } = usePermissions()
const router = useRouter()
const { get } = useApi()
const { componentTypes, loadComponentTypes } = useComponentTypes()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { productTypes, loadProductTypes } = useProductTypes()
const { updateComposant, loadComposants, composants: componentCatalogRef } = useComposants()
const { pieces, loadPieces } = usePieces()
const { products, loadProducts } = useProducts()
const { ensureConstructeurs } = useConstructeurs()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const toast = useToast()
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
const {
history,
loading: historyLoading,
error: historyError,
loadHistory,
} = useComponentHistory()
const component = ref<any | null>(null)
const loading = ref(true)
const saving = ref(false)
const selectedFiles = ref<File[]>([])
const uploadingDocuments = ref(false)
const loadingDocuments = ref(false)
const componentDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null)
const previewVisible = ref(false)
const selectedTypeId = ref<string>('')
const editionForm = reactive({
name: '' as string,
description: '' as string,
reference: '' as string,
constructeurIds: [] as string[],
prix: '' as string,
})
const customFieldInputs = ref<CustomFieldInput[]>([])
const fetchedPieceTypeMap = ref<Record<string, string>>({})
const pieceTypeLabelMap = computed(() =>
buildTypeLabelMap(pieceTypes.value, fetchedPieceTypeMap.value),
)
const fetchedProductTypeMap = ref<Record<string, string>>({})
const productTypeLabelMap = computed(() =>
buildTypeLabelMap(productTypes.value, fetchedProductTypeMap.value),
)
const pieceCatalogMap = computed(() =>
new Map(
(pieces.value || [])
.filter((item: any) => item?.id)
.map((item: any) => [String(item.id), item]),
),
)
const productCatalogMap = computed(() =>
new Map(
(products.value || [])
.filter((item: any) => item?.id)
.map((item: any) => [String(item.id), item]),
),
)
const componentCatalogMap = computed(() =>
new Map(
(componentCatalogRef.value || [])
.filter((item: any) => item?.id)
.map((item: any) => [String(item.id), item]),
),
)
const openPreview = (doc: any) => {
if (!doc || !canPreviewDocument(doc)) {
return
}
previewDocument.value = doc
previewVisible.value = true
}
const closePreview = () => {
previewVisible.value = false
previewDocument.value = null
}
const removeDocument = async (documentId: string | number | null | undefined) => {
if (!documentId) {
return
}
const result = await deleteDocument(documentId, { updateStore: false })
if (result.success) {
componentDocuments.value = componentDocuments.value.filter((doc) => doc.id !== documentId)
}
}
const refreshDocuments = async () => {
if (!component.value?.id) {
componentDocuments.value = []
return
}
loadingDocuments.value = true
try {
const result = await loadDocumentsByComponent(component.value.id, { updateStore: false })
if (result.success) {
componentDocuments.value = Array.isArray(result.data) ? result.data : result.data ? [result.data] : []
}
}
finally {
loadingDocuments.value = false
}
}
const handleFilesAdded = async (files: File[]) => {
if (!files?.length || !component.value?.id) {
return
}
uploadingDocuments.value = true
try {
const result = await uploadDocuments(
{
files,
context: { composantId: component.value.id },
},
{ updateStore: false },
)
if (result.success) {
selectedFiles.value = []
await refreshDocuments()
}
}
finally {
uploadingDocuments.value = false
}
}
const componentTypeList = computed<ComponentCatalogType[]>(() =>
(componentTypes.value || [])
.filter((item: any) => item?.category === 'COMPONENT') as ComponentCatalogType[],
)
const selectedType = computed(() => {
if (!selectedTypeId.value) {
return null
}
return componentTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
})
const selectedTypeStructure = computed<ComponentModelStructure | null>(() => {
const structure = selectedType.value?.structure ?? null
return structure ? normalizeStructureForEditor(structure) : null
})
const refreshCustomFieldInputs = (
structureOverride?: ComponentModelStructure | null,
valuesOverride?: any[] | null,
) => {
const structure = structureOverride ?? selectedTypeStructure.value ?? null
const values = valuesOverride ?? component.value?.customFieldValues ?? null
customFieldInputs.value = buildCustomFieldInputs(structure, values)
}
const requiredCustomFieldsFilled = computed(() =>
_requiredCustomFieldsFilled(customFieldInputs.value),
)
const canSubmit = computed(() => Boolean(
canEdit.value
&& component.value
&& editionForm.name
&& requiredCustomFieldsFilled.value
&& !saving.value,
))
const fetchComponent = async () => {
if (!componentId || typeof componentId !== 'string') {
component.value = null
componentDocuments.value = []
return
}
const result = await get(`/composants/${componentId}`)
if (result.success) {
component.value = result.data
componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
refreshCustomFieldInputs(undefined, customValues)
loadHistory(result.data.id).catch(() => {})
}
else {
component.value = null
componentDocuments.value = []
}
}
const resolvePieceLabel = (piece: Record<string, any>) =>
_resolvePieceLabel(piece, pieceTypeLabelMap.value)
const resolveProductLabel = (product: Record<string, any>) =>
_resolveProductLabel(product, productTypeLabelMap.value)
const structureSelections = computed(() => {
const selections = collectStructureSelections(
component.value?.structure,
{
pieceCatalogMap: pieceCatalogMap.value,
productCatalogMap: productCatalogMap.value,
componentCatalogMap: componentCatalogMap.value,
},
{ resolvePieceLabel, resolveProductLabel, resolveSubcomponentLabel },
)
const total
= selections.pieces.length + selections.products.length + selections.components.length
return {
...selections,
total,
hasAny: total > 0,
}
})
const submitEdition = async () => {
if (!component.value) {
return
}
const rawPrice = typeof editionForm.prix === 'string'
? editionForm.prix.trim()
: editionForm.prix === null || editionForm.prix === undefined
? ''
: String(editionForm.prix).trim()
const payload: Record<string, any> = {
name: editionForm.name.trim(),
description: editionForm.description.trim() || null,
}
const reference = editionForm.reference.trim()
payload.reference = reference || null
payload.constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
if (rawPrice) {
const parsed = Number(rawPrice)
if (!Number.isNaN(parsed)) {
payload.prix = String(parsed)
}
}
else {
payload.prix = null
}
saving.value = true
try {
const result = await updateComposant(component.value.id, payload)
if (result.success && result.data) {
const updatedComponent = result.data as Record<string, any>
await _saveCustomFieldValues(
'composant',
updatedComponent.id,
[
updatedComponent?.typeComposant?.customFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
await router.push('/component-catalog')
}
}
catch (error: any) {
toast.showError(error?.message || 'Erreur lors de la mise à jour du composant')
}
finally {
saving.value = false
}
}
// --- Watchers ---
const initialized = ref(false)
watch(
[component, selectedTypeStructure],
([currentComponent, currentStructure]) => {
if (!currentComponent) {
return
}
if (!initialized.value) {
const resolvedTypeId = currentComponent.typeComposantId
|| extractRelationId(currentComponent.typeComposant)
|| ''
if (resolvedTypeId && !currentComponent.typeComposantId) {
currentComponent.typeComposantId = resolvedTypeId
}
selectedTypeId.value = resolvedTypeId
editionForm.name = currentComponent.name || ''
editionForm.description = currentComponent.description || ''
editionForm.reference = currentComponent.reference || ''
editionForm.constructeurIds = uniqueConstructeurIds(
currentComponent,
Array.isArray(currentComponent.constructeurs) ? currentComponent.constructeurs : [],
currentComponent.constructeur ? [currentComponent.constructeur] : [],
)
editionForm.prix = currentComponent.prix !== null && currentComponent.prix !== undefined ? String(currentComponent.prix) : ''
if (editionForm.constructeurIds.length) {
void ensureConstructeurs(editionForm.constructeurIds)
}
initialized.value = true
}
refreshCustomFieldInputs(selectedTypeStructure.value ?? currentStructure, currentComponent.customFieldValues)
},
{ immediate: true },
)
watch(
selectedTypeStructure,
(structure) => {
const pieceIds = getStructurePieces(structure)
.map((piece: any) => piece?.typePieceId)
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
if (pieceIds.length) {
fetchModelTypeNames(Array.from(new Set(pieceIds)), pieceTypeLabelMap.value, get)
.then((additions) => {
if (Object.keys(additions).length) {
fetchedPieceTypeMap.value = { ...fetchedPieceTypeMap.value, ...additions }
}
})
.catch(() => {})
}
const productIds = getStructureProducts(structure)
.map((product: any) => product?.typeProductId)
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
if (productIds.length) {
fetchModelTypeNames(Array.from(new Set(productIds)), productTypeLabelMap.value, get)
.then((additions) => {
if (Object.keys(additions).length) {
fetchedProductTypeMap.value = { ...fetchedProductTypeMap.value, ...additions }
}
})
.catch(() => {})
}
},
{ immediate: true },
)
// --- Lifecycle ---
onMounted(async () => {
await Promise.allSettled([
loadComponentTypes(),
loadPieceTypes(),
loadProductTypes(),
fetchComponent(),
])
loading.value = false
// Defer bulk catalog loads — only needed when component has structure selections
if (component.value?.structure) {
Promise.allSettled([
loadPieces({ itemsPerPage: 200 }),
loadProducts({ itemsPerPage: 200 }),
loadComposants({ itemsPerPage: 200 }),
]).catch(() => {})
}
})
return {
// State
component,
loading,
saving,
selectedFiles,
uploadingDocuments,
loadingDocuments,
componentDocuments,
previewDocument,
previewVisible,
selectedTypeId,
editionForm,
customFieldInputs,
historyFieldLabels,
// Computed
canEdit,
canSubmit,
componentTypeList,
selectedType,
selectedTypeStructure,
structureSelections,
// History
history,
historyLoading,
historyError,
// Methods
openPreview,
closePreview,
removeDocument,
handleFilesAdded,
refreshDocuments,
submitEdition,
resolvePieceLabel,
resolveProductLabel,
resolveSubcomponentLabel,
formatStructurePreview,
}
}

View File

@@ -0,0 +1,109 @@
import { ref } from 'vue'
interface DragReorderHandlers {
draggingIndex: Ref<number | null>
dropTargetIndex: Ref<number | null>
onDragStart: (index: number, event: DragEvent) => void
onDragEnter: (index: number) => void
onDragOver: (event: DragEvent) => void
onDrop: (index: number) => void
onDragEnd: () => void
reorderClass: (index: number) => string
reset: () => void
}
interface DragReorderOptions {
draggingClass?: string
dropTargetClass?: string
onReorder?: () => void
}
function moveItemInPlace<T>(list: T[], from: number, to: number): void {
if (from === to) return
if (from < 0 || to < 0 || from >= list.length || to >= list.length) return
const updated = list.slice()
const [item] = updated.splice(from, 1)
if (item === undefined) return
updated.splice(to, 0, item)
list.splice(0, list.length, ...updated)
}
export function useDragReorder(
getList: () => unknown[] | undefined,
options: DragReorderOptions = {},
): DragReorderHandlers {
const {
draggingClass = 'border-dashed border-primary',
dropTargetClass = 'border-primary border-dashed bg-primary/5',
onReorder,
} = options
const draggingIndex = ref<number | null>(null)
const dropTargetIndex = ref<number | null>(null)
const reset = () => {
draggingIndex.value = null
dropTargetIndex.value = null
}
const onDragStart = (index: number, event: DragEvent) => {
draggingIndex.value = index
dropTargetIndex.value = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onDragEnter = (index: number) => {
if (draggingIndex.value === null) return
dropTargetIndex.value = index
}
const onDragOver = (event: DragEvent) => {
event.preventDefault()
}
const onDrop = (index: number) => {
const list = getList()
if (!Array.isArray(list)) {
reset()
return
}
const from = draggingIndex.value
if (from === null) {
reset()
return
}
moveItemInPlace(list, from, index)
onReorder?.()
reset()
}
const onDragEnd = () => {
reset()
}
const reorderClass = (index: number): string => {
if (draggingIndex.value === index) return draggingClass
if (
draggingIndex.value !== null
&& dropTargetIndex.value === index
&& draggingIndex.value !== index
) {
return dropTargetClass
}
return ''
}
return {
draggingIndex,
dropTargetIndex,
onDragStart,
onDragEnter,
onDragOver,
onDrop,
onDragEnd,
reorderClass,
reset,
}
}

View File

@@ -0,0 +1,327 @@
import { reactive, ref } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
// --- Types ---
export type MachineFieldType = 'text' | 'number' | 'select' | 'boolean' | 'date'
export interface MachineCustomFieldEditorField {
uid: string
serverId?: string
name: string
type: MachineFieldType
required: boolean
optionsText: string
orderIndex: number
}
interface InitialDef {
id: string
name: string
type: string
required: boolean
options?: string[]
orderIndex: number
defaultValue?: unknown
}
interface Deps {
machineId: string
initialDefs: InitialDef[]
onSaved: () => void | Promise<void>
}
// --- Helpers ---
let uidCounter = 0
const createUid = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
uidCounter += 1
return `mcf-${Date.now().toString(36)}-${uidCounter}`
}
const normalizeLineEndings = (value: string): string =>
value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
const toEditorField = (def: InitialDef, index: number): MachineCustomFieldEditorField => ({
uid: createUid(),
serverId: def.id,
name: def.name || '',
type: (def.type || 'text') as MachineFieldType,
required: Boolean(def.required),
optionsText: normalizeLineEndings(
Array.isArray(def.options) ? def.options.join('\n') : '',
),
orderIndex: typeof def.orderIndex === 'number' ? def.orderIndex : index,
})
const hydrateFields = (defs: InitialDef[]): MachineCustomFieldEditorField[] =>
defs
.map((def, index) => toEditorField(def, index))
.sort((a, b) => a.orderIndex - b.orderIndex)
.map((field, index) => ({ ...field, orderIndex: index }))
const buildSnapshot = (defs: InitialDef[]): Map<string, InitialDef> => {
const map = new Map<string, InitialDef>()
for (const def of defs) {
map.set(def.id, def)
}
return map
}
const applyOrderIndex = (
list: MachineCustomFieldEditorField[],
): MachineCustomFieldEditorField[] =>
list.map((field, index) => ({ ...field, orderIndex: index }))
const parseOptions = (optionsText: string): string[] =>
normalizeLineEndings(optionsText)
.split('\n')
.map(o => o.trim())
.filter(o => o.length > 0)
// --- Composable ---
export function useMachineCustomFieldDefs(deps: Deps) {
const { apiCall } = useApi()
const { showSuccess, showError } = useToast()
// --- State ---
const fields = ref<MachineCustomFieldEditorField[]>(hydrateFields(deps.initialDefs))
const initialSnapshot = ref<Map<string, InitialDef>>(buildSnapshot(deps.initialDefs))
const saving = ref(false)
// --- CRUD ---
const addField = () => {
const next = fields.value.slice()
next.push({
uid: createUid(),
name: '',
type: 'text',
required: false,
optionsText: '',
orderIndex: next.length,
})
fields.value = applyOrderIndex(next)
}
const removeField = (index: number) => {
const next = fields.value.filter((_, i) => i !== index)
fields.value = applyOrderIndex(next)
}
// --- Drag & drop ---
const dragState = reactive({
draggingIndex: null as number | null,
dropTargetIndex: null as number | null,
})
const resetDragState = () => {
dragState.draggingIndex = null
dragState.dropTargetIndex = null
}
const onDragStart = (index: number, event: DragEvent) => {
dragState.draggingIndex = index
dragState.dropTargetIndex = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onDragEnter = (index: number) => {
if (dragState.draggingIndex === null) return
dragState.dropTargetIndex = index
}
const onDrop = (index: number) => {
const from = dragState.draggingIndex
if (from === null) {
resetDragState()
return
}
if (from === index) {
resetDragState()
return
}
const list = fields.value.slice()
if (from < 0 || index < 0 || from >= list.length || index >= list.length) {
resetDragState()
return
}
const [moved] = list.splice(from, 1)
if (!moved) {
resetDragState()
return
}
list.splice(index, 0, moved)
fields.value = applyOrderIndex(list)
resetDragState()
}
const onDragEnd = () => {
resetDragState()
}
const reorderClass = (index: number): string => {
if (dragState.draggingIndex === index) {
return 'border-dashed border-primary bg-primary/5'
}
if (
dragState.draggingIndex !== null
&& dragState.dropTargetIndex === index
&& dragState.draggingIndex !== index
) {
return 'border-primary border-dashed bg-primary/10'
}
return ''
}
// --- Save ---
const saveDefinitions = async () => {
if (saving.value) return
// Validate: remove empty-name fields before saving
const emptyNameFields = fields.value.filter(f => !f.name.trim() && !f.serverId)
if (emptyNameFields.length > 0) {
fields.value = applyOrderIndex(fields.value.filter(f => f.name.trim() || f.serverId))
}
saving.value = true
try {
const snapshot = initialSnapshot.value
const currentServerIds = new Set(
fields.value.filter(f => f.serverId).map(f => f.serverId!),
)
// DELETE removed fields
const deletedIds = [...snapshot.keys()].filter(id => !currentServerIds.has(id))
for (const id of deletedIds) {
const result = await apiCall(`/custom_fields/${id}`, { method: 'DELETE' })
if (!result.success) {
showError('Erreur lors de la suppression d\'un champ personnalisé')
await deps.onSaved()
return
}
}
let hasNewFields = false
for (const field of fields.value) {
const name = field.name.trim()
if (!name) continue
const options = field.type === 'select' ? parseOptions(field.optionsText) : []
if (!field.serverId) {
// POST new field
hasNewFields = true
const body: Record<string, unknown> = {
name,
type: field.type,
required: field.required,
options,
orderIndex: field.orderIndex,
machine: `/api/machines/${deps.machineId}`,
}
const result = await apiCall('/custom_fields', {
method: 'POST',
headers: { 'Content-Type': 'application/ld+json' },
body: JSON.stringify(body),
})
if (!result.success) {
showError('Erreur lors de la création d\'un champ personnalisé')
await deps.onSaved()
return
}
} else {
// PATCH modified field
const original = snapshot.get(field.serverId)
const originalOptions = Array.isArray(original?.options)
? original.options.join('\n')
: ''
const currentOptions = field.type === 'select' ? field.optionsText : ''
const changed
= original?.name !== name
|| original?.type !== field.type
|| original?.required !== field.required
|| normalizeLineEndings(originalOptions) !== normalizeLineEndings(currentOptions)
|| original?.orderIndex !== field.orderIndex
if (changed) {
const body: Record<string, unknown> = {
name,
type: field.type,
required: field.required,
options,
orderIndex: field.orderIndex,
}
const result = await apiCall(`/custom_fields/${field.serverId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/merge-patch+json' },
body: JSON.stringify(body),
})
if (!result.success) {
showError('Erreur lors de la mise à jour d\'un champ personnalisé')
await deps.onSaved()
return
}
}
}
}
// Initialize missing custom field values if new fields were created
if (hasNewFields) {
await apiCall(`/machines/${deps.machineId}/add-custom-fields`, {
method: 'POST',
headers: { 'Content-Type': 'application/ld+json' },
body: JSON.stringify({}),
})
}
showSuccess('Champs personnalisés sauvegardés avec succès')
await deps.onSaved()
} catch {
showError('Erreur inattendue lors de la sauvegarde des champs personnalisés')
await deps.onSaved()
} finally {
saving.value = false
}
}
// --- Reinit ---
const reinit = (newDefs: InitialDef[]) => {
fields.value = hydrateFields(newDefs)
initialSnapshot.value = buildSnapshot(newDefs)
}
return {
fields,
saving,
dragState,
addField,
removeField,
onDragStart,
onDragEnter,
onDrop,
onDragEnd,
reorderClass,
saveDefinitions,
reinit,
}
}

View File

@@ -0,0 +1,396 @@
/**
* Machine detail — custom field management sub-composable.
*
* Handles custom field resolution, display filtering, sync and updates
* for machines, components and pieces.
*/
import { ref, computed } from 'vue'
import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
import { normalizeStructureForEditor } from '~/shared/modelUtils'
import {
shouldDisplayCustomField,
normalizeExistingCustomFieldDefinitions,
normalizeCustomFieldValueEntry,
mergeCustomFieldValuesWithDefinitions,
dedupeCustomFieldEntries,
} from '~/shared/utils/customFieldUtils'
import {
resolveConstructeurs,
uniqueConstructeurIds,
} from '~/shared/constructeurUtils'
type AnyRecord = Record<string, unknown>
interface MachineDetailCustomFieldsDeps {
machine: Ref<AnyRecord | null>
isEditMode: Ref<boolean>
constructeurs: Ref<unknown[]>
resolveProductReference: (source: AnyRecord) => { product: unknown; productId: string | null }
getProductDisplay: (source: AnyRecord) => unknown
}
export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps) {
const { machine, isEditMode, constructeurs, resolveProductReference, getProductDisplay } = deps
const {
upsertCustomFieldValue,
updateCustomFieldValue: updateCustomFieldValueApi,
} = useCustomFields()
const toast = useToast()
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const machineCustomFields = ref<AnyRecord[]>([])
// ---------------------------------------------------------------------------
// Computed
// ---------------------------------------------------------------------------
const visibleMachineCustomFields = computed(() => {
const fields = Array.isArray(machineCustomFields.value) ? machineCustomFields.value : []
if (isEditMode.value) return fields
return fields.filter((field) => shouldDisplayCustomField(field))
})
// ---------------------------------------------------------------------------
// Transform helpers
// ---------------------------------------------------------------------------
const getStructureCustomFields = (structure: unknown): AnyRecord[] => {
if (!structure || typeof structure !== 'object') return []
const normalized = normalizeStructureForEditor(structure as any) as any
return Array.isArray(normalized?.customFields)
? (normalized.customFields as AnyRecord[])
: []
}
const transformCustomFields = (piecesData: AnyRecord[]): AnyRecord[] => {
return (piecesData || []).map((piece) => {
const typePiece = (piece.typePiece as AnyRecord) || {}
const normalizeStructureDefs = (structure: unknown) =>
structure ? normalizeStructureForEditor(structure as AnyRecord) : null
const normalizedStructureDefs = [
normalizeStructureDefs((piece.definition as AnyRecord)?.structure),
normalizeStructureDefs(typePiece.structure),
]
const valueEntries = [
...(Array.isArray(piece.customFieldValues) ? piece.customFieldValues : []),
...(Array.isArray(piece.customFields)
? (piece.customFields as AnyRecord[])
.map(normalizeCustomFieldValueEntry)
.filter((e) => e !== null)
: []),
...(Array.isArray(typePiece.customFieldValues)
? (typePiece.customFieldValues as AnyRecord[])
.map(normalizeCustomFieldValueEntry)
.filter((e) => e !== null)
: []),
]
const customFields = dedupeCustomFieldEntries(
mergeCustomFieldValuesWithDefinitions(
valueEntries,
normalizeExistingCustomFieldDefinitions(piece.customFields),
normalizeExistingCustomFieldDefinitions((piece.definition as AnyRecord)?.customFields),
normalizeExistingCustomFieldDefinitions((piece.typePiece as AnyRecord)?.customFields),
normalizeExistingCustomFieldDefinitions(typePiece.customFields),
...normalizedStructureDefs.map((def) => getStructureCustomFields(def)),
),
)
const constructeurIds = uniqueConstructeurIds(
piece.constructeurs,
piece.constructeurIds,
piece.constructeurId,
piece.constructeur,
(piece.originalPiece as AnyRecord)?.constructeurs,
(piece.originalPiece as AnyRecord)?.constructeurIds,
(piece.originalPiece as AnyRecord)?.constructeurId,
(piece.originalPiece as AnyRecord)?.constructeur,
)
const { product: resolvedProduct, productId: resolvedProductId } =
resolveProductReference(piece)
const constructeursList = resolveConstructeurs(
constructeurIds,
Array.isArray(piece.constructeurs) ? (piece.constructeurs as any[]) : [],
piece.constructeur ? [piece.constructeur as any] : [],
Array.isArray((piece.originalPiece as AnyRecord)?.constructeurs)
? ((piece.originalPiece as AnyRecord).constructeurs as any[])
: [],
(piece.originalPiece as AnyRecord)?.constructeur
? [(piece.originalPiece as AnyRecord).constructeur as any]
: [],
constructeurs.value as any,
) as any[]
const normalizedPiece = {
...piece,
product: resolvedProduct || piece.product || null,
productId: resolvedProductId || piece.productId || (piece.product as AnyRecord)?.id || null,
}
const productDisplay = getProductDisplay(normalizedPiece)
return {
...normalizedPiece,
customFields,
documents: piece.documents || [],
constructeurs: constructeursList,
constructeur: constructeursList[0] || piece.constructeur || null,
constructeurIds,
constructeurId: constructeurIds[0] || null,
typePieceId:
piece.typePieceId ||
(piece.typePiece as AnyRecord)?.id ||
null,
__productDisplay: productDisplay,
}
})
}
const transformComponentCustomFields = (componentsData: AnyRecord[]): AnyRecord[] => {
const normalizeStructureDefs = (structure: unknown) =>
structure ? normalizeStructureForEditor(structure as AnyRecord) : null
return (componentsData || []).map((component) => {
const type = (component.typeComposant as AnyRecord) || {}
const normalizedStructureDefs = [
normalizeStructureDefs((component.definition as AnyRecord)?.structure),
normalizeStructureDefs(type.structure),
]
const actualComponent = (component.originalComposant as AnyRecord) || component
const valueEntries = [
...(Array.isArray(component.customFieldValues) ? component.customFieldValues : []),
...(Array.isArray(component.customFields)
? (component.customFields as AnyRecord[])
.map(normalizeCustomFieldValueEntry)
.filter((e) => e !== null)
: []),
...(Array.isArray(actualComponent?.customFields)
? (actualComponent.customFields as AnyRecord[])
.map(normalizeCustomFieldValueEntry)
.filter((e) => e !== null)
: []),
]
const customFields = dedupeCustomFieldEntries(
mergeCustomFieldValuesWithDefinitions(
valueEntries,
normalizeExistingCustomFieldDefinitions(component.customFields),
normalizeExistingCustomFieldDefinitions((component.definition as AnyRecord)?.customFields),
normalizeExistingCustomFieldDefinitions((component.typeComposant as AnyRecord)?.customFields),
normalizeExistingCustomFieldDefinitions(type.customFields),
normalizeExistingCustomFieldDefinitions(actualComponent?.customFields),
...normalizedStructureDefs.map((def) => getStructureCustomFields(def)),
),
)
const piecesTransformed = component.pieces
? transformCustomFields(component.pieces as AnyRecord[]).map((p) => ({
...p,
parentComponentName: component.name,
}))
: []
const subComponents = component.sousComposants
? transformComponentCustomFields(component.sousComposants as AnyRecord[])
: []
const constructeurIds = uniqueConstructeurIds(
component.constructeurs,
component.constructeurIds,
component.constructeurId,
component.constructeur,
actualComponent?.constructeurs,
actualComponent?.constructeurIds,
actualComponent?.constructeurId,
actualComponent?.constructeur,
)
const constructeursList = resolveConstructeurs(
constructeurIds,
Array.isArray(component.constructeurs) ? (component.constructeurs as any[]) : [],
component.constructeur ? [component.constructeur as any] : [],
Array.isArray(actualComponent?.constructeurs)
? (actualComponent.constructeurs as any[])
: [],
actualComponent?.constructeur ? [actualComponent.constructeur as any] : [],
constructeurs.value as any,
) as any[]
const { product: resolvedProduct, productId: resolvedProductId } =
resolveProductReference(component)
const normalizedComponent = {
...component,
product: resolvedProduct || component.product || null,
productId:
resolvedProductId || component.productId || (component.product as AnyRecord)?.id || null,
}
const productDisplay = getProductDisplay(normalizedComponent)
return {
...normalizedComponent,
customFields,
pieces: piecesTransformed,
subComponents,
documents: component.documents || [],
constructeurs: constructeursList,
constructeur: constructeursList[0] || component.constructeur || null,
constructeurIds,
constructeurId: constructeurIds[0] || null,
typeComposantId:
component.typeComposantId ||
(component.typeComposant as AnyRecord)?.id ||
null,
__productDisplay: productDisplay,
}
})
}
// ---------------------------------------------------------------------------
// Machine custom field methods
// ---------------------------------------------------------------------------
const syncMachineCustomFields = () => {
if (!machine.value) {
machineCustomFields.value = []
return
}
const valueEntries = [
...(Array.isArray(machine.value.customFieldValues) ? machine.value.customFieldValues : []),
...(Array.isArray(machine.value.customFields)
? (machine.value.customFields as AnyRecord[])
.map(normalizeCustomFieldValueEntry)
.filter((e) => e !== null)
: []),
]
const merged = dedupeCustomFieldEntries(
mergeCustomFieldValuesWithDefinitions(
valueEntries,
normalizeExistingCustomFieldDefinitions(machine.value.customFields),
),
).map((field: AnyRecord) => ({ ...field, readOnly: false }))
machineCustomFields.value = merged
}
const setMachineCustomFieldValue = (field: AnyRecord, value: unknown) => {
if (!field) return
field.value = value
if (field.customFieldValueId && (machine.value as AnyRecord)?.customFieldValues) {
const stored = ((machine.value as AnyRecord).customFieldValues as AnyRecord[]).find(
(fv) => fv.id === field.customFieldValueId,
)
if (stored) stored.value = value
}
}
const updateMachineCustomField = async (field: AnyRecord) => {
if (!machine.value || !field) return
const { id: customFieldId, customFieldValueId } = field
const fieldLabel = (field.name as string) || 'Champ personnalisé'
try {
if (customFieldValueId) {
const result: any = await updateCustomFieldValueApi(customFieldValueId as string, {
value: field.value ?? '',
} as any)
if (result.success) {
toast.showSuccess(`Champ "${fieldLabel}" de la machine mis à jour avec succès`)
syncMachineCustomFields()
} else {
toast.showError(`Erreur lors de la mise à jour du champ "${fieldLabel}"`)
}
return
}
if (!customFieldId) {
toast.showError(
'Impossible de mettre à jour ce champ personnalisé (identifiant manquant).',
)
return
}
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
toast.showSuccess(`Champ "${fieldLabel}" de la machine mis à jour avec succès`)
if (createdValue?.id) {
if (!createdValue.customField) {
createdValue.customField = {
id: customFieldId,
name: field.name,
type: field.type,
required: field.required,
options: field.options,
}
}
field.customFieldValueId = createdValue.id
field.readOnly = false
const existingValues = Array.isArray(machine.value.customFieldValues)
? (machine.value.customFieldValues as AnyRecord[]).filter(
(item) => item.id !== createdValue.id,
)
: []
machine.value.customFieldValues = [...existingValues, createdValue]
}
syncMachineCustomFields()
} else {
toast.showError(`Erreur lors de la mise à jour du champ "${fieldLabel}"`)
}
} catch (error) {
console.error('Erreur lors de la mise à jour du champ personnalisé de la machine:', error)
toast.showError(`Erreur lors de la mise à jour du champ "${fieldLabel}"`)
}
}
const updatePieceCustomField = async (fieldUpdate: AnyRecord) => {
try {
const result: any = await upsertCustomFieldValue(
fieldUpdate.fieldId as string,
'piece',
fieldUpdate.pieceId as string,
fieldUpdate.value,
)
if (result.success) {
toast.showSuccess('Champ personnalisé mis à jour avec succès')
} else {
toast.showError('Erreur lors de la mise à jour du champ personnalisé')
}
} catch (error) {
toast.showError('Erreur lors de la mise à jour du champ personnalisé')
console.error('Erreur lors de la mise à jour du champ personnalisé:', error)
}
}
return {
// State
machineCustomFields,
// Computed
visibleMachineCustomFields,
// Transform functions
transformCustomFields,
transformComponentCustomFields,
// Methods
syncMachineCustomFields,
setMachineCustomFieldValue,
updateMachineCustomField,
updatePieceCustomField,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,146 @@
/**
* Machine detail — document management sub-composable.
*
* Handles document loading, upload, delete and preview state.
*/
import { ref, computed } from 'vue'
import { useDocuments } from '~/composables/useDocuments'
import { canPreviewDocument } from '~/utils/documentPreview'
type AnyRecord = Record<string, unknown>
interface MachineDetailDocumentsDeps {
machine: Ref<AnyRecord | null>
}
export function useMachineDetailDocuments(deps: MachineDetailDocumentsDeps) {
const { machine } = deps
const {
uploadDocuments,
deleteDocument,
loadDocumentsByMachine,
loadDocumentsByProduct,
} = useDocuments()
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const machineDocumentFiles = ref<File[]>([])
const machineDocumentsUploading = ref(false)
const machineDocumentsLoaded = ref(false)
const previewDocument = ref<AnyRecord | null>(null)
const previewVisible = ref(false)
// ---------------------------------------------------------------------------
// Computed
// ---------------------------------------------------------------------------
const machineDocumentsList = computed(
() => ((machine.value as AnyRecord)?.documents as AnyRecord[]) || [],
)
// ---------------------------------------------------------------------------
// Methods
// ---------------------------------------------------------------------------
const refreshMachineDocuments = async () => {
if (!machine.value?.id) return
const result: any = await loadDocumentsByMachine(machine.value.id as string, { updateStore: false })
if (result.success && machine.value) {
machine.value.documents = result.data || []
machineDocumentsLoaded.value = true
}
}
const handleMachineFilesAdded = async (files: File[]) => {
if (!files.length || !machine.value?.id) return
machineDocumentsUploading.value = true
try {
const result: any = await uploadDocuments(
{ files, context: { machineId: machine.value.id } } as any,
{ updateStore: false },
)
if (result.success && machine.value) {
const newDocs = (result.data as AnyRecord[]) || []
machine.value.documents = [
...newDocs,
...((machine.value.documents as AnyRecord[]) || []),
]
machineDocumentFiles.value = []
}
} finally {
machineDocumentsUploading.value = false
}
}
const removeMachineDocument = async (documentId: string) => {
if (!documentId) return
const result: any = await deleteDocument(documentId, { updateStore: false })
if (result.success && machine.value) {
machine.value.documents = ((machine.value.documents as AnyRecord[]) || []).filter(
(doc) => doc.id !== documentId,
)
}
}
const openPreview = (doc: AnyRecord) => {
if (!canPreviewDocument(doc)) return
previewDocument.value = doc
previewVisible.value = true
}
const closePreview = () => {
previewVisible.value = false
previewDocument.value = null
}
const loadProductDocuments = async (machineProductLinks: AnyRecord[]) => {
const productIds = machineProductLinks
.map((link) => {
const p = link.product as AnyRecord | string | null
if (typeof p === 'string') return p.split('/').pop() || null
return (p as AnyRecord)?.id as string | null
})
.filter((id): id is string => !!id)
const results = await Promise.allSettled(
productIds.map(async (id) => {
const result: any = await loadDocumentsByProduct(id, { updateStore: false })
if (result.success && Array.isArray(result.data)) {
return { id, docs: result.data as AnyRecord[] }
}
return { id, docs: [] }
}),
)
const map = new Map<string, AnyRecord[]>()
results.forEach((r) => {
if (r.status === 'fulfilled' && r.value.docs.length) {
map.set(r.value.id, r.value.docs)
}
})
return map
}
return {
// State
machineDocumentFiles,
machineDocumentsUploading,
machineDocumentsLoaded,
previewDocument,
previewVisible,
// Computed
machineDocumentsList,
// Methods
refreshMachineDocuments,
handleMachineFilesAdded,
removeMachineDocument,
openPreview,
closePreview,
loadProductDocuments,
}
}

View File

@@ -0,0 +1,306 @@
/**
* Machine detail — hierarchy & link management sub-composable.
*
* Handles machine hierarchy building, component/piece tree resolution,
* flatten helpers, find-by-id utilities, and structure link CRUD.
*/
import { ref, computed } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import {
resolveIdentifier,
} from '~/shared/utils/productDisplayUtils'
import {
buildMachineHierarchyFromLinks,
resolveLinkArray,
} from '~/composables/useMachineHierarchy'
type AnyRecord = Record<string, unknown>
interface MachineDetailHierarchyDeps {
machineId: string
machine: Ref<AnyRecord | null>
constructeurs: Ref<unknown[]>
findProductById: (id: string | null | undefined) => AnyRecord | null
transformComponentCustomFields: (data: AnyRecord[]) => AnyRecord[]
transformCustomFields: (data: AnyRecord[]) => AnyRecord[]
syncMachineCustomFields: () => void
}
export function useMachineDetailHierarchy(deps: MachineDetailHierarchyDeps) {
const {
machineId,
machine,
constructeurs,
findProductById,
transformComponentCustomFields,
transformCustomFields,
syncMachineCustomFields,
} = deps
const { get, post: apiPost, delete: apiDel } = useApi()
const toast = useToast()
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const components = ref<AnyRecord[]>([])
const pieces = ref<AnyRecord[]>([])
const machineComponentLinks = ref<AnyRecord[]>([])
const machinePieceLinks = ref<AnyRecord[]>([])
const machineProductLinks = ref<AnyRecord[]>([])
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const flattenComponents = (list: AnyRecord[] = []): AnyRecord[] => {
const result: AnyRecord[] = []
const traverse = (items: AnyRecord[]) => {
items.forEach((item) => {
result.push(item)
if (Array.isArray(item.subComponents) && item.subComponents.length) {
traverse(item.subComponents as AnyRecord[])
}
})
}
traverse(list)
return result
}
const findComponentById = (items: AnyRecord[] | undefined, id: string): AnyRecord | null => {
for (const item of items || []) {
if (item.id === id) return item
const found = findComponentById(item.subComponents as AnyRecord[] | undefined, id)
if (found) return found
}
return null
}
const findPieceById = (pieceId: string): AnyRecord | null => {
const direct = pieces.value.find((p) => p.id === pieceId)
if (direct) return direct
const searchInComponents = (items: AnyRecord[]): AnyRecord | null => {
for (const item of items || []) {
const match = ((item.pieces as AnyRecord[]) || []).find((p) => p.id === pieceId)
if (match) return match
const nested = searchInComponents((item.subComponents as AnyRecord[]) || [])
if (nested) return nested
}
return null
}
return searchInComponents(components.value)
}
// ---------------------------------------------------------------------------
// Hierarchy & links
// ---------------------------------------------------------------------------
const applyMachineLinks = (source: AnyRecord): boolean => {
const container = (source?.machine as AnyRecord) ?? null
const componentLinksData =
resolveLinkArray(source, ['componentLinks', 'machineComponentLinks']) ??
resolveLinkArray(container, ['componentLinks', 'machineComponentLinks'])
const pieceLinksData =
resolveLinkArray(source, ['pieceLinks', 'machinePieceLinks']) ??
resolveLinkArray(container, ['pieceLinks', 'machinePieceLinks'])
const productLinksData =
resolveLinkArray(source, ['productLinks', 'machineProductLinks']) ??
resolveLinkArray(container, ['productLinks', 'machineProductLinks'])
if (componentLinksData === null && pieceLinksData === null && productLinksData === null) {
return false
}
const normalizedComponentLinks = (componentLinksData ?? []) as AnyRecord[]
const normalizedPieceLinks = (pieceLinksData ?? []) as AnyRecord[]
const normalizedProductLinks = (productLinksData ?? []) as AnyRecord[]
machineComponentLinks.value = normalizedComponentLinks
machinePieceLinks.value = normalizedPieceLinks
machineProductLinks.value = normalizedProductLinks
const { components: hierarchy, machinePieces: machineLevelPieces } =
buildMachineHierarchyFromLinks(
normalizedComponentLinks,
normalizedPieceLinks,
findProductById as any,
constructeurs.value as any,
)
components.value = transformComponentCustomFields(hierarchy as AnyRecord[])
pieces.value = transformCustomFields(machineLevelPieces as AnyRecord[])
return true
}
// ---------------------------------------------------------------------------
// Computed
// ---------------------------------------------------------------------------
const flattenedComponents = computed(() => flattenComponents(components.value))
const machinePieces = computed(() => {
return pieces.value.filter((piece) => {
const parentLinkId = resolveIdentifier(
piece.parentComponentLinkId,
(piece.machinePieceLink as AnyRecord)?.parentComponentLinkId,
piece.parentLinkId,
)
if (parentLinkId) return false
return !piece.composantId
})
})
// ---------------------------------------------------------------------------
// Structure reload
// ---------------------------------------------------------------------------
const reloadMachineStructure = async () => {
const result: any = await get(`/machines/${machineId}/structure`)
if (result.success) {
const machinePayload =
result.data?.machine && typeof result.data.machine === 'object'
? result.data.machine
: result.data
if (machinePayload && typeof machinePayload === 'object') {
machine.value = {
...machine.value,
...machinePayload,
documents: machinePayload.documents || (machine.value as AnyRecord)?.documents || [],
customFieldValues: machinePayload.customFieldValues || (machine.value as AnyRecord)?.customFieldValues || [],
}
const linksApplied = applyMachineLinks(result.data)
if (linksApplied && machine.value) {
machine.value.componentLinks = machineComponentLinks.value
machine.value.pieceLinks = machinePieceLinks.value
machine.value.productLinks = machineProductLinks.value
}
syncMachineCustomFields()
}
}
}
// ---------------------------------------------------------------------------
// Structure link CRUD
// ---------------------------------------------------------------------------
const addComponentLink = async (composantId: string) => {
const result: any = await apiPost('/machine_component_links', {
machine: `/api/machines/${machineId}`,
composant: `/api/composants/${composantId}`,
})
if (result.success) {
toast.showSuccess('Composant ajouté à la machine')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de l\'ajout du composant')
}
return result
}
const removeComponentLink = async (linkId: string) => {
const result: any = await apiDel(`/machine_component_links/${linkId}`)
if (result.success) {
toast.showSuccess('Composant retiré de la machine')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de la suppression du composant')
}
return result
}
const addPieceLink = async (pieceId: string, parentComponentLinkId?: string) => {
const payload: any = {
machine: `/api/machines/${machineId}`,
piece: `/api/pieces/${pieceId}`,
}
if (parentComponentLinkId) {
payload.parentLink = `/api/machine_component_links/${parentComponentLinkId}`
}
const result: any = await apiPost('/machine_piece_links', payload)
if (result.success) {
toast.showSuccess('Pièce ajoutée à la machine')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de l\'ajout de la pièce')
}
return result
}
const removePieceLink = async (linkId: string) => {
const result: any = await apiDel(`/machine_piece_links/${linkId}`)
if (result.success) {
toast.showSuccess('Pièce retirée de la machine')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de la suppression de la pièce')
}
return result
}
const addProductLink = async (productId: string, parentComponentLinkId?: string, parentPieceLinkId?: string) => {
const payload: any = {
machine: `/api/machines/${machineId}`,
product: `/api/products/${productId}`,
}
if (parentComponentLinkId) {
payload.parentComponentLink = `/api/machine_component_links/${parentComponentLinkId}`
}
if (parentPieceLinkId) {
payload.parentPieceLink = `/api/machine_piece_links/${parentPieceLinkId}`
}
const result: any = await apiPost('/machine_product_links', payload)
if (result.success) {
toast.showSuccess('Produit ajouté à la machine')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de l\'ajout du produit')
}
return result
}
const removeProductLink = async (linkId: string) => {
const result: any = await apiDel(`/machine_product_links/${linkId}`)
if (result.success) {
toast.showSuccess('Produit retiré de la machine')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de la suppression du produit')
}
return result
}
return {
// State
components,
pieces,
machineComponentLinks,
machinePieceLinks,
machineProductLinks,
// Computed
flattenedComponents,
machinePieces,
// Helpers
flattenComponents,
findComponentById,
findPieceById,
// Hierarchy
applyMachineLinks,
// Structure link management
reloadMachineStructure,
addComponentLink,
removeComponentLink,
addPieceLink,
removePieceLink,
addProductLink,
removeProductLink,
}
}

View File

@@ -0,0 +1,132 @@
/**
* Machine detail — product display sub-composable.
*
* Handles product resolution, display helpers, supplier info,
* and machine-level direct product links.
*/
import { computed } from 'vue'
import { useProducts } from '~/composables/useProducts'
import {
resolveProductReference as _resolveProductReference,
getProductDisplay as _getProductDisplay,
getProductSuppliersLabel,
getProductPriceLabel,
} from '~/shared/utils/productDisplayUtils'
import {
resolveConstructeurs,
uniqueConstructeurIds,
} from '~/shared/constructeurUtils'
type AnyRecord = Record<string, unknown>
interface MachineDetailProductsDeps {
machineProductLinks: Ref<AnyRecord[]>
productDocumentsMap: Ref<Map<string, AnyRecord[]>>
constructeurs: Ref<unknown[]>
}
export function useMachineDetailProducts(deps: MachineDetailProductsDeps) {
const { machineProductLinks, productDocumentsMap, constructeurs } = deps
const { products, loadProducts } = useProducts()
// ---------------------------------------------------------------------------
// Computed
// ---------------------------------------------------------------------------
const productInventory = computed(() => products.value || [])
const productById = computed(() => {
const map = new Map<string, AnyRecord>()
;(productInventory.value as AnyRecord[]).forEach((product: AnyRecord) => {
if (product?.id) map.set(product.id as string, product)
})
return map
})
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const findProductById = (productId: string | null | undefined): AnyRecord | null => {
if (!productId) return null
return productById.value.get(productId) || null
}
const resolveProductReference = (source: AnyRecord) =>
_resolveProductReference(source, findProductById as any)
const getProductDisplay = (source: AnyRecord) =>
_getProductDisplay(source, findProductById as any)
// ---------------------------------------------------------------------------
// Machine direct products
// ---------------------------------------------------------------------------
const machineDirectProducts = computed(() => {
return machineProductLinks.value.map((link) => {
const productObj = link.product as AnyRecord | string | null
let resolved: AnyRecord | null = null
let productId: string | null = null
if (typeof productObj === 'string') {
productId = productObj.split('/').pop() || null
resolved = productId ? findProductById(productId) : null
} else if (productObj && typeof productObj === 'object') {
productId = (productObj as AnyRecord)?.id as string | null
// Prefer the embedded product from the structure endpoint — it has richer
// data (typeProduct as object, supplierPrice, constructeurs) than the
// global products cache which may store typeProduct as an IRI string.
const cached = productId ? findProductById(productId) : null
resolved = productObj as AnyRecord
if (cached) {
// Merge: use embedded as base, overlay any non-null cached fields
resolved = { ...resolved, ...Object.fromEntries(
Object.entries(cached as AnyRecord).filter(([, v]) => v != null && v !== ''),
) }
// But always prefer the embedded typeProduct when it's an object
if (productObj.typeProduct && typeof productObj.typeProduct === 'object') {
resolved.typeProduct = productObj.typeProduct
}
}
}
const cIds = uniqueConstructeurIds(
resolved?.constructeurs,
resolved?.constructeurIds,
)
const resolvedConstructeurs = resolveConstructeurs(
cIds,
resolved?.constructeurs as any[] || [],
constructeurs.value as any,
)
return {
id: (resolved?.id as string) || productId || null,
linkId: (link.id as string) || (typeof link['@id'] === 'string' ? link['@id'].split('/').pop() : null) || null,
name: (resolved?.name as string) || 'Produit inconnu',
reference: (resolved?.reference as string) || null,
supplierLabel: resolvedConstructeurs.length
? resolvedConstructeurs.map((c) => c.name).filter(Boolean).join(', ') || null
: getProductSuppliersLabel(resolved),
priceLabel: resolved ? getProductPriceLabel(resolved) : null,
groupLabel: ((resolved?.typeProduct as AnyRecord)?.name as string) || '',
documents: productId ? (productDocumentsMap.value.get(productId) || []) : [],
}
})
})
return {
// Computed
productInventory,
productById,
machineDirectProducts,
// Helpers
findProductById,
resolveProductReference,
getProductDisplay,
// Loading
loadProducts,
}
}

View File

@@ -0,0 +1,214 @@
/**
* Machine detail page — update/mutation methods.
*
* Extracted from useMachineDetailData.ts to keep the orchestrator under 500 lines.
*/
import type { Ref } from 'vue'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
type AnyRecord = Record<string, unknown>
export interface UseMachineDetailUpdatesDeps {
machine: Ref<AnyRecord | null>
machineName: Ref<string>
machineReference: Ref<string>
machineConstructeurIds: Ref<string[]>
machineDocumentsLoaded: Ref<boolean>
machineComponentLinks: Ref<AnyRecord[]>
machinePieceLinks: Ref<AnyRecord[]>
machineProductLinks: Ref<AnyRecord[]>
applyMachineLinks: (data: AnyRecord) => boolean
refreshMachineDocuments: () => Promise<void>
transformComponentCustomFields: (items: AnyRecord[]) => AnyRecord[]
transformCustomFields: (items: AnyRecord[]) => AnyRecord[]
loadProductDocuments: () => Promise<void>
upsertCustomFieldValue: (
fieldId: string,
entityType: string,
entityId: string,
value: unknown,
) => Promise<unknown>
updateMachineApi: (id: string, data: any) => Promise<unknown>
updateComposantApi: (id: string, data: any) => Promise<unknown>
updatePieceApi: (id: string, data: any) => Promise<unknown>
toast: { showInfo: (msg: string) => void }
}
export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
const {
machine,
machineName,
machineReference,
machineConstructeurIds,
machineComponentLinks,
machinePieceLinks,
applyMachineLinks,
loadProductDocuments,
transformComponentCustomFields,
transformCustomFields,
upsertCustomFieldValue,
updateMachineApi,
updateComposantApi,
updatePieceApi,
toast,
} = deps
const updateMachineInfo = async () => {
if (!machine.value) return
try {
const cIds = uniqueConstructeurIds(machineConstructeurIds.value)
machineConstructeurIds.value = cIds
const result: any = await updateMachineApi(machine.value.id as string, {
name: machineName.value,
reference: machineReference.value,
constructeurIds: cIds,
} as any)
if (result.success) {
const machinePayload =
result.data?.machine && typeof result.data.machine === 'object'
? result.data.machine
: result.data
if (machinePayload && typeof machinePayload === 'object') {
machine.value = {
...machine.value,
...machinePayload,
documents: machinePayload.documents || machine.value.documents || [],
customFieldValues: machinePayload.customFieldValues || machine.value.customFieldValues || [],
}
machineConstructeurIds.value = uniqueConstructeurIds(
machine.value!.constructeurIds,
machine.value!.constructeurs,
machine.value!.constructeur,
)
const linksApplied = applyMachineLinks(result.data)
if (linksApplied && machine.value) {
machine.value.componentLinks = machineComponentLinks.value
machine.value.pieceLinks = machinePieceLinks.value
}
loadProductDocuments().catch(() => {})
}
}
} catch (error) {
console.error('Erreur lors de la mise à jour de la machine:', error)
}
}
const updateComponent = async (updatedComponent: AnyRecord) => {
try {
const cIds = uniqueConstructeurIds(
updatedComponent.constructeurIds,
updatedComponent.constructeurId,
updatedComponent.constructeur,
)
const productId = updatedComponent.productId
? String(updatedComponent.productId)
: null
const prix =
updatedComponent.prix !== null &&
updatedComponent.prix !== undefined &&
String(updatedComponent.prix).trim() !== ''
? Number(updatedComponent.prix)
: null
const result: any = await updateComposantApi(updatedComponent.id as string, {
name: updatedComponent.name,
reference: updatedComponent.reference,
constructeurIds: cIds,
prix: Number.isNaN(prix) ? null : prix,
productId,
} as any)
if (result.success) {
const transformed = transformComponentCustomFields([result.data])[0]
Object.assign(updatedComponent, transformed)
}
} catch (error) {
console.error('Erreur lors de la mise à jour du composant:', error)
}
}
const _buildAndUpdatePiece = async (updatedPiece: AnyRecord) => {
const cIds = uniqueConstructeurIds(
updatedPiece.constructeurIds,
updatedPiece.constructeurId,
updatedPiece.constructeur,
)
const productId = updatedPiece.productId ? String(updatedPiece.productId) : null
const prix =
updatedPiece.prix !== null &&
updatedPiece.prix !== undefined &&
String(updatedPiece.prix).trim() !== ''
? Number(updatedPiece.prix)
: null
const result: any = await updatePieceApi(updatedPiece.id as string, {
name: updatedPiece.name,
reference: updatedPiece.reference,
constructeurIds: cIds,
prix: Number.isNaN(prix) ? null : prix,
productId,
} as any)
if (result.success) {
const transformed = transformCustomFields([result.data])[0]
Object.assign(updatedPiece, transformed)
}
return result
}
const updatePieceFromComponent = async (updatedPiece: AnyRecord) => {
try {
const result = await _buildAndUpdatePiece(updatedPiece)
if (result?.success && updatedPiece.customFields) {
const fieldsToSave = (updatedPiece.customFields as AnyRecord[]).filter(
(field) => field.value !== undefined,
)
if (fieldsToSave.length) {
await Promise.allSettled(
fieldsToSave.map((field) =>
upsertCustomFieldValue(
field.id as string,
'piece',
updatedPiece.id as string,
field.value,
),
),
)
}
}
} catch (error) {
console.error('Erreur lors de la mise à jour de la pièce:', error)
}
}
const updatePieceInfo = async (updatedPiece: AnyRecord) => {
try {
await _buildAndUpdatePiece(updatedPiece)
} catch (error) {
console.error('Erreur lors de la mise à jour de la pièce:', error)
}
}
const handleMachineConstructeurChange = async (value: unknown) => {
machineConstructeurIds.value = uniqueConstructeurIds(value)
await updateMachineInfo()
}
const editComponent = () => {
toast.showInfo('La modification des composants sera bientôt disponible')
}
const editPiece = () => {
toast.showInfo('La modification des pièces sera bientôt disponible')
}
return {
updateMachineInfo,
updateComponent,
updatePieceFromComponent,
updatePieceInfo,
handleMachineConstructeurChange,
editComponent,
editPiece,
}
}

View File

@@ -0,0 +1,472 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from '#imports'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { usePieces } from '~/composables/usePieces'
import { useCustomFields } from '~/composables/useCustomFields'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { usePieceHistory } from '~/composables/usePieceHistory'
import { extractRelationId } from '~/shared/apiRelations'
import { canPreviewDocument } from '~/utils/documentPreview'
import { formatPieceStructurePreview } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import type { PieceModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes'
import {
getStructureProducts,
buildProductRequirementDescriptions,
buildProductRequirementEntries,
resizeProductSelections,
areProductSelectionsFilled,
applyProductSelection,
collectNormalizedProductIds,
} from '~/shared/utils/pieceProductSelectionUtils'
import { getModelType } from '~/services/modelTypes'
import {
type CustomFieldInput,
buildCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils'
interface PieceCatalogType extends ModelType {
structure: PieceModelStructure | null
customFields?: Array<Record<string, any>>
}
export function usePieceEdit(pieceId: string) {
const { canEdit } = usePermissions()
const router = useRouter()
const { get } = useApi()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { updatePiece } = usePieces()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const toast = useToast()
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
const { ensureConstructeurs } = useConstructeurs()
const {
history,
loading: historyLoading,
error: historyError,
loadHistory,
} = usePieceHistory()
const piece = ref<any | null>(null)
const loading = ref(true)
const saving = ref(false)
const selectedFiles = ref<File[]>([])
const uploadingDocuments = ref(false)
const loadingDocuments = ref(false)
const pieceDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null)
const previewVisible = ref(false)
const historyFieldLabels: Record<string, string> = {
name: 'Nom',
reference: 'Référence',
prix: 'Prix',
typePiece: 'Catégorie',
product: 'Produit lié',
productIds: 'Produits liés',
constructeurIds: 'Fournisseurs',
}
const selectedTypeId = ref<string>('')
const pieceTypeDetails = ref<any | null>(null)
const editionForm = reactive({
name: '' as string,
description: '' as string,
reference: '' as string,
constructeurIds: [] as string[],
prix: '' as string,
})
const productSelections = ref<(string | null)[]>([])
const customFieldInputs = ref<CustomFieldInput[]>([])
const resolvedStructure = computed<PieceModelStructure | null>(() =>
pieceTypeDetails.value?.structure ?? selectedType.value?.structure ?? null,
)
const refreshCustomFieldInputs = (
structureOverride?: PieceModelStructure | null,
valuesOverride?: any[] | null,
) => {
const structure = structureOverride ?? resolvedStructure.value ?? null
const values = valuesOverride ?? piece.value?.customFieldValues ?? null
customFieldInputs.value = buildCustomFieldInputs(structure, values)
}
const openPreview = (doc: any) => {
if (!doc || !canPreviewDocument(doc)) {
return
}
previewDocument.value = doc
previewVisible.value = true
}
const closePreview = () => {
previewVisible.value = false
previewDocument.value = null
}
const removeDocument = async (documentId: string | number | null | undefined) => {
if (!documentId) {
return
}
const result = await deleteDocument(documentId, { updateStore: false })
if (result.success) {
pieceDocuments.value = pieceDocuments.value.filter((doc) => doc.id !== documentId)
}
}
const handleFilesAdded = async (files: File[]) => {
if (!files?.length || !piece.value?.id) {
return
}
uploadingDocuments.value = true
try {
const result = await uploadDocuments(
{
files,
context: { pieceId: piece.value.id },
},
{ updateStore: false },
)
if (result.success) {
selectedFiles.value = []
await refreshDocuments()
}
}
finally {
uploadingDocuments.value = false
}
}
const refreshDocuments = async () => {
if (!piece.value?.id) {
pieceDocuments.value = []
return
}
loadingDocuments.value = true
try {
const result = await loadDocumentsByPiece(piece.value.id, { updateStore: false })
if (result.success) {
pieceDocuments.value = Array.isArray(result.data) ? result.data : result.data ? [result.data] : []
}
}
finally {
loadingDocuments.value = false
}
}
const pieceTypeList = computed<PieceCatalogType[]>(() => (pieceTypes.value || []) as PieceCatalogType[])
const selectedType = computed(() => {
if (!selectedTypeId.value) {
return null
}
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
})
const structureProducts = computed(() =>
getStructureProducts(resolvedStructure.value),
)
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
const productRequirementDescriptions = computed(() =>
buildProductRequirementDescriptions(structureProducts.value),
)
const ensureProductSelections = (count: number) => {
productSelections.value = resizeProductSelections(productSelections.value, count)
}
let pendingProductIds: string[] = []
const productRequirementEntries = computed(() =>
buildProductRequirementEntries(structureProducts.value, 'piece-product-requirement'),
)
const productSelectionsFilled = computed(() =>
areProductSelectionsFilled(
requiresProductSelection.value,
productRequirementEntries.value,
productSelections.value,
),
)
const setProductSelection = (index: number, value: string | null) => {
productSelections.value = applyProductSelection(productSelections.value, index, value)
}
watch(structureProducts, (products) => {
ensureProductSelections(products.length)
if (!pendingProductIds.length || products.length === 0) {
return
}
const next = Array.from(
{ length: products.length },
(_, index) => pendingProductIds[index] ?? null,
)
productSelections.value = next
pendingProductIds = []
})
const requiredCustomFieldsFilled = computed(() =>
_requiredCustomFieldsFilled(customFieldInputs.value),
)
const canSubmit = computed(() =>
Boolean(
canEdit.value
&& piece.value
&& editionForm.name
&& requiredCustomFieldsFilled.value
&& productSelectionsFilled.value
&& !saving.value,
),
)
const fetchPiece = async () => {
if (!pieceId || typeof pieceId !== 'string') {
piece.value = null
pieceDocuments.value = []
return
}
const result = await get(`/pieces/${pieceId}`)
if (result.success) {
piece.value = result.data
pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
// Use customFieldValues from entity response (enriched with customField definitions via serialization groups)
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
refreshCustomFieldInputs(undefined, customValues)
// Use cached type from loadPieceTypes() instead of separate getModelType() call
loadPieceTypeDetailsFromCache(result.data)
// History is non-blocking — template handles its own loading state
loadHistory(result.data.id).catch(() => {})
}
else {
piece.value = null
pieceDocuments.value = []
}
}
const loadPieceTypeDetailsFromCache = (currentPiece: any) => {
const typeId = currentPiece?.typePieceId
|| extractRelationId(currentPiece?.typePiece)
|| ''
if (!typeId) {
pieceTypeDetails.value = null
return
}
// Look up in the already-loaded pieceTypes cache (from loadPieceTypes in onMounted)
const cachedType = (pieceTypes.value || []).find((t: any) => t.id === typeId) ?? null
if (cachedType) {
pieceTypeDetails.value = cachedType
refreshCustomFieldInputs((cachedType.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
return
}
// Fallback: fetch if not in cache (edge case)
getModelType(typeId).then((type) => {
if (type && typeof type === 'object') {
pieceTypeDetails.value = type
refreshCustomFieldInputs((type.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
}
}).catch(() => {
pieceTypeDetails.value = null
})
}
let initialized = false
watch(
[piece, selectedType],
([currentPiece, _currentType]) => {
if (!currentPiece || initialized) {
return
}
const resolvedTypeId = currentPiece.typePieceId
|| extractRelationId(currentPiece.typePiece)
|| ''
if (resolvedTypeId && !currentPiece.typePieceId) {
currentPiece.typePieceId = resolvedTypeId
}
selectedTypeId.value = resolvedTypeId
editionForm.name = currentPiece.name || ''
editionForm.description = currentPiece.description || ''
editionForm.reference = currentPiece.reference || ''
editionForm.constructeurIds = uniqueConstructeurIds(
currentPiece,
Array.isArray(currentPiece.constructeurs) ? currentPiece.constructeurs : [],
currentPiece.constructeur ? [currentPiece.constructeur] : [],
)
editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : ''
if (editionForm.constructeurIds.length) {
void ensureConstructeurs(editionForm.constructeurIds)
}
const existingProductIds = Array.isArray(currentPiece.productIds) && currentPiece.productIds.length
? currentPiece.productIds.map((id: unknown) => String(id))
: currentPiece.product?.id || currentPiece.productId
? [String(currentPiece.product?.id || currentPiece.productId)]
: []
pendingProductIds = existingProductIds
ensureProductSelections(structureProducts.value.length)
if (existingProductIds.length && structureProducts.value.length) {
const next = Array.from(
{ length: structureProducts.value.length },
(_, index) => existingProductIds[index] ?? null,
)
productSelections.value = next
pendingProductIds = []
}
// After setting selectedTypeId, read selectedType.value (now updated) instead of
// the stale destructured currentType which was captured before the ID change.
const resolvedType = selectedType.value ?? pieceTypeDetails.value ?? null
refreshCustomFieldInputs(resolvedType?.structure ?? null, currentPiece.customFieldValues)
initialized = true
},
{ immediate: true },
)
watch(selectedType, (currentType) => {
if (!piece.value || !currentType) {
return
}
refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues)
})
watch(resolvedStructure, (currentStructure) => {
if (!piece.value) {
return
}
ensureProductSelections(structureProducts.value.length)
refreshCustomFieldInputs(currentStructure, piece.value.customFieldValues)
})
const submitEdition = async () => {
if (!piece.value) {
return
}
if (!productSelectionsFilled.value) {
toast.showError('Sélectionnez un produit conforme au squelette.')
return
}
const rawPrice = typeof editionForm.prix === 'string'
? editionForm.prix.trim()
: editionForm.prix === null || editionForm.prix === undefined
? ''
: String(editionForm.prix).trim()
const constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
const payload: Record<string, any> = {
name: editionForm.name.trim(),
description: editionForm.description.trim() || null,
constructeurIds,
}
const reference = editionForm.reference.trim()
payload.reference = reference ? reference : null
const normalizedProductIds = collectNormalizedProductIds(
productRequirementEntries.value,
productSelections.value,
)
payload.productIds = normalizedProductIds
payload.productId = normalizedProductIds[0] || null
if (rawPrice) {
const parsed = Number(rawPrice)
if (!Number.isNaN(parsed)) {
payload.prix = String(parsed)
}
}
else {
payload.prix = null
}
saving.value = true
try {
const result = await updatePiece(piece.value.id, payload)
if (result.success && result.data) {
const updatedPiece = result.data as Record<string, any>
await _saveCustomFieldValues(
'piece',
updatedPiece.id,
[
updatedPiece?.typePiece?.pieceCustomFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
await router.push('/pieces-catalog')
}
}
catch (error: any) {
toast.showError(error?.message || 'Erreur lors de la mise à jour de la pièce')
}
finally {
saving.value = false
}
}
onMounted(async () => {
await Promise.allSettled([loadPieceTypes(), fetchPiece()])
loading.value = false
})
return {
// State
piece,
loading,
saving,
selectedFiles,
uploadingDocuments,
loadingDocuments,
pieceDocuments,
previewDocument,
previewVisible,
selectedTypeId,
editionForm,
productSelections,
customFieldInputs,
canEdit,
// Computed
pieceTypeList,
selectedType,
resolvedStructure,
structureProducts,
productRequirementDescriptions,
productRequirementEntries,
canSubmit,
historyFieldLabels,
// History
history,
historyLoading,
historyError,
// Methods
openPreview,
closePreview,
removeDocument,
handleFilesAdded,
setProductSelection,
submitEdition,
formatPieceStructurePreview,
}
}

View File

@@ -0,0 +1,444 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import type {
PieceModelCustomField,
PieceModelCustomFieldType,
PieceModelProduct,
PieceModelStructure,
PieceModelStructureEditorField,
} from '~/shared/types/inventory'
import { normalizePieceStructureForSave } from '~/shared/modelUtils'
import { useProductTypes } from '~/composables/useProductTypes'
export type EditorField = PieceModelStructureEditorField & { uid: string }
export type EditorProduct = {
uid: string
typeProductId: string
typeProductLabel: string
familyCode: string
}
interface Deps {
props: {
modelValue?: PieceModelStructure | null
restrictedMode?: boolean
}
emit: (event: 'update:modelValue', value: PieceModelStructure) => void
}
// --- Pure helpers ---
const ensureArray = <T,>(value: T[] | null | undefined): T[] =>
Array.isArray(value) ? value : []
const normalizeLineEndings = (value: string): string =>
value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
const safeClone = <T,>(value: T, fallback: T): T => {
try {
return JSON.parse(JSON.stringify(value ?? fallback)) as T
}
catch {
return JSON.parse(JSON.stringify(fallback)) as T
}
}
const extractRest = (structure?: PieceModelStructure | null): Record<string, unknown> => {
if (!structure || typeof structure !== 'object') {
return {}
}
const entries = Object.entries(structure).filter(
([key]) => key !== 'customFields' && key !== 'products',
)
return safeClone(Object.fromEntries(entries), {})
}
let uidCounter = 0
const createUid = (scope: 'field' | 'product'): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
uidCounter += 1
return `piece-${scope}-${Date.now().toString(36)}-${uidCounter}`
}
// --- Hydration ---
const toEditorField = (
input: Partial<PieceModelStructureEditorField> | null | undefined,
index: number,
): EditorField => {
const baseType = typeof input?.type === 'string' && input.type ? input.type : 'text'
const optionsText = normalizeLineEndings(
typeof input?.optionsText === 'string'
? input.optionsText
: Array.isArray(input?.options)
? input.options.join('\n')
: '',
)
return {
uid: createUid('field'),
name: typeof input?.name === 'string' ? input.name : '',
type: baseType as PieceModelCustomFieldType,
required: Boolean(input?.required),
optionsText,
orderIndex: typeof input?.orderIndex === 'number' ? input.orderIndex : index,
}
}
const hydrateFields = (structure?: PieceModelStructure | null): EditorField[] => {
const source = ensureArray(structure?.customFields)
return source
.map((field, index) => toEditorField(field, index))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((field, index) => ({ ...field, orderIndex: index }))
}
const toEditorProduct = (
input: Partial<PieceModelProduct> | null | undefined,
): EditorProduct => ({
uid: createUid('product'),
typeProductId: typeof input?.typeProductId === 'string' ? input.typeProductId : '',
typeProductLabel:
typeof input?.typeProductLabel === 'string' ? input.typeProductLabel : '',
familyCode: typeof input?.familyCode === 'string' ? input.familyCode : '',
})
const hydrateProducts = (structure?: PieceModelStructure | null): EditorProduct[] => {
const source = Array.isArray(structure?.products) ? structure?.products : []
return source.map(product => toEditorProduct(product))
}
// --- Payload ---
const applyOrderIndex = (list: EditorField[]): EditorField[] =>
list.map((field, index) => ({
...field,
orderIndex: index,
}))
const normalizeProductEntry = (product: EditorProduct): PieceModelProduct | null => {
const typeProductId = typeof product.typeProductId === 'string' ? product.typeProductId.trim() : ''
const familyCode = typeof product.familyCode === 'string' ? product.familyCode.trim() : ''
if (!typeProductId && !familyCode) {
return null
}
const payload: PieceModelProduct = {}
if (typeProductId) {
payload.typeProductId = typeProductId
}
if (familyCode) {
payload.familyCode = familyCode
}
if (product.typeProductLabel) {
payload.typeProductLabel = product.typeProductLabel
}
return payload
}
const buildPayload = (
fieldsSource: EditorField[],
productsSource: EditorProduct[],
restSource: Record<string, unknown>,
): PieceModelStructure => {
const normalizedFields = fieldsSource
.map<PieceModelCustomField | null>((field, index) => {
const name = field.name.trim()
if (!name) {
return null
}
const type = (field.type || 'text') as PieceModelCustomFieldType
const required = Boolean(field.required)
const payload: PieceModelCustomField = {
name,
type,
required,
orderIndex: index,
}
if (type === 'select') {
const options = normalizeLineEndings(field.optionsText)
.split('\n')
.map(option => option.trim())
.filter(option => option.length > 0)
if (options.length > 0) {
payload.options = options
}
}
return payload
})
.filter((field): field is PieceModelCustomField => Boolean(field))
const normalizedProducts = productsSource
.map(product => normalizeProductEntry(product))
.filter((product): product is PieceModelProduct => Boolean(product))
const draft: PieceModelStructure = {
...safeClone(restSource, {}),
products: normalizedProducts,
customFields: normalizedFields,
}
return normalizePieceStructureForSave(draft)
}
const serializeStructure = (structure?: PieceModelStructure | null): string => {
return JSON.stringify(normalizePieceStructureForSave(structure ?? { customFields: [] }))
}
// --- Composable ---
export function usePieceStructureEditorLogic(deps: Deps) {
const { props, emit } = deps
const { productTypes, loadProductTypes } = useProductTypes()
// --- State ---
const fields = ref<EditorField[]>(hydrateFields(props.modelValue))
const products = ref<EditorProduct[]>(hydrateProducts(props.modelValue))
const restState = ref<Record<string, unknown>>(extractRest(props.modelValue))
const initialFieldUids = ref<Set<string>>(new Set(fields.value.map(f => f.uid)))
const initialProductUids = ref<Set<string>>(new Set(products.value.map(p => p.uid)))
// --- Product types ---
const productTypeOptions = computed(() => productTypes.value ?? [])
const productTypeMap = computed(() => {
const map = new Map<string, any>()
productTypeOptions.value.forEach((type: any) => {
if (type?.id) {
map.set(type.id, type)
}
})
return map
})
const formatProductTypeOption = (type: any) => {
if (!type) {
return ''
}
const parts: string[] = []
if (type.code) {
parts.push(type.code)
}
if (type.name) {
parts.push(type.name)
}
return parts.length ? parts.join(' • ') : type.id || ''
}
const updateProductTypeMetadata = (product: EditorProduct) => {
const option = product.typeProductId
? productTypeMap.value.get(product.typeProductId)
: null
product.typeProductLabel = option?.name ?? ''
}
const handleProductTypeSelect = (product: EditorProduct) => {
const option = product.typeProductId
? productTypeMap.value.get(product.typeProductId)
: null
product.typeProductLabel = option?.name ?? ''
if (option?.code) {
product.familyCode = option.code
}
}
// --- Locked state ---
const isFieldLocked = (field: EditorField): boolean => {
return props.restrictedMode === true && initialFieldUids.value.has(field.uid)
}
const isProductLocked = (product: EditorProduct): boolean => {
return props.restrictedMode === true && initialProductUids.value.has(product.uid)
}
const restrictedMode = computed(() => props.restrictedMode === true)
// --- CRUD ---
const createEmptyProduct = (): EditorProduct => ({
uid: createUid('product'),
typeProductId: '',
typeProductLabel: '',
familyCode: '',
})
const addProduct = () => {
products.value.push(createEmptyProduct())
}
const removeProduct = (index: number) => {
products.value = products.value.filter((_, idx) => idx !== index)
}
const createEmptyField = (orderIndex: number): EditorField => ({
uid: createUid('field'),
name: '',
type: 'text',
required: false,
optionsText: '',
orderIndex,
})
const addField = () => {
const next = fields.value.slice()
next.push(createEmptyField(next.length))
fields.value = applyOrderIndex(next)
}
const removeField = (index: number) => {
const next = fields.value.filter((_, i) => i !== index)
fields.value = applyOrderIndex(next)
}
// --- Drag & drop ---
const dragState = reactive({
draggingIndex: null as number | null,
dropTargetIndex: null as number | null,
})
const resetDragState = () => {
dragState.draggingIndex = null
dragState.dropTargetIndex = null
}
const reorderFields = (from: number, to: number) => {
if (from === to) {
resetDragState()
return
}
const list = fields.value.slice()
if (from < 0 || to < 0 || from >= list.length || to >= list.length) {
resetDragState()
return
}
const [moved] = list.splice(from, 1)
if (!moved) {
resetDragState()
return
}
list.splice(to, 0, moved)
fields.value = applyOrderIndex(list)
resetDragState()
}
const onDragStart = (index: number, event: DragEvent) => {
dragState.draggingIndex = index
dragState.dropTargetIndex = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onDragEnter = (index: number) => {
if (dragState.draggingIndex === null) {
return
}
dragState.dropTargetIndex = index
}
const onDrop = (index: number) => {
if (dragState.draggingIndex === null) {
resetDragState()
return
}
reorderFields(dragState.draggingIndex, index)
}
const onDragEnd = () => {
resetDragState()
}
const reorderClass = (index: number) => {
if (dragState.draggingIndex === index) {
return 'border-dashed border-primary bg-primary/5'
}
if (
dragState.draggingIndex !== null
&& dragState.dropTargetIndex === index
&& dragState.draggingIndex !== index
) {
return 'border-primary border-dashed bg-primary/10'
}
return ''
}
// --- Emit ---
let lastEmitted = serializeStructure(props.modelValue)
const emitUpdate = () => {
const payload = buildPayload(fields.value, products.value, restState.value)
const serialized = JSON.stringify(payload)
if (serialized !== lastEmitted) {
lastEmitted = serialized
emit('update:modelValue', payload)
}
}
// --- Watchers ---
watch(fields, emitUpdate, { deep: true })
watch(products, emitUpdate, { deep: true })
watch(productTypeOptions, () => {
products.value.forEach(product => updateProductTypeMetadata(product))
})
watch(
() => props.modelValue,
(value) => {
const incomingSerialized = serializeStructure(value)
if (incomingSerialized === lastEmitted) {
return
}
restState.value = extractRest(value)
fields.value = hydrateFields(value)
products.value = hydrateProducts(value)
products.value.forEach(product => updateProductTypeMetadata(product))
lastEmitted = incomingSerialized
initialFieldUids.value = new Set(fields.value.map(f => f.uid))
initialProductUids.value = new Set(products.value.map(p => p.uid))
},
{ deep: true },
)
// --- Lifecycle ---
onMounted(async () => {
if (!productTypeOptions.value.length) {
await loadProductTypes()
}
products.value.forEach(product => updateProductTypeMetadata(product))
})
return {
fields,
products,
productTypeOptions,
restrictedMode,
isFieldLocked,
isProductLocked,
formatProductTypeOption,
handleProductTypeSelect,
addProduct,
removeProduct,
addField,
removeField,
reorderClass,
onDragStart,
onDragEnter,
onDrop,
onDragEnd,
}
}

View File

@@ -0,0 +1,366 @@
import { computed, ref, watch } from 'vue'
import { useApi } from '~/composables/useApi'
import { extractCollection } from '~/shared/utils/apiHelpers'
import {
componentOptionDescription,
componentOptionLabel,
describePieceRequirement as _describePieceRequirement,
describeProductRequirement as _describeProductRequirement,
pieceOptionDescription,
pieceOptionLabel,
productOptionDescription,
productOptionLabel,
} from '~/shared/utils/structureAssignmentLabels'
import type {
ComponentOption,
PieceOption,
ProductOption,
StructureAssignmentNode,
StructurePieceAssignment,
StructureProductAssignment,
} from '~/shared/utils/structureAssignmentLabels'
export type {
ComponentOption,
PieceOption,
ProductOption,
StructureAssignmentNode,
StructurePieceAssignment,
StructureProductAssignment,
} from '~/shared/utils/structureAssignmentLabels'
export interface StructureAssignmentFetchDeps {
assignment: StructureAssignmentNode
pieces: PieceOption[] | null
products: ProductOption[] | null
components: ComponentOption[] | null
isRoot: () => boolean
pieceTypeLabelMap: Record<string, string>
productTypeLabelMap: Record<string, string>
componentTypeLabelMap: Record<string, string>
}
export function useStructureAssignmentFetch(deps: StructureAssignmentFetchDeps) {
const { get } = useApi()
const pieceOptionsByPath = ref<Record<string, PieceOption[]>>({})
const productOptionsByPath = ref<Record<string, ProductOption[]>>({})
const componentOptionsByPath = ref<Record<string, ComponentOption[]>>({})
const pieceLoadingByPath = ref<Record<string, boolean>>({})
const productLoadingByPath = ref<Record<string, boolean>>({})
const componentLoadingByPath = ref<Record<string, boolean>>({})
const setLoading = (target: Record<string, boolean>, key: string, value: boolean) => {
target[key] = value
}
const typeIri = (id: string) => `/api/model_types/${id}`
const primedPiecePaths = new Set<string>()
const primedProductPaths = new Set<string>()
const primedComponentPaths = new Set<string>()
// --- Component options ---
const componentOptions = computed(() => {
if (deps.isRoot()) {
return []
}
const cached = componentOptionsByPath.value[deps.assignment.path]
if (cached) {
return cached
}
const definition = deps.assignment.definition || {}
const requiredTypeId =
definition.typeComposantId || definition.modelId || null
const requiredFamilyCode = definition.familyCode || null
return (deps.components || []).filter((component) => {
if (!component || typeof component !== 'object') {
return false
}
if (requiredTypeId) {
return component.typeComposantId === requiredTypeId
}
if (requiredFamilyCode) {
return (
component.typeComposant?.code === requiredFamilyCode
|| component.typeComposantId === requiredFamilyCode
)
}
return true
})
})
const fetchComponentOptions = async (term = '') => {
if (deps.isRoot()) {
return
}
const key = deps.assignment.path
if (componentLoadingByPath.value[key]) {
return
}
const definition = deps.assignment.definition || {}
const requiredTypeId =
definition.typeComposantId || definition.modelId || definition.typeComposant?.id || null
const params = new URLSearchParams()
params.set('itemsPerPage', '50')
if (term.trim()) {
params.set('name', term.trim())
}
if (requiredTypeId) {
params.set('typeComposant', typeIri(requiredTypeId))
}
setLoading(componentLoadingByPath.value, key, true)
try {
const result = await get(`/composants?${params.toString()}`)
if (result.success) {
componentOptionsByPath.value[key] = extractCollection(result.data)
}
}
finally {
setLoading(componentLoadingByPath.value, key, false)
}
}
// --- Piece options ---
const getPieceOptions = (assignment: StructurePieceAssignment) => {
const cached = pieceOptionsByPath.value[assignment.path]
if (cached) {
return cached
}
const definition = assignment.definition
const requiredTypeId =
definition.typePieceId
|| definition.typePiece?.id
|| definition.familyCode
|| null
return (deps.pieces || []).filter((piece) => {
if (!piece || typeof piece !== 'object') {
return false
}
if (!requiredTypeId) {
return true
}
if (definition.typePieceId || definition.typePiece?.id) {
return (
piece.typePieceId === requiredTypeId
|| piece.typePiece?.id === requiredTypeId
)
}
if (definition.familyCode) {
return (
piece.typePiece?.code === requiredTypeId
|| piece.typePieceId === requiredTypeId
)
}
return false
})
}
const fetchPieceOptions = async (assignment: StructurePieceAssignment, term = '') => {
const key = assignment.path
if (pieceLoadingByPath.value[key]) {
return
}
const definition = assignment.definition || {}
const requiredTypeId =
definition.typePieceId || definition.typePiece?.id || null
const params = new URLSearchParams()
params.set('itemsPerPage', '50')
if (term.trim()) {
params.set('name', term.trim())
}
if (requiredTypeId) {
params.set('typePiece', typeIri(requiredTypeId))
}
setLoading(pieceLoadingByPath.value, key, true)
try {
const result = await get(`/pieces?${params.toString()}`)
if (result.success) {
pieceOptionsByPath.value[key] = extractCollection(result.data)
}
}
finally {
setLoading(pieceLoadingByPath.value, key, false)
}
}
const describePieceRequirement = (assignment: StructurePieceAssignment) => {
const options = getPieceOptions(assignment)
return _describePieceRequirement(assignment, options, deps.pieceTypeLabelMap)
}
// --- Product options ---
const getProductOptions = (assignment: StructureProductAssignment) => {
const cached = productOptionsByPath.value[assignment.path]
if (cached) {
return cached
}
const definition = assignment.definition
const requiredTypeId =
definition.typeProductId
|| definition.typeProduct?.id
|| definition.familyCode
|| null
return (deps.products || []).filter((product) => {
if (!product || typeof product !== 'object') {
return false
}
if (!requiredTypeId) {
return true
}
if (definition.typeProductId || definition.typeProduct?.id) {
return (
product.typeProductId === requiredTypeId
|| product.typeProduct?.id === requiredTypeId
)
}
if (definition.familyCode) {
return (
product.typeProduct?.code === requiredTypeId
|| product.typeProductId === requiredTypeId
)
}
return false
})
}
const fetchProductOptions = async (assignment: StructureProductAssignment, term = '') => {
const key = assignment.path
if (productLoadingByPath.value[key]) {
return
}
const definition = assignment.definition || {}
const requiredTypeId =
definition.typeProductId || definition.typeProduct?.id || null
const params = new URLSearchParams()
params.set('itemsPerPage', '50')
if (term.trim()) {
params.set('name', term.trim())
}
if (requiredTypeId) {
params.set('typeProduct', typeIri(requiredTypeId))
}
setLoading(productLoadingByPath.value, key, true)
try {
const result = await get(`/products?${params.toString()}`)
if (result.success) {
productOptionsByPath.value[key] = extractCollection(result.data)
}
}
finally {
setLoading(productLoadingByPath.value, key, false)
}
}
const describeProductRequirement = (assignment: StructureProductAssignment) => {
const options = getProductOptions(assignment)
return _describeProductRequirement(assignment, options, deps.productTypeLabelMap)
}
// --- Watchers ---
watch(
componentOptions,
(options) => {
if (deps.isRoot()) {
return
}
const hasMatch = options.some(
(component) => component.id === deps.assignment.selectedComponentId,
)
if (!hasMatch) {
deps.assignment.selectedComponentId = ''
}
},
{ immediate: true },
)
watch(
() => [deps.pieces, deps.assignment.pieces],
() => {
for (const pieceAssignment of deps.assignment.pieces) {
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]) {
primedPiecePaths.add(pieceAssignment.path)
fetchPieceOptions(pieceAssignment).catch(() => {})
}
}
},
{ deep: true, immediate: true },
)
watch(
() => [deps.products, deps.assignment.products],
() => {
for (const productAssignment of deps.assignment.products) {
const options = getProductOptions(productAssignment)
if (
productAssignment.selectedProductId
&& !options.some((product) => product.id === productAssignment.selectedProductId)
) {
productAssignment.selectedProductId = ''
}
if (!primedProductPaths.has(productAssignment.path) && !productOptionsByPath.value[productAssignment.path]) {
primedProductPaths.add(productAssignment.path)
fetchProductOptions(productAssignment).catch(() => {})
}
}
},
{ deep: true, immediate: true },
)
watch(
() => deps.assignment.definition,
() => {
if (deps.isRoot()) {
return
}
const key = deps.assignment.path
if (!primedComponentPaths.has(key) && !componentOptionsByPath.value[key]) {
primedComponentPaths.add(key)
fetchComponentOptions().catch(() => {})
}
},
{ immediate: true },
)
return {
pieceLoadingByPath,
productLoadingByPath,
componentLoadingByPath,
componentOptions,
componentOptionLabel,
componentOptionDescription,
fetchComponentOptions,
getPieceOptions,
pieceOptionLabel,
pieceOptionDescription,
fetchPieceOptions,
describePieceRequirement,
getProductOptions,
productOptionLabel,
productOptionDescription,
fetchProductOptions,
describeProductRequirement,
}
}

View File

@@ -0,0 +1,205 @@
import { ref } from 'vue'
import type { EditableStructureNode } from '~/composables/useStructureNodeLogic'
export interface StructureNodeCrudDeps {
node: EditableStructureNode
restrictedMode: boolean
canManageSubcomponents: () => boolean
}
export function useStructureNodeCrud(props: StructureNodeCrudDeps) {
// --- Lock state ---
const initialCustomFieldIndices = ref<Set<number>>(new Set())
const initialPieceIndices = ref<Set<number>>(new Set())
const initialProductIndices = ref<Set<number>>(new Set())
const initialSubcomponentIndices = ref<Set<number>>(new Set())
const initializeLockedIndices = () => {
if (props.restrictedMode) {
const customFieldsLength = Array.isArray(props.node.customFields) ? props.node.customFields.length : 0
const piecesLength = Array.isArray(props.node.pieces) ? props.node.pieces.length : 0
const productsLength = Array.isArray(props.node.products) ? props.node.products.length : 0
const subcomponentsLength = Array.isArray(props.node.subcomponents) ? props.node.subcomponents.length : 0
initialCustomFieldIndices.value = new Set(Array.from({ length: customFieldsLength }, (_, i) => i))
initialPieceIndices.value = new Set(Array.from({ length: piecesLength }, (_, i) => i))
initialProductIndices.value = new Set(Array.from({ length: productsLength }, (_, i) => i))
initialSubcomponentIndices.value = new Set(Array.from({ length: subcomponentsLength }, (_, i) => i))
}
}
initializeLockedIndices()
const isCustomFieldLocked = (index: number): boolean => {
return props.restrictedMode === true && initialCustomFieldIndices.value.has(index)
}
const isPieceLocked = (index: number): boolean => {
return props.restrictedMode === true && initialPieceIndices.value.has(index)
}
const isProductLocked = (index: number): boolean => {
return props.restrictedMode === true && initialProductIndices.value.has(index)
}
const isSubcomponentLocked = (index: number): boolean => {
return props.restrictedMode === true && initialSubcomponentIndices.value.has(index)
}
// --- Helpers ---
const ensureArray = (key: 'customFields' | 'pieces' | 'products' | 'subcomponents') => {
if (!Array.isArray((props.node as any)[key])) {
if (key === 'subcomponents') {
props.node.subcomponents = []
} else if (key === 'products') {
props.node.products = []
} else {
(props.node as any)[key] = []
}
}
}
// --- Custom field reindex ---
const reindexCustomFields = () => {
if (!Array.isArray(props.node.customFields)) {
return
}
props.node.customFields.forEach((field: any, index: number) => {
if (!field || typeof field !== 'object') {
return
}
field.orderIndex = index
})
}
// --- Drag reorder ---
const customFieldDrag = useDragReorder(
() => props.node.customFields,
{ onReorder: reindexCustomFields },
)
const pieceDrag = useDragReorder(() => props.node.pieces)
const productDrag = useDragReorder(() => props.node.products)
const subcomponentDrag = useDragReorder(
() => props.node.subcomponents,
{ draggingClass: 'ring-2 ring-primary', dropTargetClass: 'ring-2 ring-primary/70' },
)
// --- CRUD functions ---
const addCustomField = () => {
ensureArray('customFields')
const fields = props.node.customFields!
const nextIndex = fields.length
fields.push({
name: '',
type: 'text',
required: false,
optionsText: '',
options: [],
orderIndex: nextIndex,
})
reindexCustomFields()
}
const removeCustomField = (index: number) => {
if (!Array.isArray(props.node.customFields)) return
props.node.customFields.splice(index, 1)
reindexCustomFields()
}
const addPiece = () => {
ensureArray('pieces')
props.node.pieces!.push({
typePieceId: '',
typePieceLabel: '',
reference: '',
familyCode: '',
role: '',
})
}
const removePiece = (index: number) => {
if (!Array.isArray(props.node.pieces)) return
props.node.pieces.splice(index, 1)
}
const addProduct = () => {
ensureArray('products')
props.node.products!.push({
typeProductId: '',
typeProductLabel: '',
familyCode: '',
})
}
const removeProduct = (index: number) => {
if (!Array.isArray(props.node.products)) return
props.node.products.splice(index, 1)
}
const addSubComponent = () => {
if (!props.canManageSubcomponents()) {
return
}
ensureArray('subcomponents')
props.node.subcomponents.push({
typeComposantId: '',
typeComposantLabel: '',
modelId: '',
familyCode: '',
alias: '',
subcomponents: [],
})
}
const removeSubComponent = (index: number) => {
if (!Array.isArray(props.node.subcomponents)) return
props.node.subcomponents.splice(index, 1)
}
return {
// Lock checks
isCustomFieldLocked,
isPieceLocked,
isProductLocked,
isSubcomponentLocked,
// Helpers exposed for watchers
reindexCustomFields,
// CRUD
addCustomField,
removeCustomField,
addPiece,
removePiece,
addProduct,
removeProduct,
addSubComponent,
removeSubComponent,
// Drag reorder — custom fields
onCustomFieldDragStart: customFieldDrag.onDragStart,
onCustomFieldDragEnter: customFieldDrag.onDragEnter,
onCustomFieldDrop: customFieldDrag.onDrop,
onCustomFieldDragEnd: customFieldDrag.onDragEnd,
customFieldReorderClass: customFieldDrag.reorderClass,
// Drag reorder — pieces
onPieceDragStart: pieceDrag.onDragStart,
onPieceDragEnter: pieceDrag.onDragEnter,
onPieceDragOver: pieceDrag.onDragOver,
onPieceDrop: pieceDrag.onDrop,
onPieceDragEnd: pieceDrag.onDragEnd,
pieceReorderClass: pieceDrag.reorderClass,
// Drag reorder — products
onProductDragStart: productDrag.onDragStart,
onProductDragEnter: productDrag.onDragEnter,
onProductDragOver: productDrag.onDragOver,
onProductDrop: productDrag.onDrop,
onProductDragEnd: productDrag.onDragEnd,
productReorderClass: productDrag.reorderClass,
// Drag reorder — subcomponents
onSubcomponentDragStart: subcomponentDrag.onDragStart,
onSubcomponentDragEnter: subcomponentDrag.onDragEnter,
onSubcomponentDragOver: subcomponentDrag.onDragOver,
onSubcomponentDrop: subcomponentDrag.onDrop,
onSubcomponentDragEnd: subcomponentDrag.onDragEnd,
subcomponentReorderClass: subcomponentDrag.reorderClass,
}
}

View File

@@ -0,0 +1,462 @@
import { computed, watch } from 'vue'
import type { ComponentModelPiece, ComponentModelProduct, ComponentModelStructureNode } from '~/shared/types/inventory'
import { useStructureNodeCrud } from '~/composables/useStructureNodeCrud'
export type ModelTypeOption = {
id: string
name: string
code?: string | null
}
export type EditableStructureNode = ComponentModelStructureNode & {
customFields?: any[]
pieces?: ComponentModelPiece[]
products?: ComponentModelProduct[]
}
export interface StructureNodeLogicDeps {
node: EditableStructureNode
depth: number
componentTypes: ModelTypeOption[]
pieceTypes: ModelTypeOption[]
productTypes: ModelTypeOption[]
isRoot: boolean
lockType: boolean
lockedTypeLabel: string
allowSubcomponents: boolean
maxSubcomponentDepth: number
restrictedMode: boolean
isLocked: boolean
}
export function useStructureNodeLogic(props: StructureNodeLogicDeps) {
// --- Computed props ---
const isLocked = computed(() => props.isLocked === true)
const restrictedMode = computed(() => props.restrictedMode === true)
const componentTypes = computed(() => props.componentTypes ?? [])
const pieceTypes = computed(() => props.pieceTypes ?? [])
const productTypes = computed(() => props.productTypes ?? [])
const allowSubcomponents = computed(() => props.allowSubcomponents !== false)
const maxSubcomponentDepth = computed(() =>
typeof props.maxSubcomponentDepth === 'number' ? props.maxSubcomponentDepth : Infinity,
)
const currentDepth = computed(() => Math.max(0, props.depth ?? 0))
const canManageSubcomponents = computed(
() => allowSubcomponents.value && currentDepth.value < maxSubcomponentDepth.value,
)
const childAllowSubcomponents = computed(
() => allowSubcomponents.value && currentDepth.value + 1 < maxSubcomponentDepth.value,
)
const hasSubcomponents = computed(
() => Array.isArray(props.node?.subcomponents) && props.node.subcomponents.length > 0,
)
const depthClasses = ['', 'ml-4', 'ml-8', 'ml-12', 'ml-16', 'ml-20']
const containerClass = computed(() => {
const level = currentDepth.value
const index = Math.min(level, depthClasses.length - 1)
return level === 0 ? 'space-y-4' : `${depthClasses[index]} space-y-4`
})
const headingClass = computed(() => (props.isRoot ? 'text-sm font-semibold' : 'text-xs font-semibold'))
// --- Type maps ---
const formatModelTypeOption = (type: ModelTypeOption | undefined | null) =>
type?.name ?? ''
const componentTypeMap = computed(() => {
const map = new Map<string, ModelTypeOption>()
componentTypes.value.forEach((type) => {
if (type && typeof type.id === 'string') {
map.set(type.id, type)
}
})
return map
})
const componentTypeCodeMap = computed(() => {
const map = new Map<string, ModelTypeOption>()
componentTypes.value.forEach((type) => {
const code = typeof type?.code === 'string' ? type.code.trim() : ''
if (code) {
map.set(code, type)
}
})
return map
})
const pieceTypeMap = computed(() => {
const map = new Map<string, ModelTypeOption>()
pieceTypes.value.forEach((type) => {
if (type && typeof type.id === 'string') {
map.set(type.id, type)
}
})
return map
})
const productTypeMap = computed(() => {
const map = new Map<string, ModelTypeOption>()
productTypes.value.forEach((type) => {
if (type && typeof type.id === 'string') {
map.set(type.id, type)
}
})
return map
})
// --- Label getters ---
const getComponentTypeLabel = (id?: string) => {
if (!id) return ''
return formatModelTypeOption(componentTypeMap.value.get(id))
}
const getPieceTypeLabel = (id?: string) => {
if (!id) return ''
return formatModelTypeOption(pieceTypeMap.value.get(id))
}
const formatComponentTypeOption = (type: ModelTypeOption | undefined | null) =>
formatModelTypeOption(type)
const formatPieceTypeOption = (type: ModelTypeOption | undefined | null) =>
formatModelTypeOption(type)
const formatProductTypeOption = (type: ModelTypeOption | undefined | null) =>
formatModelTypeOption(type)
const lockedTypeDisplay = computed(() => {
if (props.lockedTypeLabel) {
return props.lockedTypeLabel
}
return getComponentTypeLabel(props.node?.typeComposantId) || 'Famille non définie'
})
// --- Sync functions ---
const syncComponentType = (component: EditableStructureNode) => {
if (!component) {
return
}
if (props.isRoot) {
component.typeComposantId = ''
component.typeComposantLabel = ''
component.familyCode = ''
if (component.alias) {
component.alias = ''
}
return
}
const id = typeof component.typeComposantId === 'string'
? component.typeComposantId
: ''
if (!id) {
const code =
typeof component.familyCode === 'string' && component.familyCode
? component.familyCode
: ''
if (code) {
const codeMatch = componentTypeCodeMap.value.get(code)
if (codeMatch?.id) {
component.typeComposantId = codeMatch.id
component.typeComposantLabel = formatModelTypeOption(codeMatch)
component.familyCode = codeMatch.code ?? component.familyCode
if (!component.alias || component.alias === '' || component.alias === lockedTypeDisplay.value) {
component.alias = codeMatch.name || component.typeComposantLabel
}
return
}
}
component.typeComposantLabel = ''
component.familyCode = ''
return
}
const option = componentTypeMap.value.get(id)
if (!option) {
component.typeComposantLabel = ''
component.familyCode = ''
return
}
component.typeComposantLabel = formatModelTypeOption(option)
component.familyCode = option.code ?? component.familyCode
if (!component.alias || component.alias === '' || component.alias === lockedTypeDisplay.value) {
component.alias = option.name || component.typeComposantLabel
}
}
const updatePieceTypeLabel = (piece: ComponentModelPiece & Record<string, any>) => {
if (!piece) return
if (piece.typePieceId) {
const option = pieceTypeMap.value.get(piece.typePieceId)
if (option) {
piece.typePieceLabel = formatPieceTypeOption(option)
return
}
}
if (piece.typePieceLabel) {
const normalized = piece.typePieceLabel.trim().toLowerCase()
if (normalized) {
const match = pieceTypes.value.find((type) => {
const formatted = formatPieceTypeOption(type).toLowerCase()
const name = (type?.name ?? '').toLowerCase()
const code = (type?.code ?? '').toLowerCase()
return formatted === normalized || name === normalized || (!!code && code === normalized)
})
if (match) {
piece.typePieceId = match.id
piece.typePieceLabel = formatPieceTypeOption(match)
return
}
}
}
}
const updateProductTypeLabel = (product: ComponentModelProduct & Record<string, any>) => {
if (!product) return
if (product.typeProductId) {
const option = productTypeMap.value.get(product.typeProductId)
if (option) {
product.typeProductLabel = formatProductTypeOption(option)
product.familyCode = option.code ?? product.familyCode ?? ''
return
}
}
if (product.typeProductLabel) {
const normalized = product.typeProductLabel.trim().toLowerCase()
if (normalized) {
const match = productTypes.value.find((type) => {
const formatted = formatProductTypeOption(type).toLowerCase()
const name = (type?.name ?? '').toLowerCase()
const code = (type?.code ?? '').toLowerCase()
return formatted === normalized || name === normalized || (!!code && code === normalized)
})
if (match) {
product.typeProductId = match.id
product.typeProductLabel = formatProductTypeOption(match)
product.familyCode = match.code ?? product.familyCode ?? ''
return
}
}
}
}
const syncPieceLabels = (pieces?: any[]) => {
if (!Array.isArray(pieces)) {
return
}
pieces.forEach((piece) => {
updatePieceTypeLabel(piece)
})
}
const syncProductLabels = (products?: any[]) => {
if (!Array.isArray(products)) {
return
}
products.forEach((product) => {
updateProductTypeLabel(product)
})
}
// --- Handler functions ---
const handleComponentTypeSelect = (component: any) => {
syncComponentType(component)
}
const handlePieceTypeSelect = (piece: ComponentModelPiece & Record<string, any>) => {
if (!piece) {
return
}
const id = typeof piece.typePieceId === 'string' ? piece.typePieceId : ''
if (!id) {
piece.typePieceLabel = ''
return
}
const option = pieceTypeMap.value.get(id)
if (!option) {
piece.typePieceId = ''
piece.typePieceLabel = ''
return
}
piece.typePieceLabel = formatPieceTypeOption(option)
}
const handleProductTypeSelect = (product: ComponentModelProduct & Record<string, any>) => {
if (!product) {
return
}
const id = typeof product.typeProductId === 'string' ? product.typeProductId : ''
if (!id) {
product.typeProductLabel = ''
return
}
const option = productTypeMap.value.get(id)
if (!option) {
product.typeProductId = ''
product.typeProductLabel = ''
return
}
product.typeProductLabel = formatProductTypeOption(option)
product.familyCode = option.code ?? product.familyCode ?? ''
}
// --- CRUD & Lock (delegated to useStructureNodeCrud) ---
const crud = useStructureNodeCrud({
node: props.node,
restrictedMode: props.restrictedMode,
canManageSubcomponents: () => canManageSubcomponents.value,
})
// --- Watchers ---
watch(
canManageSubcomponents,
(allowed) => {
if (!allowed && Array.isArray(props.node.subcomponents) && props.node.subcomponents.length) {
props.node.subcomponents.splice(0, props.node.subcomponents.length)
}
},
{ immediate: true },
)
watch(componentTypes, () => {
syncComponentType(props.node)
}, { deep: true, immediate: true })
watch(
() => props.node.typeComposantId,
() => {
syncComponentType(props.node)
},
)
watch(pieceTypes, () => {
syncPieceLabels(props.node?.pieces)
}, { deep: true, immediate: true })
watch(
() => props.node.pieces,
(value) => {
syncPieceLabels(value)
},
{ deep: true },
)
watch(productTypes, () => {
syncProductLabels(props.node?.products)
}, { deep: true, immediate: true })
watch(
() => props.node.products,
(value) => {
syncProductLabels(value)
},
{ deep: true },
)
watch(
() => props.node.customFields,
(value) => {
if (!Array.isArray(value)) {
return
}
value.sort((a: any, b: any) => {
const left = typeof a?.orderIndex === 'number' ? a.orderIndex : 0
const right = typeof b?.orderIndex === 'number' ? b.orderIndex : 0
return left - right
})
crud.reindexCustomFields()
},
{ deep: true },
)
watch(
() => [props.lockedTypeLabel, props.lockType],
() => {
if (props.lockType && props.isRoot) {
const label = props.lockedTypeLabel || lockedTypeDisplay.value
props.node.typeComposantLabel = label
if (label && (!props.node.alias || props.node.alias === lockedTypeDisplay.value)) {
props.node.alias = label
}
if (props.node.typeComposantId) {
const option = componentTypeMap.value.get(props.node.typeComposantId)
props.node.familyCode = option?.code ?? props.node.familyCode
}
}
},
{ immediate: true },
)
return {
// Lock checks
isCustomFieldLocked: crud.isCustomFieldLocked,
isPieceLocked: crud.isPieceLocked,
isProductLocked: crud.isProductLocked,
isSubcomponentLocked: crud.isSubcomponentLocked,
// Computed state
isLocked,
restrictedMode,
componentTypes,
pieceTypes,
productTypes,
canManageSubcomponents,
childAllowSubcomponents,
hasSubcomponents,
containerClass,
headingClass,
lockedTypeDisplay,
// Label getters & formatters
getComponentTypeLabel,
getPieceTypeLabel,
formatComponentTypeOption,
formatPieceTypeOption,
formatProductTypeOption,
// Handlers
handleComponentTypeSelect,
handlePieceTypeSelect,
handleProductTypeSelect,
// CRUD
addCustomField: crud.addCustomField,
removeCustomField: crud.removeCustomField,
addPiece: crud.addPiece,
removePiece: crud.removePiece,
addProduct: crud.addProduct,
removeProduct: crud.removeProduct,
addSubComponent: crud.addSubComponent,
removeSubComponent: crud.removeSubComponent,
// Drag reorder — custom fields
onCustomFieldDragStart: crud.onCustomFieldDragStart,
onCustomFieldDragEnter: crud.onCustomFieldDragEnter,
onCustomFieldDrop: crud.onCustomFieldDrop,
onCustomFieldDragEnd: crud.onCustomFieldDragEnd,
customFieldReorderClass: crud.customFieldReorderClass,
// Drag reorder — pieces
onPieceDragStart: crud.onPieceDragStart,
onPieceDragEnter: crud.onPieceDragEnter,
onPieceDragOver: crud.onPieceDragOver,
onPieceDrop: crud.onPieceDrop,
onPieceDragEnd: crud.onPieceDragEnd,
pieceReorderClass: crud.pieceReorderClass,
// Drag reorder — products
onProductDragStart: crud.onProductDragStart,
onProductDragEnter: crud.onProductDragEnter,
onProductDragOver: crud.onProductDragOver,
onProductDrop: crud.onProductDrop,
onProductDragEnd: crud.onProductDragEnd,
productReorderClass: crud.productReorderClass,
// Drag reorder — subcomponents
onSubcomponentDragStart: crud.onSubcomponentDragStart,
onSubcomponentDragEnter: crud.onSubcomponentDragEnter,
onSubcomponentDragOver: crud.onSubcomponentDragOver,
onSubcomponentDrop: crud.onSubcomponentDrop,
onSubcomponentDragEnd: crud.onSubcomponentDragEnd,
subcomponentReorderClass: crud.subcomponentReorderClass,
}
}

View File

@@ -69,6 +69,22 @@ const badgeClass = (type: ChangeType) => {
}
const releases: Release[] = [
{
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',

View File

@@ -124,16 +124,15 @@ import { computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { useComposants } from '~/composables/useComposants'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useToast } from '~/composables/useToast'
import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
import { resolvePrimaryDocument, resolvePreviewAlt } from '~/shared/utils/catalogDisplayUtils'
import { formatFrenchDate } from '~/utils/date'
const { canEdit } = usePermissions()
const { showError } = useToast()
const { composants, total, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
const { composants, total, loadComposants, loading: loadingComposants, deleteComposant } = useComposants()
const { componentTypes, loadComponentTypes } = useComponentTypes()
const loadingComposants = computed(() => loadingComposantsRef.value)
const table = useDataTable(
{ fetchData: fetchComposants },
@@ -180,63 +179,24 @@ async function fetchComposants() {
})
}
const resolvePrimaryDocument = (component: Record<string, any>) => {
const documents = Array.isArray(component?.documents) ? component.documents : []
if (!documents.length) return null
const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
const pdf = withPath.find((doc: any) => isPdfDocument(doc))
if (pdf) return pdf
const image = withPath.find((doc: any) => isImageDocument(doc))
if (image) return image
return withPath[0] ?? normalized[0] ?? null
}
const resolvePreviewAlt = (component: Record<string, any>) => {
const parts = [component?.name, component?.reference].filter(Boolean)
return parts.length ? `Aperçu du document de ${parts.join(' ')}` : 'Aperçu du document'
}
const resolveComponentType = (component: Record<string, any>) => {
if (component?.typeComposant?.name) return component.typeComposant.name
if (component?.typeComposantLabel) return component.typeComposantLabel
return '—'
}
const resolveDeleteGuard = (component: Record<string, any>) => {
const blockingReasons: string[] = []
const machineLinks = Array.isArray(component?.machineLinks) ? component.machineLinks.length : component?.machineLinksCount ?? 0
const documents = Array.isArray(component?.documents) ? component.documents.length : component?.documentsCount ?? 0
const customFields = Array.isArray(component?.customFieldValues) ? component.customFieldValues.length : component?.customFieldValuesCount ?? 0
if (machineLinks > 0) blockingReasons.push(`${machineLinks} liaison${machineLinks > 1 ? 's' : ''} machine`)
if (documents > 0) blockingReasons.push(`${documents} document${documents > 1 ? 's' : ''}`)
return { blockingReasons, hasCustomFields: customFields > 0 }
}
const { confirm } = useConfirm()
const handleDeleteComponent = async (component: Record<string, any>) => {
const { blockingReasons, hasCustomFields } = resolveDeleteGuard(component)
if (blockingReasons.length) {
showError(`Impossible de supprimer ce composant car il possède encore: ${blockingReasons.join(', ')}. Supprimez ou détachez ces éléments avant de réessayer.`)
return
}
const componentName = component?.name || 'ce composant'
const confirmLines = [`Voulez-vous vraiment supprimer ${componentName} ?`]
if (hasCustomFields) {
confirmLines.push('Les valeurs de champs personnalisés associées seront également supprimées.')
}
const { confirm } = useConfirm()
const confirmed = await confirm({ message: confirmLines.join('\n\n') })
const message = buildDeleteMessage(componentName, resolveDeleteImpact(component))
const confirmed = await confirm({ title: 'Supprimer le composant', message, dangerous: true })
if (!confirmed) return
await deleteComposant(component.id)
fetchComposants()
}
const formatDate = (dateStr: string) => {
if (!dateStr) return '—'
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date)
}
const formatDate = formatFrenchDate
onMounted(async () => {
await Promise.all([fetchComposants(), loadComponentTypes()])

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -100,6 +100,7 @@ import { useConstructeurs } from '~/composables/useConstructeurs'
import { useToast } from '~/composables/useToast'
import { usePersistedValue } from '~/composables/usePersistedValue'
import { formatPhone } from '~/utils/formatters/phone'
import { formatFrenchDate } from '~/utils/date'
import IconLucidePlus from '~icons/lucide/plus'
const { canEdit } = usePermissions()
@@ -153,16 +154,7 @@ const debouncedSearch = debounce(async () => {
await searchConstructeurs(searchTerm.value)
}, 300)
const formatDate = (dateStr) => {
if (!dateStr) return '—'
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(date)
}
const formatDate = formatFrenchDate
const formatPhoneDisplay = (value) => {
const formatted = formatPhone(value)

View File

@@ -3,7 +3,7 @@
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="documentRows"
:documents="documents"
@close="closePreview"
/>
@@ -11,7 +11,7 @@
<div class="card-body space-y-6">
<DataTable
:columns="columns"
:rows="documentRows"
:rows="documents"
:loading="loading"
:sort="table.sort.value"
:pagination="paginationState"
@@ -148,7 +148,6 @@ const attachmentFilter = table.filters.filter as Ref<string>
const previewDocument = ref<any>(null)
const previewVisible = ref(false)
const documentRows = computed(() => documents.value)
const documentsOnPage = computed(() => documents.value.length)
const paginationState = table.pagination(total, documentsOnPage)

View File

@@ -258,120 +258,27 @@
</div>
<!-- Add Site Modal -->
<div v-if="showAddSiteModal" class="modal modal-open">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">
Ajouter un nouveau site
</h3>
<form class="space-y-4" @submit.prevent="handleCreateSite">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du site</span>
</label>
<input
v-model="newSite.name"
type="text"
placeholder="Ex: Usine de production"
class="input input-bordered"
:disabled="!canEdit"
required
>
</div>
<SiteContactFormFields :form="newSite" :disabled="!canEdit" />
<div class="modal-action">
<button
type="button"
class="btn btn-outline"
@click="showAddSiteModal = false"
>
Annuler
</button>
<button type="submit" class="btn btn-primary" :disabled="!canEdit">
Créer le site
</button>
</div>
</form>
</div>
</div>
<AddSiteModal
:open="showAddSiteModal"
:disabled="!canEdit"
@close="showAddSiteModal = false"
@create="handleCreateSite"
/>
<!-- Add Machine Modal -->
<div v-if="showAddMachineModal" class="modal modal-open">
<div class="modal-box max-w-2xl">
<h3 class="font-bold text-lg mb-4">
Ajouter une nouvelle machine
</h3>
<form @submit.prevent="handleCreateMachine">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="form-control">
<label class="label">
<span class="label-text">Nom de la machine</span>
</label>
<input
v-model="newMachine.name"
type="text"
placeholder="Ex: Presse hydraulique #1"
class="input input-bordered"
:disabled="!canEdit"
required
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Site</span>
</label>
<select
v-model="newMachine.siteId"
class="select select-bordered"
:disabled="!canEdit"
required
>
<option value="">
Sélectionner un site
</option>
<option v-for="site in sites" :key="site.id" :value="site.id">
{{ site.name }}
</option>
</select>
</div>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="newMachine.reference"
type="text"
placeholder="Ex: PRESS-001"
class="input input-bordered"
:disabled="!canEdit"
>
</div>
<div class="modal-action">
<button
type="button"
class="btn btn-outline"
@click="showAddMachineModal = false"
>
Annuler
</button>
<button type="submit" class="btn btn-primary" :disabled="!canEdit">
Créer la machine
</button>
</div>
</form>
</div>
</div>
<AddMachineModal
:open="showAddMachineModal"
:sites="sites"
:disabled="!canEdit"
:preselected-site-id="preselectedSiteId"
@close="showAddMachineModal = false"
@create="handleCreateMachine"
/>
</main>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
import { ref, onMounted, computed } from 'vue'
import { useSites } from '~/composables/useSites'
import { useMachines } from '~/composables/useMachines'
import { useToast } from '~/composables/useToast'
@@ -396,21 +303,7 @@ const showAddMachineModal = ref(false)
const searchTerm = ref('')
const selectedSiteFilter = ref('')
const collapsedSites = ref([])
const newSite = reactive({
name: '',
contactName: '',
contactPhone: '',
contactAddress: '',
contactPostalCode: '',
contactCity: ''
})
const newMachine = reactive({
name: '',
siteId: '',
reference: ''
})
const preselectedSiteId = ref('')
// Computed
const machinesBySiteId = computed(() => {
@@ -491,39 +384,17 @@ const filteredSites = computed(() => {
})
// Methods
const handleCreateSite = async () => {
const result = await createSite({
name: newSite.name,
contactName: newSite.contactName,
contactPhone: newSite.contactPhone,
contactAddress: newSite.contactAddress,
contactPostalCode: newSite.contactPostalCode,
contactCity: newSite.contactCity
})
const handleCreateSite = async (data) => {
const result = await createSite(data)
if (result.success) {
showAddSiteModal.value = false
// Reset form
newSite.name = ''
newSite.contactName = ''
newSite.contactPhone = ''
newSite.contactAddress = ''
newSite.contactPostalCode = ''
newSite.contactCity = ''
}
}
const handleCreateMachine = async () => {
const result = await createMachine({
name: newMachine.name,
siteId: newMachine.siteId,
reference: newMachine.reference
})
const handleCreateMachine = async (data) => {
const result = await createMachine(data)
if (result.success) {
newMachine.name = ''
newMachine.siteId = ''
newMachine.reference = ''
showAddMachineModal.value = false
await loadMachines()
}
@@ -573,7 +444,7 @@ const confirmDeleteMachine = async (machine) => {
}
const addMachineToSite = (site) => {
newMachine.siteId = site.id
preselectedSiteId.value = site.id
showAddMachineModal.value = true
}

View File

@@ -57,12 +57,15 @@
:has-machine-constructeur="d.hasMachineConstructeur.value"
:visible-custom-fields="d.visibleMachineCustomFields.value"
:get-machine-field-id="d.getMachineFieldId"
:machine-id="machineId"
:machine-custom-field-defs="d.machine.value?.customFields ?? []"
@update:machine-name="d.machineName.value = $event"
@update:machine-reference="d.machineReference.value = $event"
@update:constructeur-ids="d.handleMachineConstructeurChange"
@blur-field="d.updateMachineInfo"
@set-custom-field-value="d.setMachineCustomFieldValue"
@update-custom-field="d.updateMachineCustomField"
@custom-fields-saved="d.loadMachineData()"
/>
<!-- Documents -->

View File

@@ -147,16 +147,15 @@ import { computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { usePieces } from '~/composables/usePieces'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useToast } from '~/composables/useToast'
import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
import { formatFrenchDate } from '~/utils/date'
const { canEdit } = usePermissions()
const { showError } = useToast()
const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = usePieces()
const { pieces, total, loadPieces, loading: loadingPieces, deletePiece } = usePieces()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const loadingPieces = computed(() => loadingPiecesRef.value)
const table = useDataTable(
{ fetchData: fetchPieces },
@@ -205,115 +204,27 @@ async function fetchPieces() {
})
}
const resolvePrimaryDocument = (piece: Record<string, any>) => {
const documents = Array.isArray(piece?.documents) ? piece.documents : []
if (!documents.length) return null
const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
const pdf = withPath.find((doc: any) => isPdfDocument(doc))
if (pdf) return pdf
const image = withPath.find((doc: any) => isImageDocument(doc))
if (image) return image
return withPath[0] ?? normalized[0] ?? null
}
const resolvePreviewAlt = (piece: Record<string, any>) => {
const parts = [piece?.name, piece?.reference].filter(Boolean)
return parts.length ? `Aperçu du document de ${parts.join(' ')}` : 'Aperçu du document'
}
const resolvePieceType = (piece: Record<string, any>) => {
if (piece?.typePiece?.name) return piece.typePiece.name
if (piece?.typePieceLabel) return piece.typePieceLabel
return '—'
}
const MAX_VISIBLE_SUPPLIERS = 3
const buildPieceSuppliersDisplay = (piece: Record<string, any>) =>
buildSuppliersDisplay(resolveSupplierNames(piece, 'product'))
const resolvePieceSuppliers = (piece: Record<string, any>) => {
const names: string[] = []
const seen = new Set<string>()
const pushName = (maybeName: unknown) => {
if (typeof maybeName !== 'string') return
const normalized = maybeName.trim().replace(/\s+/g, ' ')
if (!normalized.length) return
const key = normalized.toLowerCase()
if (seen.has(key)) return
seen.add(key)
names.push(normalized)
}
const collectConstructeurs = (value: unknown): void => {
if (!value) return
if (Array.isArray(value)) { value.forEach(collectConstructeurs); return }
if (typeof value === 'string') { pushName(value); return }
if (typeof value === 'object') {
const record = value as Record<string, any>
pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null)
if (record?.constructeur) collectConstructeurs(record.constructeur)
if (Array.isArray(record?.constructeurs)) collectConstructeurs(record.constructeurs)
}
}
const collectFromLabel = (value: unknown): void => {
if (typeof value !== 'string') return
value.split(/[,;\\/•·|]+/).map(part => part.trim()).filter(Boolean).forEach(pushName)
}
collectConstructeurs(piece?.constructeurs)
collectConstructeurs(piece?.constructeur)
collectConstructeurs(piece?.product?.constructeurs)
collectConstructeurs(piece?.product?.constructeur)
collectFromLabel(piece?.constructeursLabel)
collectFromLabel(piece?.supplierLabel)
collectFromLabel(piece?.product?.constructeursLabel)
collectFromLabel(piece?.product?.supplierLabel)
return names
}
const buildPieceSuppliersDisplay = (piece: Record<string, any>) => {
const suppliers = resolvePieceSuppliers(piece)
const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS)
const overflow = Math.max(suppliers.length - visible.length, 0)
return { suppliers, visible, overflow, tooltip: suppliers.length ? suppliers.join(', ') : '' }
}
const resolveDeleteGuard = (piece: Record<string, any>) => {
const blockingReasons: string[] = []
const machineLinks = Array.isArray(piece?.machineLinks) ? piece.machineLinks.length : piece?.machineLinksCount ?? 0
const documents = Array.isArray(piece?.documents) ? piece.documents.length : piece?.documentsCount ?? 0
const customFields = Array.isArray(piece?.customFieldValues) ? piece.customFieldValues.length : piece?.customFieldValuesCount ?? 0
if (machineLinks > 0) blockingReasons.push(`${machineLinks} liaison${machineLinks > 1 ? 's' : ''} machine`)
if (documents > 0) blockingReasons.push(`${documents} document${documents > 1 ? 's' : ''}`)
return { blockingReasons, hasCustomFields: customFields > 0 }
}
const { confirm } = useConfirm()
const handleDeletePiece = async (piece: Record<string, any>) => {
const { blockingReasons, hasCustomFields } = resolveDeleteGuard(piece)
if (blockingReasons.length) {
showError(`Impossible de supprimer cette pièce car elle possède encore: ${blockingReasons.join(', ')}. Supprimez ou détachez ces éléments avant de réessayer.`)
return
}
const pieceName = piece?.name || 'cette pièce'
const confirmLines = [`Voulez-vous vraiment supprimer ${pieceName} ?`]
if (hasCustomFields) {
confirmLines.push('Les valeurs de champs personnalisés associées seront également supprimées.')
}
const { confirm } = useConfirm()
const confirmed = await confirm({ message: confirmLines.join('\n\n') })
const message = buildDeleteMessage(pieceName, resolveDeleteImpact(piece))
const confirmed = await confirm({ title: 'Supprimer la pièce', message, dangerous: true })
if (!confirmed) return
await deletePiece(piece.id)
fetchPieces()
}
const formatDate = (dateStr: string) => {
if (!dateStr) return '—'
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date)
}
const formatDate = formatFrenchDate
onMounted(async () => {
await Promise.all([fetchPieces(), loadPieceTypes()])

View File

@@ -182,38 +182,13 @@
</div>
</div>
<div v-if="selectedType || resolvedStructure" 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">Squelette sélectionné</h2>
<p class="text-xs text-base-content/70">
{{ selectedType?.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.' }}
</p>
</div>
<span class="badge badge-outline">{{ formatPieceStructurePreview(resolvedStructure) }}</span>
</div>
<details v-if="resolvedStructure" class="collapse collapse-arrow bg-base-100">
<summary class="collapse-title text-sm font-medium">
Consulter le détail du squelette
</summary>
<div class="collapse-content space-y-2 text-sm text-base-content/80">
<div v-if="getStructureCustomFields(resolvedStructure).length" class="space-y-1">
<h3 class="font-semibold text-sm text-base-content">Champs personnalisés</h3>
<ul class="list-disc list-inside space-y-1">
<li v-for="field in getStructureCustomFields(resolvedStructure)" :key="field.name">
<span class="font-medium">{{ field.name }}</span>
<span v-if="field.value !== undefined && field.value !== null"> : {{ field.value }}</span>
</li>
</ul>
</div>
<p v-else class="text-xs text-base-content/70">
Ce squelette ne définit pas encore de champs personnalisés.
</p>
</div>
</details>
</div>
<StructureSkeletonPreview
v-if="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"
/>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
@@ -222,78 +197,7 @@
Mettez à jour les valeurs propres à cette pièce.
</p>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="(field, index) in customFieldInputs"
:key="fieldKey(field, index)"
class="form-control"
>
<label class="label">
<span class="label-text">{{ field.name }}</span>
<span v-if="field.required" class="label-text-alt text-error">*</span>
</label>
<input
v-if="field.type === 'text'"
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || saving"
>
<input
v-else-if="field.type === 'number'"
v-model="field.value"
type="number"
step="0.01"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || saving"
>
<select
v-else-if="field.type === 'select'"
v-model="field.value"
class="select select-bordered select-sm md:select-md"
:required="field.required"
:disabled="!canEdit || saving"
>
<option value="">Sélectionner...</option>
<option
v-for="option in field.options"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
<input
v-model="field.value"
type="checkbox"
class="toggle toggle-primary toggle-sm md:toggle-md"
true-value="true"
false-value="false"
:disabled="!canEdit || saving"
>
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</label>
<input
v-else-if="field.type === 'date'"
v-model="field.value"
type="date"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || saving"
>
<input
v-else
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || saving"
>
</div>
</div>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
@@ -322,147 +226,23 @@
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<div v-else-if="pieceDocuments.length" class="space-y-2">
<div
v-for="document in pieceDocuments"
:key="document.id || document.path || document.name"
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex items-center gap-3 text-sm">
<div
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center"
:class="documentThumbnailClass(document)"
>
<img
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-6 w-6"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<div>
<div class="font-medium">
{{ document.name }}
</div>
<div class="text-xs text-base-content/70">
{{ document.mimeType || 'Inconnu' }} • {{ formatSize(document.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
@click="openPreview(document)"
>
Consulter
</button>
<button
type="button"
class="btn btn-ghost btn-xs"
@click="downloadDocument(document)"
>
Télécharger
</button>
<button
v-if="canEdit"
type="button"
class="btn btn-error btn-xs"
:disabled="uploadingDocuments"
@click="removeDocument(document.id)"
>
Supprimer
</button>
</div>
</div>
</div>
<p v-else class="text-xs text-base-content/70">
Aucun document n'est associé à cette pièce pour le moment.
</p>
<DocumentListInline
v-else
:documents="pieceDocuments"
:can-delete="canEdit"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document n'est associé à cette pièce pour le moment."
@preview="openPreview"
@delete="removeDocument"
/>
</div>
<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">Historique</h2>
<p class="text-xs text-base-content/70">
Qui a changé quoi, et quand.
</p>
</div>
<span v-if="historyEntries.length" class="badge badge-outline">
{{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }}
</span>
</header>
<div v-if="historyLoading" class="flex items-center gap-2 text-sm text-base-content/70">
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
Chargement de lhistorique…
</div>
<div v-else-if="historyError" class="alert alert-warning">
<span>{{ historyError }}</span>
</div>
<p v-else-if="historyEntries.length === 0" class="text-xs text-base-content/70">
Aucun changement enregistré pour le moment.
</p>
<ul v-else class="max-h-80 space-y-2 overflow-y-auto pr-1">
<li
v-for="entry in historyEntries"
:key="entry.id"
class="rounded-md border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-base-content/70">
<span class="font-medium text-base-content">
{{ historyActionLabel(entry.action) }}
</span>
<span>{{ formatHistoryDate(entry.createdAt) }}</span>
</div>
<p class="mt-1 text-xs text-base-content/60">
Par {{ entry.actor?.label || 'Inconnu' }}
</p>
<ul
v-if="historyDiffEntries(entry).length"
class="mt-2 space-y-1 text-xs"
>
<li
v-for="diffEntry in historyDiffEntries(entry)"
:key="`${entry.id}-${diffEntry.field}`"
class="flex flex-col gap-0.5"
>
<span class="font-medium text-base-content/80">{{ diffEntry.label }}</span>
<span class="text-base-content/60">
{{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
</span>
</li>
</ul>
<p
v-else-if="entry.snapshot?.name"
class="mt-2 text-xs text-base-content/70"
>
{{ entry.snapshot.name }}
</p>
</li>
</ul>
</section>
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
@@ -489,476 +269,43 @@
</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 ProductSelect from '~/components/ProductSelect.vue'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { usePieces } from '~/composables/usePieces'
import { useCustomFields } from '~/composables/useCustomFields'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { usePieceHistory, type PieceHistoryEntry } from '~/composables/usePieceHistory'
import { extractRelationId } from '~/shared/apiRelations'
import { canPreviewDocument } from '~/utils/documentPreview'
import { formatPieceStructurePreview } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import type { PieceModelProduct, PieceModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes'
import { getModelType } from '~/services/modelTypes'
import {
type CustomFieldInput,
fieldKey,
buildCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils'
import {
documentIcon,
formatSize,
shouldInlinePdf,
documentPreviewSrc,
documentThumbnailClass,
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
import {
historyActionLabel,
formatHistoryDate,
historyDiffEntries as _historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils'
import { useRoute } from '#imports'
import { usePieceEdit } from '~/composables/usePieceEdit'
interface PieceCatalogType extends ModelType {
structure: PieceModelStructure | null
customFields?: Array<Record<string, any>>
}
const { canEdit } = usePermissions()
const route = useRoute()
const router = useRouter()
const { get } = useApi()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { updatePiece } = usePieces()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const toast = useToast()
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
const { ensureConstructeurs } = useConstructeurs()
const {
piece,
loading,
saving,
selectedFiles,
uploadingDocuments,
loadingDocuments,
pieceDocuments,
previewDocument,
previewVisible,
selectedTypeId,
editionForm,
productSelections,
customFieldInputs,
canEdit,
pieceTypeList,
selectedType,
resolvedStructure,
structureProducts,
productRequirementDescriptions,
productRequirementEntries,
canSubmit,
historyFieldLabels,
history,
loading: historyLoading,
error: historyError,
loadHistory,
} = usePieceHistory()
const piece = ref<any | null>(null)
const loading = ref(true)
const saving = ref(false)
const selectedFiles = ref<File[]>([])
const uploadingDocuments = ref(false)
const loadingDocuments = ref(false)
const pieceDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null)
const previewVisible = ref(false)
const historyEntries = computed<PieceHistoryEntry[]>(() => history.value)
const historyFieldLabels: Record<string, string> = {
name: 'Nom',
reference: 'Référence',
prix: 'Prix',
typePiece: 'Catégorie',
product: 'Produit lié',
productIds: 'Produits liés',
constructeurIds: 'Fournisseurs',
}
const historyDiffEntries = (entry: PieceHistoryEntry) =>
_historyDiffEntries(entry, historyFieldLabels)
const selectedTypeId = ref<string>('')
const pieceTypeDetails = ref<any | null>(null)
const editionForm = reactive({
name: '' as string,
description: '' as string,
reference: '' as string,
constructeurIds: [] as string[],
prix: '' as string,
})
const productSelections = ref<(string | null)[]>([])
const customFieldInputs = ref<CustomFieldInput[]>([])
const resolvedStructure = computed<PieceModelStructure | null>(() =>
pieceTypeDetails.value?.structure ?? selectedType.value?.structure ?? null,
)
const refreshCustomFieldInputs = (
structureOverride?: PieceModelStructure | null,
valuesOverride?: any[] | null,
) => {
const structure = structureOverride ?? resolvedStructure.value ?? null
const values = valuesOverride ?? piece.value?.customFieldValues ?? null
customFieldInputs.value = buildCustomFieldInputs(structure, values)
}
const openPreview = (doc: any) => {
if (!doc || !canPreviewDocument(doc)) {
return
}
previewDocument.value = doc
previewVisible.value = true
}
const closePreview = () => {
previewVisible.value = false
previewDocument.value = null
}
const removeDocument = async (documentId: string | number | null | undefined) => {
if (!documentId) {
return
}
const result = await deleteDocument(documentId, { updateStore: false })
if (result.success) {
pieceDocuments.value = pieceDocuments.value.filter((doc) => doc.id !== documentId)
}
}
const handleFilesAdded = async (files: File[]) => {
if (!files?.length || !piece.value?.id) {
return
}
uploadingDocuments.value = true
try {
const result = await uploadDocuments(
{
files,
context: { pieceId: piece.value.id },
},
{ updateStore: false },
)
if (result.success) {
selectedFiles.value = []
await refreshDocuments()
}
} finally {
uploadingDocuments.value = false
}
}
const refreshDocuments = async () => {
if (!piece.value?.id) {
pieceDocuments.value = []
return
}
loadingDocuments.value = true
try {
const result = await loadDocumentsByPiece(piece.value.id, { updateStore: false })
if (result.success) {
pieceDocuments.value = Array.isArray(result.data) ? result.data : result.data ? [result.data] : []
}
} finally {
loadingDocuments.value = false
}
}
const pieceTypeList = computed<PieceCatalogType[]>(() => (pieceTypes.value || []) as PieceCatalogType[])
const selectedType = computed(() => {
if (!selectedTypeId.value) {
return null
}
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
})
const getStructureProducts = (structure: PieceModelStructure | null) =>
Array.isArray(structure?.products) ? structure.products : []
const getStructureCustomFields = (structure: PieceModelStructure | null) =>
Array.isArray(structure?.customFields) ? structure.customFields : []
const structureProducts = computed(() =>
getStructureProducts(resolvedStructure.value),
)
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
const describeProductRequirement = (requirement: PieceModelProduct, index: number) => {
if (!requirement) {
return `Produit ${index + 1}`
}
const parts: string[] = []
if (requirement.role) {
parts.push(requirement.role)
}
if (requirement.typeProductLabel) {
parts.push(requirement.typeProductLabel)
} else if (requirement.typeProductId) {
parts.push(`Catégorie #${requirement.typeProductId}`)
}
if (requirement.familyCode) {
parts.push(`Famille ${requirement.familyCode}`)
}
if (parts.length === 0) {
parts.push(`Produit ${index + 1}`)
}
return parts.join(' • ')
}
const productRequirementDescriptions = computed(() =>
structureProducts.value.map((requirement, index) =>
describeProductRequirement(requirement, index),
),
)
const ensureProductSelections = (count: number) => {
const next = Array.from({ length: count }, (_, index) => productSelections.value[index] ?? null)
productSelections.value = next
}
let pendingProductIds: string[] = []
const productRequirementEntries = computed(() =>
structureProducts.value.map((requirement, index) => ({
index,
key: `piece-product-requirement-${index}-${requirement?.typeProductId || 'any'}`,
label: describeProductRequirement(requirement, index),
typeProductId: requirement?.typeProductId ? String(requirement.typeProductId) : null,
})),
)
const productSelectionsFilled = computed(() =>
!requiresProductSelection.value ||
productRequirementEntries.value.every((entry) => {
const value = productSelections.value[entry.index]
return typeof value === 'string' && value.trim().length > 0
}),
)
const setProductSelection = (index: number, value: string | null) => {
const normalized = typeof value === 'string' ? value : null
const next = [...productSelections.value]
next[index] = normalized
productSelections.value = next
}
watch(structureProducts, (products) => {
ensureProductSelections(products.length)
if (!pendingProductIds.length || products.length === 0) {
return
}
const next = Array.from(
{ length: products.length },
(_, index) => pendingProductIds[index] ?? null,
)
productSelections.value = next
pendingProductIds = []
})
const requiredCustomFieldsFilled = computed(() =>
_requiredCustomFieldsFilled(customFieldInputs.value),
)
const canSubmit = computed(() =>
Boolean(
canEdit.value &&
piece.value &&
editionForm.name &&
requiredCustomFieldsFilled.value &&
productSelectionsFilled.value &&
!saving.value,
),
)
const fetchPiece = async () => {
const id = route.params.id
if (!id || typeof id !== 'string') {
piece.value = null
pieceDocuments.value = []
return
}
const result = await get(`/pieces/${id}`)
if (result.success) {
piece.value = result.data
pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
// Use customFieldValues from entity response (enriched with customField definitions via serialization groups)
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
refreshCustomFieldInputs(undefined, customValues)
// Use cached type from loadPieceTypes() instead of separate getModelType() call
loadPieceTypeDetailsFromCache(result.data)
// History is non-blocking — template handles its own loading state
loadHistory(result.data.id).catch(() => {})
} else {
piece.value = null
pieceDocuments.value = []
}
}
const loadPieceTypeDetailsFromCache = (currentPiece: any) => {
const typeId = currentPiece?.typePieceId
|| extractRelationId(currentPiece?.typePiece)
|| ''
if (!typeId) {
pieceTypeDetails.value = null
return
}
// Look up in the already-loaded pieceTypes cache (from loadPieceTypes in onMounted)
const cachedType = (pieceTypes.value || []).find((t: any) => t.id === typeId) ?? null
if (cachedType) {
pieceTypeDetails.value = cachedType
refreshCustomFieldInputs((cachedType.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
return
}
// Fallback: fetch if not in cache (edge case)
getModelType(typeId).then((type) => {
if (type && typeof type === 'object') {
pieceTypeDetails.value = type
refreshCustomFieldInputs((type.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
}
}).catch(() => {
pieceTypeDetails.value = null
})
}
let initialized = false
watch(
[piece, selectedType],
([currentPiece, currentType]) => {
if (!currentPiece || initialized) {
return
}
const resolvedTypeId = currentPiece.typePieceId
|| extractRelationId(currentPiece.typePiece)
|| ''
if (resolvedTypeId && !currentPiece.typePieceId) {
currentPiece.typePieceId = resolvedTypeId
}
selectedTypeId.value = resolvedTypeId
editionForm.name = currentPiece.name || ''
editionForm.description = currentPiece.description || ''
editionForm.reference = currentPiece.reference || ''
editionForm.constructeurIds = uniqueConstructeurIds(
currentPiece,
Array.isArray(currentPiece.constructeurs) ? currentPiece.constructeurs : [],
currentPiece.constructeur ? [currentPiece.constructeur] : [],
)
editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : ''
if (editionForm.constructeurIds.length) {
void ensureConstructeurs(editionForm.constructeurIds)
}
const existingProductIds = Array.isArray(currentPiece.productIds) && currentPiece.productIds.length
? currentPiece.productIds.map((id: unknown) => String(id))
: currentPiece.product?.id || currentPiece.productId
? [String(currentPiece.product?.id || currentPiece.productId)]
: []
pendingProductIds = existingProductIds
ensureProductSelections(structureProducts.value.length)
if (existingProductIds.length && structureProducts.value.length) {
const next = Array.from(
{ length: structureProducts.value.length },
(_, index) => existingProductIds[index] ?? null,
)
productSelections.value = next
pendingProductIds = []
}
// After setting selectedTypeId, read selectedType.value (now updated) instead of
// the stale destructured currentType which was captured before the ID change.
const resolvedType = selectedType.value ?? pieceTypeDetails.value ?? null
refreshCustomFieldInputs(resolvedType?.structure ?? null, currentPiece.customFieldValues)
initialized = true
},
{ immediate: true },
)
watch(selectedType, (currentType) => {
if (!piece.value || !currentType) {
return
}
refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues)
})
watch(resolvedStructure, (currentStructure) => {
if (!piece.value) {
return
}
ensureProductSelections(structureProducts.value.length)
refreshCustomFieldInputs(currentStructure, piece.value.customFieldValues)
})
const submitEdition = async () => {
if (!piece.value) {
return
}
if (!productSelectionsFilled.value) {
toast.showError('Sélectionnez un produit conforme au squelette.')
return
}
const rawPrice = typeof editionForm.prix === 'string'
? editionForm.prix.trim()
: editionForm.prix === null || editionForm.prix === undefined
? ''
: String(editionForm.prix).trim()
const constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
const payload: Record<string, any> = {
name: editionForm.name.trim(),
description: editionForm.description.trim() || null,
constructeurIds,
}
const reference = editionForm.reference.trim()
payload.reference = reference ? reference : null
const normalizedProductIds = productRequirementEntries.value
.map((entry) => productSelections.value[entry.index])
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())
payload.productIds = normalizedProductIds
payload.productId = normalizedProductIds[0] || null
if (rawPrice) {
const parsed = Number(rawPrice)
if (!Number.isNaN(parsed)) {
payload.prix = String(parsed)
}
} else {
payload.prix = null
}
saving.value = true
try {
const result = await updatePiece(piece.value.id, payload)
if (result.success && result.data) {
const updatedPiece = result.data as Record<string, any>
await _saveCustomFieldValues(
'piece',
updatedPiece.id,
[
updatedPiece?.typePiece?.pieceCustomFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
await router.push('/pieces-catalog')
}
} catch (error: any) {
toast.showError(error?.message || 'Erreur lors de la mise à jour de la pièce')
} finally {
saving.value = false
}
}
onMounted(async () => {
await Promise.allSettled([loadPieceTypes(), fetchPiece()])
loading.value = false
})
historyLoading,
historyError,
openPreview,
closePreview,
removeDocument,
handleFilesAdded,
setProductSelection,
submitEdition,
formatPieceStructurePreview,
} = usePieceEdit(String(route.params.id))
</script>

View File

@@ -153,38 +153,13 @@
</div>
</div>
<div v-if="selectedType" 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">Squelette sélectionné</h2>
<p class="text-xs text-base-content/70">
{{ selectedType.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.' }}
</p>
</div>
<span class="badge badge-outline">{{ formatPieceStructurePreview(selectedType.structure) }}</span>
</div>
<details v-if="selectedType.structure" class="collapse collapse-arrow bg-base-100">
<summary class="collapse-title text-sm font-medium">
Consulter le détail du squelette
</summary>
<div class="collapse-content space-y-2 text-sm text-base-content/80">
<div v-if="getStructureCustomFields(selectedType.structure).length" class="space-y-1">
<h3 class="font-semibold text-sm text-base-content">Champs personnalisés</h3>
<ul class="list-disc list-inside space-y-1">
<li v-for="field in getStructureCustomFields(selectedType.structure)" :key="field.name">
<span class="font-medium">{{ field.name }}</span>
<span v-if="field.value !== undefined && field.value !== null"> : {{ field.value }}</span>
</li>
</ul>
</div>
<p v-else class="text-xs text-base-content/70">
Ce squelette ne définit pas encore de champs personnalisés.
</p>
</div>
</details>
</div>
<StructureSkeletonPreview
v-if="selectedType"
:structure="selectedType.structure"
:description="selectedType.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.'"
:preview-badge="formatPieceStructurePreview(selectedType.structure)"
variant="piece"
/>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
@@ -193,78 +168,7 @@
Renseignez les valeurs propres à cette pièce. Ces champs complètent le squelette sélectionné.
</p>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="(field, index) in customFieldInputs"
:key="fieldKey(field, index)"
class="form-control"
>
<label class="label">
<span class="label-text">{{ field.name }}</span>
<span v-if="field.required" class="label-text-alt text-error">*</span>
</label>
<input
v-if="field.type === 'text'"
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || submitting"
>
<input
v-else-if="field.type === 'number'"
v-model="field.value"
type="number"
step="0.01"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || submitting"
>
<select
v-else-if="field.type === 'select'"
v-model="field.value"
class="select select-bordered select-sm md:select-md"
:required="field.required"
:disabled="!canEdit || submitting"
>
<option value="">Sélectionner...</option>
<option
v-for="option in field.options"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
<input
v-model="field.value"
type="checkbox"
class="toggle toggle-primary toggle-sm md:toggle-md"
true-value="true"
false-value="false"
:disabled="!canEdit || submitting"
>
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</label>
<input
v-else-if="field.type === 'date'"
v-model="field.value"
type="date"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || submitting"
>
<input
v-else
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || submitting"
>
</div>
</div>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
@@ -320,11 +224,19 @@ import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments'
import { formatPieceStructurePreview } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import type { PieceModelProduct, PieceModelStructure } from '~/shared/types/inventory'
import type { PieceModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes'
import {
getStructureProducts,
buildProductRequirementDescriptions,
buildProductRequirementEntries,
resizeProductSelections,
areProductSelectionsFilled,
applyProductSelection,
collectNormalizedProductIds,
} from '~/shared/utils/pieceProductSelectionUtils'
import {
type CustomFieldInput,
fieldKey,
normalizeCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
@@ -338,7 +250,7 @@ interface PieceCatalogType extends ModelType {
const route = useRoute()
const router = useRouter()
const { pieceTypes, loadPieceTypes, loadingPieceTypes } = usePieceTypes()
const { pieceTypes, loadPieceTypes, loadingPieceTypes: loadingTypes } = usePieceTypes()
const { createPiece } = usePieces()
const toast = useToast()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
@@ -385,7 +297,6 @@ watch(selectedTypeId, (id) => {
router.replace({ path: route.path, query: nextQuery }).catch(() => {})
})
const loadingTypes = computed(() => loadingPieceTypes.value)
const pieceTypeList = computed<PieceCatalogType[]>(() => (pieceTypes.value || []) as PieceCatalogType[])
const typeOptionLabel = (type?: PieceCatalogType) =>
@@ -401,73 +312,34 @@ const selectedType = computed(() => {
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
})
const getStructureCustomFields = (structure: PieceModelStructure | null) =>
Array.isArray(structure?.customFields) ? structure.customFields : []
const getStructureProducts = (structure: PieceModelStructure | null) =>
Array.isArray(structure?.products) ? structure.products : []
const structureProducts = computed(() =>
getStructureProducts(selectedType.value?.structure ?? null),
)
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
const describeProductRequirement = (requirement: PieceModelProduct, index: number) => {
if (!requirement) {
return `Produit ${index + 1}`
}
const parts: string[] = []
if (requirement.role) {
parts.push(requirement.role)
}
if (requirement.typeProductLabel) {
parts.push(requirement.typeProductLabel)
} else if (requirement.typeProductId) {
parts.push(`Catégorie #${requirement.typeProductId}`)
}
if (requirement.familyCode) {
parts.push(`Famille ${requirement.familyCode}`)
}
if (parts.length === 0) {
parts.push(`Produit ${index + 1}`)
}
return parts.join(' • ')
}
const productRequirementDescriptions = computed(() =>
structureProducts.value.map((requirement, index) =>
describeProductRequirement(requirement, index),
),
buildProductRequirementDescriptions(structureProducts.value),
)
const ensureProductSelections = (count: number) => {
const next = Array.from({ length: count }, (_, index) => productSelections.value[index] ?? null)
productSelections.value = next
productSelections.value = resizeProductSelections(productSelections.value, count)
}
const productRequirementEntries = computed(() =>
structureProducts.value.map((requirement, index) => ({
index,
key: `piece-create-product-requirement-${index}-${requirement?.typeProductId || 'any'}`,
label: describeProductRequirement(requirement, index),
typeProductId: requirement?.typeProductId ? String(requirement.typeProductId) : null,
})),
buildProductRequirementEntries(structureProducts.value, 'piece-create-product-requirement'),
)
const productSelectionsFilled = computed(() =>
!requiresProductSelection.value ||
productRequirementEntries.value.every((entry) => {
const value = productSelections.value[entry.index]
return typeof value === 'string' && value.trim().length > 0
}),
areProductSelectionsFilled(
requiresProductSelection.value,
productRequirementEntries.value,
productSelections.value,
),
)
const setProductSelection = (index: number, value: string | null) => {
const normalized = typeof value === 'string' ? value : null
const next = [...productSelections.value]
next[index] = normalized
productSelections.value = next
productSelections.value = applyProductSelection(productSelections.value, index, value)
}
watch(structureProducts, (products) => {
@@ -541,10 +413,10 @@ const submitCreation = async () => {
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
const normalizedProductIds = productRequirementEntries.value
.map((entry) => productSelections.value[entry.index])
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())
const normalizedProductIds = collectNormalizedProductIds(
productRequirementEntries.value,
productSelections.value,
)
if (normalizedProductIds.length) {
payload.productIds = normalizedProductIds
payload.productId = normalizedProductIds[0]

View File

@@ -36,7 +36,7 @@
v-else
:columns="columns"
:rows="productRows"
:loading="loadingProducts"
:loading="loading"
:sort="table.sort.value"
:pagination="paginationState"
:column-filters="table.columnFilters.value"
@@ -63,7 +63,7 @@
<template #cell-preview="{ row }">
<DocumentThumbnail
:document="resolvePrimaryDocument(row.product)"
:document="resolvePrimaryDocument(row.product, true)"
:alt="resolvePreviewAlt(row.product)"
/>
</template>
@@ -147,7 +147,8 @@ import { useProductTypes } from '~/composables/useProductTypes'
import { useToast } from '~/composables/useToast'
import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
const { canEdit } = usePermissions()
@@ -169,7 +170,6 @@ const table = useDataTable(
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true },
)
const loadingProducts = computed(() => loading.value)
const errorMessage = computed(() => (typeof error.value === 'string' && error.value.length ? error.value : null))
const columns = [
@@ -197,7 +197,7 @@ const productRows = computed(() =>
normalizedProducts.value.map(product => ({
id: product.id,
product,
suppliers: buildSuppliersDisplay(product),
suppliers: buildProductSuppliersDisplay(product),
})),
)
@@ -225,85 +225,21 @@ const formatPrice = (value: any) => {
return Number.isNaN(number) ? '—' : priceFormatter.format(number)
}
const MAX_VISIBLE_SUPPLIERS = 3
const resolveProductSuppliers = (product: Record<string, any>) => {
const names: string[] = []
const seen = new Set<string>()
const pushName = (maybeName: unknown) => {
if (typeof maybeName !== 'string') return
const normalized = maybeName.trim().replace(/\s+/g, ' ')
if (!normalized.length) return
const key = normalized.toLowerCase()
if (seen.has(key)) return
seen.add(key)
names.push(normalized)
}
const collectConstructeurs = (value: unknown): void => {
if (!value) return
if (Array.isArray(value)) { value.forEach(collectConstructeurs); return }
if (typeof value === 'string') { pushName(value); return }
if (typeof value === 'object') {
const record = value as Record<string, any>
pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null)
if (record?.constructeur) collectConstructeurs(record.constructeur)
if (Array.isArray(record?.constructeurs)) collectConstructeurs(record.constructeurs)
}
}
const collectFromLabel = (value: unknown): void => {
if (typeof value !== 'string') return
value.split(/[,;\\/•·|]+/).map(part => part.trim()).filter(Boolean).forEach(pushName)
}
collectConstructeurs(product?.constructeurs)
collectConstructeurs(product?.constructeur)
collectFromLabel(product?.constructeursLabel)
collectFromLabel(product?.supplierLabel)
collectFromLabel(product?.suppliers)
return names
}
const buildSuppliersDisplay = (product: Record<string, any>) => {
const suppliers = resolveProductSuppliers(product)
const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS)
const overflow = Math.max(suppliers.length - visible.length, 0)
return { suppliers, visible, overflow, tooltip: suppliers.length ? suppliers.join(', ') : '' }
}
const resolvePrimaryDocument = (product: Record<string, any>) => {
const documents = Array.isArray(product?.documents) ? product.documents : []
if (!documents.length) return null
const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
if (!withPath.length) return normalized[0] ?? null
const images = withPath.filter((doc: any) => isImageDocument(doc))
if (images.length) return images[0]
const pdf = withPath.find((doc: any) => isPdfDocument(doc))
if (pdf) return pdf
return withPath[0]
}
const resolvePreviewAlt = (product: Record<string, any>) => {
const parts = [product?.name, product?.reference].filter(Boolean)
return parts.length ? `Aperçu du document de ${parts.join(' ')}` : 'Aperçu du document'
}
const buildProductSuppliersDisplay = (product: Record<string, any>) =>
buildSuppliersDisplay(resolveSupplierNames(product))
const reload = () => fetchProducts()
const { confirm } = useConfirm()
const confirmDelete = async (product: Record<string, any>) => {
const confirmed = await confirm({
message: `Voulez-vous vraiment supprimer le produit "${product.name}" ?\n\nCette action est irréversible.`,
})
const productName = product?.name || 'ce produit'
const message = buildDeleteMessage(productName, resolveDeleteImpact(product))
const confirmed = await confirm({ title: 'Supprimer le produit', message, dangerous: true })
if (!confirmed) return
const result = await deleteProduct(product.id)
if (result.success) {
toast.showSuccess(`Produit "${product.name}" supprimé`)
toast.showSuccess(`Produit "${productName}" supprimé`)
}
}

View File

@@ -133,78 +133,7 @@
Mettez à jour les valeurs propres à ce produit.
</p>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="(field, index) in customFieldInputs"
:key="fieldKey(field, index)"
class="form-control"
>
<label class="label">
<span class="label-text">{{ field.name }}</span>
<span v-if="field.required" class="label-text-alt text-error">*</span>
</label>
<input
v-if="field.type === 'text'"
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || saving"
>
<input
v-else-if="field.type === 'number'"
v-model="field.value"
type="number"
step="0.01"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || saving"
>
<select
v-else-if="field.type === 'select'"
v-model="field.value"
class="select select-bordered select-sm md:select-md"
:required="field.required"
:disabled="!canEdit || saving"
>
<option value="">Sélectionner...</option>
<option
v-for="option in field.options"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
<input
v-model="field.value"
type="checkbox"
class="toggle toggle-primary toggle-sm md:toggle-md"
true-value="true"
false-value="false"
:disabled="!canEdit || saving"
>
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</label>
<input
v-else-if="field.type === 'date'"
v-model="field.value"
type="date"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || saving"
>
<input
v-else
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || saving"
>
</div>
</div>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
@@ -233,143 +162,23 @@
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents
</p>
<div v-else-if="productDocuments.length" class="space-y-2">
<div
v-for="document in productDocuments"
:key="document.id || document.path || document.name"
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex items-center gap-3 text-sm">
<div
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center"
:class="documentThumbnailClass(document)"
>
<img
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-6 w-6"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<div>
<div class="font-medium">
{{ document.name }}
</div>
<div class="text-xs text-base-content/70">
{{ document.mimeType || 'Inconnu' }} {{ formatSize(document.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
@click="openPreview(document)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
Télécharger
</button>
<button
v-if="canEdit"
type="button"
class="btn btn-error btn-xs"
:disabled="uploadingDocuments || saving"
@click="removeDocument(document.id)"
>
Supprimer
</button>
</div>
</div>
</div>
<p v-else class="text-xs text-base-content/70">
Aucun document n'est associé à ce produit pour le moment.
</p>
<DocumentListInline
v-else
:documents="productDocuments"
:can-delete="canEdit"
:delete-disabled="uploadingDocuments || saving"
empty-text="Aucun document n'est associé à ce produit pour le moment."
@preview="openPreview"
@delete="removeDocument"
/>
</div>
<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">Historique</h2>
<p class="text-xs text-base-content/70">
Qui a changé quoi, et quand.
</p>
</div>
<span v-if="historyEntries.length" class="badge badge-outline">
{{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }}
</span>
</header>
<div v-if="historyLoading" class="flex items-center gap-2 text-sm text-base-content/70">
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
Chargement de lhistorique…
</div>
<div v-else-if="historyError" class="alert alert-warning">
<span>{{ historyError }}</span>
</div>
<p v-else-if="historyEntries.length === 0" class="text-xs text-base-content/70">
Aucun changement enregistré pour le moment.
</p>
<ul v-else class="max-h-80 space-y-2 overflow-y-auto pr-1">
<li
v-for="entry in historyEntries"
:key="entry.id"
class="rounded-md border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-base-content/70">
<span class="font-medium text-base-content">
{{ historyActionLabel(entry.action) }}
</span>
<span>{{ formatHistoryDate(entry.createdAt) }}</span>
</div>
<p class="mt-1 text-xs text-base-content/60">
Par {{ entry.actor?.label || 'Inconnu' }}
</p>
<ul
v-if="historyDiffEntries(entry).length"
class="mt-2 space-y-1 text-xs"
>
<li
v-for="diffEntry in historyDiffEntries(entry)"
:key="`${entry.id}-${diffEntry.field}`"
class="flex flex-col gap-0.5"
>
<span class="font-medium text-base-content/80">{{ diffEntry.label }}</span>
<span class="text-base-content/60">
{{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
</span>
</li>
</ul>
<p
v-else-if="entry.snapshot?.name"
class="mt-2 text-xs text-base-content/70"
>
{{ entry.snapshot.name }}
</p>
</li>
</ul>
</section>
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<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 }">
@@ -410,7 +219,7 @@ import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useProductHistory, type ProductHistoryEntry } from '~/composables/useProductHistory'
import { useProductHistory } from '~/composables/useProductHistory'
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { getModelType } from '~/services/modelTypes'
@@ -418,24 +227,10 @@ import type { ProductModelStructure } from '~/shared/types/inventory'
import { canPreviewDocument } from '~/utils/documentPreview'
import {
type CustomFieldInput,
fieldKey,
buildCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils'
import {
documentIcon,
formatSize,
shouldInlinePdf,
documentPreviewSrc,
documentThumbnailClass,
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
import {
historyActionLabel,
formatHistoryDate,
historyDiffEntries as _historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils'
const { canEdit } = usePermissions()
const route = useRoute()
@@ -469,8 +264,6 @@ const productDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null)
const previewVisible = ref(false)
const historyEntries = computed<ProductHistoryEntry[]>(() => history.value)
const historyFieldLabels: Record<string, string> = {
name: 'Nom',
reference: 'Référence',
@@ -479,9 +272,6 @@ const historyFieldLabels: Record<string, string> = {
constructeurIds: 'Fournisseurs',
}
const historyDiffEntries = (entry: ProductHistoryEntry) =>
_historyDiffEntries(entry, historyFieldLabels)
const refreshCustomFieldInputs = (
structureOverride?: ProductModelStructure | null,
valuesOverride?: any[] | null,
@@ -508,20 +298,12 @@ const canSubmit = computed(() =>
const structurePreview = computed(() => formatProductStructurePreview(structure.value))
const openPreview = (doc: any) => {
if (!doc || !canPreviewDocument(doc)) {
return
}
if (!doc || !canPreviewDocument(doc)) return
previewDocument.value = doc
previewVisible.value = true
}
const closePreview = () => {
previewVisible.value = false
previewDocument.value = null
}
const closePreview = () => { previewVisible.value = false; previewDocument.value = null }
const loadProduct = async () => {
const id = route.params.id

View File

@@ -119,78 +119,7 @@
Renseignez les valeurs propres à ce produit catalogue.
</p>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="(field, index) in customFieldInputs"
:key="fieldKey(field, index)"
class="form-control"
>
<label class="label">
<span class="label-text">{{ field.name }}</span>
<span v-if="field.required" class="label-text-alt text-error">*</span>
</label>
<input
v-if="field.type === 'text'"
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || submitting"
>
<input
v-else-if="field.type === 'number'"
v-model="field.value"
type="number"
step="0.01"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || submitting"
>
<select
v-else-if="field.type === 'select'"
v-model="field.value"
class="select select-bordered select-sm md:select-md"
:required="field.required"
:disabled="!canEdit || submitting"
>
<option value="">Sélectionner...</option>
<option
v-for="option in field.options"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
<input
v-model="field.value"
type="checkbox"
class="toggle toggle-primary toggle-sm md:toggle-md"
true-value="true"
false-value="false"
:disabled="!canEdit || submitting"
>
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</label>
<input
v-else-if="field.type === 'date'"
v-model="field.value"
type="date"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || submitting"
>
<input
v-else
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="!canEdit || submitting"
>
</div>
</div>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
@@ -262,7 +191,7 @@ interface ProductCatalogType extends ModelType {
const route = useRoute()
const router = useRouter()
const { productTypes, loadProductTypes, loadingProductTypes } = useProductTypes()
const { productTypes, loadProductTypes, loadingProductTypes: loadingTypes } = useProductTypes()
const { createProduct } = useProducts()
const toast = useToast()
const { upsertCustomFieldValue } = useCustomFields()
@@ -283,7 +212,6 @@ const uploadingDocuments = ref(false)
const customFieldInputs = ref<CustomFieldInput[]>([])
const loadingTypes = computed(() => loadingProductTypes.value)
const productTypeList = computed<ProductCatalogType[]>(() =>
(productTypes.value || []) as ProductCatalogType[],
)
@@ -354,9 +282,6 @@ const canSubmit = computed(() => Boolean(
!submitting.value,
))
const fieldKey = (field: CustomFieldInput, index: number) =>
field.customFieldId || field.id || `${field.name}-${index}`
const clearForm = () => {
creationForm.name = ''
creationForm.reference = ''

View File

@@ -1,13 +1,36 @@
import {
createEmptyComponentModelStructure,
type ComponentModelCustomFieldType,
type ComponentModelCustomField,
type ComponentModelPiece,
type ComponentModelProduct,
type ComponentModelStructure,
type ComponentModelStructureNode,
} from '../types/inventory'
// Import for internal use in this file
import { sanitizeCustomFields, sanitizePieces, sanitizeProducts, sanitizeSubcomponents } from './componentStructureSanitize'
import { hydrateCustomFields, hydratePieces, hydrateProducts, hydrateSubcomponents, mapComponentCustomFields, mapComponentPieces, mapComponentProducts, mapSubcomponents } from './componentStructureHydrate'
// Re-export sanitize functions so existing imports continue to work
export {
toStringArray,
extractFieldValueObject,
sanitizeCustomFields,
sanitizePieces,
sanitizeProducts,
sanitizeSubcomponents,
} from './componentStructureSanitize'
// Re-export hydrate functions so existing imports continue to work
export {
hydrateCustomFields,
hydratePieces,
hydrateProducts,
hydrateSubcomponents,
mapComponentCustomFields,
mapComponentPieces,
mapComponentProducts,
mapSubcomponents,
} from './componentStructureHydrate'
export const isPlainObject = (value: unknown): value is Record<string, unknown> => {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
@@ -60,440 +83,6 @@ export const cloneStructure = (input: any): ComponentModelStructure => {
}
}
export const toStringArray = (input: unknown): string[] | undefined => {
if (!Array.isArray(input)) {
return undefined
}
const parsed = input
.map((value) => {
if (typeof value === 'string') {
return value.trim()
}
if (value === null || value === undefined) {
return ''
}
return String(value).trim()
})
.filter((value) => value.length > 0)
return parsed.length ? parsed : undefined
}
export const extractFieldValueObject = (field: any): Record<string, any> => {
if (isPlainObject(field?.value)) {
return field.value as Record<string, any>
}
return {}
}
export const sanitizeCustomFields = (fields: any[]): ComponentModelCustomField[] => {
if (!Array.isArray(fields)) {
return []
}
return fields
.map((field, index) => {
const rawName =
typeof field?.name === 'string'
? field.name
: typeof field?.key === 'string'
? field.key
: ''
const name = rawName.trim()
if (!name) {
return null
}
const valueObject = extractFieldValueObject(field)
const candidateType =
typeof field?.type === 'string' && field.type
? field.type
: typeof valueObject?.type === 'string'
? valueObject.type
: ''
const allowedTypes: ComponentModelCustomFieldType[] = ['text', 'number', 'select', 'boolean', 'date']
const type = allowedTypes.includes(candidateType as ComponentModelCustomFieldType)
? (candidateType as ComponentModelCustomFieldType)
: 'text'
const required =
typeof valueObject?.required === 'boolean' ? valueObject.required : !!field?.required
let options: string[] | undefined
if (type === 'select') {
options =
toStringArray(valueObject?.options) ||
toStringArray((valueObject as any)?.choices) ||
toStringArray(field?.options)
if (!options && typeof field?.optionsText === 'string') {
const parsedFromText = field.optionsText
.split(/\r?\n/)
.map((option: string) => option.trim())
.filter((option: string) => option.length > 0)
options = parsedFromText.length ? parsedFromText : undefined
}
}
const result: ComponentModelCustomField = { name, type, required }
if (options) {
result.options = options
}
const defaultCandidate =
field?.defaultValue ?? valueObject?.defaultValue ?? field?.value ?? field?.default ?? null
const resolvedDefault = (() => {
if (defaultCandidate === undefined || defaultCandidate === null) {
return undefined
}
if (typeof defaultCandidate === 'object') {
if (defaultCandidate === null) {
return undefined
}
if ('defaultValue' in (defaultCandidate as Record<string, any>)) {
return (defaultCandidate as Record<string, any>).defaultValue
}
if ('value' in (defaultCandidate as Record<string, any>)) {
return (defaultCandidate as Record<string, any>).value
}
return undefined
}
return defaultCandidate
})()
if (resolvedDefault !== undefined && resolvedDefault !== null && resolvedDefault !== '') {
result.defaultValue = String(resolvedDefault)
}
const id = typeof field?.id === 'string' ? field.id : undefined
if (id) {
result.id = id
}
const customFieldId = typeof field?.customFieldId === 'string' ? field.customFieldId : undefined
if (customFieldId) {
result.customFieldId = customFieldId
}
const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index
result.orderIndex = orderIndex
return result
})
.filter((field): field is ComponentModelCustomField => !!field)
}
export const sanitizePieces = (pieces: any[]): ComponentModelPiece[] => {
if (!Array.isArray(pieces)) {
return []
}
return pieces
.map((piece) => {
const rawTypePieceId = typeof piece?.typePieceId === 'string'
? piece.typePieceId.trim()
: typeof piece?.typePiece?.id === 'string'
? piece.typePiece.id.trim()
: ''
const typePieceId = rawTypePieceId.length > 0 ? rawTypePieceId : undefined
const rawTypePieceLabel = typeof piece?.typePieceLabel === 'string'
? piece.typePieceLabel.trim()
: typeof piece?.typePiece?.name === 'string'
? piece.typePiece.name.trim()
: ''
const typePieceLabel = rawTypePieceLabel.length > 0 ? rawTypePieceLabel : undefined
const reference = typeof piece?.reference === 'string' && piece.reference.trim().length > 0
? piece.reference.trim()
: undefined
const rawFamilyCode = typeof piece?.familyCode === 'string'
? piece.familyCode.trim()
: typeof piece?.typePiece?.code === 'string'
? piece.typePiece.code.trim()
: ''
const familyCode = rawFamilyCode.length > 0 ? rawFamilyCode : undefined
const rawRole = typeof piece?.role === 'string' ? piece.role.trim() : ''
const role = rawRole.length > 0 ? rawRole : undefined
if (!typePieceId && !typePieceLabel && !reference && !familyCode) {
return null
}
const result: ComponentModelPiece = {}
if (role) {
result.role = role
}
if (familyCode) {
result.familyCode = familyCode
}
if (reference !== undefined) {
result.reference = reference
}
if (typePieceId) {
result.typePieceId = typePieceId
}
if (typePieceLabel) {
result.typePieceLabel = typePieceLabel
}
return result
})
.filter((piece): piece is ComponentModelPiece => !!piece)
}
export const sanitizeProducts = (products: any[]): ComponentModelProduct[] => {
if (!Array.isArray(products)) {
return []
}
return products
.map((product) => {
const rawTypeProductId = typeof product?.typeProductId === 'string'
? product.typeProductId.trim()
: typeof product?.typeProduct?.id === 'string'
? product.typeProduct.id.trim()
: ''
const typeProductId = rawTypeProductId.length > 0 ? rawTypeProductId : undefined
const rawTypeProductLabel = typeof product?.typeProductLabel === 'string'
? product.typeProductLabel.trim()
: typeof product?.typeProduct?.name === 'string'
? product.typeProduct.name.trim()
: ''
const typeProductLabel = rawTypeProductLabel.length > 0 ? rawTypeProductLabel : undefined
const reference = typeof product?.reference === 'string' && product.reference.trim().length > 0
? product.reference.trim()
: undefined
const rawFamilyCode = typeof product?.familyCode === 'string'
? product.familyCode.trim()
: typeof product?.typeProduct?.code === 'string'
? product.typeProduct.code.trim()
: ''
const familyCode = rawFamilyCode.length > 0 ? rawFamilyCode : undefined
const rawRole = typeof product?.role === 'string' ? product.role.trim() : ''
const role = rawRole.length > 0 ? rawRole : undefined
if (!typeProductId && !typeProductLabel && !reference && !familyCode) {
return null
}
const result: ComponentModelProduct = {}
if (role) {
result.role = role
}
if (familyCode) {
result.familyCode = familyCode
}
if (reference !== undefined) {
result.reference = reference
}
if (typeProductId) {
result.typeProductId = typeProductId
}
if (typeProductLabel) {
result.typeProductLabel = typeProductLabel
}
return result
})
.filter((product): product is ComponentModelProduct => !!product)
}
const sanitizeSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
if (!Array.isArray(components)) {
return []
}
return components
.map((component) => {
const rawTypeComposantId = typeof component?.typeComposantId === 'string'
? component.typeComposantId.trim()
: typeof component?.typeComposant?.id === 'string'
? component.typeComposant.id.trim()
: ''
const typeComposantId = rawTypeComposantId.length > 0 ? rawTypeComposantId : undefined
const modelId = typeof component?.modelId === 'string' && component.modelId.trim().length > 0
? component.modelId.trim()
: undefined
const familyCode = typeof component?.familyCode === 'string' && component.familyCode.trim().length > 0
? component.familyCode.trim()
: undefined
const alias = typeof component?.alias === 'string' && component.alias.trim().length > 0
? component.alias.trim()
: undefined
if (!typeComposantId && !modelId && !familyCode) {
return null
}
const result: ComponentModelStructureNode = {
subcomponents: sanitizeSubcomponents(
Array.isArray(component?.subcomponents)
? component.subcomponents
: component?.subComponents,
),
}
if (typeComposantId) {
result.typeComposantId = typeComposantId
}
const typeComposantLabel = typeof component?.typeComposantLabel === 'string'
? component.typeComposantLabel.trim()
: typeof component?.typeComposant?.name === 'string'
? component.typeComposant.name.trim()
: ''
if (typeComposantLabel) {
result.typeComposantLabel = typeComposantLabel
}
if (modelId) {
result.modelId = modelId
}
if (familyCode) {
result.familyCode = familyCode
}
if (alias) {
result.alias = alias
}
return result
})
.filter((component): component is ComponentModelStructureNode => !!component)
}
const hydrateCustomFields = (fields: any[]): any[] => {
if (!Array.isArray(fields)) {
return []
}
return fields.map((field, index) => {
const valueObject = extractFieldValueObject(field)
const name = typeof field?.name === 'string'
? field.name
: typeof field?.key === 'string'
? field.key
: ''
const candidateType =
typeof field?.type === 'string' && field.type
? field.type
: typeof valueObject?.type === 'string'
? valueObject.type
: ''
const allowedTypes: ComponentModelCustomFieldType[] = ['text', 'number', 'select', 'boolean', 'date']
const type = allowedTypes.includes(candidateType as ComponentModelCustomFieldType)
? (candidateType as ComponentModelCustomFieldType)
: 'text'
const required =
typeof field?.required === 'boolean'
? field.required
: typeof valueObject?.required === 'boolean'
? valueObject.required
: false
const options =
toStringArray(field?.options) ||
toStringArray(valueObject?.options) ||
toStringArray((valueObject as any)?.choices) ||
[]
const optionsText = typeof field?.optionsText === 'string'
? field.optionsText
: options.length
? options.join('\n')
: ''
const defaultCandidate =
field?.defaultValue ?? valueObject?.defaultValue ?? field?.value ?? field?.default ?? null
const resolvedDefault = (() => {
if (defaultCandidate === undefined || defaultCandidate === null) {
return undefined
}
if (typeof defaultCandidate === 'object') {
if (defaultCandidate === null) {
return undefined
}
if ('defaultValue' in (defaultCandidate as Record<string, any>)) {
return (defaultCandidate as Record<string, any>).defaultValue
}
if ('value' in (defaultCandidate as Record<string, any>)) {
return (defaultCandidate as Record<string, any>).value
}
return undefined
}
return defaultCandidate
})()
const defaultValue =
resolvedDefault !== undefined && resolvedDefault !== null && resolvedDefault !== ''
? String(resolvedDefault)
: ''
const id = typeof field?.id === 'string' ? field.id : undefined
const customFieldId = typeof field?.customFieldId === 'string' ? field.customFieldId : undefined
const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index
return {
name,
type,
required,
options,
optionsText,
defaultValue,
id,
customFieldId,
orderIndex,
}
})
}
const hydratePieces = (pieces: any[]): ComponentModelPiece[] => {
if (!Array.isArray(pieces)) {
return []
}
return pieces.map((piece) => ({
typePieceId: piece?.typePieceId ?? piece?.typePiece?.id ?? '',
typePieceLabel: piece?.typePieceLabel ?? piece?.typePiece?.name ?? '',
reference: piece?.reference ?? '',
familyCode: piece?.familyCode ?? piece?.typePiece?.code ?? '',
role: piece?.role ?? '',
}))
}
export const hydrateProducts = (products: any[]): ComponentModelProduct[] => {
if (!Array.isArray(products)) {
return []
}
return products.map((product) => ({
typeProductId: product?.typeProductId ?? product?.typeProduct?.id ?? '',
typeProductLabel: product?.typeProductLabel ?? product?.typeProduct?.name ?? '',
reference: product?.reference ?? '',
familyCode: product?.familyCode ?? product?.typeProduct?.code ?? '',
role: product?.role ?? '',
}))
}
const hydrateSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
if (!Array.isArray(components)) {
return []
}
return components.map((component) => ({
typeComposantId: component?.typeComposantId ?? component?.typeComposant?.id ?? '',
typeComposantLabel: component?.typeComposantLabel ?? component?.typeComposant?.name ?? '',
modelId: component?.modelId ?? '',
familyCode: component?.familyCode ?? component?.typeComposant?.code ?? '',
alias: component?.alias ?? component?.name ?? '',
subcomponents: hydrateSubcomponents(
Array.isArray(component?.subcomponents)
? component.subcomponents
: component?.subComponents,
),
}))
}
export const normalizeStructureForEditor = (input: any): ComponentModelStructure => {
const source = cloneStructure(input)
@@ -668,76 +257,6 @@ export const hydrateStructureForEditor = (input: any): ComponentModelStructure =
}
}
const mapComponentCustomFields = (fields: any[]) => {
if (!Array.isArray(fields)) {
return []
}
return hydrateCustomFields(fields).map((field, index) => {
const defaultValue =
field?.defaultValue !== undefined && field?.defaultValue !== null && field?.defaultValue !== ''
? field.defaultValue
: null
return {
name: typeof field?.name === 'string' ? field.name : '',
type: field?.type ?? 'text',
required: !!field?.required,
options: Array.isArray(field?.options) ? field.options : [],
optionsText: typeof field?.optionsText === 'string' ? field.optionsText : '',
defaultValue,
id: typeof (field as any)?.id === 'string' ? (field as any).id : undefined,
customFieldId:
typeof (field as any)?.customFieldId === 'string'
? (field as any).customFieldId
: undefined,
orderIndex: typeof field?.orderIndex === 'number' ? field.orderIndex : index,
}
})
}
const mapComponentPieces = (pieces: any[]): ComponentModelPiece[] => {
if (!Array.isArray(pieces)) {
return []
}
return pieces.map((piece) => ({
reference: piece?.reference ?? '',
typePieceId: piece?.typePieceId ?? piece?.typePiece?.id ?? '',
typePieceLabel: piece?.typePieceLabel ?? piece?.typePiece?.name ?? '',
familyCode: piece?.familyCode ?? piece?.typePiece?.code ?? '',
role: piece?.role ?? '',
}))
}
const mapComponentProducts = (products: any[]): ComponentModelProduct[] => {
if (!Array.isArray(products)) {
return []
}
return products.map((product) => ({
reference: product?.reference ?? '',
typeProductId: product?.typeProductId ?? product?.typeProduct?.id ?? '',
typeProductLabel: product?.typeProductLabel ?? product?.typeProduct?.name ?? '',
familyCode: product?.familyCode ?? product?.typeProduct?.code ?? '',
role: product?.role ?? '',
}))
}
const mapSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
if (!Array.isArray(components)) {
return []
}
return components.map((component) => ({
typeComposantId: component?.typeComposantId ?? component?.typeComposant?.id ?? '',
typeComposantLabel: component?.typeComposantLabel ?? component?.typeComposant?.name ?? '',
modelId: component?.modelId ?? '',
familyCode: component?.familyCode ?? component?.typeComposant?.code ?? '',
alias: component?.alias ?? component?.name ?? '',
subcomponents: mapSubcomponents(
Array.isArray(component?.subcomponents)
? component.subcomponents
: component?.subComponents,
),
}))
}
export const extractStructureFromComponent = (component: any) => {
if (!component) {
return defaultStructure()

View File

@@ -0,0 +1,210 @@
import type {
ComponentModelCustomFieldType,
ComponentModelPiece,
ComponentModelProduct,
ComponentModelStructureNode,
} from '../types/inventory'
import { extractFieldValueObject, toStringArray } from './componentStructureSanitize'
export const hydrateCustomFields = (fields: any[]): any[] => {
if (!Array.isArray(fields)) {
return []
}
return fields.map((field, index) => {
const valueObject = extractFieldValueObject(field)
const name = typeof field?.name === 'string'
? field.name
: typeof field?.key === 'string'
? field.key
: ''
const candidateType =
typeof field?.type === 'string' && field.type
? field.type
: typeof valueObject?.type === 'string'
? valueObject.type
: ''
const allowedTypes: ComponentModelCustomFieldType[] = ['text', 'number', 'select', 'boolean', 'date']
const type = allowedTypes.includes(candidateType as ComponentModelCustomFieldType)
? (candidateType as ComponentModelCustomFieldType)
: 'text'
const required =
typeof field?.required === 'boolean'
? field.required
: typeof valueObject?.required === 'boolean'
? valueObject.required
: false
const options =
toStringArray(field?.options) ||
toStringArray(valueObject?.options) ||
toStringArray((valueObject as any)?.choices) ||
[]
const optionsText = typeof field?.optionsText === 'string'
? field.optionsText
: options.length
? options.join('\n')
: ''
const defaultCandidate =
field?.defaultValue ?? valueObject?.defaultValue ?? field?.value ?? field?.default ?? null
const resolvedDefault = (() => {
if (defaultCandidate === undefined || defaultCandidate === null) {
return undefined
}
if (typeof defaultCandidate === 'object') {
if (defaultCandidate === null) {
return undefined
}
if ('defaultValue' in (defaultCandidate as Record<string, any>)) {
return (defaultCandidate as Record<string, any>).defaultValue
}
if ('value' in (defaultCandidate as Record<string, any>)) {
return (defaultCandidate as Record<string, any>).value
}
return undefined
}
return defaultCandidate
})()
const defaultValue =
resolvedDefault !== undefined && resolvedDefault !== null && resolvedDefault !== ''
? String(resolvedDefault)
: ''
const id = typeof field?.id === 'string' ? field.id : undefined
const customFieldId = typeof field?.customFieldId === 'string' ? field.customFieldId : undefined
const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index
return {
name,
type,
required,
options,
optionsText,
defaultValue,
id,
customFieldId,
orderIndex,
}
})
}
export const hydratePieces = (pieces: any[]): ComponentModelPiece[] => {
if (!Array.isArray(pieces)) {
return []
}
return pieces.map((piece) => ({
typePieceId: piece?.typePieceId ?? piece?.typePiece?.id ?? '',
typePieceLabel: piece?.typePieceLabel ?? piece?.typePiece?.name ?? '',
reference: piece?.reference ?? '',
familyCode: piece?.familyCode ?? piece?.typePiece?.code ?? '',
role: piece?.role ?? '',
}))
}
export const hydrateProducts = (products: any[]): ComponentModelProduct[] => {
if (!Array.isArray(products)) {
return []
}
return products.map((product) => ({
typeProductId: product?.typeProductId ?? product?.typeProduct?.id ?? '',
typeProductLabel: product?.typeProductLabel ?? product?.typeProduct?.name ?? '',
reference: product?.reference ?? '',
familyCode: product?.familyCode ?? product?.typeProduct?.code ?? '',
role: product?.role ?? '',
}))
}
export const hydrateSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
if (!Array.isArray(components)) {
return []
}
return components.map((component) => ({
typeComposantId: component?.typeComposantId ?? component?.typeComposant?.id ?? '',
typeComposantLabel: component?.typeComposantLabel ?? component?.typeComposant?.name ?? '',
modelId: component?.modelId ?? '',
familyCode: component?.familyCode ?? component?.typeComposant?.code ?? '',
alias: component?.alias ?? component?.name ?? '',
subcomponents: hydrateSubcomponents(
Array.isArray(component?.subcomponents)
? component.subcomponents
: component?.subComponents,
),
}))
}
export const mapComponentCustomFields = (fields: any[]) => {
if (!Array.isArray(fields)) {
return []
}
return hydrateCustomFields(fields).map((field, index) => {
const defaultValue =
field?.defaultValue !== undefined && field?.defaultValue !== null && field?.defaultValue !== ''
? field.defaultValue
: null
return {
name: typeof field?.name === 'string' ? field.name : '',
type: field?.type ?? 'text',
required: !!field?.required,
options: Array.isArray(field?.options) ? field.options : [],
optionsText: typeof field?.optionsText === 'string' ? field.optionsText : '',
defaultValue,
id: typeof (field as any)?.id === 'string' ? (field as any).id : undefined,
customFieldId:
typeof (field as any)?.customFieldId === 'string'
? (field as any).customFieldId
: undefined,
orderIndex: typeof field?.orderIndex === 'number' ? field.orderIndex : index,
}
})
}
export const mapComponentPieces = (pieces: any[]): ComponentModelPiece[] => {
if (!Array.isArray(pieces)) {
return []
}
return pieces.map((piece) => ({
reference: piece?.reference ?? '',
typePieceId: piece?.typePieceId ?? piece?.typePiece?.id ?? '',
typePieceLabel: piece?.typePieceLabel ?? piece?.typePiece?.name ?? '',
familyCode: piece?.familyCode ?? piece?.typePiece?.code ?? '',
role: piece?.role ?? '',
}))
}
export const mapComponentProducts = (products: any[]): ComponentModelProduct[] => {
if (!Array.isArray(products)) {
return []
}
return products.map((product) => ({
reference: product?.reference ?? '',
typeProductId: product?.typeProductId ?? product?.typeProduct?.id ?? '',
typeProductLabel: product?.typeProductLabel ?? product?.typeProduct?.name ?? '',
familyCode: product?.familyCode ?? product?.typeProduct?.code ?? '',
role: product?.role ?? '',
}))
}
export const mapSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
if (!Array.isArray(components)) {
return []
}
return components.map((component) => ({
typeComposantId: component?.typeComposantId ?? component?.typeComposant?.id ?? '',
typeComposantLabel: component?.typeComposantLabel ?? component?.typeComposant?.name ?? '',
modelId: component?.modelId ?? '',
familyCode: component?.familyCode ?? component?.typeComposant?.code ?? '',
alias: component?.alias ?? component?.name ?? '',
subcomponents: mapSubcomponents(
Array.isArray(component?.subcomponents)
? component.subcomponents
: component?.subComponents,
),
}))
}

View File

@@ -0,0 +1,312 @@
import type {
ComponentModelCustomField,
ComponentModelCustomFieldType,
ComponentModelPiece,
ComponentModelProduct,
ComponentModelStructureNode,
} from '../types/inventory'
// Inline helper to avoid circular dependency with componentStructure.ts
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
export const toStringArray = (input: unknown): string[] | undefined => {
if (!Array.isArray(input)) {
return undefined
}
const parsed = input
.map((value) => {
if (typeof value === 'string') {
return value.trim()
}
if (value === null || value === undefined) {
return ''
}
return String(value).trim()
})
.filter((value) => value.length > 0)
return parsed.length ? parsed : undefined
}
export const extractFieldValueObject = (field: any): Record<string, any> => {
if (isPlainObject(field?.value)) {
return field.value as Record<string, any>
}
return {}
}
export const sanitizeCustomFields = (fields: any[]): ComponentModelCustomField[] => {
if (!Array.isArray(fields)) {
return []
}
return fields
.map((field, index) => {
const rawName =
typeof field?.name === 'string'
? field.name
: typeof field?.key === 'string'
? field.key
: ''
const name = rawName.trim()
if (!name) {
return null
}
const valueObject = extractFieldValueObject(field)
const candidateType =
typeof field?.type === 'string' && field.type
? field.type
: typeof valueObject?.type === 'string'
? valueObject.type
: ''
const allowedTypes: ComponentModelCustomFieldType[] = ['text', 'number', 'select', 'boolean', 'date']
const type = allowedTypes.includes(candidateType as ComponentModelCustomFieldType)
? (candidateType as ComponentModelCustomFieldType)
: 'text'
const required =
typeof valueObject?.required === 'boolean' ? valueObject.required : !!field?.required
let options: string[] | undefined
if (type === 'select') {
options =
toStringArray(valueObject?.options) ||
toStringArray((valueObject as any)?.choices) ||
toStringArray(field?.options)
if (!options && typeof field?.optionsText === 'string') {
const parsedFromText = field.optionsText
.split(/\r?\n/)
.map((option: string) => option.trim())
.filter((option: string) => option.length > 0)
options = parsedFromText.length ? parsedFromText : undefined
}
}
const result: ComponentModelCustomField = { name, type, required }
if (options) {
result.options = options
}
const defaultCandidate =
field?.defaultValue ?? valueObject?.defaultValue ?? field?.value ?? field?.default ?? null
const resolvedDefault = (() => {
if (defaultCandidate === undefined || defaultCandidate === null) {
return undefined
}
if (typeof defaultCandidate === 'object') {
if (defaultCandidate === null) {
return undefined
}
if ('defaultValue' in (defaultCandidate as Record<string, any>)) {
return (defaultCandidate as Record<string, any>).defaultValue
}
if ('value' in (defaultCandidate as Record<string, any>)) {
return (defaultCandidate as Record<string, any>).value
}
return undefined
}
return defaultCandidate
})()
if (resolvedDefault !== undefined && resolvedDefault !== null && resolvedDefault !== '') {
result.defaultValue = String(resolvedDefault)
}
const id = typeof field?.id === 'string' ? field.id : undefined
if (id) {
result.id = id
}
const customFieldId = typeof field?.customFieldId === 'string' ? field.customFieldId : undefined
if (customFieldId) {
result.customFieldId = customFieldId
}
const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index
result.orderIndex = orderIndex
return result
})
.filter((field): field is ComponentModelCustomField => !!field)
}
export const sanitizePieces = (pieces: any[]): ComponentModelPiece[] => {
if (!Array.isArray(pieces)) {
return []
}
return pieces
.map((piece) => {
const rawTypePieceId = typeof piece?.typePieceId === 'string'
? piece.typePieceId.trim()
: typeof piece?.typePiece?.id === 'string'
? piece.typePiece.id.trim()
: ''
const typePieceId = rawTypePieceId.length > 0 ? rawTypePieceId : undefined
const rawTypePieceLabel = typeof piece?.typePieceLabel === 'string'
? piece.typePieceLabel.trim()
: typeof piece?.typePiece?.name === 'string'
? piece.typePiece.name.trim()
: ''
const typePieceLabel = rawTypePieceLabel.length > 0 ? rawTypePieceLabel : undefined
const reference = typeof piece?.reference === 'string' && piece.reference.trim().length > 0
? piece.reference.trim()
: undefined
const rawFamilyCode = typeof piece?.familyCode === 'string'
? piece.familyCode.trim()
: typeof piece?.typePiece?.code === 'string'
? piece.typePiece.code.trim()
: ''
const familyCode = rawFamilyCode.length > 0 ? rawFamilyCode : undefined
const rawRole = typeof piece?.role === 'string' ? piece.role.trim() : ''
const role = rawRole.length > 0 ? rawRole : undefined
if (!typePieceId && !typePieceLabel && !reference && !familyCode) {
return null
}
const result: ComponentModelPiece = {}
if (role) {
result.role = role
}
if (familyCode) {
result.familyCode = familyCode
}
if (reference !== undefined) {
result.reference = reference
}
if (typePieceId) {
result.typePieceId = typePieceId
}
if (typePieceLabel) {
result.typePieceLabel = typePieceLabel
}
return result
})
.filter((piece): piece is ComponentModelPiece => !!piece)
}
export const sanitizeProducts = (products: any[]): ComponentModelProduct[] => {
if (!Array.isArray(products)) {
return []
}
return products
.map((product) => {
const rawTypeProductId = typeof product?.typeProductId === 'string'
? product.typeProductId.trim()
: typeof product?.typeProduct?.id === 'string'
? product.typeProduct.id.trim()
: ''
const typeProductId = rawTypeProductId.length > 0 ? rawTypeProductId : undefined
const rawTypeProductLabel = typeof product?.typeProductLabel === 'string'
? product.typeProductLabel.trim()
: typeof product?.typeProduct?.name === 'string'
? product.typeProduct.name.trim()
: ''
const typeProductLabel = rawTypeProductLabel.length > 0 ? rawTypeProductLabel : undefined
const reference = typeof product?.reference === 'string' && product.reference.trim().length > 0
? product.reference.trim()
: undefined
const rawFamilyCode = typeof product?.familyCode === 'string'
? product.familyCode.trim()
: typeof product?.typeProduct?.code === 'string'
? product.typeProduct.code.trim()
: ''
const familyCode = rawFamilyCode.length > 0 ? rawFamilyCode : undefined
const rawRole = typeof product?.role === 'string' ? product.role.trim() : ''
const role = rawRole.length > 0 ? rawRole : undefined
if (!typeProductId && !typeProductLabel && !reference && !familyCode) {
return null
}
const result: ComponentModelProduct = {}
if (role) {
result.role = role
}
if (familyCode) {
result.familyCode = familyCode
}
if (reference !== undefined) {
result.reference = reference
}
if (typeProductId) {
result.typeProductId = typeProductId
}
if (typeProductLabel) {
result.typeProductLabel = typeProductLabel
}
return result
})
.filter((product): product is ComponentModelProduct => !!product)
}
export const sanitizeSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
if (!Array.isArray(components)) {
return []
}
return components
.map((component) => {
const rawTypeComposantId = typeof component?.typeComposantId === 'string'
? component.typeComposantId.trim()
: typeof component?.typeComposant?.id === 'string'
? component.typeComposant.id.trim()
: ''
const typeComposantId = rawTypeComposantId.length > 0 ? rawTypeComposantId : undefined
const modelId = typeof component?.modelId === 'string' && component.modelId.trim().length > 0
? component.modelId.trim()
: undefined
const familyCode = typeof component?.familyCode === 'string' && component.familyCode.trim().length > 0
? component.familyCode.trim()
: undefined
const alias = typeof component?.alias === 'string' && component.alias.trim().length > 0
? component.alias.trim()
: undefined
if (!typeComposantId && !modelId && !familyCode) {
return null
}
const result: ComponentModelStructureNode = {
subcomponents: sanitizeSubcomponents(
Array.isArray(component?.subcomponents)
? component.subcomponents
: component?.subComponents,
),
}
if (typeComposantId) {
result.typeComposantId = typeComposantId
}
const typeComposantLabel = typeof component?.typeComposantLabel === 'string'
? component.typeComposantLabel.trim()
: typeof component?.typeComposant?.name === 'string'
? component.typeComposant.name.trim()
: ''
if (typeComposantLabel) {
result.typeComposantLabel = typeComposantLabel
}
if (modelId) {
result.modelId = modelId
}
if (familyCode) {
result.familyCode = familyCode
}
if (alias) {
result.alias = alias
}
return result
})
.filter((component): component is ComponentModelStructureNode => !!component)
}

View File

@@ -0,0 +1,87 @@
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
/**
* Selects the best document for thumbnail preview from an entity's documents array.
* Default priority: PDF first, then images. Use `preferImages` to reverse.
*/
export const resolvePrimaryDocument = (entity: Record<string, any>, preferImages = false): any | null => {
const documents = Array.isArray(entity?.documents) ? entity.documents : []
if (!documents.length) return null
const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
if (!withPath.length) return normalized[0] ?? null
const first = preferImages ? isImageDocument : isPdfDocument
const second = preferImages ? isPdfDocument : isImageDocument
const a = withPath.find((doc: any) => first(doc))
if (a) return a
const b = withPath.find((doc: any) => second(doc))
if (b) return b
return withPath[0]
}
/**
* Builds alt text for a document preview thumbnail.
*/
export const resolvePreviewAlt = (entity: Record<string, any>): string => {
const parts = [entity?.name, entity?.reference].filter(Boolean)
return parts.length ? `Aperçu du document de ${parts.join(' ')}` : 'Aperçu du document'
}
/**
* Supplier name resolution: extracts unique supplier names from entity relations.
*/
export const resolveSupplierNames = (entity: Record<string, any>, nestedKey?: string): string[] => {
const names: string[] = []
const seen = new Set<string>()
const pushName = (maybeName: unknown) => {
if (typeof maybeName !== 'string') return
const normalized = maybeName.trim().replace(/\s+/g, ' ')
if (!normalized.length) return
const key = normalized.toLowerCase()
if (seen.has(key)) return
seen.add(key)
names.push(normalized)
}
const collectConstructeurs = (value: unknown): void => {
if (!value) return
if (Array.isArray(value)) { value.forEach(collectConstructeurs); return }
if (typeof value === 'string') { pushName(value); return }
if (typeof value === 'object') {
const record = value as Record<string, any>
pushName(record?.name ?? record?.label ?? record?.companyName ?? record?.company ?? null)
if (record?.constructeur) collectConstructeurs(record.constructeur)
if (Array.isArray(record?.constructeurs)) collectConstructeurs(record.constructeurs)
}
}
const collectFromLabel = (value: unknown): void => {
if (typeof value !== 'string') return
value.split(/[,;\\/•·|]+/).map(part => part.trim()).filter(Boolean).forEach(pushName)
}
collectConstructeurs(entity?.constructeurs)
collectConstructeurs(entity?.constructeur)
collectFromLabel(entity?.constructeursLabel)
collectFromLabel(entity?.supplierLabel)
collectFromLabel(entity?.suppliers)
if (nestedKey && entity?.[nestedKey]) {
const nested = entity[nestedKey]
collectConstructeurs(nested?.constructeurs)
collectConstructeurs(nested?.constructeur)
collectFromLabel(nested?.constructeursLabel)
collectFromLabel(nested?.supplierLabel)
}
return names
}
const MAX_VISIBLE_SUPPLIERS = 3
export const buildSuppliersDisplay = (suppliers: string[]) => {
const visible = suppliers.slice(0, MAX_VISIBLE_SUPPLIERS)
const overflow = Math.max(suppliers.length - visible.length, 0)
return { suppliers, visible, overflow, tooltip: suppliers.length ? suppliers.join(', ') : '' }
}

View File

@@ -0,0 +1,19 @@
export const resolveDeleteImpact = (entity: Record<string, any>): string[] => {
const impacts: string[] = []
const machineLinks = Array.isArray(entity?.machineLinks) ? entity.machineLinks.length : entity?.machineLinksCount ?? 0
const documents = Array.isArray(entity?.documents) ? entity.documents.length : entity?.documentsCount ?? 0
const customFields = Array.isArray(entity?.customFieldValues) ? entity.customFieldValues.length : entity?.customFieldValuesCount ?? 0
if (machineLinks > 0) impacts.push(`${machineLinks} liaison${machineLinks > 1 ? 's' : ''} machine`)
if (documents > 0) impacts.push(`${documents} document${documents > 1 ? 's' : ''}`)
if (customFields > 0) impacts.push(`${customFields} valeur${customFields > 1 ? 's' : ''} de champs personnalisés`)
return impacts
}
export const buildDeleteMessage = (entityName: string, impacts: string[]): string => {
const lines = [`Voulez-vous vraiment supprimer « ${entityName} » ?`]
if (impacts.length) {
lines.push(`Cela supprimera également :\n• ${impacts.join('\n• ')}`)
}
lines.push('Cette action est irréversible.')
return lines.join('\n\n')
}

View File

@@ -0,0 +1,104 @@
import type { PieceModelProduct, PieceModelStructure } from '~/shared/types/inventory'
/**
* Extract the products array from a piece model structure, defaulting to [].
*/
export const getStructureProducts = (structure: PieceModelStructure | null): PieceModelProduct[] =>
Array.isArray(structure?.products) ? structure.products : []
/**
* Build a human-readable label for a single product requirement.
*/
export const describeProductRequirement = (requirement: PieceModelProduct, index: number): string => {
if (!requirement) {
return `Produit ${index + 1}`
}
const parts: string[] = []
if (requirement.role) {
parts.push(requirement.role)
}
if (requirement.typeProductLabel) {
parts.push(requirement.typeProductLabel)
} else if (requirement.typeProductId) {
parts.push(`Catégorie #${requirement.typeProductId}`)
}
if (requirement.familyCode) {
parts.push(`Famille ${requirement.familyCode}`)
}
if (parts.length === 0) {
parts.push(`Produit ${index + 1}`)
}
return parts.join(' • ')
}
/**
* Build description strings for every product requirement in a structure.
*/
export const buildProductRequirementDescriptions = (
products: PieceModelProduct[],
): string[] =>
products.map((requirement, index) => describeProductRequirement(requirement, index))
/**
* Build the entry objects used to render product selection inputs.
*/
export const buildProductRequirementEntries = (
products: PieceModelProduct[],
keyPrefix: string,
) =>
products.map((requirement, index) => ({
index,
key: `${keyPrefix}-${index}-${requirement?.typeProductId || 'any'}`,
label: describeProductRequirement(requirement, index),
typeProductId: requirement?.typeProductId ? String(requirement.typeProductId) : null,
}))
/**
* Resize the selections array to match the expected count, preserving existing values.
*/
export const resizeProductSelections = (
current: (string | null)[],
count: number,
): (string | null)[] =>
Array.from({ length: count }, (_, index) => current[index] ?? null)
/**
* Return true when all required product slots have a non-empty string value,
* or when no product selection is required.
*/
export const areProductSelectionsFilled = (
requiresSelection: boolean,
entries: { index: number }[],
selections: (string | null)[],
): boolean =>
!requiresSelection ||
entries.every((entry) => {
const value = selections[entry.index]
return typeof value === 'string' && value.trim().length > 0
})
/**
* Set a single product selection by index, returning a new array.
*/
export const applyProductSelection = (
current: (string | null)[],
index: number,
value: string | null,
): (string | null)[] => {
const normalized = typeof value === 'string' ? value : null
const next = [...current]
next[index] = normalized
return next
}
/**
* Extract normalized product IDs from the current selections based on requirement entries.
*/
export const collectNormalizedProductIds = (
entries: { index: number }[],
selections: (string | null)[],
): string[] =>
entries
.map((entry) => selections[entry.index])
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())

View File

@@ -0,0 +1,257 @@
/**
* Pure helper functions for building, validating and serializing
* component structure assignment trees.
*
* Extracted from useComponentCreate composable to keep file sizes manageable.
*/
import type { StructureAssignmentNode } from '~/components/ComponentStructureAssignmentNode.vue'
import type {
ComponentModelPiece,
ComponentModelProduct,
ComponentModelStructure,
ComponentModelStructureNode,
} from '~/shared/types/inventory'
// ---------------------------------------------------------------------------
// Extraction helpers
// ---------------------------------------------------------------------------
export function extractSubcomponents(
definition: ComponentModelStructure | ComponentModelStructureNode | null | undefined,
): ComponentModelStructureNode[] {
if (!definition || typeof definition !== 'object') {
return []
}
const raw = Array.isArray((definition as any).subcomponents)
? (definition as any).subcomponents
: Array.isArray((definition as any).subComponents)
? (definition as any).subComponents
: []
return raw.filter(
(item: unknown): item is ComponentModelStructureNode =>
!!item && typeof item === 'object',
)
}
export function extractPiecesFromNode(
definition: ComponentModelStructure | ComponentModelStructureNode | null | undefined,
): ComponentModelPiece[] {
if (!definition || typeof definition !== 'object') {
return []
}
const raw = Array.isArray((definition as any).pieces)
? (definition as any).pieces
: []
return raw.filter(
(item: unknown): item is ComponentModelPiece =>
!!item && typeof item === 'object',
)
}
export function extractProductsFromNode(
definition: ComponentModelStructure | ComponentModelStructureNode | null | undefined,
): ComponentModelProduct[] {
if (!definition || typeof definition !== 'object') {
return []
}
const raw = Array.isArray((definition as any).products)
? (definition as any).products
: []
return raw.filter(
(item: unknown): item is ComponentModelProduct =>
!!item && typeof item === 'object',
)
}
// ---------------------------------------------------------------------------
// Assignment tree building
// ---------------------------------------------------------------------------
export function buildAssignmentNode(
definition: ComponentModelStructureNode | ComponentModelStructure,
path: string,
): StructureAssignmentNode {
const pieces = extractPiecesFromNode(definition).map((piece, index) => ({
path: `${path}:piece-${index}`,
definition: piece,
selectedPieceId: '',
}))
const products = extractProductsFromNode(definition).map((product, index) => ({
path: `${path}:product-${index}`,
definition: product,
selectedProductId: '',
}))
const subcomponents = extractSubcomponents(definition).map(
(child, index) => buildAssignmentNode(child, `${path}:sub-${index}`),
)
return {
path,
definition,
selectedComponentId: '',
pieces,
products,
subcomponents,
}
}
export function initializeStructureAssignments(
structure: ComponentModelStructure | null,
): StructureAssignmentNode | null {
if (!structure || typeof structure !== 'object') {
return null
}
return buildAssignmentNode(structure, 'root')
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
export function hasAssignments(node: StructureAssignmentNode | null): boolean {
if (!node) {
return false
}
if (node.pieces.length > 0 || node.products.length > 0 || node.subcomponents.length > 0) {
return true
}
return node.subcomponents.some((child) => hasAssignments(child))
}
export function isAssignmentNodeComplete(
node: StructureAssignmentNode,
isRootNode = false,
): boolean {
const piecesComplete = node.pieces.every(
(piece) => !!piece.selectedPieceId && piece.selectedPieceId.length > 0,
)
const productsComplete = node.products.every(
(product) => !!product.selectedProductId && product.selectedProductId.length > 0,
)
const subcomponentsComplete = node.subcomponents.every(
(child) =>
!!child.selectedComponentId
&& child.selectedComponentId.length > 0
&& isAssignmentNodeComplete(child, false),
)
return (
piecesComplete
&& productsComplete
&& subcomponentsComplete
&& (isRootNode || !!node.selectedComponentId)
)
}
// ---------------------------------------------------------------------------
// Serialization
// ---------------------------------------------------------------------------
export function stripNullish(input: Record<string, any>) {
return Object.fromEntries(
Object.entries(input).filter(
([, value]) => value !== null && value !== undefined && value !== '',
),
)
}
export function sanitizeStructureDefinition(
definition: ComponentModelStructureNode,
) {
return stripNullish({
alias: definition.alias ?? null,
typeComposantId: definition.typeComposantId ?? null,
typeComposantLabel: definition.typeComposantLabel ?? null,
modelId: definition.modelId ?? null,
familyCode: (definition as any).familyCode ?? null,
})
}
export function sanitizePieceDefinition(definition: ComponentModelPiece) {
return stripNullish({
role: (definition as any).role ?? null,
typePieceId: definition.typePieceId ?? null,
typePieceLabel: definition.typePieceLabel ?? null,
reference: definition.reference ?? null,
familyCode: (definition as any).familyCode ?? null,
})
}
export function sanitizeProductDefinition(definition: ComponentModelProduct) {
return stripNullish({
role: (definition as any).role ?? null,
typeProductId: definition.typeProductId ?? null,
typeProductLabel: (definition as any).typeProductLabel ?? null,
reference: (definition as any).reference ?? null,
familyCode: (definition as any).familyCode ?? null,
})
}
export function serializeStructureAssignments(
root: StructureAssignmentNode | null,
) {
if (!root) {
return null
}
const serializeNode = (
assignment: StructureAssignmentNode,
isRootNode = false,
): Record<string, any> => {
const serializedPieces = assignment.pieces
.filter((piece) => !!piece.selectedPieceId)
.map((piece) =>
stripNullish({
path: piece.path,
definition: sanitizePieceDefinition(piece.definition),
selectedPieceId: piece.selectedPieceId,
}),
)
const serializedProducts = assignment.products
.filter((product) => !!product.selectedProductId)
.map((product) =>
stripNullish({
path: product.path,
definition: sanitizeProductDefinition(product.definition),
selectedProductId: product.selectedProductId,
}),
)
const serializedSubcomponents = assignment.subcomponents
.map((child) => serializeNode(child, false))
.filter((child) => Object.keys(child).length > 0)
const base: Record<string, any> = {
path: assignment.path,
definition: sanitizeStructureDefinition(assignment.definition),
}
if (!isRootNode) {
base.selectedComponentId = assignment.selectedComponentId
}
if (serializedPieces.length) {
base.pieces = serializedPieces
}
if (serializedProducts.length) {
base.products = serializedProducts
}
if (serializedSubcomponents.length) {
base.subcomponents = serializedSubcomponents
}
return stripNullish(base)
}
const serializedRoot = serializeNode(root, true)
if (
(!serializedRoot.pieces || serializedRoot.pieces.length === 0)
&& (!serializedRoot.products || serializedRoot.products.length === 0)
&& (!serializedRoot.subcomponents || serializedRoot.subcomponents.length === 0)
) {
return null
}
return serializedRoot
}

View File

@@ -0,0 +1,259 @@
/**
* Type definitions and pure label/description helpers for structure assignments.
*
* Extracted from composables/useStructureAssignmentFetch.ts to keep files
* under 500 lines. These are stateless utilities that do not depend on Vue
* reactivity or API fetching.
*/
import type {
ComponentModelPiece,
ComponentModelProduct,
ComponentModelStructureNode,
} from '~/shared/types/inventory'
// ---------------------------------------------------------------------------
// Option types
// ---------------------------------------------------------------------------
export interface ComponentOption {
id: string
name?: string | null
reference?: string | null
typeComposantId?: string | null
typeComposant?: {
id: string
name?: string | null
code?: string | null
} | null
}
export interface PieceOption {
id: string
name?: string | null
reference?: string | null
typePieceId?: string | null
typePiece?: {
id: string
name?: string | null
code?: string | null
} | null
}
export interface ProductOption {
id: string
name?: string | null
reference?: string | null
typeProductId?: string | null
typeProduct?: {
id: string
name?: string | null
code?: string | null
} | null
}
// ---------------------------------------------------------------------------
// Assignment node types
// ---------------------------------------------------------------------------
export interface StructurePieceAssignment {
path: string
definition: ComponentModelPiece
selectedPieceId: string
}
export interface StructureProductAssignment {
path: string
definition: ComponentModelProduct
selectedProductId: string
}
export interface StructureAssignmentNode {
path: string
definition: ComponentModelStructureNode
selectedComponentId: string
pieces: StructurePieceAssignment[]
products: StructureProductAssignment[]
subcomponents: StructureAssignmentNode[]
}
// ---------------------------------------------------------------------------
// Component label helpers
// ---------------------------------------------------------------------------
export const componentOptionLabel = (component?: ComponentOption | null): string => {
if (!component) {
return 'Composant sans nom'
}
return component.name || 'Composant sans nom'
}
export const componentOptionDescription = (component?: ComponentOption | null): string => {
if (!component) {
return ''
}
const parts: string[] = []
const typeLabel =
component.typeComposant?.name || component.typeComposant?.code || null
if (typeLabel) {
parts.push(typeLabel)
}
if (component.reference) {
parts.push(`Ref. ${component.reference}`)
}
return parts.join(' \u2022 ')
}
// ---------------------------------------------------------------------------
// Piece label helpers
// ---------------------------------------------------------------------------
export const pieceOptionLabel = (piece?: PieceOption | null): string => {
if (!piece) {
return 'Pi\u00e8ce'
}
return piece.name || 'Pi\u00e8ce'
}
export const pieceOptionDescription = (piece?: PieceOption | null): string => {
if (!piece) {
return ''
}
const parts: string[] = []
const typeLabel =
piece.typePiece?.name || piece.typePiece?.code || null
if (typeLabel) {
parts.push(typeLabel)
}
if (piece.reference) {
parts.push(`Ref. ${piece.reference}`)
}
return parts.join(' \u2022 ')
}
// ---------------------------------------------------------------------------
// Product label helpers
// ---------------------------------------------------------------------------
export const productOptionLabel = (product?: ProductOption | null): string => {
if (!product) {
return 'Produit'
}
return product.name || product.reference || 'Produit'
}
export const productOptionDescription = (product?: ProductOption | null): string => {
if (!product) {
return ''
}
const parts: string[] = []
const typeLabel =
product.typeProduct?.name || product.typeProduct?.code || null
if (typeLabel) {
parts.push(typeLabel)
}
if (product.reference) {
parts.push(`Ref. ${product.reference}`)
}
return parts.join(' \u2022 ')
}
// ---------------------------------------------------------------------------
// Requirement description helpers
// ---------------------------------------------------------------------------
export const describePieceRequirement = (
assignment: StructurePieceAssignment,
options: PieceOption[],
pieceTypeLabelMap: Record<string, string>,
): string => {
const definition = assignment.definition
const parts: string[] = []
const addPart = (value?: string | null) => {
const trimmed = typeof value === 'string' ? value.trim() : ''
if (trimmed && !parts.includes(trimmed)) {
parts.push(trimmed)
}
}
const fallbackPiece = options[0] || null
const fallbackType = fallbackPiece?.typePiece || null
addPart(definition.role)
const explicitLabel =
definition.typePieceLabel
|| definition.typePiece?.name
|| (definition.typePieceId ? pieceTypeLabelMap[definition.typePieceId] : null)
|| fallbackType?.name
addPart(explicitLabel)
const family =
definition.familyCode
|| definition.typePiece?.code
|| fallbackType?.code
|| null
if (family) {
addPart(`Famille ${family}`)
}
if (parts.length === 0) {
addPart(fallbackType?.name)
if (fallbackType?.code) {
addPart(`Famille ${fallbackType.code}`)
}
}
if (parts.length === 0 && definition.typePieceId) {
addPart(`#${definition.typePieceId}`)
}
return parts.length ? parts.join(' \u2022 ') : 'Pi\u00e8ce du squelette'
}
export const describeProductRequirement = (
assignment: StructureProductAssignment,
options: ProductOption[],
productTypeLabelMap: Record<string, string>,
): string => {
const definition = assignment.definition
const parts: string[] = []
const addPart = (value?: string | null) => {
const trimmed = typeof value === 'string' ? value.trim() : ''
if (trimmed && !parts.includes(trimmed)) {
parts.push(trimmed)
}
}
const fallbackProduct = options[0] || null
const fallbackType = fallbackProduct?.typeProduct || null
addPart(definition.role)
const explicitLabel =
definition.typeProductLabel
|| definition.typeProduct?.name
|| (definition.typeProductId ? productTypeLabelMap[definition.typeProductId] : null)
|| fallbackType?.name
addPart(explicitLabel)
const family =
definition.familyCode
|| definition.typeProduct?.code
|| fallbackType?.code
|| null
if (family) {
addPart(`Famille ${family}`)
}
if (parts.length === 0) {
addPart(fallbackType?.name)
if (fallbackType?.code) {
addPart(`Famille ${fallbackType.code}`)
}
}
if (parts.length === 0 && definition.typeProductId) {
addPart(`#${definition.typeProductId}`)
}
return parts.length ? parts.join(' \u2022 ') : 'Produit du squelette'
}

View File

@@ -0,0 +1,157 @@
/**
* Shared helpers for displaying component/machine structure skeleton details.
*
* Extracted from pages/component/create.vue and pages/component/[id]/edit.vue
* where these functions were duplicated verbatim.
*/
// ---------------------------------------------------------------------------
// Structure accessors
// ---------------------------------------------------------------------------
type StructureLike = Record<string, any> | null
export const getStructureCustomFields = (structure: StructureLike): any[] => {
return Array.isArray(structure?.customFields) ? structure.customFields : []
}
export const getStructurePieces = (structure: StructureLike): any[] => {
return Array.isArray(structure?.pieces) ? structure.pieces : []
}
export const getStructureProducts = (structure: StructureLike): any[] => {
return Array.isArray(structure?.products) ? structure.products : []
}
export const getStructureSubcomponents = (structure: StructureLike): any[] => {
if (Array.isArray(structure?.subcomponents)) {
return structure.subcomponents
}
const legacy = (structure as any)?.subComponents
return Array.isArray(legacy) ? legacy : []
}
// ---------------------------------------------------------------------------
// Label resolvers
// ---------------------------------------------------------------------------
export const resolvePieceLabel = (
piece: Record<string, any>,
labelMap: Record<string, string> = {},
): string => {
const parts: string[] = []
if (piece.role) {
parts.push(piece.role)
}
if (piece.typePiece?.name) {
parts.push(piece.typePiece.name)
} else if (piece.typePieceLabel) {
parts.push(piece.typePieceLabel)
} else if (piece.typePieceId && labelMap[piece.typePieceId]) {
parts.push(labelMap[piece.typePieceId]!)
} else if (piece.typePiece?.code) {
parts.push(`Famille ${piece.typePiece.code}`)
} else if (piece.familyCode) {
parts.push(`Famille ${piece.familyCode}`)
} else if (piece.typePieceId) {
parts.push(`#${piece.typePieceId}`)
}
return parts.length ? parts.join(' • ') : 'Pièce'
}
export const resolveProductLabel = (
product: Record<string, any>,
labelMap: Record<string, string> = {},
): string => {
const parts: string[] = []
if (product.role) {
parts.push(product.role)
}
if (product.typeProduct?.name) {
parts.push(product.typeProduct.name)
} else if (product.typeProductLabel) {
parts.push(product.typeProductLabel)
} else if (product.typeProductId && labelMap[product.typeProductId]) {
parts.push(labelMap[product.typeProductId]!)
} else if (product.typeProduct?.code) {
parts.push(`Catégorie ${product.typeProduct.code}`)
} else if (product.familyCode) {
parts.push(`Catégorie ${product.familyCode}`)
} else if (product.typeProductId) {
parts.push(`#${product.typeProductId}`)
}
return parts.length ? parts.join(' • ') : 'Produit'
}
export const resolveSubcomponentLabel = (node: Record<string, any>): string => {
const parts: string[] = []
if (node.alias) {
parts.push(node.alias)
}
if (node.typeComposant?.name) {
parts.push(node.typeComposant.name)
} else if (node.typeComposantLabel) {
parts.push(node.typeComposantLabel)
} else if (node.familyCode) {
parts.push(node.familyCode)
} else if (node.typeComposantId) {
parts.push(`#${node.typeComposantId}`)
}
const childCount = Array.isArray(node.subcomponents)
? node.subcomponents.length
: Array.isArray(node.subComponents)
? node.subComponents.length
: 0
if (childCount) {
parts.push(`${childCount} sous-composant(s)`)
}
return parts.length ? parts.join(' • ') : 'Sous-composant'
}
// ---------------------------------------------------------------------------
// Generic model type name fetcher (replaces fetchPieceTypeNames / fetchProductTypeNames)
// ---------------------------------------------------------------------------
export const fetchModelTypeNames = async (
ids: string[],
existingMap: Record<string, string>,
get: (url: string) => Promise<{ success?: boolean; data?: any }>,
): Promise<Record<string, string>> => {
const missing = ids.filter((id) => id && !existingMap[id])
if (!missing.length) {
return {}
}
const results = await Promise.allSettled(
missing.map((id) => get(`/model_types/${id}`)),
)
const additions: Record<string, string> = {}
results.forEach((result, index) => {
const key = missing[index]
if (!key || result.status !== 'fulfilled') {
return
}
const data = result.value?.data
const name = data?.name || data?.code
if (name) {
additions[key] = name
}
})
return additions
}
// ---------------------------------------------------------------------------
// Type label map builder
// ---------------------------------------------------------------------------
export const buildTypeLabelMap = (
types: any[],
fetchedOverrides: Record<string, string> = {},
): Record<string, string> => ({
...Object.fromEntries(
(types || [])
.filter((type: any) => type?.id)
.map((type: any) => [type.id, type.name || type.code || '']),
),
...fetchedOverrides,
})

View File

@@ -0,0 +1,107 @@
export type SelectionEntry = {
id: string
path: string
requirementLabel: string
resolvedName: string
}
export type StructureSelectionResult = {
pieces: SelectionEntry[]
products: SelectionEntry[]
components: SelectionEntry[]
}
type CatalogMap = Map<string, { name?: string, [key: string]: any }>
type LabelResolvers = {
resolvePieceLabel: (definition: Record<string, any>) => string
resolveProductLabel: (definition: Record<string, any>) => string
resolveSubcomponentLabel: (definition: Record<string, any>) => string
}
const isNonEmptyString = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0
export function collectStructureSelections(
root: any,
catalogs: {
pieceCatalogMap: CatalogMap
productCatalogMap: CatalogMap
componentCatalogMap: CatalogMap
},
resolvers: LabelResolvers,
): StructureSelectionResult {
const piecesSelected: SelectionEntry[] = []
const productsSelected: SelectionEntry[] = []
const componentsSelected: SelectionEntry[] = []
if (!root || typeof root !== 'object') {
return { pieces: piecesSelected, products: productsSelected, components: componentsSelected }
}
const visitNode = (node: any, fallbackPath = 'racine') => {
if (!node || typeof node !== 'object') {
return
}
const nodePath = isNonEmptyString(node.path) ? node.path : fallbackPath
const nodePieces = Array.isArray(node.pieces) ? node.pieces : []
nodePieces.forEach((entry: any, index: number) => {
const selectedId = entry?.selectedPieceId
if (!isNonEmptyString(selectedId)) {
return
}
const definition = entry?.definition ?? entry
const catalogPiece = catalogs.pieceCatalogMap.get(selectedId)
piecesSelected.push({
id: selectedId,
path: isNonEmptyString(entry?.path) ? entry.path : `${nodePath}:piece-${index + 1}`,
requirementLabel: resolvers.resolvePieceLabel(definition),
resolvedName: catalogPiece?.name || selectedId,
})
})
const nodeProducts = Array.isArray(node.products) ? node.products : []
nodeProducts.forEach((entry: any, index: number) => {
const selectedId = entry?.selectedProductId
if (!isNonEmptyString(selectedId)) {
return
}
const definition = entry?.definition ?? entry
const catalogProduct = catalogs.productCatalogMap.get(selectedId)
productsSelected.push({
id: selectedId,
path: isNonEmptyString(entry?.path) ? entry.path : `${nodePath}:product-${index + 1}`,
requirementLabel: resolvers.resolveProductLabel(definition),
resolvedName: catalogProduct?.name || selectedId,
})
})
const nodeChildren = Array.isArray(node.subcomponents)
? node.subcomponents
: Array.isArray(node.subComponents)
? node.subComponents
: []
nodeChildren.forEach((child: any, index: number) => {
const selectedId = child?.selectedComponentId
if (isNonEmptyString(selectedId)) {
const definition = child?.definition ?? child
const catalogComponent = catalogs.componentCatalogMap.get(selectedId)
componentsSelected.push({
id: selectedId,
path: isNonEmptyString(child?.path) ? child.path : `${nodePath}:subcomponent-${index + 1}`,
requirementLabel: resolvers.resolveSubcomponentLabel(definition),
resolvedName: catalogComponent?.name || selectedId,
})
}
visitNode(child, isNonEmptyString(child?.path) ? child.path : `${nodePath}:subcomponent-${index + 1}`)
})
}
visitNode(root, isNonEmptyString(root?.path) ? root.path : 'racine')
return { pieces: piecesSelected, products: productsSelected, components: componentsSelected }
}

View File

@@ -2,6 +2,12 @@
* Formatte une date en respectant les conventions françaises (jj/mm/aaaa).
* Retourne "—" si la valeur est invalide ou absente.
*/
const frenchDateFormatter = new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
export const formatFrenchDate = (value: Date | string | number | null | undefined): string => {
if (value === null || value === undefined || value === '') {
return '—'
@@ -12,9 +18,5 @@ export const formatFrenchDate = (value: Date | string | number | null | undefine
return '—'
}
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(date)
return frenchDateFormatter.format(date)
}

View File

@@ -0,0 +1,647 @@
# Reduce Frontend Files Under 500 Lines — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Reduce all 14 frontend files currently over 500 lines to under 500 lines each, without changing any functionality.
**Architecture:** Extract shared UI sections into reusable components, split large composables/utilities into focused modules, and extract page-level script logic into dedicated composables. Each extraction is a pure refactor — no behavior changes.
**Tech Stack:** Vue 3 Composition API, TypeScript, Nuxt 4 (auto-imports for composables and components)
---
## Inventory of files to reduce
| # | File | Lines | Target strategy |
|---|------|------:|-----------------|
| 1 | `composables/useMachineDetailData.ts` | 1353 | Split into 4 focused composables |
| 2 | `components/StructureNodeEditor.vue` | 926 | Extract type-map + sync logic into composable |
| 3 | `pages/component/[id]/edit.vue` | 911 | Extract shared component + composable |
| 4 | `pages/component/create.vue` | 852 | Extract structure assignment helpers |
| 5 | `pages/pieces/[id]/edit.vue` | 821 | Extract page composable |
| 6 | `shared/model/componentStructure.ts` | 794 | Split into 3 focused modules |
| 7 | `components/PieceItem.vue` | 757 | Extract document list + custom fields template |
| 8 | `components/ComponentStructureAssignmentNode.vue` | 722 | Extract fetch/options logic |
| 9 | `pages/index.vue` | 584 | Extract modal components |
| 10 | `components/PieceModelStructureEditor.vue` | 578 | Extract drag-reorder + field logic |
| 11 | `components/model-types/ManagementView.vue` | 577 | Extract related-items modal |
| 12 | `components/ComponentItem.vue` | 573 | Extract document list template |
| 13 | `pages/product/[id]/edit.vue` | 570 | Extract page composable |
| 14 | `pages/pieces/create.vue` | 540 | Extract product-selection logic |
## Shared extractions (do these FIRST — they reduce multiple files)
### Task 1: Extract `DocumentListInline.vue` shared component
**Rationale:** The document list display (thumbnail + name + mimeType + size + Consulter/Télécharger/Supprimer buttons) is duplicated identically in:
- `PieceItem.vue` (lines 401-477)
- `ComponentItem.vue` (lines 312-379)
- `pages/component/[id]/edit.vue` (lines 307-375)
- `pages/pieces/[id]/edit.vue` (lines 254-325)
- `pages/product/[id]/edit.vue` (lines 165-232)
**Files:**
- Create: `app/components/common/DocumentListInline.vue`
- Modify: all 5 files above
**Step 1: Create `DocumentListInline.vue`**
```vue
<template>
<div v-if="documents.length" class="space-y-2">
<div
v-for="document in documents"
:key="document.id || document.path || document.name"
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex items-center gap-3 text-sm">
<div
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center"
:class="documentThumbnailClass(document)"
>
<img
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-6 w-6"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<div>
<div class="font-medium">{{ document.name }}</div>
<div class="text-xs text-base-content/70">
{{ document.mimeType || 'Inconnu' }} {{ formatSize(document.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
@click="$emit('preview', document)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
Télécharger
</button>
<button
v-if="canDelete"
type="button"
class="btn btn-error btn-xs"
:disabled="deleteDisabled"
@click="$emit('delete', document.id)"
>
Supprimer
</button>
</div>
</div>
</div>
<p v-else class="text-xs text-base-content/70">
{{ emptyText }}
</p>
</template>
<script setup lang="ts">
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
import {
documentIcon,
formatSize,
shouldInlinePdf,
documentPreviewSrc,
documentThumbnailClass,
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
withDefaults(defineProps<{
documents: any[]
canDelete?: boolean
deleteDisabled?: boolean
emptyText?: string
}>(), {
canDelete: false,
deleteDisabled: false,
emptyText: 'Aucun document.',
})
defineEmits<{
(e: 'preview', document: any): void
(e: 'delete', documentId: string): void
}>()
</script>
```
**Step 2: Run lint**
Run: `cd Inventory_frontend && npm run lint:fix`
**Step 3: Replace document list in each of the 5 files**
In each file, replace the `v-for` document list block with:
```vue
<DocumentListInline
:documents="xxxDocuments"
:can-delete="canEdit || isEditMode"
:delete-disabled="uploadingDocuments"
:empty-text="'Aucun document lié à cet élément.'"
@preview="openPreview"
@delete="removeDocument"
/>
```
Remove the now-unused imports (`documentIcon`, `formatSize`, `shouldInlinePdf`, etc.) from each file.
**Step 4: Run lint + typecheck**
Run: `cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck`
**Step 5: Commit**
```bash
git add app/components/common/DocumentListInline.vue app/components/PieceItem.vue app/components/ComponentItem.vue app/pages/component/\[id\]/edit.vue app/pages/pieces/\[id\]/edit.vue app/pages/product/\[id\]/edit.vue
git commit -m "refactor(frontend) : extract DocumentListInline shared component"
```
**Expected savings:** ~60 lines per file × 5 files = ~300 lines total
---
### Task 2: Extract `StructureSkeletonPreview.vue` shared component
**Rationale:** The "Squelette sélectionné" details section (collapsible, shows custom fields / pieces / products / subcomponents) is duplicated in:
- `pages/component/[id]/edit.vue` (lines 141-225)
- `pages/component/create.vue` (lines 112-189)
- `pages/pieces/[id]/edit.vue` (lines 185-216)
- `pages/pieces/create.vue` (lines 156-187)
**Files:**
- Create: `app/components/common/StructureSkeletonPreview.vue`
- Modify: all 4 pages above
**Step 1: Create the component**
Extract the common `<details>` collapse + custom fields list + pieces list + products list + subcomponents list into a single component with props:
- `structure` — the normalized structure object
- `description` — optional description text
- `previewBadge` — the badge text (e.g., from `formatStructurePreview`)
- `pieceTypeLabelMap`, `productTypeLabelMap` — for label resolution (component pages only)
- `variant``'component'` or `'piece'` to control which sections display
**Step 2: Replace in each page**
**Step 3: Run lint + typecheck**
**Step 4: Commit**
```bash
git commit -m "refactor(frontend) : extract StructureSkeletonPreview shared component"
```
**Expected savings:** ~50 lines per file × 4 files = ~200 lines total
---
### Task 3: Split `shared/model/componentStructure.ts` (794 lines → 3 files)
**Files:**
- Create: `app/shared/model/componentStructureSanitize.ts`
- Create: `app/shared/model/componentStructureHydrate.ts`
- Modify: `app/shared/model/componentStructure.ts` (keep only normalize + format + extract)
**Step 1: Create `componentStructureSanitize.ts`**
Move these functions (lines 88-362):
- `sanitizeCustomFields`
- `sanitizePieces`
- `sanitizeProducts`
- `sanitizeSubcomponents` (make it exported)
- Helper: `extractFieldValueObject`, `toStringArray`
~275 lines → new file
**Step 2: Create `componentStructureHydrate.ts`**
Move these functions (lines 364-495, 654-739):
- `hydrateCustomFields`
- `hydratePieces`
- `hydrateProducts`
- `hydrateSubcomponents`
- `mapComponentCustomFields`
- `mapComponentPieces`
- `mapComponentProducts`
- `mapSubcomponents`
~250 lines → new file
**Step 3: Update `componentStructure.ts`**
Keep only:
- `isPlainObject`, `ModelStructurePreview`, `defaultStructure`, `ensureStructureShape`, `cloneStructure`
- `normalizeStructureForEditor`, `normalizeStructureForSave`
- `hydrateStructureForEditor`, `extractStructureFromComponent`
- `computeStructureStats`, `formatStructurePreview`
Import sanitize/hydrate functions from the new files. File should end up ~270 lines.
**Step 4: Verify all imports across the codebase still work**
Run: `cd Inventory_frontend && npx nuxi typecheck`
**Step 5: Commit**
```bash
git commit -m "refactor(frontend) : split componentStructure.ts into focused modules"
```
---
### Task 4: Split `composables/useMachineDetailData.ts` (1353 lines → 4 composables)
**Files:**
- Create: `app/composables/useMachineDetailDocuments.ts` (~200 lines)
- Create: `app/composables/useMachineDetailCustomFields.ts` (~150 lines)
- Create: `app/composables/useMachineDetailHierarchy.ts` (~200 lines)
- Create: `app/composables/useMachineDetailProducts.ts` (~150 lines)
- Modify: `app/composables/useMachineDetailData.ts` (should end up ~400 lines)
**Step 1: Identify extraction boundaries**
Read the full file and map which functions/refs belong to which domain:
- **Documents:** document loading, upload, delete, preview state
- **Custom fields:** custom field value management, display logic
- **Hierarchy:** machine hierarchy building, component/piece tree resolution
- **Products:** product display, resolution, supplier info
**Step 2: Extract `useMachineDetailDocuments.ts`**
Move all document-related refs, functions, and watchers. The composable accepts `machineId` and returns `{ documents, loadDocuments, uploadDocuments, ... }`.
**Step 3: Extract `useMachineDetailCustomFields.ts`**
Move custom field resolution, display filtering, and update logic.
**Step 4: Extract `useMachineDetailHierarchy.ts`**
Move `buildMachineHierarchyFromLinks` usage, component/piece tree construction.
**Step 5: Extract `useMachineDetailProducts.ts`**
Move product display resolution, supplier info formatting.
**Step 6: Update `useMachineDetailData.ts`**
Import and compose the 4 sub-composables. Keep only the orchestration logic (data loading sequence, top-level state).
**Step 7: Run lint + typecheck**
**Step 8: Commit**
```bash
git commit -m "refactor(frontend) : split useMachineDetailData into focused composables"
```
---
### Task 5: Extract composable from `StructureNodeEditor.vue` (926 → <500)
**Files:**
- Create: `app/composables/useStructureNodeLogic.ts`
- Modify: `app/components/StructureNodeEditor.vue`
**Step 1: Create `useStructureNodeLogic.ts`**
Extract from the `<script>` section (lines 358-926):
- Type maps (`componentTypeMap`, `pieceTypeMap`, `productTypeMap`) and label getters
- Sync functions (`syncComponentType`, `updatePieceTypeLabel`, `updateProductTypeLabel`, `syncPieceLabels`, `syncProductLabels`)
- Handler functions (`handleComponentTypeSelect`, `handlePieceTypeSelect`, `handleProductTypeSelect`)
- CRUD functions (`addCustomField`, `removeCustomField`, `addPiece`, `removePiece`, `addProduct`, `removeProduct`, `addSubComponent`, `removeSubComponent`)
- Lock state (`initialCustomFieldIndices`, etc., `isCustomFieldLocked`, etc.)
- All watchers
The composable signature:
```ts
export function useStructureNodeLogic(props: { node: ..., componentTypes: ..., ... }) {
// ... all the extracted logic
return { ... }
}
```
**Step 2: Update `StructureNodeEditor.vue`**
Keep only the `<template>` (356 lines — already under 500 on its own) + thin `<script>` that calls the composable.
**Step 3: Run lint + typecheck**
**Step 4: Commit**
```bash
git commit -m "refactor(frontend) : extract StructureNodeEditor logic into composable"
```
---
### Task 6: Extract composable from `pages/component/[id]/edit.vue` (911 → <500)
After Task 1 (DocumentListInline) and Task 2 (StructureSkeletonPreview), this file will be ~750 lines. Still needs ~250 lines extracted.
**Files:**
- Create: `app/composables/useComponentEdit.ts`
- Modify: `app/pages/component/[id]/edit.vue`
**Step 1: Create `useComponentEdit.ts`**
Extract:
- All state declarations (refs, reactive)
- `fetchComponent`, `refreshDocuments`, `refreshCustomFieldInputs`
- `collectStructureSelections` function (lines 802-879 — 77 lines alone)
- `submitEdition` function
- All watchers
- Type label maps and catalog maps
**Step 2: Update the page to use the composable**
**Step 3: Run lint + typecheck**
**Step 4: Commit**
```bash
git commit -m "refactor(frontend) : extract component edit page logic into composable"
```
---
### Task 7: Extract composable from `pages/component/create.vue` (852 → <500)
After Task 2 (StructureSkeletonPreview), ~800 lines remain.
**Files:**
- Create: `app/composables/useComponentCreate.ts`
- Modify: `app/pages/component/create.vue`
**Step 1: Create `useComponentCreate.ts`**
Extract:
- Structure assignment building functions (`extractSubcomponents`, `extractPiecesFromNode`, `extractProductsFromNode`, `buildAssignmentNode`, `initializeStructureAssignments`, `hasAssignments`, `isAssignmentNodeComplete`)
- Serialization functions (`stripNullish`, `sanitizeStructureDefinition`, `sanitizePieceDefinition`, `sanitizeProductDefinition`, `serializeStructureAssignments`)
- `submitCreation` function
- State management and watchers
**Step 2: Update the page**
**Step 3: Run lint + typecheck**
**Step 4: Commit**
```bash
git commit -m "refactor(frontend) : extract component create page logic into composable"
```
---
### Task 8: Extract composable from `pages/pieces/[id]/edit.vue` (821 → <500)
After Task 1 and Task 2, ~650 lines remain.
**Files:**
- Create: `app/composables/usePieceEdit.ts`
- Modify: `app/pages/pieces/[id]/edit.vue`
**Step 1: Create `usePieceEdit.ts`**
Extract:
- Product selection logic (`describeProductRequirement`, `productRequirementEntries`, `productSelectionsFilled`, `ensureProductSelections`, `setProductSelection`)
- `fetchPiece`, `loadPieceTypeDetailsFromCache`, `submitEdition`
- State refs and watchers
**Step 2: Update the page**
**Step 3: Run lint + typecheck**
**Step 4: Commit**
```bash
git commit -m "refactor(frontend) : extract piece edit page logic into composable"
```
---
### Task 9: Reduce `PieceItem.vue` (757 → <500)
After Task 1 (DocumentListInline), ~680 lines remain. The custom field rendering template (lines 236-373) is ~140 lines.
**Files:**
- Create: `app/components/common/CustomFieldDisplay.vue` (~140 lines)
- Modify: `app/components/PieceItem.vue`
**Step 1: Create `CustomFieldDisplay.vue`**
Extract the custom field edit/display template section. Props: `fields`, `isEditMode`, plus events for `update` and `blur`.
**Step 2: Replace in PieceItem.vue and ComponentItem.vue**
Both components have this same custom field display pattern.
**Step 3: Run lint + typecheck**
**Step 4: Commit**
```bash
git commit -m "refactor(frontend) : extract CustomFieldDisplay shared component"
```
**Expected savings:** ~130 lines from PieceItem, ~70 lines from ComponentItem
---
### Task 10: Extract composable from `ComponentStructureAssignmentNode.vue` (722 → <500)
**Files:**
- Create: `app/composables/useStructureAssignmentFetch.ts`
- Modify: `app/components/ComponentStructureAssignmentNode.vue`
**Step 1: Create `useStructureAssignmentFetch.ts`**
Extract:
- All fetch functions (`fetchComponentOptions`, `fetchPieceOptions`, `fetchProductOptions`)
- Option getters (`getPieceOptions`, `getProductOptions`, `componentOptions`)
- Loading state maps (`pieceLoadingByPath`, `productLoadingByPath`, `componentLoadingByPath`)
- Options-by-path state maps
- Label/description helper functions (`describePieceRequirement`, `describeProductRequirement`, etc.)
- All watchers
**Step 2: Update the component**
**Step 3: Run lint + typecheck**
**Step 4: Commit**
```bash
git commit -m "refactor(frontend) : extract assignment fetch logic into composable"
```
---
### Task 11: Extract modals from `pages/index.vue` (584 → <500)
**Files:**
- Create: `app/components/home/AddSiteModal.vue` (~50 lines)
- Create: `app/components/home/AddMachineModal.vue` (~70 lines)
- Modify: `app/pages/index.vue`
**Step 1: Extract `AddSiteModal.vue`**
Move lines 261-297 (site modal template + form).
Props: `open`, `disabled`. Events: `close`, `create`.
**Step 2: Extract `AddMachineModal.vue`**
Move lines 300-368 (machine modal template + form).
Props: `open`, `sites`, `disabled`. Events: `close`, `create`.
**Step 3: Update `index.vue` to use the components**
Move `newSite` and `newMachine` reactive objects into the modals.
**Step 4: Run lint + typecheck**
**Step 5: Commit**
```bash
git commit -m "refactor(frontend) : extract home page modals into components"
```
---
### Task 12: Reduce `PieceModelStructureEditor.vue` (578 → <500)
After Task 9 (if `useDragReorder` composable is already available), the drag logic is already minimal. The remaining bulk is field/product CRUD.
**Files:**
- Create: `app/composables/usePieceStructureEditorLogic.ts`
- Modify: `app/components/PieceModelStructureEditor.vue`
**Step 1: Create `usePieceStructureEditorLogic.ts`**
Extract:
- `hydrateFields`, `hydrateProducts`, `toEditorField`, `toEditorProduct`
- `buildPayload`, `serializeStructure`, `emitUpdate`
- `normalizeProductEntry`, product type metadata updates
- Drag state and drag functions
**Step 2: Update the component**
**Step 3: Run lint + typecheck**
**Step 4: Commit**
```bash
git commit -m "refactor(frontend) : extract PieceModelStructureEditor logic into composable"
```
---
### Task 13: Extract related modal from `ManagementView.vue` (577 → <500)
**Files:**
- Create: `app/components/model-types/RelatedItemsModal.vue` (~100 lines)
- Modify: `app/components/model-types/ManagementView.vue`
**Step 1: Create `RelatedItemsModal.vue`**
Move the related items modal template (lines 109-161) and its logic (`relatedModalOpen`, `relatedItems`, `relatedLoading`, `relatedError`, `loadRelatedItems`, etc.).
Props: `open`, `modelType`. Events: `close`, `open-edit`.
**Step 2: Update `ManagementView.vue`**
**Step 3: Run lint + typecheck**
**Step 4: Commit**
```bash
git commit -m "refactor(frontend) : extract RelatedItemsModal from ManagementView"
```
---
### Task 14: Reduce `ComponentItem.vue` (573 → <500)
After Task 1 (DocumentListInline ~60 lines) and Task 9 (CustomFieldDisplay ~70 lines), the file drops to ~443 lines. **Already under 500 — no further action needed.**
---
### Task 15: Reduce `pages/product/[id]/edit.vue` (570 → <500)
After Task 1 (DocumentListInline ~60 lines), drops to ~510. Need ~10 more lines extracted.
**Files:**
- Modify: `app/pages/product/[id]/edit.vue`
**Step 1: Extract `loadProductType` and `hydrateForm` into a small composable or inline**
Move `loadProductType` (lines 462-488) and `hydrateForm` (lines 490-508) into a shared `useProductEdit.ts` if beneficial, or just use Task 1 savings which may be enough.
**Step 2: Run lint + typecheck**
**Step 3: Commit (if changes needed)**
---
### Task 16: Reduce `pages/pieces/create.vue` (540 → <500)
After Task 2 (StructureSkeletonPreview ~30 lines), drops to ~510. Need ~10 more lines.
**Files:**
- Modify: `app/pages/pieces/create.vue`
**Step 1: Extract product selection logic**
The `describeProductRequirement`, `productRequirementDescriptions`, `productRequirementEntries`, `productSelectionsFilled`, `ensureProductSelections`, `setProductSelection` block (lines 343-398, ~55 lines) is duplicated with `pages/pieces/[id]/edit.vue`.
Extract into `app/shared/utils/pieceProductSelectionUtils.ts`.
**Step 2: Update both piece pages to import from shared utils**
**Step 3: Run lint + typecheck**
**Step 4: Commit**
```bash
git commit -m "refactor(frontend) : extract shared piece product selection utils"
```
---
## Execution order (dependencies)
1. **Task 1** (DocumentListInline) — no deps, reduces 5 files
2. **Task 2** (StructureSkeletonPreview) — no deps, reduces 4 files
3. **Task 9** (CustomFieldDisplay) — no deps, reduces PieceItem + ComponentItem
4. **Task 3** (componentStructure.ts split) — no deps
5. **Task 4** (useMachineDetailData split) — no deps
6. **Task 5** (StructureNodeEditor) — no deps
7. **Task 10** (ComponentStructureAssignmentNode) — no deps
8. **Task 11** (index.vue modals) — no deps
9. **Task 12** (PieceModelStructureEditor) — no deps
10. **Task 13** (ManagementView) — no deps
11. **Task 16** (pieces/create.vue) — no deps
12. **Task 6** (component/edit) — after Tasks 1, 2
13. **Task 7** (component/create) — after Task 2
14. **Task 8** (pieces/edit) — after Tasks 1, 2
15. **Task 15** (product/edit) — after Task 1
16. **Task 14** (ComponentItem) — verify after Tasks 1, 9
Tasks 1-11 are independent and can be parallelized via subagents (pairs that don't touch the same files).

View File

@@ -18,6 +18,11 @@ const appVersion = process.env.NUXT_PUBLIC_APP_VERSION || getAppVersion()
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
components: {
dirs: [
{ path: '~/components', pathPrefix: false },
],
},
ssr: false, // Désactive le SSR pour un mode SPA pur (Client-Side Rendering uniquement)
devtools: { enabled: true },
devServer: {