[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:
byeongcheolryu
2025-11-23 16:10:27 +09:00
parent 63f5df7d7d
commit df3db155dd
69 changed files with 31467 additions and 4796 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,4 @@
export { CategoryTab } from './CategoryTab';
export { MasterFieldTab } from './MasterFieldTab';
export { HierarchyTab } from './HierarchyTab';
export { SectionsTab } from './SectionsTab';