5935 lines
268 KiB
TypeScript
5935 lines
268 KiB
TypeScript
|
|
'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>
|
||
|
|
);
|
||
|
|
}
|