[feat]: Item Master 데이터 관리 기능 구현 및 타입 에러 수정
- ItemMasterDataManagement 컴포넌트 구조화 (tabs, dialogs, components 분리) - HierarchyTab 타입 에러 수정 (BOMItem section_id, updated_at 추가) - API 클라이언트 구현 (item-master.ts, 13개 엔드포인트) - ItemMasterContext 구현 (상태 관리 및 데이터 흐름) - 백엔드 요구사항 문서 작성 (CORS 설정, API 스펙 등) - SSR 호환성 수정 (navigator API typeof window 체크) - 미사용 변수 ESLint 에러 해결 - Context 리팩토링 (AuthContext, RootProvider 추가) - API 유틸리티 추가 (error-handler, logger, transformers) 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Plus, Trash2, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ItemCategoryStructure {
|
||||
[category1: string]: {
|
||||
[category2: string]: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface CategoryTabProps {
|
||||
itemCategories: ItemCategoryStructure;
|
||||
setItemCategories: (categories: ItemCategoryStructure) => void;
|
||||
newCategory1: string;
|
||||
setNewCategory1: (value: string) => void;
|
||||
newCategory2: string;
|
||||
setNewCategory2: (value: string) => void;
|
||||
newCategory3: string;
|
||||
setNewCategory3: (value: string) => void;
|
||||
selectedCategory1: string;
|
||||
setSelectedCategory1: (value: string) => void;
|
||||
selectedCategory2: string;
|
||||
setSelectedCategory2: (value: string) => void;
|
||||
}
|
||||
|
||||
export function CategoryTab({
|
||||
itemCategories,
|
||||
setItemCategories,
|
||||
newCategory1,
|
||||
setNewCategory1,
|
||||
newCategory2,
|
||||
setNewCategory2,
|
||||
newCategory3,
|
||||
setNewCategory3,
|
||||
selectedCategory1,
|
||||
setSelectedCategory1,
|
||||
selectedCategory2,
|
||||
setSelectedCategory2
|
||||
}: CategoryTabProps) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { ItemPage, ItemSection } from '@/contexts/ItemMasterContext';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Plus, Edit, Trash2, Link, Copy } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { DraggableSection, DraggableField } from '../../components';
|
||||
import { BOMManagementSection } from '@/components/items/BOMManagementSection';
|
||||
|
||||
interface HierarchyTabProps {
|
||||
// Data
|
||||
itemPages: ItemPage[];
|
||||
selectedPage: ItemPage | undefined;
|
||||
ITEM_TYPE_OPTIONS: Array<{ value: string; label: string }>;
|
||||
|
||||
// State
|
||||
editingPageId: number | null;
|
||||
setEditingPageId: (id: number | null) => void;
|
||||
editingPageName: string;
|
||||
setEditingPageName: (name: string) => void;
|
||||
selectedPageId: number | null;
|
||||
setSelectedPageId: (id: number | null) => void;
|
||||
editingPathPageId: number | null;
|
||||
setEditingPathPageId: (id: number | null) => void;
|
||||
editingAbsolutePath: string;
|
||||
setEditingAbsolutePath: (path: string) => void;
|
||||
editingSectionId: string | null;
|
||||
setEditingSectionId: (id: string | null) => void;
|
||||
editingSectionTitle: string;
|
||||
setEditingSectionTitle: (title: string) => void;
|
||||
hasUnsavedChanges: boolean;
|
||||
pendingChanges: {
|
||||
pages: any[];
|
||||
sections: any[];
|
||||
fields: any[];
|
||||
masterFields: any[];
|
||||
attributes: any[];
|
||||
sectionTemplates: any[];
|
||||
};
|
||||
selectedSectionForField: number | null;
|
||||
setSelectedSectionForField: (id: number | null) => void;
|
||||
newSectionType: 'fields' | 'bom';
|
||||
setNewSectionType: Dispatch<SetStateAction<'fields' | 'bom'>>;
|
||||
|
||||
// Functions
|
||||
updateItemPage: (id: number, data: any) => void;
|
||||
trackChange: (type: 'pages' | 'sections' | 'fields' | 'masterFields' | 'attributes' | 'sectionTemplates', id: string, action: 'add' | 'update', data: any, attributeType?: string) => void;
|
||||
deleteItemPage: (id: number) => void;
|
||||
duplicatePage: (id: number) => void;
|
||||
setIsPageDialogOpen: (open: boolean) => void;
|
||||
setIsSectionDialogOpen: (open: boolean) => void;
|
||||
setIsFieldDialogOpen: (open: boolean) => void;
|
||||
handleEditSectionTitle: (sectionId: string, title: string) => void;
|
||||
handleSaveSectionTitle: () => void;
|
||||
moveSection: (dragIndex: number, hoverIndex: number) => void;
|
||||
deleteSection: (pageId: number, sectionId: number) => void;
|
||||
updateSection: (sectionId: number, updates: Partial<ItemSection>) => Promise<void>;
|
||||
deleteField: (pageId: string, sectionId: string, fieldId: string) => void;
|
||||
handleEditField: (sectionId: string, field: any) => void;
|
||||
moveField: (sectionId: number, dragIndex: number, hoverIndex: number) => void;
|
||||
}
|
||||
|
||||
export function HierarchyTab({
|
||||
itemPages,
|
||||
selectedPage,
|
||||
ITEM_TYPE_OPTIONS,
|
||||
editingPageId,
|
||||
setEditingPageId,
|
||||
editingPageName,
|
||||
setEditingPageName,
|
||||
selectedPageId,
|
||||
setSelectedPageId,
|
||||
editingPathPageId: _editingPathPageId,
|
||||
setEditingPathPageId,
|
||||
editingAbsolutePath: _editingAbsolutePath,
|
||||
setEditingAbsolutePath,
|
||||
editingSectionId,
|
||||
setEditingSectionId,
|
||||
editingSectionTitle,
|
||||
setEditingSectionTitle,
|
||||
hasUnsavedChanges: _hasUnsavedChanges,
|
||||
pendingChanges: _pendingChanges,
|
||||
selectedSectionForField: _selectedSectionForField,
|
||||
setSelectedSectionForField,
|
||||
newSectionType: _newSectionType,
|
||||
setNewSectionType,
|
||||
updateItemPage,
|
||||
trackChange,
|
||||
deleteItemPage,
|
||||
duplicatePage: _duplicatePage,
|
||||
setIsPageDialogOpen,
|
||||
setIsSectionDialogOpen,
|
||||
setIsFieldDialogOpen,
|
||||
handleEditSectionTitle,
|
||||
handleSaveSectionTitle,
|
||||
moveSection,
|
||||
deleteSection,
|
||||
updateSection,
|
||||
deleteField,
|
||||
handleEditField,
|
||||
moveField
|
||||
}: HierarchyTabProps) {
|
||||
return (
|
||||
<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, { page_name: editingPageName });
|
||||
trackChange('pages', String(page.id), 'update', { page_name: editingPageName });
|
||||
setEditingPageId(null);
|
||||
toast.success('페이지명이 수정되었습니다 (저장 필요)');
|
||||
}
|
||||
if (e.key === 'Escape') setEditingPageId(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => setSelectedPageId(page.id)}
|
||||
onDoubleClick={() => {
|
||||
setEditingPageId(page.id);
|
||||
setEditingPageName(page.page_name);
|
||||
}}
|
||||
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.page_name}</div>
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
{ITEM_TYPE_OPTIONS.find(t => t.value === page.item_type)?.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.page_name);
|
||||
}}
|
||||
title="페이지명 수정"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
{/* 페이지 복제 기능 - 향후 사용을 위해 보관 (2025-11-20)
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
duplicatePage(page.id);
|
||||
}}
|
||||
title="복제"
|
||||
>
|
||||
<Copy className="h-3 w-3 text-green-500" />
|
||||
</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.absolute_path && (
|
||||
<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.absolute_path}</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.absolute_path || '');
|
||||
}}
|
||||
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();
|
||||
const text = page.absolute_path || '';
|
||||
|
||||
// Modern API 시도 (브라우저 환경 체크)
|
||||
if (typeof window !== 'undefined' && window.navigator.clipboard && window.navigator.clipboard.writeText) {
|
||||
window.navigator.clipboard.writeText(text)
|
||||
.then(() => alert('경로가 클립보드에 복사되었습니다'))
|
||||
.catch(() => {
|
||||
// Fallback 방식
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
alert('경로가 클립보드에 복사되었습니다');
|
||||
} catch {
|
||||
alert('복사에 실패했습니다');
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
});
|
||||
} else {
|
||||
// Fallback 방식
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
alert('경로가 클립보드에 복사되었습니다');
|
||||
} catch {
|
||||
alert('복사에 실패했습니다');
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
}}
|
||||
title="경로 복사"
|
||||
>
|
||||
<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?.page_name || '섹션을 선택하세요'}</CardTitle>
|
||||
{/* 변경사항 배지 - 나중에 사용 예정으로 임시 숨김
|
||||
{hasUnsavedChanges && (
|
||||
<Badge variant="destructive" className="animate-pulse text-xs">
|
||||
{pendingChanges.pages.length + pendingChanges.sectionTemplates.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">
|
||||
{selectedPage.sections.length === 0 ? (
|
||||
<p className="text-center text-gray-500 py-8">섹션을 추가해주세요</p>
|
||||
) : (
|
||||
selectedPage.sections
|
||||
.map((section, index) => (
|
||||
<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.section_type === 'BOM' ? (
|
||||
<BOMManagementSection
|
||||
title=""
|
||||
description=""
|
||||
bomItems={section.bomItems || []}
|
||||
onAddItem={(item) => {
|
||||
const now = new Date().toISOString();
|
||||
const newBomItems = [...(section.bomItems || []), {
|
||||
...item,
|
||||
id: Date.now(),
|
||||
section_id: section.id,
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
}];
|
||||
updateSection(section.id, { bomItems: newBomItems });
|
||||
toast.success('BOM 항목이 추가되었습니다');
|
||||
}}
|
||||
onUpdateItem={(id, updatedItem) => {
|
||||
const newBomItems = (section.bomItems || []).map(item =>
|
||||
item.id === id ? { ...item, ...updatedItem } : item
|
||||
);
|
||||
updateSection(section.id, { bomItems: newBomItems });
|
||||
toast.success('BOM 항목이 수정되었습니다');
|
||||
}}
|
||||
onDeleteItem={(itemId) => {
|
||||
const newBomItems = (section.bomItems || []).filter(item => item.id !== itemId);
|
||||
updateSection(section.id, { bomItems: newBomItems });
|
||||
toast.success('BOM 항목이 삭제되었습니다');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
/* 일반 필드 타입 섹션 */
|
||||
<>
|
||||
{!section.fields || section.fields.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 text-center py-4">항목을 추가해주세요</p>
|
||||
) : (
|
||||
section.fields
|
||||
.sort((a, b) => (a.order_no ?? 0) - (b.order_no ?? 0))
|
||||
.map((field, fieldIndex) => (
|
||||
<DraggableField
|
||||
key={field.id}
|
||||
field={field}
|
||||
index={fieldIndex}
|
||||
moveField={(dragIndex, hoverIndex) => moveField(section.id, dragIndex, hoverIndex)}
|
||||
onDelete={() => {
|
||||
if (confirm('이 항목을 삭제하시겠습니까?')) {
|
||||
deleteField(String(selectedPage.id), String(section.id), String(field.id));
|
||||
toast.success('항목이 삭제되었습니다');
|
||||
}
|
||||
}}
|
||||
onEdit={() => handleEditField(String(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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { ItemMasterField } from '@/contexts/ItemMasterContext';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Plus, Edit, Trash2 } from 'lucide-react';
|
||||
|
||||
// 입력방식 옵션 (ItemMasterDataManagement에서 사용하는 상수)
|
||||
const INPUT_TYPE_OPTIONS = [
|
||||
{ value: 'textbox', label: '텍스트' },
|
||||
{ value: 'dropdown', label: '드롭다운' },
|
||||
{ value: 'checkbox', label: '체크박스' },
|
||||
{ value: 'number', label: '숫자' },
|
||||
{ value: 'date', label: '날짜' },
|
||||
{ value: 'textarea', label: '텍스트영역' }
|
||||
];
|
||||
|
||||
// 변경 레코드 타입 (임시 - 나중에 공통 타입으로 분리)
|
||||
interface ChangeRecord {
|
||||
masterFields: Array<{
|
||||
type: 'add' | 'update' | 'delete';
|
||||
id: string;
|
||||
data?: any;
|
||||
}>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface MasterFieldTabProps {
|
||||
itemMasterFields: ItemMasterField[];
|
||||
setIsMasterFieldDialogOpen: (open: boolean) => void;
|
||||
handleEditMasterField: (field: ItemMasterField) => void;
|
||||
handleDeleteMasterField: (id: number) => void;
|
||||
hasUnsavedChanges: boolean;
|
||||
pendingChanges: ChangeRecord;
|
||||
}
|
||||
|
||||
export function MasterFieldTab({
|
||||
itemMasterFields,
|
||||
setIsMasterFieldDialogOpen,
|
||||
handleEditMasterField,
|
||||
handleDeleteMasterField,
|
||||
hasUnsavedChanges,
|
||||
pendingChanges
|
||||
}: MasterFieldTabProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<CardTitle>마스터 항목 관리</CardTitle>
|
||||
<CardDescription>재사용 가능한 항목 템플릿을 관리합니다</CardDescription>
|
||||
</div>
|
||||
{/* 변경사항 배지 - 나중에 사용 예정으로 임시 숨김 */}
|
||||
{false && 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>
|
||||
{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.field_name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{INPUT_TYPE_OPTIONS.find(t => t.value === field.properties?.inputType)?.label}
|
||||
</Badge>
|
||||
{field.properties?.required && (
|
||||
<Badge variant="destructive" className="text-xs">필수</Badge>
|
||||
)}
|
||||
<Badge variant="secondary" className="text-xs">{field.category}</Badge>
|
||||
{(field.properties as any)?.attributeType && (field.properties as any).attributeType !== 'custom' && (
|
||||
<Badge variant="default" className="text-xs bg-blue-500">
|
||||
{(field.properties as any).attributeType === 'unit' ? '단위 연동' :
|
||||
(field.properties as any).attributeType === 'material' ? '재질 연동' : '표면처리 연동'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
ID: {field.id}
|
||||
{field.description && (
|
||||
<span className="ml-2">• {field.description}</span>
|
||||
)}
|
||||
</div>
|
||||
{field.properties?.options && field.properties.options.length > 0 && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
옵션: {field.properties.options.join(', ')}
|
||||
{(field.properties as any)?.attributeType && (field.properties as any).attributeType !== 'custom' && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Plus, Edit, Trash2, Folder, Package, FileText, GripVertical } from 'lucide-react';
|
||||
import type { SectionTemplate, BOMItem } from '@/contexts/ItemMasterContext';
|
||||
import { BOMManagementSection } from '../../BOMManagementSection';
|
||||
|
||||
interface SectionsTabProps {
|
||||
// 섹션 템플릿 데이터
|
||||
sectionTemplates: SectionTemplate[];
|
||||
|
||||
// 다이얼로그 상태
|
||||
setIsSectionTemplateDialogOpen: (open: boolean) => void;
|
||||
setCurrentTemplateId: (id: number | null) => void;
|
||||
setIsTemplateFieldDialogOpen: (open: boolean) => void;
|
||||
|
||||
// 템플릿 핸들러
|
||||
handleEditSectionTemplate: (template: SectionTemplate) => void;
|
||||
handleDeleteSectionTemplate: (id: number) => void;
|
||||
|
||||
// 템플릿 필드 핸들러
|
||||
handleEditTemplateField: (templateId: number, field: any) => void;
|
||||
handleDeleteTemplateField: (templateId: number, fieldId: string) => void;
|
||||
|
||||
// BOM 핸들러
|
||||
handleAddBOMItemToTemplate: (templateId: number, item: Omit<BOMItem, 'id' | 'createdAt'>) => void;
|
||||
handleUpdateBOMItemInTemplate: (templateId: number, itemId: number, item: Partial<BOMItem>) => void;
|
||||
handleDeleteBOMItemFromTemplate: (templateId: number, itemId: number) => void;
|
||||
|
||||
// 옵션
|
||||
ITEM_TYPE_OPTIONS: Array<{ value: string; label: string }>;
|
||||
INPUT_TYPE_OPTIONS: Array<{ value: string; label: string }>;
|
||||
|
||||
// 변경사항 추적 (나중에 사용 예정)
|
||||
hasUnsavedChanges?: boolean;
|
||||
pendingChanges?: {
|
||||
sectionTemplates: any[];
|
||||
};
|
||||
}
|
||||
|
||||
export function SectionsTab({
|
||||
sectionTemplates,
|
||||
setIsSectionTemplateDialogOpen,
|
||||
setCurrentTemplateId,
|
||||
setIsTemplateFieldDialogOpen,
|
||||
handleEditSectionTemplate,
|
||||
handleDeleteSectionTemplate,
|
||||
handleEditTemplateField,
|
||||
handleDeleteTemplateField,
|
||||
handleAddBOMItemToTemplate,
|
||||
handleUpdateBOMItemInTemplate,
|
||||
handleDeleteBOMItemFromTemplate,
|
||||
ITEM_TYPE_OPTIONS,
|
||||
INPUT_TYPE_OPTIONS,
|
||||
hasUnsavedChanges = false,
|
||||
pendingChanges = { sectionTemplates: [] },
|
||||
}: SectionsTabProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<CardTitle>섹션 템플릿 관리</CardTitle>
|
||||
<CardDescription>재사용 가능한 섹션 템플릿을 관리합니다</CardDescription>
|
||||
</div>
|
||||
{/* 변경사항 배지 - 나중에 사용 예정으로 임시 숨김 */}
|
||||
{false && hasUnsavedChanges && pendingChanges.sectionTemplates.length > 0 && (
|
||||
<Badge variant="destructive" className="animate-pulse">
|
||||
{pendingChanges.sectionTemplates.length}개 변경
|
||||
</Badge>
|
||||
)}
|
||||
</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.section_type !== 'BOM').length,
|
||||
templates: sectionTemplates.map(t => ({ id: t.id, template_name: t.template_name, section_type: t.section_type }))
|
||||
});
|
||||
return null;
|
||||
})()}
|
||||
{sectionTemplates.filter(t => t.section_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.section_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.template_name}</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.section_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.section_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.template_name}</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { CategoryTab } from './CategoryTab';
|
||||
export { MasterFieldTab } from './MasterFieldTab';
|
||||
export { HierarchyTab } from './HierarchyTab';
|
||||
export { SectionsTab } from './SectionsTab';
|
||||
Reference in New Issue
Block a user