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 + + + + + + + + + + + {{ document.name }} + + {{ document.mimeType || 'Inconnu' }} • {{ formatSize(document.size) }} + + + + + + Consulter + + + Télécharger + + + Supprimer + + + + + + {{ emptyText }} + + + + +``` + +**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 + +``` +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 `` 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 `
+ {{ emptyText }} +