From e911f169ce09ad59dfe9ffbc397f0030cb2b2015 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 8 Mar 2026 15:07:40 +0100 Subject: [PATCH] refactor(frontend) : extract assignment fetch logic into composable Co-Authored-By: Claude Opus 4.6 --- .../ComponentStructureAssignmentNode.vue | 563 ++------------- .../useStructureAssignmentFetch.ts | 366 ++++++++++ app/shared/utils/structureAssignmentLabels.ts | 259 +++++++ ...2026-03-08-reduce-files-under-500-lines.md | 647 ++++++++++++++++++ 4 files changed, 1322 insertions(+), 513 deletions(-) create mode 100644 app/composables/useStructureAssignmentFetch.ts create mode 100644 app/shared/utils/structureAssignmentLabels.ts create mode 100644 docs/plans/2026-03-08-reduce-files-under-500-lines.md diff --git a/app/components/ComponentStructureAssignmentNode.vue b/app/components/ComponentStructureAssignmentNode.vue index c9f3037..a52349d 100644 --- a/app/components/ComponentStructureAssignmentNode.vue +++ b/app/components/ComponentStructureAssignmentNode.vue @@ -134,76 +134,24 @@ diff --git a/app/composables/useStructureAssignmentFetch.ts b/app/composables/useStructureAssignmentFetch.ts new file mode 100644 index 0000000..996ef1c --- /dev/null +++ b/app/composables/useStructureAssignmentFetch.ts @@ -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 + productTypeLabelMap: Record + componentTypeLabelMap: Record +} + +export function useStructureAssignmentFetch(deps: StructureAssignmentFetchDeps) { + const { get } = useApi() + + const pieceOptionsByPath = ref>({}) + const productOptionsByPath = ref>({}) + const componentOptionsByPath = ref>({}) + const pieceLoadingByPath = ref>({}) + const productLoadingByPath = ref>({}) + const componentLoadingByPath = ref>({}) + + const setLoading = (target: Record, key: string, value: boolean) => { + target[key] = value + } + + const typeIri = (id: string) => `/api/model_types/${id}` + const primedPiecePaths = new Set() + const primedProductPaths = new Set() + const primedComponentPaths = new Set() + + // --- 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, + } +} diff --git a/app/shared/utils/structureAssignmentLabels.ts b/app/shared/utils/structureAssignmentLabels.ts new file mode 100644 index 0000000..35c2caf --- /dev/null +++ b/app/shared/utils/structureAssignmentLabels.ts @@ -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 => { + 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 => { + 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' +} diff --git a/docs/plans/2026-03-08-reduce-files-under-500-lines.md b/docs/plans/2026-03-08-reduce-files-under-500-lines.md new file mode 100644 index 0000000..105f3f3 --- /dev/null +++ b/docs/plans/2026-03-08-reduce-files-under-500-lines.md @@ -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 +