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:
@@ -73,10 +73,36 @@ export function useAttributeManagement(): UseAttributeManagementReturn {
|
||||
updateItemMasterField
|
||||
} = useItemMaster();
|
||||
|
||||
// 속성 옵션 상태
|
||||
const [unitOptions, setUnitOptions] = useState<MasterOption[]>([]);
|
||||
const [materialOptions, setMaterialOptions] = useState<MasterOption[]>([]);
|
||||
const [surfaceTreatmentOptions, setSurfaceTreatmentOptions] = useState<MasterOption[]>([]);
|
||||
// 속성 옵션 상태 (기본값 하드코딩 - TODO: 나중에 백엔드 API로 대체)
|
||||
const [unitOptions, setUnitOptions] = useState<MasterOption[]>([
|
||||
{ id: 'unit-1', value: 'EA', label: 'EA (개)', isActive: true },
|
||||
{ id: 'unit-2', value: 'KG', label: 'KG (킬로그램)', isActive: true },
|
||||
{ id: 'unit-3', value: 'M', label: 'M (미터)', isActive: true },
|
||||
{ id: 'unit-4', value: 'MM', label: 'MM (밀리미터)', isActive: true },
|
||||
{ id: 'unit-5', value: 'L', label: 'L (리터)', isActive: true },
|
||||
{ id: 'unit-6', value: 'SET', label: 'SET (세트)', isActive: true },
|
||||
{ id: 'unit-7', value: 'BOX', label: 'BOX (박스)', isActive: true },
|
||||
{ id: 'unit-8', value: 'ROLL', label: 'ROLL (롤)', isActive: true },
|
||||
]);
|
||||
const [materialOptions, setMaterialOptions] = useState<MasterOption[]>([
|
||||
{ id: 'mat-1', value: 'SUS304', label: 'SUS304 (스테인리스)', isActive: true },
|
||||
{ id: 'mat-2', value: 'SUS316', label: 'SUS316 (스테인리스)', isActive: true },
|
||||
{ id: 'mat-3', value: 'AL6061', label: 'AL6061 (알루미늄)', isActive: true },
|
||||
{ id: 'mat-4', value: 'AL5052', label: 'AL5052 (알루미늄)', isActive: true },
|
||||
{ id: 'mat-5', value: 'SS400', label: 'SS400 (일반강)', isActive: true },
|
||||
{ id: 'mat-6', value: 'S45C', label: 'S45C (탄소강)', isActive: true },
|
||||
{ id: 'mat-7', value: 'POM', label: 'POM (폴리아세탈)', isActive: true },
|
||||
{ id: 'mat-8', value: 'PEEK', label: 'PEEK (폴리에테르에테르케톤)', isActive: true },
|
||||
]);
|
||||
const [surfaceTreatmentOptions, setSurfaceTreatmentOptions] = useState<MasterOption[]>([
|
||||
{ id: 'surf-1', value: 'NONE', label: '없음', isActive: true },
|
||||
{ id: 'surf-2', value: 'ANODIZE', label: '아노다이징', isActive: true },
|
||||
{ id: 'surf-3', value: 'PLATING', label: '도금', isActive: true },
|
||||
{ id: 'surf-4', value: 'PAINTING', label: '도장', isActive: true },
|
||||
{ id: 'surf-5', value: 'PASSIVATION', label: '부동태처리', isActive: true },
|
||||
{ id: 'surf-6', value: 'SANDBLAST', label: '샌드블라스트', isActive: true },
|
||||
{ id: 'surf-7', value: 'POLISHING', label: '폴리싱', isActive: true },
|
||||
]);
|
||||
const [customAttributeOptions, setCustomAttributeOptions] = useState<Record<string, MasterOption[]>>({});
|
||||
|
||||
// 옵션 다이얼로그 상태
|
||||
|
||||
@@ -62,7 +62,7 @@ export interface UseFieldManagementReturn {
|
||||
setTempConditionValue: (value: string) => void;
|
||||
|
||||
// 핸들러
|
||||
handleAddField: (selectedPage: ItemPage | undefined) => void;
|
||||
handleAddField: (selectedPage: ItemPage | undefined) => Promise<void>;
|
||||
handleEditField: (sectionId: string, field: ItemField) => void;
|
||||
handleDeleteField: (pageId: string, sectionId: string, fieldId: string) => void;
|
||||
resetFieldForm: () => void;
|
||||
@@ -74,7 +74,7 @@ export function useFieldManagement(): UseFieldManagementReturn {
|
||||
addFieldToSection,
|
||||
updateField,
|
||||
deleteField,
|
||||
addItemMasterField,
|
||||
addItemMasterField: _addItemMasterField, // 2025-11-27: 중복 필드 생성 방지로 사용 안함
|
||||
updateItemMasterField,
|
||||
} = useItemMaster();
|
||||
|
||||
@@ -135,8 +135,8 @@ export function useFieldManagement(): UseFieldManagementReturn {
|
||||
}
|
||||
}, [fieldInputMode, selectedMasterFieldId, itemMasterFields]);
|
||||
|
||||
// 필드 추가
|
||||
const handleAddField = (selectedPage: ItemPage | undefined) => {
|
||||
// 필드 추가 (2025-11-27: async/await 추가 - 다른 탭 실시간 동기화)
|
||||
const handleAddField = async (selectedPage: ItemPage | undefined) => {
|
||||
if (!selectedPage || !selectedSectionForField || !newFieldName.trim() || !newFieldKey.trim()) {
|
||||
toast.error('모든 필수 항목을 입력해주세요');
|
||||
return;
|
||||
@@ -187,59 +187,41 @@ export function useFieldManagement(): UseFieldManagementReturn {
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
if (editingFieldId) {
|
||||
console.log('Updating field:', { pageId: selectedPage.id, sectionId: selectedSectionForField, fieldId: editingFieldId, fieldName: newField.field_name });
|
||||
updateField(Number(editingFieldId), newField);
|
||||
try {
|
||||
if (editingFieldId) {
|
||||
console.log('Updating field:', { pageId: selectedPage.id, sectionId: selectedSectionForField, fieldId: editingFieldId, fieldName: newField.field_name });
|
||||
await updateField(Number(editingFieldId), newField);
|
||||
|
||||
// 항목관리 탭의 마스터 항목도 업데이트 (동일한 fieldKey가 있으면)
|
||||
const existingMasterField = itemMasterFields.find(mf => mf.id.toString() === newField.field_name);
|
||||
if (existingMasterField) {
|
||||
const updatedMasterField: Partial<ItemMasterField> = {
|
||||
field_name: newField.field_name,
|
||||
description: newField.placeholder ?? null,
|
||||
properties: newField.properties,
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
updateItemMasterField(existingMasterField.id, updatedMasterField);
|
||||
}
|
||||
// 항목관리 탭의 마스터 항목도 업데이트 (동일한 fieldKey가 있으면)
|
||||
const existingMasterField = itemMasterFields.find(mf => mf.id.toString() === newField.field_name);
|
||||
if (existingMasterField) {
|
||||
const updatedMasterField: Partial<ItemMasterField> = {
|
||||
field_name: newField.field_name,
|
||||
description: newField.placeholder ?? null,
|
||||
properties: newField.properties,
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
await updateItemMasterField(existingMasterField.id, updatedMasterField);
|
||||
}
|
||||
|
||||
toast.success('항목이 섹션에 수정되었습니다!');
|
||||
} else {
|
||||
console.log('Adding field to section:', { pageId: selectedPage.id, sectionId: selectedSectionForField, fieldName: newField.field_name });
|
||||
|
||||
// 1. 섹션에 항목 추가
|
||||
addFieldToSection(Number(selectedSectionForField), newField);
|
||||
|
||||
// 2. 마스터 항목 선택이 아닌 경우에만 새 마스터 항목 자동 생성
|
||||
const isFromMasterField = masterFieldId !== null;
|
||||
const existingMasterField = itemMasterFields.find(mf => mf.id.toString() === newField.field_name);
|
||||
if (!isFromMasterField && !existingMasterField) {
|
||||
// ItemMasterField 타입에 맞게 필수 필드 포함
|
||||
const newMasterFieldData: Omit<ItemMasterField, 'id' | 'tenant_id' | 'created_by' | 'updated_by' | 'created_at' | 'updated_at'> = {
|
||||
field_name: newField.field_name,
|
||||
field_type: newField.field_type,
|
||||
description: newField.placeholder ?? null,
|
||||
category: selectedPage.item_type,
|
||||
is_common: false,
|
||||
default_value: null,
|
||||
options: newField.options ?? null,
|
||||
validation_rules: null,
|
||||
properties: newField.properties ?? null,
|
||||
};
|
||||
addItemMasterField(newMasterFieldData as any);
|
||||
|
||||
console.log('Field added to both section and master fields:', {
|
||||
fieldId: newField.id,
|
||||
fieldName: newMasterFieldData.field_name
|
||||
});
|
||||
|
||||
toast.success('항목이 섹션에 추가되고 마스터 항목으로도 등록되었습니다!');
|
||||
toast.success('항목이 섹션에 수정되었습니다!');
|
||||
} else {
|
||||
console.log('Adding field to section:', { pageId: selectedPage.id, sectionId: selectedSectionForField, fieldName: newField.field_name });
|
||||
|
||||
// 섹션에 항목 추가 (await로 완료 대기)
|
||||
// 2025-11-27: addItemMasterField 호출 제거 - 중복 필드 생성 방지
|
||||
// 계층구조에서 만든 필드는 섹션에만 연결됨 (section_id = X)
|
||||
// 항목탭에는 독립 필드(section_id = null)만 표시
|
||||
await addFieldToSection(Number(selectedSectionForField), newField);
|
||||
|
||||
toast.success('항목이 섹션에 추가되었습니다!');
|
||||
}
|
||||
}
|
||||
|
||||
resetFieldForm();
|
||||
resetFieldForm();
|
||||
} catch (error) {
|
||||
console.error('필드 처리 실패:', error);
|
||||
toast.error('항목 처리에 실패했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
// 필드 수정
|
||||
@@ -249,9 +231,10 @@ export function useFieldManagement(): UseFieldManagementReturn {
|
||||
setNewFieldName(field.field_name);
|
||||
setNewFieldKey(field.id.toString());
|
||||
setNewFieldInputType(field.field_type);
|
||||
setNewFieldRequired(field.is_required);
|
||||
// 2025-11-27: is_required와 properties.required 둘 다 체크
|
||||
setNewFieldRequired(field.is_required || field.properties?.required || false);
|
||||
setNewFieldOptions(field.options?.map(opt => opt.value).join(', ') || '');
|
||||
setNewFieldDescription('');
|
||||
setNewFieldDescription(field.placeholder || '');
|
||||
|
||||
// 조건부 표시 설정 로드
|
||||
if (field.display_condition) {
|
||||
|
||||
@@ -5,6 +5,12 @@ import { toast } from 'sonner';
|
||||
import { useItemMaster } from '@/contexts/ItemMasterContext';
|
||||
import type { ItemMasterField } from '@/contexts/ItemMasterContext';
|
||||
|
||||
/**
|
||||
* @deprecated 2025-11-27: item_fields로 통합됨.
|
||||
* - itemMasterFields → item_fields WHERE section_id IS NULL
|
||||
* - 내부적으로 fields.* API를 사용하도록 마이그레이션 완료
|
||||
* - 향후 독립 필드 관리용 훅으로 리네임 예정
|
||||
*/
|
||||
export interface UseMasterFieldManagementReturn {
|
||||
// 다이얼로그 상태
|
||||
isMasterFieldDialogOpen: boolean;
|
||||
@@ -99,7 +105,7 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn {
|
||||
|
||||
addItemMasterField(newMasterFieldData as any);
|
||||
resetMasterFieldForm();
|
||||
toast.success('마스터 항목이 추가되었습니다 (저장 필요)');
|
||||
toast.success('항목이 추가되었습니다');
|
||||
};
|
||||
|
||||
// 마스터 항목 수정 시작
|
||||
@@ -145,14 +151,14 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn {
|
||||
|
||||
updateItemMasterField(editingMasterFieldId, updateData);
|
||||
resetMasterFieldForm();
|
||||
toast.success('마스터 항목이 수정되었습니다 (저장 필요)');
|
||||
toast.success('항목이 수정되었습니다');
|
||||
};
|
||||
|
||||
// 마스터 항목 삭제
|
||||
// 항목 삭제 (2025-11-27: 마스터 항목 → 항목으로 통합)
|
||||
const handleDeleteMasterField = (id: number) => {
|
||||
if (confirm('이 마스터 항목을 삭제하시겠습니까?')) {
|
||||
if (confirm('이 항목을 삭제하시겠습니까?\n(섹션에서 사용 중인 경우 연결도 함께 해제됩니다)')) {
|
||||
deleteItemMasterField(id);
|
||||
toast.success('마스터 항목이 삭제되었습니다');
|
||||
toast.success('항목이 삭제되었습니다');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -27,11 +27,12 @@ export interface UseSectionManagementReturn {
|
||||
setExpandedSections: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
||||
|
||||
// 핸들러
|
||||
handleAddSection: (selectedPage: ItemPage | undefined) => void;
|
||||
handleLinkTemplate: (template: SectionTemplate, selectedPage: ItemPage | undefined) => void;
|
||||
handleAddSection: (selectedPage: ItemPage | undefined) => Promise<void>;
|
||||
handleLinkTemplate: (template: SectionTemplate, selectedPage: ItemPage | undefined) => Promise<void>;
|
||||
handleEditSectionTitle: (sectionId: number, currentTitle: string) => void;
|
||||
handleSaveSectionTitle: (selectedPage: ItemPage | undefined) => void;
|
||||
handleDeleteSection: (pageId: number, sectionId: number) => void;
|
||||
handleUnlinkSection: (pageId: number, sectionId: number) => void; // 계층구조 탭용 - 연결 해제
|
||||
handleDeleteSection: (pageId: number, sectionId: number) => void; // 섹션 탭용 - 실제 삭제
|
||||
toggleSection: (sectionId: string) => void;
|
||||
resetSectionForm: () => void;
|
||||
}
|
||||
@@ -42,8 +43,8 @@ export function useSectionManagement(): UseSectionManagementReturn {
|
||||
addSectionToPage,
|
||||
updateSection,
|
||||
deleteSection,
|
||||
addSectionTemplate,
|
||||
tenantId,
|
||||
linkSectionToPage, // 2025-11-26: 기존 섹션을 페이지에 연결 (entity_relationships)
|
||||
unlinkSectionFromPage, // 2025-11-26: EntityRelationship API 사용
|
||||
} = useItemMaster();
|
||||
|
||||
// 상태
|
||||
@@ -58,112 +59,84 @@ export function useSectionManagement(): UseSectionManagementReturn {
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 섹션 추가
|
||||
const handleAddSection = (selectedPage: ItemPage | undefined) => {
|
||||
const handleAddSection = async (selectedPage: ItemPage | undefined) => {
|
||||
if (!selectedPage || !newSectionTitle.trim()) {
|
||||
toast.error('하위섹션 제목을 입력해주세요');
|
||||
return;
|
||||
}
|
||||
|
||||
const sectionType: 'BASIC' | 'BOM' | 'CUSTOM' = newSectionType === 'bom' ? 'BOM' : 'BASIC';
|
||||
const newSection: ItemSection = {
|
||||
id: Date.now(),
|
||||
const newSection: Omit<ItemSection, 'id' | 'created_at' | 'updated_at'> = {
|
||||
page_id: selectedPage.id,
|
||||
section_name: newSectionTitle,
|
||||
title: newSectionTitle,
|
||||
section_type: sectionType,
|
||||
description: newSectionDescription || undefined,
|
||||
order_no: selectedPage.sections.length + 1,
|
||||
is_template: false,
|
||||
is_default: false,
|
||||
is_collapsible: true,
|
||||
is_default_open: true,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
fields: [],
|
||||
bomItems: sectionType === 'BOM' ? [] : undefined
|
||||
bom_items: sectionType === 'BOM' ? [] : undefined
|
||||
};
|
||||
|
||||
console.log('Adding section to page:', {
|
||||
pageId: selectedPage.id,
|
||||
page_name: selectedPage.page_name,
|
||||
sectionTitle: newSection.section_name,
|
||||
sectionTitle: newSection.title,
|
||||
sectionType: newSection.section_type,
|
||||
currentSectionCount: selectedPage.sections.length,
|
||||
newSection: newSection
|
||||
});
|
||||
|
||||
// 1. 페이지에 섹션 추가
|
||||
addSectionToPage(selectedPage.id, newSection);
|
||||
try {
|
||||
// 페이지에 섹션 추가 (API 호출)
|
||||
// 2025-11-26: sectionsAsTemplates가 itemPages에서 useMemo로 파생되므로
|
||||
// 별도의 addSectionTemplate 호출 불필요 (자동 동기화)
|
||||
await addSectionToPage(selectedPage.id, newSection);
|
||||
|
||||
// 2. 섹션관리 탭에도 템플릿으로 자동 추가
|
||||
const newTemplateData = {
|
||||
tenant_id: tenantId ?? 0,
|
||||
template_name: newSection.section_name,
|
||||
section_type: newSection.section_type as 'BASIC' | 'BOM' | 'CUSTOM',
|
||||
description: newSection.description ?? null,
|
||||
default_fields: null,
|
||||
created_by: null,
|
||||
updated_by: null,
|
||||
};
|
||||
addSectionTemplate(newTemplateData);
|
||||
console.log('Section added to page:', {
|
||||
sectionTitle: newSection.title
|
||||
});
|
||||
|
||||
console.log('Section added to both page and template:', {
|
||||
sectionId: newSection.id,
|
||||
templateTitle: newTemplateData.template_name
|
||||
});
|
||||
|
||||
resetSectionForm();
|
||||
toast.success(`${newSectionType === 'bom' ? 'BOM' : '일반'} 섹션이 페이지에 추가되고 템플릿으로도 등록되었습니다!`);
|
||||
resetSectionForm();
|
||||
toast.success(`${newSectionType === 'bom' ? 'BOM' : '일반'} 섹션이 추가되었습니다!`);
|
||||
} catch (error) {
|
||||
console.error('섹션 추가 실패:', error);
|
||||
toast.error('섹션 추가에 실패했습니다. 다시 시도해주세요.');
|
||||
}
|
||||
};
|
||||
|
||||
// 섹션 템플릿을 페이지에 연결
|
||||
const handleLinkTemplate = (template: SectionTemplate, selectedPage: ItemPage | undefined) => {
|
||||
// 기존 섹션을 페이지에 연결 (entity_relationships 테이블 사용)
|
||||
// 2025-11-26: 새 섹션 생성이 아닌, 기존 섹션을 연결만 함
|
||||
const handleLinkTemplate = async (template: SectionTemplate, selectedPage: ItemPage | undefined) => {
|
||||
if (!selectedPage) {
|
||||
toast.error('페이지를 먼저 선택해주세요');
|
||||
return;
|
||||
}
|
||||
|
||||
// 템플릿을 섹션으로 변환하여 페이지에 추가
|
||||
const newSection: Omit<ItemSection, 'id' | 'created_at' | 'updated_at'> = {
|
||||
page_id: selectedPage.id,
|
||||
section_name: template.template_name,
|
||||
section_type: template.section_type,
|
||||
description: template.description || undefined,
|
||||
order_no: selectedPage.sections.length + 1,
|
||||
is_collapsible: true,
|
||||
is_default_open: true,
|
||||
fields: template.fields ? template.fields.map((field, idx) => ({
|
||||
id: Date.now() + idx,
|
||||
section_id: 0, // 추후 업데이트됨
|
||||
field_name: field.name,
|
||||
field_type: field.property.inputType,
|
||||
order_no: idx + 1,
|
||||
is_required: field.property.required,
|
||||
placeholder: field.description || null,
|
||||
default_value: null,
|
||||
display_condition: null,
|
||||
validation_rules: null,
|
||||
options: field.property.options
|
||||
? field.property.options.map(opt => ({ label: opt, value: opt }))
|
||||
: null,
|
||||
properties: field.property.multiColumn ? {
|
||||
multiColumn: true,
|
||||
columnCount: field.property.columnCount,
|
||||
columnNames: field.property.columnNames
|
||||
} : null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
})) : [],
|
||||
bomItems: template.section_type === 'BOM' ? (template.bomItems || []) : undefined
|
||||
};
|
||||
// 이미 연결된 섹션인지 확인
|
||||
const isAlreadyLinked = selectedPage.sections.some(s => s.id === template.id);
|
||||
if (isAlreadyLinked) {
|
||||
toast.error('이미 페이지에 연결된 섹션입니다');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Linking template to page:', {
|
||||
templateId: template.id,
|
||||
templateName: template.template_name,
|
||||
console.log('Linking existing section to page:', {
|
||||
sectionId: template.id,
|
||||
sectionName: template.template_name,
|
||||
pageId: selectedPage.id,
|
||||
newSection
|
||||
orderNo: selectedPage.sections.length + 1,
|
||||
});
|
||||
|
||||
addSectionToPage(selectedPage.id, newSection);
|
||||
resetSectionForm();
|
||||
toast.success(`"${template.template_name}" 템플릿이 페이지에 연결되었습니다!`);
|
||||
try {
|
||||
// 기존 섹션을 페이지에 연결 (entity_relationships에 레코드 추가)
|
||||
await linkSectionToPage(selectedPage.id, template.id, selectedPage.sections.length + 1);
|
||||
resetSectionForm();
|
||||
toast.success(`"${template.template_name}" 섹션이 페이지에 연결되었습니다!`);
|
||||
} catch (error) {
|
||||
console.error('섹션 연결 실패:', error);
|
||||
toast.error('섹션 연결에 실패했습니다. 다시 시도해주세요.');
|
||||
}
|
||||
};
|
||||
|
||||
// 섹션 제목 수정 시작
|
||||
@@ -173,30 +146,53 @@ export function useSectionManagement(): UseSectionManagementReturn {
|
||||
};
|
||||
|
||||
// 섹션 제목 저장
|
||||
const handleSaveSectionTitle = (selectedPage: ItemPage | undefined) => {
|
||||
const handleSaveSectionTitle = async (selectedPage: ItemPage | undefined) => {
|
||||
if (!selectedPage || !editingSectionId || !editingSectionTitle.trim()) {
|
||||
toast.error('하위섹션 제목을 입력해주세요');
|
||||
return;
|
||||
}
|
||||
|
||||
updateSection(editingSectionId, { section_name: editingSectionTitle });
|
||||
setEditingSectionId(null);
|
||||
setEditingSectionTitle('');
|
||||
toast.success('하위섹션 제목이 수정되었습니다 (저장 필요)');
|
||||
try {
|
||||
await updateSection(editingSectionId, { title: editingSectionTitle });
|
||||
setEditingSectionId(null);
|
||||
setEditingSectionTitle('');
|
||||
toast.success('섹션 제목이 수정되었습니다!');
|
||||
} catch (error) {
|
||||
console.error('섹션 제목 수정 실패:', error);
|
||||
toast.error('섹션 제목 수정에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 섹션 삭제
|
||||
const handleDeleteSection = (pageId: number, sectionId: number) => {
|
||||
// 섹션 연결 해제 (계층구조 탭용 - 페이지에서만 분리, 섹션 데이터는 유지)
|
||||
// 2025-11-26: EntityRelationship API 사용 (DELETE /pages/{pageId}/unlink-section/{sectionId})
|
||||
const handleUnlinkSection = async (pageId: number, sectionId: number) => {
|
||||
try {
|
||||
await unlinkSectionFromPage(pageId, sectionId);
|
||||
console.log('섹션 연결 해제 완료:', { pageId, sectionId });
|
||||
toast.success('섹션 연결이 해제되었습니다');
|
||||
} catch (error) {
|
||||
console.error('섹션 연결 해제 실패:', error);
|
||||
toast.error('섹션 연결 해제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 섹션 삭제 (섹션 탭용 - 실제 데이터 삭제)
|
||||
const handleDeleteSection = async (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) || [];
|
||||
|
||||
deleteSection(sectionId);
|
||||
|
||||
console.log('섹션 삭제 완료:', {
|
||||
sectionId,
|
||||
removedFields: fieldIds.length
|
||||
});
|
||||
try {
|
||||
await deleteSection(sectionId);
|
||||
console.log('섹션 삭제 완료:', {
|
||||
sectionId,
|
||||
removedFields: fieldIds.length
|
||||
});
|
||||
toast.success('섹션이 삭제되었습니다!');
|
||||
} catch (error) {
|
||||
console.error('섹션 삭제 실패:', error);
|
||||
toast.error('섹션 삭제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 섹션 확장/축소 토글
|
||||
@@ -240,6 +236,7 @@ export function useSectionManagement(): UseSectionManagementReturn {
|
||||
handleLinkTemplate,
|
||||
handleEditSectionTitle,
|
||||
handleSaveSectionTitle,
|
||||
handleUnlinkSection,
|
||||
handleDeleteSection,
|
||||
toggleSection,
|
||||
resetSectionForm,
|
||||
|
||||
@@ -100,8 +100,13 @@ export function useTabManagement(): UseTabManagementReturn {
|
||||
]);
|
||||
const [activeTab, setActiveTab] = useState('hierarchy');
|
||||
|
||||
// 속성 하위 탭 상태
|
||||
const [attributeSubTabs, setAttributeSubTabs] = useState<AttributeSubTab[]>([]);
|
||||
// 속성 하위 탭 상태 (기본 탭: 단위, 재질, 표면처리)
|
||||
// TODO: 나중에 백엔드에서 기준값 로드로 대체 예정
|
||||
const [attributeSubTabs, setAttributeSubTabs] = useState<AttributeSubTab[]>([
|
||||
{ id: 'units', label: '단위', key: 'units', isDefault: true, order: 0 },
|
||||
{ id: 'materials', label: '재질', key: 'materials', isDefault: true, order: 1 },
|
||||
{ id: 'surface', label: '표면처리', key: 'surface', isDefault: true, order: 2 },
|
||||
]);
|
||||
const [activeAttributeTab, setActiveAttributeTab] = useState('units');
|
||||
|
||||
// 메인 탭 다이얼로그 상태
|
||||
@@ -123,7 +128,7 @@ export function useTabManagement(): UseTabManagementReturn {
|
||||
// 이전 필드 상태 추적용 ref (무한 루프 방지)
|
||||
const prevFieldsRef = useRef<string>('');
|
||||
|
||||
// 마스터 항목이 추가/수정될 때 속성 탭 자동 생성
|
||||
// 마스터 항목이 추가/수정/삭제될 때 속성 탭 자동 동기화
|
||||
useEffect(() => {
|
||||
// 현재 필드 상태를 문자열로 직렬화
|
||||
const currentFieldsState = JSON.stringify(
|
||||
@@ -136,15 +141,30 @@ export function useTabManagement(): UseTabManagementReturn {
|
||||
}
|
||||
prevFieldsRef.current = currentFieldsState;
|
||||
|
||||
// 현재 마스터 필드 ID 목록
|
||||
const currentFieldIds = new Set(itemMasterFields.map(f => f.id.toString()));
|
||||
|
||||
setAttributeSubTabs(prev => {
|
||||
const newTabs: AttributeSubTab[] = [];
|
||||
const updates: { key: string; label: string }[] = [];
|
||||
|
||||
// 삭제된 마스터 항목에 해당하는 탭 제거 (숫자 key만 체크 - 마스터 항목 ID)
|
||||
const filteredTabs = prev.filter(tab => {
|
||||
// 숫자로만 이루어진 key는 마스터 항목 ID
|
||||
const isNumericKey = /^\d+$/.test(tab.key);
|
||||
if (isNumericKey && !currentFieldIds.has(tab.key)) {
|
||||
// 삭제된 마스터 항목의 탭
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// 새로운 마스터 항목 추가 또는 기존 항목 라벨 업데이트
|
||||
itemMasterFields.forEach(field => {
|
||||
const existingTab = prev.find(tab => tab.key === field.id.toString());
|
||||
const existingTab = filteredTabs.find(tab => tab.key === field.id.toString());
|
||||
|
||||
if (!existingTab) {
|
||||
const maxOrder = Math.max(...prev.map(t => t.order), ...newTabs.map(t => t.order), -1);
|
||||
const maxOrder = Math.max(...filteredTabs.map(t => t.order), ...newTabs.map(t => t.order), -1);
|
||||
newTabs.push({
|
||||
id: `attr-${field.id.toString()}`,
|
||||
label: field.field_name,
|
||||
@@ -157,12 +177,17 @@ export function useTabManagement(): UseTabManagementReturn {
|
||||
}
|
||||
});
|
||||
|
||||
// 탭 삭제, 추가, 업데이트 여부 확인
|
||||
const hasRemovals = filteredTabs.length !== prev.length;
|
||||
const hasAdditions = newTabs.length > 0;
|
||||
const hasUpdates = updates.length > 0;
|
||||
|
||||
// 변경사항 없으면 이전 상태 그대로 반환
|
||||
if (newTabs.length === 0 && updates.length === 0) {
|
||||
if (!hasRemovals && !hasAdditions && !hasUpdates) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
let result = prev.map(tab => {
|
||||
let result = filteredTabs.map(tab => {
|
||||
const update = updates.find(u => u.key === tab.key);
|
||||
return update ? { ...tab, label: update.label } : tab;
|
||||
});
|
||||
@@ -173,6 +198,12 @@ export function useTabManagement(): UseTabManagementReturn {
|
||||
index === self.findIndex(t => t.key === tab.key)
|
||||
);
|
||||
});
|
||||
|
||||
// 현재 활성 탭이 삭제된 마스터 항목인 경우 기본 탭으로 전환
|
||||
const isNumericKey = /^\d+$/.test(activeAttributeTab);
|
||||
if (isNumericKey && !currentFieldIds.has(activeAttributeTab)) {
|
||||
setActiveAttributeTab('units');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [itemMasterFields]);
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ export interface UseTemplateManagementReturn {
|
||||
handleUpdateSectionTemplate: () => void;
|
||||
handleDeleteSectionTemplate: (id: number) => void;
|
||||
handleLoadTemplate: (selectedPage: ItemPage | undefined) => void;
|
||||
handleAddTemplateField: () => void;
|
||||
handleAddTemplateField: () => Promise<void>;
|
||||
handleEditTemplateField: (templateId: number, field: TemplateField) => void;
|
||||
handleDeleteTemplateField: (templateId: number, fieldId: string) => void;
|
||||
handleAddBOMItemToTemplate: (templateId: number, item: Omit<BOMItem, 'id' | 'created_at' | 'updated_at' | 'tenant_id' | 'section_id'>) => void;
|
||||
@@ -89,7 +89,20 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
|
||||
addSectionToPage,
|
||||
addItemMasterField,
|
||||
itemMasterFields,
|
||||
tenantId
|
||||
tenantId,
|
||||
// 2025-11-26: sectionsAsTemplates가 itemPages에서 파생되므로
|
||||
// 섹션 탭에서 수정/삭제 시 실제 섹션 API를 호출해야 함
|
||||
updateSection,
|
||||
deleteSection,
|
||||
itemPages,
|
||||
// 2025-11-26: 섹션 탭에서 새 섹션 추가 시 독립 섹션으로 생성
|
||||
createIndependentSection,
|
||||
// 2025-11-27: entity_relationships 기반 필드 연결/해제
|
||||
linkFieldToSection,
|
||||
unlinkFieldFromSection,
|
||||
independentFields,
|
||||
// 2025-11-27: 필드 수정 API
|
||||
updateField,
|
||||
} = useItemMaster();
|
||||
|
||||
// 섹션 템플릿 다이얼로그 상태
|
||||
@@ -127,28 +140,33 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
|
||||
const [templateFieldShowMasterFieldList, setTemplateFieldShowMasterFieldList] = useState(false);
|
||||
const [templateFieldSelectedMasterFieldId, setTemplateFieldSelectedMasterFieldId] = useState('');
|
||||
|
||||
// 섹션 템플릿 추가
|
||||
const handleAddSectionTemplate = () => {
|
||||
// 섹션 템플릿 추가 (2025-11-26: 독립 섹션으로 생성하여 sectionsAsTemplates에 반영)
|
||||
const handleAddSectionTemplate = async () => {
|
||||
if (!newSectionTemplateTitle.trim()) {
|
||||
toast.error('섹션 제목을 입력해주세요');
|
||||
return;
|
||||
}
|
||||
|
||||
const newTemplateData = {
|
||||
tenant_id: tenantId ?? 0,
|
||||
template_name: newSectionTemplateTitle,
|
||||
section_type: (newSectionTemplateType === 'bom' ? 'BOM' : 'BASIC') as 'BASIC' | 'BOM' | 'CUSTOM',
|
||||
description: newSectionTemplateDescription || null,
|
||||
default_fields: null,
|
||||
category: newSectionTemplateCategory,
|
||||
created_by: null,
|
||||
updated_by: null,
|
||||
// 2025-11-26: sectionsAsTemplates가 itemPages + independentSections에서 파생되므로
|
||||
// 독립 섹션으로 생성해야 화면에 바로 반영됨
|
||||
const newSectionData = {
|
||||
title: newSectionTemplateTitle,
|
||||
type: newSectionTemplateType as 'fields' | 'bom',
|
||||
description: newSectionTemplateDescription || undefined,
|
||||
is_template: true, // 섹션 탭에서 생성된 섹션은 템플릿으로 표시
|
||||
is_default: false,
|
||||
};
|
||||
|
||||
console.log('Adding section template:', newTemplateData);
|
||||
addSectionTemplate(newTemplateData);
|
||||
resetSectionTemplateForm();
|
||||
toast.success('섹션 템플릿이 추가되었습니다!');
|
||||
console.log('Adding independent section (from section tab):', newSectionData);
|
||||
|
||||
try {
|
||||
await createIndependentSection(newSectionData);
|
||||
resetSectionTemplateForm();
|
||||
toast.success('섹션이 추가되었습니다!');
|
||||
} catch (error) {
|
||||
console.error('섹션 추가 실패:', error);
|
||||
toast.error('섹션 추가에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 섹션 템플릿 수정 시작
|
||||
@@ -161,31 +179,43 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
|
||||
setIsSectionTemplateDialogOpen(true);
|
||||
};
|
||||
|
||||
// 섹션 템플릿 업데이트
|
||||
const handleUpdateSectionTemplate = () => {
|
||||
// 섹션 템플릿 업데이트 (2025-11-26: sectionsAsTemplates 사용으로 실제 섹션 API 호출)
|
||||
const handleUpdateSectionTemplate = async () => {
|
||||
if (!editingSectionTemplateId || !newSectionTemplateTitle.trim()) {
|
||||
toast.error('섹션 제목을 입력해주세요');
|
||||
return;
|
||||
}
|
||||
|
||||
// sectionsAsTemplates가 itemPages에서 파생되므로, 실제 섹션을 업데이트해야 함
|
||||
const updateData = {
|
||||
template_name: newSectionTemplateTitle,
|
||||
title: newSectionTemplateTitle,
|
||||
description: newSectionTemplateDescription || undefined,
|
||||
category: newSectionTemplateCategory.length > 0 ? newSectionTemplateCategory : undefined,
|
||||
section_type: (newSectionTemplateType === 'bom' ? 'BOM' : 'BASIC') as 'BASIC' | 'BOM' | 'CUSTOM'
|
||||
};
|
||||
|
||||
console.log('Updating section template:', { id: editingSectionTemplateId, updateData });
|
||||
updateSectionTemplate(editingSectionTemplateId, updateData);
|
||||
resetSectionTemplateForm();
|
||||
toast.success('섹션이 수정되었습니다 (저장 필요)');
|
||||
console.log('Updating section (from template handler):', { id: editingSectionTemplateId, updateData });
|
||||
try {
|
||||
// updateSection 호출 (템플릿이 아닌 실제 섹션 API)
|
||||
await updateSection(editingSectionTemplateId, updateData);
|
||||
resetSectionTemplateForm();
|
||||
toast.success('섹션이 수정되었습니다!');
|
||||
} catch (error) {
|
||||
console.error('섹션 수정 실패:', error);
|
||||
toast.error('섹션 수정에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 섹션 템플릿 삭제
|
||||
const handleDeleteSectionTemplate = (id: number) => {
|
||||
// 섹션 템플릿 삭제 (2025-11-26: sectionsAsTemplates 사용으로 실제 섹션 API 호출)
|
||||
const handleDeleteSectionTemplate = async (id: number) => {
|
||||
if (confirm('이 섹션을 삭제하시겠습니까?')) {
|
||||
deleteSectionTemplate(id);
|
||||
toast.success('섹션이 삭제되었습니다');
|
||||
try {
|
||||
// deleteSection 호출 (템플릿이 아닌 실제 섹션 API)
|
||||
await deleteSection(id);
|
||||
toast.success('섹션이 삭제되었습니다!');
|
||||
} catch (error) {
|
||||
console.error('섹션 삭제 실패:', error);
|
||||
toast.error('섹션 삭제에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -204,14 +234,16 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
|
||||
|
||||
const newSection = {
|
||||
page_id: selectedPage.id,
|
||||
section_name: template.template_name,
|
||||
title: template.template_name,
|
||||
section_type: template.section_type === 'BOM' ? 'BOM' as const : 'BASIC' as const,
|
||||
description: template.description || undefined,
|
||||
order_no: selectedPage.sections.length + 1,
|
||||
is_template: false,
|
||||
is_default: false,
|
||||
is_collapsible: true,
|
||||
is_default_open: true,
|
||||
fields: [],
|
||||
bomItems: template.section_type === 'BOM' ? [] : undefined
|
||||
bom_items: template.section_type === 'BOM' ? [] : undefined
|
||||
};
|
||||
|
||||
console.log('Loading template to section:', template.template_name, 'newSection:', newSection);
|
||||
@@ -221,77 +253,57 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
|
||||
toast.success('섹션이 불러와졌습니다');
|
||||
};
|
||||
|
||||
// 템플릿 필드 추가
|
||||
const handleAddTemplateField = () => {
|
||||
// 템플릿 필드 추가/수정 (2025-11-27: API 사용으로 변경)
|
||||
// sectionsAsTemplates가 itemPages + independentSections에서 파생되므로
|
||||
// entity_relationships 기반 연결 API를 사용해야 실시간 반영됨
|
||||
const handleAddTemplateField = async () => {
|
||||
if (!currentTemplateId || !templateFieldName.trim() || !templateFieldKey.trim()) {
|
||||
toast.error('모든 필수 항목을 입력해주세요');
|
||||
return;
|
||||
}
|
||||
|
||||
const template = sectionTemplates.find(t => t.id === currentTemplateId);
|
||||
if (!template) return;
|
||||
try {
|
||||
// 수정 모드: 기존 필드 속성 업데이트
|
||||
if (editingTemplateFieldId) {
|
||||
const updateData = {
|
||||
field_name: templateFieldName,
|
||||
field_type: templateFieldInputType,
|
||||
is_required: templateFieldRequired,
|
||||
placeholder: templateFieldDescription || null,
|
||||
options: templateFieldInputType === 'dropdown' && templateFieldOptions.trim()
|
||||
? templateFieldOptions.split(',').map(o => ({ label: o.trim(), value: o.trim() }))
|
||||
: null,
|
||||
properties: {
|
||||
inputType: templateFieldInputType,
|
||||
required: templateFieldRequired,
|
||||
multiColumn: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') ? templateFieldMultiColumn : undefined,
|
||||
columnCount: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') && templateFieldMultiColumn ? templateFieldColumnCount : undefined,
|
||||
columnNames: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') && templateFieldMultiColumn ? templateFieldColumnNames : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
// 마스터 필드에 없으면 자동 추가
|
||||
const existingMasterField = itemMasterFields.find(f => f.id.toString() === templateFieldKey);
|
||||
if (!existingMasterField && !editingTemplateFieldId) {
|
||||
const newMasterFieldData: Omit<ItemMasterField, 'id' | 'tenant_id' | 'created_by' | 'updated_by' | 'created_at' | 'updated_at'> = {
|
||||
field_name: templateFieldName,
|
||||
field_type: templateFieldInputType,
|
||||
category: '공통',
|
||||
description: templateFieldDescription || null,
|
||||
is_common: false,
|
||||
default_value: null,
|
||||
options: templateFieldInputType === 'dropdown' && templateFieldOptions.trim()
|
||||
? templateFieldOptions.split(',').map(o => ({ label: o.trim(), value: o.trim() }))
|
||||
: null,
|
||||
validation_rules: null,
|
||||
properties: {
|
||||
inputType: templateFieldInputType,
|
||||
required: templateFieldRequired,
|
||||
multiColumn: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') ? templateFieldMultiColumn : undefined,
|
||||
columnCount: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') && templateFieldMultiColumn ? templateFieldColumnCount : undefined,
|
||||
columnNames: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') && templateFieldMultiColumn ? templateFieldColumnNames : undefined
|
||||
},
|
||||
};
|
||||
addItemMasterField(newMasterFieldData as any);
|
||||
toast.success('항목 탭에 자동으로 추가되었습니다');
|
||||
await updateField(editingTemplateFieldId, updateData);
|
||||
toast.success('항목이 수정되었습니다');
|
||||
resetTemplateFieldForm();
|
||||
return;
|
||||
}
|
||||
|
||||
// 추가 모드: 기존 필드를 섹션에 연결
|
||||
const existingField = independentFields.find(f => f.id.toString() === templateFieldKey);
|
||||
|
||||
if (existingField) {
|
||||
await linkFieldToSection(currentTemplateId, existingField.id);
|
||||
toast.success('항목이 섹션에 연결되었습니다');
|
||||
} else {
|
||||
toast.error('항목 탭에서 먼저 항목을 생성해주세요');
|
||||
return;
|
||||
}
|
||||
|
||||
resetTemplateFieldForm();
|
||||
} catch (error) {
|
||||
console.error('항목 처리 실패:', error);
|
||||
toast.error('항목 처리에 실패했습니다');
|
||||
}
|
||||
|
||||
// TemplateField 형식으로 생성
|
||||
const newField: TemplateField = {
|
||||
id: String(editingTemplateFieldId || Date.now()),
|
||||
name: templateFieldName,
|
||||
fieldKey: templateFieldKey,
|
||||
property: {
|
||||
inputType: templateFieldInputType,
|
||||
required: templateFieldRequired,
|
||||
options: templateFieldInputType === 'dropdown' && templateFieldOptions.trim()
|
||||
? templateFieldOptions.split(',').map(o => o.trim())
|
||||
: undefined,
|
||||
multiColumn: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') ? templateFieldMultiColumn : undefined,
|
||||
columnCount: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') && templateFieldMultiColumn ? templateFieldColumnCount : undefined,
|
||||
columnNames: (templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') && templateFieldMultiColumn ? templateFieldColumnNames : undefined
|
||||
},
|
||||
description: templateFieldDescription || undefined
|
||||
};
|
||||
|
||||
let updatedFields;
|
||||
const currentFields = template.default_fields
|
||||
? (typeof template.default_fields === 'string' ? JSON.parse(template.default_fields) : template.default_fields)
|
||||
: [];
|
||||
|
||||
if (editingTemplateFieldId) {
|
||||
updatedFields = Array.isArray(currentFields)
|
||||
? currentFields.map((f: any) => String(f.id) === String(editingTemplateFieldId) ? newField : f)
|
||||
: [];
|
||||
toast.success('항목이 수정되었습니다');
|
||||
} else {
|
||||
updatedFields = Array.isArray(currentFields) ? [...currentFields, newField] : [newField];
|
||||
toast.success('항목이 추가되었습니다');
|
||||
}
|
||||
|
||||
updateSectionTemplate(currentTemplateId, { default_fields: updatedFields });
|
||||
resetTemplateFieldForm();
|
||||
};
|
||||
|
||||
// 템플릿 필드 수정 시작
|
||||
@@ -310,21 +322,20 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
|
||||
setIsTemplateFieldDialogOpen(true);
|
||||
};
|
||||
|
||||
// 템플릿 필드 삭제
|
||||
const handleDeleteTemplateField = (templateId: number, fieldId: string) => {
|
||||
if (!confirm('이 항목을 삭제하시겠습니까?')) return;
|
||||
// 템플릿 필드 연결 해제 (2025-11-27: entity_relationships 기반으로 변경)
|
||||
// sectionId = templateId (sectionsAsTemplates에서 섹션 ID로 사용)
|
||||
// fieldId = 실제 item_fields의 ID
|
||||
const handleDeleteTemplateField = async (templateId: number, fieldId: string) => {
|
||||
if (!confirm('이 항목의 연결을 해제하시겠습니까?\n(항목 자체는 삭제되지 않고 항목 탭에 유지됩니다)')) return;
|
||||
|
||||
const template = sectionTemplates.find(t => t.id === templateId);
|
||||
if (!template) return;
|
||||
|
||||
const currentFields = template.default_fields
|
||||
? (typeof template.default_fields === 'string' ? JSON.parse(template.default_fields) : template.default_fields)
|
||||
: [];
|
||||
const updatedFields = Array.isArray(currentFields)
|
||||
? currentFields.filter((f: any) => String(f.id) !== String(fieldId))
|
||||
: [];
|
||||
updateSectionTemplate(templateId, { default_fields: updatedFields });
|
||||
toast.success('항목이 삭제되었습니다');
|
||||
try {
|
||||
// entity_relationships 기반 연결 해제 API 호출
|
||||
await unlinkFieldFromSection(templateId, Number(fieldId));
|
||||
toast.success('항목 연결이 해제되었습니다');
|
||||
} catch (error) {
|
||||
console.error('항목 연결 해제 실패:', error);
|
||||
toast.error('항목 연결 해제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// BOM 항목 추가
|
||||
|
||||
Reference in New Issue
Block a user