fix: 품목기준관리 실시간 동기화 수정

- BOM 항목 추가/수정/삭제 시 섹션탭 즉시 반영
- 섹션 복제 시 UI 즉시 업데이트 (null vs undefined 이슈 해결)
- 항목 수정 기능 추가 (useTemplateManagement)
- 실시간 동기화 문서 추가

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-11-27 22:19:50 +09:00
parent b73603822b
commit 65a8510c0b
130 changed files with 11031 additions and 2287 deletions

View File

@@ -1,10 +1,10 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { useItemMaster } from '@/contexts/ItemMasterContext';
import type { SectionTemplate, BOMItem } from '@/contexts/ItemMasterContext';
import type { SectionTemplate, BOMItem, TemplateField } from '@/contexts/ItemMasterContext';
import { MasterFieldTab, HierarchyTab, SectionsTab } from './ItemMasterDataManagement/tabs';
import { FieldDialog } from './ItemMasterDataManagement/dialogs/FieldDialog';
// ConditionalFieldConfig type removed - not currently used
@@ -20,16 +20,21 @@ import { TemplateFieldDialog } from './ItemMasterDataManagement/dialogs/Template
import { LoadTemplateDialog } from './ItemMasterDataManagement/dialogs/LoadTemplateDialog';
import { ColumnDialog } from './ItemMasterDataManagement/dialogs/ColumnDialog';
import { SectionTemplateDialog } from './ItemMasterDataManagement/dialogs/SectionTemplateDialog';
import { ImportSectionDialog } from './ItemMasterDataManagement/dialogs/ImportSectionDialog';
import { ImportFieldDialog } from './ItemMasterDataManagement/dialogs/ImportFieldDialog';
import { itemMasterApi } from '@/lib/api/item-master';
import { getErrorMessage, ApiError } from '@/lib/api/error-handler';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { ErrorMessage } from '@/components/ui/error-message';
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
import {
transformPagesResponse,
transformSectionsResponse,
transformSectionTemplatesResponse,
transformMasterFieldsResponse,
transformFieldsResponse,
transformCustomTabsResponse,
transformUnitOptionsResponse,
transformSectionTemplateFromSection,
} from '@/lib/api/transformers';
import {
Database,
@@ -85,7 +90,7 @@ export function ItemMasterDataManagement() {
deleteSection,
addFieldToSection: _addFieldToSection,
updateField: _updateField,
deleteField,
deleteField: _deleteField,
reorderFields,
itemMasterFields,
loadItemMasterFields,
@@ -98,11 +103,47 @@ export function ItemMasterDataManagement() {
updateSectionTemplate: _updateSectionTemplate,
deleteSectionTemplate: _deleteSectionTemplate,
resetAllData,
tenantId: _tenantId
tenantId: _tenantId,
// 2025-11-26 추가: 독립 엔티티 관리
independentSections,
loadIndependentSections,
independentFields: _independentFields,
loadIndependentFields,
refreshIndependentSections,
refreshIndependentFields,
linkSectionToPage,
unlinkSectionFromPage: _unlinkSectionFromPage,
linkFieldToSection,
unlinkFieldFromSection,
getSectionUsage,
getFieldUsage,
cloneSection,
reorderSections,
// 2025-11-27 추가: BOM 항목 API 함수
addBOMItem,
updateBOMItem,
deleteBOMItem,
} = useItemMaster();
console.log('ItemMasterDataManagement: Current sectionTemplates', sectionTemplates);
// 2025-11-27: itemPages 변화 추적 (디버깅용)
useEffect(() => {
console.log('[ItemMasterDataManagement] ⚡ itemPages changed:', {
pageCount: itemPages.length,
pages: itemPages.map(p => ({
id: p.id,
name: p.name,
sectionsCount: p.sections.length,
sections: p.sections.map(s => ({
id: s.id,
title: s.title,
fieldsCount: s.fields?.length || 0
}))
}))
});
}, [itemPages]);
// ===== 커스텀 훅 초기화 =====
const pageManagement = usePageManagement();
const sectionManagement = useSectionManagement();
@@ -135,7 +176,7 @@ export function ItemMasterDataManagement() {
expandedSections: _expandedSections, setExpandedSections: _setExpandedSections,
handleAddSection, handleLinkTemplate,
handleEditSectionTitle, handleSaveSectionTitle,
handleDeleteSection: _handleDeleteSection, toggleSection: _toggleSection,
handleUnlinkSection, handleDeleteSection: _handleDeleteSection, toggleSection: _toggleSection,
} = sectionManagement;
const {
@@ -273,6 +314,92 @@ export function ItemMasterDataManagement() {
}))
);
// 2025-11-26: itemPages의 모든 섹션 + 독립 섹션(independentSections)을 SectionTemplate 형식으로 변환
// 이렇게 하면 계층구조 탭과 섹션 탭이 같은 데이터 소스를 사용하여 자동 동기화됨
// 독립 섹션: 페이지에서 연결 해제된 섹션 (page_id = null)
const sectionsAsTemplates: SectionTemplate[] = useMemo(() => {
console.log('[sectionsAsTemplates] useMemo 재계산! itemPages:', itemPages.map(p => ({
id: p.id,
sections: p.sections.map(s => ({ id: s.id, fieldsCount: s.fields?.length || 0 }))
})));
// 1. itemPages에 연결된 섹션들
const linkedSections = itemPages.flatMap(page =>
page.sections.map(section => ({
id: section.id,
tenant_id: section.tenant_id || 0,
template_name: section.title,
section_type: section.section_type,
description: section.description || null,
default_fields: null,
// ItemField → TemplateField 변환
fields: section.fields?.map(field => ({
id: field.id.toString(),
name: field.field_name,
fieldKey: field.field_name.toLowerCase().replace(/\s+/g, '_'),
property: {
inputType: field.field_type,
// 2025-11-27: is_required와 properties.required 둘 다 체크
required: field.is_required || field.properties?.required,
options: field.options?.map((opt: { label: string; value: string }) => opt.label || opt.value),
},
description: field.placeholder || undefined,
} as TemplateField)),
bomItems: section.bom_items,
created_by: section.created_by || null,
updated_by: section.updated_by || null,
created_at: section.created_at,
updated_at: section.updated_at,
}))
);
// 2. 독립 섹션들 (page_id = null, 연결 해제된 섹션)
const unlinkedSections = independentSections.map(section => ({
id: section.id,
tenant_id: section.tenant_id || 0,
template_name: section.title,
section_type: section.section_type,
description: section.description || null,
default_fields: null,
fields: section.fields?.map(field => ({
id: field.id.toString(),
name: field.field_name,
fieldKey: field.field_name.toLowerCase().replace(/\s+/g, '_'),
property: {
inputType: field.field_type,
// 2025-11-27: is_required와 properties.required 둘 다 체크
required: field.is_required || field.properties?.required,
options: field.options?.map((opt: { label: string; value: string }) => opt.label || opt.value),
},
description: field.placeholder || undefined,
} as TemplateField)),
bomItems: section.bom_items,
created_by: section.created_by || null,
updated_by: section.updated_by || null,
created_at: section.created_at,
updated_at: section.updated_at,
}));
// 3. 중복 제거 (같은 섹션이 여러 페이지에 연결되었거나, 연결 섹션과 독립 섹션에 동시 존재하는 경우)
const allSections = [...linkedSections, ...unlinkedSections];
const uniqueSections = Array.from(
new Map(allSections.map(s => [s.id, s])).values()
);
return uniqueSections;
}, [itemPages, independentSections]);
// 2025-11-27: sectionsAsTemplates 변화 추적 (디버깅용)
useEffect(() => {
console.log('[ItemMasterDataManagement] 📋 sectionsAsTemplates changed:', {
count: sectionsAsTemplates.length,
sections: sectionsAsTemplates.map(s => ({
id: s.id,
name: s.template_name,
fieldsCount: s.fields?.length || 0
}))
});
}, [sectionsAsTemplates]);
// 마운트 상태 추적 (SSR 호환)
const [_mounted, setMounted] = useState(false);
@@ -294,22 +421,67 @@ export function ItemMasterDataManagement() {
const data = await itemMasterApi.init();
// 페이지 데이터 로드 (이미 존재하는 데이터를 state에 로드 - API 호출 없음)
// 2025-11-26: 백엔드가 entity_relationships 기반으로 변경됨
// - pages[].sections: entity_relationships 기반으로 연결된 섹션 (이미 포함)
// - sections: 모든 독립 섹션 (재사용 가능 목록)
// - sectionTemplates: 삭제됨 → sections로 대체
// 1. 페이지 데이터 로드 (섹션이 이미 포함되어 있음)
const transformedPages = transformPagesResponse(data.pages);
loadItemPages(transformedPages);
// 섹션 템플릿 로드 (덮어쓰기 - API 호출 없음!)
const transformedTemplates = transformSectionTemplatesResponse(data.sectionTemplates);
loadSectionTemplates(transformedTemplates);
// 2. 독립 섹션 로드 (모든 재사용 가능 섹션)
// 백엔드가 sections 배열로 모든 독립 섹션을 반환
if (data.sections && data.sections.length > 0) {
const transformedSections = transformSectionsResponse(data.sections);
loadIndependentSections(transformedSections);
console.log('✅ 독립 섹션 로드:', transformedSections.length);
}
// 마스터 필드 로드 (덮어쓰기 - API 호출 없음!)
const transformedFields = transformMasterFieldsResponse(data.masterFields);
loadItemMasterFields(transformedFields);
// 3. 섹션 템플릿 로드 (sectionTemplates → sections로 통합됨)
// 기존 sectionTemplates가 있으면 호환성 유지, 없으면 sections 사용
if (data.sectionTemplates && data.sectionTemplates.length > 0) {
const transformedTemplates = transformSectionTemplatesResponse(data.sectionTemplates);
loadSectionTemplates(transformedTemplates);
} else if (data.sections && data.sections.length > 0) {
// sectionTemplates가 없으면 sections에서 is_template=true인 것만 사용
const templates = data.sections
.filter((s: { is_template?: boolean }) => s.is_template)
.map(transformSectionTemplateFromSection);
if (templates.length > 0) {
loadSectionTemplates(templates);
}
}
// 커스텀 탭 로드 (local state)
// 필드 로드 (2025-11-27: masterFields가 fields로 통합됨)
// data.fields = 모든 필드 목록 (백엔드 init API에서 반환)
if (data.fields && data.fields.length > 0) {
const transformedFields = transformFieldsResponse(data.fields);
// 2025-11-27: section_id가 null인 필드만 필터링 (독립 필드)
const independentOnlyFields = transformedFields.filter(
f => f.section_id === null || f.section_id === undefined
);
// 2025-11-27: 항목탭용 (itemMasterFields) - 모든 필드 로드
// 계층구조에서 추가한 필드도 항목탭에 바로 표시되도록 함
// addFieldToSection에서 setItemMasterFields를 호출하므로 일관성 유지
loadItemMasterFields(transformedFields as any);
// 독립 필드용 (independentFields) - section_id=null인 필드만
loadIndependentFields(independentOnlyFields);
console.log('✅ 필드 로드:', {
total: transformedFields.length,
independent: independentOnlyFields.length,
allFieldsForItemsTab: transformedFields.length,
});
}
// 커스텀 탭 로드 (local state) - 교체 방식 (복제 방지)
if (data.customTabs && data.customTabs.length > 0) {
const transformedTabs = transformCustomTabsResponse(data.customTabs);
setCustomTabs(prev => [...prev, ...transformedTabs]);
setCustomTabs(transformedTabs);
}
// 단위 옵션 로드 (local state)
@@ -319,9 +491,9 @@ export function ItemMasterDataManagement() {
}
console.log('✅ Initial data loaded:', {
pages: data.pages.length,
templates: data.sectionTemplates.length,
masterFields: data.masterFields.length,
pages: data.pages?.length || 0,
sections: data.sections?.length || 0,
fields: data.fields?.length || 0,
customTabs: data.customTabs?.length || 0,
unitOptions: data.unitOptions?.length || 0,
});
@@ -365,6 +537,61 @@ export function ItemMasterDataManagement() {
// BOM 관리 상태 (훅에 없음)
const [_bomItems, setBomItems] = useState<BOMItem[]>([]);
// 2025-11-26 추가: 섹션/필드 불러오기 다이얼로그 상태
const [isImportSectionDialogOpen, setIsImportSectionDialogOpen] = useState(false);
const [isImportFieldDialogOpen, setIsImportFieldDialogOpen] = useState(false);
const [selectedImportSectionId, setSelectedImportSectionId] = useState<number | null>(null);
const [selectedImportFieldId, setSelectedImportFieldId] = useState<number | null>(null);
const [importFieldTargetSectionId, setImportFieldTargetSectionId] = useState<number | null>(null);
// 2025-11-26 추가: 섹션 불러오기 핸들러
const handleImportSection = async () => {
if (!selectedPageId || !selectedImportSectionId) return;
try {
await linkSectionToPage(selectedPageId, selectedImportSectionId);
toast.success('섹션을 불러왔습니다.');
setSelectedImportSectionId(null);
} catch (error) {
console.error('섹션 불러오기 실패:', error);
toast.error(getErrorMessage(error));
}
};
/**
* 필드 불러오기 핸들러
*
* @description 2025-11-27: API 변경으로 단순화
* - 이전: source 파라미터로 'master' | 'independent' 구분
* - 현재: 모든 필드가 item_fields로 통합 → linkFieldToSection만 사용
* - section_id=NULL인 필드를 섹션에 연결하는 방식으로 통일
*/
const handleImportField = async () => {
if (!importFieldTargetSectionId || !selectedImportFieldId) return;
try {
// 2025-11-27: 통합된 필드 연결 방식
await linkFieldToSection(importFieldTargetSectionId, selectedImportFieldId);
toast.success('필드를 섹션에 연결했습니다.');
setSelectedImportFieldId(null);
setImportFieldTargetSectionId(null);
} catch (error) {
console.error('필드 불러오기 실패:', error);
toast.error(getErrorMessage(error));
}
};
// 2025-11-26 추가: 섹션 복제 핸들러
const handleCloneSection = async (sectionId: number) => {
try {
await cloneSection(sectionId);
toast.success('섹션이 복제되었습니다.');
} catch (error) {
console.error('섹션 복제 실패:', error);
toast.error(getErrorMessage(error));
}
};
// ===== 이하 핸들러들은 훅으로 이동되어 제거됨 =====
// handleAddOption, handleDeleteOption → useAttributeManagement
@@ -385,7 +612,7 @@ export function ItemMasterDataManagement() {
};
// 섹션 삭제 핸들러 (pendingChanges 제거 포함) - 훅에 없어 유지
const handleDeleteSectionWithTracking = (pageId: number, sectionId: number) => {
const _handleDeleteSectionWithTracking = (pageId: number, sectionId: number) => {
const page = itemPages.find(p => p.id === pageId);
const sectionToDelete = page?.sections.find(s => s.id === sectionId);
const fieldIds = sectionToDelete?.fields?.map(f => f.id) || [];
@@ -393,10 +620,16 @@ export function ItemMasterDataManagement() {
console.log('섹션 삭제 완료:', { sectionId, removedFields: fieldIds.length });
};
// 필드 제 핸들러 (pendingChanges 제거 포함) - 훅에 없어 유지
const handleDeleteFieldWithTracking = (_pageId: string, _sectionId: string, fieldId: string) => {
deleteField(Number(fieldId));
console.log('필드 삭제 완료:', fieldId);
// 필드 연결 해제 핸들러 (2025-11-27: 삭제 → unlink로 변경)
// 섹션에서 필드 연결만 해제하고, 필드 자체는 독립 필드 목록에 유지됨
const handleUnlinkFieldWithTracking = async (_pageId: string, sectionId: string, fieldId: string) => {
try {
await unlinkFieldFromSection(Number(sectionId), Number(fieldId));
console.log('필드 연결 해제 완료:', fieldId);
} catch (error) {
console.error('필드 연결 해제 실패:', error);
toast.error('필드 연결 해제에 실패했습니다');
}
};
// 절대경로 업데이트 - 로컬에서 처리
@@ -424,7 +657,9 @@ export function ItemMasterDataManagement() {
const handleLoadTemplateWrapper = () => handleLoadTemplate(selectedPage);
// setter 래퍼들 (Dispatch<SetStateAction> 타입 호환성)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const setNewSectionTypeWrapper: React.Dispatch<React.SetStateAction<'fields' | 'bom'>> = setNewSectionType as any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const setNewPageItemTypeWrapper: React.Dispatch<React.SetStateAction<'FG' | 'PT' | 'SM' | 'RM' | 'CS'>> = setNewPageItemType as any;
// ===== 유틸리티 함수들 =====
@@ -436,23 +671,24 @@ export function ItemMasterDataManagement() {
};
// 섹션 순서 변경 핸들러 (드래그앤드롭)
const moveSection = (dragIndex: number, hoverIndex: number) => {
const moveSection = async (dragIndex: number, hoverIndex: number) => {
if (!selectedPage) return;
const sections = [...selectedPage.sections];
const [draggedSection] = sections.splice(dragIndex, 1);
sections.splice(hoverIndex, 0, draggedSection);
// order 값 재설정
const updatedSections = sections.map((section, idx) => ({
...section,
order: idx + 1
}));
// 새로운 순서의 섹션 ID 배열 생성
const sectionIds = sections.map(s => s.id);
// 페이지 업데이트
updateItemPage(selectedPage.id, { sections: updatedSections });
// hasUnsavedChanges는 computed value이므로 자동 계산됨
toast.success('섹션 순서가 변경되었습니다 (저장 필요)');
try {
// API를 통해 섹션 순서 변경 (Context의 reorderSections 사용)
await reorderSections(selectedPage.id, sectionIds);
toast.success('섹션 순서가 변경되었습니다');
} catch (error) {
console.error('섹션 순서 변경 실패:', error);
toast.error('섹션 순서 변경에 실패했습니다');
}
};
// 필드 순서 변경 핸들러
@@ -520,12 +756,12 @@ export function ItemMasterDataManagement() {
// 에러 발생 시 UI
if (error) {
return (
<div className="flex items-center justify-center min-h-screen p-4">
<ErrorMessage
message={error}
onRetry={() => window.location.reload()}
/>
</div>
<ServerErrorPage
title="데이터를 불러올 수 없습니다"
message={error}
onRetry={() => window.location.reload()}
showContactInfo={true}
/>
);
}
@@ -935,6 +1171,7 @@ export function ItemMasterDataManagement() {
</div>
<div className="space-y-3">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{propertiesArray.map((property: any) => {
const inputTypeLabel =
property.type === 'textbox' ? '텍스트박스' :
@@ -1147,7 +1384,7 @@ export function ItemMasterDataManagement() {
{/* 섹션관리 탭 */}
<TabsContent value="sections" className="space-y-4">
<SectionsTab
sectionTemplates={sectionTemplates}
sectionTemplates={sectionsAsTemplates}
setIsSectionTemplateDialogOpen={setIsSectionTemplateDialogOpen}
setCurrentTemplateId={setCurrentTemplateId}
setIsTemplateFieldDialogOpen={setIsTemplateFieldDialogOpen}
@@ -1160,6 +1397,10 @@ export function ItemMasterDataManagement() {
handleDeleteBOMItemFromTemplate={handleDeleteBOMItemFromTemplate}
ITEM_TYPE_OPTIONS={ITEM_TYPE_OPTIONS}
INPUT_TYPE_OPTIONS={INPUT_TYPE_OPTIONS}
unitOptions={unitOptions.map(opt => ({ value: opt.value, label: opt.label }))}
onCloneSection={handleCloneSection}
setIsImportFieldDialogOpen={setIsImportFieldDialogOpen}
setImportFieldTargetSectionId={setImportFieldTargetSectionId}
/>
</TabsContent>
@@ -1169,6 +1410,7 @@ export function ItemMasterDataManagement() {
itemPages={itemPages}
selectedPage={selectedPage}
ITEM_TYPE_OPTIONS={ITEM_TYPE_OPTIONS}
unitOptions={unitOptions.map(opt => ({ value: opt.value, label: opt.label }))}
editingPageId={editingPageId}
setEditingPageId={setEditingPageId}
editingPageName={editingPageName}
@@ -1199,11 +1441,18 @@ export function ItemMasterDataManagement() {
handleEditSectionTitle={handleEditSectionTitle}
handleSaveSectionTitle={handleSaveSectionTitleWrapper}
moveSection={moveSection}
deleteSection={handleDeleteSectionWithTracking}
unlinkSection={handleUnlinkSection}
updateSection={updateSection}
deleteField={handleDeleteFieldWithTracking}
deleteField={handleUnlinkFieldWithTracking}
handleEditField={handleEditField}
moveField={moveField}
setIsImportSectionDialogOpen={setIsImportSectionDialogOpen}
setIsImportFieldDialogOpen={setIsImportFieldDialogOpen}
setImportFieldTargetSectionId={setImportFieldTargetSectionId}
// 2025-11-27 추가: BOM 항목 API 함수
addBOMItem={addBOMItem}
updateBOMItem={updateBOMItem}
deleteBOMItem={deleteBOMItem}
/>
</TabsContent>
@@ -1346,7 +1595,7 @@ export function ItemMasterDataManagement() {
handleAddSection={handleAddSectionWrapper}
sectionInputMode={sectionInputMode}
setSectionInputMode={setSectionInputMode}
sectionTemplates={sectionTemplates}
sectionTemplates={sectionsAsTemplates}
selectedTemplateId={selectedSectionTemplateId}
setSelectedTemplateId={setSelectedSectionTemplateId}
handleLinkTemplate={handleLinkTemplateWrapper}
@@ -1555,6 +1804,35 @@ export function ItemMasterDataManagement() {
setSelectedTemplateId={setSelectedTemplateId}
handleLoadTemplate={handleLoadTemplateWrapper}
/>
{/* 섹션 불러오기 다이얼로그 */}
<ImportSectionDialog
isOpen={isImportSectionDialogOpen}
setIsOpen={setIsImportSectionDialogOpen}
independentSections={independentSections}
selectedSectionId={selectedImportSectionId}
setSelectedSectionId={setSelectedImportSectionId}
onImport={handleImportSection}
onRefresh={refreshIndependentSections}
onGetUsage={getSectionUsage}
/>
{/* 필드 불러오기 다이얼로그 - 2025-11-27: 탭 통합 (항목+독립필드 → 필드) */}
<ImportFieldDialog
isOpen={isImportFieldDialogOpen}
setIsOpen={setIsImportFieldDialogOpen}
fields={itemMasterFields}
selectedFieldId={selectedImportFieldId}
setSelectedFieldId={setSelectedImportFieldId}
onImport={handleImportField}
onRefresh={refreshIndependentFields}
onGetUsage={getFieldUsage}
targetSectionTitle={
importFieldTargetSectionId
? selectedPage?.sections.find(s => s.id === importFieldTargetSectionId)?.title
: undefined
}
/>
</PageLayout>
);
}