Files
sam-react-prod/src/components/items/ItemMasterDataManagement.tsx

5935 lines
268 KiB
TypeScript
Raw Normal View History

'use client';
import { useState, useEffect, useRef } from 'react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { useData } from '@/contexts/DataContext';
import type { ItemPage, ItemSection, ItemField, FieldDisplayCondition, ItemMasterField, ItemFieldProperty, SectionTemplate } from '@/contexts/DataContext';
import { BOMManagementSection, BOMItem } from '@/components/items/BOMManagementSection';
import {
Database,
Plus,
Trash2,
ChevronRight,
ChevronDown,
FolderTree,
Folder,
FileText,
Settings,
ListTree,
Save,
X,
GripVertical,
Eye,
EyeOff,
Edit,
Check,
Package,
Layers,
ChevronUp,
Copy,
Link
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { Drawer, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';
import { toast } from 'sonner';
// 품목분류 로컬스토리지 키
const ITEM_CATEGORIES_KEY = 'item-categories';
const UNIT_OPTIONS_KEY = 'unit-options';
const MATERIAL_OPTIONS_KEY = 'material-options';
const SURFACE_TREATMENT_OPTIONS_KEY = 'surface-treatment-options';
const CUSTOM_ATTRIBUTE_OPTIONS_KEY = 'custom-attribute-options';
// 품목분류 타입
interface ItemCategoryStructure {
[category1: string]: {
[category2: string]: string[];
};
}
// 옵션 칼럼 타입
interface OptionColumn {
id: string;
name: string;
key: string;
type: 'text' | 'number';
required: boolean;
}
// 옵션 타입 (확장된 입력방식 지원)
interface MasterOption {
id: string;
value: string;
label: string;
isActive: boolean;
// 입력 방식 및 속성
inputType?: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
required?: boolean;
options?: string[]; // dropdown일 경우 선택 옵션
defaultValue?: string | number | boolean;
placeholder?: string;
// 기존 칼럼 시스템 (호환성 유지)
columns?: OptionColumn[]; // 칼럼 정의
columnValues?: Record<string, string>; // 칼럼별 값
}
// 초기 데이터
const INITIAL_ITEM_CATEGORIES: ItemCategoryStructure = {
"본체부품": {
"가이드시스템": ["가이드레일"],
"케이스시스템": ["케이스 전면부", "케이스 접검구"],
},
};
const INITIAL_UNIT_OPTIONS: MasterOption[] = [
{ id: 'unit-1', value: 'EA', label: 'EA (개)', isActive: true },
{ id: 'unit-2', value: 'SET', label: 'SET (세트)', isActive: true },
];
const INITIAL_MATERIAL_OPTIONS: MasterOption[] = [
{ id: 'mat-1', value: 'EGI 1.2T', label: 'EGI 1.2T', isActive: true },
{ id: 'mat-2', value: 'SUS 1.2T', label: 'SUS 1.2T', isActive: true },
];
const INITIAL_SURFACE_TREATMENT_OPTIONS: MasterOption[] = [
{ id: 'surf-1', value: '무도장', label: '무도장', isActive: true },
{ id: 'surf-2', value: '파우더도장', label: '파우더도장', isActive: true },
];
const ITEM_TYPE_OPTIONS = [
{ value: 'FG', label: '제품 (FG)' },
{ value: 'PT', label: '부품 (PT)' },
{ value: 'SM', label: '부자재 (SM)' },
{ value: 'RM', label: '원자재 (RM)' },
{ value: 'CS', label: '소모품 (CS)' }
];
const INPUT_TYPE_OPTIONS = [
{ value: 'textbox', label: '텍스트박스' },
{ value: 'dropdown', label: '드롭다운' },
{ value: 'checkbox', label: '체크박스' },
{ value: 'number', label: '숫자' },
{ value: 'date', label: '날짜' },
{ value: 'textarea', label: '텍스트영역' }
];
// 네이티브 드래그 가능한 섹션 컴포넌트
interface DraggableSectionProps {
section: ItemSection;
index: number;
moveSection: (dragIndex: number, hoverIndex: number) => void;
onDelete: () => void;
onEditTitle: (id: string, title: string) => void;
editingSectionId: string | null;
editingSectionTitle: string;
setEditingSectionTitle: (title: string) => void;
setEditingSectionId: (id: string | null) => void;
handleSaveSectionTitle: () => void;
children: React.ReactNode;
}
function DraggableSection({
section,
index,
moveSection,
onDelete,
onEditTitle,
editingSectionId,
editingSectionTitle,
setEditingSectionTitle,
setEditingSectionId,
handleSaveSectionTitle,
children
}: DraggableSectionProps) {
const [isDragging, setIsDragging] = useState(false);
const handleDragStart = (e: React.DragEvent) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', JSON.stringify({ index, id: section.id }));
setIsDragging(true);
};
const handleDragEnd = () => {
setIsDragging(false);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
try {
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
if (data.index !== index) {
moveSection(data.index, index);
}
} catch (err) {
// Ignore
}
};
return (
<div
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDrop={handleDrop}
className={`border rounded-lg overflow-hidden transition-opacity ${
isDragging ? 'opacity-50' : 'opacity-100'
}`}
>
{/* 섹션 헤더 */}
<div className="bg-blue-50 border-b p-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
<GripVertical className="h-4 w-4 text-gray-400" style={{ cursor: 'move' }} />
<FileText className="h-4 w-4 text-blue-600" />
{editingSectionId === section.id ? (
<div className="flex items-center gap-2 flex-1">
<Input
value={editingSectionTitle}
onChange={(e) => setEditingSectionTitle(e.target.value)}
className="h-8 bg-white"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveSectionTitle();
if (e.key === 'Escape') setEditingSectionId(null);
}}
/>
<Button size="sm" onClick={handleSaveSectionTitle}>
<Check className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" onClick={() => setEditingSectionId(null)}>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<div
className="flex items-center gap-2 flex-1 cursor-pointer group min-w-0"
onClick={() => onEditTitle(section.id, section.title)}
>
<span className="text-blue-900 truncate text-sm sm:text-base">{section.title}</span>
<Edit className="h-3 w-3 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
</div>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button
size="sm"
variant="ghost"
onClick={onDelete}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
</div>
{/* 섹션 컨텐츠 */}
<div className="p-4 bg-white space-y-2">
{children}
</div>
</div>
);
}
// 네이티브 드래그 가능한 필드 컴포넌트
interface DraggableFieldProps {
field: ItemField;
index: number;
moveField: (dragIndex: number, hoverIndex: number) => void;
onDelete: () => void;
onEdit?: () => void;
}
function DraggableField({ field, index, moveField, onDelete, onEdit }: DraggableFieldProps) {
const [isDragging, setIsDragging] = useState(false);
const handleDragStart = (e: React.DragEvent) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', JSON.stringify({ index, id: field.id }));
setIsDragging(true);
};
const handleDragEnd = () => {
setIsDragging(false);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
try {
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
if (data.index !== index) {
moveField(data.index, index);
}
} catch (err) {
// Ignore
}
};
return (
<div
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDrop={handleDrop}
className={`flex items-center justify-between p-3 border rounded hover:bg-gray-50 transition-opacity ${
isDragging ? 'opacity-50' : 'opacity-100'
}`}
style={{ cursor: 'move' }}
>
<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>
<Badge variant="outline" className="text-xs">
{INPUT_TYPE_OPTIONS.find(t => t.value === field.property.inputType)?.label}
</Badge>
{field.property.required && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
{field.displayCondition && (
<Badge variant="secondary" className="text-xs"></Badge>
)}
{field.order !== undefined && (
<Badge variant="outline" className="text-xs">: {field.order + 1}</Badge>
)}
</div>
<div className="ml-6 text-xs text-gray-500 mt-1">
: {field.fieldKey}
{field.displayCondition && (
<span className="ml-2">
(: {field.displayCondition.fieldKey} = {field.displayCondition.expectedValue})
</span>
)}
{field.description && (
<span className="ml-2"> {field.description}</span>
)}
</div>
</div>
<div className="flex gap-2">
{onEdit && (
<Button
size="sm"
variant="ghost"
onClick={onEdit}
>
<Edit className="h-4 w-4 text-blue-500" />
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={onDelete}
>
<X className="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
);
}
export function ItemMasterDataManagement() {
const {
itemPages,
addItemPage,
updateItemPage,
deleteItemPage,
addSectionToPage,
updateSection,
deleteSection,
addFieldToSection,
updateField,
deleteField,
reorderFields,
itemMasterFields,
addItemMasterField,
updateItemMasterField,
deleteItemMasterField,
sectionTemplates,
addSectionTemplate,
updateSectionTemplate,
deleteSectionTemplate
} = useData();
console.log('ItemMasterDataManagement: Current sectionTemplates', sectionTemplates);
// 모든 페이지의 섹션을 하나의 배열로 평탄화
const itemSections = itemPages.flatMap(page =>
page.sections.map(section => ({
...section,
parentPageId: page.id
}))
);
// 동적 탭 관리
const [customTabs, setCustomTabs] = useState<Array<{id: string; label: string; icon: string; isDefault: boolean; order: number}>>(() => {
// SSR 호환: 서버 환경에서는 기본값 반환
if (typeof window === 'undefined') {
return [
{ id: 'hierarchy', label: '계층구조', icon: 'FolderTree', isDefault: true, order: 1 },
{ id: 'sections', label: '섹션', icon: 'Layers', isDefault: true, order: 2 },
{ id: 'items', label: '항목', icon: 'ListTree', isDefault: true, order: 3 },
{ id: 'attributes', label: '속성', icon: 'Settings', isDefault: true, order: 4 },
{ id: 'categories', label: '품목분류', icon: 'Folder', isDefault: false, order: 5 }
];
}
const saved = localStorage.getItem('mes-itemMasterTabs');
let tabs = [];
if (saved) {
try {
tabs = JSON.parse(saved);
} catch {
tabs = [];
}
}
// 기본값이 없으면 설정
if (!tabs || tabs.length === 0) {
tabs = [
{ id: 'hierarchy', label: '계층구조', icon: 'FolderTree', isDefault: true, order: 1 },
{ id: 'sections', label: '섹션', icon: 'Layers', isDefault: true, order: 2 },
{ id: 'items', label: '항목', icon: 'ListTree', isDefault: true, order: 3 },
{ id: 'attributes', label: '속성', icon: 'Settings', isDefault: true, order: 4 },
{ id: 'categories', label: '품목분류', icon: 'Folder', isDefault: false, order: 5 }
];
} else {
// 품목분류 탭이 없으면 추가
if (!tabs.find((t: any) => t.id === 'categories')) {
tabs.push({ id: 'categories', label: '품목분류', icon: 'Folder', isDefault: false, order: 5 });
}
}
// 중복 제거 (id 기준)
const uniqueTabs = tabs.filter((tab: any, index: number, self: any[]) =>
index === self.findIndex((t: any) => t.id === tab.id)
);
return uniqueTabs;
});
useEffect(() => {
// 저장 전에도 중복 제거
const uniqueTabs = customTabs.filter((tab, index, self) =>
index === self.findIndex(t => t.id === tab.id)
);
localStorage.setItem('mes-itemMasterTabs', JSON.stringify(uniqueTabs));
}, [customTabs]);
const [activeTab, setActiveTab] = useState('hierarchy');
// 속성 하위 탭 관리
const [attributeSubTabs, setAttributeSubTabs] = useState<Array<{id: string; label: string; key: string; isDefault: boolean; order: number}>>(() => {
// SSR 호환: 서버 환경에서는 기본값 반환
if (typeof window === 'undefined') {
return [
{ 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 saved = localStorage.getItem('mes-attributeSubTabs');
let tabs = [];
if (saved) {
try {
tabs = JSON.parse(saved);
} catch {
tabs = [];
}
}
// 기본값이 없으면 설정
if (!tabs || tabs.length === 0) {
tabs = [
{ 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 }
];
}
// 중복 제거 (key 기준 - 실제 데이터 의미 기준)
const uniqueTabs = tabs.filter((tab: any, index: number, self: any[]) =>
index === self.findIndex((t: any) => t.key === tab.key)
);
return uniqueTabs;
});
useEffect(() => {
// 저장 전에도 중복 제거 (key 기준 - 실제 데이터 의미 기준)
const uniqueTabs = attributeSubTabs.filter((tab, index, self) =>
index === self.findIndex(t => t.key === tab.key)
);
localStorage.setItem('mes-attributeSubTabs', JSON.stringify(uniqueTabs));
}, [attributeSubTabs]);
// 마스터 항목이 추가/수정될 때 속성 탭 자동 생성
useEffect(() => {
// 새로 추가할 탭들을 먼저 수집
const newTabs: Array<{id: string; label: string; key: string; isDefault: boolean; order: number}> = [];
const updatedTabs: Array<{id: string; label: string; key: string; isDefault: boolean; order: number}> = [];
itemMasterFields.forEach(field => {
// 이미 탭이 있는지 확인
const existingTab = attributeSubTabs.find(tab => tab.key === field.fieldKey);
if (!existingTab) {
// 새로운 탭 추가 대상
const maxOrder = Math.max(...attributeSubTabs.map(t => t.order), ...newTabs.map(t => t.order), -1);
const newTab = {
id: `attr-${field.fieldKey}`,
label: field.name,
key: field.fieldKey,
isDefault: false,
order: maxOrder + 1
};
newTabs.push(newTab);
} else if (existingTab.label !== field.name) {
// 이름이 변경된 경우
updatedTabs.push({ ...existingTab, label: field.name });
}
});
// 상태 업데이트는 한 번만 수행
if (newTabs.length > 0 || updatedTabs.length > 0) {
setAttributeSubTabs(prev => {
// 기존 탭 업데이트
let result = prev.map(tab => {
const updated = updatedTabs.find(ut => ut.key === tab.key);
return updated || tab;
});
// 새 탭 추가
result = [...result, ...newTabs];
// 중복 제거 (key 기준)
const uniqueTabs = result.filter((tab, index, self) =>
index === self.findIndex(t => t.key === tab.key)
);
return uniqueTabs;
});
}
}, [itemMasterFields]);
// 컴포넌트 마운트 시 localStorage 중복 데이터 정리 (한 번만 실행)
useEffect(() => {
const cleanupLocalStorage = () => {
// mes-attributeSubTabs 정리
const savedAttrTabs = localStorage.getItem('mes-attributeSubTabs');
if (savedAttrTabs) {
try {
const tabs = JSON.parse(savedAttrTabs);
const uniqueTabs = tabs.filter((tab: any, index: number, self: any[]) =>
index === self.findIndex((t: any) => t.key === tab.key)
);
if (uniqueTabs.length !== tabs.length) {
console.log('🧹 localStorage 중복 제거:', tabs.length - uniqueTabs.length, '개 항목 제거됨');
localStorage.setItem('mes-attributeSubTabs', JSON.stringify(uniqueTabs));
// 상태도 업데이트
setAttributeSubTabs(uniqueTabs);
}
} catch (error) {
console.error('localStorage 정리 중 에러:', error);
}
}
// mes-itemMasterTabs 정리
const savedMainTabs = localStorage.getItem('mes-itemMasterTabs');
if (savedMainTabs) {
try {
const tabs = JSON.parse(savedMainTabs);
const uniqueTabs = tabs.filter((tab: any, index: number, self: any[]) =>
index === self.findIndex((t: any) => t.id === tab.id)
);
if (uniqueTabs.length !== tabs.length) {
console.log('🧹 메인 탭 중복 제거:', tabs.length - uniqueTabs.length, '개 항목 제거됨');
localStorage.setItem('mes-itemMasterTabs', JSON.stringify(uniqueTabs));
setCustomTabs(uniqueTabs);
}
} catch (error) {
console.error('메인 탭 정리 중 에러:', error);
}
}
};
cleanupLocalStorage();
}, []); // 빈 배열: 컴포넌트 마운트 시 한 번만 실행
const [activeAttributeTab, setActiveAttributeTab] = useState('units');
const [isAddTabDialogOpen, setIsAddTabDialogOpen] = useState(false);
const [isManageTabsDialogOpen, setIsManageTabsDialogOpen] = useState(false);
const [newTabLabel, setNewTabLabel] = useState('');
const [editingTabId, setEditingTabId] = useState<string | null>(null);
const [deletingTabId, setDeletingTabId] = useState<string | null>(null);
const [isDeleteTabDialogOpen, setIsDeleteTabDialogOpen] = useState(false);
// 속성 하위 탭 관리 상태
const [isManageAttributeTabsDialogOpen, setIsManageAttributeTabsDialogOpen] = useState(false);
const [isAddAttributeTabDialogOpen, setIsAddAttributeTabDialogOpen] = useState(false);
const [newAttributeTabLabel, setNewAttributeTabLabel] = useState('');
const [editingAttributeTabId, setEditingAttributeTabId] = useState<string | null>(null);
const [deletingAttributeTabId, setDeletingAttributeTabId] = useState<string | null>(null);
const [isDeleteAttributeTabDialogOpen, setIsDeleteAttributeTabDialogOpen] = useState(false);
// 품목분류 상태
const [itemCategories, setItemCategories] = useState<ItemCategoryStructure>(() => {
if (typeof window === 'undefined') return INITIAL_ITEM_CATEGORIES;
const saved = localStorage.getItem(ITEM_CATEGORIES_KEY);
return saved ? JSON.parse(saved) : INITIAL_ITEM_CATEGORIES;
});
const [unitOptions, setUnitOptions] = useState<MasterOption[]>(() => {
if (typeof window === 'undefined') return INITIAL_UNIT_OPTIONS;
const saved = localStorage.getItem(UNIT_OPTIONS_KEY);
return saved ? JSON.parse(saved) : INITIAL_UNIT_OPTIONS;
});
const [materialOptions, setMaterialOptions] = useState<MasterOption[]>(() => {
if (typeof window === 'undefined') return INITIAL_MATERIAL_OPTIONS;
const saved = localStorage.getItem(MATERIAL_OPTIONS_KEY);
return saved ? JSON.parse(saved) : INITIAL_MATERIAL_OPTIONS;
});
const [surfaceTreatmentOptions, setSurfaceTreatmentOptions] = useState<MasterOption[]>(() => {
if (typeof window === 'undefined') return INITIAL_SURFACE_TREATMENT_OPTIONS;
const saved = localStorage.getItem(SURFACE_TREATMENT_OPTIONS_KEY);
return saved ? JSON.parse(saved) : INITIAL_SURFACE_TREATMENT_OPTIONS;
});
// 사용자 정의 속성 옵션 상태
const [customAttributeOptions, setCustomAttributeOptions] = useState<Record<string, MasterOption[]>>(() => {
if (typeof window === 'undefined') return {};
const saved = localStorage.getItem(CUSTOM_ATTRIBUTE_OPTIONS_KEY);
return saved ? JSON.parse(saved) : {};
});
const [newCategory1, setNewCategory1] = useState('');
const [newCategory2, setNewCategory2] = useState('');
const [newCategory3, setNewCategory3] = useState('');
const [selectedCategory1, setSelectedCategory1] = useState('');
const [selectedCategory2, setSelectedCategory2] = useState('');
const [isOptionDialogOpen, setIsOptionDialogOpen] = useState(false);
const [editingOptionType, setEditingOptionType] = useState<string | null>(null);
const [newOptionValue, setNewOptionValue] = useState('');
const [newOptionLabel, setNewOptionLabel] = useState('');
const [newOptionColumnValues, setNewOptionColumnValues] = useState<Record<string, string>>({});
// 확장된 입력방식 관련 상태
const [newOptionInputType, setNewOptionInputType] = useState<'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea'>('textbox');
const [newOptionRequired, setNewOptionRequired] = useState(false);
const [newOptionOptions, setNewOptionOptions] = useState(''); // dropdown 옵션 (쉼표 구분)
const [newOptionPlaceholder, setNewOptionPlaceholder] = useState('');
const [newOptionDefaultValue, setNewOptionDefaultValue] = useState('');
// 칼럼 관리 상태
const [isColumnManageDialogOpen, setIsColumnManageDialogOpen] = useState(false);
const [managingColumnType, setManagingColumnType] = useState<string | null>(null);
const [attributeColumns, setAttributeColumns] = useState<Record<string, OptionColumn[]>>(() => {
if (typeof window === 'undefined') return {};
const saved = localStorage.getItem('attribute-columns');
return saved ? JSON.parse(saved) : {};
});
// 칼럼 추가 폼 상태
const [newColumnName, setNewColumnName] = useState('');
const [newColumnKey, setNewColumnKey] = useState('');
const [newColumnType, setNewColumnType] = useState<'text' | 'number'>('text');
const [newColumnRequired, setNewColumnRequired] = useState(false);
useEffect(() => {
localStorage.setItem('attribute-columns', JSON.stringify(attributeColumns));
}, [attributeColumns]);
// 계층구조 상태
const [selectedPageId, setSelectedPageId] = useState<string | null>(itemPages[0]?.id || null);
const selectedPage = itemPages.find(p => p.id === selectedPageId) || null;
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
const [editingSectionId, setEditingSectionId] = useState<string | null>(null);
const [editingSectionTitle, setEditingSectionTitle] = useState('');
// 기존 페이지들에 절대경로 자동 생성 (마이그레이션)
useEffect(() => {
let needsUpdate = false;
itemPages.forEach(page => {
if (!page.absolutePath) {
const absolutePath = generateAbsolutePath(page.itemType, page.pageName);
updateItemPage(page.id, { absolutePath });
needsUpdate = true;
}
});
if (needsUpdate) {
console.log('절대경로가 자동으로 생성되었습니다');
}
}, []); // 빈 의존성 배열로 최초 1회만 실행
const [editingPageId, setEditingPageId] = useState<string | null>(null);
const [editingPageName, setEditingPageName] = useState('');
const [isPageDialogOpen, setIsPageDialogOpen] = useState(false);
const [newPageName, setNewPageName] = useState('');
const [newPageItemType, setNewPageItemType] = useState<'FG' | 'PT' | 'SM' | 'RM' | 'CS'>('FG');
const [editingPathPageId, setEditingPathPageId] = useState<string | null>(null);
const [editingAbsolutePath, setEditingAbsolutePath] = useState('');
const [isSectionDialogOpen, setIsSectionDialogOpen] = useState(false);
const [newSectionTitle, setNewSectionTitle] = useState('');
const [newSectionDescription, setNewSectionDescription] = useState('');
const [newSectionType, setNewSectionType] = useState<'fields' | 'bom'>('fields');
// 모바일 체크
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
const [isFieldDialogOpen, setIsFieldDialogOpen] = useState(false);
const [selectedSectionForField, setSelectedSectionForField] = useState<string | null>(null);
const [editingFieldId, setEditingFieldId] = useState<string | null>(null);
const [fieldInputMode, setFieldInputMode] = useState<'master' | 'custom'>('custom'); // 마스터 항목 선택 vs 직접 입력
const [showMasterFieldList, setShowMasterFieldList] = useState(false); // 마스터 항목 목록 표시 여부
const [selectedMasterFieldId, setSelectedMasterFieldId] = useState('');
const [newFieldName, setNewFieldName] = useState('');
const [newFieldKey, setNewFieldKey] = useState('');
const [newFieldInputType, setNewFieldInputType] = useState<'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea'>('textbox');
const [newFieldRequired, setNewFieldRequired] = useState(false);
const [newFieldOptions, setNewFieldOptions] = useState('');
const [newFieldDescription, setNewFieldDescription] = useState('');
// 텍스트박스 컬럼 관리
const [textboxColumns, setTextboxColumns] = useState<Array<{id: string, name: string, key: string}>>([]);
const [isColumnDialogOpen, setIsColumnDialogOpen] = useState(false);
const [editingColumnId, setEditingColumnId] = useState<string | null>(null);
const [columnName, setColumnName] = useState('');
const [columnKey, setColumnKey] = useState('');
// 조건부 항목 상태
const [newFieldConditionEnabled, setNewFieldConditionEnabled] = useState(false);
const [newFieldConditionTargetType, setNewFieldConditionTargetType] = useState<'field' | 'section'>('field');
const [newFieldConditionFields, setNewFieldConditionFields] = useState<Array<{fieldKey: string, expectedValue: string}>>([]);
const [newFieldConditionSections, setNewFieldConditionSections] = useState<string[]>([]);
// 임시 입력용
const [tempConditionFieldKey, setTempConditionFieldKey] = useState('');
const [tempConditionValue, setTempConditionValue] = useState('');
// 마스터 항목 관리 상태
const [isMasterFieldDialogOpen, setIsMasterFieldDialogOpen] = useState(false);
const [editingMasterFieldId, setEditingMasterFieldId] = useState<string | null>(null);
const [newMasterFieldName, setNewMasterFieldName] = useState('');
const [newMasterFieldKey, setNewMasterFieldKey] = useState('');
const [newMasterFieldInputType, setNewMasterFieldInputType] = useState<'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea'>('textbox');
const [newMasterFieldRequired, setNewMasterFieldRequired] = useState(false);
const [newMasterFieldCategory, setNewMasterFieldCategory] = useState('공통');
const [newMasterFieldDescription, setNewMasterFieldDescription] = useState('');
const [newMasterFieldOptions, setNewMasterFieldOptions] = useState('');
const [newMasterFieldAttributeType, setNewMasterFieldAttributeType] = useState<'custom' | 'unit' | 'material' | 'surface'>('custom');
const [newMasterFieldMultiColumn, setNewMasterFieldMultiColumn] = useState(false);
const [newMasterFieldColumnCount, setNewMasterFieldColumnCount] = useState(2);
const [newMasterFieldColumnNames, setNewMasterFieldColumnNames] = useState<string[]>(['컬럼1', '컬럼2']);
// 섹션 템플릿 관리 상태
const [isSectionTemplateDialogOpen, setIsSectionTemplateDialogOpen] = useState(false);
const [editingSectionTemplateId, setEditingSectionTemplateId] = useState<string | null>(null);
const [newSectionTemplateTitle, setNewSectionTemplateTitle] = useState('');
const [newSectionTemplateDescription, setNewSectionTemplateDescription] = useState('');
const [newSectionTemplateCategory, setNewSectionTemplateCategory] = useState<string[]>([]);
const [newSectionTemplateType, setNewSectionTemplateType] = useState<'fields' | 'bom'>('fields');
// 섹션 템플릿 불러오기 다이얼로그
const [isLoadTemplateDialogOpen, setIsLoadTemplateDialogOpen] = useState(false);
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
// 섹션 템플릿 확장 상태
const [expandedTemplateId, setExpandedTemplateId] = useState<string | null>(null);
// 섹션 템플릿 항목 추가 다이얼로그
const [isTemplateFieldDialogOpen, setIsTemplateFieldDialogOpen] = useState(false);
const [currentTemplateId, setCurrentTemplateId] = useState<string | null>(null);
const [editingTemplateFieldId, setEditingTemplateFieldId] = useState<string | null>(null);
const [templateFieldName, setTemplateFieldName] = useState('');
const [templateFieldKey, setTemplateFieldKey] = useState('');
const [templateFieldInputType, setTemplateFieldInputType] = useState<'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea'>('textbox');
const [templateFieldRequired, setTemplateFieldRequired] = useState(false);
const [templateFieldOptions, setTemplateFieldOptions] = useState('');
const [templateFieldDescription, setTemplateFieldDescription] = useState('');
const [templateFieldMultiColumn, setTemplateFieldMultiColumn] = useState(false);
const [templateFieldColumnCount, setTemplateFieldColumnCount] = useState(2);
const [templateFieldColumnNames, setTemplateFieldColumnNames] = useState<string[]>(['컬럼1', '컬럼2']);
// 변경사항 추적 상태
const [pendingChanges, setPendingChanges] = useState<{
pages: { id: string; action: 'add' | 'update'; data: any }[];
sections: { id: string; action: 'add' | 'update'; data: any }[];
fields: { id: string; action: 'add' | 'update'; data: any }[];
masterFields: { id: string; action: 'add' | 'update'; data: any }[];
attributes: { id: string; action: 'add' | 'update'; type: string; data: any }[];
}>({
pages: [],
sections: [],
fields: [],
masterFields: [],
attributes: []
});
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
// BOM 관리 상태
const [bomItems, setBomItems] = useState<BOMItem[]>(() => {
if (typeof window === 'undefined') return [];
const saved = localStorage.getItem('bom-items');
return saved ? JSON.parse(saved) : [];
});
// BOM 데이터 저장
useEffect(() => {
localStorage.setItem('bom-items', JSON.stringify(bomItems));
}, [bomItems]);
useEffect(() => {
localStorage.setItem(ITEM_CATEGORIES_KEY, JSON.stringify(itemCategories));
}, [itemCategories]);
useEffect(() => {
localStorage.setItem(UNIT_OPTIONS_KEY, JSON.stringify(unitOptions));
}, [unitOptions]);
useEffect(() => {
localStorage.setItem(MATERIAL_OPTIONS_KEY, JSON.stringify(materialOptions));
}, [materialOptions]);
useEffect(() => {
localStorage.setItem(SURFACE_TREATMENT_OPTIONS_KEY, JSON.stringify(surfaceTreatmentOptions));
}, [surfaceTreatmentOptions]);
useEffect(() => {
localStorage.setItem(CUSTOM_ATTRIBUTE_OPTIONS_KEY, JSON.stringify(customAttributeOptions));
}, [customAttributeOptions]);
// 속성 변경 시 연동된 마스터 항목의 옵션 자동 업데이트
useEffect(() => {
itemMasterFields.forEach(field => {
const attributeType = (field.property as any).attributeType;
if (attributeType && attributeType !== 'custom' && field.property.inputType === 'dropdown') {
let newOptions: string[] = [];
if (attributeType === 'unit') {
newOptions = unitOptions.map(opt => opt.label);
} else if (attributeType === 'material') {
newOptions = materialOptions.map(opt => opt.label);
} else if (attributeType === 'surface') {
newOptions = surfaceTreatmentOptions.map(opt => opt.label);
} else {
// 사용자 정의 속성
const customOptions = customAttributeOptions[attributeType] || [];
newOptions = customOptions.map(opt => opt.label);
}
const currentOptions = field.property.options || [];
const optionsChanged = JSON.stringify(currentOptions.sort()) !== JSON.stringify(newOptions.sort());
if (optionsChanged && newOptions.length > 0) {
updateItemMasterField(field.id, {
...field,
property: {
...field.property,
options: newOptions
}
});
}
}
});
}, [unitOptions, materialOptions, surfaceTreatmentOptions, customAttributeOptions, itemMasterFields]);
const handleAddCategory1 = () => {
if (!newCategory1.trim()) return toast.error('대분류명을 입력해주세요');
if (itemCategories[newCategory1]) return toast.error('이미 존재하는 대분류입니다');
setItemCategories({ ...itemCategories, [newCategory1]: {} });
setNewCategory1('');
toast.success('대분류가 추가되었습니다');
};
const handleAddCategory2 = () => {
if (!selectedCategory1) return toast.error('대분류를 선택해주세요');
if (!newCategory2.trim()) return toast.error('중분류명을 입력해주세요');
setItemCategories({
...itemCategories,
[selectedCategory1]: { ...itemCategories[selectedCategory1], [newCategory2]: [] }
});
setNewCategory2('');
toast.success('중분류가 추가되었습니다');
};
const handleAddCategory3 = () => {
if (!selectedCategory1 || !selectedCategory2 || !newCategory3.trim())
return toast.error('모든 항목을 입력해주세요');
setItemCategories({
...itemCategories,
[selectedCategory1]: {
...itemCategories[selectedCategory1],
[selectedCategory2]: [...itemCategories[selectedCategory1][selectedCategory2], newCategory3]
}
});
setNewCategory3('');
toast.success('소분류가 추가되었습니다');
};
const handleDeleteCategory1 = (cat1: string) => {
const newCategories = { ...itemCategories };
delete newCategories[cat1];
setItemCategories(newCategories);
toast.success('삭제되었습니다');
};
const handleDeleteCategory2 = (cat1: string, cat2: string) => {
const newCategories = { ...itemCategories };
delete newCategories[cat1][cat2];
setItemCategories(newCategories);
toast.success('삭제되었습니다');
};
const handleDeleteCategory3 = (cat1: string, cat2: string, cat3: string) => {
const newCategories = { ...itemCategories };
newCategories[cat1][cat2] = newCategories[cat1][cat2].filter(c => c !== cat3);
setItemCategories(newCategories);
toast.success('삭제되었습니다');
};
const handleAddOption = () => {
if (!editingOptionType || !newOptionValue.trim() || !newOptionLabel.trim())
return toast.error('모든 항목을 입력해주세요');
// dropdown일 경우 옵션 필수 체크
if (newOptionInputType === 'dropdown' && !newOptionOptions.trim()) {
return toast.error('드롭다운 옵션을 입력해주세요');
}
// 칼럼 필수 값 체크
const currentColumns = attributeColumns[editingOptionType] || [];
for (const column of currentColumns) {
if (column.required && !newOptionColumnValues[column.key]?.trim()) {
return toast.error(`${column.name}은(는) 필수 입력 항목입니다`);
}
}
const newOption: MasterOption = {
id: `${editingOptionType}-${Date.now()}`,
value: newOptionValue,
label: newOptionLabel,
isActive: true,
inputType: newOptionInputType,
required: newOptionRequired,
options: newOptionInputType === 'dropdown' ? newOptionOptions.split(',').map(o => o.trim()).filter(o => o) : undefined,
placeholder: newOptionPlaceholder || undefined,
defaultValue: newOptionDefaultValue || undefined,
columnValues: Object.keys(newOptionColumnValues).length > 0 ? { ...newOptionColumnValues } : undefined
};
if (editingOptionType === 'unit') {
setUnitOptions([...unitOptions, newOption]);
} else if (editingOptionType === 'material') {
setMaterialOptions([...materialOptions, newOption]);
} else if (editingOptionType === 'surface') {
setSurfaceTreatmentOptions([...surfaceTreatmentOptions, newOption]);
} else {
// 사용자 정의 속성 탭
setCustomAttributeOptions(prev => ({
...prev,
[editingOptionType]: [...(prev[editingOptionType] || []), newOption]
}));
}
trackChange('attributes', newOption.id, 'add', newOption, editingOptionType);
setNewOptionValue('');
setNewOptionLabel('');
setNewOptionColumnValues({});
setNewOptionInputType('textbox');
setNewOptionRequired(false);
setNewOptionOptions('');
setNewOptionPlaceholder('');
setNewOptionDefaultValue('');
setIsOptionDialogOpen(false);
toast.success('속성이 추가되었습니다 (저장 필요)');
};
const handleDeleteOption = (type: string, id: string) => {
if (type === 'unit') {
setUnitOptions(unitOptions.filter(o => o.id !== id));
} else if (type === 'material') {
setMaterialOptions(materialOptions.filter(o => o.id !== id));
} else if (type === 'surface') {
setSurfaceTreatmentOptions(surfaceTreatmentOptions.filter(o => o.id !== id));
} else {
// 사용자 정의 속성 탭
setCustomAttributeOptions(prev => ({
...prev,
[type]: (prev[type] || []).filter(o => o.id !== id)
}));
}
toast.success('삭제되었습니다');
};
// 절대경로 자동 생성 함수
const generateAbsolutePath = (itemType: string, pageName: string): string => {
const typeMap: Record<string, string> = {
'FG': '제품관리',
'PT': '부품관리',
'SM': '부자재관리',
'RM': '원자재관리',
'CS': '소모품관리'
};
const category = typeMap[itemType] || '기타';
return `/${category}/${pageName}`;
};
// 계층구조 핸들러
const handleAddPage = () => {
if (!newPageName.trim()) return toast.error('섹션명을 입력해주세요');
const absolutePath = generateAbsolutePath(newPageItemType, newPageName);
const newPage: ItemPage = {
id: `PAGE-${Date.now()}`,
pageName: newPageName,
itemType: newPageItemType,
sections: [],
isActive: true,
absolutePath,
createdAt: new Date().toISOString().split('T')[0]
};
addItemPage(newPage);
trackChange('pages', newPage.id, 'add', newPage);
setSelectedPageId(newPage.id);
setNewPageName('');
setIsPageDialogOpen(false);
toast.success('페이지가 추가되었습니다 (저장 필요)');
};
const handleAddSection = () => {
if (!selectedPage || !newSectionTitle.trim()) return toast.error('하위섹션 제목을 입력해주세요');
const newSection: ItemSection = {
id: `SECTION-${Date.now()}`,
title: newSectionTitle,
description: newSectionDescription || undefined,
fields: [],
type: newSectionType,
bomItems: newSectionType === 'bom' ? [] : undefined,
order: selectedPage.sections.length + 1,
isCollapsible: true,
isCollapsed: false,
createdAt: new Date().toISOString().split('T')[0]
};
console.log('Adding section to page:', {
pageId: selectedPage.id,
pageName: selectedPage.pageName,
sectionTitle: newSection.title,
sectionType: newSection.type,
currentSectionCount: selectedPage.sections.length,
newSection: newSection
});
// 1. 페이지에 섹션 추가
addSectionToPage(selectedPage.id, newSection);
trackChange('sections', newSection.id, 'add', newSection);
// 2. 섹션관리 탭에도 템플릿으로 자동 추가
const newTemplate: SectionTemplate = {
id: `TEMPLATE-${Date.now()}`,
title: newSection.title,
description: newSection.description,
category: [selectedPage.itemType], // 현재 페이지의 품목유형을 카테고리로 설정
fields: [], // 초기에는 빈 필드 배열
type: newSection.type,
bomItems: newSection.type === 'bom' ? [] : undefined,
isCollapsible: true,
isCollapsed: false,
isActive: true,
createdAt: new Date().toISOString().split('T')[0]
};
addSectionTemplate(newTemplate);
trackChange('sections', newTemplate.id, 'add', newTemplate);
console.log('Section added to both page and template:', {
sectionId: newSection.id,
templateId: newTemplate.id
});
setNewSectionTitle('');
setNewSectionDescription('');
setNewSectionType('fields');
setIsSectionDialogOpen(false);
toast.success(`${newSectionType === 'bom' ? 'BOM' : '일반'} 섹션이 페이지에 추가되고 템플릿으로도 등록되었습니다!`);
};
const handleEditSectionTitle = (sectionId: string, currentTitle: string) => {
setEditingSectionId(sectionId);
setEditingSectionTitle(currentTitle);
};
const handleSaveSectionTitle = () => {
if (!selectedPage || !editingSectionId || !editingSectionTitle.trim())
return toast.error('하위섹션 제목을 입력해주세요');
updateSection(selectedPage.id, editingSectionId, { title: editingSectionTitle });
trackChange('sections', editingSectionId, 'update', { title: editingSectionTitle });
setEditingSectionId(null);
toast.success('하위섹션 제목이 수정되었습니다 (저장 필요)');
};
const handleMoveSectionUp = (sectionId: string) => {
if (!selectedPage) return;
const sections = [...selectedPage.sections];
const index = sections.findIndex(s => s.id === sectionId);
if (index <= 0) return; // 첫 번째 섹션이거나 못 찾음
// 배열에서 위치 교환
[sections[index - 1], sections[index]] = [sections[index], sections[index - 1]];
// order 값 재설정
const updatedSections = sections.map((section, idx) => ({
...section,
order: idx + 1
}));
// 페이지 업데이트
updateItemPage(selectedPage.id, { sections: updatedSections });
trackChange('pages', selectedPage.id, 'update', { sections: updatedSections });
toast.success('섹션 순서가 변경되었습니다');
};
const handleMoveSectionDown = (sectionId: string) => {
if (!selectedPage) return;
const sections = [...selectedPage.sections];
const index = sections.findIndex(s => s.id === sectionId);
if (index < 0 || index >= sections.length - 1) return; // 마지막 섹션이거나 못 찾음
// 배열에서 위치 교환
[sections[index], sections[index + 1]] = [sections[index + 1], sections[index]];
// order 값 재설정
const updatedSections = sections.map((section, idx) => ({
...section,
order: idx + 1
}));
// 페이지 업데이트
updateItemPage(selectedPage.id, { sections: updatedSections });
trackChange('pages', selectedPage.id, 'update', { sections: updatedSections });
toast.success('섹션 순서가 변경되었습니다');
};
const handleAddField = () => {
if (!selectedPage || !selectedSectionForField || !newFieldName.trim() || !newFieldKey.trim())
return toast.error('모든 필수 항목을 입력해주세요');
// 조건부 표시 설정
const displayCondition: FieldDisplayCondition | undefined = newFieldConditionEnabled
? {
targetType: newFieldConditionTargetType,
fieldConditions: newFieldConditionTargetType === 'field' && newFieldConditionFields.length > 0
? newFieldConditionFields
: undefined,
sectionIds: newFieldConditionTargetType === 'section' && newFieldConditionSections.length > 0
? newFieldConditionSections
: undefined
}
: undefined;
// 텍스트박스 컬럼 설정
const hasColumns = newFieldInputType === 'textbox' && textboxColumns.length > 0;
const newField: ItemField = {
id: editingFieldId || `FIELD-${Date.now()}`,
name: newFieldName,
fieldKey: newFieldKey,
property: {
inputType: newFieldInputType,
required: newFieldRequired,
row: 1,
col: 1,
options: newFieldInputType === 'dropdown' && newFieldOptions.trim()
? newFieldOptions.split(',').map(o => o.trim())
: undefined,
multiColumn: hasColumns,
columnCount: hasColumns ? textboxColumns.length : undefined,
columnNames: hasColumns ? textboxColumns.map(c => c.name) : undefined
},
description: newFieldDescription || undefined,
displayCondition,
createdAt: new Date().toISOString().split('T')[0]
};
if (editingFieldId) {
console.log('Updating field:', { pageId: selectedPage.id, sectionId: selectedSectionForField, fieldId: editingFieldId, fieldName: newField.name });
updateField(selectedPage.id, selectedSectionForField, editingFieldId, newField);
trackChange('fields', editingFieldId, 'update', newField);
// 항목관리 탭의 마스터 항목도 업데이트 (동일한 fieldKey가 있으면)
const existingMasterField = itemMasterFields.find(mf => mf.fieldKey === newField.fieldKey);
if (existingMasterField) {
const updatedMasterField: ItemMasterField = {
...existingMasterField,
name: newField.name,
description: newField.description,
property: newField.property,
displayCondition: newField.displayCondition,
updatedAt: new Date().toISOString().split('T')[0]
};
updateItemMasterField(existingMasterField.id, updatedMasterField);
trackChange('masterFields', existingMasterField.id, 'update', updatedMasterField);
}
toast.success('항목이 섹션에 수정되었습니다!');
} else {
console.log('Adding field to section:', { pageId: selectedPage.id, sectionId: selectedSectionForField, fieldName: newField.name, fieldKey: newField.fieldKey });
// 1. 섹션에 항목 추가
addFieldToSection(selectedPage.id, selectedSectionForField, newField);
trackChange('fields', newField.id, 'add', newField);
// 2. 항목관리 탭에도 마스터 항목으로 자동 추가 (중복 체크)
const existingMasterField = itemMasterFields.find(mf => mf.fieldKey === newField.fieldKey);
if (!existingMasterField) {
const newMasterField: ItemMasterField = {
id: `MASTER-FIELD-${Date.now()}`,
name: newField.name,
fieldKey: newField.fieldKey,
description: newField.description,
property: newField.property,
category: [selectedPage.itemType], // 현재 페이지의 품목유형을 카테고리로 설정
displayCondition: newField.displayCondition,
isActive: true,
usageCount: 1,
createdAt: new Date().toISOString().split('T')[0]
};
addItemMasterField(newMasterField);
trackChange('masterFields', newMasterField.id, 'add', newMasterField);
console.log('Field added to both section and master fields:', {
fieldId: newField.id,
masterFieldId: newMasterField.id,
fieldKey: newField.fieldKey
});
// 3. dropdown 타입이고 옵션이 있으면 속성관리 탭에도 자동 추가
if (newField.property.inputType === 'dropdown' && newField.property.options && newField.property.options.length > 0) {
const existingCustomOptions = customAttributeOptions[newField.fieldKey];
if (!existingCustomOptions || existingCustomOptions.length === 0) {
const customOptions = newField.property.options.map((option, index) => ({
id: `CUSTOM-${newField.fieldKey}-${Date.now()}-${index}`,
value: option,
label: option,
isActive: true
}));
setCustomAttributeOptions(prev => ({
...prev,
[newField.fieldKey]: customOptions
}));
// 속성관리 탭에 하위 탭으로 추가
const existingTab = attributeSubTabs.find(tab => tab.key === newField.fieldKey);
if (!existingTab) {
const maxOrder = Math.max(...attributeSubTabs.map(t => t.order), -1);
const newTab = {
id: `attr-${newField.fieldKey}`,
label: newField.name,
key: newField.fieldKey,
isDefault: false,
order: maxOrder + 1
};
setAttributeSubTabs(prev => {
// 추가 전 중복 체크 (혹시 모를 race condition 대비)
const alreadyExists = prev.find(t => t.key === newTab.key);
if (alreadyExists) return prev;
const updated = [...prev, newTab];
// 중복 제거
return updated.filter((tab, index, self) =>
index === self.findIndex(t => t.key === tab.key)
);
});
console.log('New attribute tab added:', newTab);
}
console.log('Dropdown options added to custom attributes:', {
attributeKey: newField.fieldKey,
options: customOptions
});
toast.success(`항목이 추가되고 "${newField.name}" 속성 탭이 속성관리에 등록되었습니다!`);
} else {
toast.success('항목이 섹션에 추가되고 마스터 항목으로도 등록되었습니다!');
}
} else {
toast.success('항목이 섹션에 추가되고 마스터 항목으로도 등록되었습니다!');
}
} else {
toast.success('항목이 섹션에 추가되었습니다! (이미 마스터 항목에 존재함)');
}
}
// 폼 초기화
setNewFieldName('');
setNewFieldKey('');
setNewFieldInputType('textbox');
setNewFieldRequired(false);
setNewFieldOptions('');
setNewFieldDescription('');
setNewFieldConditionEnabled(false);
setNewFieldConditionTargetType('field');
setNewFieldConditionFields([]);
setNewFieldConditionSections([]);
setTempConditionFieldKey('');
setTempConditionValue('');
setEditingFieldId(null);
setIsFieldDialogOpen(false);
};
const handleEditField = (sectionId: string, field: ItemField) => {
setSelectedSectionForField(sectionId);
setEditingFieldId(field.id);
setNewFieldName(field.name);
setNewFieldKey(field.fieldKey);
setNewFieldInputType(field.property.inputType);
setNewFieldRequired(field.property.required);
setNewFieldOptions(field.property.options?.join(', ') || '');
setNewFieldDescription(field.description || '');
// 조건부 표시 설정 로드
if (field.displayCondition) {
setNewFieldConditionEnabled(true);
setNewFieldConditionTargetType(field.displayCondition.targetType);
setNewFieldConditionFields(field.displayCondition.fieldConditions || []);
setNewFieldConditionSections(field.displayCondition.sectionIds || []);
} else {
setNewFieldConditionEnabled(false);
setNewFieldConditionTargetType('field');
setNewFieldConditionFields([]);
setNewFieldConditionSections([]);
}
setIsFieldDialogOpen(true);
};
// 마스터 필드 선택 시 폼 자동 채우기
useEffect(() => {
if (fieldInputMode === 'master' && selectedMasterFieldId) {
const masterField = itemMasterFields.find(f => f.id === selectedMasterFieldId);
if (masterField) {
setNewFieldName(masterField.name);
setNewFieldKey(masterField.fieldKey);
setNewFieldInputType(masterField.property.inputType);
setNewFieldRequired(masterField.property.required);
setNewFieldOptions(masterField.property.options?.join(', ') || '');
setNewFieldDescription(masterField.description || '');
}
} else if (fieldInputMode === 'custom') {
// 직접 입력 모드로 전환 시 폼 초기화
setNewFieldName('');
setNewFieldKey('');
setNewFieldInputType('textbox');
setNewFieldRequired(false);
setNewFieldOptions('');
setNewFieldDescription('');
}
}, [fieldInputMode, selectedMasterFieldId, itemMasterFields]);
// 마스터 항목 관리 핸들러
const handleAddMasterField = () => {
if (!newMasterFieldName.trim() || !newMasterFieldKey.trim())
return toast.error('항목명과 필드 키를 입력해주세요');
// 속성 목록 초기화 (dropdown 타입이고 옵션이 있으면 각 옵션을 속성으로 추가)
let properties: ItemFieldProperty[] = [];
if (newMasterFieldInputType === 'dropdown' && newMasterFieldOptions.trim()) {
const options = newMasterFieldOptions.split(',').map(o => o.trim());
properties = options.map((opt, idx) => ({
id: `prop-${Date.now()}-${idx}`,
key: `${newMasterFieldKey}_${opt.toLowerCase().replace(/\s+/g, '_')}`,
label: opt,
type: 'textbox',
inputType: 'textbox',
required: false,
row: idx + 1,
col: 1
}));
}
const newMasterField: ItemMasterField = {
id: `MASTER-${Date.now()}`,
name: newMasterFieldName,
fieldKey: newMasterFieldKey,
property: {
inputType: newMasterFieldInputType,
required: newMasterFieldRequired,
row: 1,
col: 1,
options: newMasterFieldInputType === 'dropdown' && newMasterFieldOptions.trim()
? newMasterFieldOptions.split(',').map(o => o.trim())
: undefined,
attributeType: newMasterFieldInputType === 'dropdown' ? newMasterFieldAttributeType : undefined,
multiColumn: (newMasterFieldInputType === 'textbox' || newMasterFieldInputType === 'textarea') ? newMasterFieldMultiColumn : undefined,
columnCount: (newMasterFieldInputType === 'textbox' || newMasterFieldInputType === 'textarea') && newMasterFieldMultiColumn ? newMasterFieldColumnCount : undefined,
columnNames: (newMasterFieldInputType === 'textbox' || newMasterFieldInputType === 'textarea') && newMasterFieldMultiColumn ? newMasterFieldColumnNames : undefined
} as any,
properties: properties.length > 0 ? properties : undefined,
category: newMasterFieldCategory,
description: newMasterFieldDescription || undefined,
isActive: true,
createdAt: new Date().toISOString().split('T')[0]
};
addItemMasterField(newMasterField);
trackChange('masterFields', newMasterField.id, 'add', newMasterField);
// dropdown 타입이고 attributeType이 'custom'이며 옵션이 있으면 속성관리 탭에도 자동 추가
if (newMasterFieldInputType === 'dropdown' && newMasterFieldAttributeType === 'custom' && newMasterFieldOptions.trim()) {
const options = newMasterFieldOptions.split(',').map(o => o.trim());
const existingCustomOptions = customAttributeOptions[newMasterFieldKey];
if (!existingCustomOptions || existingCustomOptions.length === 0) {
const customOptions = options.map((option, index) => ({
id: `CUSTOM-${newMasterFieldKey}-${Date.now()}-${index}`,
value: option,
label: option,
isActive: true
}));
setCustomAttributeOptions(prev => ({
...prev,
[newMasterFieldKey]: customOptions
}));
// 속성관리 탭에 하위 탭으로 추가
const existingTab = attributeSubTabs.find(tab => tab.key === newMasterFieldKey);
if (!existingTab) {
const maxOrder = Math.max(...attributeSubTabs.map(t => t.order), -1);
const newTab = {
id: `attr-${newMasterFieldKey}`,
label: newMasterFieldName,
key: newMasterFieldKey,
isDefault: false,
order: maxOrder + 1
};
setAttributeSubTabs(prev => {
// 추가 전 중복 체크 (혹시 모를 race condition 대비)
const alreadyExists = prev.find(t => t.key === newTab.key);
if (alreadyExists) return prev;
const updated = [...prev, newTab];
// 중복 제거
return updated.filter((tab, index, self) =>
index === self.findIndex(t => t.key === tab.key)
);
});
console.log('New attribute tab added from master field:', newTab);
}
console.log('Master field dropdown options added to custom attributes:', {
attributeKey: newMasterFieldKey,
options: customOptions
});
}
}
// 폼 초기화
setNewMasterFieldName('');
setNewMasterFieldKey('');
setNewMasterFieldInputType('textbox');
setNewMasterFieldRequired(false);
setNewMasterFieldCategory('공통');
setNewMasterFieldDescription('');
setNewMasterFieldOptions('');
setNewMasterFieldAttributeType('custom');
setNewMasterFieldMultiColumn(false);
setNewMasterFieldColumnCount(2);
setNewMasterFieldColumnNames(['컬럼1', '컬럼2']);
setIsMasterFieldDialogOpen(false);
toast.success('마스터 항목이 추가되었습니다 (속성 탭에 반영됨, 저장 필요)');
};
const handleEditMasterField = (field: ItemMasterField) => {
setEditingMasterFieldId(field.id);
setNewMasterFieldName(field.name);
setNewMasterFieldKey(field.fieldKey);
setNewMasterFieldInputType(field.property.inputType);
setNewMasterFieldRequired(field.property.required);
setNewMasterFieldCategory(field.category || '공통');
setNewMasterFieldDescription(field.description || '');
setNewMasterFieldOptions(field.property.options?.join(', ') || '');
setNewMasterFieldAttributeType((field.property as any).attributeType || 'custom');
setNewMasterFieldMultiColumn(field.property.multiColumn || false);
setNewMasterFieldColumnCount(field.property.columnCount || 2);
setNewMasterFieldColumnNames(field.property.columnNames || ['컬럼1', '컬럼2']);
setIsMasterFieldDialogOpen(true);
};
const handleUpdateMasterField = () => {
if (!editingMasterFieldId || !newMasterFieldName.trim() || !newMasterFieldKey.trim())
return toast.error('항목명과 필드 키를 입력해주세요');
// 속성 목록 업데이트 (dropdown 타입이고 옵션이 있으면 각 옵션을 속성으로 추가)
let properties: ItemFieldProperty[] = [];
if (newMasterFieldInputType === 'dropdown' && newMasterFieldOptions.trim()) {
const options = newMasterFieldOptions.split(',').map(o => o.trim());
properties = options.map((opt, idx) => ({
id: `prop-${Date.now()}-${idx}`,
key: `${newMasterFieldKey}_${opt.toLowerCase().replace(/\s+/g, '_')}`,
label: opt,
type: 'textbox',
inputType: 'textbox',
required: false,
row: idx + 1,
col: 1
}));
}
updateItemMasterField(editingMasterFieldId, {
name: newMasterFieldName,
fieldKey: newMasterFieldKey,
property: {
inputType: newMasterFieldInputType,
required: newMasterFieldRequired,
row: 1,
col: 1,
options: newMasterFieldInputType === 'dropdown' && newMasterFieldOptions.trim()
? newMasterFieldOptions.split(',').map(o => o.trim())
: undefined,
attributeType: newMasterFieldInputType === 'dropdown' ? newMasterFieldAttributeType : undefined,
multiColumn: (newMasterFieldInputType === 'textbox' || newMasterFieldInputType === 'textarea') ? newMasterFieldMultiColumn : undefined,
columnCount: (newMasterFieldInputType === 'textbox' || newMasterFieldInputType === 'textarea') && newMasterFieldMultiColumn ? newMasterFieldColumnCount : undefined,
columnNames: (newMasterFieldInputType === 'textbox' || newMasterFieldInputType === 'textarea') && newMasterFieldMultiColumn ? newMasterFieldColumnNames : undefined
} as any,
properties: properties.length > 0 ? properties : undefined,
category: newMasterFieldCategory,
description: newMasterFieldDescription || undefined
});
// 폼 초기화
setEditingMasterFieldId(null);
setNewMasterFieldName('');
setNewMasterFieldKey('');
setNewMasterFieldInputType('textbox');
setNewMasterFieldRequired(false);
setNewMasterFieldCategory('공통');
setNewMasterFieldDescription('');
setNewMasterFieldOptions('');
setNewMasterFieldAttributeType('custom');
setNewMasterFieldMultiColumn(false);
setNewMasterFieldColumnCount(2);
setNewMasterFieldColumnNames(['컬럼1', '컬럼2']);
setIsMasterFieldDialogOpen(false);
trackChange('masterFields', editingMasterFieldId, 'update', { id: editingMasterFieldId });
toast.success('마스터 항목이 수정되었습니다 (속성 탭에 반영됨, 저장 필요)');
};
const handleDeleteMasterField = (id: string) => {
if (confirm('이 마스터 항목을 삭제하시겠습니까?')) {
// 삭제할 마스터 항목 찾기
const fieldToDelete = itemMasterFields.find(f => f.id === id);
// 마스터 항목 삭제
deleteItemMasterField(id);
// 속성 탭에서 해당 탭 제거
if (fieldToDelete) {
setAttributeSubTabs(prev => prev.filter(tab => tab.key !== fieldToDelete.fieldKey));
// 삭제된 탭이 현재 활성 탭이면 다른 탭으로 전환
if (activeAttributeTab === fieldToDelete.fieldKey) {
setActiveAttributeTab('units');
}
}
toast.success('마스터 항목이 삭제되었습니다');
}
};
// 섹션 템플릿 핸들러
const handleAddSectionTemplate = () => {
if (!newSectionTemplateTitle.trim())
return toast.error('섹션 제목을 입력해주세요');
const newTemplate: SectionTemplate = {
id: `TEMPLATE-${Date.now()}`,
title: newSectionTemplateTitle,
description: newSectionTemplateDescription || undefined,
category: newSectionTemplateCategory.length > 0 ? newSectionTemplateCategory : undefined,
fields: [],
type: newSectionTemplateType,
bomItems: newSectionTemplateType === 'bom' ? [] : undefined,
isCollapsible: true,
isCollapsed: false,
isActive: true,
createdAt: new Date().toISOString().split('T')[0]
};
console.log('Adding section template:', newTemplate);
addSectionTemplate(newTemplate);
setNewSectionTemplateTitle('');
setNewSectionTemplateDescription('');
setNewSectionTemplateCategory([]);
setNewSectionTemplateType('fields');
setIsSectionTemplateDialogOpen(false);
toast.success('섹션 템플릿이 추가되었습니다! (템플릿 목록에서 확인 가능)');
};
const handleEditSectionTemplate = (template: SectionTemplate) => {
setEditingSectionTemplateId(template.id);
setNewSectionTemplateTitle(template.title);
setNewSectionTemplateDescription(template.description || '');
setNewSectionTemplateCategory(template.category || []);
setNewSectionTemplateType(template.type || 'fields');
setIsSectionTemplateDialogOpen(true);
};
const handleUpdateSectionTemplate = () => {
if (!editingSectionTemplateId || !newSectionTemplateTitle.trim())
return toast.error('섹션 제목을 입력해주세요');
updateSectionTemplate(editingSectionTemplateId, {
title: newSectionTemplateTitle,
description: newSectionTemplateDescription || undefined,
category: newSectionTemplateCategory.length > 0 ? newSectionTemplateCategory : undefined,
type: newSectionTemplateType
});
setEditingSectionTemplateId(null);
setNewSectionTemplateTitle('');
setNewSectionTemplateDescription('');
setNewSectionTemplateCategory([]);
setNewSectionTemplateType('fields');
setIsSectionTemplateDialogOpen(false);
toast.success('섹션이 수정되었습니다');
};
const handleDeleteSectionTemplate = (id: string) => {
if (confirm('이 섹션을 삭제하시겠습니까?')) {
deleteSectionTemplate(id);
toast.success('섹션이 삭제되었습니다');
}
};
// 섹션 템플릿 불러오기
const handleLoadTemplate = () => {
if (!selectedTemplateId || !selectedPage) {
return toast.error('템플릿을 선택해주세요');
}
const template = sectionTemplates.find(t => t.id === selectedTemplateId);
if (!template) {
return toast.error('템플릿을 찾을 수 없습니다');
}
// 템플릿을 복사해서 섹션으로 추가
const newSection: ItemSection = {
id: `SECTION-${Date.now()}`,
title: template.title,
description: template.description,
category: template.category,
fields: template.fields.map(field => ({
...field,
id: `FIELD-${Date.now()}-${Math.random()}`,
createdAt: new Date().toISOString().split('T')[0]
})),
type: template.type,
bomItems: template.type === 'bom' && template.bomItems ? template.bomItems.map(bom => ({
...bom,
id: `BOM-${Date.now()}-${Math.random()}`,
createdAt: new Date().toISOString().split('T')[0]
})) : undefined,
order: selectedPage.sections.length + 1,
isCollapsible: template.isCollapsible,
isCollapsed: template.isCollapsed,
createdAt: new Date().toISOString().split('T')[0]
};
console.log('Loading template to section:', template.title, 'type:', template.type, 'newSection:', newSection);
addSectionToPage(selectedPage.id, newSection);
setSelectedTemplateId(null);
setIsLoadTemplateDialogOpen(false);
toast.success('섹션이 불러와졌습니다');
};
// 섹션 템플릿 항목 추가
const handleAddTemplateField = () => {
if (!currentTemplateId || !templateFieldName.trim() || !templateFieldKey.trim()) {
return toast.error('모든 필수 항목을 입력해주세요');
}
const template = sectionTemplates.find(t => t.id === currentTemplateId);
if (!template) return;
// 항목 탭에 해당 항목이 없으면 자동으로 추가
const existingMasterField = itemMasterFields.find(f => f.fieldKey === templateFieldKey);
if (!existingMasterField && !editingTemplateFieldId) {
const newMasterField: ItemMasterField = {
id: `MASTER-${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
} as any,
category: '공통',
description: templateFieldDescription || undefined,
isActive: true,
createdAt: new Date().toISOString().split('T')[0]
};
addItemMasterField(newMasterField);
// dropdown 타입이고 옵션이 있으면 속성관리 탭에도 자동 추가
if (templateFieldInputType === 'dropdown' && templateFieldOptions.trim()) {
const options = templateFieldOptions.split(',').map(o => o.trim());
const existingCustomOptions = customAttributeOptions[templateFieldKey];
if (!existingCustomOptions || existingCustomOptions.length === 0) {
const customOptions = options.map((option, index) => ({
id: `CUSTOM-${templateFieldKey}-${Date.now()}-${index}`,
value: option,
label: option,
isActive: true
}));
setCustomAttributeOptions(prev => ({
...prev,
[templateFieldKey]: customOptions
}));
console.log('Template field dropdown options added to custom attributes:', {
attributeKey: templateFieldKey,
options: customOptions
});
toast.success(`항목 탭과 속성관리 탭에 "${templateFieldName}" 속성이 자동으로 추가되었습니다`);
} else {
toast.success('항목 탭에 자동으로 추가되었습니다');
}
} else {
toast.success('항목 탭에 자동으로 추가되었습니다');
}
}
const newField: ItemField = {
id: editingTemplateFieldId || `FIELD-${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
},
description: templateFieldDescription || undefined,
createdAt: new Date().toISOString().split('T')[0]
};
let updatedFields;
if (editingTemplateFieldId) {
updatedFields = template.fields.map(f => f.id === editingTemplateFieldId ? newField : f);
toast.success('항목이 수정되었습니다');
} else {
updatedFields = [...template.fields, newField];
toast.success('항목이 추가되었습니다');
}
updateSectionTemplate(currentTemplateId, { fields: updatedFields });
// 폼 초기화
setTemplateFieldName('');
setTemplateFieldKey('');
setTemplateFieldInputType('textbox');
setTemplateFieldRequired(false);
setTemplateFieldOptions('');
setTemplateFieldDescription('');
setTemplateFieldMultiColumn(false);
setTemplateFieldColumnCount(2);
setTemplateFieldColumnNames(['컬럼1', '컬럼2']);
setEditingTemplateFieldId(null);
setIsTemplateFieldDialogOpen(false);
};
const handleEditTemplateField = (templateId: string, field: ItemField) => {
setCurrentTemplateId(templateId);
setEditingTemplateFieldId(field.id);
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);
};
const handleDeleteTemplateField = (templateId: string, fieldId: string) => {
if (!confirm('이 항목을 삭제하시겠습니까?')) return;
const template = sectionTemplates.find(t => t.id === templateId);
if (!template) return;
const updatedFields = template.fields.filter(f => f.id !== fieldId);
updateSectionTemplate(templateId, { fields: updatedFields });
toast.success('항목이 삭제되었습니다');
};
// BOM 관리 핸들러
const handleAddBOMItem = (item: Omit<BOMItem, 'id' | 'createdAt'>) => {
const newItem: BOMItem = {
...item,
id: `BOM-${Date.now()}`,
createdAt: new Date().toISOString().split('T')[0]
};
setBomItems(prev => [...prev, newItem]);
};
const handleUpdateBOMItem = (id: string, item: Partial<BOMItem>) => {
setBomItems(prev => prev.map(bom => bom.id === id ? { ...bom, ...item } : bom));
};
const handleDeleteBOMItem = (id: string) => {
setBomItems(prev => prev.filter(bom => bom.id !== id));
};
// 템플릿별 BOM 관리 핸들러
const handleAddBOMItemToTemplate = (templateId: string, item: Omit<BOMItem, 'id' | 'createdAt'>) => {
const newItem: BOMItem = {
...item,
id: `BOM-${Date.now()}`,
createdAt: new Date().toISOString().split('T')[0]
};
const template = sectionTemplates.find(t => t.id === templateId);
if (!template) return;
const updatedBomItems = [...(template.bomItems || []), newItem];
updateSectionTemplate(templateId, { bomItems: updatedBomItems });
};
const handleUpdateBOMItemInTemplate = (templateId: string, itemId: string, item: Partial<BOMItem>) => {
const template = sectionTemplates.find(t => t.id === templateId);
if (!template || !template.bomItems) return;
const updatedBomItems = template.bomItems.map(bom =>
bom.id === itemId ? { ...bom, ...item } : bom
);
updateSectionTemplate(templateId, { bomItems: updatedBomItems });
};
const handleDeleteBOMItemFromTemplate = (templateId: string, itemId: string) => {
const template = sectionTemplates.find(t => t.id === templateId);
if (!template || !template.bomItems) return;
const updatedBomItems = template.bomItems.filter(bom => bom.id !== itemId);
updateSectionTemplate(templateId, { bomItems: updatedBomItems });
};
const toggleSection = (sectionId: string) => {
setExpandedSections(prev => ({ ...prev, [sectionId]: !prev[sectionId] }));
};
// 탭 관리 함수
const handleAddTab = () => {
if (!newTabLabel.trim()) return toast.error('탭 이름을 입력해주세요');
const newTab = {
id: `TAB-${Date.now()}`,
label: newTabLabel,
icon: 'FileText',
isDefault: false,
order: customTabs.length + 1
};
setCustomTabs(prev => [...prev, newTab]);
setNewTabLabel('');
setIsAddTabDialogOpen(false);
toast.success('탭이 추가되었습니다');
};
const handleEditTab = (tabId: string) => {
const tab = customTabs.find(t => t.id === tabId);
if (!tab || tab.isDefault) return;
setEditingTabId(tabId);
setNewTabLabel(tab.label);
setIsAddTabDialogOpen(true);
};
const handleUpdateTab = () => {
if (!newTabLabel.trim() || !editingTabId) return toast.error('탭 이름을 입력해주세요');
setCustomTabs(prev => prev.map(tab =>
tab.id === editingTabId ? { ...tab, label: newTabLabel } : tab
));
setEditingTabId(null);
setNewTabLabel('');
setIsAddTabDialogOpen(false);
setIsManageTabsDialogOpen(true);
toast.success('탭이 수정되었습니다');
};
const handleDeleteTab = (tabId: string) => {
const tab = customTabs.find(t => t.id === tabId);
if (!tab || tab.isDefault) return toast.error('기본 탭은 삭제할 수 없습니다');
setDeletingTabId(tabId);
setIsDeleteTabDialogOpen(true);
};
const confirmDeleteTab = () => {
if (!deletingTabId) return;
setCustomTabs(prev => prev.filter(t => t.id !== deletingTabId));
if (activeTab === deletingTabId) setActiveTab('categories');
setIsDeleteTabDialogOpen(false);
setDeletingTabId(null);
toast.success('탭이 삭제되었습니다');
};
// 속성 하위 탭 관리 함수들
const handleAddAttributeTab = () => {
if (!newAttributeTabLabel.trim()) return toast.error('탭 이름을 입력해주세요');
const newTab = {
id: `attr-${Date.now()}`,
label: newAttributeTabLabel,
key: `custom-${Date.now()}`,
isDefault: false,
order: attributeSubTabs.length
};
setAttributeSubTabs(prev => [...prev, newTab]);
setNewAttributeTabLabel('');
setIsAddAttributeTabDialogOpen(false);
toast.success('속성 탭이 추가되었습니다');
};
const handleUpdateAttributeTab = () => {
if (!newAttributeTabLabel.trim() || !editingAttributeTabId) return toast.error('탭 이름을 입력해주세요');
setAttributeSubTabs(prev => prev.map(tab =>
tab.id === editingAttributeTabId ? { ...tab, label: newAttributeTabLabel } : tab
));
setEditingAttributeTabId(null);
setNewAttributeTabLabel('');
setIsAddAttributeTabDialogOpen(false);
setIsManageAttributeTabsDialogOpen(true);
toast.success('속성 탭이 수정되었습니다');
};
const handleDeleteAttributeTab = (tabId: string) => {
const tab = attributeSubTabs.find(t => t.id === tabId);
if (!tab || tab.isDefault) return toast.error('기본 속성 탭은 삭제할 수 없습니다');
setDeletingAttributeTabId(tabId);
setIsDeleteAttributeTabDialogOpen(true);
};
const confirmDeleteAttributeTab = () => {
if (!deletingAttributeTabId) return;
setAttributeSubTabs(prev => prev.filter(t => t.id !== deletingAttributeTabId));
if (activeAttributeTab === deletingAttributeTabId) {
const firstTab = attributeSubTabs.find(t => t.id !== deletingAttributeTabId);
if (firstTab) setActiveAttributeTab(firstTab.key);
}
setIsDeleteAttributeTabDialogOpen(false);
setDeletingAttributeTabId(null);
toast.success('속성 탭이 삭제되었습니다');
};
const moveAttributeTabUp = (tabId: string) => {
const tabIndex = attributeSubTabs.findIndex(t => t.id === tabId);
if (tabIndex <= 0) return;
const newTabs = [...attributeSubTabs];
const temp = newTabs[tabIndex - 1].order;
newTabs[tabIndex - 1].order = newTabs[tabIndex].order;
newTabs[tabIndex].order = temp;
setAttributeSubTabs(newTabs.sort((a, b) => a.order - b.order));
};
const moveAttributeTabDown = (tabId: string) => {
const tabIndex = attributeSubTabs.findIndex(t => t.id === tabId);
if (tabIndex >= attributeSubTabs.length - 1) return;
const newTabs = [...attributeSubTabs];
const temp = newTabs[tabIndex + 1].order;
newTabs[tabIndex + 1].order = newTabs[tabIndex].order;
newTabs[tabIndex].order = temp;
setAttributeSubTabs(newTabs.sort((a, b) => a.order - b.order));
};
const getTabIcon = (iconName: string) => {
const icons: Record<string, any> = {
FolderTree, ListTree, FileText, Settings, Layers, Database, Plus, Folder
};
return icons[iconName] || FileText;
};
const moveTabUp = (tabId: string) => {
const tabIndex = customTabs.findIndex(t => t.id === tabId);
if (tabIndex <= 0) return;
const newTabs = [...customTabs];
const temp = newTabs[tabIndex - 1].order;
newTabs[tabIndex - 1].order = newTabs[tabIndex].order;
newTabs[tabIndex].order = temp;
setCustomTabs(newTabs.sort((a, b) => a.order - b.order));
toast.success('탭 순서가 변경되었습니다');
};
const moveTabDown = (tabId: string) => {
const tabIndex = customTabs.findIndex(t => t.id === tabId);
if (tabIndex >= customTabs.length - 1) return;
const newTabs = [...customTabs];
const temp = newTabs[tabIndex + 1].order;
newTabs[tabIndex + 1].order = newTabs[tabIndex].order;
newTabs[tabIndex].order = temp;
setCustomTabs(newTabs.sort((a, b) => a.order - b.order));
toast.success('탭 순서가 변경되었습니다');
};
const handleEditTabFromManage = (tab: typeof customTabs[0]) => {
if (tab.isDefault) return toast.error('기본 탭은 수정할 수 없습니다');
setEditingTabId(tab.id);
setNewTabLabel(tab.label);
setIsManageTabsDialogOpen(false);
setIsAddTabDialogOpen(true);
};
// 현재 섹션의 모든 필드 가져오기 (조건부 필드 참조용)
const getAllFieldsInSection = (sectionId: string) => {
if (!selectedPage) return [];
const section = selectedPage.sections.find(s => s.id === sectionId);
return section?.fields || [];
};
// 섹션 순서 변경 핸들러 (드래그앤드롭)
const moveSection = (dragIndex: number, hoverIndex: number) => {
if (!selectedPage) return;
const sections = [...selectedPage.sections];
const [draggedSection] = sections.splice(dragIndex, 1);
sections.splice(hoverIndex, 0, draggedSection);
// order 값 재설정
const updatedSections = sections.map((section, idx) => ({
...section,
order: idx + 1
}));
// 페이지 업데이트
updateItemPage(selectedPage.id, { sections: updatedSections });
trackChange('pages', selectedPage.id, 'update', { sections: updatedSections });
toast.success('섹션 순서가 변경되었습니다 (저장 필요)');
};
// 필드 순서 변경 핸들러
const moveField = (sectionId: string, dragIndex: number, hoverIndex: number) => {
if (!selectedPage) return;
const section = selectedPage.sections.find(s => s.id === sectionId);
if (!section) return;
const newFields = [...section.fields];
const [draggedField] = newFields.splice(dragIndex, 1);
newFields.splice(hoverIndex, 0, draggedField);
reorderFields(selectedPage.id, sectionId, newFields.map(f => f.id));
};
// 변경사항 추적 함수
const trackChange = (type: 'pages' | 'sections' | 'fields' | 'masterFields' | 'attributes', id: string, action: 'add' | 'update', data: any, attributeType?: string) => {
setPendingChanges(prev => {
const updated = { ...prev };
if (type === 'attributes') {
const existingIndex = updated.attributes.findIndex(item => item.id === id);
if (existingIndex >= 0) {
updated.attributes[existingIndex] = { id, action, type: attributeType || '', data };
} else {
updated.attributes.push({ id, action, type: attributeType || '', data });
}
} else {
const existingIndex = updated[type].findIndex(item => item.id === id);
if (existingIndex >= 0) {
updated[type][existingIndex] = { id, action, data };
} else {
updated[type].push({ id, action, data });
}
}
return updated;
});
setHasUnsavedChanges(true);
};
// 일괄 저장 핸들러 - 모든 페이지, 섹션, 항목, 속성을 통합 저장
const handleSaveAllChanges = () => {
if (!hasUnsavedChanges) {
return toast.info('저장할 변경사항이 없습니다');
}
try {
// 모든 변경사항은 이미 DataContext에 실시간으로 반영되어 있습니다.
// DataContext의 useEffect가 자동으로 localStorage에 저장합니다.
// 이 함수는 변경사항 추적을 초기화하고 사용자에게 확인 메시지를 보여줍니다.
const totalChanges =
pendingChanges.pages.length +
pendingChanges.sections.length +
pendingChanges.fields.length +
pendingChanges.masterFields.length +
pendingChanges.attributes.length;
// 변경사항 요약
const summary = [];
if (pendingChanges.pages.length > 0) summary.push(`페이지 ${pendingChanges.pages.length}`);
if (pendingChanges.sections.length > 0) summary.push(`섹션 ${pendingChanges.sections.length}`);
if (pendingChanges.fields.length > 0) summary.push(`항목 ${pendingChanges.fields.length}`);
if (pendingChanges.masterFields.length > 0) summary.push(`마스터항목 ${pendingChanges.masterFields.length}`);
if (pendingChanges.attributes.length > 0) summary.push(`속성 ${pendingChanges.attributes.length}`);
console.log('Confirming changes:', { totalChanges, pendingChanges, currentItemPages: itemPages });
// itemPages, sectionTemplates, itemMasterFields를 명시적으로 localStorage에 저장 (안전성 보장)
localStorage.setItem('mes-itemPages', JSON.stringify(itemPages));
localStorage.setItem('mes-sectionTemplates', JSON.stringify(sectionTemplates));
localStorage.setItem('mes-itemMasterFields', JSON.stringify(itemMasterFields));
console.log('Saved to localStorage:', {
itemPages: itemPages.length,
sectionTemplates: sectionTemplates.length,
itemMasterFields: itemMasterFields.length
});
// 속성 탭의 모든 데이터도 localStorage에 명시적으로 저장 (안전성 보장)
localStorage.setItem(UNIT_OPTIONS_KEY, JSON.stringify(unitOptions));
localStorage.setItem(MATERIAL_OPTIONS_KEY, JSON.stringify(materialOptions));
localStorage.setItem(SURFACE_TREATMENT_OPTIONS_KEY, JSON.stringify(surfaceTreatmentOptions));
localStorage.setItem(CUSTOM_ATTRIBUTE_OPTIONS_KEY, JSON.stringify(customAttributeOptions));
localStorage.setItem(ITEM_CATEGORIES_KEY, JSON.stringify(itemCategories));
// 변경사항 초기화
setPendingChanges({
pages: [],
sections: [],
fields: [],
masterFields: [],
attributes: []
});
setHasUnsavedChanges(false);
toast.success(`✅ 모든 변경사항 저장 완료!\n${summary.join(', ')} - 총 ${totalChanges}\n페이지, 섹션, 항목, 속성이 모두 자동 목록에 반영되었습니다.`);
} catch (error) {
toast.error('저장 중 오류가 발생했습니다');
console.error('Save error:', error);
}
};
return (
<PageLayout>
<PageHeader
title="품목기준관리"
description="품목관리에서 사용되는 기준 정보를 설정하고 관리합니다"
icon={Database}
/>
{/* 전역 저장 버튼 - 모든 탭의 변경사항을 저장 */}
{hasUnsavedChanges && (
<div className="mb-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Badge variant="destructive" className="animate-pulse text-base px-3 py-1">
{pendingChanges.pages.length +
pendingChanges.sections.length +
pendingChanges.fields.length +
pendingChanges.masterFields.length +
pendingChanges.attributes.length}
</Badge>
<div className="text-sm text-gray-700">
{pendingChanges.pages.length > 0 && <span className="mr-2"> {pendingChanges.pages.length}</span>}
{pendingChanges.sections.length > 0 && <span className="mr-2"> {pendingChanges.sections.length}</span>}
{pendingChanges.fields.length > 0 && <span className="mr-2"> {pendingChanges.fields.length}</span>}
{pendingChanges.masterFields.length > 0 && <span className="mr-2"> {pendingChanges.masterFields.length}</span>}
{pendingChanges.attributes.length > 0 && <span className="mr-2"> {pendingChanges.attributes.length}</span>}
</div>
</div>
<Button
size="lg"
variant="default"
onClick={handleSaveAllChanges}
className="bg-green-600 hover:bg-green-700 shadow-lg"
>
<Save className="h-5 w-5 mr-2" />
</Button>
</div>
</div>
)}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<div className="flex items-center gap-2 mb-4">
<TabsList className="flex-1">
{customTabs.sort((a, b) => a.order - b.order).map(tab => {
const Icon = getTabIcon(tab.icon);
return (
<TabsTrigger key={tab.id} value={tab.id}>
<Icon className="w-4 h-4 mr-2" />
{tab.label}
</TabsTrigger>
);
})}
</TabsList>
<Button
size="sm"
variant="outline"
onClick={() => setIsManageTabsDialogOpen(true)}
>
<Settings className="h-4 w-4 mr-1" />
</Button>
</div>
{/* 속성 탭 (단위/재질/표면처리 통합) */}
<TabsContent value="attributes" className="space-y-4">
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription>, , </CardDescription>
</CardHeader>
<CardContent>
{/* 속성 하위 탭 (칩 형태) */}
<div className="flex items-center gap-2 mb-6 border-b pb-2">
<div className="flex gap-2 flex-1 flex-wrap">
{attributeSubTabs.sort((a, b) => a.order - b.order).map(tab => (
<Button
key={tab.id}
variant={activeAttributeTab === tab.key ? 'default' : 'outline'}
size="sm"
onClick={() => setActiveAttributeTab(tab.key)}
className="rounded-full"
>
{tab.label}
</Button>
))}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setIsManageAttributeTabsDialogOpen(true)}
className="shrink-0"
>
<Settings className="w-4 h-4 mr-1" />
</Button>
</div>
{/* 단위 관리 */}
{activeAttributeTab === 'units' && (
<div className="space-y-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium"> </h3>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => {
setManagingColumnType('units');
setNewColumnName('');
setNewColumnKey('');
setNewColumnType('text');
setNewColumnRequired(false);
setIsColumnManageDialogOpen(true);
}}>
<Settings className="w-4 h-4 mr-2" />
</Button>
<Button size="sm" onClick={() => { setEditingOptionType('unit'); setIsOptionDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
<div className="space-y-3">
{unitOptions.map((option) => {
const columns = attributeColumns['units'] || [];
const hasColumns = columns.length > 0 && option.columnValues;
const inputTypeLabel =
option.inputType === 'textbox' ? '텍스트박스' :
option.inputType === 'number' ? '숫자' :
option.inputType === 'dropdown' ? '드롭다운' :
option.inputType === 'checkbox' ? '체크박스' :
option.inputType === 'date' ? '날짜' :
option.inputType === 'textarea' ? '텍스트영역' :
'텍스트박스';
return (
<div key={option.id} className="p-4 border rounded hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<span className="font-medium text-base">{option.label}</span>
{option.inputType && (
<Badge variant="outline" className="text-xs">{inputTypeLabel}</Badge>
)}
{option.required && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
</div>
<div className="text-sm text-muted-foreground space-y-1">
<div className="flex gap-2">
<span className="font-medium min-w-16">(Value):</span>
<span>{option.value}</span>
</div>
{option.placeholder && (
<div className="flex gap-2">
<span className="font-medium min-w-16">:</span>
<span>{option.placeholder}</span>
</div>
)}
{option.defaultValue && (
<div className="flex gap-2">
<span className="font-medium min-w-16">:</span>
<span>{option.defaultValue}</span>
</div>
)}
{option.inputType === 'dropdown' && option.options && (
<div className="flex gap-2">
<span className="font-medium min-w-16">:</span>
<div className="flex flex-wrap gap-1">
{option.options.map((opt, idx) => (
<Badge key={idx} variant="secondary" className="text-xs">{opt}</Badge>
))}
</div>
</div>
)}
</div>
{hasColumns && (
<div className="mt-3 pt-3 border-t">
<p className="text-xs font-medium text-muted-foreground mb-2"> </p>
<div className="grid grid-cols-2 gap-2 text-sm">
{columns.map((column) => (
<div key={column.id} className="flex gap-2">
<span className="text-muted-foreground">{column.name}:</span>
<span>{option.columnValues?.[column.key] || '-'}</span>
</div>
))}
</div>
</div>
)}
</div>
<Button variant="ghost" size="sm" onClick={() => handleDeleteOption('unit', option.id)}>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</div>
);
})}
</div>
</div>
)}
{/* 재질 관리 */}
{activeAttributeTab === 'materials' && (
<div className="space-y-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium"> </h3>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => {
setManagingColumnType('materials');
setNewColumnName('');
setNewColumnKey('');
setNewColumnType('text');
setNewColumnRequired(false);
setIsColumnManageDialogOpen(true);
}}>
<Settings className="w-4 h-4 mr-2" />
</Button>
<Button size="sm" onClick={() => { setEditingOptionType('material'); setIsOptionDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
<div className="space-y-3">
{materialOptions.map((option) => {
const columns = attributeColumns['materials'] || [];
const hasColumns = columns.length > 0 && option.columnValues;
const inputTypeLabel =
option.inputType === 'textbox' ? '텍스트박스' :
option.inputType === 'number' ? '숫자' :
option.inputType === 'dropdown' ? '드롭다운' :
option.inputType === 'checkbox' ? '체크박스' :
option.inputType === 'date' ? '날짜' :
option.inputType === 'textarea' ? '텍스트영역' :
'텍스트박스';
return (
<div key={option.id} className="p-4 border rounded hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<span className="font-medium text-base">{option.label}</span>
{option.inputType && (
<Badge variant="outline" className="text-xs">{inputTypeLabel}</Badge>
)}
{option.required && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
</div>
<div className="text-sm text-muted-foreground space-y-1">
<div className="flex gap-2">
<span className="font-medium min-w-16">(Value):</span>
<span>{option.value}</span>
</div>
{option.placeholder && (
<div className="flex gap-2">
<span className="font-medium min-w-16">:</span>
<span>{option.placeholder}</span>
</div>
)}
{option.defaultValue && (
<div className="flex gap-2">
<span className="font-medium min-w-16">:</span>
<span>{option.defaultValue}</span>
</div>
)}
{option.inputType === 'dropdown' && option.options && (
<div className="flex gap-2">
<span className="font-medium min-w-16">:</span>
<div className="flex flex-wrap gap-1">
{option.options.map((opt, idx) => (
<Badge key={idx} variant="secondary" className="text-xs">{opt}</Badge>
))}
</div>
</div>
)}
</div>
{hasColumns && (
<div className="mt-3 pt-3 border-t">
<p className="text-xs font-medium text-muted-foreground mb-2"> </p>
<div className="grid grid-cols-2 gap-2 text-sm">
{columns.map((column) => (
<div key={column.id} className="flex gap-2">
<span className="text-muted-foreground">{column.name}:</span>
<span>{option.columnValues?.[column.key] || '-'}</span>
</div>
))}
</div>
</div>
)}
</div>
<Button variant="ghost" size="sm" onClick={() => handleDeleteOption('material', option.id)}>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</div>
);
})}
</div>
</div>
)}
{/* 표면처리 관리 */}
{activeAttributeTab === 'surface' && (
<div className="space-y-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium"> </h3>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => {
setManagingColumnType('surface');
setNewColumnName('');
setNewColumnKey('');
setNewColumnType('text');
setNewColumnRequired(false);
setIsColumnManageDialogOpen(true);
}}>
<Settings className="w-4 h-4 mr-2" />
</Button>
<Button size="sm" onClick={() => { setEditingOptionType('surface'); setIsOptionDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
<div className="space-y-3">
{surfaceTreatmentOptions.map((option) => {
const columns = attributeColumns['surface'] || [];
const hasColumns = columns.length > 0 && option.columnValues;
const inputTypeLabel =
option.inputType === 'textbox' ? '텍스트박스' :
option.inputType === 'number' ? '숫자' :
option.inputType === 'dropdown' ? '드롭다운' :
option.inputType === 'checkbox' ? '체크박스' :
option.inputType === 'date' ? '날짜' :
option.inputType === 'textarea' ? '텍스트영역' :
'텍스트박스';
return (
<div key={option.id} className="p-4 border rounded hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<span className="font-medium text-base">{option.label}</span>
{option.inputType && (
<Badge variant="outline" className="text-xs">{inputTypeLabel}</Badge>
)}
{option.required && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
</div>
<div className="text-sm text-muted-foreground space-y-1">
<div className="flex gap-2">
<span className="font-medium min-w-16">(Value):</span>
<span>{option.value}</span>
</div>
{option.placeholder && (
<div className="flex gap-2">
<span className="font-medium min-w-16">:</span>
<span>{option.placeholder}</span>
</div>
)}
{option.defaultValue && (
<div className="flex gap-2">
<span className="font-medium min-w-16">:</span>
<span>{option.defaultValue}</span>
</div>
)}
{option.inputType === 'dropdown' && option.options && (
<div className="flex gap-2">
<span className="font-medium min-w-16">:</span>
<div className="flex flex-wrap gap-1">
{option.options.map((opt, idx) => (
<Badge key={idx} variant="secondary" className="text-xs">{opt}</Badge>
))}
</div>
</div>
)}
</div>
{hasColumns && (
<div className="mt-3 pt-3 border-t">
<p className="text-xs font-medium text-muted-foreground mb-2"> </p>
<div className="grid grid-cols-2 gap-2 text-sm">
{columns.map((column) => (
<div key={column.id} className="flex gap-2">
<span className="text-muted-foreground">{column.name}:</span>
<span>{option.columnValues?.[column.key] || '-'}</span>
</div>
))}
</div>
</div>
)}
</div>
<Button variant="ghost" size="sm" onClick={() => handleDeleteOption('surface', option.id)}>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</div>
);
})}
</div>
</div>
)}
{/* 사용자 정의 속성 탭 및 마스터 항목 탭 */}
{!['units', 'materials', 'surface'].includes(activeAttributeTab) && (() => {
const currentTabKey = activeAttributeTab;
// 마스터 항목인지 확인
const masterField = itemMasterFields.find(f => f.fieldKey === currentTabKey);
// 마스터 항목이면 해당 항목의 속성값들을 표시
if (masterField && masterField.properties && masterField.properties.length > 0) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-medium">{masterField.name} </h3>
<p className="text-sm text-muted-foreground mt-1">
"{masterField.name}"
</p>
</div>
</div>
<div className="space-y-3">
{masterField.properties.map((property) => {
const inputTypeLabel =
property.type === 'textbox' ? '텍스트박스' :
property.type === 'number' ? '숫자' :
property.type === 'dropdown' ? '드롭다운' :
property.type === 'checkbox' ? '체크박스' :
property.type === 'date' ? '날짜' :
property.type === 'textarea' ? '텍스트영역' :
'텍스트박스';
return (
<div key={property.id} className="p-4 border rounded hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<span className="font-medium text-base">{property.label}</span>
<Badge variant="outline" className="text-xs">{inputTypeLabel}</Badge>
{property.required && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
</div>
<div className="text-sm text-muted-foreground space-y-1">
<div className="flex gap-2">
<span className="font-medium min-w-24">(Key):</span>
<code className="bg-gray-100 px-2 py-0.5 rounded text-xs">{property.key}</code>
</div>
{property.placeholder && (
<div className="flex gap-2">
<span className="font-medium min-w-24">:</span>
<span>{property.placeholder}</span>
</div>
)}
{property.defaultValue && (
<div className="flex gap-2">
<span className="font-medium min-w-24">:</span>
<span>{property.defaultValue}</span>
</div>
)}
{property.type === 'dropdown' && property.options && (
<div className="flex gap-2">
<span className="font-medium min-w-24">:</span>
<div className="flex flex-wrap gap-1">
{property.options.map((opt, idx) => (
<Badge key={idx} variant="secondary" className="text-xs">{opt}</Badge>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-2">
<Package className="w-5 h-5 text-blue-600 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-blue-900">
</p>
<p className="text-xs text-blue-700 mt-1">
<strong> </strong> "{masterField.name}" // .
</p>
</div>
</div>
</div>
</div>
);
}
// 사용자 정의 속성 탭 (기존 로직)
const currentOptions = customAttributeOptions[currentTabKey] || [];
return (
<div className="space-y-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium">
{attributeSubTabs.find(t => t.key === activeAttributeTab)?.label || '사용자 정의'}
</h3>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => {
setManagingColumnType(currentTabKey);
setNewColumnName('');
setNewColumnKey('');
setNewColumnType('text');
setNewColumnRequired(false);
setIsColumnManageDialogOpen(true);
}}>
<Settings className="w-4 h-4 mr-2" />
</Button>
<Button size="sm" onClick={() => {
setEditingOptionType(activeAttributeTab);
setNewOptionValue('');
setNewOptionLabel('');
setNewOptionColumnValues({});
setIsOptionDialogOpen(true);
}}>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{currentOptions.length > 0 ? (
<div className="space-y-3">
{currentOptions.map((option) => {
const columns = attributeColumns[currentTabKey] || [];
const hasColumns = columns.length > 0 && option.columnValues;
const inputTypeLabel =
option.inputType === 'textbox' ? '텍스트박스' :
option.inputType === 'number' ? '숫자' :
option.inputType === 'dropdown' ? '드롭다운' :
option.inputType === 'checkbox' ? '체크박스' :
option.inputType === 'date' ? '날짜' :
option.inputType === 'textarea' ? '텍스트영역' :
'텍스트박스';
return (
<div key={option.id} className="p-4 border rounded hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<span className="font-medium text-base">{option.label}</span>
<Badge variant="outline" className="text-xs">{inputTypeLabel}</Badge>
{option.required && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
</div>
<div className="text-sm text-muted-foreground space-y-1">
<div className="flex gap-2">
<span className="font-medium min-w-16">(Value):</span>
<span>{option.value}</span>
</div>
{option.placeholder && (
<div className="flex gap-2">
<span className="font-medium min-w-16">:</span>
<span>{option.placeholder}</span>
</div>
)}
{option.defaultValue && (
<div className="flex gap-2">
<span className="font-medium min-w-16">:</span>
<span>{option.defaultValue}</span>
</div>
)}
{option.inputType === 'dropdown' && option.options && (
<div className="flex gap-2">
<span className="font-medium min-w-16">:</span>
<div className="flex flex-wrap gap-1">
{option.options.map((opt, idx) => (
<Badge key={idx} variant="secondary" className="text-xs">{opt}</Badge>
))}
</div>
</div>
)}
</div>
{hasColumns && (
<div className="mt-3 pt-3 border-t">
<p className="text-xs font-medium text-muted-foreground mb-2"> </p>
<div className="grid grid-cols-2 gap-2 text-sm">
{columns.map((column) => (
<div key={column.id} className="flex gap-2">
<span className="text-muted-foreground">{column.name}:</span>
<span>{option.columnValues?.[column.key] || '-'}</span>
</div>
))}
</div>
</div>
)}
</div>
<Button variant="ghost" size="sm" onClick={() => handleDeleteOption(currentTabKey, option.id)}>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</div>
);
})}
</div>
) : (
<div className="border-2 border-dashed rounded-lg p-8 text-center text-gray-500">
<Settings className="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p className="mb-2"> </p>
<p className="text-sm"> "추가" </p>
</div>
)}
</div>
);
})()}
</CardContent>
</Card>
</TabsContent>
{/* 항목 탭 */}
<TabsContent value="items" className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div>
<CardTitle> </CardTitle>
<CardDescription> 릿 </CardDescription>
</div>
{hasUnsavedChanges && pendingChanges.masterFields.length > 0 && (
<Badge variant="destructive" className="animate-pulse">
{pendingChanges.masterFields.length}
</Badge>
)}
</div>
<Button onClick={() => setIsMasterFieldDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
</CardHeader>
<CardContent>
{(() => {
console.log('Rendering master fields:', {
totalFields: itemMasterFields.length,
fields: itemMasterFields.map(f => ({ id: f.id, name: f.name, fieldKey: f.fieldKey }))
});
return null;
})()}
{itemMasterFields.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground mb-2"> </p>
<p className="text-sm text-muted-foreground">
.
</p>
</div>
) : (
<div className="space-y-2">
{itemMasterFields.map((field) => (
<div key={field.id} className="flex items-center justify-between p-4 border rounded hover:bg-gray-50 transition-colors">
<div className="flex-1">
<div className="flex items-center gap-2">
<span>{field.name}</span>
<Badge variant="outline" className="text-xs">
{INPUT_TYPE_OPTIONS.find(t => t.value === field.property.inputType)?.label}
</Badge>
{field.property.required && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
<Badge variant="secondary" className="text-xs">{field.category}</Badge>
{(field.property as any).attributeType && (field.property as any).attributeType !== 'custom' && (
<Badge variant="default" className="text-xs bg-blue-500">
{(field.property as any).attributeType === 'unit' ? '단위 연동' :
(field.property as any).attributeType === 'material' ? '재질 연동' : '표면처리 연동'}
</Badge>
)}
</div>
<div className="text-sm text-muted-foreground mt-1">
: {field.fieldKey}
{field.description && (
<span className="ml-2"> {field.description}</span>
)}
</div>
{field.property.options && field.property.options.length > 0 && (
<div className="text-xs text-gray-500 mt-1">
: {field.property.options.join(', ')}
{(field.property as any).attributeType && (field.property as any).attributeType !== 'custom' && (
<span className="ml-2 text-blue-600">
( )
</span>
)}
</div>
)}
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => handleEditMasterField(field)}
>
<Edit className="h-4 w-4 text-blue-500" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteMasterField(field.id)}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* 섹션관리 탭 */}
<TabsContent value="sections" className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription> 릿 </CardDescription>
</div>
<Button onClick={() => setIsSectionTemplateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
</CardHeader>
<CardContent>
<Tabs defaultValue="general" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="general" className="flex items-center gap-2">
<Folder className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="module" className="flex items-center gap-2">
<Package className="h-4 w-4" />
</TabsTrigger>
</TabsList>
{/* 일반 섹션 탭 */}
<TabsContent value="general">
{(() => {
console.log('Rendering section templates:', {
totalTemplates: sectionTemplates.length,
generalTemplates: sectionTemplates.filter(t => t.type !== 'bom').length,
templates: sectionTemplates.map(t => ({ id: t.id, title: t.title, type: t.type }))
});
return null;
})()}
{sectionTemplates.filter(t => t.type !== 'bom').length === 0 ? (
<div className="text-center py-12">
<Folder className="w-16 h-16 mx-auto text-gray-300 mb-4" />
<p className="text-muted-foreground mb-2"> </p>
<p className="text-sm text-muted-foreground">
.
</p>
</div>
) : (
<div className="space-y-4">
{sectionTemplates.filter(t => t.type !== 'bom').map((template) => (
<Card key={template.id}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
<Folder className="h-5 w-5 text-blue-500" />
<div className="flex-1">
<CardTitle className="text-base">{template.title}</CardTitle>
{template.description && (
<CardDescription className="text-sm mt-0.5">{template.description}</CardDescription>
)}
</div>
</div>
<div className="flex items-center gap-2">
{template.category && template.category.length > 0 && (
<div className="flex flex-wrap gap-1 mr-2">
{template.category.map((cat, idx) => (
<Badge key={idx} variant="secondary" className="text-xs">
{ITEM_TYPE_OPTIONS.find(t => t.value === cat)?.label || cat}
</Badge>
))}
</div>
)}
<Button
size="sm"
variant="ghost"
onClick={() => handleEditSectionTemplate(template)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteSectionTemplate(template.id)}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
릿
</p>
<Button
size="sm"
onClick={() => {
setCurrentTemplateId(template.id);
setIsTemplateFieldDialogOpen(true);
}}
>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
{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">
, ,
</p>
</div>
</div>
) : (
<div className="space-y-2">
{template.fields.map((field, index) => (
<div
key={field.id}
className="flex items-center justify-between p-3 border rounded hover:bg-gray-50 transition-colors"
>
<div className="flex-1">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-gray-400" />
<span className="text-sm font-medium">{field.name}</span>
<Badge variant="outline" className="text-xs">
{INPUT_TYPE_OPTIONS.find(t => t.value === field.property.inputType)?.label}
</Badge>
{field.property.required && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
</div>
<div className="ml-6 text-xs text-gray-500 mt-1">
: {field.fieldKey}
{field.description && (
<span className="ml-2"> {field.description}</span>
)}
</div>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => handleEditTemplateField(template.id, field)}
>
<Edit className="h-4 w-4 text-blue-500" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteTemplateField(template.id, field.id)}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
{/* 모듈 섹션 (BOM) 탭 */}
<TabsContent value="module">
{sectionTemplates.filter(t => t.type === 'bom').length === 0 ? (
<div className="text-center py-12">
<Package className="w-16 h-16 mx-auto text-gray-300 mb-4" />
<p className="text-muted-foreground mb-2"> </p>
<p className="text-sm text-muted-foreground">
BOM .
</p>
</div>
) : (
<div className="space-y-4">
{sectionTemplates.filter(t => t.type === 'bom').map((template) => (
<Card key={template.id}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
<Package className="h-5 w-5 text-green-500" />
<div className="flex-1">
<CardTitle className="text-base">{template.title}</CardTitle>
{template.description && (
<CardDescription className="text-sm mt-0.5">{template.description}</CardDescription>
)}
</div>
</div>
<div className="flex items-center gap-2">
{template.category && template.category.length > 0 && (
<div className="flex flex-wrap gap-1 mr-2">
{template.category.map((cat, idx) => (
<Badge key={idx} variant="secondary" className="text-xs">
{ITEM_TYPE_OPTIONS.find(t => t.value === cat)?.label || cat}
</Badge>
))}
</div>
)}
<Button
size="sm"
variant="ghost"
onClick={() => handleEditSectionTemplate(template)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteSectionTemplate(template.id)}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<BOMManagementSection
title=""
description=""
bomItems={template.bomItems || []}
onAddItem={(item) => handleAddBOMItemToTemplate(template.id, item)}
onUpdateItem={(itemId, item) => handleUpdateBOMItemInTemplate(template.id, itemId, item)}
onDeleteItem={(itemId) => handleDeleteBOMItemFromTemplate(template.id, itemId)}
/>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
</Tabs>
</CardContent>
</Card>
</TabsContent>
{/* 계층구조 탭 */}
<TabsContent value="hierarchy" className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* 섹션 목록 */}
<Card className="col-span-full md:col-span-1 max-h-[500px] md:max-h-[calc(100vh-300px)] flex flex-col">
<CardHeader className="flex-shrink-0">
<div className="flex items-center justify-between">
<CardTitle className="text-base"></CardTitle>
<Button size="sm" onClick={() => setIsPageDialogOpen(true)}>
<Plus className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-2 overflow-y-auto flex-1">
{itemPages.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4"> </p>
) : (
itemPages.map(page => (
<div key={page.id} className="relative group">
{editingPageId === page.id ? (
<div className="flex items-center gap-1 p-2 border rounded bg-white">
<Input
value={editingPageName}
onChange={(e) => setEditingPageName(e.target.value)}
className="h-7 text-sm"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (!editingPageName.trim()) return toast.error('섹션명을 입력해주세요');
updateItemPage(page.id, { pageName: editingPageName });
trackChange('pages', page.id, 'update', { pageName: editingPageName });
setEditingPageId(null);
toast.success('페이지명이 수정되었습니다 (저장 필요)');
}
if (e.key === 'Escape') setEditingPageId(null);
}}
/>
</div>
) : (
<div
onClick={() => setSelectedPageId(page.id)}
onDoubleClick={() => {
setEditingPageId(page.id);
setEditingPageName(page.pageName);
}}
className={`w-full text-left p-2 rounded hover:bg-gray-50 transition-colors cursor-pointer ${
selectedPage?.id === page.id ? 'bg-blue-50 border border-blue-200' : 'border'
}`}
>
<div className="space-y-1">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="text-sm truncate">{page.pageName}</div>
<div className="text-xs text-gray-500 truncate">
{ITEM_TYPE_OPTIONS.find(t => t.value === page.itemType)?.label}
</div>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
setEditingPageId(page.id);
setEditingPageName(page.pageName);
}}
title="페이지명 수정"
>
<Edit className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
if (confirm('이 섹션과 모든 하위섹션, 항목을 삭제하시겠습니까?')) {
deleteItemPage(page.id);
if (selectedPageId === page.id) {
setSelectedPageId(itemPages[0]?.id || null);
}
toast.success('섹션이 삭제되었습니다');
}
}}
title="삭제"
>
<Trash2 className="h-3 w-3 text-red-500" />
</Button>
</div>
</div>
{/* 절대경로 표시 */}
{page.absolutePath && (
<div className="flex items-start gap-1 text-xs">
<Link className="h-3 w-3 text-gray-400 flex-shrink-0 mt-0.5" />
<span className="text-gray-500 font-mono break-all flex-1 min-w-0">{page.absolutePath}</span>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0"
onClick={(e) => {
e.stopPropagation();
setEditingPathPageId(page.id);
setEditingAbsolutePath(page.absolutePath || '');
}}
title="Edit Path"
>
<Edit className="h-3 w-3 text-blue-500" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(page.absolutePath || '');
toast.success('Path copied to clipboard');
}}
title="Copy Path"
>
<Copy className="h-3 w-3 text-green-500" />
</Button>
</div>
</div>
)}
</div>
</div>
)}
</div>
))
)}
</CardContent>
</Card>
{/* 계층구조 */}
<Card className="md:col-span-3 max-h-[600px] md:max-h-[calc(100vh-300px)] flex flex-col">
<CardHeader className="flex-shrink-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap">
<CardTitle className="text-sm sm:text-base">{selectedPage?.pageName || '섹션을 선택하세요'}</CardTitle>
{hasUnsavedChanges && (
<Badge variant="destructive" className="animate-pulse text-xs">
{pendingChanges.pages.length + pendingChanges.sections.length + pendingChanges.fields.length + pendingChanges.masterFields.length + pendingChanges.attributes.length}
</Badge>
)}
</div>
{selectedPage && (
<Button
size="sm"
onClick={() => {
setNewSectionType('fields');
setIsSectionDialogOpen(true);
}}
>
<Plus className="h-4 w-4 mr-2" />
</Button>
)}
</div>
</CardHeader>
<CardContent className="overflow-y-auto flex-1">
{selectedPage ? (
<div className="h-full flex flex-col space-y-4">
{/* 일반 섹션 */}
<div className="space-y-4">
<div className="space-y-6">
{(() => {
console.log('Rendering sections for page:', {
pageId: selectedPage.id,
pageName: selectedPage.pageName,
totalSections: selectedPage.sections.length,
nonBomSections: selectedPage.sections.filter(s => s.type !== 'bom').length,
sections: selectedPage.sections.map(s => ({ id: s.id, title: s.title, type: s.type }))
});
return null;
})()}
{selectedPage.sections.length === 0 ? (
<p className="text-center text-gray-500 py-8"> </p>
) : (
selectedPage.sections
.map((section, index) => {
console.log('Rendering section:', section.title, 'type:', section.type, 'bomItems:', section.bomItems);
return (
<DraggableSection
key={section.id}
section={section}
index={index}
moveSection={(dragIndex, hoverIndex) => {
moveSection(dragIndex, hoverIndex);
}}
onDelete={() => {
if (confirm('이 섹션과 모든 항목을 삭제하시겠습니까?')) {
deleteSection(selectedPage.id, section.id);
toast.success('섹션이 삭제되었습니다');
}
}}
onEditTitle={handleEditSectionTitle}
editingSectionId={editingSectionId}
editingSectionTitle={editingSectionTitle}
setEditingSectionTitle={setEditingSectionTitle}
setEditingSectionId={setEditingSectionId}
handleSaveSectionTitle={handleSaveSectionTitle}
>
{/* BOM 타입 섹션 */}
{section.type === 'bom' ? (
<BOMManagementSection
title=""
description=""
bomItems={section.bomItems || []}
onAddItem={(item) => {
const newBomItems = [...(section.bomItems || []), {
...item,
id: `BOM-${Date.now()}`,
createdAt: new Date().toISOString().split('T')[0]
}];
updateSection(selectedPage.id, section.id, { type: 'bom', bomItems: newBomItems });
toast.success('BOM 항목이 추가되었습니다');
}}
onEditItem={(itemId, updatedItem) => {
const newBomItems = (section.bomItems || []).map(item =>
item.id === itemId ? { ...item, ...updatedItem } : item
);
updateSection(selectedPage.id, section.id, { type: 'bom', bomItems: newBomItems });
toast.success('BOM 항목이 수정되었습니다');
}}
onDeleteItem={(itemId) => {
const newBomItems = (section.bomItems || []).filter(item => item.id !== itemId);
updateSection(selectedPage.id, section.id, { type: 'bom', bomItems: newBomItems });
toast.success('BOM 항목이 삭제되었습니다');
}}
/>
) : (
/* 일반 필드 타입 섹션 */
<>
{section.fields.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4"> </p>
) : (
section.fields
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((field, fieldIndex) => (
<DraggableField
key={field.id}
field={field}
index={fieldIndex}
moveField={(dragIndex, hoverIndex) => moveField(section.id, dragIndex, hoverIndex)}
onDelete={() => {
if (confirm('이 항목을 삭제하시겠습니까?')) {
deleteField(selectedPage.id, section.id, field.id);
toast.success('항목이 삭제되었습니다');
}
}}
onEdit={() => handleEditField(section.id, field)}
/>
))
)}
<Button
size="sm"
variant="outline"
className="w-full mt-3"
onClick={() => {
setSelectedSectionForField(section.id);
setIsFieldDialogOpen(true);
}}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</>
)}
</DraggableSection>
);
})
)}
</div>
</div>
</div>
) : (
<p className="text-center text-gray-500 py-8"> </p>
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* 품목분류 탭 */}
<TabsContent value="categories">
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> ( )</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* 대분류 추가 */}
<div className="border rounded-lg p-4">
<h3 className="font-medium mb-3"> </h3>
<div className="flex gap-2">
<Input
placeholder="대분류명 입력"
value={newCategory1}
onChange={(e) => setNewCategory1(e.target.value)}
/>
<Button onClick={() => {
if (!newCategory1.trim()) return toast.error('대분류명을 입력해주세요');
setItemCategories({ ...itemCategories, [newCategory1]: {} });
setNewCategory1('');
toast.success('대분류가 추가되었습니다');
}}>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 대분류 목록 */}
<div className="space-y-4">
{Object.keys(itemCategories).map(cat1 => (
<div key={cat1} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-lg">{cat1}</h3>
<Button
variant="ghost"
size="sm"
onClick={() => {
const newCategories = { ...itemCategories };
delete newCategories[cat1];
setItemCategories(newCategories);
toast.success('삭제되었습니다');
}}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
{/* 중분류 추가 */}
<div className="ml-4 mb-3">
<div className="flex gap-2">
<Input
placeholder="중분류명 입력"
value={selectedCategory1 === cat1 ? newCategory2 : ''}
onChange={(e) => {
setSelectedCategory1(cat1);
setNewCategory2(e.target.value);
}}
/>
<Button
size="sm"
onClick={() => {
if (!newCategory2.trim()) return toast.error('중분류명을 입력해주세요');
setItemCategories({
...itemCategories,
[cat1]: { ...itemCategories[cat1], [newCategory2]: [] }
});
setNewCategory2('');
toast.success('중분류가 추가되었습니다');
}}
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 중분류 목록 */}
<div className="ml-4 space-y-3">
{Object.keys(itemCategories[cat1] || {}).map(cat2 => (
<div key={cat2} className="border-l-2 pl-4">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium">{cat2}</h4>
<Button
variant="ghost"
size="sm"
onClick={() => {
const newCategories = { ...itemCategories };
delete newCategories[cat1][cat2];
setItemCategories(newCategories);
toast.success('삭제되었습니다');
}}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
{/* 소분류 추가 */}
<div className="ml-4 mb-2">
<div className="flex gap-2">
<Input
placeholder="소분류명 입력"
value={selectedCategory1 === cat1 && selectedCategory2 === cat2 ? newCategory3 : ''}
onChange={(e) => {
setSelectedCategory1(cat1);
setSelectedCategory2(cat2);
setNewCategory3(e.target.value);
}}
/>
<Button
size="sm"
onClick={() => {
if (!newCategory3.trim()) return toast.error('소분류명을 입력해주세요');
setItemCategories({
...itemCategories,
[cat1]: {
...itemCategories[cat1],
[cat2]: [...(itemCategories[cat1][cat2] || []), newCategory3]
}
});
setNewCategory3('');
toast.success('소분류가 추가되었습니다');
}}
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 소분류 목록 */}
<div className="ml-4 flex flex-wrap gap-2">
{(itemCategories[cat1]?.[cat2] || []).map((cat3, idx) => (
<Badge key={idx} variant="secondary" className="flex items-center gap-1">
{cat3}
<button
onClick={() => {
const newCategories = { ...itemCategories };
newCategories[cat1][cat2] = newCategories[cat1][cat2].filter(c => c !== cat3);
setItemCategories(newCategories);
toast.success('삭제되었습니다');
}}
className="ml-1 hover:text-red-500"
>
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* 사용자 정의 탭들 (품목분류 제외) */}
{customTabs.filter(tab => !tab.isDefault && tab.id !== 'categories').map(tab => (
<TabsContent key={tab.id} value={tab.id}>
<Card>
<CardHeader>
<CardTitle>{tab.label}</CardTitle>
<CardDescription> . .</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center py-12">
<FileText className="w-16 h-16 mx-auto text-gray-300 mb-4" />
<p className="text-muted-foreground mb-2">{tab.label} </p>
<p className="text-sm text-muted-foreground">
</p>
</div>
</CardContent>
</Card>
</TabsContent>
))}
</Tabs>
{/* 탭 관리 다이얼로그 */}
<Dialog open={isManageTabsDialogOpen} onOpenChange={setIsManageTabsDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
,
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
{customTabs.sort((a, b) => a.order - b.order).map((tab, index) => {
const Icon = getTabIcon(tab.icon);
return (
<div
key={tab.id}
className="flex items-center gap-3 p-3 border rounded-lg hover:bg-gray-50 transition-colors"
>
<div className="flex flex-col gap-1">
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => moveTabUp(tab.id)}
disabled={index === 0}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => moveTabDown(tab.id)}
disabled={index === customTabs.length - 1}
>
<ChevronDown className="h-4 w-4" />
</Button>
</div>
<Icon className="w-5 h-5 text-gray-500 flex-shrink-0" />
<div className="flex-1">
<div className="font-medium">{tab.label}</div>
<div className="text-xs text-gray-500">
{tab.isDefault ? '기본 탭' : '사용자 정의 탭'} : {tab.order}
</div>
</div>
<div className="flex gap-2">
{!tab.isDefault && (
<>
<Button
size="sm"
variant="outline"
onClick={() => handleEditTabFromManage(tab)}
>
<Edit className="h-4 w-4 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleDeleteTab(tab.id)}
>
<Trash2 className="h-4 w-4 mr-1 text-red-500" />
</Button>
</>
)}
{tab.isDefault && (
<Badge variant="secondary"> </Badge>
)}
</div>
</div>
);
})}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsManageTabsDialogOpen(false);
setIsAddTabDialogOpen(true);
}}
>
<Plus className="h-4 w-4 mr-2" />
</Button>
<Button onClick={() => setIsManageTabsDialogOpen(false)}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 탭 삭제 확인 다이얼로그 */}
<AlertDialog open={isDeleteTabDialogOpen} onOpenChange={setIsDeleteTabDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
"{customTabs.find(t => t.id === deletingTabId)?.label}" ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => {
setIsDeleteTabDialogOpen(false);
setDeletingTabId(null);
}}>
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDeleteTab}
className="bg-red-500 hover:bg-red-600"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 탭 추가/수정 다이얼로그 */}
<Dialog open={isAddTabDialogOpen} onOpenChange={(open) => {
setIsAddTabDialogOpen(open);
if (!open) {
setEditingTabId(null);
setNewTabLabel('');
}
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingTabId ? '탭 수정' : '탭 추가'}</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label> *</Label>
<Input
value={newTabLabel}
onChange={(e) => setNewTabLabel(e.target.value)}
placeholder="예: 거래처, 창고"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddTabDialogOpen(false)}></Button>
<Button onClick={editingTabId ? handleUpdateTab : handleAddTab}>
{editingTabId ? '수정' : '추가'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 속성 하위 탭 관리 다이얼로그 */}
<Dialog open={isManageAttributeTabsDialogOpen} onOpenChange={setIsManageAttributeTabsDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
,
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
{attributeSubTabs.sort((a, b) => a.order - b.order).map((tab, index) => {
const Icon = Settings;
return (
<div key={tab.id} className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50">
<div className="flex items-center gap-3">
<Icon className="h-5 w-5 text-gray-500" />
<div>
<div className="font-medium">{tab.label}</div>
<div className="text-sm text-gray-500">ID: {tab.key}</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => moveAttributeTabUp(tab.id)}
disabled={index === 0}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => moveAttributeTabDown(tab.id)}
disabled={index === attributeSubTabs.length - 1}
>
<ChevronDown className="h-4 w-4" />
</Button>
{!tab.isDefault && (
<>
<Button
size="sm"
variant="outline"
onClick={() => {
setEditingAttributeTabId(tab.id);
setNewAttributeTabLabel(tab.label);
setIsManageAttributeTabsDialogOpen(false);
setIsAddAttributeTabDialogOpen(true);
}}
>
<Edit className="h-4 w-4 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleDeleteAttributeTab(tab.id)}
>
<Trash2 className="h-4 w-4 mr-1 text-red-500" />
</Button>
</>
)}
{tab.isDefault && (
<Badge variant="secondary"> </Badge>
)}
</div>
</div>
);
})}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsManageAttributeTabsDialogOpen(false);
setIsAddAttributeTabDialogOpen(true);
}}
>
<Plus className="h-4 w-4 mr-2" />
</Button>
<Button onClick={() => setIsManageAttributeTabsDialogOpen(false)}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 속성 하위 탭 삭제 확인 다이얼로그 */}
<AlertDialog open={isDeleteAttributeTabDialogOpen} onOpenChange={setIsDeleteAttributeTabDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
"{attributeSubTabs.find(t => t.id === deletingAttributeTabId)?.label}" ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => {
setIsDeleteAttributeTabDialogOpen(false);
setDeletingAttributeTabId(null);
}}>
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDeleteAttributeTab}
className="bg-red-500 hover:bg-red-600"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 속성 하위 탭 추가/수정 다이얼로그 */}
<Dialog open={isAddAttributeTabDialogOpen} onOpenChange={(open) => {
setIsAddAttributeTabDialogOpen(open);
if (!open) {
setEditingAttributeTabId(null);
setNewAttributeTabLabel('');
}
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingAttributeTabId ? '속성 탭 수정' : '속성 탭 추가'}</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label> *</Label>
<Input
value={newAttributeTabLabel}
onChange={(e) => setNewAttributeTabLabel(e.target.value)}
placeholder="예: 색상, 규격"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddAttributeTabDialogOpen(false)}></Button>
<Button onClick={editingAttributeTabId ? handleUpdateAttributeTab : handleAddAttributeTab}>
{editingAttributeTabId ? '수정' : '추가'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 옵션 추가 다이얼로그 */}
<Dialog open={isOptionDialogOpen} onOpenChange={(open) => {
setIsOptionDialogOpen(open);
if (!open) {
setNewOptionValue('');
setNewOptionLabel('');
setNewOptionColumnValues({});
setNewOptionInputType('textbox');
setNewOptionRequired(false);
setNewOptionOptions('');
setNewOptionPlaceholder('');
setNewOptionDefaultValue('');
}
}}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
{editingOptionType === 'unit' && '단위'}
{editingOptionType === 'material' && '재질'}
{editingOptionType === 'surface' && '표면처리'}
{editingOptionType && !['unit', 'material', 'surface'].includes(editingOptionType) &&
(attributeSubTabs.find(t => t.key === editingOptionType)?.label || '속성')}
{' '} . .
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 기본 정보 */}
<div className="border rounded-lg p-4 space-y-3 bg-blue-50">
<h4 className="font-medium text-sm"> </h4>
<div className="grid grid-cols-2 gap-3">
<div>
<Label> (Value) *</Label>
<Input
value={newOptionValue}
onChange={(e) => setNewOptionValue(e.target.value)}
placeholder="예: kg, stainless"
/>
</div>
<div>
<Label> () *</Label>
<Input
value={newOptionLabel}
onChange={(e) => setNewOptionLabel(e.target.value)}
placeholder="예: 킬로그램, 스테인리스"
/>
</div>
</div>
</div>
{/* 입력 방식 설정 */}
<div className="border rounded-lg p-4 space-y-3">
<h4 className="font-medium text-sm"> </h4>
<div>
<Label> *</Label>
<Select value={newOptionInputType} onValueChange={(v: any) => setNewOptionInputType(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="textbox"></SelectItem>
<SelectItem value="number"></SelectItem>
<SelectItem value="dropdown"></SelectItem>
<SelectItem value="checkbox"></SelectItem>
<SelectItem value="date"></SelectItem>
<SelectItem value="textarea"></SelectItem>
</SelectContent>
</Select>
</div>
{newOptionInputType === 'dropdown' && (
<div>
<Label className="flex items-center gap-1">
<span className="text-red-500">*</span>
</Label>
<Input
value={newOptionOptions}
onChange={(e) => setNewOptionOptions(e.target.value)}
placeholder="옵션1, 옵션2, 옵션3 (쉼표로 구분)"
/>
<p className="text-xs text-muted-foreground mt-1">
</p>
</div>
)}
<div>
<Label> ()</Label>
<Input
value={newOptionPlaceholder}
onChange={(e) => setNewOptionPlaceholder(e.target.value)}
placeholder="예: 값을 입력하세요"
/>
</div>
<div>
<Label> ()</Label>
<Input
value={newOptionDefaultValue}
onChange={(e) => setNewOptionDefaultValue(e.target.value)}
placeholder={
newOptionInputType === 'checkbox' ? 'true 또는 false' :
newOptionInputType === 'number' ? '숫자' :
'기본값'
}
/>
</div>
<div className="flex items-center gap-2">
<Switch checked={newOptionRequired} onCheckedChange={setNewOptionRequired} />
<Label> </Label>
</div>
</div>
{/* 추가 칼럼 (기존 칼럼 시스템과 호환) */}
{editingOptionType && attributeColumns[editingOptionType]?.length > 0 && (
<div className="border rounded-lg p-4 space-y-3">
<h4 className="font-medium text-sm"> </h4>
<div className="grid grid-cols-2 gap-4">
{attributeColumns[editingOptionType].map((column) => (
<div key={column.id}>
<Label className="flex items-center gap-1">
{column.name}
{column.required && <span className="text-red-500">*</span>}
</Label>
<Input
type={column.type === 'number' ? 'number' : 'text'}
value={newOptionColumnValues[column.key] || ''}
onChange={(e) => setNewOptionColumnValues({
...newOptionColumnValues,
[column.key]: e.target.value
})}
placeholder={`${column.name} 입력`}
/>
</div>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsOptionDialogOpen(false)}></Button>
<Button onClick={handleAddOption}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 칼럼 관리 다이얼로그 */}
<Dialog open={isColumnManageDialogOpen} onOpenChange={setIsColumnManageDialogOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
{managingColumnType === 'units' && '단위'}
{managingColumnType === 'materials' && '재질'}
{managingColumnType === 'surface' && '표면처리'}
{managingColumnType && !['units', 'materials', 'surface'].includes(managingColumnType) &&
(attributeSubTabs.find(t => t.key === managingColumnType)?.label || '속성')}
{' '} (: 규격 // )
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 기존 칼럼 목록 */}
{managingColumnType && attributeColumns[managingColumnType]?.length > 0 && (
<div className="border rounded-lg p-4">
<h4 className="font-medium mb-3"> </h4>
<div className="space-y-2">
{attributeColumns[managingColumnType].map((column, idx) => (
<div key={column.id} className="flex items-center justify-between p-3 bg-gray-50 rounded">
<div className="flex items-center gap-3">
<Badge variant="outline">{idx + 1}</Badge>
<div>
<p className="font-medium">{column.name}</p>
<p className="text-xs text-muted-foreground">
: {column.key} | : {column.type === 'text' ? '텍스트' : '숫자'}
{column.required && ' | 필수'}
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (managingColumnType) {
setAttributeColumns(prev => ({
...prev,
[managingColumnType]: prev[managingColumnType]?.filter(c => c.id !== column.id) || []
}));
}
}}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
))}
</div>
</div>
)}
{/* 새 칼럼 추가 폼 */}
<div className="border rounded-lg p-4 space-y-3">
<h4 className="font-medium"> </h4>
<div className="grid grid-cols-2 gap-3">
<div>
<Label> *</Label>
<Input
value={newColumnName}
onChange={(e) => setNewColumnName(e.target.value)}
placeholder="예: 속성, 값, 단위"
/>
</div>
<div>
<Label> () *</Label>
<Input
value={newColumnKey}
onChange={(e) => setNewColumnKey(e.target.value)}
placeholder="예: property, value, unit"
/>
</div>
<div>
<Label></Label>
<Select value={newColumnType} onValueChange={(value: 'text' | 'number') => setNewColumnType(value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="number"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 pt-6">
<Switch
checked={newColumnRequired}
onCheckedChange={setNewColumnRequired}
/>
<Label> </Label>
</div>
</div>
<Button
size="sm"
className="w-full"
onClick={() => {
if (!newColumnName.trim() || !newColumnKey.trim()) {
toast.error('칼럼명과 키를 입력해주세요');
return;
}
if (managingColumnType) {
const newColumn: OptionColumn = {
id: `col-${Date.now()}`,
name: newColumnName,
key: newColumnKey,
type: newColumnType,
required: newColumnRequired
};
setAttributeColumns(prev => ({
...prev,
[managingColumnType]: [...(prev[managingColumnType] || []), newColumn]
}));
// 입력 필드 초기화
setNewColumnName('');
setNewColumnKey('');
setNewColumnType('text');
setNewColumnRequired(false);
toast.success('칼럼이 추가되었습니다');
}
}}
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
<DialogFooter>
<Button onClick={() => setIsColumnManageDialogOpen(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 절대경로 편집 다이얼로그 */}
<Dialog open={editingPathPageId !== null} onOpenChange={(open) => !open && setEditingPathPageId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> (: //)</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label> *</Label>
<Input
value={editingAbsolutePath}
onChange={(e) => setEditingAbsolutePath(e.target.value)}
placeholder="/제품관리/제품등록"
/>
<p className="text-xs text-gray-500 mt-1">(/) , </p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingPathPageId(null)}></Button>
<Button onClick={() => {
if (!editingAbsolutePath.trim()) {
toast.error('절대경로를 입력해주세요');
return;
}
if (!editingAbsolutePath.startsWith('/')) {
toast.error('절대경로는 슬래시(/)로 시작해야 합니다');
return;
}
if (editingPathPageId) {
updateItemPage(editingPathPageId, { absolutePath: editingAbsolutePath });
trackChange('pages', editingPathPageId, 'update', { absolutePath: editingAbsolutePath });
setEditingPathPageId(null);
toast.success('절대경로가 수정되었습니다 (저장 필요)');
}
}}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 섹션 추가 다이얼로그 */}
<Dialog open={isPageDialogOpen} onOpenChange={setIsPageDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label> *</Label>
<Input
value={newPageName}
onChange={(e) => setNewPageName(e.target.value)}
placeholder="예: 품목 등록"
/>
</div>
<div>
<Label> *</Label>
<Select value={newPageItemType} onValueChange={(v: any) => setNewPageItemType(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ITEM_TYPE_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsPageDialogOpen(false)}></Button>
<Button onClick={handleAddPage}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 섹션 추가 다이얼로그 */}
<Dialog open={isSectionDialogOpen} onOpenChange={(open) => {
setIsSectionDialogOpen(open);
if (!open) setNewSectionType('fields');
}}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{newSectionType === 'bom' ? 'BOM 섹션' : '일반 섹션'} </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>
<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>
</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>
</DialogContent>
</Dialog>
{/* 항목 추가/수정 다이얼로그 - 데스크톱 */}
{!isMobile && (
<Dialog open={isFieldDialogOpen} onOpenChange={(open) => {
setIsFieldDialogOpen(open);
if (!open) {
setEditingFieldId(null);
setFieldInputMode('custom');
setShowMasterFieldList(false);
setSelectedMasterFieldId('');
setTextboxColumns([]);
setNewFieldConditionEnabled(false);
setNewFieldConditionTargetType('field');
setNewFieldConditionFields([]);
setNewFieldConditionSections([]);
setTempConditionFieldKey('');
setTempConditionValue('');
}
}}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col p-0">
<DialogHeader className="sticky top-0 bg-white z-10 px-6 py-4 border-b">
<DialogTitle>{editingFieldId ? '항목 수정' : '항목 추가'}</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* 입력 모드 선택 (편집 시에는 표시 안 함) */}
{!editingFieldId && (
<div className="flex gap-2 p-1 bg-gray-100 rounded">
<Button
variant={fieldInputMode === 'custom' ? 'default' : 'ghost'}
size="sm"
onClick={() => setFieldInputMode('custom')}
className="flex-1"
>
</Button>
<Button
variant={fieldInputMode === 'master' ? 'default' : 'ghost'}
size="sm"
onClick={() => {
setFieldInputMode('master');
setShowMasterFieldList(true);
}}
className="flex-1"
>
</Button>
</div>
)}
{/* 마스터 항목 목록 */}
{fieldInputMode === 'master' && !editingFieldId && 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 === 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);
setNewFieldDescription(field.description || '');
setNewFieldOptions(field.property.options?.join(', ') || '');
if (field.property.multiColumn && field.property.columnNames) {
setTextboxColumns(
field.property.columnNames.map((name, idx) => ({
id: `col-${idx}`,
name,
key: `column${idx + 1}`
}))
);
}
}}
>
<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>
<Badge variant="outline" className="text-xs">
{INPUT_TYPE_OPTIONS.find(o => o.value === field.property.inputType)?.label}
</Badge>
{field.property.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 && (
<div className="flex gap-1 mt-1">
{field.category.map((cat, idx) => (
<Badge key={idx} variant="secondary" className="text-xs">
{cat}
</Badge>
))}
</div>
)}
</div>
{selectedMasterFieldId === field.id && (
<Check className="h-5 w-5 text-blue-600" />
)}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* 직접 입력 폼 */}
{(fieldInputMode === 'custom' || editingFieldId) && (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label> *</Label>
<Input
value={newFieldName}
onChange={(e) => setNewFieldName(e.target.value)}
placeholder="예: 품목명"
/>
</div>
<div>
<Label> *</Label>
<Input
value={newFieldKey}
onChange={(e) => setNewFieldKey(e.target.value)}
placeholder="예: itemName"
/>
</div>
</div>
<div>
<Label> *</Label>
<Select value={newFieldInputType} onValueChange={(v: any) => setNewFieldInputType(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{INPUT_TYPE_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{newFieldInputType === 'dropdown' && (
<div>
<Label> </Label>
<Input
value={newFieldOptions}
onChange={(e) => setNewFieldOptions(e.target.value)}
placeholder="옵션1, 옵션2, 옵션3 (쉼표로 구분)"
/>
</div>
)}
{/* 텍스트박스 컬럼 관리 */}
{newFieldInputType === 'textbox' && (
<div className="border rounded p-3 space-y-3">
<div className="flex items-center justify-between">
<Label> </Label>
<Button
variant="outline"
size="sm"
onClick={() => {
setIsColumnDialogOpen(true);
setEditingColumnId(null);
setColumnName('');
setColumnKey('');
}}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
{textboxColumns.length > 0 ? (
<div className="space-y-2">
{textboxColumns.map((col, index) => (
<div key={col.id} className="flex items-center gap-2 p-2 bg-gray-50 rounded">
<span className="text-sm flex-1">
{index + 1}. {col.name} ({col.key})
</span>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingColumnId(col.id);
setColumnName(col.name);
setColumnKey(col.key);
setIsColumnDialogOpen(true);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setTextboxColumns(prev => prev.filter(c => c.id !== col.id));
toast.success('컬럼이 삭제되었습니다');
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-2">
</p>
)}
</div>
)}
<div>
<Label> ()</Label>
<Textarea
value={newFieldDescription}
onChange={(e) => setNewFieldDescription(e.target.value)}
placeholder="항목에 대한 설명"
/>
</div>
<div className="flex items-center gap-2">
<Switch checked={newFieldRequired} onCheckedChange={setNewFieldRequired} />
<Label> </Label>
</div>
</>
)}
{/* 조건부 표시 설정 - 모든 입력방식에서 사용 가능 */}
{(fieldInputMode === 'custom' || editingFieldId) && (
<div className="border-t pt-4 space-y-3">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Switch checked={newFieldConditionEnabled} onCheckedChange={setNewFieldConditionEnabled} />
<Label className="text-base"> </Label>
</div>
<p className="text-xs text-muted-foreground pl-8">
/ ( )
</p>
</div>
{newFieldConditionEnabled && selectedSectionForField && (
<div className="space-y-4 pl-6 pt-3 border-l-2 border-blue-200">
{/* 대상 타입 선택 */}
<div className="space-y-2 bg-blue-50 p-3 rounded">
<Label className="text-sm font-semibold"> ?</Label>
<div className="flex gap-4 pl-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
checked={newFieldConditionTargetType === 'field'}
onChange={() => setNewFieldConditionTargetType('field')}
className="cursor-pointer"
/>
<span className="text-sm"> ( )</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
checked={newFieldConditionTargetType === 'section'}
onChange={() => setNewFieldConditionTargetType('section')}
className="cursor-pointer"
/>
<span className="text-sm"> </span>
</label>
</div>
</div>
{/* 일반항목용 조건 설정 */}
{newFieldConditionTargetType === 'field' && (
<div className="space-y-3">
<div className="bg-yellow-50 p-3 rounded border border-yellow-200">
<p className="text-xs text-yellow-800">
<strong>💡 :</strong><br/>
1. (: "제품", "원자재") <br/>
2. , <br/>
3. "조건부 표시" <br/>
4.
</p>
</div>
<div>
<Label className="text-sm font-semibold"> ( )</Label>
<p className="text-xs text-muted-foreground mt-1">
{newFieldInputType === 'dropdown' && '드롭다운 옵션값을 입력하세요'}
{newFieldInputType === 'checkbox' && '체크박스 상태값(true/false)을 입력하세요'}
{newFieldInputType === 'textbox' && '텍스트 값을 입력하세요 (예: "제품", "부품")'}
{newFieldInputType === 'number' && '숫자 값을 입력하세요 (예: 100, 200)'}
{newFieldInputType === 'date' && '날짜 값을 입력하세요 (예: 2025-01-01)'}
{newFieldInputType === 'textarea' && '텍스트 값을 입력하세요'}
</p>
</div>
{/* 추가된 조건 목록 */}
{newFieldConditionFields.length > 0 && (
<div className="space-y-2">
{newFieldConditionFields.map((condition, index) => (
<div key={index} className="flex items-center gap-2 p-3 bg-blue-50 rounded border border-blue-200">
<div className="flex-1">
<span className="text-sm font-medium text-blue-900">
"{condition.expectedValue}"
</span>
<p className="text-xs text-blue-700 mt-1">
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setNewFieldConditionFields(prev => prev.filter((_, i) => i !== index));
toast.success('조건이 제거되었습니다.');
}}
className="h-8 w-8 p-0"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
{/* 새 조건 추가 */}
<div className="flex gap-2">
<Input
value={tempConditionValue}
onChange={(e) => setTempConditionValue(e.target.value)}
placeholder={
newFieldInputType === 'dropdown' ? "예: 제품, 부품, 원자재..." :
newFieldInputType === 'checkbox' ? "예: true 또는 false" :
newFieldInputType === 'number' ? "예: 100" :
newFieldInputType === 'date' ? "예: 2025-01-01" :
"예: 값을 입력하세요"
}
className="flex-1"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
if (tempConditionValue) {
setNewFieldConditionFields(prev => [...prev, {
fieldKey: newFieldKey, // 현재 항목 자신의 키를 사용
expectedValue: tempConditionValue
}]);
setTempConditionValue('');
toast.success('조건값이 추가되었습니다.');
} else {
toast.error('조건값을 입력해주세요.');
}
}}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
)}
{/* 섹션용 조건 설정 */}
{newFieldConditionTargetType === 'section' && selectedPage && (
<div className="space-y-3">
<div className="bg-yellow-50 p-3 rounded border border-yellow-200">
<p className="text-xs text-yellow-800">
<strong>💡 :</strong><br/>
1. (: "제품", "부품")<br/>
2. <br/>
3. /
</p>
</div>
{/* 조건값 추가 */}
<div>
<Label className="text-sm font-semibold"> </Label>
<div className="flex gap-2 mt-2">
<Input
value={tempConditionValue}
onChange={(e) => setTempConditionValue(e.target.value)}
placeholder={
newFieldInputType === 'dropdown' ? "예: 제품, 부품, 원자재..." :
newFieldInputType === 'checkbox' ? "예: true 또는 false" :
newFieldInputType === 'number' ? "예: 100" :
newFieldInputType === 'date' ? "예: 2025-01-01" :
"예: 값을 입력하세요"
}
className="flex-1"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
if (tempConditionValue) {
if (!newFieldConditionFields.find(f => f.expectedValue === tempConditionValue)) {
setNewFieldConditionFields(prev => [...prev, {
fieldKey: newFieldKey,
expectedValue: tempConditionValue
}]);
setTempConditionValue('');
toast.success('조건값이 추가되었습니다.');
} else {
toast.error('이미 추가된 조건값입니다.');
}
} else {
toast.error('조건값을 입력해주세요.');
}
}}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
{/* 조건값 목록 표시 */}
{newFieldConditionFields.length > 0 && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground"> :</Label>
<div className="flex flex-wrap gap-2">
{newFieldConditionFields.map((condition, index) => (
<Badge key={index} variant="secondary" className="gap-1">
{condition.expectedValue}
<button
onClick={() => {
setNewFieldConditionFields(prev => prev.filter((_, i) => i !== index));
toast.success('조건값이 제거되었습니다.');
}}
className="ml-1 hover:text-red-500"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
</div>
)}
{/* 섹션 선택 */}
<div className="space-y-2">
<Label className="text-sm font-semibold"> :</Label>
<p className="text-xs text-muted-foreground">
</p>
<div className="space-y-2 max-h-60 overflow-y-auto">
{selectedPage.sections
.filter(section => section.type !== 'bom')
.map(section => (
<label key={section.id} className="flex items-center gap-2 p-2 bg-muted rounded cursor-pointer hover:bg-muted/80">
<input
type="checkbox"
checked={newFieldConditionSections.includes(section.id)}
onChange={(e) => {
if (e.target.checked) {
setNewFieldConditionSections(prev => [...prev, section.id]);
} else {
setNewFieldConditionSections(prev => prev.filter(id => id !== section.id));
}
}}
className="cursor-pointer"
/>
<span className="flex-1 text-sm">{section.title}</span>
</label>
))}
</div>
{newFieldConditionSections.length > 0 && (
<div className="text-sm text-blue-600 font-medium mt-2">
{newFieldConditionSections.length}
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
<DialogFooter className="sticky bottom-0 bg-white z-10 px-6 py-4 border-t">
<Button variant="outline" onClick={() => setIsFieldDialogOpen(false)}></Button>
<Button onClick={handleAddField}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{/* 항목 추가/수정 다이얼로그 - 모바일 (바텀시트) */}
{isMobile && (
<Drawer open={isFieldDialogOpen} onOpenChange={(open) => {
setIsFieldDialogOpen(open);
if (!open) {
setEditingFieldId(null);
setFieldInputMode('custom');
setShowMasterFieldList(false);
setSelectedMasterFieldId('');
setTextboxColumns([]);
setNewFieldConditionEnabled(false);
setNewFieldConditionTargetType('field');
setNewFieldConditionFields([]);
setNewFieldConditionSections([]);
setTempConditionFieldKey('');
setTempConditionValue('');
}
}}>
<DrawerContent className="max-h-[90vh] flex flex-col">
<DrawerHeader className="px-4 py-3 border-b">
<DrawerTitle>{editingFieldId ? '항목 수정' : '항목 추가'}</DrawerTitle>
<DrawerDescription>
</DrawerDescription>
</DrawerHeader>
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
{/* 입력 모드 선택 (편집 시에는 표시 안 함) */}
{!editingFieldId && (
<div className="flex gap-2 p-1 bg-gray-100 rounded">
<Button
variant={fieldInputMode === 'custom' ? 'default' : 'ghost'}
size="sm"
onClick={() => setFieldInputMode('custom')}
className="flex-1"
>
</Button>
<Button
variant={fieldInputMode === 'master' ? 'default' : 'ghost'}
size="sm"
onClick={() => {
setFieldInputMode('master');
setShowMasterFieldList(true);
}}
className="flex-1"
>
</Button>
</div>
)}
{/* 마스터 항목 목록 */}
{fieldInputMode === 'master' && !editingFieldId && 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 === 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);
setNewFieldDescription(field.description || '');
setNewFieldOptions(field.property.options?.join(', ') || '');
if (field.property.multiColumn && field.property.columnNames) {
setTextboxColumns(
field.property.columnNames.map((name, idx) => ({
id: `col-${idx}`,
name,
key: `column${idx + 1}`
}))
);
}
}}
>
<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>
<Badge variant="outline" className="text-xs">
{INPUT_TYPE_OPTIONS.find(o => o.value === field.property.inputType)?.label}
</Badge>
{field.property.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 && (
<div className="flex gap-1 mt-1">
{field.category.map((cat, idx) => (
<Badge key={idx} variant="secondary" className="text-xs">
{cat}
</Badge>
))}
</div>
)}
</div>
{selectedMasterFieldId === field.id && (
<Check className="h-5 w-5 text-blue-600" />
)}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* 직접 입력 폼 */}
{(fieldInputMode === 'custom' || editingFieldId) && (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label> *</Label>
<Input
value={newFieldName}
onChange={(e) => setNewFieldName(e.target.value)}
placeholder="예: 품목명"
/>
</div>
<div>
<Label> *</Label>
<Input
value={newFieldKey}
onChange={(e) => setNewFieldKey(e.target.value)}
placeholder="예: itemName"
/>
</div>
</div>
<div>
<Label> *</Label>
<Select value={newFieldInputType} onValueChange={(v: any) => setNewFieldInputType(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{INPUT_TYPE_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{newFieldInputType === 'dropdown' && (
<div>
<Label> </Label>
<Input
value={newFieldOptions}
onChange={(e) => setNewFieldOptions(e.target.value)}
placeholder="옵션1, 옵션2, 옵션3 (쉼표로 구분)"
/>
</div>
)}
{/* 텍스트박스 컬럼 관리 */}
{newFieldInputType === 'textbox' && (
<div className="border rounded p-3 space-y-3">
<div className="flex items-center justify-between">
<Label> </Label>
<Button
variant="outline"
size="sm"
onClick={() => {
setIsColumnDialogOpen(true);
setEditingColumnId(null);
setColumnName('');
setColumnKey('');
}}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
{textboxColumns.length > 0 ? (
<div className="space-y-2">
{textboxColumns.map((col, index) => (
<div key={col.id} className="flex items-center gap-2 p-2 bg-gray-50 rounded">
<span className="text-sm flex-1">
{index + 1}. {col.name} ({col.key})
</span>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingColumnId(col.id);
setColumnName(col.name);
setColumnKey(col.key);
setIsColumnDialogOpen(true);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setTextboxColumns(prev => prev.filter(c => c.id !== col.id));
toast.success('컬럼이 삭제되었습니다');
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-2">
</p>
)}
</div>
)}
<div>
<Label> ()</Label>
<Textarea
value={newFieldDescription}
onChange={(e) => setNewFieldDescription(e.target.value)}
placeholder="항목에 대한 설명"
/>
</div>
<div className="flex items-center gap-2">
<Switch checked={newFieldRequired} onCheckedChange={setNewFieldRequired} />
<Label> </Label>
</div>
</>
)}
{/* 조건부 표시 설정 - 모든 입력방식에서 사용 가능 */}
{(fieldInputMode === 'custom' || editingFieldId) && (
<div className="border-t pt-4 space-y-3">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Switch checked={newFieldConditionEnabled} onCheckedChange={setNewFieldConditionEnabled} />
<Label className="text-base"> </Label>
</div>
<p className="text-xs text-muted-foreground pl-8">
/ ( )
</p>
</div>
{newFieldConditionEnabled && selectedSectionForField && (
<div className="space-y-4 pl-6 pt-3 border-l-2 border-blue-200">
{/* 대상 타입 선택 */}
<div className="space-y-2 bg-blue-50 p-3 rounded">
<Label className="text-sm font-semibold"> ?</Label>
<div className="flex gap-4 pl-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
checked={newFieldConditionTargetType === 'field'}
onChange={() => setNewFieldConditionTargetType('field')}
className="cursor-pointer"
/>
<span className="text-sm"> ( )</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
checked={newFieldConditionTargetType === 'section'}
onChange={() => setNewFieldConditionTargetType('section')}
className="cursor-pointer"
/>
<span className="text-sm"> </span>
</label>
</div>
</div>
{/* 일반항목용 조건 설정 */}
{newFieldConditionTargetType === 'field' && (
<div className="space-y-3">
<div className="bg-yellow-50 p-3 rounded border border-yellow-200">
<p className="text-xs text-yellow-800">
<strong>💡 :</strong><br/>
1. (: "제품", "원자재") <br/>
2. , <br/>
3. "조건부 표시" <br/>
4.
</p>
</div>
<div>
<Label className="text-sm font-semibold"> ( )</Label>
<p className="text-xs text-muted-foreground mt-1">
{newFieldInputType === 'dropdown' && '드롭다운 옵션값을 입력하세요'}
{newFieldInputType === 'checkbox' && '체크박스 상태값(true/false)을 입력하세요'}
{newFieldInputType === 'textbox' && '텍스트 값을 입력하세요 (예: "제품", "부품")'}
{newFieldInputType === 'number' && '숫자 값을 입력하세요 (예: 100, 200)'}
{newFieldInputType === 'date' && '날짜 값을 입력하세요 (예: 2025-01-01)'}
{newFieldInputType === 'textarea' && '텍스트 값을 입력하세요'}
</p>
</div>
{/* 추가된 조건 목록 */}
{newFieldConditionFields.length > 0 && (
<div className="space-y-2">
{newFieldConditionFields.map((condition, index) => (
<div key={index} className="flex items-center gap-2 p-3 bg-blue-50 rounded border border-blue-200">
<div className="flex-1">
<span className="text-sm font-medium text-blue-900">
"{condition.expectedValue}"
</span>
<p className="text-xs text-blue-700 mt-1">
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setNewFieldConditionFields(prev => prev.filter((_, i) => i !== index));
toast.success('조건이 제거되었습니다.');
}}
className="h-8 w-8 p-0"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
{/* 새 조건 추가 */}
<div className="flex gap-2">
<Input
value={tempConditionValue}
onChange={(e) => setTempConditionValue(e.target.value)}
placeholder={
newFieldInputType === 'dropdown' ? "예: 제품, 부품, 원자재..." :
newFieldInputType === 'checkbox' ? "예: true 또는 false" :
newFieldInputType === 'number' ? "예: 100" :
newFieldInputType === 'date' ? "예: 2025-01-01" :
"예: 값을 입력하세요"
}
className="flex-1"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
if (tempConditionValue) {
setNewFieldConditionFields(prev => [...prev, {
fieldKey: newFieldKey, // 현재 항목 자신의 키를 사용
expectedValue: tempConditionValue
}]);
setTempConditionValue('');
toast.success('조건값이 추가되었습니다.');
} else {
toast.error('조건값을 입력해주세요.');
}
}}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
)}
{/* 섹션용 조건 설정 */}
{newFieldConditionTargetType === 'section' && selectedPage && (
<div className="space-y-3">
<div className="bg-yellow-50 p-3 rounded border border-yellow-200">
<p className="text-xs text-yellow-800">
<strong>💡 :</strong><br/>
1. (: "제품", "부품")<br/>
2. <br/>
3. /
</p>
</div>
{/* 조건값 추가 */}
<div>
<Label className="text-sm font-semibold"> </Label>
<div className="flex gap-2 mt-2">
<Input
value={tempConditionValue}
onChange={(e) => setTempConditionValue(e.target.value)}
placeholder={
newFieldInputType === 'dropdown' ? "예: 제품, 부품, 원자재..." :
newFieldInputType === 'checkbox' ? "예: true 또는 false" :
newFieldInputType === 'number' ? "예: 100" :
newFieldInputType === 'date' ? "예: 2025-01-01" :
"예: 값을 입력하세요"
}
className="flex-1"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
if (tempConditionValue) {
if (!newFieldConditionFields.find(f => f.expectedValue === tempConditionValue)) {
setNewFieldConditionFields(prev => [...prev, {
fieldKey: newFieldKey,
expectedValue: tempConditionValue
}]);
setTempConditionValue('');
toast.success('조건값이 추가되었습니다.');
} else {
toast.error('이미 추가된 조건값입니다.');
}
} else {
toast.error('조건값을 입력해주세요.');
}
}}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
{/* 조건값 목록 표시 */}
{newFieldConditionFields.length > 0 && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground"> :</Label>
<div className="flex flex-wrap gap-2">
{newFieldConditionFields.map((condition, index) => (
<Badge key={index} variant="secondary" className="gap-1">
{condition.expectedValue}
<button
onClick={() => {
setNewFieldConditionFields(prev => prev.filter((_, i) => i !== index));
toast.success('조건값이 제거되었습니다.');
}}
className="ml-1 hover:text-red-500"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
</div>
)}
{/* 섹션 선택 */}
<div className="space-y-2">
<Label className="text-sm font-semibold"> :</Label>
<p className="text-xs text-muted-foreground">
</p>
<div className="space-y-2 max-h-60 overflow-y-auto">
{selectedPage.sections
.filter(section => section.type !== 'bom')
.map(section => (
<label key={section.id} className="flex items-center gap-2 p-2 bg-muted rounded cursor-pointer hover:bg-muted/80">
<input
type="checkbox"
checked={newFieldConditionSections.includes(section.id)}
onChange={(e) => {
if (e.target.checked) {
setNewFieldConditionSections(prev => [...prev, section.id]);
} else {
setNewFieldConditionSections(prev => prev.filter(id => id !== section.id));
}
}}
className="cursor-pointer"
/>
<span className="flex-1 text-sm">{section.title}</span>
</label>
))}
</div>
{newFieldConditionSections.length > 0 && (
<div className="text-sm text-blue-600 font-medium mt-2">
{newFieldConditionSections.length}
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
<DrawerFooter className="px-4 py-3 border-t flex-row gap-2">
<Button variant="outline" onClick={() => setIsFieldDialogOpen(false)} className="flex-1"></Button>
<Button onClick={handleAddField} className="flex-1"></Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
)}
{/* 텍스트박스 컬럼 추가/수정 다이얼로그 */}
<Dialog open={isColumnDialogOpen} onOpenChange={setIsColumnDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingColumnId ? '컬럼 수정' : '컬럼 추가'}</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label> *</Label>
<Input
value={columnName}
onChange={(e) => setColumnName(e.target.value)}
placeholder="예: 가로"
/>
</div>
<div>
<Label> *</Label>
<Input
value={columnKey}
onChange={(e) => setColumnKey(e.target.value)}
placeholder="예: width"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsColumnDialogOpen(false)}></Button>
<Button onClick={() => {
if (!columnName.trim() || !columnKey.trim()) {
return toast.error('모든 필드를 입력해주세요');
}
if (editingColumnId) {
// 수정
setTextboxColumns(prev => prev.map(col =>
col.id === editingColumnId
? { ...col, name: columnName, key: columnKey }
: col
));
toast.success('컬럼이 수정되었습니다');
} else {
// 추가
setTextboxColumns(prev => [...prev, {
id: `col-${Date.now()}`,
name: columnName,
key: columnKey
}]);
toast.success('컬럼이 추가되었습니다');
}
setIsColumnDialogOpen(false);
setEditingColumnId(null);
setColumnName('');
setColumnKey('');
}}>
{editingColumnId ? '수정' : '추가'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 마스터 항목 추가/수정 다이얼로그 */}
<Dialog open={isMasterFieldDialogOpen} onOpenChange={(open) => {
setIsMasterFieldDialogOpen(open);
if (!open) {
setEditingMasterFieldId(null);
setNewMasterFieldName('');
setNewMasterFieldKey('');
setNewMasterFieldInputType('textbox');
setNewMasterFieldRequired(false);
setNewMasterFieldCategory('공통');
setNewMasterFieldDescription('');
setNewMasterFieldOptions('');
setNewMasterFieldAttributeType('custom');
setNewMasterFieldMultiColumn(false);
setNewMasterFieldColumnCount(2);
setNewMasterFieldColumnNames(['컬럼1', '컬럼2']);
}
}}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingMasterFieldId ? '마스터 항목 수정' : '마스터 항목 추가'}</DialogTitle>
<DialogDescription>
릿
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label> *</Label>
<Input
value={newMasterFieldName}
onChange={(e) => setNewMasterFieldName(e.target.value)}
placeholder="예: 품목명"
/>
</div>
<div>
<Label> *</Label>
<Input
value={newMasterFieldKey}
onChange={(e) => setNewMasterFieldKey(e.target.value)}
placeholder="예: itemName"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label> *</Label>
<Select value={newMasterFieldInputType} onValueChange={(v: any) => setNewMasterFieldInputType(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{INPUT_TYPE_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
<Switch checked={newMasterFieldRequired} onCheckedChange={setNewMasterFieldRequired} />
<Label> </Label>
</div>
<div>
<Label></Label>
<Textarea
value={newMasterFieldDescription}
onChange={(e) => setNewMasterFieldDescription(e.target.value)}
placeholder="항목에 대한 설명"
/>
<p className="text-xs text-gray-500 mt-1">* []</p>
</div>
{(newMasterFieldInputType === 'textbox' || newMasterFieldInputType === 'textarea') && (
<div className="space-y-4 p-4 border rounded-lg bg-gray-50">
<div className="flex items-center gap-2">
<Switch
checked={newMasterFieldMultiColumn}
onCheckedChange={(checked) => {
setNewMasterFieldMultiColumn(checked);
if (!checked) {
setNewMasterFieldColumnCount(2);
setNewMasterFieldColumnNames(['컬럼1', '컬럼2']);
}
}}
/>
<Label> </Label>
</div>
<p className="text-xs text-gray-500">
(: 규격 - , , )
</p>
{newMasterFieldMultiColumn && (
<div className="space-y-4 pt-4 border-t">
<div>
<Label> </Label>
<Input
type="number"
min="2"
max="10"
value={newMasterFieldColumnCount}
onChange={(e) => {
const count = parseInt(e.target.value) || 2;
setNewMasterFieldColumnCount(count);
// 컬럼 개수에 맞게 이름 배열 조정
const newNames = Array.from({ length: count }, (_, i) =>
newMasterFieldColumnNames[i] || `컬럼${i + 1}`
);
setNewMasterFieldColumnNames(newNames);
}}
placeholder="컬럼 개수 (2~10)"
/>
</div>
<div>
<Label> </Label>
<div className="space-y-2 mt-2">
{Array.from({ length: newMasterFieldColumnCount }, (_, i) => (
<Input
key={i}
value={newMasterFieldColumnNames[i] || ''}
onChange={(e) => {
const newNames = [...newMasterFieldColumnNames];
newNames[i] = e.target.value;
setNewMasterFieldColumnNames(newNames);
}}
placeholder={`${i + 1}번째 컬럼 이름`}
/>
))}
</div>
<p className="text-xs text-gray-500 mt-2">
예시: 가로, , / , / ,
</p>
</div>
</div>
)}
</div>
)}
{newMasterFieldInputType === 'dropdown' && (
<div className="space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<Label> </Label>
{newMasterFieldAttributeType !== 'custom' && (
<Badge variant="secondary" className="text-xs">
{newMasterFieldAttributeType === 'unit' ? '단위' :
newMasterFieldAttributeType === 'material' ? '재질' : '표면처리'}
</Badge>
)}
</div>
<Textarea
value={newMasterFieldOptions}
onChange={(e) => setNewMasterFieldOptions(e.target.value)}
placeholder="제품,부품,원자재 (쉼표로 구분)"
disabled={newMasterFieldAttributeType !== 'custom'}
className="min-h-[80px]"
/>
<p className="text-xs text-gray-500 mt-1">
{newMasterFieldAttributeType === 'custom'
? '쉼표(,)로 구분하여 입력하세요'
: '속성 탭에서 옵션을 추가/삭제하면 자동으로 반영됩니다'
}
</p>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsMasterFieldDialogOpen(false)}></Button>
<Button onClick={editingMasterFieldId ? handleUpdateMasterField : handleAddMasterField}>
{editingMasterFieldId ? '수정' : '추가'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 섹션 추가/수정 다이얼로그 */}
<Dialog open={isSectionTemplateDialogOpen} onOpenChange={(open) => {
setIsSectionTemplateDialogOpen(open);
if (!open) {
setEditingSectionTemplateId(null);
setNewSectionTemplateTitle('');
setNewSectionTemplateDescription('');
setNewSectionTemplateCategory([]);
setNewSectionTemplateType('fields');
}
}}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingSectionTemplateId ? '섹션 수정' : '섹션 추가'}</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label> *</Label>
<Input
value={newSectionTemplateTitle}
onChange={(e) => setNewSectionTemplateTitle(e.target.value)}
placeholder="예: 기본 정보"
/>
</div>
<div>
<Label> ()</Label>
<Textarea
value={newSectionTemplateDescription}
onChange={(e) => setNewSectionTemplateDescription(e.target.value)}
placeholder="섹션에 대한 설명"
/>
</div>
<div>
<Label> *</Label>
<Select
value={newSectionTemplateType}
onValueChange={(val) => setNewSectionTemplateType(val as 'fields' | 'bom')}
disabled={!!editingSectionTemplateId}
>
<SelectTrigger>
<SelectValue placeholder="섹션 타입을 선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="fields"> </SelectItem>
<SelectItem value="bom">BOM ( )</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-gray-500 mt-1">
{editingSectionTemplateId
? '※ 템플릿 타입은 수정할 수 없습니다.'
: '일반 필드: 텍스트, 드롭다운 등의 항목 관리 | BOM: 하위 품목 구성 관리'}
</p>
</div>
<div>
<Label> ()</Label>
<div className="grid grid-cols-3 gap-2 mt-2">
{ITEM_TYPE_OPTIONS.map((type) => (
<div key={type.value} className="flex items-center gap-2">
<input
type="checkbox"
id={`cat-${type.value}`}
checked={newSectionTemplateCategory.includes(type.value)}
onChange={(e) => {
if (e.target.checked) {
setNewSectionTemplateCategory([...newSectionTemplateCategory, type.value]);
} else {
setNewSectionTemplateCategory(newSectionTemplateCategory.filter(c => c !== type.value));
}
}}
className="rounded"
/>
<label htmlFor={`cat-${type.value}`} className="text-sm cursor-pointer">
{type.label}
</label>
</div>
))}
</div>
<p className="text-xs text-muted-foreground mt-2">
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsSectionTemplateDialogOpen(false)}></Button>
<Button onClick={editingSectionTemplateId ? handleUpdateSectionTemplate : handleAddSectionTemplate}>
{editingSectionTemplateId ? '수정' : '추가'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 섹션 템플릿 항목 추가/수정 다이얼로그 */}
<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']);
}
}}>
<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">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label> *</Label>
<Input
value={templateFieldName}
onChange={(e) => setTemplateFieldName(e.target.value)}
placeholder="예: 품목명"
/>
</div>
<div>
<Label> *</Label>
<Input
value={templateFieldKey}
onChange={(e) => setTemplateFieldKey(e.target.value)}
placeholder="예: itemName"
/>
</div>
</div>
<div>
<Label> *</Label>
<Select value={templateFieldInputType} onValueChange={(v: any) => setTemplateFieldInputType(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{INPUT_TYPE_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{templateFieldInputType === 'dropdown' && (
<div>
<Label> </Label>
<Input
value={templateFieldOptions}
onChange={(e) => setTemplateFieldOptions(e.target.value)}
placeholder="옵션1, 옵션2, 옵션3 (쉼표로 구분)"
/>
</div>
)}
{(templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') && (
<div className="space-y-3 border rounded-lg p-4 bg-muted/30">
<div className="flex items-center gap-2">
<Switch
checked={templateFieldMultiColumn}
onCheckedChange={setTemplateFieldMultiColumn}
/>
<Label> </Label>
</div>
{templateFieldMultiColumn && (
<>
<div>
<Label> </Label>
<Input
type="number"
min={2}
max={10}
value={templateFieldColumnCount}
onChange={(e) => {
const count = parseInt(e.target.value) || 2;
setTemplateFieldColumnCount(count);
const newNames = Array.from({ length: count }, (_, i) =>
templateFieldColumnNames[i] || `컬럼${i + 1}`
);
setTemplateFieldColumnNames(newNames);
}}
/>
</div>
<div className="space-y-2">
<Label></Label>
{Array.from({ length: templateFieldColumnCount }).map((_, idx) => (
<Input
key={idx}
placeholder={`컬럼 ${idx + 1}`}
value={templateFieldColumnNames[idx] || ''}
onChange={(e) => {
const newNames = [...templateFieldColumnNames];
newNames[idx] = e.target.value;
setTemplateFieldColumnNames(newNames);
}}
/>
))}
</div>
</>
)}
</div>
)}
<div>
<Label> ()</Label>
<Textarea
value={templateFieldDescription}
onChange={(e) => setTemplateFieldDescription(e.target.value)}
placeholder="항목에 대한 설명"
/>
</div>
<div className="flex items-center gap-2">
<Switch checked={templateFieldRequired} onCheckedChange={setTemplateFieldRequired} />
<Label> </Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsTemplateFieldDialogOpen(false)}></Button>
<Button onClick={handleAddTemplateField}>
{editingTemplateFieldId ? '수정' : '추가'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 섹션 템플릿 불러오기 다이얼로그 */}
<Dialog open={isLoadTemplateDialogOpen} onOpenChange={(open) => {
setIsLoadTemplateDialogOpen(open);
if (!open) {
setSelectedTemplateId(null);
}
}}>
<DialogContent>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{sectionTemplates.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground"> </p>
<p className="text-sm text-muted-foreground mt-1">
"섹션관리"
</p>
</div>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{sectionTemplates.map((template) => (
<div
key={template.id}
onClick={() => setSelectedTemplateId(template.id)}
className={`p-4 border rounded-lg cursor-pointer transition-all ${
selectedTemplateId === template.id
? 'border-primary bg-primary/5 shadow-md'
: 'border-border hover:border-primary/50 hover:bg-accent'
}`}
>
<div className="flex items-start gap-3">
{template.type === 'bom' ? (
<Package className={`h-5 w-5 mt-0.5 ${
selectedTemplateId === template.id ? 'text-primary' : 'text-muted-foreground'
}`} />
) : (
<Folder className={`h-5 w-5 mt-0.5 ${
selectedTemplateId === template.id ? 'text-primary' : 'text-muted-foreground'
}`} />
)}
<div className="flex-1">
<div className="font-semibold mb-1">{template.title}</div>
{template.description && (
<p className="text-sm text-muted-foreground mb-2">{template.description}</p>
)}
<div className="flex items-center gap-2 flex-wrap">
{template.type === 'bom' ? (
<Badge variant="secondary" className="text-xs">
BOM {(template.bomItems || []).length}
</Badge>
) : (
<Badge variant="secondary" className="text-xs">
{template.fields.length}
</Badge>
)}
{template.category && template.category.length > 0 && template.category.map((cat, idx) => (
<Badge key={idx} variant="outline" className="text-xs">
{ITEM_TYPE_OPTIONS.find(t => t.value === cat)?.label || cat}
</Badge>
))}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsLoadTemplateDialogOpen(false)}></Button>
<Button
onClick={handleLoadTemplate}
disabled={!selectedTemplateId}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</PageLayout>
);
}