Files
sam-react-prod/src/components/items/ItemMasterDataManagement/tabs/HierarchyTab/index.tsx
유병철 0db6302652 refactor(WEB): 코드 품질 개선 및 불필요 코드 제거
- 미사용 import/변수/console.log 대량 정리 (100+개 파일)
- ItemMasterContext 간소화 (미사용 로직 제거)
- IntegratedListTemplateV2 / UniversalListPage 개선
- 결재 컴포넌트(ApprovalBox, DraftBox, ReferenceBox) 정리
- HR 컴포넌트(급여/휴가/부서) 코드 간소화
- globals.css 스타일 정리 및 개선
- AuthenticatedLayout 개선
- middleware CSP 정리
- proxy route 불필요 로깅 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:55:11 +09:00

499 lines
25 KiB
TypeScript

import type { Dispatch, SetStateAction } from 'react';
import type { ItemPage, ItemSection, ItemField, BOMItem } 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, Download } from 'lucide-react';
import { toast } from 'sonner';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
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 }>;
unitOptions?: 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: number | null;
setEditingSectionId: (id: number | null) => void;
editingSectionTitle: string;
setEditingSectionTitle: (title: string) => void;
hasUnsavedChanges: boolean;
pendingChanges: {
pages: Record<string, unknown>[];
sections: Record<string, unknown>[];
fields: Record<string, unknown>[];
masterFields: Record<string, unknown>[];
attributes: Record<string, unknown>[];
sectionTemplates: Record<string, unknown>[];
};
selectedSectionForField: number | null;
setSelectedSectionForField: (id: number | null) => void;
newSectionType: 'fields' | 'bom';
setNewSectionType: Dispatch<SetStateAction<'fields' | 'bom'>>;
// Functions
updateItemPage: (id: number, data: Partial<ItemPage>) => void;
trackChange: (type: 'pages' | 'sections' | 'fields' | 'masterFields' | 'attributes' | 'sectionTemplates', id: string, action: 'add' | 'update', data: Record<string, unknown>, 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: number, title: string) => void;
handleSaveSectionTitle: () => void;
moveSection: (dragIndex: number, hoverIndex: number) => void;
unlinkSection: (pageId: number, sectionId: number) => void; // 연결 해제 (삭제 아님)
updateSection: (sectionId: number, updates: Partial<ItemSection>) => Promise<void>;
deleteField: (pageId: string, sectionId: string, fieldId: string) => void; // 2025-11-27: 연결 해제로 변경 (삭제 아님, 항목 탭에 유지)
handleEditField: (sectionId: string, field: ItemField) => void;
// 2025-12-03: ID 기반으로 변경 (index stale 문제 해결)
moveField: (sectionId: number, dragFieldId: number, hoverFieldId: number) => void | Promise<void>;
// 2025-11-26 추가: 섹션/필드 불러오기
setIsImportSectionDialogOpen?: (open: boolean) => void;
setIsImportFieldDialogOpen?: (open: boolean) => void;
setImportFieldTargetSectionId?: (id: number | null) => void;
// 2025-11-27 추가: BOM 항목 API 함수
addBOMItem?: (sectionId: number, bomData: Omit<BOMItem, 'id' | 'created_at' | 'updated_at'>) => Promise<void>;
updateBOMItem?: (bomId: number, updates: Partial<BOMItem>) => Promise<void>;
deleteBOMItem?: (bomId: number) => Promise<void>;
}
export function HierarchyTab({
itemPages,
selectedPage,
ITEM_TYPE_OPTIONS,
unitOptions = [],
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,
unlinkSection,
updateSection,
deleteField,
handleEditField,
moveField,
// 2025-11-26 추가: 섹션/필드 불러오기
setIsImportSectionDialogOpen,
setIsImportFieldDialogOpen,
setImportFieldTargetSectionId,
// 2025-11-27 추가: BOM 항목 API 함수
addBOMItem,
updateBOMItem,
deleteBOMItem,
}: 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-${section.id}-${index}`}
section={section}
index={index}
moveSection={(dragIndex, hoverIndex) => {
moveSection(dragIndex, hoverIndex);
}}
onDelete={() => {
if (confirm('이 섹션을 페이지에서 연결 해제하시겠습니까?\n(섹션 데이터는 섹션 탭에 유지됩니다)')) {
unlinkSection(selectedPage.id, section.id);
}
}}
onEditTitle={handleEditSectionTitle}
editingSectionId={editingSectionId}
editingSectionTitle={editingSectionTitle}
setEditingSectionTitle={setEditingSectionTitle}
setEditingSectionId={setEditingSectionId}
handleSaveSectionTitle={handleSaveSectionTitle}
>
{/* BOM 타입 섹션 */}
{section.section_type === 'BOM' ? (
<BOMManagementSection
title=""
description=""
bomItems={section.bom_items || []}
onAddItem={async (item) => {
// 2025-11-27: API 함수로 BOM 항목 추가
if (addBOMItem) {
try {
await addBOMItem(section.id, {
section_id: section.id,
item_name: item.item_name,
item_code: item.item_code,
quantity: item.quantity,
unit: item.unit,
spec: item.spec,
});
toast.success('BOM 항목이 추가되었습니다');
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[HierarchyTab] BOM 추가 실패:', error);
toast.error('BOM 항목 추가에 실패했습니다. 백엔드 API를 확인하세요.');
}
} else {
// Fallback: 로컬 상태만 업데이트 (API 함수 없을 경우)
console.warn('[HierarchyTab] addBOMItem 함수가 없어 로컬 저장만 수행합니다');
const now = new Date().toISOString();
const newBomItems = [...(section.bom_items || []), {
...item,
id: Date.now(),
section_id: section.id,
created_at: now,
updated_at: now
}];
updateSection(section.id, { bom_items: newBomItems });
toast.success('BOM 항목이 추가되었습니다 (로컬 - 새로고침 시 사라짐)');
}
}}
onUpdateItem={async (id, updatedItem) => {
// 2025-11-27: API 함수로 BOM 항목 수정
if (updateBOMItem) {
await updateBOMItem(id, updatedItem);
toast.success('BOM 항목이 수정되었습니다');
} else {
// Fallback: 로컬 상태만 업데이트
const newBomItems = (section.bom_items || []).map(item =>
item.id === id ? { ...item, ...updatedItem } : item
);
updateSection(section.id, { bom_items: newBomItems });
toast.success('BOM 항목이 수정되었습니다 (로컬)');
}
}}
onDeleteItem={async (itemId) => {
// 2025-11-27: API 함수로 BOM 항목 삭제
if (deleteBOMItem) {
await deleteBOMItem(itemId);
toast.success('BOM 항목이 삭제되었습니다');
} else {
// Fallback: 로컬 상태만 업데이트
const newBomItems = (section.bom_items || []).filter(item => item.id !== itemId);
updateSection(section.id, { bom_items: newBomItems });
toast.success('BOM 항목이 삭제되었습니다 (로컬)');
}
}}
unitOptions={unitOptions}
itemTypeOptions={ITEM_TYPE_OPTIONS}
/>
) : (
/* 일반 필드 타입 섹션 */
<>
{!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={`${section.id}-${field.id}-${fieldIndex}`}
field={field}
index={fieldIndex}
moveField={(dragFieldId, hoverFieldId) => moveField(section.id, dragFieldId, hoverFieldId)}
onDelete={() => {
if (confirm('이 항목을 섹션에서 연결 해제하시겠습니까?\n(항목 데이터는 항목 탭에 유지됩니다)')) {
deleteField(String(selectedPage.id), String(section.id), String(field.id));
toast.success('항목 연결이 해제되었습니다');
}
}}
onEdit={() => handleEditField(String(section.id), field)}
/>
))
)}
<div className="flex gap-2 mt-3">
<Button
size="sm"
variant="outline"
className="flex-1"
onClick={() => {
setImportFieldTargetSectionId?.(section.id);
setIsImportFieldDialogOpen?.(true);
}}
>
<Download className="h-3 w-3 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
className="flex-1"
onClick={() => {
setSelectedSectionForField(section.id);
setIsFieldDialogOpen(true);
}}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
</>
)}
</DraggableSection>
))
)}
</div>
</div>
</div>
) : (
<p className="text-center text-gray-500 py-8"> </p>
)}
</CardContent>
</Card>
</div>
);
}