feat: API 프록시 추가 및 품목기준관리 기능 개선

- HttpOnly 쿠키 기반 API 프록시 라우트 추가 (/api/proxy/[...path])
- 품목기준관리 컴포넌트 개선 (섹션, 필드, 다이얼로그)
- ItemMasterContext API 연동 강화
- mock-data 제거 및 실제 API 연동
- 문서 명명규칙 정리 ([TYPE-DATE] 형식)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-11-25 21:07:10 +09:00
parent 5b2f8adc87
commit 593644922a
37 changed files with 5897 additions and 3267 deletions

View File

@@ -16,7 +16,7 @@ interface BOMManagementSectionProps {
title?: string;
description?: string;
bomItems: BOMItem[];
onAddItem: (item: Omit<BOMItem, 'id' | 'createdAt' | 'created_at' | 'updated_at' | 'tenant_id' | 'section_id'>) => void;
onAddItem: (item: Omit<BOMItem, 'id' | 'created_at' | 'updated_at' | 'tenant_id' | 'section_id'>) => void;
onUpdateItem: (id: number, item: Partial<BOMItem>) => void;
onDeleteItem: (id: number) => void;
itemTypeOptions?: { value: string; label: string }[];
@@ -30,17 +30,8 @@ export function BOMManagementSection({
onAddItem,
onUpdateItem,
onDeleteItem,
itemTypeOptions = [
{ value: 'product', label: '제품' },
{ value: 'part', label: '부품' },
{ value: 'material', label: '원자재' },
],
unitOptions = [
{ value: 'EA', label: 'EA' },
{ value: 'KG', label: 'KG' },
{ value: 'M', label: 'M' },
{ value: 'L', label: 'L' },
],
itemTypeOptions = [],
unitOptions = [],
}: BOMManagementSectionProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);

View File

@@ -8,7 +8,7 @@
import { useRouter } from 'next/navigation';
import type { ItemMaster } from '@/types/item';
import { ITEM_TYPE_LABELS, _PART_TYPE_LABELS, _PART_USAGE_LABELS, PRODUCT_CATEGORY_LABELS } from '@/types/item';
import { ITEM_TYPE_LABELS, PART_TYPE_LABELS, PART_USAGE_LABELS, PRODUCT_CATEGORY_LABELS } from '@/types/item';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';

View File

@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { useItemMaster } from '@/contexts/ItemMasterContext';
import type { ItemPage, ItemSection, ItemField, FieldDisplayCondition, ItemMasterField, ItemFieldProperty, SectionTemplate, BOMItem } from '@/contexts/ItemMasterContext';
import type { ItemPage, ItemSection, ItemField, FieldDisplayCondition, ItemMasterField, ItemFieldProperty, SectionTemplate, BOMItem, TemplateField } from '@/contexts/ItemMasterContext';
import { MasterFieldTab, HierarchyTab, SectionsTab } from './ItemMasterDataManagement/tabs';
import { FieldDialog } from './ItemMasterDataManagement/dialogs/FieldDialog';
import { type ConditionalFieldConfig } from './ItemMasterDataManagement/components/ConditionalDisplayUI';
@@ -82,6 +82,7 @@ const INPUT_TYPE_OPTIONS = [
export function ItemMasterDataManagement() {
const {
itemPages,
loadItemPages,
addItemPage,
updateItemPage,
deleteItemPage,
@@ -93,14 +94,17 @@ export function ItemMasterDataManagement() {
deleteField,
reorderFields,
itemMasterFields,
loadItemMasterFields,
addItemMasterField,
updateItemMasterField,
deleteItemMasterField,
sectionTemplates,
loadSectionTemplates,
addSectionTemplate,
updateSectionTemplate,
deleteSectionTemplate,
resetAllData
resetAllData,
tenantId
} = useItemMaster();
console.log('ItemMasterDataManagement: Current sectionTemplates', sectionTemplates);
@@ -134,23 +138,17 @@ export function ItemMasterDataManagement() {
const data = await itemMasterApi.init();
// 페이지 데이터 로드 (context의 addItemPage 사용)
data.pages.forEach(page => {
const transformed = transformPagesResponse([page])[0];
addItemPage(transformed);
});
// 페이지 데이터 로드 (이미 존재하는 데이터를 state에 로드 - API 호출 없음)
const transformedPages = transformPagesResponse(data.pages);
loadItemPages(transformedPages);
// 섹션 템플릿 로드
data.sectionTemplates.forEach(template => {
const transformed = transformSectionTemplatesResponse([template])[0];
addSectionTemplate(transformed);
});
// 섹션 템플릿 로드 (덮어쓰기 - API 호출 없음!)
const transformedTemplates = transformSectionTemplatesResponse(data.sectionTemplates);
loadSectionTemplates(transformedTemplates);
// 마스터 필드 로드
data.masterFields.forEach(field => {
const transformed = transformMasterFieldsResponse([field])[0];
addItemMasterField(transformed);
});
// 마스터 필드 로드 (덮어쓰기 - API 호출 없음!)
const transformedFields = transformMasterFieldsResponse(data.masterFields);
loadItemMasterFields(transformedFields);
// 커스텀 탭 로드 (local state)
if (data.customTabs && data.customTabs.length > 0) {
@@ -207,12 +205,8 @@ export function ItemMasterDataManagement() {
const [activeTab, setActiveTab] = useState('hierarchy');
// 속성 하위 탭 관리
const [attributeSubTabs, setAttributeSubTabs] = useState<Array<{id: string; label: string; key: string; isDefault: boolean; order: number}>>([
{ 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 }
]);
// 속성 하위 탭 관리 (API에서 로드)
const [attributeSubTabs, setAttributeSubTabs] = useState<Array<{id: string; label: string; key: string; isDefault: boolean; order: number}>>([]);
// 마스터 항목이 추가/수정될 때 속성 탭 자동 생성
useEffect(() => {
@@ -344,7 +338,9 @@ export function ItemMasterDataManagement() {
const [newSectionTitle, setNewSectionTitle] = useState('');
const [newSectionDescription, setNewSectionDescription] = useState('');
const [newSectionType, setNewSectionType] = useState<'fields' | 'bom'>('fields');
const [sectionInputMode, setSectionInputMode] = useState<'custom' | 'template'>('custom');
const [selectedSectionTemplateId, setSelectedSectionTemplateId] = useState<number | null>(null);
// 모바일 체크
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
@@ -426,6 +422,10 @@ export function ItemMasterDataManagement() {
const [templateFieldMultiColumn, setTemplateFieldMultiColumn] = useState(false);
const [templateFieldColumnCount, setTemplateFieldColumnCount] = useState(2);
const [templateFieldColumnNames, setTemplateFieldColumnNames] = useState<string[]>(['컬럼1', '컬럼2']);
// 템플릿 필드 마스터 항목 관련 상태
const [templateFieldInputMode, setTemplateFieldInputMode] = useState<'custom' | 'master'>('custom');
const [templateFieldShowMasterFieldList, setTemplateFieldShowMasterFieldList] = useState(false);
const [templateFieldSelectedMasterFieldId, setTemplateFieldSelectedMasterFieldId] = useState('');
// BOM 관리 상태
const [_bomItems, setBomItems] = useState<BOMItem[]>([]);
@@ -433,6 +433,8 @@ export function ItemMasterDataManagement() {
// 속성 변경 시 연동된 마스터 항목의 옵션 자동 업데이트
useEffect(() => {
itemMasterFields.forEach(field => {
// default_properties가 null/undefined인 경우 스킵
if (!field.default_properties) return;
const attributeType = (field.default_properties as any).attributeType;
if (attributeType && attributeType !== 'custom' && field.default_properties?.inputType === 'dropdown') {
let newOptions: string[] = [];
@@ -548,20 +550,19 @@ export function ItemMasterDataManagement() {
setIsLoading(true);
const absolutePath = generateAbsolutePath(newPageItemType, newPageName);
// API 호출
const response = await itemMasterApi.pages.create({
// Context의 addItemPage 사용 (API 호출 + state 업데이트)
// ⚠️ 이전 코드는 여기서 API 호출 후 addItemPage도 호출해서 API가 2번 호출되는 버그가 있었음
const newPage = await addItemPage({
page_name: newPageName,
item_type: newPageItemType,
absolute_path: absolutePath,
is_active: true,
sections: [],
order_no: 0,
});
// 응답 변환 및 context에 추가
const transformedPage = transformPageResponse(response);
addItemPage(transformedPage);
// 새로 생성된 페이지를 선택
setSelectedPageId(transformedPage.id);
setSelectedPageId(newPage.id);
// 폼 초기화
setNewPageName('');
@@ -586,43 +587,39 @@ export function ItemMasterDataManagement() {
}
};
const handleDuplicatePage = (pageId: number) => {
const handleDuplicatePage = async (pageId: number) => {
const originalPage = itemPages.find(p => p.id === pageId);
if (!originalPage) return toast.error('페이지를 찾을 수 없습니다');
// 섹션 인스턴스 깊은 복사 (새로운 ID 부여)
const duplicatedSections = originalPage.sections.map(section => ({
...section,
id: Date.now(),
fields: section.fields?.map(field => ({
...field,
id: Date.now()
})) || [],
bomItems: section.bomItems?.map(item => ({
...item,
id: Date.now()
}))
}));
try {
setIsLoading(true);
// 페이지 복제
const duplicatedPageName = `${originalPage.page_name} (복제)`;
const absolutePath = generateAbsolutePath(originalPage.item_type, duplicatedPageName);
const newPage: ItemPage = {
id: Date.now(),
page_name: duplicatedPageName,
item_type: originalPage.item_type,
sections: duplicatedSections,
is_active: true,
absolute_path: absolutePath,
order_no: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
// 페이지 복제
const duplicatedPageName = `${originalPage.page_name} (복제)`;
const absolutePath = generateAbsolutePath(originalPage.item_type, duplicatedPageName);
// 페이지 추가
addItemPage(newPage);
setSelectedPageId(newPage.id);
toast.success('페이지가 복제되었습니다 (저장 필요)');
// Context의 addItemPage 사용 (API 호출 + state 업데이트)
const newPage = await addItemPage({
page_name: duplicatedPageName,
item_type: originalPage.item_type,
sections: [], // 섹션은 별도 API로 복제해야 함
is_active: true,
absolute_path: absolutePath,
order_no: 0,
});
// 서버에서 반환된 ID로 선택
setSelectedPageId(newPage.id);
toast.success('페이지가 복제되었습니다');
// TODO: 원본 페이지의 섹션들도 복제 필요 (별도 API 호출)
} catch (err) {
const errorMessage = getErrorMessage(err);
toast.error(errorMessage);
console.error('❌ Failed to duplicate page:', err);
} finally {
setIsLoading(false);
}
};
const handleAddSection = () => {
@@ -657,20 +654,21 @@ export function ItemMasterDataManagement() {
// 섹션은 페이지의 일부이므로 sections로 별도 추적하지 않음
// 2. 섹션관리 탭에도 템플릿으로 자동 추가 (계층구조 섹션 = 섹션 탭 섹션)
const newTemplate: SectionTemplate = {
id: Date.now(),
// 프론트엔드 형식: template_name, section_type ('BASIC' | 'BOM' | 'CUSTOM')
const newTemplateData = {
tenant_id: tenantId ?? 0,
template_name: newSection.section_name,
section_type: newSection.section_type,
description: newSection.description,
section_type: newSection.section_type as 'BASIC' | 'BOM' | 'CUSTOM',
description: newSection.description ?? null,
default_fields: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
created_by: null,
updated_by: null,
};
addSectionTemplate(newTemplate);
addSectionTemplate(newTemplateData);
console.log('Section added to both page and template:', {
sectionId: newSection.id,
templateId: newTemplate.id
templateTitle: newTemplateData.title
});
setNewSectionTitle('');
@@ -680,6 +678,67 @@ export function ItemMasterDataManagement() {
toast.success(`${newSectionType === 'bom' ? 'BOM' : '일반'} 섹션이 페이지에 추가되고 템플릿으로도 등록되었습니다!`);
};
// 섹션 템플릿을 페이지에 연결 (SectionDialog에서 사용)
const handleLinkTemplate = (template: SectionTemplate) => {
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
};
console.log('Linking template to page:', {
templateId: template.id,
templateName: template.template_name,
pageId: selectedPage.id,
newSection
});
addSectionToPage(selectedPage.id, newSection);
// 다이얼로그 상태 초기화
setSectionInputMode('custom');
setSelectedSectionTemplateId(null);
setNewSectionTitle('');
setNewSectionDescription('');
setNewSectionType('fields');
setIsSectionDialogOpen(false);
toast.success(`"${template.template_name}" 템플릿이 페이지에 연결되었습니다!`);
};
const handleEditSectionTitle = (sectionId: string, currentTitle: string) => {
setEditingSectionId(sectionId);
setEditingSectionTitle(currentTitle);
@@ -758,9 +817,15 @@ export function ItemMasterDataManagement() {
// 텍스트박스 컬럼 설정
const hasColumns = newFieldInputType === 'textbox' && textboxColumns.length > 0;
// 마스터 항목에서 가져온 경우 master_field_id 설정
const masterFieldId = fieldInputMode === 'master' && selectedMasterFieldId
? Number(selectedMasterFieldId)
: null;
const newField: ItemField = {
id: editingFieldId ? Number(editingFieldId) : Date.now(),
section_id: Number(selectedSectionForField),
master_field_id: masterFieldId, // 마스터 항목 연결 정보
field_name: newFieldName,
field_type: newFieldInputType,
order_no: 0,
@@ -805,17 +870,16 @@ export function ItemMasterDataManagement() {
// 1. 섹션에 항목 추가
addFieldToSection(Number(selectedSectionForField), newField);
// 2. 항목관리 탭에도 마스터 항목으로 자동 추가 (중복 체크)
// 2. 마스터 항목 선택이 아닌 경우에만 새 마스터 항목 자동 생성
// (마스터 항목 선택 시에는 이미 master_field_id로 연결되어 있음)
const isFromMasterField = masterFieldId !== null;
const existingMasterField = itemMasterFields.find(mf => mf.id.toString() === newField.field_name);
if (!existingMasterField) {
if (!isFromMasterField && !existingMasterField) {
// API 스펙: field_type은 소문자만 허용 (textbox, number, dropdown, checkbox, date, textarea)
const newMasterField: ItemMasterField = {
id: Date.now(),
field_name: newField.field_name,
field_type: newField.field_type === 'textbox' ? 'TEXT' :
newField.field_type === 'number' ? 'NUMBER' :
newField.field_type === 'date' ? 'DATE' :
newField.field_type === 'textarea' ? 'TEXTAREA' :
newField.field_type === 'checkbox' ? 'CHECKBOX' : 'SELECT',
field_type: newField.field_type, // API 스펙에 맞게 소문자 그대로 전달
description: newField.placeholder,
default_properties: newField.properties,
category: selectedPage.item_type, // 현재 페이지의 품목유형을 카테고리로 설정
@@ -975,14 +1039,11 @@ export function ItemMasterDataManagement() {
}));
}
// API 스펙: field_type은 소문자만 허용 (textbox, number, dropdown, checkbox, date, textarea)
const newMasterField: ItemMasterField = {
id: Date.now(),
field_name: newMasterFieldName,
field_type: newMasterFieldInputType === 'textbox' ? 'TEXT' :
newMasterFieldInputType === 'number' ? 'NUMBER' :
newMasterFieldInputType === 'date' ? 'DATE' :
newMasterFieldInputType === 'textarea' ? 'TEXTAREA' :
newMasterFieldInputType === 'checkbox' ? 'CHECKBOX' : 'SELECT',
field_type: newMasterFieldInputType,
category: newMasterFieldCategory || null,
description: newMasterFieldDescription || null,
default_validation: null,
@@ -1214,21 +1275,24 @@ export function ItemMasterDataManagement() {
// 섹션 템플릿 핸들러
const handleAddSectionTemplate = () => {
if (!newSectionTemplateTitle.trim())
if (!newSectionTemplateTitle.trim())
return toast.error('섹션 제목을 입력해주세요');
const newTemplate: SectionTemplate = {
id: Date.now(),
// Context의 addSectionTemplate이 기대하는 SectionTemplate 형식 사용
// template_name, section_type ('BASIC' | 'BOM' | 'CUSTOM')
const newTemplateData = {
tenant_id: tenantId ?? 0,
template_name: newSectionTemplateTitle,
section_type: newSectionTemplateType === 'bom' ? 'BOM' : 'BASIC',
description: newSectionTemplateDescription || undefined,
section_type: (newSectionTemplateType === 'bom' ? 'BOM' : 'BASIC') as 'BASIC' | 'BOM' | 'CUSTOM',
description: newSectionTemplateDescription || null,
default_fields: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
category: newSectionTemplateCategory,
created_by: null,
updated_by: null,
};
console.log('Adding section template:', newTemplate);
addSectionTemplate(newTemplate);
console.log('Adding section template:', newTemplateData);
addSectionTemplate(newTemplateData);
setNewSectionTemplateTitle('');
setNewSectionTemplateDescription('');
@@ -1240,9 +1304,10 @@ export function ItemMasterDataManagement() {
const handleEditSectionTemplate = (template: SectionTemplate) => {
setEditingSectionTemplateId(template.id);
// SectionTemplate 타입에 맞게 template_name, section_type 사용
setNewSectionTemplateTitle(template.template_name);
setNewSectionTemplateDescription(template.description || '');
setNewSectionTemplateCategory([]);
setNewSectionTemplateCategory(template.category || []);
setNewSectionTemplateType(template.section_type === 'BOM' ? 'bom' : 'fields');
setIsSectionTemplateDialogOpen(true);
};
@@ -1251,13 +1316,16 @@ export function ItemMasterDataManagement() {
if (!editingSectionTemplateId || !newSectionTemplateTitle.trim())
return toast.error('섹션 제목을 입력해주세요');
// Context의 updateSectionTemplate이 기대하는 SectionTemplate 형식 사용
// template_name, section_type ('BASIC' | 'BOM' | 'CUSTOM')
const updateData = {
title: newSectionTemplateTitle,
template_name: newSectionTemplateTitle,
description: newSectionTemplateDescription || undefined,
category: newSectionTemplateCategory.length > 0 ? newSectionTemplateCategory : undefined,
type: newSectionTemplateType
section_type: (newSectionTemplateType === 'bom' ? 'BOM' : 'BASIC') as 'BASIC' | 'BOM' | 'CUSTOM'
};
console.log('Updating section template:', { id: editingSectionTemplateId, updateData });
updateSectionTemplate(editingSectionTemplateId, updateData);
setEditingSectionTemplateId(null);
@@ -1290,19 +1358,20 @@ export function ItemMasterDataManagement() {
}
// 템플릿을 복사해서 섹션으로 추가
// API 스펙: SectionTemplate은 title, type ('fields' | 'bom') 사용
const newSection: Omit<ItemSection, 'id' | 'created_at' | 'updated_at'> = {
page_id: selectedPage.id,
section_name: template.template_name,
section_type: template.section_type,
section_name: template.title,
section_type: template.type === 'bom' ? 'BOM' : 'BASIC',
description: template.description || undefined,
order_no: selectedPage.sections.length + 1,
is_collapsible: true,
is_default_open: true,
fields: [],
bomItems: template.section_type === 'BOM' ? [] : undefined
bomItems: template.type === 'bom' ? [] : undefined
};
console.log('Loading template to section:', template.template_name, 'type:', template.section_type, 'newSection:', newSection);
console.log('Loading template to section:', template.title, 'type:', template.type, 'newSection:', newSection);
addSectionToPage(selectedPage.id, newSection);
setSelectedTemplateId(null);
setIsLoadTemplateDialogOpen(false);
@@ -1321,15 +1390,11 @@ export function ItemMasterDataManagement() {
// 항목 탭에 해당 항목이 없으면 자동으로 추가
const existingMasterField = itemMasterFields.find(f => f.id.toString() === templateFieldKey);
if (!existingMasterField && !editingTemplateFieldId) {
// API 스펙: field_type은 소문자만 허용 (textbox, number, dropdown, checkbox, date, textarea)
const newMasterField: ItemMasterField = {
id: Date.now(),
field_name: templateFieldName,
field_type: templateFieldInputType === 'textbox' ? 'TEXT' :
templateFieldInputType === 'number' ? 'NUMBER' :
templateFieldInputType === 'date' ? 'DATE' :
templateFieldInputType === 'dropdown' ? 'SELECT' :
templateFieldInputType === 'textarea' ? 'TEXTAREA' :
templateFieldInputType === 'checkbox' ? 'CHECKBOX' : 'TEXT',
field_type: templateFieldInputType,
default_properties: {
inputType: templateFieldInputType,
required: templateFieldRequired,
@@ -1380,38 +1445,30 @@ export function ItemMasterDataManagement() {
}
}
const newField: ItemField = {
id: editingTemplateFieldId || Date.now(),
section_id: 0, // Placeholder for template
field_name: templateFieldName,
field_type: templateFieldInputType,
order_no: 0,
is_required: templateFieldRequired,
placeholder: templateFieldDescription || null,
default_value: null,
display_condition: null,
validation_rules: null,
options: templateFieldInputType === 'dropdown' && templateFieldOptions.trim()
? templateFieldOptions.split(',').map(o => ({ label: o.trim(), value: o.trim() }))
: null,
properties: {
// TemplateField 형식으로 생성 (UI가 기대하는 형식)
const newField: TemplateField = {
id: String(editingTemplateFieldId || Date.now()),
name: templateFieldName,
fieldKey: templateFieldKey,
property: {
inputType: templateFieldInputType,
required: templateFieldRequired,
row: 1,
col: 1,
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
},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
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) => f.id === editingTemplateFieldId ? newField : f) : [];
// f.id는 string, editingTemplateFieldId는 number이므로 String으로 변환하여 비교
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];
@@ -1434,18 +1491,19 @@ export function ItemMasterDataManagement() {
setIsTemplateFieldDialogOpen(false);
};
const handleEditTemplateField = (templateId: number, field: ItemField) => {
// TemplateField 형식으로 수정 (UI가 전달하는 형식)
const handleEditTemplateField = (templateId: number, field: TemplateField) => {
setCurrentTemplateId(templateId);
setEditingTemplateFieldId(field.id);
setTemplateFieldName(field.field_name);
setTemplateFieldKey(field.id.toString());
setTemplateFieldInputType(field.properties?.inputType);
setTemplateFieldRequired(field.is_required);
setTemplateFieldOptions(field.options?.map(o => o.value).join(', ') || '');
setTemplateFieldDescription(field.placeholder || '');
setTemplateFieldMultiColumn(field.properties?.multiColumn || false);
setTemplateFieldColumnCount(field.properties?.columnCount || 2);
setTemplateFieldColumnNames(field.properties?.columnNames || ['컬럼1', '컬럼2']);
setEditingTemplateFieldId(Number(field.id)); // TemplateField.id는 string
setTemplateFieldName(field.name);
setTemplateFieldKey(field.fieldKey);
setTemplateFieldInputType(field.property.inputType);
setTemplateFieldRequired(field.property.required);
setTemplateFieldOptions(field.property.options?.join(', ') || '');
setTemplateFieldDescription(field.description || '');
setTemplateFieldMultiColumn(field.property.multiColumn || false);
setTemplateFieldColumnCount(field.property.columnCount || 2);
setTemplateFieldColumnNames(field.property.columnNames || ['컬럼1', '컬럼2']);
setIsTemplateFieldDialogOpen(true);
};
@@ -1456,7 +1514,8 @@ export function ItemMasterDataManagement() {
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) => f.id !== fieldId) : [];
// f.id는 number 또는 string일 수 있으므로 String으로 변환하여 비교
const updatedFields = Array.isArray(currentFields) ? currentFields.filter((f: any) => String(f.id) !== String(fieldId)) : [];
updateSectionTemplate(templateId, { default_fields: updatedFields });
toast.success('항목이 삭제되었습니다');
};
@@ -1468,7 +1527,7 @@ export function ItemMasterDataManagement() {
id: Date.now(),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
tenant_id: 1,
tenant_id: tenantId ?? 0,
section_id: 0
};
setBomItems(prev => [...prev, newItem]);
@@ -1489,7 +1548,7 @@ export function ItemMasterDataManagement() {
id: Date.now(),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
tenant_id: 1,
tenant_id: tenantId ?? 0,
section_id: 0
};
@@ -1770,11 +1829,7 @@ export function ItemMasterDataManagement() {
{ id: 'attributes', label: '속성', icon: 'Settings', isDefault: true, order: 4 }
]);
setAttributeSubTabs([
{ 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 }
]);
setAttributeSubTabs([]);
console.log('🗑️ 모든 품목기준관리 데이터가 초기화되었습니다');
toast.success('✅ 모든 데이터가 초기화되었습니다!\n계층구조, 섹션, 항목, 속성이 모두 삭제되었습니다.');
@@ -1831,14 +1886,14 @@ export function ItemMasterDataManagement() {
);
})}
</TabsList>
<Button
size="sm"
variant="outline"
onClick={() => setIsManageTabsDialogOpen(true)}
>
<Settings className="h-4 w-4 mr-1" />
</Button>
{/*<Button*/}
{/* size="sm"*/}
{/* variant="outline"*/}
{/* onClick={() => setIsManageTabsDialogOpen(true)}*/}
{/*>*/}
{/* <Settings className="h-4 w-4 mr-1" />*/}
{/* 탭 관리*/}
{/*</Button>*/}
{/* 전체 초기화 버튼 숨김 처리 - 디자인에 없는 기능 */}
{/* <Button
size="sm"
@@ -1880,7 +1935,7 @@ export function ItemMasterDataManagement() {
className="shrink-0"
>
<Settings className="w-4 h-4 mr-1" />
</Button>
</div>
@@ -2622,6 +2677,12 @@ export function ItemMasterDataManagement() {
newSectionDescription={newSectionDescription}
setNewSectionDescription={setNewSectionDescription}
handleAddSection={handleAddSection}
sectionInputMode={sectionInputMode}
setSectionInputMode={setSectionInputMode}
sectionTemplates={sectionTemplates}
selectedTemplateId={selectedSectionTemplateId}
setSelectedTemplateId={setSelectedSectionTemplateId}
handleLinkTemplate={handleLinkTemplate}
/>
{/* 항목 추가/수정 다이얼로그 - 데스크톱 */}
@@ -2809,6 +2870,14 @@ export function ItemMasterDataManagement() {
templateFieldColumnNames={templateFieldColumnNames}
setTemplateFieldColumnNames={setTemplateFieldColumnNames}
handleAddTemplateField={handleAddTemplateField}
// 마스터 항목 관련 props
itemMasterFields={itemMasterFields}
templateFieldInputMode={templateFieldInputMode}
setTemplateFieldInputMode={setTemplateFieldInputMode}
showMasterFieldList={templateFieldShowMasterFieldList}
setShowMasterFieldList={setTemplateFieldShowMasterFieldList}
selectedMasterFieldId={templateFieldSelectedMasterFieldId}
setSelectedMasterFieldId={setTemplateFieldSelectedMasterFieldId}
/>
<LoadTemplateDialog

View File

@@ -30,7 +30,7 @@ interface ConditionalDisplayUIProps {
newFieldInputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
selectedPage: ItemPage | null;
selectedSectionForField: ItemSection | null;
editingFieldId: string | null;
editingFieldId: number | null;
// Constants
INPUT_TYPE_OPTIONS: Array<{value: string; label: string}>;
@@ -92,8 +92,10 @@ export function ConditionalDisplayUI({
};
// selectedSectionForField는 이미 ItemSection 객체이므로 바로 사용
// 신규 ItemField 타입: id는 number
const availableFields = selectedSectionForField?.fields?.filter(f => f.id !== editingFieldId) || [];
const availableSections = selectedPage?.sections.filter(s => s.type !== 'bom') || [];
// 신규 ItemSection 타입: section_type은 'BASIC' | 'BOM' | 'CUSTOM'
const availableSections = selectedPage?.sections.filter(s => s.section_type !== 'BOM') || [];
return (
<div className="border-t pt-4 space-y-3">
@@ -175,32 +177,35 @@ export function ConditionalDisplayUI({
({condition.targetFieldIds?.length || 0} ):
</Label>
<div className="space-y-1 max-h-40 overflow-y-auto bg-white rounded p-2">
{availableFields.map(field => (
<label key={field.id} className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded cursor-pointer">
<input
type="checkbox"
checked={condition.targetFieldIds?.includes(field.id) || false}
onChange={(e) => {
const newFields = [...newFieldConditionFields];
if (e.target.checked) {
newFields[conditionIndex].targetFieldIds = [
...(newFields[conditionIndex].targetFieldIds || []),
field.id
];
} else {
newFields[conditionIndex].targetFieldIds =
(newFields[conditionIndex].targetFieldIds || []).filter(id => id !== field.id);
}
setNewFieldConditionFields(newFields);
}}
className="cursor-pointer"
/>
<span className="text-xs flex-1">{field.name}</span>
<Badge variant="outline" className="text-xs">
{INPUT_TYPE_OPTIONS.find(o => o.value === field.property.inputType)?.label}
</Badge>
</label>
))}
{availableFields.map(field => {
const fieldIdStr = String(field.id);
return (
<label key={field.id} className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded cursor-pointer">
<input
type="checkbox"
checked={condition.targetFieldIds?.includes(fieldIdStr) || false}
onChange={(e) => {
const newFields = [...newFieldConditionFields];
if (e.target.checked) {
newFields[conditionIndex].targetFieldIds = [
...(newFields[conditionIndex].targetFieldIds || []),
fieldIdStr
];
} else {
newFields[conditionIndex].targetFieldIds =
(newFields[conditionIndex].targetFieldIds || []).filter(id => id !== fieldIdStr);
}
setNewFieldConditionFields(newFields);
}}
className="cursor-pointer"
/>
<span className="text-xs flex-1">{field.field_name}</span>
<Badge variant="outline" className="text-xs">
{INPUT_TYPE_OPTIONS.find(o => o.value === field.field_type)?.label || field.field_type}
</Badge>
</label>
);
})}
</div>
</div>
) : (
@@ -278,29 +283,32 @@ export function ConditionalDisplayUI({
({condition.targetSectionIds?.length || 0} ):
</Label>
<div className="space-y-1 max-h-40 overflow-y-auto bg-white rounded p-2">
{availableSections.map(section => (
<label key={section.id} className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded cursor-pointer">
<input
type="checkbox"
checked={condition.targetSectionIds?.includes(section.id) || false}
onChange={(e) => {
const newFields = [...newFieldConditionFields];
if (e.target.checked) {
newFields[conditionIndex].targetSectionIds = [
...(newFields[conditionIndex].targetSectionIds || []),
section.id
];
} else {
newFields[conditionIndex].targetSectionIds =
(newFields[conditionIndex].targetSectionIds || []).filter(id => id !== section.id);
}
setNewFieldConditionFields(newFields);
}}
className="cursor-pointer"
/>
<span className="text-xs flex-1">{section.title}</span>
</label>
))}
{availableSections.map(section => {
const sectionIdStr = String(section.id);
return (
<label key={section.id} className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded cursor-pointer">
<input
type="checkbox"
checked={condition.targetSectionIds?.includes(sectionIdStr) || false}
onChange={(e) => {
const newFields = [...newFieldConditionFields];
if (e.target.checked) {
newFields[conditionIndex].targetSectionIds = [
...(newFields[conditionIndex].targetSectionIds || []),
sectionIdStr
];
} else {
newFields[conditionIndex].targetSectionIds =
(newFields[conditionIndex].targetSectionIds || []).filter(id => id !== sectionIdStr);
}
setNewFieldConditionFields(newFields);
}}
className="cursor-pointer"
/>
<span className="text-xs flex-1">{section.section_name}</span>
</label>
);
})}
</div>
</div>
</div>

View File

@@ -71,29 +71,29 @@ export function DraggableField({ field, index, moveField, onDelete, onEdit }: Dr
<div className="flex-1">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-gray-400" />
<span className="text-sm">{field.name}</span>
<span className="text-sm">{field.field_name}</span>
<Badge variant="outline" className="text-xs">
{INPUT_TYPE_OPTIONS.find(t => t.value === field.property.inputType)?.label}
{INPUT_TYPE_OPTIONS.find(t => t.value === field.field_type)?.label || field.field_type}
</Badge>
{field.property.required && (
{field.is_required && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
{field.displayCondition && (
{field.display_condition && (
<Badge variant="secondary" className="text-xs"></Badge>
)}
{field.order !== undefined && (
<Badge variant="outline" className="text-xs">: {field.order + 1}</Badge>
{field.order_no !== undefined && (
<Badge variant="outline" className="text-xs">: {field.order_no + 1}</Badge>
)}
</div>
<div className="ml-6 text-xs text-gray-500 mt-1">
: {field.fieldKey}
{field.displayCondition && (
ID: {field.id}
{field.display_condition && (
<span className="ml-2">
(: {field.displayCondition.fieldKey} = {field.displayCondition.expectedValue})
( )
</span>
)}
{field.description && (
<span className="ml-2"> {field.description}</span>
{field.placeholder && (
<span className="ml-2"> {field.placeholder}</span>
)}
</div>
</div>

View File

@@ -16,11 +16,11 @@ interface DraggableSectionProps {
index: number;
moveSection: (dragIndex: number, hoverIndex: number) => void;
onDelete: () => void;
onEditTitle: (id: string, title: string) => void;
editingSectionId: string | null;
onEditTitle: (id: number, title: string) => void;
editingSectionId: number | null;
editingSectionTitle: string;
setEditingSectionTitle: (title: string) => void;
setEditingSectionId: (id: string | null) => void;
setEditingSectionId: (id: number | null) => void;
handleSaveSectionTitle: () => void;
children: React.ReactNode;
}
@@ -106,9 +106,9 @@ export function DraggableSection({
) : (
<div
className="flex items-center gap-2 flex-1 cursor-pointer group min-w-0"
onClick={() => onEditTitle(section.id, section.title)}
onClick={() => onEditTitle(section.id, section.section_name)}
>
<span className="text-blue-900 truncate text-sm sm:text-base">{section.title}</span>
<span className="text-blue-900 truncate text-sm sm:text-base">{section.section_name}</span>
<Edit className="h-3 w-3 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
</div>
)}
@@ -118,8 +118,9 @@ export function DraggableSection({
size="sm"
variant="ghost"
onClick={onDelete}
title="페이지에서 연결 해제"
>
<Trash2 className="h-4 w-4 text-red-500" />
<X className="h-4 w-4 text-gray-500" />
</Button>
</div>
</div>

View File

@@ -13,6 +13,9 @@ import { toast } from 'sonner';
import type { ItemPage, ItemSection, ItemMasterField } from '@/contexts/ItemMasterContext';
import { ConditionalDisplayUI, type ConditionalFieldConfig } from '../components/ConditionalDisplayUI';
// 입력 타입 정의
export type InputType = 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
// 텍스트박스 칼럼 타입 (단순 구조)
interface OptionColumn {
id: string;
@@ -20,7 +23,7 @@ interface OptionColumn {
key: string;
}
const INPUT_TYPE_OPTIONS = [
const INPUT_TYPE_OPTIONS: Array<{ value: InputType; label: string }> = [
{ value: 'textbox', label: '텍스트박스' },
{ value: 'dropdown', label: '드롭다운' },
{ value: 'checkbox', label: '체크박스' },
@@ -56,8 +59,8 @@ interface FieldDialogProps {
setNewFieldName: (name: string) => void;
newFieldKey: string;
setNewFieldKey: (key: string) => void;
newFieldInputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea' | 'section';
setNewFieldInputType: (type: any) => void;
newFieldInputType: InputType;
setNewFieldInputType: (type: InputType) => void;
newFieldRequired: boolean;
setNewFieldRequired: (required: boolean) => void;
newFieldDescription: string;
@@ -198,21 +201,22 @@ export function FieldDialog({
<div
key={field.id}
className={`p-3 border rounded cursor-pointer transition-colors ${
selectedMasterFieldId === field.id
selectedMasterFieldId === String(field.id)
? 'bg-blue-50 border-blue-300'
: 'hover:bg-gray-50'
}`}
onClick={() => {
setSelectedMasterFieldId(field.id);
setNewFieldName(field.name);
setNewFieldKey(field.fieldKey);
setNewFieldInputType(field.property.inputType);
setNewFieldRequired(field.property.required);
setSelectedMasterFieldId(String(field.id));
setNewFieldName(field.field_name);
setNewFieldKey(field.id.toString());
setNewFieldInputType(field.field_type);
setNewFieldRequired(field.properties?.required || false);
setNewFieldDescription(field.description || '');
setNewFieldOptions(field.property.options?.join(', ') || '');
if (field.property.multiColumn && field.property.columnNames) {
// options는 {label, value}[] 배열이므로 label만 추출
setNewFieldOptions(field.options?.map(opt => opt.label).join(', ') || '');
if (field.properties?.multiColumn && field.properties?.columnNames) {
setTextboxColumns(
field.property.columnNames.map((name, idx) => ({
field.properties.columnNames.map((name: string, idx: number) => ({
id: `col-${idx}`,
name,
key: `column${idx + 1}`
@@ -224,28 +228,26 @@ export function FieldDialog({
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{field.name}</span>
<span className="font-medium">{field.field_name}</span>
<Badge variant="outline" className="text-xs">
{INPUT_TYPE_OPTIONS.find(o => o.value === field.property.inputType)?.label}
{INPUT_TYPE_OPTIONS.find(o => o.value === field.field_type)?.label || field.field_type}
</Badge>
{field.property.required && (
{field.properties?.required && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
</div>
{field.description && (
<p className="text-xs text-muted-foreground mt-1">{field.description}</p>
)}
{Array.isArray(field.category) && field.category.length > 0 && (
{field.category && (
<div className="flex gap-1 mt-1">
{field.category.map((cat, idx) => (
<Badge key={idx} variant="secondary" className="text-xs">
{cat}
</Badge>
))}
<Badge variant="secondary" className="text-xs">
{field.category}
</Badge>
</div>
)}
</div>
{selectedMasterFieldId === field.id && (
{selectedMasterFieldId === String(field.id) && (
<Check className="h-5 w-5 text-blue-600" />
)}
</div>
@@ -280,7 +282,7 @@ export function FieldDialog({
<div>
<Label> *</Label>
<Select value={newFieldInputType} onValueChange={(v: any) => setNewFieldInputType(v)}>
<Select value={newFieldInputType} onValueChange={(v) => setNewFieldInputType(v as InputType)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>

View File

@@ -7,10 +7,11 @@ import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
const ITEM_TYPE_OPTIONS = [
{ value: 'product', label: '제품' },
{ value: 'part', label: '부품' },
{ value: 'material', label: '자재' },
{ value: 'assembly', label: '조립품' },
{ value: 'FG', label: '제품 (FG)' },
{ value: 'PT', label: '부품 (PT)' },
{ value: 'SM', label: '반제품 (SM)' },
{ value: 'RM', label: '원자재 (RM)' },
{ value: 'CS', label: '소모품 (CS)' },
];
interface PageDialogProps {

View File

@@ -5,6 +5,9 @@ import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { FileText, Package, Check, X } from 'lucide-react';
import type { SectionTemplate } from '@/contexts/ItemMasterContext';
interface SectionDialogProps {
isSectionDialogOpen: boolean;
@@ -16,6 +19,13 @@ interface SectionDialogProps {
newSectionDescription: string;
setNewSectionDescription: (description: string) => void;
handleAddSection: () => void;
// 템플릿 선택 관련 props
sectionInputMode: 'custom' | 'template';
setSectionInputMode: (mode: 'custom' | 'template') => void;
sectionTemplates: SectionTemplate[];
selectedTemplateId: number | null;
setSelectedTemplateId: (id: number | null) => void;
handleLinkTemplate: (template: SectionTemplate) => void;
}
export function SectionDialog({
@@ -28,59 +38,246 @@ export function SectionDialog({
newSectionDescription,
setNewSectionDescription,
handleAddSection,
sectionInputMode,
setSectionInputMode,
sectionTemplates,
selectedTemplateId,
setSelectedTemplateId,
handleLinkTemplate,
}: SectionDialogProps) {
const handleClose = () => {
setIsSectionDialogOpen(false);
setNewSectionType('fields');
setNewSectionTitle('');
setNewSectionDescription('');
setSectionInputMode('custom');
setSelectedTemplateId(null);
};
// 템플릿 선택 시 폼에 값 채우기
const handleSelectTemplate = (template: SectionTemplate) => {
setSelectedTemplateId(template.id);
setNewSectionTitle(template.template_name);
setNewSectionDescription(template.description || '');
setNewSectionType(template.section_type === 'BOM' ? 'bom' : 'fields');
};
return (
<Dialog open={isSectionDialogOpen} onOpenChange={(open) => {
setIsSectionDialogOpen(open);
if (!open) {
setNewSectionType('fields');
setNewSectionTitle('');
setNewSectionDescription('');
}
if (!open) handleClose();
else setIsSectionDialogOpen(open);
}}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{newSectionType === 'bom' ? 'BOM 섹션' : '일반 섹션'} </DialogTitle>
<DialogContent className="max-w-[95vw] sm:max-w-[600px] max-h-[90vh] flex flex-col p-0">
<DialogHeader className="sticky top-0 bg-white z-10 px-6 py-4 border-b">
<DialogTitle> </DialogTitle>
<DialogDescription>
{newSectionType === 'bom'
? '새로운 BOM(자재명세서) 섹션을 추가합니다'
: '새로운 일반 섹션을 추가합니다'}
릿 .
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label> *</Label>
<Input
value={newSectionTitle}
onChange={(e) => setNewSectionTitle(e.target.value)}
placeholder={newSectionType === 'bom' ? '예: BOM 구성' : '예: 기본 정보'}
/>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* 입력 모드 선택 */}
<div className="flex gap-2 p-1 bg-gray-100 rounded">
<Button
variant={sectionInputMode === 'custom' ? 'default' : 'ghost'}
size="sm"
onClick={() => {
setSectionInputMode('custom');
setSelectedTemplateId(null);
}}
className="flex-1"
>
</Button>
<Button
variant={sectionInputMode === 'template' ? 'default' : 'ghost'}
size="sm"
onClick={() => setSectionInputMode('template')}
className="flex-1"
>
릿
</Button>
</div>
<div>
<Label> ()</Label>
<Textarea
value={newSectionDescription}
onChange={(e) => setNewSectionDescription(e.target.value)}
placeholder="섹션에 대한 설명"
/>
</div>
{newSectionType === 'bom' && (
<div className="bg-blue-50 p-3 rounded-md">
<p className="text-sm text-blue-700">
<strong>BOM :</strong> (BOM) .
, , .
</p>
{/* 템플릿 목록 */}
{sectionInputMode === 'template' && (
<div className="border rounded p-3 space-y-2 max-h-[300px] overflow-y-auto">
<div className="flex items-center justify-between mb-2">
<Label> 릿 </Label>
</div>
{sectionTemplates.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
릿 .<br/>
릿 .
</p>
) : (
<div className="space-y-2">
{sectionTemplates.map(template => (
<div
key={template.id}
className={`p-3 border rounded cursor-pointer transition-colors ${
selectedTemplateId === template.id
? 'bg-blue-50 border-blue-300'
: 'hover:bg-gray-50'
}`}
onClick={() => handleSelectTemplate(template)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
{template.section_type === 'BOM' ? (
<Package className="h-4 w-4 text-orange-500" />
) : (
<FileText className="h-4 w-4 text-blue-500" />
)}
<span className="font-medium">{template.template_name}</span>
<Badge variant="outline" className="text-xs">
{template.section_type === 'BOM' ? '모듈(BOM)' : '일반'}
</Badge>
</div>
{template.description && (
<p className="text-xs text-muted-foreground mt-1">{template.description}</p>
)}
{template.fields && template.fields.length > 0 && (
<p className="text-xs text-blue-600 mt-1">
{template.fields.length}
</p>
)}
</div>
{selectedTemplateId === template.id && (
<Check className="h-5 w-5 text-blue-600" />
)}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* 직접 입력 폼 또는 선택된 템플릿 정보 표시 */}
{(sectionInputMode === 'custom' || selectedTemplateId) && (
<>
{/* 섹션 유형 선택 - 템플릿 선택 시 비활성화 */}
<div>
<Label className="mb-3 block"> *</Label>
<div className="grid grid-cols-2 gap-3">
{/* 일반 섹션 */}
<div
onClick={() => {
if (sectionInputMode === 'custom') setNewSectionType('fields');
}}
className={`flex items-center gap-3 p-4 border rounded-lg transition-all ${
sectionInputMode === 'template' ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'
} ${
newSectionType === 'fields'
? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<FileText className={`h-5 w-5 ${newSectionType === 'fields' ? 'text-blue-600' : 'text-gray-400'}`} />
<div className="flex-1">
<div className={`font-medium ${newSectionType === 'fields' ? 'text-blue-900' : 'text-gray-900'}`}>
</div>
<div className="text-xs text-gray-500"> </div>
</div>
{newSectionType === 'fields' && (
<Check className="h-5 w-5 text-blue-600" />
)}
</div>
{/* BOM 섹션 */}
<div
onClick={() => {
if (sectionInputMode === 'custom') setNewSectionType('bom');
}}
className={`flex items-center gap-3 p-4 border rounded-lg transition-all ${
sectionInputMode === 'template' ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'
} ${
newSectionType === 'bom'
? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<Package className={`h-5 w-5 ${newSectionType === 'bom' ? 'text-blue-600' : 'text-gray-400'}`} />
<div className="flex-1">
<div className={`font-medium ${newSectionType === 'bom' ? 'text-blue-900' : 'text-gray-900'}`}>
(BOM)
</div>
<div className="text-xs text-gray-500"> </div>
</div>
{newSectionType === 'bom' && (
<Check className="h-5 w-5 text-blue-600" />
)}
</div>
</div>
</div>
<div>
<Label> *</Label>
<Input
value={newSectionTitle}
onChange={(e) => setNewSectionTitle(e.target.value)}
placeholder={newSectionType === 'bom' ? '예: BOM 구성' : '예: 기본 정보'}
disabled={sectionInputMode === 'template'}
/>
</div>
<div>
<Label> ()</Label>
<Textarea
value={newSectionDescription}
onChange={(e) => setNewSectionDescription(e.target.value)}
placeholder="섹션에 대한 설명"
disabled={sectionInputMode === 'template'}
/>
</div>
{sectionInputMode === 'template' && selectedTemplateId && (
<div className="bg-green-50 p-3 rounded-md border border-green-200">
<p className="text-sm text-green-700">
<strong>릿 :</strong> 릿 .
릿 .
</p>
</div>
)}
{newSectionType === 'bom' && sectionInputMode === 'custom' && (
<div className="bg-blue-50 p-3 rounded-md">
<p className="text-sm text-blue-700">
<strong>BOM :</strong> (BOM) .
, , .
</p>
</div>
)}
</>
)}
</div>
<DialogFooter className="flex-col sm:flex-row gap-2">
<Button variant="outline" onClick={() => {
setIsSectionDialogOpen(false);
setNewSectionType('fields');
}} className="w-full sm:w-auto"></Button>
<Button onClick={handleAddSection} className="w-full sm:w-auto"></Button>
<DialogFooter className="shrink-0 bg-white z-10 px-6 py-4 border-t flex-col sm:flex-row gap-2">
<Button variant="outline" onClick={handleClose} className="w-full sm:w-auto">
</Button>
{sectionInputMode === 'template' && selectedTemplateId ? (
<Button
onClick={() => {
const template = sectionTemplates.find(t => t.id === selectedTemplateId);
if (template) handleLinkTemplate(template);
}}
className="w-full sm:w-auto"
>
릿
</Button>
) : (
<Button
onClick={handleAddSection}
className="w-full sm:w-auto"
disabled={sectionInputMode === 'template' && !selectedTemplateId}
>
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
}

View File

@@ -7,6 +7,9 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { Check, X } from 'lucide-react';
import type { ItemMasterField } from '@/contexts/ItemMasterContext';
const INPUT_TYPE_OPTIONS = [
{ value: 'textbox', label: '텍스트 입력' },
@@ -41,6 +44,14 @@ interface TemplateFieldDialogProps {
templateFieldColumnNames: string[];
setTemplateFieldColumnNames: (names: string[]) => void;
handleAddTemplateField: () => void;
// 마스터 항목 관련 props
itemMasterFields?: ItemMasterField[];
templateFieldInputMode?: 'custom' | 'master';
setTemplateFieldInputMode?: (mode: 'custom' | 'master') => void;
showMasterFieldList?: boolean;
setShowMasterFieldList?: (show: boolean) => void;
selectedMasterFieldId?: string;
setSelectedMasterFieldId?: (id: string) => void;
}
export function TemplateFieldDialog({
@@ -67,31 +78,151 @@ export function TemplateFieldDialog({
templateFieldColumnNames,
setTemplateFieldColumnNames,
handleAddTemplateField,
// 마스터 항목 관련 props (optional)
itemMasterFields = [],
templateFieldInputMode = 'custom',
setTemplateFieldInputMode,
showMasterFieldList = false,
setShowMasterFieldList,
selectedMasterFieldId = '',
setSelectedMasterFieldId,
}: TemplateFieldDialogProps) {
const handleClose = () => {
setIsTemplateFieldDialogOpen(false);
setEditingTemplateFieldId(null);
setTemplateFieldName('');
setTemplateFieldKey('');
setTemplateFieldInputType('textbox');
setTemplateFieldRequired(false);
setTemplateFieldOptions('');
setTemplateFieldDescription('');
setTemplateFieldMultiColumn(false);
setTemplateFieldColumnCount(2);
setTemplateFieldColumnNames(['컬럼1', '컬럼2']);
// 마스터 항목 관련 상태 초기화
setTemplateFieldInputMode?.('custom');
setShowMasterFieldList?.(false);
setSelectedMasterFieldId?.('');
};
const handleSelectMasterField = (field: ItemMasterField) => {
setSelectedMasterFieldId?.(String(field.id));
setTemplateFieldName(field.field_name);
setTemplateFieldKey(field.id.toString());
setTemplateFieldInputType(field.field_type);
setTemplateFieldRequired(field.properties?.required || false);
setTemplateFieldDescription(field.description || '');
// options는 {label, value}[] 배열이므로 label만 추출
setTemplateFieldOptions(field.options?.map(opt => opt.label).join(', ') || '');
if (field.properties?.multiColumn && field.properties?.columnNames) {
setTemplateFieldMultiColumn(true);
setTemplateFieldColumnCount(field.properties.columnNames.length);
setTemplateFieldColumnNames(field.properties.columnNames);
} else {
setTemplateFieldMultiColumn(false);
}
};
return (
<Dialog open={isTemplateFieldDialogOpen} onOpenChange={(open) => {
setIsTemplateFieldDialogOpen(open);
if (!open) {
setEditingTemplateFieldId(null);
setTemplateFieldName('');
setTemplateFieldKey('');
setTemplateFieldInputType('textbox');
setTemplateFieldRequired(false);
setTemplateFieldOptions('');
setTemplateFieldDescription('');
setTemplateFieldMultiColumn(false);
setTemplateFieldColumnCount(2);
setTemplateFieldColumnNames(['컬럼1', '컬럼2']);
}
}}>
<Dialog open={isTemplateFieldDialogOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingTemplateFieldId ? '항목 수정' : '항목 추가'}</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 입력 모드 선택 (편집 시에는 표시 안 함) */}
{!editingTemplateFieldId && setTemplateFieldInputMode && (
<div className="flex gap-2 p-1 bg-gray-100 rounded">
<Button
variant={templateFieldInputMode === 'custom' ? 'default' : 'ghost'}
size="sm"
onClick={() => setTemplateFieldInputMode('custom')}
className="flex-1"
>
</Button>
<Button
variant={templateFieldInputMode === 'master' ? 'default' : 'ghost'}
size="sm"
onClick={() => {
setTemplateFieldInputMode('master');
setShowMasterFieldList?.(true);
}}
className="flex-1"
>
</Button>
</div>
)}
{/* 마스터 항목 목록 */}
{templateFieldInputMode === 'master' && !editingTemplateFieldId && showMasterFieldList && (
<div className="border rounded p-3 space-y-2 max-h-[400px] overflow-y-auto">
<div className="flex items-center justify-between mb-2">
<Label> </Label>
<Button
variant="ghost"
size="sm"
onClick={() => setShowMasterFieldList?.(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
{itemMasterFields.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
</p>
) : (
<div className="space-y-2">
{itemMasterFields.map(field => (
<div
key={field.id}
className={`p-3 border rounded cursor-pointer transition-colors ${
selectedMasterFieldId === String(field.id)
? 'bg-blue-50 border-blue-300'
: 'hover:bg-gray-50'
}`}
onClick={() => handleSelectMasterField(field)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{field.field_name}</span>
<Badge variant="outline" className="text-xs">
{INPUT_TYPE_OPTIONS.find(o => o.value === field.field_type)?.label || field.field_type}
</Badge>
{field.properties?.required && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
</div>
{field.description && (
<p className="text-xs text-muted-foreground mt-1">{field.description}</p>
)}
{field.category && (
<div className="flex gap-1 mt-1">
<Badge variant="secondary" className="text-xs">
{field.category}
</Badge>
</div>
)}
</div>
{selectedMasterFieldId === String(field.id) && (
<Check className="h-5 w-5 text-blue-600" />
)}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* 직접 입력 폼 */}
{(templateFieldInputMode === 'custom' || editingTemplateFieldId || !setTemplateFieldInputMode) && (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label> *</Label>
@@ -199,6 +330,8 @@ export function TemplateFieldDialog({
<Switch checked={templateFieldRequired} onCheckedChange={setTemplateFieldRequired} />
<Label> </Label>
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsTemplateFieldDialogOpen(false)}></Button>

View File

@@ -304,6 +304,7 @@ export function HierarchyTab({
<Button
size="sm"
onClick={() => {
// 다이얼로그에서 타입 선택하도록 기본값만 설정
setNewSectionType('fields');
setIsSectionDialogOpen(true);
}}
@@ -332,9 +333,9 @@ export function HierarchyTab({
moveSection(dragIndex, hoverIndex);
}}
onDelete={() => {
if (confirm('이 섹션과 모든 항목을 삭제하시겠습니까?')) {
if (confirm('이 섹션을 페이지에서 연결 해제하시겠습니까?\n(섹션 데이터는 섹션 탭에 유지됩니다)')) {
deleteSection(selectedPage.id, section.id);
toast.success('섹션이 제되었습니다');
toast.success('섹션 연결제되었습니다');
}
}}
onEditTitle={handleEditSectionTitle}

View File

@@ -78,16 +78,18 @@ export function MasterFieldTab({
<div className="flex items-center gap-2">
<span>{field.field_name}</span>
<Badge variant="outline" className="text-xs">
{INPUT_TYPE_OPTIONS.find(t => t.value === field.properties?.inputType)?.label}
{INPUT_TYPE_OPTIONS.find(t => t.value === field.field_type)?.label}
</Badge>
{field.properties?.required && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
<Badge variant="secondary" className="text-xs">{field.category}</Badge>
{(field.properties as any)?.attributeType && (field.properties as any).attributeType !== 'custom' && (
{field.category && (
<Badge variant="secondary" className="text-xs">{field.category}</Badge>
)}
{field.properties?.attributeType && field.properties.attributeType !== 'custom' && (
<Badge variant="default" className="text-xs bg-blue-500">
{(field.properties as any).attributeType === 'unit' ? '단위 연동' :
(field.properties as any).attributeType === 'material' ? '재질 연동' : '표면처리 연동'}
{field.properties.attributeType === 'unit' ? '단위 연동' :
field.properties.attributeType === 'material' ? '재질 연동' : '표면처리 연동'}
</Badge>
)}
</div>
@@ -97,10 +99,10 @@ export function MasterFieldTab({
<span className="ml-2"> {field.description}</span>
)}
</div>
{field.properties?.options && field.properties.options.length > 0 && (
{field.options && field.options.length > 0 && (
<div className="text-xs text-gray-500 mt-1">
: {field.properties.options.join(', ')}
{(field.properties as any)?.attributeType && (field.properties as any).attributeType !== 'custom' && (
: {field.options.map(opt => opt.label).join(', ')}
{field.properties?.attributeType && field.properties.attributeType !== 'custom' && (
<span className="ml-2 text-blue-600">
( )
</span>

View File

@@ -5,7 +5,7 @@ import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Plus, Edit, Trash2, Folder, Package, FileText, GripVertical } from 'lucide-react';
import type { SectionTemplate, BOMItem } from '@/contexts/ItemMasterContext';
import type { SectionTemplate, BOMItem, TemplateField } from '@/contexts/ItemMasterContext';
import { BOMManagementSection } from '../../BOMManagementSection';
interface SectionsTabProps {
@@ -22,11 +22,11 @@ interface SectionsTabProps {
handleDeleteSectionTemplate: (id: number) => void;
// 템플릿 필드 핸들러
handleEditTemplateField: (templateId: number, field: any) => void;
handleEditTemplateField: (templateId: number, field: TemplateField) => void;
handleDeleteTemplateField: (templateId: number, fieldId: string) => void;
// BOM 핸들러
handleAddBOMItemToTemplate: (templateId: number, item: Omit<BOMItem, 'id' | 'createdAt'>) => void;
handleAddBOMItemToTemplate: (templateId: number, item: Omit<BOMItem, 'id' | 'created_at' | 'updated_at' | 'tenant_id' | 'section_id'>) => void;
handleUpdateBOMItemInTemplate: (templateId: number, itemId: number, item: Partial<BOMItem>) => void;
handleDeleteBOMItemFromTemplate: (templateId: number, itemId: number) => void;
@@ -169,14 +169,14 @@ export function SectionsTab({
</Button>
</div>
{template.fields.length === 0 ? (
{(!template.fields || template.fields.length === 0) ? (
<div className="bg-gray-50 border-2 border-dashed border-gray-200 rounded-lg py-16">
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 rounded-lg flex items-center justify-center">
<FileText className="w-8 h-8 text-gray-400" />
</div>
<p className="text-gray-600 mb-1">
</p>
<p className="text-sm text-gray-500">
, ,