Compare commits
17 Commits
v1.8.1
...
3b24dc128a
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b24dc128a | |||
| c188bd7e8b | |||
| e911f169ce | |||
| 9f9ad80c61 | |||
| c831f65ef3 | |||
| 81eb181000 | |||
| a3fde7a191 | |||
| b696b5aa1f | |||
| c6db96dc76 | |||
| 165e0a6341 | |||
| de7be1b9d0 | |||
| 7b3eb1c5fc | |||
|
|
592beb0fa7 | ||
|
|
e732585e63 | ||
|
|
f1cc21c31b | ||
|
|
6c2f84dd3a | ||
|
|
032b3b33c9 |
@@ -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>
|
||||
<CommonCustomFieldDisplay
|
||||
: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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -130,7 +130,7 @@ const cleanupRemovedPreviews = (previousFiles = [], nextFiles = []) => {
|
||||
})
|
||||
}
|
||||
|
||||
const selectedFiles = computed(() => internalFiles.value)
|
||||
const selectedFiles = internalFiles
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
|
||||
@@ -234,143 +234,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>
|
||||
<CommonCustomFieldDisplay
|
||||
: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 +267,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>
|
||||
@@ -499,19 +299,12 @@ 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 +458,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) {
|
||||
|
||||
@@ -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 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 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
173
app/components/common/CustomFieldDisplay.vue
Normal file
173
app/components/common/CustomFieldDisplay.vue
Normal 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>
|
||||
83
app/components/common/CustomFieldInputGrid.vue
Normal file
83
app/components/common/CustomFieldInputGrid.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
104
app/components/common/DocumentListInline.vue
Normal file
104
app/components/common/DocumentListInline.vue
Normal 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>
|
||||
97
app/components/common/EntityHistorySection.vue
Normal file
97
app/components/common/EntityHistorySection.vue
Normal 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>
|
||||
162
app/components/common/StructureSkeletonPreview.vue
Normal file
162
app/components/common/StructureSkeletonPreview.vue
Normal 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"> • Obligatoire</span>
|
||||
<span v-if="Array.isArray(field.options) && field.options.length">
|
||||
• Options : {{ field.options.join(', ') }}
|
||||
</span>
|
||||
<span v-if="field.defaultValue">
|
||||
• 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>
|
||||
108
app/components/home/AddMachineModal.vue
Normal file
108
app/components/home/AddMachineModal.vue
Normal 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>
|
||||
78
app/components/home/AddSiteModal.vue
Normal file
78
app/components/home/AddSiteModal.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
109
app/composables/useDragReorder.ts
Normal file
109
app/composables/useDragReorder.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
396
app/composables/useMachineDetailCustomFields.ts
Normal file
396
app/composables/useMachineDetailCustomFields.ts
Normal 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
146
app/composables/useMachineDetailDocuments.ts
Normal file
146
app/composables/useMachineDetailDocuments.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
306
app/composables/useMachineDetailHierarchy.ts
Normal file
306
app/composables/useMachineDetailHierarchy.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
132
app/composables/useMachineDetailProducts.ts
Normal file
132
app/composables/useMachineDetailProducts.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
214
app/composables/useMachineDetailUpdates.ts
Normal file
214
app/composables/useMachineDetailUpdates.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
444
app/composables/usePieceStructureEditorLogic.ts
Normal file
444
app/composables/usePieceStructureEditorLogic.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
366
app/composables/useStructureAssignmentFetch.ts
Normal file
366
app/composables/useStructureAssignmentFetch.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
205
app/composables/useStructureNodeCrud.ts
Normal file
205
app/composables/useStructureNodeCrud.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
462
app/composables/useStructureNodeLogic.ts
Normal file
462
app/composables/useStructureNodeLogic.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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()])
|
||||
|
||||
@@ -138,91 +138,17 @@
|
||||
</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 contraintes du composant.' }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="badge badge-outline">{{ formatStructurePreview(selectedTypeStructure) }}</span>
|
||||
</div>
|
||||
|
||||
<details v-if="selectedTypeStructure" 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-4 text-sm text-base-content/80">
|
||||
<div v-if="getStructureCustomFields(selectedTypeStructure).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 getStructureCustomFields(selectedTypeStructure)"
|
||||
: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"> • Obligatoire</span>
|
||||
<span v-if="Array.isArray(field.options) && field.options.length">
|
||||
• Options : {{ field.options.join(', ') }}
|
||||
</span>
|
||||
<span v-if="field.defaultValue">
|
||||
• Défaut : {{ field.defaultValue }}
|
||||
</span>
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="getStructurePieces(selectedTypeStructure).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 getStructurePieces(selectedTypeStructure)"
|
||||
:key="piece.role || piece.typePieceId || piece.familyCode || index"
|
||||
>
|
||||
{{ resolvePieceLabel(piece) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="getStructureProducts(selectedTypeStructure).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 getStructureProducts(selectedTypeStructure)"
|
||||
:key="product.role || product.typeProductId || product.familyCode || index"
|
||||
>
|
||||
{{ resolveProductLabel(product) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="getStructureSubcomponents(selectedTypeStructure).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 getStructureSubcomponents(selectedTypeStructure)"
|
||||
:key="subcomponent.alias || subcomponent.typeComposantId || subcomponent.familyCode || index"
|
||||
>
|
||||
{{ resolveSubcomponentLabel(subcomponent) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="!getStructureCustomFields(selectedTypeStructure).length && !getStructurePieces(selectedTypeStructure).length && !getStructureProducts(selectedTypeStructure).length && !getStructureSubcomponents(selectedTypeStructure).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>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<StructureSkeletonPreview
|
||||
v-if="selectedType"
|
||||
:structure="selectedTypeStructure"
|
||||
:description="selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.'"
|
||||
:preview-badge="formatStructurePreview(selectedTypeStructure)"
|
||||
variant="component"
|
||||
show-empty-state
|
||||
:resolve-piece-label="resolvePieceLabel"
|
||||
:resolve-product-label="resolveProductLabel"
|
||||
:resolve-subcomponent-label="resolveSubcomponentLabel"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="structureSelections.hasAny"
|
||||
@@ -275,78 +201,7 @@
|
||||
Mettez à jour les valeurs propres à ce composant.
|
||||
</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">
|
||||
@@ -375,147 +230,23 @@
|
||||
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
|
||||
Chargement des documents en cours…
|
||||
</p>
|
||||
<div v-else-if="componentDocuments.length" class="space-y-2">
|
||||
<div
|
||||
v-for="document in componentDocuments"
|
||||
: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é à ce composant pour le moment.
|
||||
</p>
|
||||
<DocumentListInline
|
||||
v-else
|
||||
:documents="componentDocuments"
|
||||
:can-delete="canEdit"
|
||||
:delete-disabled="uploadingDocuments"
|
||||
empty-text="Aucun document n'est associé à ce composant 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 l’historique…
|
||||
</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="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
||||
@@ -559,32 +290,27 @@ import { useToast } from '~/composables/useToast'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { useComponentHistory, type ComponentHistoryEntry } from '~/composables/useComponentHistory'
|
||||
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,
|
||||
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'
|
||||
|
||||
interface ComponentCatalogType extends ModelType {
|
||||
structure: ComponentModelStructure | null
|
||||
@@ -622,8 +348,6 @@ const componentDocuments = ref<any[]>([])
|
||||
const previewDocument = ref<any | null>(null)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
const historyEntries = computed<ComponentHistoryEntry[]>(() => history.value)
|
||||
|
||||
const historyFieldLabels: Record<string, string> = {
|
||||
name: 'Nom',
|
||||
reference: 'Référence',
|
||||
@@ -634,8 +358,6 @@ const historyFieldLabels: Record<string, string> = {
|
||||
constructeurIds: 'Fournisseurs',
|
||||
}
|
||||
|
||||
const historyDiffEntries = (entry: ComponentHistoryEntry) =>
|
||||
_historyDiffEntries(entry, historyFieldLabels)
|
||||
const selectedTypeId = ref<string>('')
|
||||
const editionForm = reactive({
|
||||
name: '' as string,
|
||||
@@ -647,23 +369,13 @@ const editionForm = reactive({
|
||||
|
||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||
const fetchedPieceTypeMap = ref<Record<string, string>>({})
|
||||
const pieceTypeLabelMap = computed(() => ({
|
||||
...Object.fromEntries(
|
||||
(pieceTypes.value || [])
|
||||
.filter((type: any) => type?.id)
|
||||
.map((type: any) => [type.id, type.name || type.code || '']),
|
||||
),
|
||||
...fetchedPieceTypeMap.value,
|
||||
}))
|
||||
const pieceTypeLabelMap = computed(() =>
|
||||
buildTypeLabelMap(pieceTypes.value, fetchedPieceTypeMap.value),
|
||||
)
|
||||
const fetchedProductTypeMap = ref<Record<string, string>>({})
|
||||
const productTypeLabelMap = computed(() => ({
|
||||
...Object.fromEntries(
|
||||
(productTypes.value || [])
|
||||
.filter((type: any) => type?.id)
|
||||
.map((type: any) => [type.id, type.name || type.code || '']),
|
||||
),
|
||||
...fetchedProductTypeMap.value,
|
||||
}))
|
||||
const productTypeLabelMap = computed(() =>
|
||||
buildTypeLabelMap(productTypes.value, fetchedProductTypeMap.value),
|
||||
)
|
||||
const pieceCatalogMap = computed(() =>
|
||||
new Map(
|
||||
(pieces.value || [])
|
||||
@@ -802,52 +514,45 @@ const fetchComponent = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
let initialized = false
|
||||
const initialized = ref(false)
|
||||
|
||||
watch(
|
||||
[component, selectedTypeStructure],
|
||||
([currentComponent, currentStructure]) => {
|
||||
if (!currentComponent || initialized) {
|
||||
if (!currentComponent) {
|
||||
return
|
||||
}
|
||||
|
||||
const resolvedTypeId = currentComponent.typeComposantId
|
||||
|| extractRelationId(currentComponent.typeComposant)
|
||||
|| ''
|
||||
if (resolvedTypeId && !currentComponent.typeComposantId) {
|
||||
currentComponent.typeComposantId = resolvedTypeId
|
||||
}
|
||||
selectedTypeId.value = resolvedTypeId
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
// After setting selectedTypeId, read selectedTypeStructure.value (now updated) instead of
|
||||
// the stale destructured currentStructure which was captured before the ID change.
|
||||
refreshCustomFieldInputs(selectedTypeStructure.value ?? currentStructure, currentComponent.customFieldValues)
|
||||
|
||||
initialized = true
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(selectedTypeStructure, (currentStructure) => {
|
||||
if (!component.value) {
|
||||
return
|
||||
}
|
||||
refreshCustomFieldInputs(currentStructure, component.value.customFieldValues)
|
||||
})
|
||||
|
||||
const submitEdition = async () => {
|
||||
if (!component.value) {
|
||||
return
|
||||
@@ -899,116 +604,14 @@ const submitEdition = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const getStructureCustomFields = (structure: ComponentModelStructure | null) => {
|
||||
return Array.isArray(structure?.customFields) ? structure.customFields : []
|
||||
}
|
||||
|
||||
const getStructurePieces = (structure: ComponentModelStructure | null) => {
|
||||
return Array.isArray(structure?.pieces) ? structure.pieces : []
|
||||
}
|
||||
|
||||
const getStructureProducts = (structure: ComponentModelStructure | null) => {
|
||||
return Array.isArray(structure?.products) ? structure.products : []
|
||||
}
|
||||
|
||||
const getStructureSubcomponents = (structure: ComponentModelStructure | null) => {
|
||||
if (Array.isArray(structure?.subcomponents)) {
|
||||
return structure.subcomponents
|
||||
}
|
||||
const legacy = (structure as any)?.subComponents
|
||||
return Array.isArray(legacy) ? legacy : []
|
||||
}
|
||||
|
||||
const isNonEmptyString = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.trim().length > 0
|
||||
|
||||
const resolvePieceLabel = (piece: Record<string, any>) => {
|
||||
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 && pieceTypeLabelMap.value[piece.typePieceId]) {
|
||||
parts.push(pieceTypeLabelMap.value[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'
|
||||
}
|
||||
const resolvePieceLabel = (piece: Record<string, any>) =>
|
||||
_resolvePieceLabel(piece, pieceTypeLabelMap.value)
|
||||
|
||||
const fetchPieceTypeNames = async (ids: string[]) => {
|
||||
const missing = ids.filter((id) => id && !pieceTypeLabelMap.value[id])
|
||||
if (!missing.length) {
|
||||
return
|
||||
}
|
||||
const results = await Promise.allSettled(
|
||||
missing.map((id) => get(`/model_types/${id}`)),
|
||||
)
|
||||
const next = { ...fetchedPieceTypeMap.value }
|
||||
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) {
|
||||
next[key] = name
|
||||
}
|
||||
})
|
||||
fetchedPieceTypeMap.value = next
|
||||
}
|
||||
|
||||
const resolveProductLabel = (product: Record<string, any>) => {
|
||||
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 && productTypeLabelMap.value[product.typeProductId]) {
|
||||
parts.push(productTypeLabelMap.value[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'
|
||||
}
|
||||
|
||||
const fetchProductTypeNames = async (ids: string[]) => {
|
||||
const missing = ids.filter((id) => id && !productTypeLabelMap.value[id])
|
||||
if (!missing.length) {
|
||||
return
|
||||
}
|
||||
const results = await Promise.allSettled(
|
||||
missing.map((id) => get(`/model_types/${id}`)),
|
||||
)
|
||||
const next = { ...fetchedProductTypeMap.value }
|
||||
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) {
|
||||
next[key] = name
|
||||
}
|
||||
})
|
||||
fetchedProductTypeMap.value = next
|
||||
}
|
||||
const resolveProductLabel = (product: Record<string, any>) =>
|
||||
_resolveProductLabel(product, productTypeLabelMap.value)
|
||||
|
||||
watch(
|
||||
selectedTypeStructure,
|
||||
@@ -1017,45 +620,31 @@ watch(
|
||||
.map((piece: any) => piece?.typePieceId)
|
||||
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
|
||||
if (pieceIds.length) {
|
||||
fetchPieceTypeNames(Array.from(new Set(pieceIds))).catch(() => {})
|
||||
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) {
|
||||
fetchProductTypeNames(Array.from(new Set(productIds))).catch(() => {})
|
||||
fetchModelTypeNames(Array.from(new Set(productIds)), productTypeLabelMap.value, get)
|
||||
.then((additions) => {
|
||||
if (Object.keys(additions).length) {
|
||||
fetchedProductTypeMap.value = { ...fetchedProductTypeMap.value, ...additions }
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const resolveSubcomponentLabel = (node: Record<string, any>) => {
|
||||
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'
|
||||
}
|
||||
|
||||
type SelectionEntry = {
|
||||
id: string
|
||||
path: string
|
||||
@@ -1163,11 +752,13 @@ onMounted(async () => {
|
||||
])
|
||||
loading.value = false
|
||||
|
||||
// Defer bulk catalog loads — not needed for initial render
|
||||
Promise.allSettled([
|
||||
loadPieces({ itemsPerPage: 200 }),
|
||||
loadProducts({ itemsPerPage: 200 }),
|
||||
loadComposants({ itemsPerPage: 200 }),
|
||||
]).catch(() => {})
|
||||
// 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(() => {})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -109,84 +109,16 @@
|
||||
</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 contraintes du composant.' }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="badge badge-outline">{{ formatStructurePreview(selectedTypeStructure) }}</span>
|
||||
</div>
|
||||
|
||||
<details v-if="selectedTypeStructure" 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-4 text-sm text-base-content/80">
|
||||
<div v-if="getStructureCustomFields(selectedTypeStructure).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 getStructureCustomFields(selectedTypeStructure)"
|
||||
: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"> • Obligatoire</span>
|
||||
<span v-if="Array.isArray(field.options) && field.options.length">
|
||||
• Options : {{ field.options.join(', ') }}
|
||||
</span>
|
||||
<span v-if="field.defaultValue">
|
||||
• Défaut : {{ field.defaultValue }}
|
||||
</span>
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="getStructurePieces(selectedTypeStructure).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 getStructurePieces(selectedTypeStructure)"
|
||||
:key="piece.role || piece.typePieceId || piece.familyCode || index"
|
||||
>
|
||||
{{ resolvePieceLabel(piece) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="getStructureProducts(selectedTypeStructure).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 getStructureProducts(selectedTypeStructure)"
|
||||
:key="product.role || product.typeProductId || product.familyCode || index"
|
||||
>
|
||||
{{ resolveProductLabel(product) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="getStructureSubcomponents(selectedTypeStructure).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 getStructureSubcomponents(selectedTypeStructure)"
|
||||
:key="subcomponent.alias || subcomponent.typeComposantId || subcomponent.familyCode || index"
|
||||
>
|
||||
{{ resolveSubcomponentLabel(subcomponent) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<StructureSkeletonPreview
|
||||
v-if="selectedType"
|
||||
:structure="selectedTypeStructure"
|
||||
:description="selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.'"
|
||||
:preview-badge="formatStructurePreview(selectedTypeStructure)"
|
||||
variant="component"
|
||||
:resolve-piece-label="resolvePieceLabel"
|
||||
:resolve-product-label="resolveProductLabel"
|
||||
:resolve-subcomponent-label="resolveSubcomponentLabel"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="structureHasRequirements"
|
||||
@@ -241,78 +173,7 @@
|
||||
Renseignez les valeurs propres à ce composant selon le squelette choisi.
|
||||
</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">
|
||||
@@ -375,10 +236,20 @@ import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||
import {
|
||||
toFieldString,
|
||||
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 type {
|
||||
ComponentModelPiece,
|
||||
ComponentModelProduct,
|
||||
@@ -396,7 +267,7 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { get } = useApi()
|
||||
|
||||
const { componentTypes, loadComponentTypes, loadingComponentTypes } = useComponentTypes()
|
||||
const { componentTypes, loadComponentTypes, loadingComponentTypes: loadingTypes } = useComponentTypes()
|
||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const { productTypes, loadProductTypes } = useProductTypes()
|
||||
const {
|
||||
@@ -417,8 +288,7 @@ const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const { uploadDocuments } = useDocuments()
|
||||
const { canEdit } = usePermissions()
|
||||
|
||||
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
||||
const selectedTypeId = ref<string>(initialTypeId.value)
|
||||
const selectedTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
||||
const submitting = ref(false)
|
||||
const creationForm = reactive({
|
||||
name: '' as string,
|
||||
@@ -441,27 +311,14 @@ const structureDataLoading = computed(
|
||||
)
|
||||
|
||||
const fetchedPieceTypeMap = ref<Record<string, string>>({})
|
||||
const pieceTypeLabelMap = computed(() => ({
|
||||
...Object.fromEntries(
|
||||
(pieceTypes.value || [])
|
||||
.filter((type: any) => type?.id)
|
||||
.map((type: any) => [type.id, type.name || type.code || '']),
|
||||
),
|
||||
...fetchedPieceTypeMap.value,
|
||||
}))
|
||||
const pieceTypeLabelMap = computed(() =>
|
||||
buildTypeLabelMap(pieceTypes.value, fetchedPieceTypeMap.value),
|
||||
)
|
||||
const productTypeLabelMap = computed(() =>
|
||||
Object.fromEntries(
|
||||
(productTypes.value || [])
|
||||
.filter((type: any) => type?.id)
|
||||
.map((type: any) => [type.id, type.name || type.code || '']),
|
||||
),
|
||||
buildTypeLabelMap(productTypes.value),
|
||||
)
|
||||
const componentTypeLabelMap = computed(() =>
|
||||
Object.fromEntries(
|
||||
(componentTypes.value || [])
|
||||
.filter((type: any) => type?.id)
|
||||
.map((type: any) => [type.id, type.name || type.code || '']),
|
||||
),
|
||||
buildTypeLabelMap(componentTypes.value),
|
||||
)
|
||||
|
||||
watch(
|
||||
@@ -487,7 +344,6 @@ watch(selectedTypeId, (id) => {
|
||||
router.replace({ path: route.path, query: nextQuery }).catch(() => {})
|
||||
})
|
||||
|
||||
const loadingTypes = computed(() => loadingComponentTypes.value)
|
||||
const componentTypeList = computed<ComponentCatalogType[]>(() =>
|
||||
(componentTypes.value || [])
|
||||
.filter((item: any) => item?.category === 'COMPONENT') as ComponentCatalogType[],
|
||||
@@ -779,69 +635,11 @@ const canSubmit = computed(() => Boolean(
|
||||
!submitting.value,
|
||||
))
|
||||
|
||||
const getStructureCustomFields = (structure: ComponentModelStructure | null) => {
|
||||
return Array.isArray(structure?.customFields) ? structure.customFields : []
|
||||
}
|
||||
const resolvePieceLabel = (piece: Record<string, any>) =>
|
||||
_resolvePieceLabel(piece, pieceTypeLabelMap.value)
|
||||
|
||||
const getStructurePieces = (structure: ComponentModelStructure | null) => {
|
||||
return Array.isArray(structure?.pieces) ? structure.pieces : []
|
||||
}
|
||||
|
||||
const getStructureProducts = (structure: ComponentModelStructure | null) => {
|
||||
return Array.isArray(structure?.products) ? structure.products : []
|
||||
}
|
||||
|
||||
const getStructureSubcomponents = (structure: ComponentModelStructure | null) => {
|
||||
if (Array.isArray(structure?.subcomponents)) {
|
||||
return structure.subcomponents
|
||||
}
|
||||
const legacy = (structure as any)?.subComponents
|
||||
return Array.isArray(legacy) ? legacy : []
|
||||
}
|
||||
|
||||
const resolvePieceLabel = (piece: Record<string, any>) => {
|
||||
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 && pieceTypeLabelMap.value[piece.typePieceId]) {
|
||||
parts.push(pieceTypeLabelMap.value[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'
|
||||
}
|
||||
|
||||
const fetchPieceTypeNames = async (ids: string[]) => {
|
||||
const missing = ids.filter((id) => id && !pieceTypeLabelMap.value[id])
|
||||
if (!missing.length) {
|
||||
return
|
||||
}
|
||||
const results = await Promise.allSettled(
|
||||
missing.map((id) => get(`/model_types/${id}`)),
|
||||
)
|
||||
const next = { ...fetchedPieceTypeMap.value }
|
||||
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) {
|
||||
next[key] = name
|
||||
}
|
||||
})
|
||||
fetchedPieceTypeMap.value = next
|
||||
}
|
||||
const resolveProductLabel = (product: Record<string, any>) =>
|
||||
_resolveProductLabel(product, productTypeLabelMap.value)
|
||||
|
||||
watch(
|
||||
selectedTypeStructure,
|
||||
@@ -852,56 +650,17 @@ watch(
|
||||
if (!ids.length) {
|
||||
return
|
||||
}
|
||||
fetchPieceTypeNames(Array.from(new Set(ids))).catch(() => {})
|
||||
fetchModelTypeNames(Array.from(new Set(ids)), pieceTypeLabelMap.value, get)
|
||||
.then((additions) => {
|
||||
if (Object.keys(additions).length) {
|
||||
fetchedPieceTypeMap.value = { ...fetchedPieceTypeMap.value, ...additions }
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const resolveProductLabel = (product: Record<string, any>) => {
|
||||
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.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'
|
||||
}
|
||||
|
||||
const resolveSubcomponentLabel = (node: Record<string, any>) => {
|
||||
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'
|
||||
}
|
||||
|
||||
const clearCreationForm = () => {
|
||||
creationForm.name = ''
|
||||
creationForm.description = ''
|
||||
@@ -975,7 +734,13 @@ const submitCreation = async () => {
|
||||
try {
|
||||
const result = await createComposant(payload)
|
||||
if (result.success) {
|
||||
await saveCustomFieldValues(result.data)
|
||||
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(
|
||||
@@ -1013,276 +778,4 @@ onMounted(async () => {
|
||||
loadProductTypes(),
|
||||
])
|
||||
})
|
||||
|
||||
interface CustomFieldInput {
|
||||
id: string | null
|
||||
name: string
|
||||
type: string
|
||||
required: boolean
|
||||
options: string[]
|
||||
value: string
|
||||
customFieldId: string | null
|
||||
customFieldValueId: string | null
|
||||
orderIndex: number
|
||||
}
|
||||
|
||||
const fieldKey = (field: CustomFieldInput, index: number) =>
|
||||
field.customFieldValueId || field.id || `${field.name}-${index}`
|
||||
|
||||
const normalizeCustomFieldInputs = (structure: ComponentModelStructure | null): CustomFieldInput[] => {
|
||||
if (!structure || typeof structure !== 'object') {
|
||||
return []
|
||||
}
|
||||
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
|
||||
return fields
|
||||
.map((field, index) => normalizeCustomField(field, index))
|
||||
.filter((field): field is CustomFieldInput => field !== null)
|
||||
.sort((a, b) => a.orderIndex - b.orderIndex)
|
||||
}
|
||||
|
||||
const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
|
||||
if (!rawField || typeof rawField !== 'object') {
|
||||
return null
|
||||
}
|
||||
const name = resolveFieldName(rawField)
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
const type = resolveFieldType(rawField)
|
||||
const required = resolveRequiredFlag(rawField)
|
||||
const options = resolveOptions(rawField)
|
||||
const defaultSource = resolveDefaultValue(rawField)
|
||||
const value = formatDefaultValue(type, defaultSource)
|
||||
const id = typeof rawField.id === 'string' ? rawField.id : null
|
||||
const customFieldId = typeof rawField.customFieldId === 'string' ? rawField.customFieldId : id
|
||||
const customFieldValueId = typeof rawField.customFieldValueId === 'string'
|
||||
? rawField.customFieldValueId
|
||||
: null
|
||||
const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
|
||||
return { id, name, type, required, options, value, customFieldId, customFieldValueId, orderIndex }
|
||||
}
|
||||
|
||||
const resolveFieldName = (field: any): string => {
|
||||
if (typeof field?.name === 'string' && field.name.trim()) {
|
||||
return field.name.trim()
|
||||
}
|
||||
if (typeof field?.key === 'string' && field.key.trim()) {
|
||||
return field.key.trim()
|
||||
}
|
||||
if (typeof field?.label === 'string' && field.label.trim()) {
|
||||
return field.label.trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const resolveFieldType = (field: any): string => {
|
||||
const allowed = ['text', 'number', 'select', 'boolean', 'date']
|
||||
const rawType =
|
||||
typeof field?.type === 'string'
|
||||
? field.type
|
||||
: typeof field?.value?.type === 'string'
|
||||
? field.value.type
|
||||
: ''
|
||||
const value = rawType.toLowerCase()
|
||||
return allowed.includes(value) ? value : 'text'
|
||||
}
|
||||
|
||||
const resolveDefaultValue = (field: any): any => {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return null
|
||||
}
|
||||
if (field.defaultValue !== undefined && field.defaultValue !== null) {
|
||||
return field.defaultValue
|
||||
}
|
||||
if (field.value !== undefined && field.value !== null && typeof field.value !== 'object') {
|
||||
return field.value
|
||||
}
|
||||
if (field.default !== undefined && field.default !== null) {
|
||||
return field.default
|
||||
}
|
||||
if (field.value && typeof field.value === 'object') {
|
||||
if ((field.value as any).defaultValue !== undefined && (field.value as any).defaultValue !== null) {
|
||||
return (field.value as any).defaultValue
|
||||
}
|
||||
if ((field.value as any).value !== undefined && (field.value as any).value !== null && typeof (field.value as any).value !== 'object') {
|
||||
return (field.value as any).value
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const formatDefaultValue = (type: string, defaultValue: any): string => {
|
||||
if (defaultValue === null || defaultValue === undefined) {
|
||||
return ''
|
||||
}
|
||||
if (typeof defaultValue === 'object') {
|
||||
if (defaultValue === null) {
|
||||
return ''
|
||||
}
|
||||
if ('defaultValue' in (defaultValue as Record<string, any>)) {
|
||||
return formatDefaultValue(type, (defaultValue as Record<string, any>).defaultValue)
|
||||
}
|
||||
if ('value' in (defaultValue as Record<string, any>)) {
|
||||
return formatDefaultValue(type, (defaultValue as Record<string, any>).value)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
if (type === 'boolean') {
|
||||
const normalized = String(defaultValue).toLowerCase()
|
||||
if (normalized === 'true' || normalized === '1') {
|
||||
return 'true'
|
||||
}
|
||||
if (normalized === 'false' || normalized === '0') {
|
||||
return 'false'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
return String(defaultValue)
|
||||
}
|
||||
|
||||
const resolveRequiredFlag = (field: any): boolean => {
|
||||
if (typeof field?.required === 'boolean') {
|
||||
return field.required
|
||||
}
|
||||
const nestedRequired = field?.value?.required
|
||||
if (typeof nestedRequired === 'boolean') {
|
||||
return nestedRequired
|
||||
}
|
||||
if (typeof nestedRequired === 'string') {
|
||||
const normalized = nestedRequired.toLowerCase()
|
||||
return normalized === 'true' || normalized === '1'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const resolveOptions = (field: any): string[] => {
|
||||
const sources = [field?.options, field?.value?.options, field?.value?.choices]
|
||||
for (const source of sources) {
|
||||
if (Array.isArray(source)) {
|
||||
const mapped = source
|
||||
.map((option: unknown) => {
|
||||
if (option === null || option === undefined) {
|
||||
return ''
|
||||
}
|
||||
if (typeof option === 'string') {
|
||||
return option.trim()
|
||||
}
|
||||
if (typeof option === 'object') {
|
||||
const record = option as Record<string, unknown>
|
||||
const keys = ['value', 'label', 'name']
|
||||
for (const key of keys) {
|
||||
const candidate = record[key]
|
||||
if (typeof candidate === 'string' && candidate.trim().length > 0) {
|
||||
return candidate.trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
const fallback = String(option).trim()
|
||||
return fallback === '[object Object]' ? '' : fallback
|
||||
})
|
||||
.filter((option) => option.length > 0)
|
||||
if (mapped.length) {
|
||||
return mapped
|
||||
}
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const buildCustomFieldMetadata = (field: CustomFieldInput) => ({
|
||||
customFieldName: field.name,
|
||||
customFieldType: field.type,
|
||||
customFieldRequired: field.required,
|
||||
customFieldOptions: field.options,
|
||||
})
|
||||
|
||||
const saveCustomFieldValues = async (createdComponent: any) => {
|
||||
if (!createdComponent || !createdComponent.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const definitionMap = new Map<string, string>()
|
||||
const registerDefinitions = (fields: any[]) => {
|
||||
if (!Array.isArray(fields)) {
|
||||
return
|
||||
}
|
||||
fields.forEach((field) => {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return
|
||||
}
|
||||
const name = typeof field.name === 'string' ? field.name : null
|
||||
const id = typeof field.id === 'string' ? field.id : null
|
||||
if (name && id && !definitionMap.has(name)) {
|
||||
definitionMap.set(name, id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
registerDefinitions(createdComponent?.typeComposant?.customFields)
|
||||
|
||||
const resolveDefinitionId = (field: CustomFieldInput) => {
|
||||
if (field.customFieldId) {
|
||||
return field.customFieldId
|
||||
}
|
||||
if (field.id) {
|
||||
return field.id
|
||||
}
|
||||
return definitionMap.get(field.name) ?? null
|
||||
}
|
||||
|
||||
for (const field of customFieldInputs.value) {
|
||||
if (!shouldPersistField(field)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const definitionId = resolveDefinitionId(field)
|
||||
const metadata = definitionId ? undefined : buildCustomFieldMetadata(field)
|
||||
const value = formatValueForPersistence(field)
|
||||
|
||||
if (field.customFieldValueId) {
|
||||
const result = await updateCustomFieldValue(field.customFieldValueId, { value })
|
||||
if (!result.success) {
|
||||
toast.showError(`Impossible de mettre à jour le champ personnalisé "${field.name}"`)
|
||||
} else if (definitionId && !field.customFieldId) {
|
||||
field.customFieldId = definitionId
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await upsertCustomFieldValue(
|
||||
definitionId,
|
||||
'composant',
|
||||
createdComponent.id,
|
||||
value,
|
||||
metadata,
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
toast.showError(`Impossible d'enregistrer le champ personnalisé "${field.name}"`)
|
||||
} else {
|
||||
const createdValue = result.data
|
||||
if (createdValue?.id) {
|
||||
field.customFieldValueId = createdValue.id
|
||||
}
|
||||
const resolvedId = createdValue?.customField?.id || definitionId
|
||||
if (resolvedId) {
|
||||
field.customFieldId = resolvedId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const shouldPersistField = (field: CustomFieldInput) => {
|
||||
if (field.type === 'boolean') {
|
||||
return field.value === 'true' || field.value === 'false'
|
||||
}
|
||||
return toFieldString(field.value).trim() !== ''
|
||||
}
|
||||
|
||||
const formatValueForPersistence = (field: CustomFieldInput) => {
|
||||
if (field.type === 'boolean') {
|
||||
return field.value === 'true' ? 'true' : 'false'
|
||||
}
|
||||
return toFieldString(field.value).trim()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
<HomeAddSiteModal
|
||||
: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>
|
||||
<HomeAddMachineModal
|
||||
: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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()])
|
||||
|
||||
@@ -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 l’historique…
|
||||
</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 }">
|
||||
@@ -502,7 +282,7 @@ 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 { usePieceHistory } from '~/composables/usePieceHistory'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
||||
@@ -512,24 +292,10 @@ 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'
|
||||
|
||||
interface PieceCatalogType extends ModelType {
|
||||
structure: PieceModelStructure | null
|
||||
@@ -563,8 +329,6 @@ 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',
|
||||
@@ -575,9 +339,6 @@ const historyFieldLabels: Record<string, string> = {
|
||||
constructeurIds: 'Fournisseurs',
|
||||
}
|
||||
|
||||
const historyDiffEntries = (entry: PieceHistoryEntry) =>
|
||||
_historyDiffEntries(entry, historyFieldLabels)
|
||||
|
||||
const selectedTypeId = ref<string>('')
|
||||
const pieceTypeDetails = ref<any | null>(null)
|
||||
const editionForm = reactive({
|
||||
@@ -671,9 +432,6 @@ const selectedType = computed(() => {
|
||||
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),
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
@@ -324,7 +228,6 @@ import type { PieceModelProduct, PieceModelStructure } from '~/shared/types/inve
|
||||
import type { ModelType } from '~/services/modelTypes'
|
||||
import {
|
||||
type CustomFieldInput,
|
||||
fieldKey,
|
||||
normalizeCustomFieldInputs,
|
||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||
saveCustomFieldValues as _saveCustomFieldValues,
|
||||
@@ -338,7 +241,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 +288,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,9 +303,6 @@ 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 : []
|
||||
|
||||
|
||||
@@ -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é`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 l’historique…
|
||||
</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,
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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()
|
||||
|
||||
210
app/shared/model/componentStructureHydrate.ts
Normal file
210
app/shared/model/componentStructureHydrate.ts
Normal 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,
|
||||
),
|
||||
}))
|
||||
}
|
||||
312
app/shared/model/componentStructureSanitize.ts
Normal file
312
app/shared/model/componentStructureSanitize.ts
Normal 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)
|
||||
}
|
||||
87
app/shared/utils/catalogDisplayUtils.ts
Normal file
87
app/shared/utils/catalogDisplayUtils.ts
Normal 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(', ') : '' }
|
||||
}
|
||||
19
app/shared/utils/deleteImpactUtils.ts
Normal file
19
app/shared/utils/deleteImpactUtils.ts
Normal 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')
|
||||
}
|
||||
259
app/shared/utils/structureAssignmentLabels.ts
Normal file
259
app/shared/utils/structureAssignmentLabels.ts
Normal 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'
|
||||
}
|
||||
157
app/shared/utils/structureDisplayUtils.ts
Normal file
157
app/shared/utils/structureDisplayUtils.ts
Normal 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,
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
647
docs/plans/2026-03-08-reduce-files-under-500-lines.md
Normal file
647
docs/plans/2026-03-08-reduce-files-under-500-lines.md
Normal 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).
|
||||
Reference in New Issue
Block a user