- 미사용 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>
499 lines
25 KiB
TypeScript
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>
|
|
);
|
|
}
|