refactor(frontend) : extract assignment fetch logic into composable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
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'
|
||||||
|
}
|
||||||
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