'use client'; import { createContext, useContext, useState, useEffect, useMemo, ReactNode } from 'react'; import { useAuth } from './AuthContext'; import { TenantAwareCache } from '@/lib/cache'; import { itemMasterApi } from '@/lib/api/item-master'; import { getErrorMessage, ApiError } from '@/lib/api/error-handler'; import { transformPageResponse, transformSectionResponse, transformFieldResponse, transformBomItemResponse, } from '@/lib/api/transformers'; import type { ItemPageRequest, IndependentSectionRequest, IndependentFieldRequest, IndependentBomItemRequest, LinkSectionRequest, LinkFieldRequest, SectionUsageResponse, FieldUsageResponse, } from '@/types/item-master-api'; // ===== Type Definitions ===== // 전개도 상세 정보 export interface BendingDetail { id: string; no: number; // 번호 input: number; // 입력 elongation: number; // 연신율 (기본값 -1) calculated: number; // 연신율 계산 후 sum: number; // 합계 shaded: boolean; // 음영 여부 aAngle?: number; // A각 } // 부품구성표(BOM, Bill of Materials) - 자재 명세서 export interface BOMLine { id: string; childItemCode: string; // 구성 품목 코드 childItemName: string; // 구성 품목명 quantity: number; // 기준 수량 unit: string; // 단위 unitPrice?: number; // 단가 quantityFormula?: string; // 수량 계산식 (예: "W * 2", "H + 100") note?: string; // 비고 // 절곡품 관련 (하위 절곡 부품용) isBending?: boolean; bendingDiagram?: string; // 전개도 이미지 URL bendingDetails?: BendingDetail[]; // 전개도 상세 데이터 } // 규격 마스터 (원자재/부자재용) export interface SpecificationMaster { id: string; specificationCode: string; // 규격 코드 (예: 1.6T x 1219 x 2438) itemType: 'RM' | 'SM'; // 원자재 | 부자재 itemName?: string; // 품목명 (예: SPHC-SD, SPCC-SD) - 품목명별 규격 필터링용 fieldCount: '1' | '2' | '3'; // 너비 입력 개수 thickness: string; // 두께 widthA: string; // 너비A widthB?: string; // 너비B widthC?: string; // 너비C length: string; // 길이 description?: string; // 설명 isActive: boolean; // 활성 여부 createdAt?: string; updatedAt?: string; } // 원자재/부자재 품목명 마스터 export interface MaterialItemName { id: string; itemType: 'RM' | 'SM'; // 원자재 | 부자재 itemName: string; // 품목명 (예: "SPHC-SD", "STS430") category?: string; // 분류 (예: "냉연", "열연", "스테인리스") description?: string; // 설명 isActive: boolean; // 활성 여부 createdAt: string; updatedAt?: string; } // 품목 수정 이력 export interface ItemRevision { revisionNumber: number; // 수정 차수 (1차, 2차, 3차...) revisionDate: string; // 수정일 revisionBy: string; // 수정자 revisionReason?: string; // 수정 사유 previousData: any; // 이전 버전의 전체 데이터 } // 품목 마스터 export interface ItemMaster { id: string; itemCode: string; itemName: string; itemType: 'FG' | 'PT' | 'SM' | 'RM' | 'CS'; // 제품, 부품, 부자재, 원자재, 소모품 productCategory?: 'SCREEN' | 'STEEL'; // 제품 카테고리 (스크린/철재) partType?: 'ASSEMBLY' | 'BENDING' | 'PURCHASED'; // 부품 유형 (조립/절곡/구매) partUsage?: 'GUIDE_RAIL' | 'BOTTOM_FINISH' | 'CASE' | 'DOOR' | 'BRACKET' | 'GENERAL'; // 부품 용도 unit: string; category1?: string; category2?: string; category3?: string; specification?: string; isVariableSize?: boolean; isActive?: boolean; // 품목 활성/비활성 (제품/부품/원자재/부자재만 사용) lotAbbreviation?: string; // 로트 약자 (제품만 사용) purchasePrice?: number; marginRate?: number; processingCost?: number; laborCost?: number; installCost?: number; salesPrice?: number; safetyStock?: number; leadTime?: number; bom?: BOMLine[]; // 부품구성표(BOM) - 자재 명세서 bomCategories?: string[]; // 견적산출용 샘플 제품의 BOM 카테고리 (예: ['motor', 'guide-rail']) // 인정 정보 certificationNumber?: string; // 인정번호 certificationStartDate?: string; // 인정 유효기간 시작일 certificationEndDate?: string; // 인정 유효기간 종료일 specificationFile?: string; // 시방서 파일 (Base64 또는 URL) specificationFileName?: string; // 시방서 파일명 certificationFile?: string; // 인정서 파일 (Base64 또는 URL) certificationFileName?: string; // 인정서 파일명 note?: string; // 비고 (제품만 사용) // 조립 부품 관련 필드 installationType?: string; // 설치 유형 (wall: 벽면형, side: 측면형, steel: 스틸, iron: 철재) assemblyType?: string; // 종류 (M, T, C, D, S, U 등) sideSpecWidth?: string; // 측면 규격 가로 (mm) sideSpecHeight?: string; // 측면 규격 세로 (mm) assemblyLength?: string; // 길이 (2438, 3000, 3500, 4000, 4300 등) // 가이드레일 관련 필드 guideRailModelType?: string; // 가이드레일 모델 유형 guideRailModel?: string; // 가이드레일 모델 // 절곡품 관련 (부품 유형이 BENDING인 경우) bendingDiagram?: string; // 전개도 이미지 URL bendingDetails?: BendingDetail[]; // 전개도 상세 데이터 material?: string; // 재질 (EGI 1.55T, SUS 1.2T 등) length?: string; // 길이/목함 (mm) // 버전 관리 currentRevision: number; // 현재 차수 (0 = 최초, 1 = 1차 수정...) revisions?: ItemRevision[]; // 수정 이력 isFinal: boolean; // 최종 확정 여부 finalizedDate?: string; // 최종 확정일 finalizedBy?: string; // 최종 확정자 createdAt: string; } // 품목 기준정보 관리 (Master Data) export interface ItemCategory { id: string; categoryType: 'PRODUCT' | 'PART' | 'MATERIAL' | 'SUB_MATERIAL'; // 품목 구분 category1: string; // 대분류 category2?: string; // 중분류 category3?: string; // 소분류 code?: string; // 코드 (자동생성 또는 수동입력) description?: string; isActive: boolean; createdAt: string; updatedAt?: string; } export interface ItemUnit { id: string; unitCode: string; // 단위 코드 (EA, SET, M, KG, L 등) unitName: string; // 단위명 description?: string; isActive: boolean; createdAt: string; updatedAt?: string; } export interface ItemMaterial { id: string; materialCode: string; // 재질 코드 materialName: string; // 재질명 (EGI 1.55T, SUS 1.2T 등) materialType: 'STEEL' | 'ALUMINUM' | 'PLASTIC' | 'OTHER'; // 재질 유형 thickness?: string; // 두께 (1.2T, 1.6T 등) description?: string; isActive: boolean; createdAt: string; updatedAt?: string; } export interface SurfaceTreatment { id: string; treatmentCode: string; // 처리 코드 treatmentName: string; // 처리명 (무도장, 파우더도장, 아노다이징 등) treatmentType: 'PAINTING' | 'COATING' | 'PLATING' | 'NONE'; // 처리 유형 description?: string; isActive: boolean; createdAt: string; updatedAt?: string; } export interface PartTypeOption { id: string; partType: 'ASSEMBLY' | 'BENDING' | 'PURCHASED'; // 부품 유형 optionCode: string; // 옵션 코드 optionName: string; // 옵션명 description?: string; isActive: boolean; createdAt: string; updatedAt?: string; } export interface PartUsageOption { id: string; usageCode: string; // 용도 코드 usageName: string; // 용도명 (가이드레일, 하단마감재, 케이스 등) description?: string; isActive: boolean; createdAt: string; updatedAt?: string; } export interface GuideRailOption { id: string; optionType: 'MODEL_TYPE' | 'MODEL' | 'CERTIFICATION' | 'SHAPE' | 'FINISH' | 'LENGTH'; // 옵션 유형 optionCode: string; // 옵션 코드 optionName: string; // 옵션명 parentOption?: string; // 상위 옵션 (종속 관계) description?: string; isActive: boolean; createdAt: string; updatedAt?: string; } // ===== 품목기준관리 계층구조 ===== // 항목 속성 export interface ItemFieldProperty { id?: string; // 속성 ID (properties 배열에서 사용) key?: string; // 속성 키 (properties 배열에서 사용) label?: string; // 속성 라벨 (properties 배열에서 사용) type?: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea'; // 속성 타입 (properties 배열에서 사용) inputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea' | 'section'; // 입력방식 required: boolean; // 필수 여부 row: number; // 행 위치 col: number; // 열 위치 options?: string[]; // 드롭다운 옵션 (입력방식이 dropdown일 경우) defaultValue?: string; // 기본값 placeholder?: string; // 플레이스홀더 multiColumn?: boolean; // 다중 컬럼 사용 여부 columnCount?: number; // 컬럼 개수 columnNames?: string[]; // 각 컬럼의 이름 } // 항목 마스터 (재사용 가능한 항목 템플릿) - MasterFieldResponse와 정확히 일치 export interface ItemMasterField { id: number; tenant_id: number; field_name: string; field_key?: string | null; // 2025-11-28: field_key 추가 (형식: {ID}_{사용자입력}) field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea'; // API와 동일 category: string | null; description: string | null; is_common: boolean; // 공통 필드 여부 is_required?: boolean; // 필수 여부 (API에서 반환) default_value: string | null; // 기본값 options: Array<{ label: string; value: string }> | null; // dropdown 옵션 validation_rules: Record | null; // 검증 규칙 properties: Record | null; // 추가 속성 created_by: number | null; updated_by: number | null; created_at: string; updated_at: string; } // 조건부 표시 설정 export interface FieldDisplayCondition { targetType: 'field' | 'section'; // 조건 대상 타입 // 일반항목 조건 (여러 개 가능) fieldConditions?: Array<{ fieldKey: string; // 조건이 되는 필드의 키 expectedValue: string; // 예상되는 값 }>; // 섹션 조건 (여러 개 가능) sectionIds?: string[]; // 표시할 섹션 ID 배열 } // 항목 (Field) - API 응답 구조에 맞춰 수정 export interface ItemField { id: number; // 서버 생성 ID (string → number) tenant_id?: number; // 백엔드에서 자동 추가 group_id?: number | null; // 그룹 ID (독립 필드용) section_id: number | null; // 외래키 - 섹션 ID (독립 필드는 null) master_field_id?: number | null; // 마스터 항목 ID (마스터에서 가져온 경우) field_name: string; // 항목명 (name → field_name) field_key?: string | null; // 2025-11-28: 필드 키 (형식: {ID}_{사용자입력}, 백엔드에서 생성) field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea'; // 필드 타입 order_no: number; // 항목 순서 (order → order_no, required) is_required: boolean; // 필수 여부 placeholder?: string | null; // 플레이스홀더 default_value?: string | null; // 기본값 display_condition?: Record | null; // 조건부 표시 설정 (displayCondition → display_condition) validation_rules?: Record | null; // 검증 규칙 options?: Array<{ label: string; value: string }> | null; // dropdown 옵션 properties?: Record | null; // 추가 속성 // 2025-11-28 추가: 잠금 기능 is_locked?: boolean; // 잠금 여부 locked_by?: number | null; // 잠금 설정자 locked_at?: string | null; // 잠금 시간 created_by?: number | null; // 생성자 ID 추가 updated_by?: number | null; // 수정자 ID 추가 created_at: string; // 생성일 (camelCase → snake_case) updated_at: string; // 수정일 추가 } // BOM 아이템 타입 - API 응답 구조에 맞춰 수정 export interface BOMItem { id: number; // 서버 생성 ID (string → number) tenant_id?: number; // 백엔드에서 자동 추가 group_id?: number | null; // 그룹 ID (독립 BOM용) section_id: number | null; // 외래키 - 섹션 ID (독립 BOM은 null) item_code?: string | null; // 품목 코드 (itemCode → item_code, optional) item_name: string; // 품목명 (itemName → item_name) quantity: number; // 수량 unit?: string | null; // 단위 (optional) unit_price?: number | null; // 단가 추가 total_price?: number | null; // 총액 추가 spec?: string | null; // 규격/사양 추가 note?: string | null; // 비고 (optional) created_by?: number | null; // 생성자 ID 추가 updated_by?: number | null; // 수정자 ID 추가 created_at: string; // 생성일 (createdAt → created_at) updated_at: string; // 수정일 추가 } // 섹션 (Section) - API 응답 구조에 맞춰 수정 export interface ItemSection { id: number; // 서버 생성 ID (string → number) tenant_id?: number; // 백엔드에서 자동 추가 group_id?: number | null; // 그룹 ID (독립 섹션 그룹화용) - 2025-11-26 추가 page_id: number | null; // 외래키 - 페이지 ID (null이면 독립 섹션) - 2025-11-26 수정 title: string; // 섹션 제목 (API 필드명과 일치하도록 section_name → title) section_type: 'BASIC' | 'BOM' | 'CUSTOM'; // 섹션 타입 (type → section_type, 값 변경) description?: string | null; // 설명 order_no: number; // 섹션 순서 (order → order_no) is_template: boolean; // 템플릿 여부 (section_templates 통합) - 2025-11-26 추가 is_default: boolean; // 기본 템플릿 여부 - 2025-11-26 추가 is_collapsible?: boolean; // 접기/펼치기 가능 여부 (프론트엔드 전용, optional) is_default_open?: boolean; // 기본 열림 상태 (프론트엔드 전용, optional) created_by?: number | null; // 생성자 ID 추가 updated_by?: number | null; // 수정자 ID 추가 created_at: string; // 생성일 (camelCase → snake_case) updated_at: string; // 수정일 추가 fields?: ItemField[]; // 섹션에 포함된 항목들 (optional로 변경) bom_items?: BOMItem[]; // BOM 타입일 경우 BOM 품목 목록 (bomItems → bom_items) } // 페이지 (Page) - API 응답 구조에 맞춰 수정 export interface ItemPage { id: number; // 서버 생성 ID (string → number) tenant_id?: number; // 백엔드에서 자동 추가 page_name: string; // 페이지명 (camelCase → snake_case) item_type: 'FG' | 'PT' | 'SM' | 'RM' | 'CS'; // 품목유형 description?: string | null; // 설명 추가 absolute_path: string; // 절대경로 (camelCase → snake_case) is_active: boolean; // 사용 여부 (camelCase → snake_case) order_no: number; // 순서 번호 추가 created_by?: number | null; // 생성자 ID 추가 updated_by?: number | null; // 수정자 ID 추가 created_at: string; // 생성일 (camelCase → snake_case) updated_at: string; // 수정일 (camelCase → snake_case) sections: ItemSection[]; // 페이지에 포함된 섹션들 (Nested) } // 템플릿 필드 (로컬 관리용 - API에서 제공하지 않음) export interface TemplateField { id: string; name: string; fieldKey: string; property: { inputType: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea'; required: boolean; options?: string[]; multiColumn?: boolean; columnCount?: number; columnNames?: string[]; }; description?: string; } // 섹션 템플릿 (재사용 가능한 섹션) - Transformer 출력과 UI 요구사항에 맞춤 export interface SectionTemplate { id: number; tenant_id: number; template_name: string; // transformer가 title → template_name으로 변환 section_type: 'BASIC' | 'BOM' | 'CUSTOM'; // transformer가 type → section_type으로 변환 description: string | null; default_fields: TemplateField[] | null; // 기본 필드 (로컬 관리) category?: string[]; // 적용 카테고리 (로컬 관리) fields?: TemplateField[]; // 템플릿에 포함된 필드 (로컬 관리) bomItems?: BOMItem[]; // BOM 타입일 경우 BOM 품목 (로컬 관리) created_by: number | null; updated_by: number | null; created_at: string; updated_at: string; } // ===== Context Type ===== interface ItemMasterContextType { // 품목 마스터 데이터 itemMasters: ItemMaster[]; addItemMaster: (item: ItemMaster) => void; updateItemMaster: (id: string, updates: Partial) => void; deleteItemMaster: (id: string) => void; // 규격 마스터 데이터 (원자재/부자재) specificationMasters: SpecificationMaster[]; addSpecificationMaster: (spec: SpecificationMaster) => void; updateSpecificationMaster: (id: string, updates: Partial) => void; deleteSpecificationMaster: (id: string) => void; // 원자재/부자재 품목명 마스터 데이터 materialItemNames: MaterialItemName[]; addMaterialItemName: (item: MaterialItemName) => void; updateMaterialItemName: (id: string, updates: Partial) => void; deleteMaterialItemName: (id: string) => void; // 품목기준관리 - 마스터 항목 itemMasterFields: ItemMasterField[]; loadItemMasterFields: (fields: ItemMasterField[]) => void; // 초기 데이터 로딩용 (API 호출 없음) addItemMasterField: (field: Omit) => Promise; updateItemMasterField: (id: number, updates: Partial) => Promise; deleteItemMasterField: (id: number) => Promise; // 품목기준관리 - 섹션 템플릿 sectionTemplates: SectionTemplate[]; loadSectionTemplates: (templates: SectionTemplate[]) => void; // 초기 데이터 로딩용 (API 호출 없음) addSectionTemplate: (template: Omit) => Promise; updateSectionTemplate: (id: number, updates: Partial) => Promise; deleteSectionTemplate: (id: number) => Promise; // 품목기준관리 계층구조 itemPages: ItemPage[]; loadItemPages: (pages: ItemPage[]) => void; // 초기 데이터 로딩용 (API 호출 없음) addItemPage: (page: Omit) => Promise; updateItemPage: (id: number, updates: Partial) => Promise; deleteItemPage: (id: number) => Promise; reorderPages: (newOrder: Array<{ id: number; order_no: number }>) => Promise; addSectionToPage: (pageId: number, sectionData: Omit) => Promise; updateSection: (sectionId: number, updates: Partial) => Promise; deleteSection: (sectionId: number) => Promise; reorderSections: (pageId: number, sectionIds: number[]) => Promise; addFieldToSection: (sectionId: number, fieldData: Omit) => Promise; updateField: (fieldId: number, updates: Partial) => Promise; deleteField: (fieldId: number) => Promise; reorderFields: (sectionId: number, fieldIds: number[]) => Promise; // BOM 관리 addBOMItem: (sectionId: number, bomData: Omit) => Promise; updateBOMItem: (bomId: number, updates: Partial) => Promise; deleteBOMItem: (bomId: number) => Promise; // 독립 엔티티 관리 (2025-11-26 추가) independentSections: ItemSection[]; // 독립 섹션 목록 (page_id=null) independentFields: ItemField[]; // 독립 필드 목록 (section_id=null) independentBomItems: BOMItem[]; // 독립 BOM 목록 (section_id=null) loadIndependentSections: (sections: ItemSection[]) => void; loadIndependentFields: (fields: ItemField[]) => void; loadIndependentBomItems: (bomItems: BOMItem[]) => void; refreshIndependentSections: (isTemplate?: boolean) => Promise; refreshIndependentFields: () => Promise; refreshIndependentBomItems: () => Promise; createIndependentSection: (data: Omit) => Promise; createIndependentField: (data: Omit) => Promise; createIndependentBomItem: (data: Omit) => Promise; // 링크/언링크 관리 (2025-11-26 추가) linkSectionToPage: (pageId: number, sectionId: number, orderNo?: number) => Promise; unlinkSectionFromPage: (pageId: number, sectionId: number) => Promise; linkFieldToSection: (sectionId: number, fieldId: number, orderNo?: number, fieldData?: ItemField) => Promise; unlinkFieldFromSection: (sectionId: number, fieldId: number) => Promise; // 사용처 조회 (2025-11-26 추가) getSectionUsage: (sectionId: number) => Promise; getFieldUsage: (fieldId: number) => Promise; // 복제 (2025-11-26 추가) cloneSection: (sectionId: number) => Promise; cloneField: (fieldId: number) => Promise; // 품목 기준정보 관리 itemCategories: ItemCategory[]; itemUnits: ItemUnit[]; itemMaterials: ItemMaterial[]; surfaceTreatments: SurfaceTreatment[]; partTypeOptions: PartTypeOption[]; partUsageOptions: PartUsageOption[]; guideRailOptions: GuideRailOption[]; addItemCategory: (category: ItemCategory) => void; updateItemCategory: (id: string, updates: Partial) => void; deleteItemCategory: (id: string) => void; addItemUnit: (unit: ItemUnit) => void; updateItemUnit: (id: string, updates: Partial) => void; deleteItemUnit: (id: string) => void; addItemMaterial: (material: ItemMaterial) => void; updateItemMaterial: (id: string, updates: Partial) => void; deleteItemMaterial: (id: string) => void; addSurfaceTreatment: (treatment: SurfaceTreatment) => void; updateSurfaceTreatment: (id: string, updates: Partial) => void; deleteSurfaceTreatment: (id: string) => void; addPartTypeOption: (option: PartTypeOption) => void; updatePartTypeOption: (id: string, updates: Partial) => void; deletePartTypeOption: (id: string) => void; addPartUsageOption: (option: PartUsageOption) => void; updatePartUsageOption: (id: string, updates: Partial) => void; deletePartUsageOption: (id: string) => void; addGuideRailOption: (option: GuideRailOption) => void; updateGuideRailOption: (id: string, updates: Partial) => void; deleteGuideRailOption: (id: string) => void; // 캐시 및 데이터 초기화 clearCache: () => void; // TenantAwareCache 정리 resetAllData: () => void; // 모든 state 초기화 // 테넌트 정보 tenantId: number | undefined; // 현재 로그인한 사용자의 테넌트 ID } // Create context const ItemMasterContext = createContext(undefined); // Provider component export function ItemMasterProvider({ children }: { children: ReactNode }) { // ===== Initial Data ===== // ✅ Mock 데이터 주석 처리 - API에서 가져올 예정 const initialItemMasters: ItemMaster[] = []; // ✅ 빈 배열로 초기화 - API에서 데이터 로드 예정 const initialSpecificationMasters: SpecificationMaster[] = []; const initialMaterialItemNames: MaterialItemName[] = []; const initialItemCategories: ItemCategory[] = []; const initialItemUnits: ItemUnit[] = []; const initialItemMaterials: ItemMaterial[] = []; const initialSurfaceTreatments: SurfaceTreatment[] = []; const initialPartTypeOptions: PartTypeOption[] = []; const initialPartUsageOptions: PartUsageOption[] = []; const initialGuideRailOptions: GuideRailOption[] = []; const initialItemMasterFields: ItemMasterField[] = []; const initialItemPages: ItemPage[] = []; // ===== Auth & Cache Setup ===== const { currentUser } = useAuth(); const tenantId = currentUser?.tenant?.id; // ✅ TenantAwareCache 인스턴스 생성 (tenant.id 기반, SSR-safe) const cache = useMemo(() => { // 서버 환경에서는 null 반환 (sessionStorage 없음) if (typeof window === 'undefined') return null; // 클라이언트 환경에서만 캐시 생성 return tenantId ? new TenantAwareCache(tenantId, sessionStorage, 3600000) : null; }, [tenantId]); // ===== State Management (SSR-safe) ===== const [itemMasters, setItemMasters] = useState(initialItemMasters); const [specificationMasters, setSpecificationMasters] = useState(initialSpecificationMasters); const [materialItemNames, setMaterialItemNames] = useState(initialMaterialItemNames); const [itemCategories, setItemCategories] = useState(initialItemCategories); const [itemUnits, setItemUnits] = useState(initialItemUnits); const [itemMaterials, setItemMaterials] = useState(initialItemMaterials); const [surfaceTreatments, setSurfaceTreatments] = useState(initialSurfaceTreatments); const [partTypeOptions, setPartTypeOptions] = useState(initialPartTypeOptions); const [partUsageOptions, setPartUsageOptions] = useState(initialPartUsageOptions); const [guideRailOptions, setGuideRailOptions] = useState(initialGuideRailOptions); const [sectionTemplates, setSectionTemplates] = useState([]); const [itemMasterFields, setItemMasterFields] = useState(initialItemMasterFields); const [itemPages, setItemPages] = useState(initialItemPages); // 2025-11-26 추가: 독립 엔티티 상태 const [independentSections, setIndependentSections] = useState([]); const [independentFields, setIndependentFields] = useState([]); const [independentBomItems, setIndependentBomItems] = useState([]); // ✅ TenantAwareCache에서 초기 데이터 로드 useEffect(() => { if (!cache) return; // tenant.id 없으면 초기 데이터 유지 // ItemMasters const cachedItemMasters = cache.get('itemMasters'); if (cachedItemMasters) setItemMasters(cachedItemMasters); // SpecificationMasters (버전 체크) if (cache.isVersionMatch('specificationMasters', '1.0')) { const cachedSpecs = cache.get('specificationMasters'); if (cachedSpecs) setSpecificationMasters(cachedSpecs); } // MaterialItemNames (버전 체크) if (cache.isVersionMatch('materialItemNames', '1.1')) { const cachedMaterials = cache.get('materialItemNames'); if (cachedMaterials) setMaterialItemNames(cachedMaterials); } // ItemCategories const cachedCategories = cache.get('itemCategories'); if (cachedCategories) setItemCategories(cachedCategories); // ItemUnits const cachedUnits = cache.get('itemUnits'); if (cachedUnits) setItemUnits(cachedUnits); // ItemMaterials const cachedMaterials = cache.get('itemMaterials'); if (cachedMaterials) setItemMaterials(cachedMaterials); // SurfaceTreatments const cachedTreatments = cache.get('surfaceTreatments'); if (cachedTreatments) setSurfaceTreatments(cachedTreatments); // PartTypeOptions const cachedPartTypes = cache.get('partTypeOptions'); if (cachedPartTypes) setPartTypeOptions(cachedPartTypes); // PartUsageOptions const cachedPartUsages = cache.get('partUsageOptions'); if (cachedPartUsages) setPartUsageOptions(cachedPartUsages); // GuideRailOptions const cachedGuideRails = cache.get('guideRailOptions'); if (cachedGuideRails) setGuideRailOptions(cachedGuideRails); // SectionTemplates const cachedTemplates = cache.get('sectionTemplates'); if (cachedTemplates) setSectionTemplates(cachedTemplates); // ItemMasterFields const cachedFields = cache.get('itemMasterFields'); if (cachedFields) setItemMasterFields(cachedFields); // ItemPages const cachedPages = cache.get('itemPages'); if (cachedPages) setItemPages(cachedPages); }, [cache]); // ✅ TenantAwareCache 동기화 (상태 변경 시 자동 저장) useEffect(() => { if (cache && itemMasters !== initialItemMasters) { cache.set('itemMasters', itemMasters); } }, [cache, itemMasters]); useEffect(() => { if (cache && specificationMasters !== initialSpecificationMasters) { cache.set('specificationMasters', specificationMasters, '1.0'); } }, [cache, specificationMasters]); useEffect(() => { if (cache && materialItemNames !== initialMaterialItemNames) { cache.set('materialItemNames', materialItemNames, '1.1'); } }, [cache, materialItemNames]); useEffect(() => { if (cache && itemCategories !== initialItemCategories) { cache.set('itemCategories', itemCategories); } }, [cache, itemCategories]); useEffect(() => { if (cache && itemUnits !== initialItemUnits) { cache.set('itemUnits', itemUnits); } }, [cache, itemUnits]); useEffect(() => { if (cache && itemMaterials !== initialItemMaterials) { cache.set('itemMaterials', itemMaterials); } }, [cache, itemMaterials]); useEffect(() => { if (cache && surfaceTreatments !== initialSurfaceTreatments) { cache.set('surfaceTreatments', surfaceTreatments); } }, [cache, surfaceTreatments]); useEffect(() => { if (cache && partTypeOptions !== initialPartTypeOptions) { cache.set('partTypeOptions', partTypeOptions); } }, [cache, partTypeOptions]); useEffect(() => { if (cache && partUsageOptions !== initialPartUsageOptions) { cache.set('partUsageOptions', partUsageOptions); } }, [cache, partUsageOptions]); useEffect(() => { if (cache && guideRailOptions !== initialGuideRailOptions) { cache.set('guideRailOptions', guideRailOptions); } }, [cache, guideRailOptions]); useEffect(() => { if (cache) { cache.set('sectionTemplates', sectionTemplates); } }, [cache, sectionTemplates]); useEffect(() => { if (cache && itemMasterFields !== initialItemMasterFields) { cache.set('itemMasterFields', itemMasterFields); } }, [cache, itemMasterFields]); useEffect(() => { if (cache && itemPages !== initialItemPages) { cache.set('itemPages', itemPages); } }, [cache, itemPages]); // ===== CRUD Functions ===== // ItemMaster CRUD const addItemMaster = (item: ItemMaster) => { setItemMasters(prev => [...prev, item]); }; const updateItemMaster = (id: string, updates: Partial) => { setItemMasters(prev => prev.map(item => item.id === id ? { ...item, ...updates } : item)); }; const deleteItemMaster = (id: string) => { setItemMasters(prev => prev.filter(item => item.id !== id)); }; // SpecificationMaster CRUD const addSpecificationMaster = (spec: SpecificationMaster) => { setSpecificationMasters(prev => [...prev, spec]); }; const updateSpecificationMaster = (id: string, updates: Partial) => { setSpecificationMasters(prev => prev.map(spec => spec.id === id ? { ...spec, ...updates } : spec)); }; const deleteSpecificationMaster = (id: string) => { setSpecificationMasters(prev => prev.filter(spec => spec.id !== id)); }; // MaterialItemName CRUD const addMaterialItemName = (item: MaterialItemName) => { setMaterialItemNames(prev => [...prev, item]); }; const updateMaterialItemName = (id: string, updates: Partial) => { setMaterialItemNames(prev => prev.map(item => item.id === id ? { ...item, ...updates } : item)); }; const deleteMaterialItemName = (id: string) => { setMaterialItemNames(prev => prev.filter(item => item.id !== id)); }; // ItemCategory CRUD const addItemCategory = (category: ItemCategory) => { setItemCategories(prev => [...prev, category]); }; const updateItemCategory = (id: string, updates: Partial) => { setItemCategories(prev => prev.map(cat => cat.id === id ? { ...cat, ...updates } : cat)); }; const deleteItemCategory = (id: string) => { setItemCategories(prev => prev.filter(cat => cat.id !== id)); }; // ItemUnit CRUD const addItemUnit = (unit: ItemUnit) => { setItemUnits(prev => [...prev, unit]); }; const updateItemUnit = (id: string, updates: Partial) => { setItemUnits(prev => prev.map(unit => unit.id === id ? { ...unit, ...updates } : unit)); }; const deleteItemUnit = (id: string) => { setItemUnits(prev => prev.filter(unit => unit.id !== id)); }; // ItemMaterial CRUD const addItemMaterial = (material: ItemMaterial) => { setItemMaterials(prev => [...prev, material]); }; const updateItemMaterial = (id: string, updates: Partial) => { setItemMaterials(prev => prev.map(mat => mat.id === id ? { ...mat, ...updates } : mat)); }; const deleteItemMaterial = (id: string) => { setItemMaterials(prev => prev.filter(mat => mat.id !== id)); }; // SurfaceTreatment CRUD const addSurfaceTreatment = (treatment: SurfaceTreatment) => { setSurfaceTreatments(prev => [...prev, treatment]); }; const updateSurfaceTreatment = (id: string, updates: Partial) => { setSurfaceTreatments(prev => prev.map(treat => treat.id === id ? { ...treat, ...updates } : treat)); }; const deleteSurfaceTreatment = (id: string) => { setSurfaceTreatments(prev => prev.filter(treat => treat.id !== id)); }; // PartTypeOption CRUD const addPartTypeOption = (option: PartTypeOption) => { setPartTypeOptions(prev => [...prev, option]); }; const updatePartTypeOption = (id: string, updates: Partial) => { setPartTypeOptions(prev => prev.map(opt => opt.id === id ? { ...opt, ...updates } : opt)); }; const deletePartTypeOption = (id: string) => { setPartTypeOptions(prev => prev.filter(opt => opt.id !== id)); }; // PartUsageOption CRUD const addPartUsageOption = (option: PartUsageOption) => { setPartUsageOptions(prev => [...prev, option]); }; const updatePartUsageOption = (id: string, updates: Partial) => { setPartUsageOptions(prev => prev.map(opt => opt.id === id ? { ...opt, ...updates } : opt)); }; const deletePartUsageOption = (id: string) => { setPartUsageOptions(prev => prev.filter(opt => opt.id !== id)); }; // GuideRailOption CRUD const addGuideRailOption = (option: GuideRailOption) => { setGuideRailOptions(prev => [...prev, option]); }; const updateGuideRailOption = (id: string, updates: Partial) => { setGuideRailOptions(prev => prev.map(opt => opt.id === id ? { ...opt, ...updates } : opt)); }; const deleteGuideRailOption = (id: string) => { setGuideRailOptions(prev => prev.filter(opt => opt.id !== id)); }; // ItemMasterField CRUD (임시: 로컬 state) /** * 초기 데이터 로딩용: API 호출 없이 마스터 필드를 state에 로드 (덮어쓰기) */ /** * @deprecated 2025-11-27: item_fields로 통합됨. * independentFields 및 loadIndependentFields 사용 권장 */ const loadItemMasterFields = (fields: ItemMasterField[]) => { setItemMasterFields(fields); console.log('[ItemMasterContext] 마스터 필드 로드 완료:', fields.length); }; /** * @deprecated 2025-11-27: item_fields로 통합됨. * independentFields 및 createIndependentField 사용 권장 */ const addItemMasterField = async (field: Omit) => { try { // API 호출 - 2025-11-27: fields.createIndependent() 사용 (masterFields.create() deprecated) // Note: API가 ItemFieldResponse를 직접 반환 (wrapper 없음) // 2025-11-28: field_key 추가 const response = await itemMasterApi.fields.createIndependent({ field_name: field.field_name, field_key: field.field_key ?? undefined, // 2025-11-28: field_key 추가 field_type: field.field_type, category: field.category ?? undefined, description: field.description ?? undefined, is_common: field.is_common, default_value: field.default_value ?? undefined, options: field.options ?? undefined, validation_rules: field.validation_rules ?? undefined, properties: field.properties ?? undefined, }); // 응답 데이터 변환 및 state 업데이트 // 2025-11-27: API가 ItemFieldResponse를 직접 반환하므로 response를 직접 사용 // 2025-11-28: field_key 추가 const newField: ItemMasterField = { id: response.id, tenant_id: response.tenant_id, field_name: response.field_name, field_key: response.field_key, // 2025-11-28: field_key 추가 field_type: response.field_type, category: response.category, description: response.description, is_common: response.is_common, default_value: response.default_value, options: response.options, validation_rules: response.validation_rules, properties: response.properties, created_by: response.created_by, updated_by: response.updated_by, created_at: response.created_at, updated_at: response.updated_at, }; setItemMasterFields(prev => [...prev, newField]); console.log('[ItemMasterContext] 마스터 필드 생성 성공:', newField.id); } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 마스터 필드 생성 실패:', errorMessage); throw error; } }; /** * @deprecated 2025-11-27: item_fields로 통합됨. * independentFields 및 updateIndependentField 사용 권장 */ const updateItemMasterField = async (id: number, updates: Partial) => { try { // API 호출 - 2025-11-27: fields.update() 사용 (masterFields.update() deprecated) const requestData: any = {}; if (updates.field_name !== undefined) requestData.field_name = updates.field_name; if (updates.field_key !== undefined) requestData.field_key = updates.field_key; // 2025-11-28: field_key 추가 if (updates.field_type !== undefined) requestData.field_type = updates.field_type; if (updates.category !== undefined) requestData.category = updates.category; if (updates.description !== undefined) requestData.description = updates.description; if (updates.is_common !== undefined) requestData.is_common = updates.is_common; if (updates.default_value !== undefined) requestData.default_value = updates.default_value; if (updates.options !== undefined) requestData.options = updates.options; if (updates.validation_rules !== undefined) requestData.validation_rules = updates.validation_rules; if (updates.properties !== undefined) requestData.properties = updates.properties; const response = await itemMasterApi.fields.update(id, requestData); if (!response.success || !response.data) { throw new Error(response.message || '마스터 필드 수정 실패'); } // state 업데이트 - 2025-11-28: field_key 추가 setItemMasterFields(prev => prev.map(field => field.id === id ? { ...field, field_name: response.data!.field_name, field_key: response.data!.field_key, // 2025-11-28: field_key 추가 field_type: response.data!.field_type, category: response.data!.category, description: response.data!.description, is_common: response.data!.is_common, default_value: response.data!.default_value, options: response.data!.options, validation_rules: response.data!.validation_rules, properties: response.data!.properties, updated_at: response.data!.updated_at, } : field )); // 2025-11-27: 섹션탭/계층구조 실시간 반영 // sectionsAsTemplates useMemo가 [itemPages, independentSections] 둘 다 의존 // 2025-11-28: field_key 추가 setIndependentSections(prev => prev.map(section => ({ ...section, fields: (section.fields || []).map(field => field.id === id ? { ...field, field_name: response.data!.field_name, field_key: response.data!.field_key, // 2025-11-28: field_key 추가 field_type: response.data!.field_type, is_required: response.data!.is_required, default_value: response.data!.default_value, options: response.data!.options, validation_rules: response.data!.validation_rules, properties: response.data!.properties, updated_at: response.data!.updated_at, } : field ) }))); // 2025-11-28: field_key 추가 setItemPages(prev => prev.map(page => ({ ...page, sections: page.sections.map(section => ({ ...section, fields: (section.fields || []).map(field => field.id === id ? { ...field, field_name: response.data!.field_name, field_key: response.data!.field_key, // 2025-11-28: field_key 추가 field_type: response.data!.field_type, is_required: response.data!.is_required, default_value: response.data!.default_value, options: response.data!.options, validation_rules: response.data!.validation_rules, properties: response.data!.properties, updated_at: response.data!.updated_at, } : field ) })) }))); console.log('[ItemMasterContext] 마스터 필드 수정 성공:', id); } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 마스터 필드 수정 실패:', errorMessage); throw error; } }; /** * @deprecated 2025-11-27: item_fields로 통합됨. * independentFields 및 deleteIndependentField 사용 권장 */ const deleteItemMasterField = async (id: number) => { try { // API 호출 - 2025-11-27: fields.delete() 사용 (masterFields.delete() deprecated) const response = await itemMasterApi.fields.delete(id); if (!response.success) { throw new Error(response.message || '마스터 필드 삭제 실패'); } // state 업데이트 setItemMasterFields(prev => prev.filter(field => field.id !== id)); // 2025-11-27: 섹션탭/계층구조 실시간 반영 // sectionsAsTemplates useMemo가 [itemPages, independentSections] 둘 다 의존 setIndependentSections(prev => prev.map(section => ({ ...section, fields: (section.fields || []).filter(field => field.id !== id) }))); setItemPages(prev => prev.map(page => ({ ...page, sections: page.sections.map(section => ({ ...section, fields: (section.fields || []).filter(field => field.id !== id) })) }))); console.log('[ItemMasterContext] 마스터 필드 삭제 성공:', id); } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 마스터 필드 삭제 실패:', errorMessage); throw error; } }; // SectionTemplate CRUD with API /** * 초기 데이터 로딩용: API 호출 없이 섹션 템플릿을 state에 로드 (덮어쓰기) */ const loadSectionTemplates = (templates: SectionTemplate[]) => { setSectionTemplates(templates); console.log('[ItemMasterContext] 섹션 템플릿 로드 완료:', templates.length); }; const addSectionTemplate = async (template: Omit) => { try { // 프론트엔드 형식 → API 형식 변환 // template_name → title, section_type → type const apiType = template.section_type === 'BOM' ? 'bom' : 'fields'; const response = await itemMasterApi.templates.create({ title: template.template_name, type: apiType, description: template.description ?? undefined, is_default: false, // 기본값 }); if (!response.success || !response.data) { throw new Error(response.message || '섹션 템플릿 생성 실패'); } // API 응답 → 프론트엔드 형식 변환 // title → template_name, type → section_type const SECTION_TYPE_MAP: Record = { fields: 'BASIC', bom: 'BOM', }; const newTemplate: SectionTemplate = { id: response.data.id, tenant_id: response.data.tenant_id, template_name: response.data.title, section_type: SECTION_TYPE_MAP[response.data.type] || 'BASIC', description: response.data.description, default_fields: null, category: template.category, fields: template.fields, bomItems: template.bomItems, created_by: response.data.created_by, updated_by: response.data.updated_by, created_at: response.data.created_at, updated_at: response.data.updated_at, }; setSectionTemplates(prev => [...prev, newTemplate]); console.log('[ItemMasterContext] 섹션 템플릿 생성 성공:', newTemplate.id); } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 섹션 템플릿 생성 실패:', errorMessage); throw error; } }; const updateSectionTemplate = async (id: number, updates: Partial) => { try { // default_fields, fields, category, bomItems는 로컬에서만 관리 (API 미지원) const localOnlyUpdates = ['default_fields', 'fields', 'category', 'bomItems']; const hasApiUpdates = Object.keys(updates).some(key => !localOnlyUpdates.includes(key)); const hasLocalUpdates = Object.keys(updates).some(key => localOnlyUpdates.includes(key)); // API 호출이 필요한 경우에만 API 요청 if (hasApiUpdates) { // API 요청 형식으로 변환 (frontend → API) // frontend: template_name, section_type // API: title, type const requestData: any = {}; if (updates.template_name !== undefined) requestData.title = updates.template_name; if (updates.section_type !== undefined) { // section_type 변환: 'BASIC' | 'CUSTOM' → 'fields', 'BOM' → 'bom' requestData.type = updates.section_type === 'BOM' ? 'bom' : 'fields'; } if (updates.description !== undefined) requestData.description = updates.description; const response = await itemMasterApi.templates.update(id, requestData); if (!response.success || !response.data) { throw new Error(response.message || '섹션 템플릿 수정 실패'); } // state 업데이트 (API 응답 → frontend 형식으로 변환) // API 응답: title, type ('fields' | 'bom') // Frontend 형식: template_name, section_type ('BASIC' | 'BOM' | 'CUSTOM') const SECTION_TYPE_MAP: Record = { fields: 'BASIC', bom: 'BOM', }; // 1. sectionTemplates 업데이트 (섹션 탭) setSectionTemplates(prev => prev.map(template => template.id === id ? { ...template, template_name: response.data!.title, section_type: SECTION_TYPE_MAP[response.data!.type] || 'BASIC', description: response.data!.description, updated_at: response.data!.updated_at, } : template )); // 2. itemPages 업데이트 (계층구조 탭) - 같은 ID의 섹션도 동기화 setItemPages(prev => prev.map(page => ({ ...page, sections: page.sections.map(section => section.id === id ? { ...section, title: response.data!.title } : section ) }))); console.log('[ItemMasterContext] 섹션 템플릿 수정 성공 (양방향 동기화):', id); } // 로컬 전용 필드 업데이트 (default_fields, fields, category, bomItems) if (hasLocalUpdates) { setSectionTemplates(prev => prev.map(template => { if (template.id !== id) return template; const updatedTemplate = { ...template }; // default_fields 업데이트 시 fields도 같이 업데이트 if (updates.default_fields !== undefined) { updatedTemplate.default_fields = updates.default_fields; updatedTemplate.fields = updates.default_fields as TemplateField[]; } if (updates.fields !== undefined) { updatedTemplate.fields = updates.fields; updatedTemplate.default_fields = updates.fields; } if (updates.category !== undefined) { updatedTemplate.category = updates.category; } if (updates.bomItems !== undefined) { updatedTemplate.bomItems = updates.bomItems; } return updatedTemplate; })); console.log('[ItemMasterContext] 섹션 템플릿 수정 성공 (로컬):', id, Object.keys(updates).filter(k => localOnlyUpdates.includes(k))); } } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 섹션 템플릿 수정 실패:', errorMessage); throw error; } }; const deleteSectionTemplate = async (id: number) => { try { // API 호출 const response = await itemMasterApi.templates.delete(id); if (!response.success) { throw new Error(response.message || '섹션 템플릿 삭제 실패'); } // 1. sectionTemplates 업데이트 (섹션 탭) setSectionTemplates(prev => prev.filter(template => template.id !== id)); // 2. itemPages 업데이트 (계층구조 탭) - 같은 ID의 섹션도 삭제 setItemPages(prev => prev.map(page => ({ ...page, sections: page.sections.filter(section => section.id !== id) }))); console.log('[ItemMasterContext] 섹션 템플릿 삭제 성공 (양방향 동기화):', id); } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 섹션 템플릿 삭제 실패:', errorMessage); throw error; } }; // ItemPage CRUD with API /** * 초기 데이터 로딩용: API 호출 없이 페이지 데이터를 state에 로드 (덮어쓰기) * (이미 백엔드에서 받아온 데이터를 로드할 때 사용) */ const loadItemPages = (pages: ItemPage[]) => { setItemPages(pages); // 덮어쓰기 (append가 아님!) console.log('[ItemMasterContext] 페이지 데이터 로드 완료:', pages.length); }; /** * 새 페이지 생성: API 호출 + state 업데이트 * (사용자가 새 페이지를 만들 때 사용) * @returns 생성된 페이지 반환 */ const addItemPage = async (pageData: Omit): Promise => { try { // API 요청 데이터 변환 const requestData: ItemPageRequest = { page_name: pageData.page_name, item_type: pageData.item_type, absolute_path: pageData.absolute_path || '', }; // API 호출 const response = await itemMasterApi.pages.create(requestData); // 응답 데이터 변환 및 state 업데이트 const newPage = transformPageResponse(response); setItemPages(prev => [...prev, newPage]); console.log('[ItemMasterContext] 페이지 생성 성공:', newPage); return newPage; } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 페이지 생성 실패:', errorMessage); throw error; } }; const updateItemPage = async (id: number, updates: Partial) => { try { // API 요청 데이터 변환 const requestData: Partial = {}; if (updates.page_name) requestData.page_name = updates.page_name; if (updates.absolute_path !== undefined) requestData.absolute_path = updates.absolute_path; // API 호출 const response = await itemMasterApi.pages.update(id, requestData); if (!response.success || !response.data) { throw new Error(response.message || '페이지 수정 실패'); } // 응답 데이터 변환 및 state 업데이트 const updatedPage = transformPageResponse(response.data); setItemPages(prev => prev.map(page => page.id === id ? updatedPage : page)); console.log('[ItemMasterContext] 페이지 수정 성공:', updatedPage); } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 페이지 수정 실패:', errorMessage); throw error; } }; const deleteItemPage = async (id: number) => { try { // 2025-12-01: 페이지 삭제 전에 해당 페이지의 섹션들(필드 포함)을 저장 // refreshIndependentSections()는 백엔드에서 섹션만 가져오고 필드 데이터는 포함하지 않음 // 따라서 직접 섹션 데이터를 보존하여 필드 연결을 유지해야 함 const pageToDelete = itemPages.find(page => page.id === id); const sectionsToPreserve = pageToDelete?.sections || []; // API 호출 const response = await itemMasterApi.pages.delete(id); if (!response.success) { throw new Error(response.message || '페이지 삭제 실패'); } // state 업데이트 setItemPages(prev => prev.filter(page => page.id !== id)); // 2025-12-01: 페이지의 섹션들을 독립 섹션으로 이동 (필드 데이터 유지) // refreshIndependentSections() 대신 직접 섹션 추가하여 필드 연결 보존 if (sectionsToPreserve.length > 0) { setIndependentSections(prev => { // 기존 독립 섹션 ID 목록 const existingIds = new Set(prev.map(s => s.id)); // 중복되지 않는 섹션만 추가 (필드 포함된 원본 데이터) const newSections = sectionsToPreserve.filter(s => !existingIds.has(s.id)); console.log('[ItemMasterContext] 페이지 삭제 - 섹션을 독립 섹션으로 이동:', { preserved: newSections.length, withFields: newSections.map(s => ({ id: s.id, title: s.title, fieldCount: s.fields?.length || 0 })) }); return [...prev, ...newSections]; }); } console.log('[ItemMasterContext] 페이지 삭제 성공:', id); } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 페이지 삭제 실패:', errorMessage); throw error; } }; const reorderPages = async (newOrder: Array<{ id: number; order_no: number }>) => { try { // Optimistic UI 업데이트 (즉시 반영) setItemPages(prev => { const updated = [...prev]; updated.sort((a, b) => { const orderA = newOrder.find(o => o.id === a.id)?.order_no ?? 0; const orderB = newOrder.find(o => o.id === b.id)?.order_no ?? 0; return orderA - orderB; }); return updated.map(page => { const newOrderNo = newOrder.find(o => o.id === page.id)?.order_no; return newOrderNo !== undefined ? { ...page, order_no: newOrderNo } : page; }); }); // API 호출 const response = await itemMasterApi.pages.reorder({ page_orders: newOrder }); if (!response.success || !response.data) { throw new Error(response.message || '페이지 순서 변경 실패'); } // API 응답으로 최종 업데이트 const reorderedPages = response.data.map(transformPageResponse); setItemPages(reorderedPages); console.log('[ItemMasterContext] 페이지 순서 변경 성공'); } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 페이지 순서 변경 실패:', errorMessage); // 실패 시 이전 상태로 롤백 // 여기서는 페이지 전체를 다시 로드하는 것이 더 안전할 수 있음 throw error; } }; // Section CRUD with API const addSectionToPage = async (pageId: number, sectionData: Omit) => { try { // API 호출 const response = await itemMasterApi.sections.create(pageId, { title: sectionData.title, type: sectionData.section_type === 'BOM' ? 'bom' : 'fields', }); if (!response.success || !response.data) { throw new Error(response.message || '섹션 생성 실패'); } // 응답 데이터 변환 및 state 업데이트 const newSection = transformSectionResponse(response.data); setItemPages(prev => prev.map(page => page.id === pageId ? { ...page, sections: [...page.sections, newSection] } : page )); console.log('[ItemMasterContext] 섹션 생성 성공:', newSection); } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 섹션 생성 실패:', errorMessage); throw error; } }; const updateSection = async (sectionId: number, updates: Partial) => { try { // API 요청 데이터 변환 const requestData: any = {}; if (updates.title) requestData.title = updates.title; // page_id 변경 지원 (연결 해제 시 null) if ('page_id' in updates) requestData.page_id = updates.page_id; // API 호출 const response = await itemMasterApi.sections.update(sectionId, requestData); if (!response.success || !response.data) { throw new Error(response.message || '섹션 수정 실패'); } // 응답 데이터 변환 const updatedSection = transformSectionResponse(response.data); // page_id가 null로 변경되면 (연결 해제) 페이지에서 섹션 제거 + 독립 섹션에 추가 if (updates.page_id === null) { // 1. itemPages에서 해당 섹션 찾아서 제거 let unlinkedSection: ItemSection | null = null; setItemPages(prev => prev.map(page => { const foundSection = page.sections.find(s => s.id === sectionId); if (foundSection) { unlinkedSection = { ...foundSection, page_id: null }; } return { ...page, sections: page.sections.filter(section => section.id !== sectionId) }; })); // 2. 독립 섹션에 추가 (섹션 탭에서 보이도록) if (unlinkedSection) { setIndependentSections(prev => [...prev, unlinkedSection!]); } console.log('[ItemMasterContext] 섹션 연결 해제 (계층구조→독립):', sectionId); } else { // 일반 수정: 섹션 데이터 업데이트 setItemPages(prev => prev.map(page => ({ ...page, sections: page.sections.map(section => section.id === sectionId ? updatedSection : section ) }))); } // 2. sectionTemplates 업데이트 (섹션 탭) - 같은 ID의 템플릿도 동기화 setSectionTemplates(prev => prev.map(template => template.id === sectionId ? { ...template, template_name: updatedSection.title } : template )); // 3. independentSections 업데이트 (독립 섹션) - sectionsAsTemplates useMemo 재계산 트리거 setIndependentSections(prev => prev.map(section => section.id === sectionId ? { ...section, ...updatedSection } : section )); console.log('[ItemMasterContext] 섹션 수정 성공 (3방향 동기화):', updatedSection); } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 섹션 수정 실패:', errorMessage); throw error; } }; const deleteSection = async (sectionId: number) => { try { // API 호출 const response = await itemMasterApi.sections.delete(sectionId); if (!response.success) { throw new Error(response.message || '섹션 삭제 실패'); } // 1. itemPages 업데이트 (계층구조 탭) setItemPages(prev => prev.map(page => ({ ...page, sections: page.sections.filter(section => section.id !== sectionId) }))); // 2. sectionTemplates 업데이트 (섹션 탭) - 같은 ID의 템플릿도 삭제 setSectionTemplates(prev => prev.filter(template => template.id !== sectionId)); // 3. independentSections 업데이트 (독립 섹션) - sectionsAsTemplates useMemo 재계산 트리거 setIndependentSections(prev => prev.filter(section => section.id !== sectionId)); console.log('[ItemMasterContext] 섹션 삭제 성공 (3방향 동기화):', sectionId); } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 섹션 삭제 실패:', errorMessage); throw error; } }; const reorderSections = async (pageId: number, sectionIds: number[]) => { try { // 요청 데이터 구성 (백엔드는 'items' 필드를 기대함) const requestData = { items: sectionIds.map((id, index) => ({ id, order_no: index + 1 })) // order_no는 1부터 시작 }; console.log('[ItemMasterContext] 섹션 순서 변경 요청:', { pageId, sectionIds, requestData }); // API 호출 const response = await itemMasterApi.sections.reorder(pageId, requestData); console.log('[ItemMasterContext] 섹션 순서 변경 응답:', response); if (!response.success) { throw new Error(response.message || '섹션 순서 변경 실패'); } // 응답 데이터 처리 - 배열 또는 객체 형태 모두 지원 // 백엔드 응답 형식에 따라 처리 if (response.data && Array.isArray(response.data)) { // 배열 형태: 변경된 섹션 목록 반환 const reorderedSections = response.data.map(transformSectionResponse); setItemPages(prev => prev.map(page => page.id === pageId ? { ...page, sections: reorderedSections } : page )); } else { // 배열이 아닌 경우: 로컬 state에서 순서만 업데이트 setItemPages(prev => prev.map(page => { if (page.id !== pageId) return page; // sectionIds 순서대로 섹션 재정렬 const sectionMap = new Map(page.sections.map(s => [s.id, s])); const reorderedSections = sectionIds .map((id, index) => { const section = sectionMap.get(id); return section ? { ...section, order_no: index + 1 } : null; }) .filter((s): s is ItemSection => s !== null); return { ...page, sections: reorderedSections }; })); } console.log('[ItemMasterContext] 섹션 순서 변경 성공'); } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 섹션 순서 변경 실패:', errorMessage); throw error; } }; // Field CRUD with API const addFieldToSection = async (sectionId: number, fieldData: Omit) => { try { // API 호출 (null → undefined 변환) // 2025-11-28: field_key 추가 const response = await itemMasterApi.fields.create(sectionId, { field_name: fieldData.field_name, field_key: fieldData.field_key ?? undefined, // 2025-11-28: field_key 추가 field_type: fieldData.field_type, is_required: fieldData.is_required, default_value: fieldData.default_value ?? undefined, placeholder: fieldData.placeholder ?? undefined, display_condition: fieldData.display_condition ?? undefined, validation_rules: fieldData.validation_rules ?? undefined, options: fieldData.options ?? undefined, properties: fieldData.properties ?? undefined, }); if (!response.success || !response.data) { throw new Error(response.message || '필드 생성 실패'); } // 응답 데이터 변환 및 state 업데이트 // 2025-11-28: field_key 추가 const newField: ItemField = { id: response.data.id, tenant_id: response.data.tenant_id, section_id: response.data.section_id, master_field_id: response.data.master_field_id, field_name: response.data.field_name, field_key: response.data.field_key, // 2025-11-28: field_key 추가 field_type: response.data.field_type, order_no: response.data.order_no, is_required: response.data.is_required, default_value: response.data.default_value, placeholder: response.data.placeholder, display_condition: response.data.display_condition, validation_rules: response.data.validation_rules, options: response.data.options, properties: response.data.properties, created_at: response.data.created_at, updated_at: response.data.updated_at, }; console.log('[addFieldToSection] Before setItemPages, sectionId:', sectionId, 'newField:', newField.field_name); // 2025-11-27: itemPages 업데이트 (페이지에 연결된 섹션) setItemPages(prev => { const updated = prev.map(page => ({ ...page, sections: page.sections.map(section => section.id === sectionId ? { ...section, fields: [...(section.fields || []), newField] } : section ) })); console.log('[addFieldToSection] After setItemPages, updated pages:', updated.map(p => ({ id: p.id, sections: p.sections.map(s => ({ id: s.id, title: s.title, fieldsCount: s.fields?.length || 0 })) }))); return updated; }); // 2025-11-27: independentSections도 업데이트 (독립 섹션에 필드 추가된 경우) // sectionsAsTemplates useMemo가 [itemPages, independentSections] 둘 다 의존하므로 // 두 상태 모두 업데이트해야 모든 탭에서 실시간 동기화됨 setIndependentSections(prev => { const updated = prev.map(section => section.id === sectionId ? { ...section, fields: [...(section.fields || []), newField] } : section ); console.log('[addFieldToSection] After setIndependentSections, updated sections:', updated.map(s => ({ id: s.id, title: s.title, fieldsCount: s.fields?.length || 0 }))); return updated; }); // 2025-11-27: 항목탭/속성탭 실시간 동기화 // ItemField → ItemMasterField 변환하여 추가 // 2025-11-28: field_key 추가 const newMasterField: ItemMasterField = { id: newField.id, tenant_id: newField.tenant_id ?? 0, field_name: newField.field_name, field_key: newField.field_key, // 2025-11-28: field_key 추가 field_type: newField.field_type, description: newField.placeholder ?? null, category: null, is_common: false, is_required: newField.is_required, default_value: newField.default_value ?? null, options: newField.options ?? null, validation_rules: newField.validation_rules ?? null, properties: newField.properties ?? null, created_by: null, updated_by: null, created_at: newField.created_at, updated_at: newField.updated_at, }; setItemMasterFields(prev => [...prev, newMasterField]); console.log('[ItemMasterContext] 필드 생성 성공:', newField.id); } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 필드 생성 실패:', errorMessage); throw error; } }; const updateField = async (fieldId: number, updates: Partial) => { try { // API 호출 (null → undefined 변환) const requestData: any = {}; if (updates.field_name !== undefined) requestData.field_name = updates.field_name; // 2025-11-28: field_key 추가 if (updates.field_key !== undefined) requestData.field_key = updates.field_key ?? undefined; if (updates.field_type !== undefined) requestData.field_type = updates.field_type; if (updates.is_required !== undefined) requestData.is_required = updates.is_required; if (updates.default_value !== undefined) requestData.default_value = updates.default_value ?? undefined; if (updates.placeholder !== undefined) requestData.placeholder = updates.placeholder ?? undefined; // 2025-11-27: display_condition도 API로 전송 (JSON 타입으로 저장) if (updates.display_condition !== undefined) requestData.display_condition = updates.display_condition ?? undefined; if (updates.validation_rules !== undefined) requestData.validation_rules = updates.validation_rules ?? undefined; if (updates.options !== undefined) requestData.options = updates.options ?? undefined; if (updates.properties !== undefined) requestData.properties = updates.properties ?? undefined; const response = await itemMasterApi.fields.update(fieldId, requestData); if (!response.success || !response.data) { throw new Error(response.message || '필드 수정 실패'); } // state 업데이트 // 2025-11-28: field_key 추가 setItemPages(prev => prev.map(page => ({ ...page, sections: page.sections.map(section => ({ ...section, fields: (section.fields || []).map(field => field.id === fieldId ? { ...field, field_name: response.data!.field_name, field_key: response.data!.field_key, // 2025-11-28: field_key 추가 field_type: response.data!.field_type, is_required: response.data!.is_required, default_value: response.data!.default_value, placeholder: response.data!.placeholder, display_condition: response.data!.display_condition, validation_rules: response.data!.validation_rules, options: response.data!.options, properties: response.data!.properties, updated_at: response.data!.updated_at, } : field ) })) }))); // 2025-11-27: itemMasterFields 동기화 (항목탭 실시간 반영) // ItemField → ItemMasterField 타입 매핑 // 2025-11-28: field_key 추가 setItemMasterFields(prev => prev.map(mf => mf.id === fieldId ? { ...mf, field_name: response.data!.field_name, field_key: response.data!.field_key, // 2025-11-28: field_key 추가 field_type: response.data!.field_type, is_required: response.data!.is_required, default_value: response.data!.default_value ?? null, description: response.data!.placeholder ?? null, // placeholder → description validation_rules: response.data!.validation_rules ?? null, options: response.data!.options ?? null, properties: response.data!.properties ?? null, updated_at: response.data!.updated_at, } : mf )); // 2025-11-27: independentFields 동기화 (섹션에 연결되지 않은 필드) // 2025-11-28: field_key 추가 setIndependentFields(prev => prev.map(field => field.id === fieldId ? { ...field, field_name: response.data!.field_name, field_key: response.data!.field_key, // 2025-11-28: field_key 추가 field_type: response.data!.field_type, is_required: response.data!.is_required, default_value: response.data!.default_value, placeholder: response.data!.placeholder, display_condition: response.data!.display_condition, validation_rules: response.data!.validation_rules, options: response.data!.options, properties: response.data!.properties, updated_at: response.data!.updated_at, } : field )); // 2025-11-27: independentSections 동기화 (섹션탭 실시간 반영) // sectionsAsTemplates useMemo가 [itemPages, independentSections] 둘 다 의존 // 2025-11-28: field_key 추가 setIndependentSections(prev => prev.map(section => ({ ...section, fields: (section.fields || []).map(field => field.id === fieldId ? { ...field, field_name: response.data!.field_name, field_key: response.data!.field_key, // 2025-11-28: field_key 추가 field_type: response.data!.field_type, is_required: response.data!.is_required, default_value: response.data!.default_value, placeholder: response.data!.placeholder, display_condition: response.data!.display_condition, validation_rules: response.data!.validation_rules, options: response.data!.options, properties: response.data!.properties, updated_at: response.data!.updated_at, } : field ) }))); console.log('[ItemMasterContext] 필드 수정 성공:', fieldId); } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 필드 수정 실패:', errorMessage); throw error; } }; const deleteField = async (fieldId: number) => { try { // API 호출 const response = await itemMasterApi.fields.delete(fieldId); if (!response.success) { throw new Error(response.message || '필드 삭제 실패'); } // state 업데이트 setItemPages(prev => prev.map(page => ({ ...page, sections: page.sections.map(section => ({ ...section, fields: (section.fields || []).filter(field => field.id !== fieldId) })) }))); // 2025-11-27: itemMasterFields 동기화 (항목탭 실시간 반영) setItemMasterFields(prev => prev.filter(mf => mf.id !== fieldId)); // 2025-11-27: independentFields 동기화 setIndependentFields(prev => prev.filter(f => f.id !== fieldId)); // 2025-11-27: independentSections 동기화 (섹션탭/계층구조 실시간 반영) // sectionsAsTemplates useMemo가 [itemPages, independentSections] 둘 다 의존 setIndependentSections(prev => prev.map(section => ({ ...section, fields: (section.fields || []).filter(field => field.id !== fieldId) }))); console.log('[ItemMasterContext] 필드 삭제 성공:', fieldId); } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 필드 삭제 실패:', errorMessage); throw error; } }; const reorderFields = async (sectionId: number, fieldIds: number[]) => { try { // 2025-12-03: 검증 추가 - 빈 배열이면 스킵 if (!fieldIds || fieldIds.length === 0) { console.warn('[ItemMasterContext] reorderFields 스킵: 필드 ID 배열이 비어있음'); return; } // 2025-12-03: 검증 추가 - 유효하지 않은 ID 필터링 (null, undefined, NaN 제거) const validFieldIds = fieldIds.filter(id => id !== null && id !== undefined && !isNaN(id)); if (validFieldIds.length !== fieldIds.length) { console.warn('[ItemMasterContext] reorderFields: 유효하지 않은 필드 ID 제거됨', { original: fieldIds, valid: validFieldIds }); } if (validFieldIds.length === 0) { console.warn('[ItemMasterContext] reorderFields 스킵: 유효한 필드 ID가 없음'); return; } // 요청 데이터 구성 (2025-12-03: field_orders → items로 변경, 백엔드와 동일하게) const requestData = { items: validFieldIds.map((id, index) => ({ id, order_no: index + 1 })) // order_no는 1부터 시작 }; // API 호출 const response = await itemMasterApi.fields.reorder(sectionId, requestData); if (!response.success) { throw new Error(response.message || '필드 순서 변경 실패'); } // 2025-12-03: 백엔드가 'success' 문자열만 반환하므로 로컬 state에서 순서 업데이트 // validFieldIds 순서대로 필드 재정렬 setItemPages(prev => prev.map(page => ({ ...page, sections: page.sections.map(section => { if (section.id !== sectionId) return section; // fieldIds 순서대로 필드 재정렬 const fieldMap = new Map((section.fields || []).map(f => [f.id, f])); const reorderedFields = validFieldIds .map((id, index) => { const field = fieldMap.get(id); return field ? { ...field, order_no: index + 1 } : null; }) .filter((f): f is ItemField => f !== null); return { ...section, fields: reorderedFields }; }) }))); // 2025-12-03: 섹션탭 실시간 반영 추가 setIndependentSections(prev => prev.map(section => { if (section.id !== sectionId) return section; const fieldMap = new Map((section.fields || []).map(f => [f.id, f])); const reorderedFields = validFieldIds .map((id, index) => { const field = fieldMap.get(id); return field ? { ...field, order_no: index + 1 } : null; }) .filter((f): f is ItemField => f !== null); return { ...section, fields: reorderedFields }; })); } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 필드 순서 변경 실패:', errorMessage); // 2025-12-03: ApiError인 경우 상세 정보 출력 if (error instanceof ApiError && error.errors) { console.error('[ItemMasterContext] 상세 검증 에러:', error.errors); } throw error; } }; // BOM CRUD with API const addBOMItem = async (sectionId: number, bomData: Omit) => { try { // API 호출 (null → undefined 변환) const response = await itemMasterApi.bomItems.create(sectionId, { item_code: bomData.item_code ?? undefined, item_name: bomData.item_name, quantity: bomData.quantity, unit: bomData.unit ?? undefined, unit_price: bomData.unit_price ?? undefined, total_price: bomData.total_price ?? undefined, spec: bomData.spec ?? undefined, note: bomData.note ?? undefined, }); if (!response.success || !response.data) { throw new Error(response.message || 'BOM 항목 생성 실패'); } // 응답 데이터 변환 및 state 업데이트 const newBOM: BOMItem = { id: response.data.id, tenant_id: response.data.tenant_id, section_id: response.data.section_id, item_code: response.data.item_code, item_name: response.data.item_name, quantity: response.data.quantity, unit: response.data.unit, unit_price: response.data.unit_price, total_price: response.data.total_price, spec: response.data.spec, note: response.data.note, created_at: response.data.created_at, updated_at: response.data.updated_at, }; setItemPages(prev => prev.map(page => ({ ...page, sections: page.sections.map(section => section.id === sectionId ? { ...section, bom_items: [...(section.bom_items || []), newBOM] } : section ) }))); // 2025-11-27: independentSections 동기화 (섹션탭 실시간 반영) // sectionsAsTemplates useMemo가 [itemPages, independentSections] 둘 다 의존 setIndependentSections(prev => prev.map(section => section.id === sectionId ? { ...section, bom_items: [...(section.bom_items || []), newBOM] } : section )); console.log('[ItemMasterContext] BOM 항목 생성 성공 (양방향 동기화):', newBOM.id); } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] BOM 항목 생성 실패:', errorMessage); throw error; } }; const updateBOMItem = async (bomId: number, updates: Partial) => { try { // API 호출 (null → undefined 변환) const requestData: any = {}; if (updates.item_code !== undefined) requestData.item_code = updates.item_code ?? undefined; if (updates.item_name !== undefined) requestData.item_name = updates.item_name; if (updates.quantity !== undefined) requestData.quantity = updates.quantity; if (updates.unit !== undefined) requestData.unit = updates.unit ?? undefined; if (updates.unit_price !== undefined) requestData.unit_price = updates.unit_price ?? undefined; if (updates.total_price !== undefined) requestData.total_price = updates.total_price ?? undefined; if (updates.spec !== undefined) requestData.spec = updates.spec ?? undefined; if (updates.note !== undefined) requestData.note = updates.note ?? undefined; const response = await itemMasterApi.bomItems.update(bomId, requestData); if (!response.success || !response.data) { throw new Error(response.message || 'BOM 항목 수정 실패'); } // 업데이트된 BOM 데이터 const updatedBOMData = { item_code: response.data!.item_code, item_name: response.data!.item_name, quantity: response.data!.quantity, unit: response.data!.unit, unit_price: response.data!.unit_price, total_price: response.data!.total_price, spec: response.data!.spec, note: response.data!.note, updated_at: response.data!.updated_at, }; // state 업데이트 setItemPages(prev => prev.map(page => ({ ...page, sections: page.sections.map(section => ({ ...section, bom_items: (section.bom_items || []).map((bom: BOMItem) => bom.id === bomId ? { ...bom, ...updatedBOMData } : bom ) })) }))); // 2025-11-27: independentSections 동기화 (섹션탭 실시간 반영) // sectionsAsTemplates useMemo가 [itemPages, independentSections] 둘 다 의존 setIndependentSections(prev => prev.map(section => ({ ...section, bom_items: (section.bom_items || []).map((bom: BOMItem) => bom.id === bomId ? { ...bom, ...updatedBOMData } : bom ) }))); console.log('[ItemMasterContext] BOM 항목 수정 성공 (양방향 동기화):', bomId); } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] BOM 항목 수정 실패:', errorMessage); throw error; } }; const deleteBOMItem = async (bomId: number) => { try { // API 호출 const response = await itemMasterApi.bomItems.delete(bomId); if (!response.success) { throw new Error(response.message || 'BOM 항목 삭제 실패'); } // state 업데이트 setItemPages(prev => prev.map(page => ({ ...page, sections: page.sections.map(section => ({ ...section, bom_items: (section.bom_items || []).filter((bom: BOMItem) => bom.id !== bomId) })) }))); // 2025-11-27: independentSections 동기화 (섹션탭 실시간 반영) // sectionsAsTemplates useMemo가 [itemPages, independentSections] 둘 다 의존 setIndependentSections(prev => prev.map(section => ({ ...section, bom_items: (section.bom_items || []).filter((bom: BOMItem) => bom.id !== bomId) }))); console.log('[ItemMasterContext] BOM 항목 삭제 성공 (양방향 동기화):', bomId); } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] BOM 항목 삭제 실패:', errorMessage); throw error; } }; // ============================================ // 2025-11-26 추가: 독립 엔티티 관리 // ============================================ /** * 독립 섹션 로드 (API 호출 없이 state 설정) */ const loadIndependentSections = (sections: ItemSection[]) => { setIndependentSections(sections); console.log('[ItemMasterContext] 독립 섹션 로드 완료:', sections.length); }; /** * 독립 필드 로드 (API 호출 없이 state 설정) */ const loadIndependentFields = (fields: ItemField[]) => { setIndependentFields(fields); console.log('[ItemMasterContext] 독립 필드 로드 완료:', fields.length); }; /** * 독립 BOM 로드 (API 호출 없이 state 설정) */ const loadIndependentBomItems = (bomItems: BOMItem[]) => { setIndependentBomItems(bomItems); console.log('[ItemMasterContext] 독립 BOM 로드 완료:', bomItems.length); }; /** * 독립 섹션 새로고침 (API 호출) * @param isTemplate - true면 템플릿만, false면 일반 독립 섹션만, undefined면 전체 */ const refreshIndependentSections = async (isTemplate?: boolean) => { try { // 2025-11-26: sections.list()는 ItemSectionResponse[]를 직접 반환 const sectionsData = await itemMasterApi.sections.list({ is_template: isTemplate }); // API가 배열을 직접 반환하므로 바로 변환 const sections = sectionsData.map(transformSectionResponse); setIndependentSections(sections); console.log('[ItemMasterContext] 독립 섹션 새로고침 완료:', sections.length); } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 독립 섹션 새로고침 실패:', errorMessage); throw error; } }; /** * 독립 필드 새로고침 (API 호출) */ const refreshIndependentFields = async () => { try { // 2025-11-26: fields.list()는 ItemFieldResponse[]를 직접 반환 const fieldsData = await itemMasterApi.fields.list(); // API가 배열을 직접 반환하므로 바로 변환 const fields = fieldsData.map(transformFieldResponse); setIndependentFields(fields); console.log('[ItemMasterContext] 독립 필드 새로고침 완료:', fields.length); } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 독립 필드 새로고침 실패:', errorMessage); throw error; } }; /** * 독립 BOM 새로고침 (API 호출) */ const refreshIndependentBomItems = async () => { try { // 2025-11-26: bomItems.list()는 BomItemResponse[]를 직접 반환 const bomItemsData = await itemMasterApi.bomItems.list(); // API가 배열을 직접 반환하므로 바로 변환 const bomItems = bomItemsData.map(transformBomItemResponse); setIndependentBomItems(bomItems); console.log('[ItemMasterContext] 독립 BOM 새로고침 완료:', bomItems.length); } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 독립 BOM 새로고침 실패:', errorMessage); throw error; } }; /** * 독립 섹션 생성 (API 호출) * 2025-11-26: sections.createIndependent()는 ItemSectionResponse를 직접 반환 */ const createIndependentSection = async (data: Omit): Promise => { try { // API가 ItemSectionResponse를 직접 반환 (ApiResponse 래퍼 없음) const sectionData = await itemMasterApi.sections.createIndependent(data); const newSection = transformSectionResponse(sectionData); setIndependentSections(prev => [...prev, newSection]); console.log('[ItemMasterContext] 독립 섹션 생성 성공:', newSection.id); return newSection; } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 독립 섹션 생성 실패:', errorMessage); throw error; } }; /** * 독립 필드 생성 (API 호출) * 2025-11-26: fields.createIndependent()는 ItemFieldResponse를 직접 반환 */ const createIndependentField = async (data: Omit): Promise => { try { // API가 ItemFieldResponse를 직접 반환 (ApiResponse 래퍼 없음) const fieldData = await itemMasterApi.fields.createIndependent(data); const newField = transformFieldResponse(fieldData); setIndependentFields(prev => [...prev, newField]); console.log('[ItemMasterContext] 독립 필드 생성 성공:', newField.id); return newField; } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 독립 필드 생성 실패:', errorMessage); throw error; } }; /** * 독립 BOM 생성 (API 호출) * 2025-11-26: bomItems.createIndependent()는 BomItemResponse를 직접 반환 */ const createIndependentBomItem = async (data: Omit): Promise => { try { // API가 BomItemResponse를 직접 반환 (ApiResponse 래퍼 없음) const bomItemData = await itemMasterApi.bomItems.createIndependent(data); const newBomItem = transformBomItemResponse(bomItemData); setIndependentBomItems(prev => [...prev, newBomItem]); console.log('[ItemMasterContext] 독립 BOM 생성 성공:', newBomItem.id); return newBomItem; } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 독립 BOM 생성 실패:', errorMessage); throw error; } }; // ============================================ // 2025-11-26 추가: 링크/언링크 관리 // ============================================ /** * 섹션을 페이지에 연결 */ const linkSectionToPage = async (pageId: number, sectionId: number, orderNo?: number) => { try { const response = await itemMasterApi.pages.linkSection(pageId, { child_id: sectionId, order_no: orderNo, }); if (!response.success) { throw new Error(response.message || '섹션 연결 실패'); } // 페이지 구조 새로고침 // 2025-11-26: API가 PageStructureResponse를 직접 반환 (ApiResponse 래퍼 없음) const structureData = await itemMasterApi.pages.getStructure(pageId); console.log('[linkSectionToPage] getStructure 응답:', structureData); console.log('[linkSectionToPage] sections 배열:', structureData?.sections); if (structureData?.sections?.[0]) { console.log('[linkSectionToPage] 첫번째 섹션 원본:', structureData.sections[0]); console.log('[linkSectionToPage] 첫번째 섹션 키:', Object.keys(structureData.sections[0])); } if (structureData && structureData.page) { const updatedPage = transformPageResponse(structureData.page); // getStructure API 응답 형식: { section: {...}, order_no, fields: [], bom_items: [] } // section wrapper를 해제하고 fields/bom_items를 병합 updatedPage.sections = structureData.sections?.map((sectionData: any) => { // sectionData가 wrapper 형태인지 확인 const rawSection = sectionData.section || sectionData; const transformed = transformSectionResponse(rawSection); // fields와 bom_items는 wrapper에서 직접 가져옴 (있다면) if (sectionData.fields && Array.isArray(sectionData.fields)) { transformed.fields = sectionData.fields.map((f: any) => f.field ? transformFieldResponse(f.field) : transformFieldResponse(f) ); } if (sectionData.bom_items && Array.isArray(sectionData.bom_items)) { transformed.bom_items = sectionData.bom_items.map((b: any) => b.bom_item ? transformBomItemResponse(b.bom_item) : transformBomItemResponse(b) ); } return transformed; }) || []; console.log('[linkSectionToPage] 변환된 페이지:', updatedPage); console.log('[linkSectionToPage] 변환된 sections:', updatedPage.sections); setItemPages(prev => prev.map(page => page.id === pageId ? updatedPage : page)); } else { console.warn('[linkSectionToPage] structureData.page가 없음, 페이지 데이터를 다시 로드해주세요'); // TODO: 전체 init 데이터 새로고침 기능 구현 필요 // 현재는 페이지를 새로고침하거나 init API를 다시 호출해야 함 } // 독립 섹션 목록에서 제거 (연결되면 더 이상 독립이 아님) setIndependentSections(prev => prev.filter(s => s.id !== sectionId)); console.log('[ItemMasterContext] 섹션 연결 성공:', { pageId, sectionId }); } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 섹션 연결 실패:', errorMessage); throw error; } }; /** * 섹션을 페이지에서 연결 해제 */ const unlinkSectionFromPage = async (pageId: number, sectionId: number) => { try { const response = await itemMasterApi.pages.unlinkSection(pageId, sectionId); if (!response.success) { throw new Error(response.message || '섹션 연결 해제 실패'); } // 페이지에서 섹션 제거 setItemPages(prev => prev.map(page => page.id === pageId ? { ...page, sections: page.sections.filter(s => s.id !== sectionId) } : page )); // 독립 섹션 목록 새로고침 (연결 해제된 섹션이 추가됨) // 2025-11-26: refresh 실패는 치명적이지 않으므로 에러 로그만 남기고 진행 try { await refreshIndependentSections(); } catch (refreshError) { console.warn('[ItemMasterContext] 독립 섹션 새로고침 실패 (무시됨):', getErrorMessage(refreshError)); } console.log('[ItemMasterContext] 섹션 연결 해제 성공:', { pageId, sectionId }); } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 섹션 연결 해제 실패:', errorMessage); throw error; } }; /** * 필드를 섹션에 연결 * 2025-12-01: fieldData 파라미터 추가 (createIndependentField 직후 호출 시 상태 동기화 이슈 해결) */ const linkFieldToSection = async (sectionId: number, fieldId: number, orderNo?: number, fieldData?: ItemField) => { try { const response = await itemMasterApi.sections.linkField(sectionId, { child_id: fieldId, order_no: orderNo, }); if (!response.success) { throw new Error(response.message || '필드 연결 실패'); } // 2025-12-01: fieldData가 직접 전달되면 사용, 아니면 independentFields에서 찾기 // (createIndependentField 직후 호출 시 상태가 아직 업데이트되지 않아 find가 실패할 수 있음) const linkedField = fieldData || independentFields.find(f => f.id === fieldId); if (linkedField) { setItemPages(prev => prev.map(page => ({ ...page, sections: page.sections.map(section => section.id === sectionId ? { ...section, fields: [ ...(section.fields || []), { ...linkedField, section_id: sectionId, // 2025-12-02: 섹션별 순서 종속 - 새 order_no 할당 order_no: orderNo ?? section.fields?.length ?? 0 } ] } : section ) }))); // 2025-11-27: 섹션탭 실시간 반영 // sectionsAsTemplates useMemo가 [itemPages, independentSections] 둘 다 의존 setIndependentSections(prev => prev.map(section => section.id === sectionId ? { ...section, fields: [ ...(section.fields || []), { ...linkedField, section_id: sectionId, // 2025-12-02: 섹션별 순서 종속 - 새 order_no 할당 order_no: orderNo ?? section.fields?.length ?? 0 } ] } : section )); } // 독립 필드 목록에서 제거 (fieldData가 전달된 경우에도 실행) setIndependentFields(prev => prev.filter(f => f.id !== fieldId)); console.log('[ItemMasterContext] 필드 연결 성공:', { sectionId, fieldId }); } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 필드 연결 실패:', errorMessage); throw error; } }; /** * 필드를 섹션에서 연결 해제 */ const unlinkFieldFromSection = async (sectionId: number, fieldId: number) => { try { const response = await itemMasterApi.sections.unlinkField(sectionId, fieldId); if (!response.success) { throw new Error(response.message || '필드 연결 해제 실패'); } // 섹션에서 필드 제거 setItemPages(prev => prev.map(page => ({ ...page, sections: page.sections.map(section => section.id === sectionId ? { ...section, fields: (section.fields || []).filter(f => f.id !== fieldId) } : section ) }))); // 2025-11-27: 섹션탭 실시간 반영 // sectionsAsTemplates useMemo가 [itemPages, independentSections] 둘 다 의존 setIndependentSections(prev => prev.map(section => section.id === sectionId ? { ...section, fields: (section.fields || []).filter(f => f.id !== fieldId) } : section )); // 독립 필드 목록 새로고침 await refreshIndependentFields(); console.log('[ItemMasterContext] 필드 연결 해제 성공:', { sectionId, fieldId }); } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 필드 연결 해제 실패:', errorMessage); throw error; } }; // ============================================ // 2025-11-26 추가: 사용처 조회 // ============================================ /** * 섹션 사용처 조회 */ const getSectionUsage = async (sectionId: number): Promise => { try { // 2025-11-26: API가 SectionUsageResponse를 직접 반환 (ApiResponse 래퍼 없음) const usageData = await itemMasterApi.sections.getUsage(sectionId); console.log('[ItemMasterContext] 섹션 사용처 조회 성공:', sectionId); return usageData; } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 섹션 사용처 조회 실패:', errorMessage); throw error; } }; /** * 필드 사용처 조회 */ const getFieldUsage = async (fieldId: number): Promise => { try { // 2025-11-26: API가 FieldUsageResponse를 직접 반환 (ApiResponse 래퍼 없음) const usageData = await itemMasterApi.fields.getUsage(fieldId); console.log('[ItemMasterContext] 필드 사용처 조회 성공:', fieldId); return usageData; } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 필드 사용처 조회 실패:', errorMessage); throw error; } }; // ============================================ // 2025-11-26 추가: 복제 기능 // ============================================ /** * 섹션 복제 */ const cloneSection = async (sectionId: number): Promise => { try { // 2025-11-26: API가 ItemSectionResponse를 직접 반환 (ApiResponse 래퍼 없음) const sectionData = await itemMasterApi.sections.clone(sectionId); console.log('[cloneSection] API 응답 원본:', sectionData); const clonedSection = transformSectionResponse(sectionData); console.log('[cloneSection] 변환 후 섹션:', { id: clonedSection.id, title: clonedSection.title, page_id: clonedSection.page_id, section_type: clonedSection.section_type, fieldsCount: clonedSection.fields?.length || 0, fields: clonedSection.fields, }); // 2025-11-27: 복제된 섹션을 적절한 상태에 추가 (즉시 UI 업데이트) // page_id가 null 또는 undefined면 독립 섹션 (API가 null 대신 undefined를 반환할 수 있음) if (clonedSection.page_id == null) { // 독립 섹션(일반 섹션)인 경우 independentSections에 추가 console.log('[cloneSection] 독립 섹션 추가 (independentSections)'); setIndependentSections(prev => { const newSections = [...prev, clonedSection]; console.log('[cloneSection] independentSections 업데이트:', newSections.length); return newSections; }); } else { // 모듈 섹션인 경우 해당 페이지의 sections에 추가 console.log('[cloneSection] 모듈 섹션 추가 (itemPages), page_id:', clonedSection.page_id); setItemPages(prev => { const newPages = prev.map(page => { if (page.id === clonedSection.page_id) { console.log('[cloneSection] 페이지 찾음:', page.id, '기존 섹션 수:', page.sections.length); return { ...page, sections: [...page.sections, clonedSection] }; } return page; }); return newPages; }); } console.log('[ItemMasterContext] 섹션 복제 성공:', clonedSection.id); return clonedSection; } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 섹션 복제 실패:', errorMessage); throw error; } }; /** * 필드 복제 */ const cloneField = async (fieldId: number): Promise => { try { // 2025-11-26: API가 ItemFieldResponse를 직접 반환 (ApiResponse 래퍼 없음) const fieldData = await itemMasterApi.fields.clone(fieldId); const clonedField = transformFieldResponse(fieldData); // 복제된 필드가 독립 필드면 독립 필드 목록에 추가 if (clonedField.section_id === null) { setIndependentFields(prev => [...prev, clonedField]); } console.log('[ItemMasterContext] 필드 복제 성공:', clonedField.id); return clonedField; } catch (error) { const errorMessage = getErrorMessage(error); console.error('[ItemMasterContext] 필드 복제 실패:', errorMessage); throw error; } }; // 캐시 및 데이터 초기화 함수 const clearCache = () => { if (cache) { cache.clear(); console.log('[ItemMasterContext] TenantAwareCache cleared'); } }; const resetAllData = () => { // 모든 state를 초기값으로 reset setItemMasters(initialItemMasters); setSpecificationMasters(initialSpecificationMasters); setMaterialItemNames(initialMaterialItemNames); setItemCategories(initialItemCategories); setItemUnits(initialItemUnits); setItemMaterials(initialItemMaterials); setSurfaceTreatments(initialSurfaceTreatments); setPartTypeOptions(initialPartTypeOptions); setPartUsageOptions(initialPartUsageOptions); setGuideRailOptions(initialGuideRailOptions); setSectionTemplates([]); setItemMasterFields(initialItemMasterFields); setItemPages(initialItemPages); // TenantAwareCache도 정리 clearCache(); console.log('[ItemMasterContext] All data reset to initial state'); }; // Context value const value: ItemMasterContextType = { itemMasters, addItemMaster, updateItemMaster, deleteItemMaster, specificationMasters, addSpecificationMaster, updateSpecificationMaster, deleteSpecificationMaster, materialItemNames, addMaterialItemName, updateMaterialItemName, deleteMaterialItemName, itemMasterFields, loadItemMasterFields, addItemMasterField, updateItemMasterField, deleteItemMasterField, sectionTemplates, loadSectionTemplates, addSectionTemplate, updateSectionTemplate, deleteSectionTemplate, itemPages, loadItemPages, addItemPage, updateItemPage, deleteItemPage, reorderPages, addSectionToPage, updateSection, deleteSection, reorderSections, addFieldToSection, updateField, deleteField, reorderFields, addBOMItem, updateBOMItem, deleteBOMItem, // 2025-11-26 추가: 독립 엔티티 관리 independentSections, independentFields, independentBomItems, loadIndependentSections, loadIndependentFields, loadIndependentBomItems, refreshIndependentSections, refreshIndependentFields, refreshIndependentBomItems, createIndependentSection, createIndependentField, createIndependentBomItem, // 링크/언링크 관리 linkSectionToPage, unlinkSectionFromPage, linkFieldToSection, unlinkFieldFromSection, // 사용처 조회 getSectionUsage, getFieldUsage, // 복제 cloneSection, cloneField, itemCategories, itemUnits, itemMaterials, surfaceTreatments, partTypeOptions, partUsageOptions, guideRailOptions, addItemCategory, updateItemCategory, deleteItemCategory, addItemUnit, updateItemUnit, deleteItemUnit, addItemMaterial, updateItemMaterial, deleteItemMaterial, addSurfaceTreatment, updateSurfaceTreatment, deleteSurfaceTreatment, addPartTypeOption, updatePartTypeOption, deletePartTypeOption, addPartUsageOption, updatePartUsageOption, deletePartUsageOption, addGuideRailOption, updateGuideRailOption, deleteGuideRailOption, clearCache, resetAllData, tenantId, }; return ( {children} ); } // Custom hook export function useItemMaster() { const context = useContext(ItemMasterContext); if (context === undefined) { throw new Error('useItemMaster must be used within an ItemMasterProvider'); } return context; }