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>
|
</div>
|
||||||
|
|
||||||
<!-- Custom Fields Display - Editable or Read-only -->
|
<!-- Custom Fields Display - Editable or Read-only -->
|
||||||
<div v-if="displayedCustomFields.length" class="mt-4 pt-4 border-t border-gray-200">
|
<CommonCustomFieldDisplay
|
||||||
<h4 class="font-semibold text-sm text-gray-700 mb-3">
|
:fields="displayedCustomFields"
|
||||||
Champs personnalisés
|
:is-edit-mode="isEditMode"
|
||||||
</h4>
|
:columns="2"
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
@field-blur="updateComponentCustomField"
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="mt-4 pt-4 border-t border-gray-200 space-y-3">
|
<div class="mt-4 pt-4 border-t border-gray-200 space-y-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -309,74 +242,14 @@
|
|||||||
@files-added="handleFilesAdded"
|
@files-added="handleFilesAdded"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="componentDocuments.length" class="space-y-2">
|
<DocumentListInline
|
||||||
<div
|
:documents="componentDocuments"
|
||||||
v-for="document in componentDocuments"
|
:can-delete="isEditMode"
|
||||||
:key="document.id"
|
:delete-disabled="uploadingDocuments"
|
||||||
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
|
empty-text="Aucun document lié à ce composant."
|
||||||
>
|
@preview="openPreview"
|
||||||
<div class="flex items-center gap-3 text-sm">
|
@delete="removeDocument"
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Component Pieces -->
|
<!-- Component Pieces -->
|
||||||
@@ -438,19 +311,9 @@ import {
|
|||||||
formatSize,
|
formatSize,
|
||||||
shouldInlinePdf,
|
shouldInlinePdf,
|
||||||
documentPreviewSrc,
|
documentPreviewSrc,
|
||||||
documentThumbnailClass,
|
|
||||||
documentIcon,
|
documentIcon,
|
||||||
downloadDocument,
|
downloadDocument,
|
||||||
} from '~/shared/utils/documentDisplayUtils'
|
} from '~/shared/utils/documentDisplayUtils'
|
||||||
import {
|
|
||||||
resolveFieldKey,
|
|
||||||
resolveFieldName,
|
|
||||||
resolveFieldType,
|
|
||||||
resolveFieldOptions,
|
|
||||||
resolveFieldRequired,
|
|
||||||
resolveFieldReadOnly,
|
|
||||||
formatFieldDisplayValue,
|
|
||||||
} from '~/shared/utils/entityCustomFieldLogic'
|
|
||||||
import { useEntityDocuments } from '~/composables/useEntityDocuments'
|
import { useEntityDocuments } from '~/composables/useEntityDocuments'
|
||||||
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
|
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
|
||||||
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
|
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
|
||||||
|
|||||||
@@ -134,76 +134,24 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed } from 'vue';
|
||||||
import SearchSelect from '~/components/common/SearchSelect.vue';
|
import SearchSelect from '~/components/common/SearchSelect.vue';
|
||||||
import { useApi } from '~/composables/useApi';
|
import { useStructureAssignmentFetch } from '~/composables/useStructureAssignmentFetch';
|
||||||
import { extractCollection } from '~/shared/utils/apiHelpers';
|
|
||||||
import type {
|
import type {
|
||||||
ComponentModelPiece,
|
ComponentOption,
|
||||||
ComponentModelProduct,
|
PieceOption,
|
||||||
ComponentModelStructureNode,
|
ProductOption,
|
||||||
} from '~/shared/types/inventory';
|
} from '~/composables/useStructureAssignmentFetch';
|
||||||
|
|
||||||
interface ComponentOption {
|
export type {
|
||||||
id: string;
|
StructureAssignmentNode,
|
||||||
name?: string | null;
|
StructurePieceAssignment,
|
||||||
reference?: string | null;
|
StructureProductAssignment,
|
||||||
typeComposantId?: string | null;
|
} from '~/composables/useStructureAssignmentFetch';
|
||||||
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[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
assignment: StructureAssignmentNode;
|
assignment: import('~/composables/useStructureAssignmentFetch').StructureAssignmentNode;
|
||||||
pieces: PieceOption[] | null;
|
pieces: PieceOption[] | null;
|
||||||
products: ProductOption[] | null;
|
products: ProductOption[] | null;
|
||||||
components: ComponentOption[] | 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',
|
depth.value === 0 ? 'space-y-6' : 'space-y-6 border-l border-base-300 pl-4',
|
||||||
);
|
);
|
||||||
|
|
||||||
const { get } = useApi();
|
const {
|
||||||
const pieceOptionsByPath = ref<Record<string, PieceOption[]>>({});
|
pieceLoadingByPath,
|
||||||
const productOptionsByPath = ref<Record<string, ProductOption[]>>({});
|
productLoadingByPath,
|
||||||
const componentOptionsByPath = ref<Record<string, ComponentOption[]>>({});
|
componentLoadingByPath,
|
||||||
const pieceLoadingByPath = ref<Record<string, boolean>>({});
|
componentOptions,
|
||||||
const productLoadingByPath = ref<Record<string, boolean>>({});
|
componentOptionLabel,
|
||||||
const componentLoadingByPath = ref<Record<string, boolean>>({});
|
componentOptionDescription,
|
||||||
|
fetchComponentOptions,
|
||||||
const setLoading = (target: Record<string, boolean>, key: string, value: boolean) => {
|
getPieceOptions,
|
||||||
target[key] = value;
|
pieceOptionLabel,
|
||||||
};
|
pieceOptionDescription,
|
||||||
|
fetchPieceOptions,
|
||||||
const componentOptions = computed(() => {
|
describePieceRequirement,
|
||||||
if (isRoot.value) {
|
getProductOptions,
|
||||||
return [];
|
productOptionLabel,
|
||||||
}
|
productOptionDescription,
|
||||||
const cached = componentOptionsByPath.value[props.assignment.path];
|
fetchProductOptions,
|
||||||
if (cached) {
|
describeProductRequirement,
|
||||||
return cached;
|
} = useStructureAssignmentFetch({
|
||||||
}
|
assignment: props.assignment,
|
||||||
const definition = props.assignment.definition || {};
|
pieces: props.pieces,
|
||||||
const requiredTypeId =
|
products: props.products,
|
||||||
definition.typeComposantId || definition.modelId || null;
|
components: props.components,
|
||||||
const requiredFamilyCode = definition.familyCode || null;
|
isRoot: () => isRoot.value,
|
||||||
|
pieceTypeLabelMap: props.pieceTypeLabelMap ?? {},
|
||||||
return (props.components || []).filter((component) => {
|
productTypeLabelMap: props.productTypeLabelMap ?? {},
|
||||||
if (!component || typeof component !== 'object') {
|
componentTypeLabelMap: props.componentTypeLabelMap ?? {},
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (requiredTypeId) {
|
|
||||||
return component.typeComposantId === requiredTypeId;
|
|
||||||
}
|
|
||||||
if (requiredFamilyCode) {
|
|
||||||
return (
|
|
||||||
component.typeComposant?.code === requiredFamilyCode ||
|
|
||||||
component.typeComposantId === requiredFamilyCode
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const componentOptionLabel = (component?: ComponentOption | null) => {
|
const normalizeSelectionValue = (value: unknown) => {
|
||||||
if (!component) {
|
if (value === null || value === undefined || value === '') {
|
||||||
return 'Composant sans nom';
|
|
||||||
}
|
|
||||||
return component.name || 'Composant sans nom';
|
|
||||||
};
|
|
||||||
|
|
||||||
const componentOptionDescription = (component?: ComponentOption | null) => {
|
|
||||||
if (!component) {
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
const parts: string[] = [];
|
if (typeof value === 'string') {
|
||||||
const typeLabel =
|
return value;
|
||||||
component.typeComposant?.name || component.typeComposant?.code || null;
|
|
||||||
if (typeLabel) {
|
|
||||||
parts.push(typeLabel);
|
|
||||||
}
|
}
|
||||||
if (component.reference) {
|
if (typeof value === 'number') {
|
||||||
parts.push(`Ref. ${component.reference}`);
|
return String(value);
|
||||||
}
|
}
|
||||||
return parts.join(' • ');
|
return '';
|
||||||
};
|
|
||||||
|
|
||||||
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';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const requirementLabel = computed(() => {
|
const requirementLabel = computed(() => {
|
||||||
@@ -584,139 +247,13 @@ const requirementLabel = computed(() => {
|
|||||||
const requirementDescription = computed(() => {
|
const requirementDescription = computed(() => {
|
||||||
const definition = props.assignment.definition || {};
|
const definition = props.assignment.definition || {};
|
||||||
const family =
|
const family =
|
||||||
definition.typeComposantLabel ||
|
definition.typeComposantLabel
|
||||||
(definition.typeComposantId ? props.componentTypeLabelMap[definition.typeComposantId] : null) ||
|
|| (definition.typeComposantId ? props.componentTypeLabelMap[definition.typeComposantId] : null)
|
||||||
definition.typeComposant?.name ||
|
|| definition.typeComposant?.name
|
||||||
definition.familyCode;
|
|| definition.familyCode;
|
||||||
if (family) {
|
if (family) {
|
||||||
return `Doit appartenir à la famille "${family}".`;
|
return `Doit appartenir à la famille "${family}".`;
|
||||||
}
|
}
|
||||||
return 'Sélectionnez un composant enfant conforme à cette position.';
|
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>
|
</script>
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ const cleanupRemovedPreviews = (previousFiles = [], nextFiles = []) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedFiles = computed(() => internalFiles.value)
|
const selectedFiles = internalFiles
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
|
|||||||
@@ -234,143 +234,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Champs personnalisés de la pièce -->
|
<!-- Champs personnalisés de la pièce -->
|
||||||
<div
|
<CommonCustomFieldDisplay
|
||||||
v-if="displayedCustomFields.length"
|
:fields="displayedCustomFields"
|
||||||
class="mt-4 pt-4 border-t border-gray-200"
|
:is-edit-mode="isEditMode"
|
||||||
>
|
@field-input="handleCustomFieldInput"
|
||||||
<h5 class="text-sm font-medium text-gray-700 mb-3">
|
@field-blur="handleCustomFieldBlur"
|
||||||
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>
|
|
||||||
|
|
||||||
<div class="mt-4 pt-4 border-t border-gray-200 space-y-3">
|
<div class="mt-4 pt-4 border-t border-gray-200 space-y-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -398,83 +267,14 @@
|
|||||||
@files-added="handleFilesAdded"
|
@files-added="handleFilesAdded"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="pieceDocuments.length" class="space-y-2">
|
<DocumentListInline
|
||||||
<div
|
:documents="pieceDocuments"
|
||||||
v-for="document in pieceDocuments"
|
:can-delete="isEditMode"
|
||||||
:key="document.id"
|
:delete-disabled="uploadingDocuments"
|
||||||
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
|
empty-text="Aucun document lié à cette pièce."
|
||||||
>
|
@preview="openPreview"
|
||||||
<div class="flex items-center gap-3 text-sm">
|
@delete="removeDocument"
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -499,19 +299,12 @@ import {
|
|||||||
formatSize,
|
formatSize,
|
||||||
shouldInlinePdf,
|
shouldInlinePdf,
|
||||||
documentPreviewSrc,
|
documentPreviewSrc,
|
||||||
documentThumbnailClass,
|
|
||||||
documentIcon,
|
documentIcon,
|
||||||
downloadDocument,
|
downloadDocument,
|
||||||
} from '~/shared/utils/documentDisplayUtils'
|
} from '~/shared/utils/documentDisplayUtils'
|
||||||
import {
|
import {
|
||||||
resolveFieldKey,
|
|
||||||
resolveFieldId,
|
resolveFieldId,
|
||||||
resolveFieldName,
|
|
||||||
resolveFieldType,
|
|
||||||
resolveFieldOptions,
|
|
||||||
resolveFieldRequired,
|
|
||||||
resolveFieldReadOnly,
|
resolveFieldReadOnly,
|
||||||
formatFieldDisplayValue,
|
|
||||||
} from '~/shared/utils/entityCustomFieldLogic'
|
} from '~/shared/utils/entityCustomFieldLogic'
|
||||||
import { useEntityDocuments } from '~/composables/useEntityDocuments'
|
import { useEntityDocuments } from '~/composables/useEntityDocuments'
|
||||||
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
|
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
|
||||||
@@ -665,16 +458,16 @@ const handleProductChange = async (value) => {
|
|||||||
updatePiece()
|
updatePiece()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Custom field local helpers ---
|
// --- Custom field event handlers ---
|
||||||
const setCustomFieldValue = (fieldValueId, value, field) => {
|
const handleCustomFieldInput = (field, value) => {
|
||||||
if (resolveFieldReadOnly(field)) return
|
if (resolveFieldReadOnly(field)) return
|
||||||
if (field && typeof field === 'object') field.value = value
|
const fieldValueId = resolveFieldId(field)
|
||||||
if (!fieldValueId) return
|
if (!fieldValueId) return
|
||||||
const fieldValue = props.piece.customFieldValues?.find((fv) => fv.id === fieldValueId)
|
const fieldValue = props.piece.customFieldValues?.find((fv) => fv.id === fieldValueId)
|
||||||
if (fieldValue) fieldValue.value = value
|
if (fieldValue) fieldValue.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateCustomFieldValue = async (_fieldValueId, field) => {
|
const handleCustomFieldBlur = async (field) => {
|
||||||
await updateCustomField(field)
|
await updateCustomField(field)
|
||||||
const cfId = field?.customFieldId || field?.customField?.id || null
|
const cfId = field?.customFieldId || field?.customField?.id || null
|
||||||
if (cfId || field?.customFieldValueId) {
|
if (cfId || field?.customFieldValueId) {
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<section class="space-y-3">
|
<section class="space-y-3">
|
||||||
<header class="flex items-center justify-between">
|
<header>
|
||||||
<div>
|
<h3 class="text-sm font-semibold">
|
||||||
<h3 class="text-sm font-semibold">
|
Produits inclus par défaut
|
||||||
Produits inclus par défaut
|
</h3>
|
||||||
</h3>
|
<p class="text-xs text-base-content/70">
|
||||||
<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.
|
||||||
Ces produits s'afficheront lors de la création d'une pièce basée sur cette catégorie.
|
</p>
|
||||||
</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>
|
</header>
|
||||||
|
|
||||||
<p v-if="!products.length" class="text-xs text-gray-500">
|
<p v-if="!products.length" class="text-xs text-gray-500">
|
||||||
@@ -71,18 +65,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</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>
|
||||||
|
|
||||||
<section class="space-y-3">
|
<section class="space-y-3">
|
||||||
<header class="flex items-center justify-between">
|
<h3 class="text-sm font-semibold">
|
||||||
<h3 class="text-sm font-semibold">
|
Champs personnalisés
|
||||||
Champs personnalisés
|
</h3>
|
||||||
</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>
|
|
||||||
|
|
||||||
<p v-if="!fields.length" class="text-xs text-gray-500">
|
<p v-if="!fields.length" class="text-xs text-gray-500">
|
||||||
Aucun champ personnalisé n'a encore été défini.
|
Aucun champ personnalisé n'a encore été défini.
|
||||||
@@ -101,106 +93,94 @@
|
|||||||
@drop.prevent="onDrop(index)"
|
@drop.prevent="onDrop(index)"
|
||||||
@dragend="onDragEnd"
|
@dragend="onDragEnd"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<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">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
|
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing mt-1"
|
||||||
disabled
|
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" />
|
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
<div v-else class="tooltip tooltip-left" data-tip="Ce champ ne peut pas être supprimé car des éléments utilisent cette catégorie">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
</li>
|
</ul>
|
||||||
</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
||||||
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
|
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
import IconLucideTrash from '~icons/lucide/trash'
|
import IconLucideTrash from '~icons/lucide/trash'
|
||||||
import type {
|
import type { PieceModelStructure } from '~/shared/types/inventory'
|
||||||
PieceModelCustomField,
|
import { usePieceStructureEditorLogic } from '~/composables/usePieceStructureEditorLogic'
|
||||||
PieceModelCustomFieldType,
|
|
||||||
PieceModelProduct,
|
|
||||||
PieceModelStructure,
|
|
||||||
PieceModelStructureEditorField,
|
|
||||||
} from '~/shared/types/inventory'
|
|
||||||
import { normalizePieceStructureForSave } from '~/shared/modelUtils'
|
|
||||||
import { useProductTypes } from '~/composables/useProductTypes'
|
|
||||||
|
|
||||||
defineOptions({ name: 'PieceModelStructureEditor' })
|
defineOptions({ name: 'PieceModelStructureEditor' })
|
||||||
|
|
||||||
type EditorField = PieceModelStructureEditorField & { uid: string }
|
|
||||||
type EditorProduct = {
|
|
||||||
uid: string
|
|
||||||
typeProductId: string
|
|
||||||
typeProductLabel: string
|
|
||||||
familyCode: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue?: PieceModelStructure | null
|
modelValue?: PieceModelStructure | null
|
||||||
restrictedMode?: boolean
|
restrictedMode?: boolean
|
||||||
@@ -210,373 +190,23 @@ const emit = defineEmits<{
|
|||||||
(event: 'update:modelValue', value: PieceModelStructure): void
|
(event: 'update:modelValue', value: PieceModelStructure): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { productTypes, loadProductTypes } = useProductTypes()
|
const {
|
||||||
|
fields,
|
||||||
const ensureArray = <T,>(value: T[] | null | undefined): T[] =>
|
products,
|
||||||
Array.isArray(value) ? value : []
|
productTypeOptions,
|
||||||
|
restrictedMode,
|
||||||
const normalizeLineEndings = (value: string): string =>
|
isFieldLocked,
|
||||||
value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
isProductLocked,
|
||||||
|
formatProductTypeOption,
|
||||||
const safeClone = <T,>(value: T, fallback: T): T => {
|
handleProductTypeSelect,
|
||||||
try {
|
addProduct,
|
||||||
return JSON.parse(JSON.stringify(value ?? fallback)) as T
|
removeProduct,
|
||||||
} catch {
|
addField,
|
||||||
return JSON.parse(JSON.stringify(fallback)) as T
|
removeField,
|
||||||
}
|
reorderClass,
|
||||||
}
|
onDragStart,
|
||||||
|
onDragEnter,
|
||||||
const extractRest = (structure?: PieceModelStructure | null): Record<string, unknown> => {
|
onDrop,
|
||||||
if (!structure || typeof structure !== 'object') {
|
onDragEnd,
|
||||||
return {}
|
} = usePieceStructureEditorLogic({ props, emit })
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -70,15 +70,9 @@
|
|||||||
|
|
||||||
<div class="px-4 py-4 space-y-5">
|
<div class="px-4 py-4 space-y-5">
|
||||||
<section v-if="isRoot" class="space-y-3">
|
<section v-if="isRoot" class="space-y-3">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<h4 :class="headingClass">
|
||||||
<h4 :class="headingClass">
|
{{ isRoot ? 'Champs personnalisés du composant' : 'Champs personnalisés' }}
|
||||||
{{ isRoot ? 'Champs personnalisés du composant' : 'Champs personnalisés' }}
|
</h4>
|
||||||
</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>
|
|
||||||
<p v-if="!(node.customFields?.length)" class="text-xs text-gray-500">
|
<p v-if="!(node.customFields?.length)" class="text-xs text-gray-500">
|
||||||
Aucun champ n'a encore été défini.
|
Aucun champ n'a encore été défini.
|
||||||
</p>
|
</p>
|
||||||
@@ -155,18 +149,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<section v-if="isRoot" class="space-y-3">
|
<section v-if="isRoot" class="space-y-3">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<h4 :class="headingClass">
|
||||||
<h4 :class="headingClass">
|
{{ isRoot ? 'Produits inclus par défaut' : 'Produits' }}
|
||||||
{{ isRoot ? 'Produits inclus par défaut' : 'Produits' }}
|
</h4>
|
||||||
</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>
|
|
||||||
<p v-if="!(node.products?.length)" class="text-xs text-gray-500">
|
<p v-if="!(node.products?.length)" class="text-xs text-gray-500">
|
||||||
Aucun produit défini.
|
Aucun produit défini.
|
||||||
</p>
|
</p>
|
||||||
@@ -228,18 +220,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<section v-if="isRoot" class="space-y-3">
|
<section v-if="isRoot" class="space-y-3">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<h4 :class="headingClass">
|
||||||
<h4 :class="headingClass">
|
{{ isRoot ? 'Pièces incluses par défaut' : 'Pièces' }}
|
||||||
{{ isRoot ? 'Pièces incluses par défaut' : 'Pièces' }}
|
</h4>
|
||||||
</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>
|
|
||||||
<p v-if="!(node.pieces?.length)" class="text-xs text-gray-500">
|
<p v-if="!(node.pieces?.length)" class="text-xs text-gray-500">
|
||||||
Aucune pièce définie.
|
Aucune pièce définie.
|
||||||
</p>
|
</p>
|
||||||
@@ -302,21 +292,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<section v-if="canManageSubcomponents || hasSubcomponents" class="space-y-3">
|
<section v-if="canManageSubcomponents || hasSubcomponents" class="space-y-3">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<h4 :class="headingClass">Sous-composants</h4>
|
||||||
<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>
|
|
||||||
<p v-if="!isRoot && canManageSubcomponents" class="text-[11px] text-gray-500">
|
<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.
|
Sélectionnez uniquement la famille de ce sous-composant ; il sera configuré via son propre modèle.
|
||||||
</p>
|
</p>
|
||||||
@@ -357,6 +340,15 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -364,26 +356,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
import IconLucideTrash from '~icons/lucide/trash'
|
import IconLucideTrash from '~icons/lucide/trash'
|
||||||
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
|
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' })
|
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<{
|
const props = withDefaults(defineProps<{
|
||||||
node: EditableStructureNode
|
node: EditableStructureNode
|
||||||
depth?: number
|
depth?: number
|
||||||
@@ -413,754 +392,60 @@ const props = withDefaults(defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits(['remove'])
|
const emit = defineEmits(['remove'])
|
||||||
|
|
||||||
const initialCustomFieldIndices = ref<Set<number>>(new Set())
|
const {
|
||||||
const initialPieceIndices = ref<Set<number>>(new Set())
|
isCustomFieldLocked,
|
||||||
const initialProductIndices = ref<Set<number>>(new Set())
|
isPieceLocked,
|
||||||
const initialSubcomponentIndices = ref<Set<number>>(new Set())
|
isProductLocked,
|
||||||
|
isSubcomponentLocked,
|
||||||
const initializeLockedIndices = () => {
|
isLocked,
|
||||||
if (props.restrictedMode) {
|
restrictedMode,
|
||||||
const customFieldsLength = Array.isArray(props.node.customFields) ? props.node.customFields.length : 0
|
componentTypes,
|
||||||
const piecesLength = Array.isArray(props.node.pieces) ? props.node.pieces.length : 0
|
pieceTypes,
|
||||||
const productsLength = Array.isArray(props.node.products) ? props.node.products.length : 0
|
productTypes,
|
||||||
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(
|
|
||||||
canManageSubcomponents,
|
canManageSubcomponents,
|
||||||
(allowed) => {
|
childAllowSubcomponents,
|
||||||
if (!allowed && Array.isArray(props.node.subcomponents) && props.node.subcomponents.length) {
|
hasSubcomponents,
|
||||||
props.node.subcomponents.splice(0, props.node.subcomponents.length)
|
containerClass,
|
||||||
}
|
headingClass,
|
||||||
},
|
lockedTypeDisplay,
|
||||||
{ immediate: true }
|
getComponentTypeLabel,
|
||||||
)
|
getPieceTypeLabel,
|
||||||
|
formatComponentTypeOption,
|
||||||
watch(componentTypes, () => {
|
formatPieceTypeOption,
|
||||||
syncComponentType(props.node)
|
formatProductTypeOption,
|
||||||
}, { deep: true, immediate: true })
|
handleComponentTypeSelect,
|
||||||
|
handlePieceTypeSelect,
|
||||||
watch(
|
handleProductTypeSelect,
|
||||||
() => props.node.typeComposantId,
|
addCustomField,
|
||||||
() => {
|
removeCustomField,
|
||||||
syncComponentType(props.node)
|
addPiece,
|
||||||
},
|
removePiece,
|
||||||
)
|
addProduct,
|
||||||
|
removeProduct,
|
||||||
watch(pieceTypes, () => {
|
addSubComponent,
|
||||||
syncPieceLabels(props.node?.pieces)
|
removeSubComponent,
|
||||||
}, { deep: true, immediate: true })
|
onCustomFieldDragStart,
|
||||||
|
onCustomFieldDragEnter,
|
||||||
watch(
|
onCustomFieldDrop,
|
||||||
() => props.node.pieces,
|
onCustomFieldDragEnd,
|
||||||
(value) => {
|
customFieldReorderClass,
|
||||||
syncPieceLabels(value)
|
onPieceDragStart,
|
||||||
},
|
onPieceDragEnter,
|
||||||
{ deep: true }
|
onPieceDragOver,
|
||||||
)
|
onPieceDrop,
|
||||||
|
onPieceDragEnd,
|
||||||
watch(productTypes, () => {
|
pieceReorderClass,
|
||||||
syncProductLabels(props.node?.products)
|
onProductDragStart,
|
||||||
}, { deep: true, immediate: true })
|
onProductDragEnter,
|
||||||
|
onProductDragOver,
|
||||||
watch(
|
onProductDrop,
|
||||||
() => props.node.products,
|
onProductDragEnd,
|
||||||
(value) => {
|
productReorderClass,
|
||||||
syncProductLabels(value)
|
onSubcomponentDragStart,
|
||||||
},
|
onSubcomponentDragEnter,
|
||||||
{ deep: true }
|
onSubcomponentDragOver,
|
||||||
)
|
onSubcomponentDrop,
|
||||||
|
onSubcomponentDragEnd,
|
||||||
watch(
|
subcomponentReorderClass,
|
||||||
() => props.node.customFields,
|
} = useStructureNodeLogic(props)
|
||||||
(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 }
|
|
||||||
)
|
|
||||||
</script>
|
</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) -->
|
<!-- Table (always shown when there are filterable columns, even during loading or with 0 rows) -->
|
||||||
<template v-else>
|
<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) -->
|
<!-- Loading overlay (keeps table & filter inputs visible) -->
|
||||||
<div
|
<div
|
||||||
v-if="loading && hasFilterableColumns"
|
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="card-body">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h2 class="card-title">Composants</h2>
|
<h2 class="card-title">Composants</h2>
|
||||||
<div class="flex items-center gap-2">
|
<button
|
||||||
<button
|
type="button"
|
||||||
v-if="isEditMode"
|
class="btn btn-ghost btn-sm gap-2"
|
||||||
type="button"
|
@click="$emit('toggle-collapse')"
|
||||||
class="btn btn-sm md:btn-md btn-primary"
|
:title="collapsed ? 'Déplier tous les composants' : 'Replier tous les composants'"
|
||||||
@click="$emit('add-component')"
|
>
|
||||||
>
|
<IconLucideChevronRight
|
||||||
Ajouter un composant
|
class="w-5 h-5 transition-transform"
|
||||||
</button>
|
:class="collapsed ? 'rotate-0' : 'rotate-90'"
|
||||||
<button
|
aria-hidden="true"
|
||||||
type="button"
|
/>
|
||||||
class="btn btn-ghost btn-sm gap-2"
|
<span class="text-sm">
|
||||||
@click="$emit('toggle-collapse')"
|
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
|
||||||
:title="collapsed ? 'Déplier tous les composants' : 'Replier tous les composants'"
|
</span>
|
||||||
>
|
</button>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div v-if="components.length === 0" class="text-sm text-gray-500 py-4">
|
<div v-if="components.length === 0" class="text-sm text-gray-500 py-4">
|
||||||
@@ -54,6 +44,15 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,31 +3,21 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h2 class="card-title">Pièces de la machine</h2>
|
<h2 class="card-title">Pièces de la machine</h2>
|
||||||
<div class="flex items-center gap-2">
|
<button
|
||||||
<button
|
type="button"
|
||||||
v-if="isEditMode"
|
class="btn btn-ghost btn-sm gap-2"
|
||||||
type="button"
|
@click="$emit('toggle-collapse')"
|
||||||
class="btn btn-sm md:btn-md btn-primary"
|
:title="collapsed ? 'Déplier toutes les pièces' : 'Replier toutes les pièces'"
|
||||||
@click="$emit('add-piece')"
|
>
|
||||||
>
|
<IconLucideChevronRight
|
||||||
Ajouter une pièce
|
class="w-5 h-5 transition-transform"
|
||||||
</button>
|
:class="collapsed ? 'rotate-0' : 'rotate-90'"
|
||||||
<button
|
aria-hidden="true"
|
||||||
type="button"
|
/>
|
||||||
class="btn btn-ghost btn-sm gap-2"
|
<span class="text-sm">
|
||||||
@click="$emit('toggle-collapse')"
|
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
|
||||||
:title="collapsed ? 'Déplier toutes les pièces' : 'Replier toutes les pièces'"
|
</span>
|
||||||
>
|
</button>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div v-if="pieces.length === 0" class="text-sm text-gray-500 py-4">
|
<div v-if="pieces.length === 0" class="text-sm text-gray-500 py-4">
|
||||||
@@ -54,6 +44,15 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -15,19 +15,9 @@
|
|||||||
Produits sélectionnés directement pour cette machine.
|
Produits sélectionnés directement pour cette machine.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<span class="badge badge-outline" v-if="products.length">
|
||||||
<button
|
{{ products.length }} produit{{ products.length > 1 ? 's' : '' }}
|
||||||
v-if="isEditMode"
|
</span>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="products.length" class="space-y-3">
|
<div v-if="products.length" class="space-y-3">
|
||||||
@@ -117,6 +107,15 @@
|
|||||||
<p v-else class="text-xs text-gray-500">
|
<p v-else class="text-xs text-gray-500">
|
||||||
Aucun produit n'a été associé directement à cette machine.
|
Aucun produit n'a été associé directement à cette machine.
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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[] = [
|
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',
|
version: 'v1.8.0',
|
||||||
date: '2026-03-03',
|
date: '2026-03-03',
|
||||||
|
|||||||
@@ -124,16 +124,15 @@ import { computed, onMounted } from 'vue'
|
|||||||
import DataTable from '~/components/common/DataTable.vue'
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
import { useComposants } from '~/composables/useComposants'
|
import { useComposants } from '~/composables/useComposants'
|
||||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
|
||||||
import { useDataTable } from '~/composables/useDataTable'
|
import { useDataTable } from '~/composables/useDataTable'
|
||||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
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 { canEdit } = usePermissions()
|
||||||
const { showError } = useToast()
|
const { composants, total, loadComposants, loading: loadingComposants, deleteComposant } = useComposants()
|
||||||
const { composants, total, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
|
|
||||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||||
const loadingComposants = computed(() => loadingComposantsRef.value)
|
|
||||||
|
|
||||||
const table = useDataTable(
|
const table = useDataTable(
|
||||||
{ fetchData: fetchComposants },
|
{ 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>) => {
|
const resolveComponentType = (component: Record<string, any>) => {
|
||||||
if (component?.typeComposant?.name) return component.typeComposant.name
|
if (component?.typeComposant?.name) return component.typeComposant.name
|
||||||
if (component?.typeComposantLabel) return component.typeComposantLabel
|
if (component?.typeComposantLabel) return component.typeComposantLabel
|
||||||
return '—'
|
return '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveDeleteGuard = (component: Record<string, any>) => {
|
const { confirm } = useConfirm()
|
||||||
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 handleDeleteComponent = async (component: Record<string, any>) => {
|
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 componentName = component?.name || 'ce composant'
|
||||||
const confirmLines = [`Voulez-vous vraiment supprimer ${componentName} ?`]
|
const message = buildDeleteMessage(componentName, resolveDeleteImpact(component))
|
||||||
if (hasCustomFields) {
|
const confirmed = await confirm({ title: 'Supprimer le composant', message, dangerous: true })
|
||||||
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') })
|
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
await deleteComposant(component.id)
|
await deleteComposant(component.id)
|
||||||
fetchComposants()
|
fetchComposants()
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = formatFrenchDate
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([fetchComposants(), loadComponentTypes()])
|
await Promise.all([fetchComposants(), loadComponentTypes()])
|
||||||
|
|||||||
@@ -138,91 +138,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<StructureSkeletonPreview
|
||||||
<div class="flex items-center justify-between gap-4">
|
v-if="selectedType"
|
||||||
<div>
|
:structure="selectedTypeStructure"
|
||||||
<h2 class="font-semibold text-base-content">Squelette sélectionné</h2>
|
:description="selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.'"
|
||||||
<p class="text-xs text-base-content/70">
|
:preview-badge="formatStructurePreview(selectedTypeStructure)"
|
||||||
{{ selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.' }}
|
variant="component"
|
||||||
</p>
|
show-empty-state
|
||||||
</div>
|
:resolve-piece-label="resolvePieceLabel"
|
||||||
<span class="badge badge-outline">{{ formatStructurePreview(selectedTypeStructure) }}</span>
|
:resolve-product-label="resolveProductLabel"
|
||||||
</div>
|
:resolve-subcomponent-label="resolveSubcomponentLabel"
|
||||||
|
/>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="structureSelections.hasAny"
|
v-if="structureSelections.hasAny"
|
||||||
@@ -275,78 +201,7 @@
|
|||||||
Mettez à jour les valeurs propres à ce composant.
|
Mettez à jour les valeurs propres à ce composant.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<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">
|
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
|
||||||
Chargement des documents en cours…
|
Chargement des documents en cours…
|
||||||
</p>
|
</p>
|
||||||
<div v-else-if="componentDocuments.length" class="space-y-2">
|
<DocumentListInline
|
||||||
<div
|
v-else
|
||||||
v-for="document in componentDocuments"
|
:documents="componentDocuments"
|
||||||
:key="document.id || document.path || document.name"
|
:can-delete="canEdit"
|
||||||
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
|
:delete-disabled="uploadingDocuments"
|
||||||
>
|
empty-text="Aucun document n'est associé à ce composant pour le moment."
|
||||||
<div class="flex items-center gap-3 text-sm">
|
@preview="openPreview"
|
||||||
<div
|
@delete="removeDocument"
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<EntityHistorySection
|
||||||
<header class="flex items-center justify-between gap-3">
|
:entries="history"
|
||||||
<div>
|
:loading="historyLoading"
|
||||||
<h2 class="font-semibold text-base-content">Historique</h2>
|
:error="historyError"
|
||||||
<p class="text-xs text-base-content/70">
|
:field-labels="historyFieldLabels"
|
||||||
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>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||||
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
||||||
@@ -559,32 +290,27 @@ import { useToast } from '~/composables/useToast'
|
|||||||
import { extractRelationId } from '~/shared/apiRelations'
|
import { extractRelationId } from '~/shared/apiRelations'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
import { useComponentHistory, type ComponentHistoryEntry } from '~/composables/useComponentHistory'
|
import { useComponentHistory } from '~/composables/useComponentHistory'
|
||||||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
import { uniqueConstructeurIds } 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 { ComponentModelStructure } from '~/shared/types/inventory'
|
||||||
import type { ModelType } from '~/services/modelTypes'
|
import type { ModelType } from '~/services/modelTypes'
|
||||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||||
import {
|
import {
|
||||||
type CustomFieldInput,
|
type CustomFieldInput,
|
||||||
fieldKey,
|
|
||||||
buildCustomFieldInputs,
|
buildCustomFieldInputs,
|
||||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||||
saveCustomFieldValues as _saveCustomFieldValues,
|
saveCustomFieldValues as _saveCustomFieldValues,
|
||||||
} from '~/shared/utils/customFieldFormUtils'
|
} 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 {
|
interface ComponentCatalogType extends ModelType {
|
||||||
structure: ComponentModelStructure | null
|
structure: ComponentModelStructure | null
|
||||||
@@ -622,8 +348,6 @@ const componentDocuments = ref<any[]>([])
|
|||||||
const previewDocument = ref<any | null>(null)
|
const previewDocument = ref<any | null>(null)
|
||||||
const previewVisible = ref(false)
|
const previewVisible = ref(false)
|
||||||
|
|
||||||
const historyEntries = computed<ComponentHistoryEntry[]>(() => history.value)
|
|
||||||
|
|
||||||
const historyFieldLabels: Record<string, string> = {
|
const historyFieldLabels: Record<string, string> = {
|
||||||
name: 'Nom',
|
name: 'Nom',
|
||||||
reference: 'Référence',
|
reference: 'Référence',
|
||||||
@@ -634,8 +358,6 @@ const historyFieldLabels: Record<string, string> = {
|
|||||||
constructeurIds: 'Fournisseurs',
|
constructeurIds: 'Fournisseurs',
|
||||||
}
|
}
|
||||||
|
|
||||||
const historyDiffEntries = (entry: ComponentHistoryEntry) =>
|
|
||||||
_historyDiffEntries(entry, historyFieldLabels)
|
|
||||||
const selectedTypeId = ref<string>('')
|
const selectedTypeId = ref<string>('')
|
||||||
const editionForm = reactive({
|
const editionForm = reactive({
|
||||||
name: '' as string,
|
name: '' as string,
|
||||||
@@ -647,23 +369,13 @@ const editionForm = reactive({
|
|||||||
|
|
||||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||||
const fetchedPieceTypeMap = ref<Record<string, string>>({})
|
const fetchedPieceTypeMap = ref<Record<string, string>>({})
|
||||||
const pieceTypeLabelMap = computed(() => ({
|
const pieceTypeLabelMap = computed(() =>
|
||||||
...Object.fromEntries(
|
buildTypeLabelMap(pieceTypes.value, fetchedPieceTypeMap.value),
|
||||||
(pieceTypes.value || [])
|
)
|
||||||
.filter((type: any) => type?.id)
|
|
||||||
.map((type: any) => [type.id, type.name || type.code || '']),
|
|
||||||
),
|
|
||||||
...fetchedPieceTypeMap.value,
|
|
||||||
}))
|
|
||||||
const fetchedProductTypeMap = ref<Record<string, string>>({})
|
const fetchedProductTypeMap = ref<Record<string, string>>({})
|
||||||
const productTypeLabelMap = computed(() => ({
|
const productTypeLabelMap = computed(() =>
|
||||||
...Object.fromEntries(
|
buildTypeLabelMap(productTypes.value, fetchedProductTypeMap.value),
|
||||||
(productTypes.value || [])
|
)
|
||||||
.filter((type: any) => type?.id)
|
|
||||||
.map((type: any) => [type.id, type.name || type.code || '']),
|
|
||||||
),
|
|
||||||
...fetchedProductTypeMap.value,
|
|
||||||
}))
|
|
||||||
const pieceCatalogMap = computed(() =>
|
const pieceCatalogMap = computed(() =>
|
||||||
new Map(
|
new Map(
|
||||||
(pieces.value || [])
|
(pieces.value || [])
|
||||||
@@ -802,52 +514,45 @@ const fetchComponent = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let initialized = false
|
const initialized = ref(false)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[component, selectedTypeStructure],
|
[component, selectedTypeStructure],
|
||||||
([currentComponent, currentStructure]) => {
|
([currentComponent, currentStructure]) => {
|
||||||
if (!currentComponent || initialized) {
|
if (!currentComponent) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedTypeId = currentComponent.typeComposantId
|
if (!initialized.value) {
|
||||||
|| extractRelationId(currentComponent.typeComposant)
|
const resolvedTypeId = currentComponent.typeComposantId
|
||||||
|| ''
|
|| extractRelationId(currentComponent.typeComposant)
|
||||||
if (resolvedTypeId && !currentComponent.typeComposantId) {
|
|| ''
|
||||||
currentComponent.typeComposantId = resolvedTypeId
|
if (resolvedTypeId && !currentComponent.typeComposantId) {
|
||||||
}
|
currentComponent.typeComposantId = resolvedTypeId
|
||||||
selectedTypeId.value = resolvedTypeId
|
}
|
||||||
|
selectedTypeId.value = resolvedTypeId
|
||||||
|
|
||||||
editionForm.name = currentComponent.name || ''
|
editionForm.name = currentComponent.name || ''
|
||||||
editionForm.description = currentComponent.description || ''
|
editionForm.description = currentComponent.description || ''
|
||||||
editionForm.reference = currentComponent.reference || ''
|
editionForm.reference = currentComponent.reference || ''
|
||||||
editionForm.constructeurIds = uniqueConstructeurIds(
|
editionForm.constructeurIds = uniqueConstructeurIds(
|
||||||
currentComponent,
|
currentComponent,
|
||||||
Array.isArray(currentComponent.constructeurs) ? currentComponent.constructeurs : [],
|
Array.isArray(currentComponent.constructeurs) ? currentComponent.constructeurs : [],
|
||||||
currentComponent.constructeur ? [currentComponent.constructeur] : [],
|
currentComponent.constructeur ? [currentComponent.constructeur] : [],
|
||||||
)
|
)
|
||||||
editionForm.prix = currentComponent.prix !== null && currentComponent.prix !== undefined ? String(currentComponent.prix) : ''
|
editionForm.prix = currentComponent.prix !== null && currentComponent.prix !== undefined ? String(currentComponent.prix) : ''
|
||||||
if (editionForm.constructeurIds.length) {
|
if (editionForm.constructeurIds.length) {
|
||||||
void ensureConstructeurs(editionForm.constructeurIds)
|
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)
|
refreshCustomFieldInputs(selectedTypeStructure.value ?? currentStructure, currentComponent.customFieldValues)
|
||||||
|
|
||||||
initialized = true
|
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(selectedTypeStructure, (currentStructure) => {
|
|
||||||
if (!component.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
refreshCustomFieldInputs(currentStructure, component.value.customFieldValues)
|
|
||||||
})
|
|
||||||
|
|
||||||
const submitEdition = async () => {
|
const submitEdition = async () => {
|
||||||
if (!component.value) {
|
if (!component.value) {
|
||||||
return
|
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 =>
|
const isNonEmptyString = (value: unknown): value is string =>
|
||||||
typeof value === 'string' && value.trim().length > 0
|
typeof value === 'string' && value.trim().length > 0
|
||||||
|
|
||||||
const resolvePieceLabel = (piece: Record<string, any>) => {
|
const resolvePieceLabel = (piece: Record<string, any>) =>
|
||||||
const parts: string[] = []
|
_resolvePieceLabel(piece, pieceTypeLabelMap.value)
|
||||||
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 resolveProductLabel = (product: Record<string, any>) =>
|
||||||
const missing = ids.filter((id) => id && !pieceTypeLabelMap.value[id])
|
_resolveProductLabel(product, productTypeLabelMap.value)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
selectedTypeStructure,
|
selectedTypeStructure,
|
||||||
@@ -1017,45 +620,31 @@ watch(
|
|||||||
.map((piece: any) => piece?.typePieceId)
|
.map((piece: any) => piece?.typePieceId)
|
||||||
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
|
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
|
||||||
if (pieceIds.length) {
|
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)
|
const productIds = getStructureProducts(structure)
|
||||||
.map((product: any) => product?.typeProductId)
|
.map((product: any) => product?.typeProductId)
|
||||||
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
|
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
|
||||||
if (productIds.length) {
|
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 },
|
{ 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 = {
|
type SelectionEntry = {
|
||||||
id: string
|
id: string
|
||||||
path: string
|
path: string
|
||||||
@@ -1163,11 +752,13 @@ onMounted(async () => {
|
|||||||
])
|
])
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
|
||||||
// Defer bulk catalog loads — not needed for initial render
|
// Defer bulk catalog loads — only needed when component has structure selections
|
||||||
Promise.allSettled([
|
if (component.value?.structure) {
|
||||||
loadPieces({ itemsPerPage: 200 }),
|
Promise.allSettled([
|
||||||
loadProducts({ itemsPerPage: 200 }),
|
loadPieces({ itemsPerPage: 200 }),
|
||||||
loadComposants({ itemsPerPage: 200 }),
|
loadProducts({ itemsPerPage: 200 }),
|
||||||
]).catch(() => {})
|
loadComposants({ itemsPerPage: 200 }),
|
||||||
|
]).catch(() => {})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -109,84 +109,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<StructureSkeletonPreview
|
||||||
<div class="flex items-center justify-between gap-4">
|
v-if="selectedType"
|
||||||
<div>
|
:structure="selectedTypeStructure"
|
||||||
<h2 class="font-semibold text-base-content">Squelette sélectionné</h2>
|
:description="selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.'"
|
||||||
<p class="text-xs text-base-content/70">
|
:preview-badge="formatStructurePreview(selectedTypeStructure)"
|
||||||
{{ selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.' }}
|
variant="component"
|
||||||
</p>
|
:resolve-piece-label="resolvePieceLabel"
|
||||||
</div>
|
:resolve-product-label="resolveProductLabel"
|
||||||
<span class="badge badge-outline">{{ formatStructurePreview(selectedTypeStructure) }}</span>
|
:resolve-subcomponent-label="resolveSubcomponentLabel"
|
||||||
</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>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="structureHasRequirements"
|
v-if="structureHasRequirements"
|
||||||
@@ -241,78 +173,7 @@
|
|||||||
Renseignez les valeurs propres à ce composant selon le squelette choisi.
|
Renseignez les valeurs propres à ce composant selon le squelette choisi.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<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 { useDocuments } from '~/composables/useDocuments'
|
||||||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||||
import {
|
import {
|
||||||
toFieldString,
|
type CustomFieldInput,
|
||||||
|
normalizeCustomFieldInputs,
|
||||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||||
|
saveCustomFieldValues as _saveCustomFieldValues,
|
||||||
} from '~/shared/utils/customFieldFormUtils'
|
} from '~/shared/utils/customFieldFormUtils'
|
||||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||||
|
import {
|
||||||
|
getStructurePieces,
|
||||||
|
resolvePieceLabel as _resolvePieceLabel,
|
||||||
|
resolveProductLabel as _resolveProductLabel,
|
||||||
|
resolveSubcomponentLabel,
|
||||||
|
fetchModelTypeNames,
|
||||||
|
buildTypeLabelMap,
|
||||||
|
} from '~/shared/utils/structureDisplayUtils'
|
||||||
import type {
|
import type {
|
||||||
ComponentModelPiece,
|
ComponentModelPiece,
|
||||||
ComponentModelProduct,
|
ComponentModelProduct,
|
||||||
@@ -396,7 +267,7 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { get } = useApi()
|
const { get } = useApi()
|
||||||
|
|
||||||
const { componentTypes, loadComponentTypes, loadingComponentTypes } = useComponentTypes()
|
const { componentTypes, loadComponentTypes, loadingComponentTypes: loadingTypes } = useComponentTypes()
|
||||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||||
const { productTypes, loadProductTypes } = useProductTypes()
|
const { productTypes, loadProductTypes } = useProductTypes()
|
||||||
const {
|
const {
|
||||||
@@ -417,8 +288,7 @@ const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
|||||||
const { uploadDocuments } = useDocuments()
|
const { uploadDocuments } = useDocuments()
|
||||||
const { canEdit } = usePermissions()
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
const selectedTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
||||||
const selectedTypeId = ref<string>(initialTypeId.value)
|
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const creationForm = reactive({
|
const creationForm = reactive({
|
||||||
name: '' as string,
|
name: '' as string,
|
||||||
@@ -441,27 +311,14 @@ const structureDataLoading = computed(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const fetchedPieceTypeMap = ref<Record<string, string>>({})
|
const fetchedPieceTypeMap = ref<Record<string, string>>({})
|
||||||
const pieceTypeLabelMap = computed(() => ({
|
const pieceTypeLabelMap = computed(() =>
|
||||||
...Object.fromEntries(
|
buildTypeLabelMap(pieceTypes.value, fetchedPieceTypeMap.value),
|
||||||
(pieceTypes.value || [])
|
)
|
||||||
.filter((type: any) => type?.id)
|
|
||||||
.map((type: any) => [type.id, type.name || type.code || '']),
|
|
||||||
),
|
|
||||||
...fetchedPieceTypeMap.value,
|
|
||||||
}))
|
|
||||||
const productTypeLabelMap = computed(() =>
|
const productTypeLabelMap = computed(() =>
|
||||||
Object.fromEntries(
|
buildTypeLabelMap(productTypes.value),
|
||||||
(productTypes.value || [])
|
|
||||||
.filter((type: any) => type?.id)
|
|
||||||
.map((type: any) => [type.id, type.name || type.code || '']),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
const componentTypeLabelMap = computed(() =>
|
const componentTypeLabelMap = computed(() =>
|
||||||
Object.fromEntries(
|
buildTypeLabelMap(componentTypes.value),
|
||||||
(componentTypes.value || [])
|
|
||||||
.filter((type: any) => type?.id)
|
|
||||||
.map((type: any) => [type.id, type.name || type.code || '']),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -487,7 +344,6 @@ watch(selectedTypeId, (id) => {
|
|||||||
router.replace({ path: route.path, query: nextQuery }).catch(() => {})
|
router.replace({ path: route.path, query: nextQuery }).catch(() => {})
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadingTypes = computed(() => loadingComponentTypes.value)
|
|
||||||
const componentTypeList = computed<ComponentCatalogType[]>(() =>
|
const componentTypeList = computed<ComponentCatalogType[]>(() =>
|
||||||
(componentTypes.value || [])
|
(componentTypes.value || [])
|
||||||
.filter((item: any) => item?.category === 'COMPONENT') as ComponentCatalogType[],
|
.filter((item: any) => item?.category === 'COMPONENT') as ComponentCatalogType[],
|
||||||
@@ -779,69 +635,11 @@ const canSubmit = computed(() => Boolean(
|
|||||||
!submitting.value,
|
!submitting.value,
|
||||||
))
|
))
|
||||||
|
|
||||||
const getStructureCustomFields = (structure: ComponentModelStructure | null) => {
|
const resolvePieceLabel = (piece: Record<string, any>) =>
|
||||||
return Array.isArray(structure?.customFields) ? structure.customFields : []
|
_resolvePieceLabel(piece, pieceTypeLabelMap.value)
|
||||||
}
|
|
||||||
|
|
||||||
const getStructurePieces = (structure: ComponentModelStructure | null) => {
|
const resolveProductLabel = (product: Record<string, any>) =>
|
||||||
return Array.isArray(structure?.pieces) ? structure.pieces : []
|
_resolveProductLabel(product, productTypeLabelMap.value)
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
selectedTypeStructure,
|
selectedTypeStructure,
|
||||||
@@ -852,56 +650,17 @@ watch(
|
|||||||
if (!ids.length) {
|
if (!ids.length) {
|
||||||
return
|
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 },
|
{ 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 = () => {
|
const clearCreationForm = () => {
|
||||||
creationForm.name = ''
|
creationForm.name = ''
|
||||||
creationForm.description = ''
|
creationForm.description = ''
|
||||||
@@ -975,7 +734,13 @@ const submitCreation = async () => {
|
|||||||
try {
|
try {
|
||||||
const result = await createComposant(payload)
|
const result = await createComposant(payload)
|
||||||
if (result.success) {
|
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) {
|
if (selectedDocuments.value.length && result.data?.id) {
|
||||||
uploadingDocuments.value = true
|
uploadingDocuments.value = true
|
||||||
const uploadResult = await uploadDocuments(
|
const uploadResult = await uploadDocuments(
|
||||||
@@ -1013,276 +778,4 @@ onMounted(async () => {
|
|||||||
loadProductTypes(),
|
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>
|
</script>
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ import { useConstructeurs } from '~/composables/useConstructeurs'
|
|||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { usePersistedValue } from '~/composables/usePersistedValue'
|
import { usePersistedValue } from '~/composables/usePersistedValue'
|
||||||
import { formatPhone } from '~/utils/formatters/phone'
|
import { formatPhone } from '~/utils/formatters/phone'
|
||||||
|
import { formatFrenchDate } from '~/utils/date'
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
|
|
||||||
const { canEdit } = usePermissions()
|
const { canEdit } = usePermissions()
|
||||||
@@ -153,16 +154,7 @@ const debouncedSearch = debounce(async () => {
|
|||||||
await searchConstructeurs(searchTerm.value)
|
await searchConstructeurs(searchTerm.value)
|
||||||
}, 300)
|
}, 300)
|
||||||
|
|
||||||
const formatDate = (dateStr) => {
|
const formatDate = formatFrenchDate
|
||||||
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 formatPhoneDisplay = (value) => {
|
const formatPhoneDisplay = (value) => {
|
||||||
const formatted = formatPhone(value)
|
const formatted = formatPhone(value)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<DocumentPreviewModal
|
<DocumentPreviewModal
|
||||||
:document="previewDocument"
|
:document="previewDocument"
|
||||||
:visible="previewVisible"
|
:visible="previewVisible"
|
||||||
:documents="documentRows"
|
:documents="documents"
|
||||||
@close="closePreview"
|
@close="closePreview"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<div class="card-body space-y-6">
|
<div class="card-body space-y-6">
|
||||||
<DataTable
|
<DataTable
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:rows="documentRows"
|
:rows="documents"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:sort="table.sort.value"
|
:sort="table.sort.value"
|
||||||
:pagination="paginationState"
|
:pagination="paginationState"
|
||||||
@@ -148,7 +148,6 @@ const attachmentFilter = table.filters.filter as Ref<string>
|
|||||||
const previewDocument = ref<any>(null)
|
const previewDocument = ref<any>(null)
|
||||||
const previewVisible = ref(false)
|
const previewVisible = ref(false)
|
||||||
|
|
||||||
const documentRows = computed(() => documents.value)
|
|
||||||
const documentsOnPage = computed(() => documents.value.length)
|
const documentsOnPage = computed(() => documents.value.length)
|
||||||
const paginationState = table.pagination(total, documentsOnPage)
|
const paginationState = table.pagination(total, documentsOnPage)
|
||||||
|
|
||||||
|
|||||||
@@ -258,120 +258,27 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add Site Modal -->
|
<!-- Add Site Modal -->
|
||||||
<div v-if="showAddSiteModal" class="modal modal-open">
|
<HomeAddSiteModal
|
||||||
<div class="modal-box">
|
:open="showAddSiteModal"
|
||||||
<h3 class="font-bold text-lg mb-4">
|
:disabled="!canEdit"
|
||||||
Ajouter un nouveau site
|
@close="showAddSiteModal = false"
|
||||||
</h3>
|
@create="handleCreateSite"
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Add Machine Modal -->
|
<!-- Add Machine Modal -->
|
||||||
<div v-if="showAddMachineModal" class="modal modal-open">
|
<HomeAddMachineModal
|
||||||
<div class="modal-box max-w-2xl">
|
:open="showAddMachineModal"
|
||||||
<h3 class="font-bold text-lg mb-4">
|
:sites="sites"
|
||||||
Ajouter une nouvelle machine
|
:disabled="!canEdit"
|
||||||
</h3>
|
:preselected-site-id="preselectedSiteId"
|
||||||
<form @submit.prevent="handleCreateMachine">
|
@close="showAddMachineModal = false"
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
@create="handleCreateMachine"
|
||||||
<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>
|
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
|
|
||||||
import { useSites } from '~/composables/useSites'
|
import { useSites } from '~/composables/useSites'
|
||||||
import { useMachines } from '~/composables/useMachines'
|
import { useMachines } from '~/composables/useMachines'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
@@ -396,21 +303,7 @@ const showAddMachineModal = ref(false)
|
|||||||
const searchTerm = ref('')
|
const searchTerm = ref('')
|
||||||
const selectedSiteFilter = ref('')
|
const selectedSiteFilter = ref('')
|
||||||
const collapsedSites = ref([])
|
const collapsedSites = ref([])
|
||||||
|
const preselectedSiteId = ref('')
|
||||||
const newSite = reactive({
|
|
||||||
name: '',
|
|
||||||
contactName: '',
|
|
||||||
contactPhone: '',
|
|
||||||
contactAddress: '',
|
|
||||||
contactPostalCode: '',
|
|
||||||
contactCity: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const newMachine = reactive({
|
|
||||||
name: '',
|
|
||||||
siteId: '',
|
|
||||||
reference: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const machinesBySiteId = computed(() => {
|
const machinesBySiteId = computed(() => {
|
||||||
@@ -491,39 +384,17 @@ const filteredSites = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
const handleCreateSite = async () => {
|
const handleCreateSite = async (data) => {
|
||||||
const result = await createSite({
|
const result = await createSite(data)
|
||||||
name: newSite.name,
|
|
||||||
contactName: newSite.contactName,
|
|
||||||
contactPhone: newSite.contactPhone,
|
|
||||||
contactAddress: newSite.contactAddress,
|
|
||||||
contactPostalCode: newSite.contactPostalCode,
|
|
||||||
contactCity: newSite.contactCity
|
|
||||||
})
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showAddSiteModal.value = false
|
showAddSiteModal.value = false
|
||||||
|
|
||||||
// Reset form
|
|
||||||
newSite.name = ''
|
|
||||||
newSite.contactName = ''
|
|
||||||
newSite.contactPhone = ''
|
|
||||||
newSite.contactAddress = ''
|
|
||||||
newSite.contactPostalCode = ''
|
|
||||||
newSite.contactCity = ''
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreateMachine = async () => {
|
const handleCreateMachine = async (data) => {
|
||||||
const result = await createMachine({
|
const result = await createMachine(data)
|
||||||
name: newMachine.name,
|
|
||||||
siteId: newMachine.siteId,
|
|
||||||
reference: newMachine.reference
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
newMachine.name = ''
|
|
||||||
newMachine.siteId = ''
|
|
||||||
newMachine.reference = ''
|
|
||||||
showAddMachineModal.value = false
|
showAddMachineModal.value = false
|
||||||
await loadMachines()
|
await loadMachines()
|
||||||
}
|
}
|
||||||
@@ -573,7 +444,7 @@ const confirmDeleteMachine = async (machine) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addMachineToSite = (site) => {
|
const addMachineToSite = (site) => {
|
||||||
newMachine.siteId = site.id
|
preselectedSiteId.value = site.id
|
||||||
showAddMachineModal.value = true
|
showAddMachineModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -147,16 +147,15 @@ import { computed, onMounted } from 'vue'
|
|||||||
import DataTable from '~/components/common/DataTable.vue'
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
import { usePieces } from '~/composables/usePieces'
|
import { usePieces } from '~/composables/usePieces'
|
||||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
|
||||||
import { useDataTable } from '~/composables/useDataTable'
|
import { useDataTable } from '~/composables/useDataTable'
|
||||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
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 { canEdit } = usePermissions()
|
||||||
const { showError } = useToast()
|
const { pieces, total, loadPieces, loading: loadingPieces, deletePiece } = usePieces()
|
||||||
const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = usePieces()
|
|
||||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||||
const loadingPieces = computed(() => loadingPiecesRef.value)
|
|
||||||
|
|
||||||
const table = useDataTable(
|
const table = useDataTable(
|
||||||
{ fetchData: fetchPieces },
|
{ 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>) => {
|
const resolvePieceType = (piece: Record<string, any>) => {
|
||||||
if (piece?.typePiece?.name) return piece.typePiece.name
|
if (piece?.typePiece?.name) return piece.typePiece.name
|
||||||
if (piece?.typePieceLabel) return piece.typePieceLabel
|
if (piece?.typePieceLabel) return piece.typePieceLabel
|
||||||
return '—'
|
return '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_VISIBLE_SUPPLIERS = 3
|
const buildPieceSuppliersDisplay = (piece: Record<string, any>) =>
|
||||||
|
buildSuppliersDisplay(resolveSupplierNames(piece, 'product'))
|
||||||
|
|
||||||
const resolvePieceSuppliers = (piece: Record<string, any>) => {
|
const { confirm } = useConfirm()
|
||||||
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 handleDeletePiece = async (piece: Record<string, any>) => {
|
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 pieceName = piece?.name || 'cette pièce'
|
||||||
const confirmLines = [`Voulez-vous vraiment supprimer ${pieceName} ?`]
|
const message = buildDeleteMessage(pieceName, resolveDeleteImpact(piece))
|
||||||
if (hasCustomFields) {
|
const confirmed = await confirm({ title: 'Supprimer la pièce', message, dangerous: true })
|
||||||
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') })
|
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
await deletePiece(piece.id)
|
await deletePiece(piece.id)
|
||||||
fetchPieces()
|
fetchPieces()
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = formatFrenchDate
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([fetchPieces(), loadPieceTypes()])
|
await Promise.all([fetchPieces(), loadPieceTypes()])
|
||||||
|
|||||||
@@ -182,38 +182,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="selectedType || resolvedStructure" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<StructureSkeletonPreview
|
||||||
<div class="flex items-center justify-between gap-4">
|
v-if="selectedType || resolvedStructure"
|
||||||
<div>
|
:structure="resolvedStructure"
|
||||||
<h2 class="font-semibold text-base-content">Squelette sélectionné</h2>
|
:description="selectedType?.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.'"
|
||||||
<p class="text-xs text-base-content/70">
|
:preview-badge="formatPieceStructurePreview(resolvedStructure)"
|
||||||
{{ selectedType?.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.' }}
|
variant="piece"
|
||||||
</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>
|
|
||||||
|
|
||||||
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<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">
|
<header class="space-y-1">
|
||||||
@@ -222,78 +197,7 @@
|
|||||||
Mettez à jour les valeurs propres à cette pièce.
|
Mettez à jour les valeurs propres à cette pièce.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<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">
|
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
|
||||||
Chargement des documents en cours…
|
Chargement des documents en cours…
|
||||||
</p>
|
</p>
|
||||||
<div v-else-if="pieceDocuments.length" class="space-y-2">
|
<DocumentListInline
|
||||||
<div
|
v-else
|
||||||
v-for="document in pieceDocuments"
|
:documents="pieceDocuments"
|
||||||
:key="document.id || document.path || document.name"
|
:can-delete="canEdit"
|
||||||
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
|
:delete-disabled="uploadingDocuments"
|
||||||
>
|
empty-text="Aucun document n'est associé à cette pièce pour le moment."
|
||||||
<div class="flex items-center gap-3 text-sm">
|
@preview="openPreview"
|
||||||
<div
|
@delete="removeDocument"
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<EntityHistorySection
|
||||||
<header class="flex items-center justify-between gap-3">
|
:entries="history"
|
||||||
<div>
|
:loading="historyLoading"
|
||||||
<h2 class="font-semibold text-base-content">Historique</h2>
|
:error="historyError"
|
||||||
<p class="text-xs text-base-content/70">
|
:field-labels="historyFieldLabels"
|
||||||
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>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||||
<NuxtLink to="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
<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 { useToast } from '~/composables/useToast'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
import { usePieceHistory, type PieceHistoryEntry } from '~/composables/usePieceHistory'
|
import { usePieceHistory } from '~/composables/usePieceHistory'
|
||||||
import { extractRelationId } from '~/shared/apiRelations'
|
import { extractRelationId } from '~/shared/apiRelations'
|
||||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||||
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
||||||
@@ -512,24 +292,10 @@ import type { ModelType } from '~/services/modelTypes'
|
|||||||
import { getModelType } from '~/services/modelTypes'
|
import { getModelType } from '~/services/modelTypes'
|
||||||
import {
|
import {
|
||||||
type CustomFieldInput,
|
type CustomFieldInput,
|
||||||
fieldKey,
|
|
||||||
buildCustomFieldInputs,
|
buildCustomFieldInputs,
|
||||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||||
saveCustomFieldValues as _saveCustomFieldValues,
|
saveCustomFieldValues as _saveCustomFieldValues,
|
||||||
} from '~/shared/utils/customFieldFormUtils'
|
} 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 {
|
interface PieceCatalogType extends ModelType {
|
||||||
structure: PieceModelStructure | null
|
structure: PieceModelStructure | null
|
||||||
@@ -563,8 +329,6 @@ const pieceDocuments = ref<any[]>([])
|
|||||||
const previewDocument = ref<any | null>(null)
|
const previewDocument = ref<any | null>(null)
|
||||||
const previewVisible = ref(false)
|
const previewVisible = ref(false)
|
||||||
|
|
||||||
const historyEntries = computed<PieceHistoryEntry[]>(() => history.value)
|
|
||||||
|
|
||||||
const historyFieldLabels: Record<string, string> = {
|
const historyFieldLabels: Record<string, string> = {
|
||||||
name: 'Nom',
|
name: 'Nom',
|
||||||
reference: 'Référence',
|
reference: 'Référence',
|
||||||
@@ -575,9 +339,6 @@ const historyFieldLabels: Record<string, string> = {
|
|||||||
constructeurIds: 'Fournisseurs',
|
constructeurIds: 'Fournisseurs',
|
||||||
}
|
}
|
||||||
|
|
||||||
const historyDiffEntries = (entry: PieceHistoryEntry) =>
|
|
||||||
_historyDiffEntries(entry, historyFieldLabels)
|
|
||||||
|
|
||||||
const selectedTypeId = ref<string>('')
|
const selectedTypeId = ref<string>('')
|
||||||
const pieceTypeDetails = ref<any | null>(null)
|
const pieceTypeDetails = ref<any | null>(null)
|
||||||
const editionForm = reactive({
|
const editionForm = reactive({
|
||||||
@@ -671,9 +432,6 @@ const selectedType = computed(() => {
|
|||||||
const getStructureProducts = (structure: PieceModelStructure | null) =>
|
const getStructureProducts = (structure: PieceModelStructure | null) =>
|
||||||
Array.isArray(structure?.products) ? structure.products : []
|
Array.isArray(structure?.products) ? structure.products : []
|
||||||
|
|
||||||
const getStructureCustomFields = (structure: PieceModelStructure | null) =>
|
|
||||||
Array.isArray(structure?.customFields) ? structure.customFields : []
|
|
||||||
|
|
||||||
const structureProducts = computed(() =>
|
const structureProducts = computed(() =>
|
||||||
getStructureProducts(resolvedStructure.value),
|
getStructureProducts(resolvedStructure.value),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -153,38 +153,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<StructureSkeletonPreview
|
||||||
<div class="flex items-center justify-between gap-4">
|
v-if="selectedType"
|
||||||
<div>
|
:structure="selectedType.structure"
|
||||||
<h2 class="font-semibold text-base-content">Squelette sélectionné</h2>
|
:description="selectedType.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.'"
|
||||||
<p class="text-xs text-base-content/70">
|
:preview-badge="formatPieceStructurePreview(selectedType.structure)"
|
||||||
{{ selectedType.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.' }}
|
variant="piece"
|
||||||
</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>
|
|
||||||
|
|
||||||
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<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">
|
<header class="space-y-1">
|
||||||
@@ -193,78 +168,7 @@
|
|||||||
Renseignez les valeurs propres à cette pièce. Ces champs complètent le squelette sélectionné.
|
Renseignez les valeurs propres à cette pièce. Ces champs complètent le squelette sélectionné.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<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 { ModelType } from '~/services/modelTypes'
|
||||||
import {
|
import {
|
||||||
type CustomFieldInput,
|
type CustomFieldInput,
|
||||||
fieldKey,
|
|
||||||
normalizeCustomFieldInputs,
|
normalizeCustomFieldInputs,
|
||||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||||
saveCustomFieldValues as _saveCustomFieldValues,
|
saveCustomFieldValues as _saveCustomFieldValues,
|
||||||
@@ -338,7 +241,7 @@ interface PieceCatalogType extends ModelType {
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const { pieceTypes, loadPieceTypes, loadingPieceTypes } = usePieceTypes()
|
const { pieceTypes, loadPieceTypes, loadingPieceTypes: loadingTypes } = usePieceTypes()
|
||||||
const { createPiece } = usePieces()
|
const { createPiece } = usePieces()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||||
@@ -385,7 +288,6 @@ watch(selectedTypeId, (id) => {
|
|||||||
router.replace({ path: route.path, query: nextQuery }).catch(() => {})
|
router.replace({ path: route.path, query: nextQuery }).catch(() => {})
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadingTypes = computed(() => loadingPieceTypes.value)
|
|
||||||
const pieceTypeList = computed<PieceCatalogType[]>(() => (pieceTypes.value || []) as PieceCatalogType[])
|
const pieceTypeList = computed<PieceCatalogType[]>(() => (pieceTypes.value || []) as PieceCatalogType[])
|
||||||
|
|
||||||
const typeOptionLabel = (type?: PieceCatalogType) =>
|
const typeOptionLabel = (type?: PieceCatalogType) =>
|
||||||
@@ -401,9 +303,6 @@ const selectedType = computed(() => {
|
|||||||
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
|
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) =>
|
const getStructureProducts = (structure: PieceModelStructure | null) =>
|
||||||
Array.isArray(structure?.products) ? structure.products : []
|
Array.isArray(structure?.products) ? structure.products : []
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
v-else
|
v-else
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:rows="productRows"
|
:rows="productRows"
|
||||||
:loading="loadingProducts"
|
:loading="loading"
|
||||||
:sort="table.sort.value"
|
:sort="table.sort.value"
|
||||||
:pagination="paginationState"
|
:pagination="paginationState"
|
||||||
:column-filters="table.columnFilters.value"
|
:column-filters="table.columnFilters.value"
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
|
|
||||||
<template #cell-preview="{ row }">
|
<template #cell-preview="{ row }">
|
||||||
<DocumentThumbnail
|
<DocumentThumbnail
|
||||||
:document="resolvePrimaryDocument(row.product)"
|
:document="resolvePrimaryDocument(row.product, true)"
|
||||||
:alt="resolvePreviewAlt(row.product)"
|
:alt="resolvePreviewAlt(row.product)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -147,7 +147,8 @@ import { useProductTypes } from '~/composables/useProductTypes'
|
|||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { useDataTable } from '~/composables/useDataTable'
|
import { useDataTable } from '~/composables/useDataTable'
|
||||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
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()
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
@@ -169,7 +170,6 @@ const table = useDataTable(
|
|||||||
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true },
|
{ 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 errorMessage = computed(() => (typeof error.value === 'string' && error.value.length ? error.value : null))
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
@@ -197,7 +197,7 @@ const productRows = computed(() =>
|
|||||||
normalizedProducts.value.map(product => ({
|
normalizedProducts.value.map(product => ({
|
||||||
id: product.id,
|
id: product.id,
|
||||||
product,
|
product,
|
||||||
suppliers: buildSuppliersDisplay(product),
|
suppliers: buildProductSuppliersDisplay(product),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -225,85 +225,21 @@ const formatPrice = (value: any) => {
|
|||||||
return Number.isNaN(number) ? '—' : priceFormatter.format(number)
|
return Number.isNaN(number) ? '—' : priceFormatter.format(number)
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_VISIBLE_SUPPLIERS = 3
|
const buildProductSuppliersDisplay = (product: Record<string, any>) =>
|
||||||
|
buildSuppliersDisplay(resolveSupplierNames(product))
|
||||||
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 reload = () => fetchProducts()
|
const reload = () => fetchProducts()
|
||||||
|
|
||||||
const { confirm } = useConfirm()
|
const { confirm } = useConfirm()
|
||||||
|
|
||||||
const confirmDelete = async (product: Record<string, any>) => {
|
const confirmDelete = async (product: Record<string, any>) => {
|
||||||
const confirmed = await confirm({
|
const productName = product?.name || 'ce produit'
|
||||||
message: `Voulez-vous vraiment supprimer le produit "${product.name}" ?\n\nCette action est irréversible.`,
|
const message = buildDeleteMessage(productName, resolveDeleteImpact(product))
|
||||||
})
|
const confirmed = await confirm({ title: 'Supprimer le produit', message, dangerous: true })
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
const result = await deleteProduct(product.id)
|
const result = await deleteProduct(product.id)
|
||||||
if (result.success) {
|
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.
|
Mettez à jour les valeurs propres à ce produit.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<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">
|
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
|
||||||
Chargement des documents…
|
Chargement des documents…
|
||||||
</p>
|
</p>
|
||||||
<div v-else-if="productDocuments.length" class="space-y-2">
|
<DocumentListInline
|
||||||
<div
|
v-else
|
||||||
v-for="document in productDocuments"
|
:documents="productDocuments"
|
||||||
:key="document.id || document.path || document.name"
|
:can-delete="canEdit"
|
||||||
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
|
:delete-disabled="uploadingDocuments || saving"
|
||||||
>
|
empty-text="Aucun document n'est associé à ce produit pour le moment."
|
||||||
<div class="flex items-center gap-3 text-sm">
|
@preview="openPreview"
|
||||||
<div
|
@delete="removeDocument"
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<EntityHistorySection
|
||||||
<header class="flex items-center justify-between gap-3">
|
:entries="history"
|
||||||
<div>
|
:loading="historyLoading"
|
||||||
<h2 class="font-semibold text-base-content">Historique</h2>
|
:error="historyError"
|
||||||
<p class="text-xs text-base-content/70">
|
:field-labels="historyFieldLabels"
|
||||||
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>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||||
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
||||||
@@ -410,7 +219,7 @@ import { useToast } from '~/composables/useToast'
|
|||||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
import { useProductHistory, type ProductHistoryEntry } from '~/composables/useProductHistory'
|
import { useProductHistory } from '~/composables/useProductHistory'
|
||||||
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
||||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||||
import { getModelType } from '~/services/modelTypes'
|
import { getModelType } from '~/services/modelTypes'
|
||||||
@@ -418,24 +227,10 @@ import type { ProductModelStructure } from '~/shared/types/inventory'
|
|||||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||||
import {
|
import {
|
||||||
type CustomFieldInput,
|
type CustomFieldInput,
|
||||||
fieldKey,
|
|
||||||
buildCustomFieldInputs,
|
buildCustomFieldInputs,
|
||||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||||
saveCustomFieldValues as _saveCustomFieldValues,
|
saveCustomFieldValues as _saveCustomFieldValues,
|
||||||
} from '~/shared/utils/customFieldFormUtils'
|
} 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 { canEdit } = usePermissions()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -469,8 +264,6 @@ const productDocuments = ref<any[]>([])
|
|||||||
const previewDocument = ref<any | null>(null)
|
const previewDocument = ref<any | null>(null)
|
||||||
const previewVisible = ref(false)
|
const previewVisible = ref(false)
|
||||||
|
|
||||||
const historyEntries = computed<ProductHistoryEntry[]>(() => history.value)
|
|
||||||
|
|
||||||
const historyFieldLabels: Record<string, string> = {
|
const historyFieldLabels: Record<string, string> = {
|
||||||
name: 'Nom',
|
name: 'Nom',
|
||||||
reference: 'Référence',
|
reference: 'Référence',
|
||||||
@@ -479,9 +272,6 @@ const historyFieldLabels: Record<string, string> = {
|
|||||||
constructeurIds: 'Fournisseurs',
|
constructeurIds: 'Fournisseurs',
|
||||||
}
|
}
|
||||||
|
|
||||||
const historyDiffEntries = (entry: ProductHistoryEntry) =>
|
|
||||||
_historyDiffEntries(entry, historyFieldLabels)
|
|
||||||
|
|
||||||
const refreshCustomFieldInputs = (
|
const refreshCustomFieldInputs = (
|
||||||
structureOverride?: ProductModelStructure | null,
|
structureOverride?: ProductModelStructure | null,
|
||||||
valuesOverride?: any[] | null,
|
valuesOverride?: any[] | null,
|
||||||
|
|||||||
@@ -119,78 +119,7 @@
|
|||||||
Renseignez les valeurs propres à ce produit catalogue.
|
Renseignez les valeurs propres à ce produit catalogue.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<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 route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const { productTypes, loadProductTypes, loadingProductTypes } = useProductTypes()
|
const { productTypes, loadProductTypes, loadingProductTypes: loadingTypes } = useProductTypes()
|
||||||
const { createProduct } = useProducts()
|
const { createProduct } = useProducts()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { upsertCustomFieldValue } = useCustomFields()
|
const { upsertCustomFieldValue } = useCustomFields()
|
||||||
@@ -283,7 +212,6 @@ const uploadingDocuments = ref(false)
|
|||||||
|
|
||||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||||
|
|
||||||
const loadingTypes = computed(() => loadingProductTypes.value)
|
|
||||||
const productTypeList = computed<ProductCatalogType[]>(() =>
|
const productTypeList = computed<ProductCatalogType[]>(() =>
|
||||||
(productTypes.value || []) as ProductCatalogType[],
|
(productTypes.value || []) as ProductCatalogType[],
|
||||||
)
|
)
|
||||||
@@ -354,9 +282,6 @@ const canSubmit = computed(() => Boolean(
|
|||||||
!submitting.value,
|
!submitting.value,
|
||||||
))
|
))
|
||||||
|
|
||||||
const fieldKey = (field: CustomFieldInput, index: number) =>
|
|
||||||
field.customFieldId || field.id || `${field.name}-${index}`
|
|
||||||
|
|
||||||
const clearForm = () => {
|
const clearForm = () => {
|
||||||
creationForm.name = ''
|
creationForm.name = ''
|
||||||
creationForm.reference = ''
|
creationForm.reference = ''
|
||||||
|
|||||||
@@ -1,13 +1,36 @@
|
|||||||
import {
|
import {
|
||||||
createEmptyComponentModelStructure,
|
createEmptyComponentModelStructure,
|
||||||
type ComponentModelCustomFieldType,
|
|
||||||
type ComponentModelCustomField,
|
type ComponentModelCustomField,
|
||||||
type ComponentModelPiece,
|
|
||||||
type ComponentModelProduct,
|
|
||||||
type ComponentModelStructure,
|
type ComponentModelStructure,
|
||||||
type ComponentModelStructureNode,
|
type ComponentModelStructureNode,
|
||||||
} from '../types/inventory'
|
} 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> => {
|
export const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
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 => {
|
export const normalizeStructureForEditor = (input: any): ComponentModelStructure => {
|
||||||
const source = cloneStructure(input)
|
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) => {
|
export const extractStructureFromComponent = (component: any) => {
|
||||||
if (!component) {
|
if (!component) {
|
||||||
return defaultStructure()
|
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).
|
* Formatte une date en respectant les conventions françaises (jj/mm/aaaa).
|
||||||
* Retourne "—" si la valeur est invalide ou absente.
|
* 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 => {
|
export const formatFrenchDate = (value: Date | string | number | null | undefined): string => {
|
||||||
if (value === null || value === undefined || value === '') {
|
if (value === null || value === undefined || value === '') {
|
||||||
return '—'
|
return '—'
|
||||||
@@ -12,9 +18,5 @@ export const formatFrenchDate = (value: Date | string | number | null | undefine
|
|||||||
return '—'
|
return '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('fr-FR', {
|
return frenchDateFormatter.format(date)
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
}).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