feat: 품목기준관리 Zustand 리팩토링 및 422 에러 팝업

- Zustand store 도입 (useItemMasterStore)
- 훅 분리 및 구조 개선 (hooks/, contexts/)
- 422 ValidationException 에러 AlertDialog 팝업 추가
- API 함수 분리 (src/lib/api/item-master.ts)
- 타입 정의 정리 (item-master.types.ts, item-master-api.ts)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2026-01-06 20:49:37 +09:00
32 changed files with 6747 additions and 1410 deletions

View File

@@ -36,8 +36,8 @@ export function useFormStructure(
// 단위 옵션 저장 (SimpleUnitOption 형식으로 변환)
console.log('[useFormStructure] API initData.unitOptions:', initData.unitOptions);
const simpleUnitOptions: SimpleUnitOption[] = (initData.unitOptions || []).map((u) => ({
label: u.label,
value: u.value,
label: u.unit_name,
value: u.unit_code,
}));
console.log('[useFormStructure] Processed unitOptions:', simpleUnitOptions.length, 'items');
setUnitOptions(simpleUnitOptions);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,452 @@
'use client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Plus, Trash2, Settings, Package } from 'lucide-react';
import type { ItemMasterField } from '@/contexts/ItemMasterContext';
import type { MasterOption, OptionColumn } from '../types';
import type { AttributeSubTab } from '../hooks/useTabManagement';
// UnitOption은 MasterOption으로 대체
export type UnitOption = MasterOption;
// AttributeColumn은 OptionColumn으로 대체
export type AttributeColumn = OptionColumn;
// 입력 타입 라벨 변환 헬퍼 함수
const getInputTypeLabel = (inputType: string | undefined): string => {
const labels: Record<string, string> = {
textbox: '텍스트박스',
number: '숫자',
dropdown: '드롭다운',
checkbox: '체크박스',
date: '날짜',
textarea: '텍스트영역',
};
return labels[inputType || ''] || '텍스트박스';
};
interface AttributeTabContentProps {
activeAttributeTab: string;
setActiveAttributeTab: (tab: string) => void;
attributeSubTabs: AttributeSubTab[];
unitOptions: UnitOption[];
materialOptions: UnitOption[];
surfaceTreatmentOptions: UnitOption[];
customAttributeOptions: Record<string, UnitOption[]>;
attributeColumns: Record<string, AttributeColumn[]>;
itemMasterFields: ItemMasterField[];
// 다이얼로그 핸들러
setIsManageAttributeTabsDialogOpen: (open: boolean) => void;
setIsOptionDialogOpen: (open: boolean) => void;
setEditingOptionType: (type: string) => void;
setNewOptionValue: (value: string) => void;
setNewOptionLabel: (value: string) => void;
setNewOptionColumnValues: (values: Record<string, string>) => void;
setIsColumnManageDialogOpen: (open: boolean) => void;
setManagingColumnType: (type: string) => void;
setNewColumnName: (name: string) => void;
setNewColumnKey: (key: string) => void;
setNewColumnType: (type: 'text' | 'number') => void;
setNewColumnRequired: (required: boolean) => void;
handleDeleteOption: (type: string, id: string) => void;
}
export function AttributeTabContent({
activeAttributeTab,
setActiveAttributeTab,
attributeSubTabs,
unitOptions,
materialOptions,
surfaceTreatmentOptions,
customAttributeOptions,
attributeColumns,
itemMasterFields,
setIsManageAttributeTabsDialogOpen,
setIsOptionDialogOpen,
setEditingOptionType,
setNewOptionValue,
setNewOptionLabel,
setNewOptionColumnValues,
setIsColumnManageDialogOpen,
setManagingColumnType,
setNewColumnName,
setNewColumnKey,
setNewColumnType,
setNewColumnRequired,
handleDeleteOption,
}: AttributeTabContentProps) {
// 옵션 목록 렌더링 헬퍼
const renderOptionList = (
options: UnitOption[],
optionType: string,
title: string
) => {
const columns = attributeColumns[optionType] || [];
return (
<div className="space-y-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium">{title}</h3>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => {
setManagingColumnType(optionType);
setNewColumnName('');
setNewColumnKey('');
setNewColumnType('text');
setNewColumnRequired(false);
setIsColumnManageDialogOpen(true);
}}>
<Settings className="w-4 h-4 mr-2" />
</Button>
<Button size="sm" onClick={() => {
setEditingOptionType(optionType === 'units' ? 'unit' : optionType === 'materials' ? 'material' : optionType === 'surface' ? 'surface' : optionType);
setIsOptionDialogOpen(true);
}}>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
<div className="space-y-3">
{options.map((option) => {
const hasColumns = columns.length > 0 && option.columnValues;
return (
<div key={option.id} className="p-4 border rounded hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<span className="font-medium text-base">{option.label}</span>
{option.inputType && (
<Badge variant="outline" className="text-xs">{getInputTypeLabel(option.inputType)}</Badge>
)}
{option.required && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
</div>
<div className="text-sm text-muted-foreground space-y-1">
<div className="flex gap-2">
<span className="font-medium min-w-16">(Value):</span>
<span>{option.value}</span>
</div>
{option.placeholder && (
<div className="flex gap-2">
<span className="font-medium min-w-16">:</span>
<span>{option.placeholder}</span>
</div>
)}
{option.defaultValue && (
<div className="flex gap-2">
<span className="font-medium min-w-16">:</span>
<span>{option.defaultValue}</span>
</div>
)}
{option.inputType === 'dropdown' && option.options && (
<div className="flex gap-2">
<span className="font-medium min-w-16">:</span>
<div className="flex flex-wrap gap-1">
{option.options.map((opt, idx) => (
<Badge key={idx} variant="secondary" className="text-xs">{opt}</Badge>
))}
</div>
</div>
)}
</div>
{hasColumns && (
<div className="mt-3 pt-3 border-t">
<p className="text-xs font-medium text-muted-foreground mb-2"> </p>
<div className="grid grid-cols-2 gap-2 text-sm">
{columns.map((column) => (
<div key={column.id} className="flex gap-2">
<span className="text-muted-foreground">{column.name}:</span>
<span>{option.columnValues?.[column.key] || '-'}</span>
</div>
))}
</div>
</div>
)}
</div>
<Button variant="ghost" size="sm" onClick={() => handleDeleteOption(optionType === 'units' ? 'unit' : optionType === 'materials' ? 'material' : optionType === 'surface' ? 'surface' : optionType, option.id)}>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</div>
);
})}
</div>
</div>
);
};
// 마스터 필드 속성 렌더링
const renderMasterFieldProperties = (masterField: ItemMasterField) => {
const propertiesArray = masterField?.properties
? Object.entries(masterField.properties).map(([key, value]) => ({ key, ...value as object }))
: [];
if (propertiesArray.length === 0) return null;
return (
<div className="space-y-4">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-medium">{masterField.field_name} </h3>
<p className="text-sm text-muted-foreground mt-1">
&quot;{masterField.field_name}&quot;
</p>
</div>
</div>
<div className="space-y-3">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{propertiesArray.map((property: any) => {
const inputTypeLabel = getInputTypeLabel(property.type);
return (
<div key={property.key} className="p-4 border rounded hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<span className="font-medium text-base">{property.label}</span>
<Badge variant="outline" className="text-xs">{inputTypeLabel}</Badge>
{property.required && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
</div>
<div className="text-sm text-muted-foreground space-y-1">
<div className="flex gap-2">
<span className="font-medium min-w-24">(Key):</span>
<code className="bg-gray-100 px-2 py-0.5 rounded text-xs">{property.key}</code>
</div>
{property.placeholder && (
<div className="flex gap-2">
<span className="font-medium min-w-24">:</span>
<span>{property.placeholder}</span>
</div>
)}
{property.defaultValue && (
<div className="flex gap-2">
<span className="font-medium min-w-24">:</span>
<span>{property.defaultValue}</span>
</div>
)}
{property.type === 'dropdown' && property.options && (
<div className="flex gap-2">
<span className="font-medium min-w-24">:</span>
<div className="flex flex-wrap gap-1">
{property.options.map((opt: string, idx: number) => (
<Badge key={idx} variant="secondary" className="text-xs">{opt}</Badge>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-2">
<Package className="w-5 h-5 text-blue-600 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-blue-900">
</p>
<p className="text-xs text-blue-700 mt-1">
<strong> </strong> &quot;{masterField.field_name}&quot; // .
</p>
</div>
</div>
</div>
</div>
);
};
// 사용자 정의 속성 렌더링
const renderCustomAttributeTab = () => {
const currentTabKey = activeAttributeTab;
const currentOptions = customAttributeOptions[currentTabKey] || [];
const columns = attributeColumns[currentTabKey] || [];
return (
<div className="space-y-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium">
{attributeSubTabs.find(t => t.key === activeAttributeTab)?.label || '사용자 정의'}
</h3>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => {
setManagingColumnType(currentTabKey);
setNewColumnName('');
setNewColumnKey('');
setNewColumnType('text');
setNewColumnRequired(false);
setIsColumnManageDialogOpen(true);
}}>
<Settings className="w-4 h-4 mr-2" />
</Button>
<Button size="sm" onClick={() => {
setEditingOptionType(activeAttributeTab);
setNewOptionValue('');
setNewOptionLabel('');
setNewOptionColumnValues({});
setIsOptionDialogOpen(true);
}}>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{currentOptions.length > 0 ? (
<div className="space-y-3">
{currentOptions.map((option) => {
const hasColumns = columns.length > 0 && option.columnValues;
const inputTypeLabel = getInputTypeLabel(option.inputType);
return (
<div key={option.id} className="p-4 border rounded hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<span className="font-medium text-base">{option.label}</span>
<Badge variant="outline" className="text-xs">{inputTypeLabel}</Badge>
{option.required && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
</div>
<div className="text-sm text-muted-foreground space-y-1">
<div className="flex gap-2">
<span className="font-medium min-w-16">(Value):</span>
<span>{option.value}</span>
</div>
{option.placeholder && (
<div className="flex gap-2">
<span className="font-medium min-w-16">:</span>
<span>{option.placeholder}</span>
</div>
)}
{option.defaultValue && (
<div className="flex gap-2">
<span className="font-medium min-w-16">:</span>
<span>{option.defaultValue}</span>
</div>
)}
{option.inputType === 'dropdown' && option.options && (
<div className="flex gap-2">
<span className="font-medium min-w-16">:</span>
<div className="flex flex-wrap gap-1">
{option.options.map((opt, idx) => (
<Badge key={idx} variant="secondary" className="text-xs">{opt}</Badge>
))}
</div>
</div>
)}
</div>
{hasColumns && (
<div className="mt-3 pt-3 border-t">
<p className="text-xs font-medium text-muted-foreground mb-2"> </p>
<div className="grid grid-cols-2 gap-2 text-sm">
{columns.map((column) => (
<div key={column.id} className="flex gap-2">
<span className="text-muted-foreground">{column.name}:</span>
<span>{option.columnValues?.[column.key] || '-'}</span>
</div>
))}
</div>
</div>
)}
</div>
<Button variant="ghost" size="sm" onClick={() => handleDeleteOption(currentTabKey, option.id)}>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</div>
);
})}
</div>
) : (
<div className="border-2 border-dashed rounded-lg p-8 text-center text-gray-500">
<Settings className="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p className="mb-2"> </p>
<p className="text-sm"> &quot;&quot; </p>
</div>
)}
</div>
);
};
return (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription>, , </CardDescription>
</CardHeader>
<CardContent>
{/* 속성 하위 탭 (칩 형태) */}
<div className="flex items-center gap-2 mb-6 border-b pb-2">
<div className="flex gap-2 flex-1 flex-wrap">
{attributeSubTabs.sort((a, b) => a.order - b.order).map(tab => (
<Button
key={tab.id}
variant={activeAttributeTab === tab.key ? 'default' : 'outline'}
size="sm"
onClick={() => setActiveAttributeTab(tab.key)}
className="rounded-full"
>
{tab.label}
</Button>
))}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setIsManageAttributeTabsDialogOpen(true)}
className="shrink-0"
>
<Settings className="w-4 h-4 mr-1" />
</Button>
</div>
{/* 단위 관리 */}
{activeAttributeTab === 'units' && renderOptionList(unitOptions, 'units', '단위 목록')}
{/* 재질 관리 */}
{activeAttributeTab === 'materials' && renderOptionList(materialOptions, 'materials', '재질 목록')}
{/* 표면처리 관리 */}
{activeAttributeTab === 'surface' && renderOptionList(surfaceTreatmentOptions, 'surface', '표면처리 목록')}
{/* 사용자 정의 속성 탭 및 마스터 항목 탭 */}
{!['units', 'materials', 'surface'].includes(activeAttributeTab) && (() => {
const currentTabKey = activeAttributeTab;
// 마스터 항목인지 확인
const masterField = itemMasterFields.find(f => f.id.toString() === currentTabKey);
if (masterField) {
const propertiesArray = masterField?.properties
? Object.entries(masterField.properties).map(([key, value]) => ({ key, ...value as object }))
: [];
if (propertiesArray.length > 0) {
return renderMasterFieldProperties(masterField);
}
}
// 사용자 정의 속성 탭
return renderCustomAttributeTab();
})()}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { AlertCircle } from 'lucide-react';
interface ErrorAlertDialogProps {
open: boolean;
onClose: () => void;
title?: string;
message: string;
}
/**
* 에러 알림 다이얼로그 컴포넌트
* 422 ValidationException 등의 에러 메시지를 표시
*/
export function ErrorAlertDialog({
open,
onClose,
title = '오류',
message,
}: ErrorAlertDialogProps) {
return (
<AlertDialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2 text-destructive">
<AlertCircle className="h-5 w-5" />
{title}
</AlertDialogTitle>
<AlertDialogDescription className="text-base">
{message}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={onClose}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,943 @@
'use client';
import type { ItemPage, SectionTemplate, ItemMasterField, ItemSection, BOMItem } from '@/contexts/ItemMasterContext';
import { FieldDialog } from '../dialogs/FieldDialog';
import { FieldDrawer } from '../dialogs/FieldDrawer';
import { TabManagementDialogs } from '../dialogs/TabManagementDialogs';
import { OptionDialog } from '../dialogs/OptionDialog';
import { ColumnManageDialog } from '../dialogs/ColumnManageDialog';
import { PathEditDialog } from '../dialogs/PathEditDialog';
import { PageDialog } from '../dialogs/PageDialog';
import { SectionDialog } from '../dialogs/SectionDialog';
import { MasterFieldDialog } from '../dialogs/MasterFieldDialog';
import { TemplateFieldDialog } from '../dialogs/TemplateFieldDialog';
import { LoadTemplateDialog } from '../dialogs/LoadTemplateDialog';
import { ColumnDialog } from '../dialogs/ColumnDialog';
import { SectionTemplateDialog } from '../dialogs/SectionTemplateDialog';
import { ImportSectionDialog } from '../dialogs/ImportSectionDialog';
import { ImportFieldDialog } from '../dialogs/ImportFieldDialog';
import type { CustomTab, AttributeSubTab } from '../hooks/useTabManagement';
import type { UnitOption } from '../hooks/useAttributeManagement';
interface TextboxColumn {
id: string;
name: string;
key: string;
}
interface ConditionField {
fieldId: string;
fieldName: string;
operator: string;
value: string;
logicOperator?: 'AND' | 'OR';
}
interface ConditionSection {
sectionId: string;
sectionTitle: string;
operator: string;
value: string;
logicOperator?: 'AND' | 'OR';
}
interface AttributeColumn {
id: string;
name: string;
key: string;
type: string;
required: boolean;
}
export interface ItemMasterDialogsProps {
isMobile: boolean;
selectedPage: ItemPage | null;
// Tab Management
isManageTabsDialogOpen: boolean;
setIsManageTabsDialogOpen: (open: boolean) => void;
customTabs: CustomTab[];
moveTabUp: (tabId: string) => void;
moveTabDown: (tabId: string) => void;
handleEditTabFromManage: (tabId: string) => void;
handleDeleteTab: (tabId: string) => void;
getTabIcon: (iconName: string) => React.ComponentType<{ className?: string }>;
setIsAddTabDialogOpen: (open: boolean) => void;
isDeleteTabDialogOpen: boolean;
setIsDeleteTabDialogOpen: (open: boolean) => void;
deletingTabId: string | null;
setDeletingTabId: (id: string | null) => void;
confirmDeleteTab: () => void;
isAddTabDialogOpen: boolean;
editingTabId: string | null;
setEditingTabId: (id: string | null) => void;
newTabLabel: string;
setNewTabLabel: (label: string) => void;
handleUpdateTab: () => void;
handleAddTab: () => void;
isManageAttributeTabsDialogOpen: boolean;
setIsManageAttributeTabsDialogOpen: (open: boolean) => void;
attributeSubTabs: AttributeSubTab[];
moveAttributeTabUp: (tabId: string) => void;
moveAttributeTabDown: (tabId: string) => void;
handleDeleteAttributeTab: (tabId: string) => void;
isDeleteAttributeTabDialogOpen: boolean;
setIsDeleteAttributeTabDialogOpen: (open: boolean) => void;
deletingAttributeTabId: string | null;
setDeletingAttributeTabId: (id: string | null) => void;
confirmDeleteAttributeTab: () => void;
isAddAttributeTabDialogOpen: boolean;
setIsAddAttributeTabDialogOpen: (open: boolean) => void;
editingAttributeTabId: string | null;
setEditingAttributeTabId: (id: string | null) => void;
newAttributeTabLabel: string;
setNewAttributeTabLabel: (label: string) => void;
handleUpdateAttributeTab: () => void;
handleAddAttributeTab: () => void;
// Option Dialog
isOptionDialogOpen: boolean;
setIsOptionDialogOpen: (open: boolean) => void;
newOptionValue: string;
setNewOptionValue: (value: string) => void;
newOptionLabel: string;
setNewOptionLabel: (value: string) => void;
newOptionColumnValues: Record<string, string>;
setNewOptionColumnValues: (values: Record<string, string>) => void;
newOptionInputType: string;
setNewOptionInputType: (type: string) => void;
newOptionRequired: boolean;
setNewOptionRequired: (required: boolean) => void;
newOptionOptions: string[];
setNewOptionOptions: (options: string[]) => void;
newOptionPlaceholder: string;
setNewOptionPlaceholder: (placeholder: string) => void;
newOptionDefaultValue: string;
setNewOptionDefaultValue: (value: string) => void;
editingOptionType: string;
attributeColumns: Record<string, AttributeColumn[]>;
handleAddOption: () => void;
// Column Manage Dialog
isColumnManageDialogOpen: boolean;
setIsColumnManageDialogOpen: (open: boolean) => void;
managingColumnType: string;
setAttributeColumns: React.Dispatch<React.SetStateAction<Record<string, AttributeColumn[]>>>;
newColumnName: string;
setNewColumnName: (name: string) => void;
newColumnKey: string;
setNewColumnKey: (key: string) => void;
newColumnType: string;
setNewColumnType: (type: string) => void;
newColumnRequired: boolean;
setNewColumnRequired: (required: boolean) => void;
// Path Edit Dialog
editingPathPageId: number | null;
setEditingPathPageId: (id: number | null) => void;
editingAbsolutePath: string;
setEditingAbsolutePath: (path: string) => void;
updateItemPage: (id: number, updates: Partial<ItemPage>) => void;
// Page Dialog
isPageDialogOpen: boolean;
setIsPageDialogOpen: (open: boolean) => void;
newPageName: string;
setNewPageName: (name: string) => void;
newPageItemType: 'FG' | 'PT' | 'SM' | 'RM' | 'CS';
setNewPageItemType: React.Dispatch<React.SetStateAction<'FG' | 'PT' | 'SM' | 'RM' | 'CS'>>;
handleAddPage: () => void;
// Section Dialog
isSectionDialogOpen: boolean;
setIsSectionDialogOpen: (open: boolean) => void;
newSectionType: 'fields' | 'bom';
setNewSectionType: (type: 'fields' | 'bom') => void;
newSectionTitle: string;
setNewSectionTitle: (title: string) => void;
newSectionDescription: string;
setNewSectionDescription: (description: string) => void;
handleAddSection: () => void;
sectionInputMode: 'new' | 'existing';
setSectionInputMode: (mode: 'new' | 'existing') => void;
sectionsAsTemplates: SectionTemplate[];
selectedSectionTemplateId: number | null;
setSelectedSectionTemplateId: (id: number | null) => void;
handleLinkTemplate: () => void;
// Field Dialog
isFieldDialogOpen: boolean;
setIsFieldDialogOpen: (open: boolean) => void;
selectedSectionForField: number | null;
editingFieldId: number | null;
setEditingFieldId: (id: number | null) => void;
fieldInputMode: 'new' | 'existing';
setFieldInputMode: (mode: 'new' | 'existing') => void;
showMasterFieldList: boolean;
setShowMasterFieldList: (show: boolean) => void;
selectedMasterFieldId: number | null;
setSelectedMasterFieldId: (id: number | null) => void;
textboxColumns: TextboxColumn[];
setTextboxColumns: React.Dispatch<React.SetStateAction<TextboxColumn[]>>;
newFieldConditionEnabled: boolean;
setNewFieldConditionEnabled: (enabled: boolean) => void;
newFieldConditionTargetType: 'field' | 'section';
setNewFieldConditionTargetType: (type: 'field' | 'section') => void;
newFieldConditionFields: ConditionField[];
setNewFieldConditionFields: React.Dispatch<React.SetStateAction<ConditionField[]>>;
newFieldConditionSections: ConditionSection[];
setNewFieldConditionSections: React.Dispatch<React.SetStateAction<ConditionSection[]>>;
tempConditionValue: string;
setTempConditionValue: (value: string) => void;
newFieldName: string;
setNewFieldName: (name: string) => void;
newFieldKey: string;
setNewFieldKey: (key: string) => void;
newFieldInputType: string;
setNewFieldInputType: (type: string) => void;
newFieldRequired: boolean;
setNewFieldRequired: (required: boolean) => void;
newFieldDescription: string;
setNewFieldDescription: (description: string) => void;
newFieldOptions: string[];
setNewFieldOptions: React.Dispatch<React.SetStateAction<string[]>>;
itemMasterFields: ItemMasterField[];
handleAddField: () => void;
isColumnDialogOpen: boolean;
setIsColumnDialogOpen: (open: boolean) => void;
editingColumnId: string | null;
setEditingColumnId: (id: string | null) => void;
columnName: string;
setColumnName: (name: string) => void;
columnKey: string;
setColumnKey: (key: string) => void;
// Master Field Dialog
isMasterFieldDialogOpen: boolean;
setIsMasterFieldDialogOpen: (open: boolean) => void;
editingMasterFieldId: number | null;
setEditingMasterFieldId: (id: number | null) => void;
newMasterFieldName: string;
setNewMasterFieldName: (name: string) => void;
newMasterFieldKey: string;
setNewMasterFieldKey: (key: string) => void;
newMasterFieldInputType: string;
setNewMasterFieldInputType: (type: string) => void;
newMasterFieldRequired: boolean;
setNewMasterFieldRequired: (required: boolean) => void;
newMasterFieldCategory: string;
setNewMasterFieldCategory: (category: string) => void;
newMasterFieldDescription: string;
setNewMasterFieldDescription: (description: string) => void;
newMasterFieldOptions: string[];
setNewMasterFieldOptions: React.Dispatch<React.SetStateAction<string[]>>;
newMasterFieldAttributeType: string;
setNewMasterFieldAttributeType: (type: string) => void;
newMasterFieldMultiColumn: boolean;
setNewMasterFieldMultiColumn: (multiColumn: boolean) => void;
newMasterFieldColumnCount: number;
setNewMasterFieldColumnCount: (count: number) => void;
newMasterFieldColumnNames: string[];
setNewMasterFieldColumnNames: React.Dispatch<React.SetStateAction<string[]>>;
handleUpdateMasterField: () => void;
handleAddMasterField: () => void;
// Section Template Dialog
isSectionTemplateDialogOpen: boolean;
setIsSectionTemplateDialogOpen: (open: boolean) => void;
editingSectionTemplateId: number | null;
setEditingSectionTemplateId: (id: number | null) => void;
newSectionTemplateTitle: string;
setNewSectionTemplateTitle: (title: string) => void;
newSectionTemplateDescription: string;
setNewSectionTemplateDescription: (description: string) => void;
newSectionTemplateCategory: string;
setNewSectionTemplateCategory: (category: string) => void;
newSectionTemplateType: 'fields' | 'bom';
setNewSectionTemplateType: (type: 'fields' | 'bom') => void;
handleUpdateSectionTemplate: () => void;
handleAddSectionTemplate: () => void;
// Template Field Dialog
isTemplateFieldDialogOpen: boolean;
setIsTemplateFieldDialogOpen: (open: boolean) => void;
editingTemplateFieldId: string | null;
setEditingTemplateFieldId: (id: string | null) => void;
templateFieldName: string;
setTemplateFieldName: (name: string) => void;
templateFieldKey: string;
setTemplateFieldKey: (key: string) => void;
templateFieldInputType: string;
setTemplateFieldInputType: (type: string) => void;
templateFieldRequired: boolean;
setTemplateFieldRequired: (required: boolean) => void;
templateFieldOptions: string[];
setTemplateFieldOptions: React.Dispatch<React.SetStateAction<string[]>>;
templateFieldDescription: string;
setTemplateFieldDescription: (description: string) => void;
templateFieldMultiColumn: boolean;
setTemplateFieldMultiColumn: (multiColumn: boolean) => void;
templateFieldColumnCount: number;
setTemplateFieldColumnCount: (count: number) => void;
templateFieldColumnNames: string[];
setTemplateFieldColumnNames: React.Dispatch<React.SetStateAction<string[]>>;
handleAddTemplateField: () => void;
templateFieldInputMode: 'new' | 'existing';
setTemplateFieldInputMode: (mode: 'new' | 'existing') => void;
templateFieldShowMasterFieldList: boolean;
setTemplateFieldShowMasterFieldList: (show: boolean) => void;
templateFieldSelectedMasterFieldId: number | null;
setTemplateFieldSelectedMasterFieldId: (id: number | null) => void;
// Load Template Dialog
isLoadTemplateDialogOpen: boolean;
setIsLoadTemplateDialogOpen: (open: boolean) => void;
sectionTemplates: SectionTemplate[];
selectedTemplateId: number | null;
setSelectedTemplateId: (id: number | null) => void;
handleLoadTemplate: () => void;
// Import Section Dialog
isImportSectionDialogOpen: boolean;
setIsImportSectionDialogOpen: (open: boolean) => void;
independentSections: ItemSection[];
selectedImportSectionId: number | null;
setSelectedImportSectionId: (id: number | null) => void;
handleImportSection: () => Promise<void>;
refreshIndependentSections: () => void;
getSectionUsage: (sectionId: number) => Promise<{ pages: { id: number; name: string }[] }>;
// Import Field Dialog
isImportFieldDialogOpen: boolean;
setIsImportFieldDialogOpen: (open: boolean) => void;
selectedImportFieldId: number | null;
setSelectedImportFieldId: (id: number | null) => void;
handleImportField: () => Promise<void>;
refreshIndependentFields: () => void;
getFieldUsage: (fieldId: number) => Promise<{ sections: { id: number; title: string }[] }>;
importFieldTargetSectionId: number | null;
}
export function ItemMasterDialogs({
isMobile,
selectedPage,
// Tab Management
isManageTabsDialogOpen,
setIsManageTabsDialogOpen,
customTabs,
moveTabUp,
moveTabDown,
handleEditTabFromManage,
handleDeleteTab,
getTabIcon,
setIsAddTabDialogOpen,
isDeleteTabDialogOpen,
setIsDeleteTabDialogOpen,
deletingTabId,
setDeletingTabId,
confirmDeleteTab,
isAddTabDialogOpen,
editingTabId,
setEditingTabId,
newTabLabel,
setNewTabLabel,
handleUpdateTab,
handleAddTab,
isManageAttributeTabsDialogOpen,
setIsManageAttributeTabsDialogOpen,
attributeSubTabs,
moveAttributeTabUp,
moveAttributeTabDown,
handleDeleteAttributeTab,
isDeleteAttributeTabDialogOpen,
setIsDeleteAttributeTabDialogOpen,
deletingAttributeTabId,
setDeletingAttributeTabId,
confirmDeleteAttributeTab,
isAddAttributeTabDialogOpen,
setIsAddAttributeTabDialogOpen,
editingAttributeTabId,
setEditingAttributeTabId,
newAttributeTabLabel,
setNewAttributeTabLabel,
handleUpdateAttributeTab,
handleAddAttributeTab,
// Option Dialog
isOptionDialogOpen,
setIsOptionDialogOpen,
newOptionValue,
setNewOptionValue,
newOptionLabel,
setNewOptionLabel,
newOptionColumnValues,
setNewOptionColumnValues,
newOptionInputType,
setNewOptionInputType,
newOptionRequired,
setNewOptionRequired,
newOptionOptions,
setNewOptionOptions,
newOptionPlaceholder,
setNewOptionPlaceholder,
newOptionDefaultValue,
setNewOptionDefaultValue,
editingOptionType,
attributeColumns,
handleAddOption,
// Column Manage Dialog
isColumnManageDialogOpen,
setIsColumnManageDialogOpen,
managingColumnType,
setAttributeColumns,
newColumnName,
setNewColumnName,
newColumnKey,
setNewColumnKey,
newColumnType,
setNewColumnType,
newColumnRequired,
setNewColumnRequired,
// Path Edit Dialog
editingPathPageId,
setEditingPathPageId,
editingAbsolutePath,
setEditingAbsolutePath,
updateItemPage,
// Page Dialog
isPageDialogOpen,
setIsPageDialogOpen,
newPageName,
setNewPageName,
newPageItemType,
setNewPageItemType,
handleAddPage,
// Section Dialog
isSectionDialogOpen,
setIsSectionDialogOpen,
newSectionType,
setNewSectionType,
newSectionTitle,
setNewSectionTitle,
newSectionDescription,
setNewSectionDescription,
handleAddSection,
sectionInputMode,
setSectionInputMode,
sectionsAsTemplates,
selectedSectionTemplateId,
setSelectedSectionTemplateId,
handleLinkTemplate,
// Field Dialog
isFieldDialogOpen,
setIsFieldDialogOpen,
selectedSectionForField,
editingFieldId,
setEditingFieldId,
fieldInputMode,
setFieldInputMode,
showMasterFieldList,
setShowMasterFieldList,
selectedMasterFieldId,
setSelectedMasterFieldId,
textboxColumns,
setTextboxColumns,
newFieldConditionEnabled,
setNewFieldConditionEnabled,
newFieldConditionTargetType,
setNewFieldConditionTargetType,
newFieldConditionFields,
setNewFieldConditionFields,
newFieldConditionSections,
setNewFieldConditionSections,
tempConditionValue,
setTempConditionValue,
newFieldName,
setNewFieldName,
newFieldKey,
setNewFieldKey,
newFieldInputType,
setNewFieldInputType,
newFieldRequired,
setNewFieldRequired,
newFieldDescription,
setNewFieldDescription,
newFieldOptions,
setNewFieldOptions,
itemMasterFields,
handleAddField,
isColumnDialogOpen,
setIsColumnDialogOpen,
editingColumnId,
setEditingColumnId,
columnName,
setColumnName,
columnKey,
setColumnKey,
// Master Field Dialog
isMasterFieldDialogOpen,
setIsMasterFieldDialogOpen,
editingMasterFieldId,
setEditingMasterFieldId,
newMasterFieldName,
setNewMasterFieldName,
newMasterFieldKey,
setNewMasterFieldKey,
newMasterFieldInputType,
setNewMasterFieldInputType,
newMasterFieldRequired,
setNewMasterFieldRequired,
newMasterFieldCategory,
setNewMasterFieldCategory,
newMasterFieldDescription,
setNewMasterFieldDescription,
newMasterFieldOptions,
setNewMasterFieldOptions,
newMasterFieldAttributeType,
setNewMasterFieldAttributeType,
newMasterFieldMultiColumn,
setNewMasterFieldMultiColumn,
newMasterFieldColumnCount,
setNewMasterFieldColumnCount,
newMasterFieldColumnNames,
setNewMasterFieldColumnNames,
handleUpdateMasterField,
handleAddMasterField,
// Section Template Dialog
isSectionTemplateDialogOpen,
setIsSectionTemplateDialogOpen,
editingSectionTemplateId,
setEditingSectionTemplateId,
newSectionTemplateTitle,
setNewSectionTemplateTitle,
newSectionTemplateDescription,
setNewSectionTemplateDescription,
newSectionTemplateCategory,
setNewSectionTemplateCategory,
newSectionTemplateType,
setNewSectionTemplateType,
handleUpdateSectionTemplate,
handleAddSectionTemplate,
// Template Field Dialog
isTemplateFieldDialogOpen,
setIsTemplateFieldDialogOpen,
editingTemplateFieldId,
setEditingTemplateFieldId,
templateFieldName,
setTemplateFieldName,
templateFieldKey,
setTemplateFieldKey,
templateFieldInputType,
setTemplateFieldInputType,
templateFieldRequired,
setTemplateFieldRequired,
templateFieldOptions,
setTemplateFieldOptions,
templateFieldDescription,
setTemplateFieldDescription,
templateFieldMultiColumn,
setTemplateFieldMultiColumn,
templateFieldColumnCount,
setTemplateFieldColumnCount,
templateFieldColumnNames,
setTemplateFieldColumnNames,
handleAddTemplateField,
templateFieldInputMode,
setTemplateFieldInputMode,
templateFieldShowMasterFieldList,
setTemplateFieldShowMasterFieldList,
templateFieldSelectedMasterFieldId,
setTemplateFieldSelectedMasterFieldId,
// Load Template Dialog
isLoadTemplateDialogOpen,
setIsLoadTemplateDialogOpen,
sectionTemplates,
selectedTemplateId,
setSelectedTemplateId,
handleLoadTemplate,
// Import Section Dialog
isImportSectionDialogOpen,
setIsImportSectionDialogOpen,
independentSections,
selectedImportSectionId,
setSelectedImportSectionId,
handleImportSection,
refreshIndependentSections,
getSectionUsage,
// Import Field Dialog
isImportFieldDialogOpen,
setIsImportFieldDialogOpen,
selectedImportFieldId,
setSelectedImportFieldId,
handleImportField,
refreshIndependentFields,
getFieldUsage,
importFieldTargetSectionId,
}: ItemMasterDialogsProps) {
return (
<>
<TabManagementDialogs
isManageTabsDialogOpen={isManageTabsDialogOpen}
setIsManageTabsDialogOpen={setIsManageTabsDialogOpen}
customTabs={customTabs}
moveTabUp={moveTabUp}
moveTabDown={moveTabDown}
handleEditTabFromManage={handleEditTabFromManage}
handleDeleteTab={handleDeleteTab}
getTabIcon={getTabIcon}
setIsAddTabDialogOpen={setIsAddTabDialogOpen}
isDeleteTabDialogOpen={isDeleteTabDialogOpen}
setIsDeleteTabDialogOpen={setIsDeleteTabDialogOpen}
deletingTabId={deletingTabId}
setDeletingTabId={setDeletingTabId}
confirmDeleteTab={confirmDeleteTab}
isAddTabDialogOpen={isAddTabDialogOpen}
editingTabId={editingTabId}
setEditingTabId={setEditingTabId}
newTabLabel={newTabLabel}
setNewTabLabel={setNewTabLabel}
handleUpdateTab={handleUpdateTab}
handleAddTab={handleAddTab}
isManageAttributeTabsDialogOpen={isManageAttributeTabsDialogOpen}
setIsManageAttributeTabsDialogOpen={setIsManageAttributeTabsDialogOpen}
attributeSubTabs={attributeSubTabs}
moveAttributeTabUp={moveAttributeTabUp}
moveAttributeTabDown={moveAttributeTabDown}
handleDeleteAttributeTab={handleDeleteAttributeTab}
isDeleteAttributeTabDialogOpen={isDeleteAttributeTabDialogOpen}
setIsDeleteAttributeTabDialogOpen={setIsDeleteAttributeTabDialogOpen}
deletingAttributeTabId={deletingAttributeTabId}
setDeletingAttributeTabId={setDeletingAttributeTabId}
confirmDeleteAttributeTab={confirmDeleteAttributeTab}
isAddAttributeTabDialogOpen={isAddAttributeTabDialogOpen}
setIsAddAttributeTabDialogOpen={setIsAddAttributeTabDialogOpen}
editingAttributeTabId={editingAttributeTabId}
setEditingAttributeTabId={setEditingAttributeTabId}
newAttributeTabLabel={newAttributeTabLabel}
setNewAttributeTabLabel={setNewAttributeTabLabel}
handleUpdateAttributeTab={handleUpdateAttributeTab}
handleAddAttributeTab={handleAddAttributeTab}
/>
<OptionDialog
isOpen={isOptionDialogOpen}
setIsOpen={setIsOptionDialogOpen}
newOptionValue={newOptionValue}
setNewOptionValue={setNewOptionValue}
newOptionLabel={newOptionLabel}
setNewOptionLabel={setNewOptionLabel}
newOptionColumnValues={newOptionColumnValues}
setNewOptionColumnValues={setNewOptionColumnValues}
newOptionInputType={newOptionInputType}
setNewOptionInputType={setNewOptionInputType}
newOptionRequired={newOptionRequired}
setNewOptionRequired={setNewOptionRequired}
newOptionOptions={newOptionOptions}
setNewOptionOptions={setNewOptionOptions}
newOptionPlaceholder={newOptionPlaceholder}
setNewOptionPlaceholder={setNewOptionPlaceholder}
newOptionDefaultValue={newOptionDefaultValue}
setNewOptionDefaultValue={setNewOptionDefaultValue}
editingOptionType={editingOptionType}
attributeSubTabs={attributeSubTabs}
attributeColumns={attributeColumns}
handleAddOption={handleAddOption}
/>
<ColumnManageDialog
isOpen={isColumnManageDialogOpen}
setIsOpen={setIsColumnManageDialogOpen}
managingColumnType={managingColumnType}
attributeSubTabs={attributeSubTabs}
attributeColumns={attributeColumns}
setAttributeColumns={setAttributeColumns}
newColumnName={newColumnName}
setNewColumnName={setNewColumnName}
newColumnKey={newColumnKey}
setNewColumnKey={setNewColumnKey}
newColumnType={newColumnType}
setNewColumnType={setNewColumnType}
newColumnRequired={newColumnRequired}
setNewColumnRequired={setNewColumnRequired}
/>
<PathEditDialog
editingPathPageId={editingPathPageId}
setEditingPathPageId={setEditingPathPageId}
editingAbsolutePath={editingAbsolutePath}
setEditingAbsolutePath={setEditingAbsolutePath}
updateItemPage={updateItemPage}
trackChange={() => {}}
/>
<PageDialog
isPageDialogOpen={isPageDialogOpen}
setIsPageDialogOpen={setIsPageDialogOpen}
newPageName={newPageName}
setNewPageName={setNewPageName}
newPageItemType={newPageItemType}
setNewPageItemType={setNewPageItemType}
handleAddPage={handleAddPage}
/>
<SectionDialog
isSectionDialogOpen={isSectionDialogOpen}
setIsSectionDialogOpen={setIsSectionDialogOpen}
newSectionType={newSectionType}
setNewSectionType={setNewSectionType}
newSectionTitle={newSectionTitle}
setNewSectionTitle={setNewSectionTitle}
newSectionDescription={newSectionDescription}
setNewSectionDescription={setNewSectionDescription}
handleAddSection={handleAddSection}
sectionInputMode={sectionInputMode}
setSectionInputMode={setSectionInputMode}
sectionTemplates={sectionsAsTemplates}
selectedTemplateId={selectedSectionTemplateId}
setSelectedTemplateId={setSelectedSectionTemplateId}
handleLinkTemplate={handleLinkTemplate}
/>
{/* 항목 추가/수정 다이얼로그 - 데스크톱 */}
{!isMobile && (
<FieldDialog
isOpen={isFieldDialogOpen}
onOpenChange={setIsFieldDialogOpen}
editingFieldId={editingFieldId}
setEditingFieldId={setEditingFieldId}
fieldInputMode={fieldInputMode}
setFieldInputMode={setFieldInputMode}
showMasterFieldList={showMasterFieldList}
setShowMasterFieldList={setShowMasterFieldList}
selectedMasterFieldId={selectedMasterFieldId}
setSelectedMasterFieldId={setSelectedMasterFieldId}
textboxColumns={textboxColumns}
setTextboxColumns={setTextboxColumns}
newFieldConditionEnabled={newFieldConditionEnabled}
setNewFieldConditionEnabled={setNewFieldConditionEnabled}
newFieldConditionTargetType={newFieldConditionTargetType}
setNewFieldConditionTargetType={setNewFieldConditionTargetType}
newFieldConditionFields={newFieldConditionFields}
setNewFieldConditionFields={setNewFieldConditionFields}
newFieldConditionSections={newFieldConditionSections}
setNewFieldConditionSections={setNewFieldConditionSections}
tempConditionValue={tempConditionValue}
setTempConditionValue={setTempConditionValue}
newFieldName={newFieldName}
setNewFieldName={setNewFieldName}
newFieldKey={newFieldKey}
setNewFieldKey={setNewFieldKey}
newFieldInputType={newFieldInputType}
setNewFieldInputType={setNewFieldInputType}
newFieldRequired={newFieldRequired}
setNewFieldRequired={setNewFieldRequired}
newFieldDescription={newFieldDescription}
setNewFieldDescription={setNewFieldDescription}
newFieldOptions={newFieldOptions}
setNewFieldOptions={setNewFieldOptions}
selectedSectionForField={selectedPage?.sections.find(s => s.id === selectedSectionForField) || null}
selectedPage={selectedPage || null}
itemMasterFields={itemMasterFields}
handleAddField={handleAddField}
setIsColumnDialogOpen={setIsColumnDialogOpen}
setEditingColumnId={setEditingColumnId}
setColumnName={setColumnName}
setColumnKey={setColumnKey}
/>
)}
{/* 항목 추가/수정 다이얼로그 - 모바일 (바텀시트) */}
{isMobile && (
<FieldDrawer
isOpen={isFieldDialogOpen}
onOpenChange={setIsFieldDialogOpen}
editingFieldId={editingFieldId}
setEditingFieldId={setEditingFieldId}
fieldInputMode={fieldInputMode}
setFieldInputMode={setFieldInputMode}
showMasterFieldList={showMasterFieldList}
setShowMasterFieldList={setShowMasterFieldList}
selectedMasterFieldId={selectedMasterFieldId}
setSelectedMasterFieldId={setSelectedMasterFieldId}
textboxColumns={textboxColumns}
setTextboxColumns={setTextboxColumns}
newFieldConditionEnabled={newFieldConditionEnabled}
setNewFieldConditionEnabled={setNewFieldConditionEnabled}
newFieldConditionTargetType={newFieldConditionTargetType}
setNewFieldConditionTargetType={setNewFieldConditionTargetType}
newFieldConditionFields={newFieldConditionFields}
setNewFieldConditionFields={setNewFieldConditionFields}
newFieldConditionSections={newFieldConditionSections}
setNewFieldConditionSections={setNewFieldConditionSections}
tempConditionValue={tempConditionValue}
setTempConditionValue={setTempConditionValue}
newFieldName={newFieldName}
setNewFieldName={setNewFieldName}
newFieldKey={newFieldKey}
setNewFieldKey={setNewFieldKey}
newFieldInputType={newFieldInputType}
setNewFieldInputType={setNewFieldInputType}
newFieldRequired={newFieldRequired}
setNewFieldRequired={setNewFieldRequired}
newFieldDescription={newFieldDescription}
setNewFieldDescription={setNewFieldDescription}
newFieldOptions={newFieldOptions}
setNewFieldOptions={setNewFieldOptions}
selectedSectionForField={selectedPage?.sections.find(s => s.id === selectedSectionForField) || null}
selectedPage={selectedPage || null}
itemMasterFields={itemMasterFields}
handleAddField={handleAddField}
setIsColumnDialogOpen={setIsColumnDialogOpen}
setEditingColumnId={setEditingColumnId}
setColumnName={setColumnName}
setColumnKey={setColumnKey}
/>
)}
{/* 텍스트박스 컬럼 추가/수정 다이얼로그 */}
<ColumnDialog
isColumnDialogOpen={isColumnDialogOpen}
setIsColumnDialogOpen={setIsColumnDialogOpen}
editingColumnId={editingColumnId}
setEditingColumnId={setEditingColumnId}
columnName={columnName}
setColumnName={setColumnName}
columnKey={columnKey}
setColumnKey={setColumnKey}
textboxColumns={textboxColumns}
setTextboxColumns={setTextboxColumns}
/>
<MasterFieldDialog
isMasterFieldDialogOpen={isMasterFieldDialogOpen}
setIsMasterFieldDialogOpen={setIsMasterFieldDialogOpen}
editingMasterFieldId={editingMasterFieldId}
setEditingMasterFieldId={setEditingMasterFieldId}
newMasterFieldName={newMasterFieldName}
setNewMasterFieldName={setNewMasterFieldName}
newMasterFieldKey={newMasterFieldKey}
setNewMasterFieldKey={setNewMasterFieldKey}
newMasterFieldInputType={newMasterFieldInputType}
setNewMasterFieldInputType={setNewMasterFieldInputType}
newMasterFieldRequired={newMasterFieldRequired}
setNewMasterFieldRequired={setNewMasterFieldRequired}
newMasterFieldCategory={newMasterFieldCategory}
setNewMasterFieldCategory={setNewMasterFieldCategory}
newMasterFieldDescription={newMasterFieldDescription}
setNewMasterFieldDescription={setNewMasterFieldDescription}
newMasterFieldOptions={newMasterFieldOptions}
setNewMasterFieldOptions={setNewMasterFieldOptions}
newMasterFieldAttributeType={newMasterFieldAttributeType}
setNewMasterFieldAttributeType={setNewMasterFieldAttributeType}
newMasterFieldMultiColumn={newMasterFieldMultiColumn}
setNewMasterFieldMultiColumn={setNewMasterFieldMultiColumn}
newMasterFieldColumnCount={newMasterFieldColumnCount}
setNewMasterFieldColumnCount={setNewMasterFieldColumnCount}
newMasterFieldColumnNames={newMasterFieldColumnNames}
setNewMasterFieldColumnNames={setNewMasterFieldColumnNames}
handleUpdateMasterField={handleUpdateMasterField}
handleAddMasterField={handleAddMasterField}
/>
<SectionTemplateDialog
isSectionTemplateDialogOpen={isSectionTemplateDialogOpen}
setIsSectionTemplateDialogOpen={setIsSectionTemplateDialogOpen}
editingSectionTemplateId={editingSectionTemplateId}
setEditingSectionTemplateId={setEditingSectionTemplateId}
newSectionTemplateTitle={newSectionTemplateTitle}
setNewSectionTemplateTitle={setNewSectionTemplateTitle}
newSectionTemplateDescription={newSectionTemplateDescription}
setNewSectionTemplateDescription={setNewSectionTemplateDescription}
newSectionTemplateCategory={newSectionTemplateCategory}
setNewSectionTemplateCategory={setNewSectionTemplateCategory}
newSectionTemplateType={newSectionTemplateType}
setNewSectionTemplateType={setNewSectionTemplateType}
handleUpdateSectionTemplate={handleUpdateSectionTemplate}
handleAddSectionTemplate={handleAddSectionTemplate}
/>
<TemplateFieldDialog
isTemplateFieldDialogOpen={isTemplateFieldDialogOpen}
setIsTemplateFieldDialogOpen={setIsTemplateFieldDialogOpen}
editingTemplateFieldId={editingTemplateFieldId}
setEditingTemplateFieldId={setEditingTemplateFieldId}
templateFieldName={templateFieldName}
setTemplateFieldName={setTemplateFieldName}
templateFieldKey={templateFieldKey}
setTemplateFieldKey={setTemplateFieldKey}
templateFieldInputType={templateFieldInputType}
setTemplateFieldInputType={setTemplateFieldInputType}
templateFieldRequired={templateFieldRequired}
setTemplateFieldRequired={setTemplateFieldRequired}
templateFieldOptions={templateFieldOptions}
setTemplateFieldOptions={setTemplateFieldOptions}
templateFieldDescription={templateFieldDescription}
setTemplateFieldDescription={setTemplateFieldDescription}
templateFieldMultiColumn={templateFieldMultiColumn}
setTemplateFieldMultiColumn={setTemplateFieldMultiColumn}
templateFieldColumnCount={templateFieldColumnCount}
setTemplateFieldColumnCount={setTemplateFieldColumnCount}
templateFieldColumnNames={templateFieldColumnNames}
setTemplateFieldColumnNames={setTemplateFieldColumnNames}
handleAddTemplateField={handleAddTemplateField}
itemMasterFields={itemMasterFields}
templateFieldInputMode={templateFieldInputMode}
setTemplateFieldInputMode={setTemplateFieldInputMode}
showMasterFieldList={templateFieldShowMasterFieldList}
setShowMasterFieldList={setTemplateFieldShowMasterFieldList}
selectedMasterFieldId={templateFieldSelectedMasterFieldId}
setSelectedMasterFieldId={setTemplateFieldSelectedMasterFieldId}
/>
<LoadTemplateDialog
isLoadTemplateDialogOpen={isLoadTemplateDialogOpen}
setIsLoadTemplateDialogOpen={setIsLoadTemplateDialogOpen}
sectionTemplates={sectionTemplates}
selectedTemplateId={selectedTemplateId}
setSelectedTemplateId={setSelectedTemplateId}
handleLoadTemplate={handleLoadTemplate}
/>
{/* 섹션 불러오기 다이얼로그 */}
<ImportSectionDialog
isOpen={isImportSectionDialogOpen}
setIsOpen={setIsImportSectionDialogOpen}
independentSections={independentSections}
selectedSectionId={selectedImportSectionId}
setSelectedSectionId={setSelectedImportSectionId}
onImport={handleImportSection}
onRefresh={refreshIndependentSections}
onGetUsage={getSectionUsage}
/>
{/* 필드 불러오기 다이얼로그 */}
<ImportFieldDialog
isOpen={isImportFieldDialogOpen}
setIsOpen={setIsImportFieldDialogOpen}
fields={itemMasterFields}
selectedFieldId={selectedImportFieldId}
setSelectedFieldId={setSelectedImportFieldId}
onImport={handleImportField}
onRefresh={refreshIndependentFields}
onGetUsage={getFieldUsage}
targetSectionTitle={
importFieldTargetSectionId
? selectedPage?.sections.find(s => s.id === importFieldTargetSectionId)?.title
: undefined
}
/>
</>
);
}

View File

@@ -1,2 +1,6 @@
export { DraggableSection } from './DraggableSection';
export { DraggableField } from './DraggableField';
export { DraggableField } from './DraggableField';
// 2025-12-24: Phase 2 UI 컴포넌트 분리
export { AttributeTabContent } from './AttributeTabContent';
// ItemMasterDialogs는 props가 너무 많아 사용하지 않음 (2025-12-24 결정)

View File

@@ -0,0 +1,93 @@
'use client';
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { AlertCircle } from 'lucide-react';
interface ErrorAlertState {
open: boolean;
title: string;
message: string;
}
interface ErrorAlertContextType {
showErrorAlert: (message: string, title?: string) => void;
}
const ErrorAlertContext = createContext<ErrorAlertContextType | null>(null);
/**
* 에러 알림 Context 사용 훅
*/
export function useErrorAlert() {
const context = useContext(ErrorAlertContext);
if (!context) {
throw new Error('useErrorAlert must be used within ErrorAlertProvider');
}
return context;
}
interface ErrorAlertProviderProps {
children: ReactNode;
}
/**
* 에러 알림 Provider
* ItemMasterDataManagement 컴포넌트에서 사용
*/
export function ErrorAlertProvider({ children }: ErrorAlertProviderProps) {
const [errorAlert, setErrorAlert] = useState<ErrorAlertState>({
open: false,
title: '오류',
message: '',
});
const showErrorAlert = useCallback((message: string, title: string = '오류') => {
setErrorAlert({
open: true,
title,
message,
});
}, []);
const closeErrorAlert = useCallback(() => {
setErrorAlert(prev => ({
...prev,
open: false,
}));
}, []);
return (
<ErrorAlertContext.Provider value={{ showErrorAlert }}>
{children}
{/* 에러 알림 다이얼로그 */}
<AlertDialog open={errorAlert.open} onOpenChange={(isOpen) => !isOpen && closeErrorAlert()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2 text-destructive">
<AlertCircle className="h-5 w-5" />
{errorAlert.title}
</AlertDialogTitle>
<AlertDialogDescription className="text-base text-foreground">
{errorAlert.message}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={closeErrorAlert}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ErrorAlertContext.Provider>
);
}

View File

@@ -0,0 +1 @@
export { ErrorAlertProvider, useErrorAlert } from './ErrorAlertContext';

View File

@@ -17,4 +17,17 @@ export { useAttributeManagement } from './useAttributeManagement';
export type { UseAttributeManagementReturn } from './useAttributeManagement';
export { useTabManagement } from './useTabManagement';
export type { UseTabManagementReturn, CustomTab, AttributeSubTab } from './useTabManagement';
export type { UseTabManagementReturn, CustomTab, AttributeSubTab } from './useTabManagement';
// 2025-12-24: 신규 훅 추가
export { useInitialDataLoading } from './useInitialDataLoading';
export type { UseInitialDataLoadingReturn } from './useInitialDataLoading';
export { useImportManagement } from './useImportManagement';
export type { UseImportManagementReturn } from './useImportManagement';
export { useReorderManagement } from './useReorderManagement';
export type { UseReorderManagementReturn } from './useReorderManagement';
export { useDeleteManagement } from './useDeleteManagement';
export type { UseDeleteManagementReturn } from './useDeleteManagement';

View File

@@ -0,0 +1,124 @@
'use client';
import { useCallback } from 'react';
import { useItemMaster } from '@/contexts/ItemMasterContext';
import { toast } from 'sonner';
import type { ItemPage, BOMItem } from '@/contexts/ItemMasterContext';
import type { CustomTab, AttributeSubTab } from './useTabManagement';
import type { MasterOption, OptionColumn } from '../types';
// 타입 alias (기존 호환성)
type UnitOption = MasterOption;
type MaterialOption = MasterOption;
type SurfaceTreatmentOption = MasterOption;
export interface UseDeleteManagementReturn {
handleDeletePage: (pageId: number) => void;
handleDeleteSection: (pageId: number, sectionId: number) => void;
handleUnlinkField: (pageId: string, sectionId: string, fieldId: string) => Promise<void>;
handleResetAllData: (
setUnitOptions: React.Dispatch<React.SetStateAction<UnitOption[]>>,
setMaterialOptions: React.Dispatch<React.SetStateAction<MaterialOption[]>>,
setSurfaceTreatmentOptions: React.Dispatch<React.SetStateAction<SurfaceTreatmentOption[]>>,
setCustomAttributeOptions: React.Dispatch<React.SetStateAction<Record<string, UnitOption[]>>>,
setAttributeColumns: React.Dispatch<React.SetStateAction<Record<string, OptionColumn[]>>>,
setBomItems: React.Dispatch<React.SetStateAction<BOMItem[]>>,
setCustomTabs: React.Dispatch<React.SetStateAction<CustomTab[]>>,
setAttributeSubTabs: React.Dispatch<React.SetStateAction<AttributeSubTab[]>>,
) => void;
}
interface UseDeleteManagementProps {
itemPages: ItemPage[];
}
export function useDeleteManagement({ itemPages }: UseDeleteManagementProps): UseDeleteManagementReturn {
const {
deleteItemPage,
deleteSection,
unlinkFieldFromSection,
resetAllData,
} = useItemMaster();
// 페이지 삭제 핸들러
const handleDeletePage = useCallback((pageId: number) => {
const pageToDelete = itemPages.find(p => p.id === pageId);
const sectionIds = pageToDelete?.sections.map(s => s.id) || [];
const fieldIds = pageToDelete?.sections.flatMap(s => s.fields?.map(f => f.id) || []) || [];
deleteItemPage(pageId);
console.log('페이지 삭제 완료:', { pageId, removedSections: sectionIds.length, removedFields: fieldIds.length });
}, [itemPages, deleteItemPage]);
// 섹션 삭제 핸들러
const handleDeleteSection = useCallback((pageId: number, sectionId: number) => {
const page = itemPages.find(p => p.id === pageId);
const sectionToDelete = page?.sections.find(s => s.id === sectionId);
const fieldIds = sectionToDelete?.fields?.map(f => f.id) || [];
deleteSection(Number(sectionId));
console.log('섹션 삭제 완료:', { sectionId, removedFields: fieldIds.length });
}, [itemPages, deleteSection]);
// 필드 연결 해제 핸들러
const handleUnlinkField = useCallback(async (_pageId: string, sectionId: string, fieldId: string) => {
try {
await unlinkFieldFromSection(Number(sectionId), Number(fieldId));
console.log('필드 연결 해제 완료:', fieldId);
} catch (error) {
console.error('필드 연결 해제 실패:', error);
toast.error('필드 연결 해제에 실패했습니다');
}
}, [unlinkFieldFromSection]);
// 전체 데이터 초기화 핸들러
const handleResetAllData = useCallback((
setUnitOptions: React.Dispatch<React.SetStateAction<UnitOption[]>>,
setMaterialOptions: React.Dispatch<React.SetStateAction<MaterialOption[]>>,
setSurfaceTreatmentOptions: React.Dispatch<React.SetStateAction<SurfaceTreatmentOption[]>>,
setCustomAttributeOptions: React.Dispatch<React.SetStateAction<Record<string, UnitOption[]>>>,
setAttributeColumns: React.Dispatch<React.SetStateAction<Record<string, OptionColumn[]>>>,
setBomItems: React.Dispatch<React.SetStateAction<BOMItem[]>>,
setCustomTabs: React.Dispatch<React.SetStateAction<CustomTab[]>>,
setAttributeSubTabs: React.Dispatch<React.SetStateAction<AttributeSubTab[]>>,
) => {
if (!confirm('⚠️ 경고: 모든 품목기준관리 데이터(계층구조, 섹션, 항목, 속성)를 초기화하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다!')) {
return;
}
try {
resetAllData();
setUnitOptions([]);
setMaterialOptions([]);
setSurfaceTreatmentOptions([]);
setCustomAttributeOptions({});
setAttributeColumns({});
setBomItems([]);
setCustomTabs([
{ id: 'hierarchy', label: '계층구조', icon: 'FolderTree', isDefault: true, order: 1 },
{ id: 'sections', label: '섹션', icon: 'Layers', isDefault: true, order: 2 },
{ id: 'items', label: '항목', icon: 'ListTree', isDefault: true, order: 3 },
{ id: 'attributes', label: '속성', icon: 'Settings', isDefault: true, order: 4 }
]);
setAttributeSubTabs([]);
console.log('🗑️ 모든 품목기준관리 데이터가 초기화되었습니다');
toast.success('✅ 모든 데이터가 초기화되었습니다!\n계층구조, 섹션, 항목, 속성이 모두 삭제되었습니다.');
setTimeout(() => {
window.location.reload();
}, 1500);
} catch (error) {
toast.error('초기화 중 오류가 발생했습니다');
console.error('Reset error:', error);
}
}, [resetAllData]);
return {
handleDeletePage,
handleDeleteSection,
handleUnlinkField,
handleResetAllData,
};
}

View File

@@ -0,0 +1,48 @@
'use client';
import { useState, useCallback } from 'react';
export interface ErrorAlertState {
open: boolean;
title: string;
message: string;
}
export interface UseErrorAlertReturn {
errorAlert: ErrorAlertState;
showErrorAlert: (message: string, title?: string) => void;
closeErrorAlert: () => void;
}
/**
* 에러 알림 다이얼로그 상태 관리 훅
* AlertDialog로 에러 메시지를 표시할 때 사용
*/
export function useErrorAlert(): UseErrorAlertReturn {
const [errorAlert, setErrorAlert] = useState<ErrorAlertState>({
open: false,
title: '오류',
message: '',
});
const showErrorAlert = useCallback((message: string, title: string = '오류') => {
setErrorAlert({
open: true,
title,
message,
});
}, []);
const closeErrorAlert = useCallback(() => {
setErrorAlert(prev => ({
...prev,
open: false,
}));
}, []);
return {
errorAlert,
showErrorAlert,
closeErrorAlert,
};
}

View File

@@ -3,9 +3,11 @@
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { useItemMaster } from '@/contexts/ItemMasterContext';
import { useErrorAlert } from '../contexts';
import type { ItemPage, ItemField, ItemMasterField, FieldDisplayCondition } from '@/contexts/ItemMasterContext';
import { type ConditionalFieldConfig } from '../components/ConditionalDisplayUI';
import { fieldService } from '../services';
import { ApiError } from '@/lib/api/error-handler';
export interface UseFieldManagementReturn {
// 다이얼로그 상태
@@ -79,6 +81,9 @@ export function useFieldManagement(): UseFieldManagementReturn {
updateItemMasterField,
} = useItemMaster();
// 에러 알림 (AlertDialog로 표시)
const { showErrorAlert } = useErrorAlert();
// 다이얼로그 상태
const [isFieldDialogOpen, setIsFieldDialogOpen] = useState(false);
const [selectedSectionForField, setSelectedSectionForField] = useState<number | null>(null);
@@ -238,7 +243,23 @@ export function useFieldManagement(): UseFieldManagementReturn {
resetFieldForm();
} catch (error) {
console.error('필드 처리 실패:', error);
toast.error('항목 처리에 실패했습니다');
// 422 ValidationException 상세 메시지 처리 (field_key 중복/예약어, field_name 중복 등)
if (error instanceof ApiError) {
console.log('🔍 ApiError.errors:', error.errors); // 디버깅용
// errors 객체에서 첫 번째 에러 메시지 추출 → AlertDialog로 표시
if (error.errors && Object.keys(error.errors).length > 0) {
const firstKey = Object.keys(error.errors)[0];
const firstError = error.errors[firstKey];
const errorMessage = Array.isArray(firstError) ? firstError[0] : firstError;
showErrorAlert(errorMessage, '항목 저장 실패');
} else {
showErrorAlert(error.message, '항목 저장 실패');
}
} else {
showErrorAlert('항목 처리에 실패했습니다', '오류');
}
}
};

View File

@@ -0,0 +1,112 @@
'use client';
import { useState, useCallback } from 'react';
import { useItemMaster } from '@/contexts/ItemMasterContext';
import { getErrorMessage } from '@/lib/api/error-handler';
import { toast } from 'sonner';
import type { ItemPage } from '@/contexts/ItemMasterContext';
export interface UseImportManagementReturn {
// 섹션 Import 상태
isImportSectionDialogOpen: boolean;
setIsImportSectionDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
selectedImportSectionId: number | null;
setSelectedImportSectionId: React.Dispatch<React.SetStateAction<number | null>>;
// 필드 Import 상태
isImportFieldDialogOpen: boolean;
setIsImportFieldDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
selectedImportFieldId: number | null;
setSelectedImportFieldId: React.Dispatch<React.SetStateAction<number | null>>;
importFieldTargetSectionId: number | null;
setImportFieldTargetSectionId: React.Dispatch<React.SetStateAction<number | null>>;
// 핸들러
handleImportSection: (selectedPageId: number | null) => Promise<void>;
handleImportField: (selectedPage: ItemPage | null) => Promise<void>;
handleCloneSection: (sectionId: number) => Promise<void>;
}
export function useImportManagement(): UseImportManagementReturn {
const {
linkSectionToPage,
linkFieldToSection,
cloneSection,
} = useItemMaster();
// 섹션 Import 상태
const [isImportSectionDialogOpen, setIsImportSectionDialogOpen] = useState(false);
const [selectedImportSectionId, setSelectedImportSectionId] = useState<number | null>(null);
// 필드 Import 상태
const [isImportFieldDialogOpen, setIsImportFieldDialogOpen] = useState(false);
const [selectedImportFieldId, setSelectedImportFieldId] = useState<number | null>(null);
const [importFieldTargetSectionId, setImportFieldTargetSectionId] = useState<number | null>(null);
// 섹션 불러오기 핸들러
const handleImportSection = useCallback(async (selectedPageId: number | null) => {
if (!selectedPageId || !selectedImportSectionId) return;
try {
await linkSectionToPage(selectedPageId, selectedImportSectionId);
toast.success('섹션을 불러왔습니다.');
setSelectedImportSectionId(null);
} catch (error) {
console.error('섹션 불러오기 실패:', error);
toast.error(getErrorMessage(error));
}
}, [selectedImportSectionId, linkSectionToPage]);
// 필드 불러오기 핸들러
const handleImportField = useCallback(async (selectedPage: ItemPage | null) => {
if (!importFieldTargetSectionId || !selectedImportFieldId) return;
try {
// 해당 섹션의 마지막 순서 + 1로 설정
const targetSection = selectedPage?.sections.find(s => s.id === importFieldTargetSectionId);
const existingFieldsCount = targetSection?.fields?.length ?? 0;
const newOrderNo = existingFieldsCount;
await linkFieldToSection(importFieldTargetSectionId, selectedImportFieldId, newOrderNo);
toast.success('필드를 섹션에 연결했습니다.');
setSelectedImportFieldId(null);
setImportFieldTargetSectionId(null);
} catch (error) {
console.error('필드 불러오기 실패:', error);
toast.error(getErrorMessage(error));
}
}, [importFieldTargetSectionId, selectedImportFieldId, linkFieldToSection]);
// 섹션 복제 핸들러
const handleCloneSection = useCallback(async (sectionId: number) => {
try {
await cloneSection(sectionId);
toast.success('섹션이 복제되었습니다.');
} catch (error) {
console.error('섹션 복제 실패:', error);
toast.error(getErrorMessage(error));
}
}, [cloneSection]);
return {
// 섹션 Import
isImportSectionDialogOpen,
setIsImportSectionDialogOpen,
selectedImportSectionId,
setSelectedImportSectionId,
// 필드 Import
isImportFieldDialogOpen,
setIsImportFieldDialogOpen,
selectedImportFieldId,
setSelectedImportFieldId,
importFieldTargetSectionId,
setImportFieldTargetSectionId,
// 핸들러
handleImportSection,
handleImportField,
handleCloneSection,
};
}

View File

@@ -0,0 +1,176 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useItemMaster } from '@/contexts/ItemMasterContext';
import { useItemMasterStore } from '@/stores/item-master/useItemMasterStore';
import { itemMasterApi } from '@/lib/api/item-master';
import { getErrorMessage, ApiError } from '@/lib/api/error-handler';
import {
transformPagesResponse,
transformSectionsResponse,
transformSectionTemplatesResponse,
transformFieldsResponse,
transformCustomTabsResponse,
transformUnitOptionsResponse,
transformSectionTemplateFromSection,
} from '@/lib/api/transformers';
import { toast } from 'sonner';
import type { CustomTab } from './useTabManagement';
import type { MasterOption } from '../types';
// 타입 alias
type UnitOption = MasterOption;
export interface UseInitialDataLoadingReturn {
isInitialLoading: boolean;
error: string | null;
reload: () => Promise<void>;
}
interface UseInitialDataLoadingProps {
setCustomTabs: React.Dispatch<React.SetStateAction<CustomTab[]>>;
setUnitOptions: React.Dispatch<React.SetStateAction<UnitOption[]>>;
}
export function useInitialDataLoading({
setCustomTabs,
setUnitOptions,
}: UseInitialDataLoadingProps): UseInitialDataLoadingReturn {
const {
loadItemPages,
loadSectionTemplates,
loadItemMasterFields,
loadIndependentSections,
loadIndependentFields,
} = useItemMaster();
// ✅ 2025-12-24: Zustand store 연동
const initFromApi = useItemMasterStore((state) => state.initFromApi);
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 초기 로딩이 이미 실행되었는지 추적하는 ref
const hasInitialLoadRun = useRef(false);
const loadInitialData = useCallback(async () => {
try {
setIsInitialLoading(true);
setError(null);
// ✅ Zustand store 초기화 (정규화된 상태로 저장)
// Context와 병행 운영 - 점진적 마이그레이션
try {
await initFromApi();
console.log('✅ [Zustand] Store initialized');
} catch (zustandError) {
// Zustand 초기화 실패해도 Context로 fallback
console.warn('⚠️ [Zustand] Init failed, falling back to Context:', zustandError);
}
const data = await itemMasterApi.init();
// 1. 페이지 데이터 로드 (섹션이 이미 포함되어 있음)
const transformedPages = transformPagesResponse(data.pages);
loadItemPages(transformedPages);
// 2. 독립 섹션 로드 (모든 재사용 가능 섹션)
if (data.sections && data.sections.length > 0) {
const transformedSections = transformSectionsResponse(data.sections);
loadIndependentSections(transformedSections);
console.log('✅ 독립 섹션 로드:', transformedSections.length);
}
// 3. 섹션 템플릿 로드
if (data.sectionTemplates && data.sectionTemplates.length > 0) {
const transformedTemplates = transformSectionTemplatesResponse(data.sectionTemplates);
loadSectionTemplates(transformedTemplates);
} else if (data.sections && data.sections.length > 0) {
const templates = data.sections
.filter((s: { is_template?: boolean }) => s.is_template)
.map(transformSectionTemplateFromSection);
if (templates.length > 0) {
loadSectionTemplates(templates);
}
}
// 4. 필드 로드
if (data.fields && data.fields.length > 0) {
const transformedFields = transformFieldsResponse(data.fields);
const independentOnlyFields = transformedFields.filter(
f => f.section_id === null || f.section_id === undefined
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
loadItemMasterFields(transformedFields as any);
loadIndependentFields(independentOnlyFields);
console.log('✅ 필드 로드:', {
total: transformedFields.length,
independent: independentOnlyFields.length,
});
}
// 5. 커스텀 탭 로드
if (data.customTabs && data.customTabs.length > 0) {
const transformedTabs = transformCustomTabsResponse(data.customTabs);
setCustomTabs(transformedTabs);
}
// 6. 단위 옵션 로드
if (data.unitOptions && data.unitOptions.length > 0) {
const transformedUnits = transformUnitOptionsResponse(data.unitOptions);
setUnitOptions(transformedUnits);
}
console.log('✅ Initial data loaded:', {
pages: data.pages?.length || 0,
sections: data.sections?.length || 0,
fields: data.fields?.length || 0,
customTabs: data.customTabs?.length || 0,
unitOptions: data.unitOptions?.length || 0,
});
} catch (err) {
if (err instanceof ApiError && err.errors) {
const errorMessages = Object.entries(err.errors)
.map(([field, messages]) => `${field}: ${messages.join(', ')}`)
.join('\n');
toast.error(errorMessages);
setError('입력값을 확인해주세요.');
} else {
const errorMessage = getErrorMessage(err);
setError(errorMessage);
toast.error(errorMessage);
}
console.error('❌ Failed to load initial data:', err);
} finally {
setIsInitialLoading(false);
}
}, [
loadItemPages,
loadSectionTemplates,
loadItemMasterFields,
loadIndependentSections,
loadIndependentFields,
setCustomTabs,
setUnitOptions,
]);
// 초기 로딩은 한 번만 실행 (의존성 배열의 함수들이 불안정해도 무한 루프 방지)
useEffect(() => {
if (hasInitialLoadRun.current) {
return;
}
hasInitialLoadRun.current = true;
loadInitialData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
isInitialLoading,
error,
reload: loadInitialData,
};
}

View File

@@ -3,8 +3,10 @@
import { useState } from 'react';
import { toast } from 'sonner';
import { useItemMaster } from '@/contexts/ItemMasterContext';
import { useErrorAlert } from '../contexts';
import type { ItemMasterField } from '@/contexts/ItemMasterContext';
import { masterFieldService } from '../services';
import { ApiError } from '@/lib/api/error-handler';
/**
* @deprecated 2025-11-27: item_fields로 통합됨.
@@ -44,10 +46,10 @@ export interface UseMasterFieldManagementReturn {
setNewMasterFieldColumnNames: React.Dispatch<React.SetStateAction<string[]>>;
// 핸들러
handleAddMasterField: () => void;
handleAddMasterField: () => Promise<void>;
handleEditMasterField: (field: ItemMasterField) => void;
handleUpdateMasterField: () => void;
handleDeleteMasterField: (id: number) => void;
handleUpdateMasterField: () => Promise<void>;
handleDeleteMasterField: (id: number) => Promise<void>;
resetMasterFieldForm: () => void;
}
@@ -59,6 +61,9 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn {
deleteItemMasterField,
} = useItemMaster();
// 에러 알림 (AlertDialog로 표시)
const { showErrorAlert } = useErrorAlert();
// 다이얼로그 상태
const [isMasterFieldDialogOpen, setIsMasterFieldDialogOpen] = useState(false);
const [editingMasterFieldId, setEditingMasterFieldId] = useState<number | null>(null);
@@ -77,7 +82,7 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn {
const [newMasterFieldColumnNames, setNewMasterFieldColumnNames] = useState<string[]>(['컬럼1', '컬럼2']);
// 마스터 항목 추가
const handleAddMasterField = () => {
const handleAddMasterField = async () => {
if (!newMasterFieldName.trim() || !newMasterFieldKey.trim()) {
toast.error('항목명과 필드 키를 입력해주세요');
return;
@@ -106,9 +111,30 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn {
},
};
addItemMasterField(newMasterFieldData as any);
resetMasterFieldForm();
toast.success('항목이 추가되었습니다');
try {
await addItemMasterField(newMasterFieldData as any);
resetMasterFieldForm();
toast.success('항목이 추가되었습니다');
} catch (error) {
console.error('항목 추가 실패:', error);
// 422 ValidationException 상세 메시지 처리 (field_key 중복/예약어) → AlertDialog로 표시
if (error instanceof ApiError) {
console.log('🔍 ApiError.errors:', error.errors); // 디버깅용
// errors 객체에서 첫 번째 에러 메시지 추출
if (error.errors && Object.keys(error.errors).length > 0) {
const firstKey = Object.keys(error.errors)[0];
const firstError = error.errors[firstKey];
const errorMessage = Array.isArray(firstError) ? firstError[0] : firstError;
showErrorAlert(errorMessage, '항목 추가 실패');
} else {
showErrorAlert(error.message, '항목 추가 실패');
}
} else {
showErrorAlert('항목 추가에 실패했습니다', '오류');
}
}
};
// 마스터 항목 수정 시작
@@ -134,7 +160,7 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn {
};
// 마스터 항목 업데이트
const handleUpdateMasterField = () => {
const handleUpdateMasterField = async () => {
if (!editingMasterFieldId || !newMasterFieldName.trim() || !newMasterFieldKey.trim()) {
toast.error('항목명과 필드 키를 입력해주세요');
return;
@@ -159,16 +185,47 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn {
},
};
updateItemMasterField(editingMasterFieldId, updateData);
resetMasterFieldForm();
toast.success('항목이 수정되었습니다');
try {
await updateItemMasterField(editingMasterFieldId, updateData);
resetMasterFieldForm();
toast.success('항목이 수정되었습니다');
} catch (error) {
console.error('항목 수정 실패:', error);
// 422 ValidationException 상세 메시지 처리 (field_key 중복/예약어, field_name 중복 등) → AlertDialog로 표시
if (error instanceof ApiError) {
console.log('🔍 ApiError.errors:', error.errors); // 디버깅용
// errors 객체에서 첫 번째 에러 메시지 추출
if (error.errors && Object.keys(error.errors).length > 0) {
const firstKey = Object.keys(error.errors)[0];
const firstError = error.errors[firstKey];
const errorMessage = Array.isArray(firstError) ? firstError[0] : firstError;
showErrorAlert(errorMessage, '항목 수정 실패');
} else {
showErrorAlert(error.message, '항목 수정 실패');
}
} else {
showErrorAlert('항목 수정에 실패했습니다', '오류');
}
}
};
// 항목 삭제 (2025-11-27: 마스터 항목 → 항목으로 통합)
const handleDeleteMasterField = (id: number) => {
const handleDeleteMasterField = async (id: number) => {
if (confirm('이 항목을 삭제하시겠습니까?\n(섹션에서 사용 중인 경우 연결도 함께 해제됩니다)')) {
deleteItemMasterField(id);
toast.success('항목이 삭제되었습니다');
try {
await deleteItemMasterField(id);
toast.success('항목이 삭제되었습니다');
} catch (error) {
console.error('항목 삭제 실패:', error);
if (error instanceof ApiError) {
toast.error(error.message);
} else {
toast.error('항목 삭제에 실패했습니다');
}
}
}
};

View File

@@ -0,0 +1,84 @@
'use client';
import { useCallback } from 'react';
import { useItemMaster } from '@/contexts/ItemMasterContext';
import { toast } from 'sonner';
import type { ItemPage } from '@/contexts/ItemMasterContext';
export interface UseReorderManagementReturn {
moveSection: (selectedPage: ItemPage | null, dragIndex: number, hoverIndex: number) => Promise<void>;
moveField: (selectedPage: ItemPage | null, sectionId: number, dragFieldId: number, hoverFieldId: number) => Promise<void>;
}
export function useReorderManagement(): UseReorderManagementReturn {
const {
reorderSections,
reorderFields,
} = useItemMaster();
// 섹션 순서 변경 핸들러 (드래그앤드롭)
const moveSection = useCallback(async (
selectedPage: ItemPage | null,
dragIndex: number,
hoverIndex: number
) => {
if (!selectedPage) return;
const sections = [...selectedPage.sections];
const [draggedSection] = sections.splice(dragIndex, 1);
sections.splice(hoverIndex, 0, draggedSection);
const sectionIds = sections.map(s => s.id);
try {
await reorderSections(selectedPage.id, sectionIds);
toast.success('섹션 순서가 변경되었습니다');
} catch (error) {
console.error('섹션 순서 변경 실패:', error);
toast.error('섹션 순서 변경에 실패했습니다');
}
}, [reorderSections]);
// 필드 순서 변경 핸들러
const moveField = useCallback(async (
selectedPage: ItemPage | null,
sectionId: number,
dragFieldId: number,
hoverFieldId: number
) => {
if (!selectedPage) return;
const section = selectedPage.sections.find(s => s.id === sectionId);
if (!section || !section.fields) return;
// 동일 필드면 스킵
if (dragFieldId === hoverFieldId) return;
// 정렬된 배열에서 ID로 인덱스 찾기
const sortedFields = [...section.fields].sort((a, b) => (a.order_no ?? 0) - (b.order_no ?? 0));
const dragIndex = sortedFields.findIndex(f => f.id === dragFieldId);
const hoverIndex = sortedFields.findIndex(f => f.id === hoverFieldId);
// 유효하지 않은 인덱스 체크
if (dragIndex === -1 || hoverIndex === -1) {
return;
}
// 드래그된 필드를 제거하고 새 위치에 삽입
const [draggedField] = sortedFields.splice(dragIndex, 1);
sortedFields.splice(hoverIndex, 0, draggedField);
const newFieldIds = sortedFields.map(f => f.id);
try {
await reorderFields(sectionId, newFieldIds);
toast.success('항목 순서가 변경되었습니다');
} catch (error) {
toast.error('항목 순서 변경에 실패했습니다');
}
}, [reorderFields]);
return {
moveSection,
moveField,
};
}

View File

@@ -3,8 +3,10 @@
import { useState } from 'react';
import { toast } from 'sonner';
import { useItemMaster } from '@/contexts/ItemMasterContext';
import { useErrorAlert } from '../contexts';
import type { ItemPage, SectionTemplate, TemplateField, BOMItem, ItemMasterField } from '@/contexts/ItemMasterContext';
import { templateService } from '../services';
import { ApiError } from '@/lib/api/error-handler';
export interface UseTemplateManagementReturn {
// 섹션 템플릿 다이얼로그 상태
@@ -112,6 +114,9 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
deleteBOMItem,
} = useItemMaster();
// 에러 알림 (AlertDialog로 표시)
const { showErrorAlert } = useErrorAlert();
// 섹션 템플릿 다이얼로그 상태
const [isSectionTemplateDialogOpen, setIsSectionTemplateDialogOpen] = useState(false);
const [editingSectionTemplateId, setEditingSectionTemplateId] = useState<number | null>(null);
@@ -348,7 +353,23 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
resetTemplateFieldForm();
} catch (error) {
console.error('항목 처리 실패:', error);
toast.error('항목 처리에 실패했습니다');
// 422 ValidationException 상세 메시지 처리 (field_key 중복/예약어, field_name 중복 등) → AlertDialog로 표시
if (error instanceof ApiError) {
console.log('🔍 ApiError.errors:', error.errors); // 디버깅용
// errors 객체에서 첫 번째 에러 메시지 추출
if (error.errors && Object.keys(error.errors).length > 0) {
const firstKey = Object.keys(error.errors)[0];
const firstError = error.errors[firstKey];
const errorMessage = Array.isArray(firstError) ? firstError[0] : firstError;
showErrorAlert(errorMessage, '항목 저장 실패');
} else {
showErrorAlert(error.message, '항목 저장 실패');
}
} else {
showErrorAlert('항목 처리에 실패했습니다', '오류');
}
}
};

View File

@@ -22,392 +22,56 @@ import type {
FieldUsageResponse,
} from '@/types/item-master-api';
// ===== Type Definitions =====
// 타입 정의는 별도 파일에서 import
export type {
BendingDetail,
BOMLine,
SpecificationMaster,
MaterialItemName,
ItemRevision,
ItemMaster,
ItemCategory,
ItemUnit,
ItemMaterial,
SurfaceTreatment,
PartTypeOption,
PartUsageOption,
GuideRailOption,
ItemFieldProperty,
ItemMasterField,
FieldDisplayCondition,
ItemField,
BOMItem,
ItemSection,
ItemPage,
TemplateField,
SectionTemplate,
} from '@/types/item-master.types';
// 전개도 상세 정보
export interface BendingDetail {
id: string;
no: number; // 번호
input: number; // 입력
elongation: number; // 연신율 (기본값 -1)
calculated: number; // 연신율 계산 후
sum: number; // 합계
shaded: boolean; // 음영 여부
aAngle?: number; // A각
}
// 부품구성표(BOM, Bill of Materials) - 자재 명세서
export interface BOMLine {
id: string;
childItemCode: string; // 구성 품목 코드
childItemName: string; // 구성 품목명
quantity: number; // 기준 수량
unit: string; // 단위
unitPrice?: number; // 단가
quantityFormula?: string; // 수량 계산식 (예: "W * 2", "H + 100")
note?: string; // 비고
// 절곡품 관련 (하위 절곡 부품용)
isBending?: boolean;
bendingDiagram?: string; // 전개도 이미지 URL
bendingDetails?: BendingDetail[]; // 전개도 상세 데이터
}
// 규격 마스터 (원자재/부자재용)
export interface SpecificationMaster {
id: string;
specificationCode: string; // 규격 코드 (예: 1.6T x 1219 x 2438)
itemType: 'RM' | 'SM'; // 원자재 | 부자재
itemName?: string; // 품목명 (예: SPHC-SD, SPCC-SD) - 품목명별 규격 필터링용
fieldCount: '1' | '2' | '3'; // 너비 입력 개수
thickness: string; // 두께
widthA: string; // 너비A
widthB?: string; // 너비B
widthC?: string; // 너비C
length: string; // 길이
description?: string; // 설명
isActive: boolean; // 활성 여부
createdAt?: string;
updatedAt?: string;
}
// 원자재/부자재 품목명 마스터
export interface MaterialItemName {
id: string;
itemType: 'RM' | 'SM'; // 원자재 | 부자재
itemName: string; // 품목명 (예: "SPHC-SD", "STS430")
category?: string; // 분류 (예: "냉연", "열연", "스테인리스")
description?: string; // 설명
isActive: boolean; // 활성 여부
createdAt: string;
updatedAt?: string;
}
// 품목 수정 이력
export interface ItemRevision {
revisionNumber: number; // 수정 차수 (1차, 2차, 3차...)
revisionDate: string; // 수정일
revisionBy: string; // 수정자
revisionReason?: string; // 수정 사유
previousData: any; // 이전 버전의 전체 데이터
}
// 품목 마스터
export interface ItemMaster {
id: string;
itemCode: string;
itemName: string;
itemType: 'FG' | 'PT' | 'SM' | 'RM' | 'CS'; // 제품, 부품, 부자재, 원자재, 소모품
productCategory?: 'SCREEN' | 'STEEL'; // 제품 카테고리 (스크린/철재)
partType?: 'ASSEMBLY' | 'BENDING' | 'PURCHASED'; // 부품 유형 (조립/절곡/구매)
partUsage?: 'GUIDE_RAIL' | 'BOTTOM_FINISH' | 'CASE' | 'DOOR' | 'BRACKET' | 'GENERAL'; // 부품 용도
unit: string;
category1?: string;
category2?: string;
category3?: string;
specification?: string;
isVariableSize?: boolean;
isActive?: boolean; // 품목 활성/비활성 (제품/부품/원자재/부자재만 사용)
lotAbbreviation?: string; // 로트 약자 (제품만 사용)
purchasePrice?: number;
marginRate?: number;
processingCost?: number;
laborCost?: number;
installCost?: number;
salesPrice?: number;
safetyStock?: number;
leadTime?: number;
bom?: BOMLine[]; // 부품구성표(BOM) - 자재 명세서
bomCategories?: string[]; // 견적산출용 샘플 제품의 BOM 카테고리 (예: ['motor', 'guide-rail'])
// 인정 정보
certificationNumber?: string; // 인정번호
certificationStartDate?: string; // 인정 유효기간 시작일
certificationEndDate?: string; // 인정 유효기간 종료일
specificationFile?: string; // 시방서 파일 (Base64 또는 URL)
specificationFileName?: string; // 시방서 파일명
certificationFile?: string; // 인정서 파일 (Base64 또는 URL)
certificationFileName?: string; // 인정서 파일명
note?: string; // 비고 (제품만 사용)
// 조립 부품 관련 필드
installationType?: string; // 설치 유형 (wall: 벽면형, side: 측면형, steel: 스틸, iron: 철재)
assemblyType?: string; // 종류 (M, T, C, D, S, U 등)
sideSpecWidth?: string; // 측면 규격 가로 (mm)
sideSpecHeight?: string; // 측면 규격 세로 (mm)
assemblyLength?: string; // 길이 (2438, 3000, 3500, 4000, 4300 등)
// 가이드레일 관련 필드
guideRailModelType?: string; // 가이드레일 모델 유형
guideRailModel?: string; // 가이드레일 모델
// 절곡품 관련 (부품 유형이 BENDING인 경우)
bendingDiagram?: string; // 전개도 이미지 URL
bendingDetails?: BendingDetail[]; // 전개도 상세 데이터
material?: string; // 재질 (EGI 1.55T, SUS 1.2T 등)
length?: string; // 길이/목함 (mm)
// 버전 관리
currentRevision: number; // 현재 차수 (0 = 최초, 1 = 1차 수정...)
revisions?: ItemRevision[]; // 수정 이력
isFinal: boolean; // 최종 확정 여부
finalizedDate?: string; // 최종 확정일
finalizedBy?: string; // 최종 확정자
createdAt: string;
}
// 품목 기준정보 관리 (Master Data)
export interface ItemCategory {
id: string;
categoryType: 'PRODUCT' | 'PART' | 'MATERIAL' | 'SUB_MATERIAL'; // 품목 구분
category1: string; // 대분류
category2?: string; // 중분류
category3?: string; // 소분류
code?: string; // 코드 (자동생성 또는 수동입력)
description?: string;
isActive: boolean;
createdAt: string;
updatedAt?: string;
}
export interface ItemUnit {
id: string;
unitCode: string; // 단위 코드 (EA, SET, M, KG, L 등)
unitName: string; // 단위명
description?: string;
isActive: boolean;
createdAt: string;
updatedAt?: string;
}
export interface ItemMaterial {
id: string;
materialCode: string; // 재질 코드
materialName: string; // 재질명 (EGI 1.55T, SUS 1.2T 등)
materialType: 'STEEL' | 'ALUMINUM' | 'PLASTIC' | 'OTHER'; // 재질 유형
thickness?: string; // 두께 (1.2T, 1.6T 등)
description?: string;
isActive: boolean;
createdAt: string;
updatedAt?: string;
}
export interface SurfaceTreatment {
id: string;
treatmentCode: string; // 처리 코드
treatmentName: string; // 처리명 (무도장, 파우더도장, 아노다이징 등)
treatmentType: 'PAINTING' | 'COATING' | 'PLATING' | 'NONE'; // 처리 유형
description?: string;
isActive: boolean;
createdAt: string;
updatedAt?: string;
}
export interface PartTypeOption {
id: string;
partType: 'ASSEMBLY' | 'BENDING' | 'PURCHASED'; // 부품 유형
optionCode: string; // 옵션 코드
optionName: string; // 옵션명
description?: string;
isActive: boolean;
createdAt: string;
updatedAt?: string;
}
export interface PartUsageOption {
id: string;
usageCode: string; // 용도 코드
usageName: string; // 용도명 (가이드레일, 하단마감재, 케이스 등)
description?: string;
isActive: boolean;
createdAt: string;
updatedAt?: string;
}
export interface GuideRailOption {
id: string;
optionType: 'MODEL_TYPE' | 'MODEL' | 'CERTIFICATION' | 'SHAPE' | 'FINISH' | 'LENGTH'; // 옵션 유형
optionCode: string; // 옵션 코드
optionName: string; // 옵션명
parentOption?: string; // 상위 옵션 (종속 관계)
description?: string;
isActive: boolean;
createdAt: string;
updatedAt?: string;
}
// ===== 품목기준관리 계층구조 =====
// 항목 속성
export interface ItemFieldProperty {
id?: string; // 속성 ID (properties 배열에서 사용)
key?: string; // 속성 키 (properties 배열에서 사용)
label?: string; // 속성 라벨 (properties 배열에서 사용)
type?: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea'; // 속성 타입 (properties 배열에서 사용)
inputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea' | 'section'; // 입력방식
required: boolean; // 필수 여부
row: number; // 행 위치
col: number; // 열 위치
options?: string[]; // 드롭다운 옵션 (입력방식이 dropdown일 경우)
defaultValue?: string; // 기본값
placeholder?: string; // 플레이스홀더
multiColumn?: boolean; // 다중 컬럼 사용 여부
columnCount?: number; // 컬럼 개수
columnNames?: string[]; // 각 컬럼의 이름
}
// 항목 마스터 (재사용 가능한 항목 템플릿) - MasterFieldResponse와 정확히 일치
export interface ItemMasterField {
id: number;
tenant_id: number;
field_name: string;
field_key?: string | null; // 2025-11-28: field_key 추가 (형식: {ID}_{사용자입력})
field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea'; // API와 동일
category: string | null;
description: string | null;
is_common: boolean; // 공통 필드 여부
is_required?: boolean; // 필수 여부 (API에서 반환)
default_value: string | null; // 기본값
options: Array<{ label: string; value: string }> | null; // dropdown 옵션
validation_rules: Record<string, any> | null; // 검증 규칙
properties: Record<string, any> | null; // 추가 속성
created_by: number | null;
updated_by: number | null;
created_at: string;
updated_at: string;
}
// 조건부 표시 설정
export interface FieldDisplayCondition {
targetType: 'field' | 'section'; // 조건 대상 타입
// 일반항목 조건 (여러 개 가능)
fieldConditions?: Array<{
fieldKey: string; // 조건이 되는 필드의 키
expectedValue: string; // 예상되는 값
}>;
// 섹션 조건 (여러 개 가능)
sectionIds?: string[]; // 표시할 섹션 ID 배열
}
// 항목 (Field) - API 응답 구조에 맞춰 수정
export interface ItemField {
id: number; // 서버 생성 ID (string → number)
tenant_id?: number; // 백엔드에서 자동 추가
group_id?: number | null; // 그룹 ID (독립 필드용)
section_id: number | null; // 외래키 - 섹션 ID (독립 필드는 null)
master_field_id?: number | null; // 마스터 항목 ID (마스터에서 가져온 경우)
field_name: string; // 항목명 (name → field_name)
field_key?: string | null; // 2025-11-28: 필드 키 (형식: {ID}_{사용자입력}, 백엔드에서 생성)
field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea'; // 필드 타입
order_no: number; // 항목 순서 (order → order_no, required)
is_required: boolean; // 필수 여부
placeholder?: string | null; // 플레이스홀더
default_value?: string | null; // 기본값
display_condition?: Record<string, any> | null; // 조건부 표시 설정 (displayCondition → display_condition)
validation_rules?: Record<string, any> | null; // 검증 규칙
options?: Array<{ label: string; value: string }> | null; // dropdown 옵션
properties?: Record<string, any> | null; // 추가 속성
// 2025-11-28 추가: 잠금 기능
is_locked?: boolean; // 잠금 여부
locked_by?: number | null; // 잠금 설정자
locked_at?: string | null; // 잠금 시간
created_by?: number | null; // 생성자 ID 추가
updated_by?: number | null; // 수정자 ID 추가
created_at: string; // 생성일 (camelCase → snake_case)
updated_at: string; // 수정일 추가
}
// BOM 아이템 타입 - API 응답 구조에 맞춰 수정
export interface BOMItem {
id: number; // 서버 생성 ID (string → number)
tenant_id?: number; // 백엔드에서 자동 추가
group_id?: number | null; // 그룹 ID (독립 BOM용)
section_id: number | null; // 외래키 - 섹션 ID (독립 BOM은 null)
item_code?: string | null; // 품목 코드 (itemCode → item_code, optional)
item_name: string; // 품목명 (itemName → item_name)
quantity: number; // 수량
unit?: string | null; // 단위 (optional)
unit_price?: number | null; // 단가 추가
total_price?: number | null; // 총액 추가
spec?: string | null; // 규격/사양 추가
note?: string | null; // 비고 (optional)
created_by?: number | null; // 생성자 ID 추가
updated_by?: number | null; // 수정자 ID 추가
created_at: string; // 생성일 (createdAt → created_at)
updated_at: string; // 수정일 추가
}
// 섹션 (Section) - API 응답 구조에 맞춰 수정
export interface ItemSection {
id: number; // 서버 생성 ID (string → number)
tenant_id?: number; // 백엔드에서 자동 추가
group_id?: number | null; // 그룹 ID (독립 섹션 그룹화용) - 2025-11-26 추가
page_id: number | null; // 외래키 - 페이지 ID (null이면 독립 섹션) - 2025-11-26 수정
title: string; // 섹션 제목 (API 필드명과 일치하도록 section_name → title)
section_type: 'BASIC' | 'BOM' | 'CUSTOM'; // 섹션 타입 (type → section_type, 값 변경)
description?: string | null; // 설명
order_no: number; // 섹션 순서 (order → order_no)
is_template: boolean; // 템플릿 여부 (section_templates 통합) - 2025-11-26 추가
is_default: boolean; // 기본 템플릿 여부 - 2025-11-26 추가
is_collapsible?: boolean; // 접기/펼치기 가능 여부 (프론트엔드 전용, optional)
is_default_open?: boolean; // 기본 열림 상태 (프론트엔드 전용, optional)
created_by?: number | null; // 생성자 ID 추가
updated_by?: number | null; // 수정자 ID 추가
created_at: string; // 생성일 (camelCase → snake_case)
updated_at: string; // 수정일 추가
fields?: ItemField[]; // 섹션에 포함된 항목들 (optional로 변경)
bom_items?: BOMItem[]; // BOM 타입일 경우 BOM 품목 목록 (bomItems → bom_items)
}
// 페이지 (Page) - API 응답 구조에 맞춰 수정
export interface ItemPage {
id: number; // 서버 생성 ID (string → number)
tenant_id?: number; // 백엔드에서 자동 추가
page_name: string; // 페이지명 (camelCase → snake_case)
item_type: 'FG' | 'PT' | 'SM' | 'RM' | 'CS'; // 품목유형
description?: string | null; // 설명 추가
absolute_path: string; // 절대경로 (camelCase → snake_case)
is_active: boolean; // 사용 여부 (camelCase → snake_case)
order_no: number; // 순서 번호 추가
created_by?: number | null; // 생성자 ID 추가
updated_by?: number | null; // 수정자 ID 추가
created_at: string; // 생성일 (camelCase → snake_case)
updated_at: string; // 수정일 (camelCase → snake_case)
sections: ItemSection[]; // 페이지에 포함된 섹션들 (Nested)
}
// 템플릿 필드 (로컬 관리용 - API에서 제공하지 않음)
export interface TemplateField {
id: string;
name: string;
fieldKey: string;
property: {
inputType: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
required: boolean;
options?: string[];
multiColumn?: boolean;
columnCount?: number;
columnNames?: string[];
};
description?: string;
}
// 섹션 템플릿 (재사용 가능한 섹션) - Transformer 출력과 UI 요구사항에 맞춤
export interface SectionTemplate {
id: number;
tenant_id: number;
template_name: string; // transformer가 title → template_name으로 변환
section_type: 'BASIC' | 'BOM' | 'CUSTOM'; // transformer가 type → section_type으로 변환
description: string | null;
default_fields: TemplateField[] | null; // 기본 필드 (로컬 관리)
category?: string[]; // 적용 카테고리 (로컬 관리)
fields?: TemplateField[]; // 템플릿에 포함된 필드 (로컬 관리)
bomItems?: BOMItem[]; // BOM 타입일 경우 BOM 품목 (로컬 관리)
created_by: number | null;
updated_by: number | null;
created_at: string;
updated_at: string;
}
import type {
BendingDetail,
BOMLine,
SpecificationMaster,
MaterialItemName,
ItemRevision,
ItemMaster,
ItemCategory,
ItemUnit,
ItemMaterial,
SurfaceTreatment,
PartTypeOption,
PartUsageOption,
GuideRailOption,
ItemFieldProperty,
ItemMasterField,
FieldDisplayCondition,
ItemField,
BOMItem,
ItemSection,
ItemPage,
TemplateField,
SectionTemplate,
} from '@/types/item-master.types';
// ===== Context Type =====
interface ItemMasterContextType {
@@ -1295,11 +959,22 @@ export function ItemMasterProvider({ children }: { children: ReactNode }) {
throw new Error(response.message || '페이지 수정 실패');
}
// 응답 데이터 변환 및 state 업데이트
const updatedPage = transformPageResponse(response.data);
setItemPages(prev => prev.map(page => page.id === id ? updatedPage : page));
// ⚠️ 2026-01-06: 변경 요청한 필드만 업데이트
// API 응답(response.data)에 sections가 빈 배열로 오기 때문에
// 응답 전체를 덮어쓰면 기존 섹션이 사라지는 버그 발생
// → 변경한 필드(page_name, absolute_path)만 업데이트하고 나머지는 기존 값 유지
setItemPages(prev => prev.map(page => {
if (page.id === id) {
return {
...page,
page_name: updates.page_name ?? page.page_name,
absolute_path: updates.absolute_path ?? page.absolute_path,
};
}
return page;
}));
console.log('[ItemMasterContext] 페이지 수정 성공:', updatedPage);
console.log('[ItemMasterContext] 페이지 수정 성공:', { id, updates });
} catch (error) {
const errorMessage = getErrorMessage(error);
console.error('[ItemMasterContext] 페이지 수정 실패:', errorMessage);

View File

@@ -38,6 +38,11 @@ import type {
LinkEntityRequest,
LinkBomRequest,
ReorderRelationshipsRequest,
// 2025-12-21 추가: 재질/표면처리 타입
MaterialOptionRequest,
MaterialOptionResponse,
TreatmentOptionRequest,
TreatmentOptionResponse,
} from '@/types/item-master-api';
import { getAuthHeaders } from './auth-headers';
import { handleApiError } from './error-handler';
@@ -1893,6 +1898,36 @@ export const itemMasterApi = {
}
},
update: async (id: number, data: Partial<UnitOptionRequest>): Promise<ApiResponse<UnitOptionResponse>> => {
const startTime = apiLogger.logRequest('PUT', `${BASE_URL}/item-master/unit-options/${id}`, data);
try {
const headers = getAuthHeaders();
const response = await fetch(`${BASE_URL}/item-master/unit-options/${id}`, {
method: 'PUT',
headers,
body: JSON.stringify(data),
});
if (!response.ok) {
await handleApiError(response);
}
const result: ApiResponse<UnitOptionResponse> = await response.json();
apiLogger.logResponse('PUT', `${BASE_URL}/item-master/unit-options/${id}`, response.status, result, startTime);
return result;
} catch (error) {
if (error instanceof TypeError) {
apiLogger.logError('PUT', `${BASE_URL}/item-master/unit-options/${id}`, error, undefined, startTime);
throw new Error('네트워크 연결을 확인해주세요. 서버에 연결할 수 없습니다.');
}
apiLogger.logError('PUT', `${BASE_URL}/item-master/unit-options/${id}`, error as Error, undefined, startTime);
throw error;
}
},
delete: async (id: number): Promise<ApiResponse<void>> => {
const startTime = apiLogger.logRequest('DELETE', `${BASE_URL}/item-master/unit-options/${id}`);
@@ -1922,4 +1957,276 @@ export const itemMasterApi = {
}
},
},
// ============================================
// 재질 관리
// ============================================
materials: {
list: async (): Promise<ApiResponse<any[]>> => {
const startTime = apiLogger.logRequest('GET', `${BASE_URL}/item-master/materials`);
try {
const headers = getAuthHeaders();
const response = await fetch(`${BASE_URL}/item-master/materials`, {
method: 'GET',
headers,
});
if (!response.ok) {
await handleApiError(response);
}
const result: ApiResponse<any[]> = await response.json();
apiLogger.logResponse('GET', `${BASE_URL}/item-master/materials`, response.status, result, startTime);
return result;
} catch (error) {
if (error instanceof TypeError) {
apiLogger.logError('GET', `${BASE_URL}/item-master/materials`, error, undefined, startTime);
throw new Error('네트워크 연결을 확인해주세요. 서버에 연결할 수 없습니다.');
}
apiLogger.logError('GET', `${BASE_URL}/item-master/materials`, error as Error, undefined, startTime);
throw error;
}
},
create: async (data: {
material_code: string;
material_name: string;
material_type: string;
thickness?: string;
description?: string;
is_active?: boolean;
}): Promise<ApiResponse<any>> => {
const startTime = apiLogger.logRequest('POST', `${BASE_URL}/item-master/materials`, data);
try {
const headers = getAuthHeaders();
const response = await fetch(`${BASE_URL}/item-master/materials`, {
method: 'POST',
headers,
body: JSON.stringify(data),
});
if (!response.ok) {
await handleApiError(response);
}
const result: ApiResponse<any> = await response.json();
apiLogger.logResponse('POST', `${BASE_URL}/item-master/materials`, response.status, result, startTime);
return result;
} catch (error) {
if (error instanceof TypeError) {
apiLogger.logError('POST', `${BASE_URL}/item-master/materials`, error, undefined, startTime);
throw new Error('네트워크 연결을 확인해주세요. 서버에 연결할 수 없습니다.');
}
apiLogger.logError('POST', `${BASE_URL}/item-master/materials`, error as Error, undefined, startTime);
throw error;
}
},
update: async (id: number | string, data: {
material_code?: string;
material_name?: string;
material_type?: string;
thickness?: string;
description?: string;
is_active?: boolean;
}): Promise<ApiResponse<any>> => {
const startTime = apiLogger.logRequest('PUT', `${BASE_URL}/item-master/materials/${id}`, data);
try {
const headers = getAuthHeaders();
const response = await fetch(`${BASE_URL}/item-master/materials/${id}`, {
method: 'PUT',
headers,
body: JSON.stringify(data),
});
if (!response.ok) {
await handleApiError(response);
}
const result: ApiResponse<any> = await response.json();
apiLogger.logResponse('PUT', `${BASE_URL}/item-master/materials/${id}`, response.status, result, startTime);
return result;
} catch (error) {
if (error instanceof TypeError) {
apiLogger.logError('PUT', `${BASE_URL}/item-master/materials/${id}`, error, undefined, startTime);
throw new Error('네트워크 연결을 확인해주세요. 서버에 연결할 수 없습니다.');
}
apiLogger.logError('PUT', `${BASE_URL}/item-master/materials/${id}`, error as Error, undefined, startTime);
throw error;
}
},
delete: async (id: number | string): Promise<ApiResponse<void>> => {
const startTime = apiLogger.logRequest('DELETE', `${BASE_URL}/item-master/materials/${id}`);
try {
const headers = getAuthHeaders();
const response = await fetch(`${BASE_URL}/item-master/materials/${id}`, {
method: 'DELETE',
headers,
});
if (!response.ok) {
await handleApiError(response);
}
const result: ApiResponse<void> = await response.json();
apiLogger.logResponse('DELETE', `${BASE_URL}/item-master/materials/${id}`, response.status, result, startTime);
return result;
} catch (error) {
if (error instanceof TypeError) {
apiLogger.logError('DELETE', `${BASE_URL}/item-master/materials/${id}`, error, undefined, startTime);
throw new Error('네트워크 연결을 확인해주세요. 서버에 연결할 수 없습니다.');
}
apiLogger.logError('DELETE', `${BASE_URL}/item-master/materials/${id}`, error as Error, undefined, startTime);
throw error;
}
},
},
// ============================================
// 표면처리 관리
// ============================================
treatments: {
list: async (): Promise<ApiResponse<any[]>> => {
const startTime = apiLogger.logRequest('GET', `${BASE_URL}/item-master/surface-treatments`);
try {
const headers = getAuthHeaders();
const response = await fetch(`${BASE_URL}/item-master/surface-treatments`, {
method: 'GET',
headers,
});
if (!response.ok) {
await handleApiError(response);
}
const result: ApiResponse<any[]> = await response.json();
apiLogger.logResponse('GET', `${BASE_URL}/item-master/surface-treatments`, response.status, result, startTime);
return result;
} catch (error) {
if (error instanceof TypeError) {
apiLogger.logError('GET', `${BASE_URL}/item-master/surface-treatments`, error, undefined, startTime);
throw new Error('네트워크 연결을 확인해주세요. 서버에 연결할 수 없습니다.');
}
apiLogger.logError('GET', `${BASE_URL}/item-master/surface-treatments`, error as Error, undefined, startTime);
throw error;
}
},
create: async (data: {
treatment_code: string;
treatment_name: string;
treatment_type: string;
description?: string;
is_active?: boolean;
}): Promise<ApiResponse<any>> => {
const startTime = apiLogger.logRequest('POST', `${BASE_URL}/item-master/surface-treatments`, data);
try {
const headers = getAuthHeaders();
const response = await fetch(`${BASE_URL}/item-master/surface-treatments`, {
method: 'POST',
headers,
body: JSON.stringify(data),
});
if (!response.ok) {
await handleApiError(response);
}
const result: ApiResponse<any> = await response.json();
apiLogger.logResponse('POST', `${BASE_URL}/item-master/surface-treatments`, response.status, result, startTime);
return result;
} catch (error) {
if (error instanceof TypeError) {
apiLogger.logError('POST', `${BASE_URL}/item-master/surface-treatments`, error, undefined, startTime);
throw new Error('네트워크 연결을 확인해주세요. 서버에 연결할 수 없습니다.');
}
apiLogger.logError('POST', `${BASE_URL}/item-master/surface-treatments`, error as Error, undefined, startTime);
throw error;
}
},
update: async (id: number | string, data: {
treatment_code?: string;
treatment_name?: string;
treatment_type?: string;
description?: string;
is_active?: boolean;
}): Promise<ApiResponse<any>> => {
const startTime = apiLogger.logRequest('PUT', `${BASE_URL}/item-master/surface-treatments/${id}`, data);
try {
const headers = getAuthHeaders();
const response = await fetch(`${BASE_URL}/item-master/surface-treatments/${id}`, {
method: 'PUT',
headers,
body: JSON.stringify(data),
});
if (!response.ok) {
await handleApiError(response);
}
const result: ApiResponse<any> = await response.json();
apiLogger.logResponse('PUT', `${BASE_URL}/item-master/surface-treatments/${id}`, response.status, result, startTime);
return result;
} catch (error) {
if (error instanceof TypeError) {
apiLogger.logError('PUT', `${BASE_URL}/item-master/surface-treatments/${id}`, error, undefined, startTime);
throw new Error('네트워크 연결을 확인해주세요. 서버에 연결할 수 없습니다.');
}
apiLogger.logError('PUT', `${BASE_URL}/item-master/surface-treatments/${id}`, error as Error, undefined, startTime);
throw error;
}
},
delete: async (id: number | string): Promise<ApiResponse<void>> => {
const startTime = apiLogger.logRequest('DELETE', `${BASE_URL}/item-master/surface-treatments/${id}`);
try {
const headers = getAuthHeaders();
const response = await fetch(`${BASE_URL}/item-master/surface-treatments/${id}`, {
method: 'DELETE',
headers,
});
if (!response.ok) {
await handleApiError(response);
}
const result: ApiResponse<void> = await response.json();
apiLogger.logResponse('DELETE', `${BASE_URL}/item-master/surface-treatments/${id}`, response.status, result, startTime);
return result;
} catch (error) {
if (error instanceof TypeError) {
apiLogger.logError('DELETE', `${BASE_URL}/item-master/surface-treatments/${id}`, error, undefined, startTime);
throw new Error('네트워크 연결을 확인해주세요. 서버에 연결할 수 없습니다.');
}
apiLogger.logError('DELETE', `${BASE_URL}/item-master/surface-treatments/${id}`, error as Error, undefined, startTime);
throw error;
}
},
},
};

View File

@@ -376,9 +376,9 @@ export const transformUnitOptionResponse = (
): { id: string; value: string; label: string; isActive: boolean } => {
return {
id: response.id.toString(), // number → string 변환
value: response.value,
label: response.label,
isActive: true, // API에 없으므로 기본값
value: response.unit_code,
label: response.unit_name,
isActive: response.is_active,
};
};

View File

@@ -0,0 +1,344 @@
/**
* API 응답 → 정규화된 상태 변환 함수
*
* API 응답 (Nested 구조) → Zustand 스토어 (정규화된 구조)
*/
import type {
InitResponse,
ItemPageResponse,
ItemSectionResponse,
ItemFieldResponse,
BomItemResponse,
} from '@/types/item-master-api';
import type {
PageEntity,
SectionEntity,
FieldEntity,
BOMItemEntity,
EntitiesState,
IdsState,
SectionType,
FieldType,
ItemType,
} from './types';
// ===== 타입 변환 헬퍼 =====
/**
* 섹션 타입 변환: API ('fields' | 'bom') → Store ('BASIC' | 'BOM' | 'CUSTOM')
*/
const normalizeSectionType = (apiType: string): SectionType => {
switch (apiType) {
case 'bom':
return 'BOM';
case 'fields':
default:
return 'BASIC';
}
};
/**
* 필드 타입 변환 (API와 동일하므로 그대로 사용)
*/
const normalizeFieldType = (apiType: string): FieldType => {
return apiType as FieldType;
};
// ===== 개별 엔티티 변환 =====
/**
* API 페이지 응답 → PageEntity
*/
export const normalizePageResponse = (
page: ItemPageResponse,
sectionIds: number[] = []
): PageEntity => ({
id: page.id,
tenant_id: page.tenant_id,
page_name: page.page_name,
item_type: page.item_type as ItemType,
description: page.description,
absolute_path: page.absolute_path || '',
is_active: page.is_active,
order_no: page.order_no,
created_by: page.created_by,
updated_by: page.updated_by,
created_at: page.created_at,
updated_at: page.updated_at,
sectionIds,
});
/**
* API 섹션 응답 → SectionEntity
*/
export const normalizeSectionResponse = (
section: ItemSectionResponse,
fieldIds: number[] = [],
bomItemIds: number[] = []
): SectionEntity => ({
id: section.id,
tenant_id: section.tenant_id,
group_id: section.group_id,
page_id: section.page_id,
title: section.title,
section_type: normalizeSectionType(section.type),
description: section.description,
order_no: section.order_no,
is_template: section.is_template,
is_default: section.is_default,
is_collapsible: true, // 프론트엔드 기본값
is_default_open: true, // 프론트엔드 기본값
created_by: section.created_by,
updated_by: section.updated_by,
created_at: section.created_at,
updated_at: section.updated_at,
fieldIds,
bomItemIds,
});
/**
* API 필드 응답 → FieldEntity
*/
export const normalizeFieldResponse = (field: ItemFieldResponse): FieldEntity => ({
id: field.id,
tenant_id: field.tenant_id,
group_id: field.group_id,
section_id: field.section_id,
master_field_id: field.master_field_id,
field_name: field.field_name,
field_key: field.field_key,
field_type: normalizeFieldType(field.field_type),
order_no: field.order_no,
is_required: field.is_required,
placeholder: field.placeholder,
default_value: field.default_value,
display_condition: field.display_condition,
validation_rules: field.validation_rules,
options: field.options,
properties: field.properties,
is_locked: field.is_locked,
locked_by: field.locked_by,
locked_at: field.locked_at,
created_by: field.created_by,
updated_by: field.updated_by,
created_at: field.created_at,
updated_at: field.updated_at,
});
/**
* API BOM 응답 → BOMItemEntity
*/
export const normalizeBomItemResponse = (bom: BomItemResponse): BOMItemEntity => ({
id: bom.id,
tenant_id: bom.tenant_id,
group_id: bom.group_id,
section_id: bom.section_id,
item_code: bom.item_code,
item_name: bom.item_name,
quantity: bom.quantity,
unit: bom.unit,
unit_price: bom.unit_price,
total_price: bom.total_price,
spec: bom.spec,
note: bom.note,
created_by: bom.created_by,
updated_by: bom.updated_by,
created_at: bom.created_at,
updated_at: bom.updated_at,
});
// ===== Init 응답 정규화 (전체 데이터) =====
export interface NormalizedInitData {
entities: EntitiesState;
ids: IdsState;
}
/**
* Init API 응답 → 정규화된 상태
*
* API 응답의 Nested 구조를 평탄화:
* pages[].sections[].fields[] → entities.pages, entities.sections, entities.fields
*/
export const normalizeInitResponse = (data: InitResponse): NormalizedInitData => {
const entities: EntitiesState = {
pages: {},
sections: {},
fields: {},
bomItems: {},
};
const ids: IdsState = {
pages: [],
independentSections: [],
independentFields: [],
independentBomItems: [],
};
// 1. 페이지 정규화 (Nested sections 포함)
data.pages.forEach((page) => {
const sectionIds: number[] = [];
// 페이지에 포함된 섹션 처리
if (page.sections && page.sections.length > 0) {
page.sections.forEach((section) => {
sectionIds.push(section.id);
const fieldIds: number[] = [];
const bomItemIds: number[] = [];
// 섹션에 포함된 필드 처리
if (section.fields && section.fields.length > 0) {
section.fields.forEach((field) => {
fieldIds.push(field.id);
entities.fields[field.id] = normalizeFieldResponse(field);
});
}
// 섹션에 포함된 BOM 처리 (bom_items 또는 bomItems)
const bomItems = section.bom_items || section.bomItems || [];
if (bomItems.length > 0) {
bomItems.forEach((bom) => {
bomItemIds.push(bom.id);
entities.bomItems[bom.id] = normalizeBomItemResponse(bom);
});
}
// 섹션 저장
entities.sections[section.id] = normalizeSectionResponse(section, fieldIds, bomItemIds);
});
}
// 페이지 저장
entities.pages[page.id] = normalizePageResponse(page, sectionIds);
ids.pages.push(page.id);
});
// 2. 독립 섹션 정규화 (sections 필드가 있을 경우)
if (data.sections && data.sections.length > 0) {
data.sections.forEach((section) => {
// 이미 페이지에서 처리된 섹션은 스킵
if (entities.sections[section.id]) return;
const fieldIds: number[] = [];
const bomItemIds: number[] = [];
// 필드 처리
if (section.fields && section.fields.length > 0) {
section.fields.forEach((field) => {
fieldIds.push(field.id);
if (!entities.fields[field.id]) {
entities.fields[field.id] = normalizeFieldResponse(field);
}
});
}
// BOM 처리
const bomItems = section.bom_items || section.bomItems || [];
if (bomItems.length > 0) {
bomItems.forEach((bom) => {
bomItemIds.push(bom.id);
if (!entities.bomItems[bom.id]) {
entities.bomItems[bom.id] = normalizeBomItemResponse(bom);
}
});
}
// 섹션 저장
entities.sections[section.id] = normalizeSectionResponse(section, fieldIds, bomItemIds);
// 독립 섹션인 경우 (page_id === null)
if (section.page_id === null) {
ids.independentSections.push(section.id);
}
});
}
// 3. 독립 필드 정규화 (fields 필드가 있을 경우)
if (data.fields && data.fields.length > 0) {
data.fields.forEach((field) => {
// 이미 섹션에서 처리된 필드는 스킵
if (entities.fields[field.id]) return;
entities.fields[field.id] = normalizeFieldResponse(field);
// 독립 필드인 경우 (section_id === null)
if (field.section_id === null) {
ids.independentFields.push(field.id);
}
});
}
return { entities, ids };
};
// ===== 역변환 (상태 → API 요청) =====
/**
* PageEntity → API 요청 형식
*/
export const denormalizePageForRequest = (
page: Partial<PageEntity>
): Record<string, unknown> => ({
page_name: page.page_name,
item_type: page.item_type,
description: page.description,
absolute_path: page.absolute_path,
is_active: page.is_active,
order_no: page.order_no,
});
/**
* SectionEntity → API 요청 형식
*/
export const denormalizeSectionForRequest = (
section: Partial<SectionEntity>
): Record<string, unknown> => {
// section_type → type 변환
const type = section.section_type === 'BOM' ? 'bom' : 'fields';
return {
title: section.title,
type,
description: section.description,
order_no: section.order_no,
is_template: section.is_template,
is_default: section.is_default,
};
};
/**
* FieldEntity → API 요청 형식
*/
export const denormalizeFieldForRequest = (
field: Partial<FieldEntity>
): Record<string, unknown> => ({
field_name: field.field_name,
field_key: field.field_key,
field_type: field.field_type,
order_no: field.order_no,
is_required: field.is_required,
placeholder: field.placeholder,
default_value: field.default_value,
display_condition: field.display_condition,
validation_rules: field.validation_rules,
options: field.options,
properties: field.properties,
});
/**
* BOMItemEntity → API 요청 형식
*/
export const denormalizeBomItemForRequest = (
bom: Partial<BOMItemEntity>
): Record<string, unknown> => ({
item_code: bom.item_code,
item_name: bom.item_name,
quantity: bom.quantity,
unit: bom.unit,
unit_price: bom.unit_price,
spec: bom.spec,
note: bom.note,
});

View File

@@ -0,0 +1,562 @@
/**
* 품목기준관리 Zustand Store 타입 정의
*
* 핵심 원칙:
* 1. 정규화된 상태 구조 (Normalized State)
* 2. 1곳 수정 → 모든 뷰 자동 업데이트
* 3. 기존 API 타입과 호환성 유지
*/
// ===== 기본 타입 (API 호환) =====
/** 품목 유형 */
export type ItemType = 'FG' | 'PT' | 'SM' | 'RM' | 'CS';
/** 섹션 타입 */
export type SectionType = 'BASIC' | 'BOM' | 'CUSTOM';
/** 필드 타입 */
export type FieldType = 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
/** 부품 유형 */
export type PartType = 'ASSEMBLY' | 'BENDING' | 'PURCHASED';
/** 재질 유형 */
export type MaterialType = 'STEEL' | 'ALUMINUM' | 'PLASTIC' | 'OTHER';
/** 표면처리 유형 */
export type TreatmentType = 'PAINTING' | 'COATING' | 'PLATING' | 'NONE';
/** 가이드레일 옵션 유형 */
export type GuideRailOptionType = 'MODEL_TYPE' | 'MODEL' | 'CERTIFICATION' | 'SHAPE' | 'FINISH' | 'LENGTH';
// ===== 정규화된 엔티티 타입 =====
/**
* 페이지 엔티티 (정규화)
* - sections 배열 대신 sectionIds만 저장
*/
export interface PageEntity {
id: number;
tenant_id?: number;
page_name: string;
item_type: ItemType;
description?: string | null;
absolute_path: string;
is_active: boolean;
order_no: number;
created_by?: number | null;
updated_by?: number | null;
created_at: string;
updated_at: string;
// 정규화: 섹션 ID만 참조
sectionIds: number[];
}
/**
* 섹션 엔티티 (정규화)
* - fields, bom_items 배열 대신 ID만 저장
*/
export interface SectionEntity {
id: number;
tenant_id?: number;
group_id?: number | null;
page_id: number | null; // null = 독립 섹션
title: string;
section_type: SectionType;
description?: string | null;
order_no: number;
is_template: boolean;
is_default: boolean;
is_collapsible?: boolean;
is_default_open?: boolean;
created_by?: number | null;
updated_by?: number | null;
created_at: string;
updated_at: string;
// 정규화: ID만 참조
fieldIds: number[];
bomItemIds: number[];
}
/**
* 필드 엔티티
*/
export interface FieldEntity {
id: number;
tenant_id?: number;
group_id?: number | null;
section_id: number | null; // null = 독립 필드
master_field_id?: number | null;
field_name: string;
field_key?: string | null;
field_type: FieldType;
order_no: number;
is_required: boolean;
placeholder?: string | null;
default_value?: string | null;
display_condition?: Record<string, unknown> | null;
validation_rules?: Record<string, unknown> | null;
options?: Array<{ label: string; value: string }> | null;
properties?: Record<string, unknown> | null;
is_locked?: boolean;
locked_by?: number | null;
locked_at?: string | null;
created_by?: number | null;
updated_by?: number | null;
created_at: string;
updated_at: string;
}
/**
* BOM 아이템 엔티티
*/
export interface BOMItemEntity {
id: number;
tenant_id?: number;
group_id?: number | null;
section_id: number | null; // null = 독립 BOM
item_code?: string | null;
item_name: string;
quantity: number;
unit?: string | null;
unit_price?: number | null;
total_price?: number | null;
spec?: string | null;
note?: string | null;
created_by?: number | null;
updated_by?: number | null;
created_at: string;
updated_at: string;
}
// ===== 참조 데이터 타입 =====
/** 품목 마스터 */
export interface ItemMasterRef {
id: string;
itemCode: string;
itemName: string;
itemType: ItemType;
unit: string;
isActive?: boolean;
createdAt: string;
}
/** 규격 마스터 */
export interface SpecificationMasterRef {
id: string;
specificationCode: string;
itemType: 'RM' | 'SM';
itemName?: string;
fieldCount: '1' | '2' | '3';
thickness: string;
widthA: string;
widthB?: string;
widthC?: string;
length: string;
description?: string;
isActive: boolean;
createdAt?: string;
updatedAt?: string;
}
/** 원자재/부자재 품목명 마스터 */
export interface MaterialItemNameRef {
id: string;
itemType: 'RM' | 'SM';
itemName: string;
category?: string;
description?: string;
isActive: boolean;
createdAt: string;
updatedAt?: string;
}
/** 품목 분류 */
export interface ItemCategoryRef {
id: string;
categoryType: 'PRODUCT' | 'PART' | 'MATERIAL' | 'SUB_MATERIAL';
category1: string;
category2?: string;
category3?: string;
code?: string;
description?: string;
isActive: boolean;
createdAt: string;
updatedAt?: string;
}
/** 단위 */
export interface ItemUnitRef {
id: string;
unitCode: string;
unitName: string;
description?: string;
isActive: boolean;
createdAt: string;
updatedAt?: string;
}
/** 재질 */
export interface ItemMaterialRef {
id: string;
materialCode: string;
materialName: string;
materialType: MaterialType;
thickness?: string;
description?: string;
isActive: boolean;
createdAt: string;
updatedAt?: string;
}
/** 표면처리 */
export interface SurfaceTreatmentRef {
id: string;
treatmentCode: string;
treatmentName: string;
treatmentType: TreatmentType;
description?: string;
isActive: boolean;
createdAt: string;
updatedAt?: string;
}
/** 부품유형 옵션 */
export interface PartTypeOptionRef {
id: string;
partType: PartType;
optionCode: string;
optionName: string;
description?: string;
isActive: boolean;
createdAt: string;
updatedAt?: string;
}
/** 부품용도 옵션 */
export interface PartUsageOptionRef {
id: string;
usageCode: string;
usageName: string;
description?: string;
isActive: boolean;
createdAt: string;
updatedAt?: string;
}
/** 가이드레일 옵션 */
export interface GuideRailOptionRef {
id: string;
optionType: GuideRailOptionType;
optionCode: string;
optionName: string;
parentOption?: string;
description?: string;
isActive: boolean;
createdAt: string;
updatedAt?: string;
}
/** 마스터 필드 (재사용 가능한 필드 템플릿) */
export interface MasterFieldRef {
id: number;
tenant_id: number;
field_name: string;
field_key?: string | null;
field_type: FieldType;
category: string | null;
description: string | null;
is_common: boolean;
is_required?: boolean;
default_value: string | null;
options: Array<{ label: string; value: string }> | null;
validation_rules: Record<string, unknown> | null;
properties: Record<string, unknown> | null;
created_by: number | null;
updated_by: number | null;
created_at: string;
updated_at: string;
}
// ===== 스토어 상태 타입 =====
/**
* 정규화된 엔티티 상태
*/
export interface EntitiesState {
pages: Record<number, PageEntity>;
sections: Record<number, SectionEntity>;
fields: Record<number, FieldEntity>;
bomItems: Record<number, BOMItemEntity>;
}
/**
* ID 목록 (순서 관리)
*/
export interface IdsState {
pages: number[];
independentSections: number[]; // page_id가 null인 섹션
independentFields: number[]; // section_id가 null인 필드
independentBomItems: number[]; // section_id가 null인 BOM
}
/**
* 참조 데이터 상태
*/
export interface ReferencesState {
itemMasters: ItemMasterRef[];
specificationMasters: SpecificationMasterRef[];
materialItemNames: MaterialItemNameRef[];
itemCategories: ItemCategoryRef[];
itemUnits: ItemUnitRef[];
itemMaterials: ItemMaterialRef[];
surfaceTreatments: SurfaceTreatmentRef[];
partTypeOptions: PartTypeOptionRef[];
partUsageOptions: PartUsageOptionRef[];
guideRailOptions: GuideRailOptionRef[];
masterFields: MasterFieldRef[];
}
/**
* UI 상태
*/
export interface UIState {
isLoading: boolean;
error: string | null;
selectedPageId: number | null;
selectedSectionId: number | null;
selectedFieldId: number | null;
}
/**
* 메인 스토어 상태
*/
export interface ItemMasterState {
entities: EntitiesState;
ids: IdsState;
references: ReferencesState;
ui: UIState;
}
// ===== 액션 타입 =====
/**
* 페이지 액션
*/
export interface PageActions {
loadPages: (pages: PageEntity[]) => void;
createPage: (page: Omit<PageEntity, 'id' | 'sectionIds' | 'created_at' | 'updated_at'>) => Promise<PageEntity>;
updatePage: (id: number, updates: Partial<PageEntity>) => Promise<void>;
deletePage: (id: number) => Promise<void>;
}
/**
* 섹션 액션
*/
export interface SectionActions {
loadSections: (sections: SectionEntity[]) => void;
createSection: (section: Omit<SectionEntity, 'id' | 'fieldIds' | 'bomItemIds' | 'created_at' | 'updated_at'>) => Promise<SectionEntity>;
createSectionInPage: (pageId: number, section: Omit<SectionEntity, 'id' | 'page_id' | 'fieldIds' | 'bomItemIds' | 'created_at' | 'updated_at'>) => Promise<SectionEntity>;
updateSection: (id: number, updates: Partial<SectionEntity>) => Promise<void>;
deleteSection: (id: number) => Promise<void>;
linkSectionToPage: (sectionId: number, pageId: number) => Promise<void>;
unlinkSectionFromPage: (sectionId: number) => Promise<void>;
reorderSections: (pageId: number, dragSectionId: number, hoverSectionId: number) => Promise<void>;
}
/**
* 필드 액션
*/
export interface FieldActions {
loadFields: (fields: FieldEntity[]) => void;
createField: (field: Omit<FieldEntity, 'id' | 'created_at' | 'updated_at'>) => Promise<FieldEntity>;
createFieldInSection: (sectionId: number, field: Omit<FieldEntity, 'id' | 'section_id' | 'created_at' | 'updated_at'>) => Promise<FieldEntity>;
updateField: (id: number, updates: Partial<FieldEntity>) => Promise<void>;
deleteField: (id: number) => Promise<void>;
linkFieldToSection: (fieldId: number, sectionId: number) => Promise<void>;
unlinkFieldFromSection: (fieldId: number) => Promise<void>;
reorderFields: (sectionId: number, dragFieldId: number, hoverFieldId: number) => Promise<void>;
}
/**
* BOM 액션
*/
export interface BOMActions {
loadBomItems: (items: BOMItemEntity[]) => void;
createBomItem: (item: Omit<BOMItemEntity, 'id' | 'created_at' | 'updated_at'>) => Promise<BOMItemEntity>;
updateBomItem: (id: number, updates: Partial<BOMItemEntity>) => Promise<void>;
deleteBomItem: (id: number) => Promise<void>;
}
/**
* UI 액션
*/
export interface UIActions {
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
selectPage: (pageId: number | null) => void;
selectSection: (sectionId: number | null) => void;
selectField: (fieldId: number | null) => void;
}
/**
* 속성(단위/재질/표면처리) CRUD 액션
*/
export interface PropertyActions {
// 단위 CRUD
addUnit: (data: {
unitCode: string;
unitName: string;
description?: string;
isActive?: boolean;
}) => Promise<ItemUnitRef>;
updateUnit: (
id: string,
updates: {
unitCode?: string;
unitName?: string;
description?: string;
isActive?: boolean;
}
) => Promise<void>;
deleteUnit: (id: string) => Promise<void>;
// 재질 CRUD
addMaterial: (data: {
materialCode: string;
materialName: string;
materialType: MaterialType;
thickness?: string;
description?: string;
isActive?: boolean;
}) => Promise<ItemMaterialRef>;
updateMaterial: (
id: string,
updates: {
materialCode?: string;
materialName?: string;
materialType?: MaterialType;
thickness?: string;
description?: string;
isActive?: boolean;
}
) => Promise<void>;
deleteMaterial: (id: string) => Promise<void>;
// 표면처리 CRUD
addTreatment: (data: {
treatmentCode: string;
treatmentName: string;
treatmentType: TreatmentType;
description?: string;
isActive?: boolean;
}) => Promise<SurfaceTreatmentRef>;
updateTreatment: (
id: string,
updates: {
treatmentCode?: string;
treatmentName?: string;
treatmentType?: TreatmentType;
description?: string;
isActive?: boolean;
}
) => Promise<void>;
deleteTreatment: (id: string) => Promise<void>;
// 섹션 복제
cloneSection: (sectionId: number) => Promise<SectionEntity>;
}
/**
* 전체 스토어 타입
*/
export interface ItemMasterStore extends ItemMasterState {
// 페이지 액션
loadPages: PageActions['loadPages'];
createPage: PageActions['createPage'];
updatePage: PageActions['updatePage'];
deletePage: PageActions['deletePage'];
// 섹션 액션
loadSections: SectionActions['loadSections'];
createSection: SectionActions['createSection'];
createSectionInPage: SectionActions['createSectionInPage'];
updateSection: SectionActions['updateSection'];
deleteSection: SectionActions['deleteSection'];
linkSectionToPage: SectionActions['linkSectionToPage'];
unlinkSectionFromPage: SectionActions['unlinkSectionFromPage'];
reorderSections: SectionActions['reorderSections'];
// 필드 액션
loadFields: FieldActions['loadFields'];
createField: FieldActions['createField'];
createFieldInSection: FieldActions['createFieldInSection'];
updateField: FieldActions['updateField'];
deleteField: FieldActions['deleteField'];
linkFieldToSection: FieldActions['linkFieldToSection'];
unlinkFieldFromSection: FieldActions['unlinkFieldFromSection'];
reorderFields: FieldActions['reorderFields'];
// BOM 액션
loadBomItems: BOMActions['loadBomItems'];
createBomItem: BOMActions['createBomItem'];
updateBomItem: BOMActions['updateBomItem'];
deleteBomItem: BOMActions['deleteBomItem'];
// UI 액션
setLoading: UIActions['setLoading'];
setError: UIActions['setError'];
selectPage: UIActions['selectPage'];
selectSection: UIActions['selectSection'];
selectField: UIActions['selectField'];
// 속성 CRUD 액션
addUnit: PropertyActions['addUnit'];
updateUnit: PropertyActions['updateUnit'];
deleteUnit: PropertyActions['deleteUnit'];
addMaterial: PropertyActions['addMaterial'];
updateMaterial: PropertyActions['updateMaterial'];
deleteMaterial: PropertyActions['deleteMaterial'];
addTreatment: PropertyActions['addTreatment'];
updateTreatment: PropertyActions['updateTreatment'];
deleteTreatment: PropertyActions['deleteTreatment'];
cloneSection: PropertyActions['cloneSection'];
// 초기화
reset: () => void;
// API 연동
initFromApi: () => Promise<void>;
}
// ===== 파생 상태 (Denormalized) 타입 =====
/**
* 계층구조 뷰용 페이지 (섹션/필드 포함)
*/
export interface PageWithDetails {
id: number;
page_name: string;
item_type: ItemType;
description?: string | null;
is_active: boolean;
order_no: number;
sections: SectionWithDetails[];
}
/**
* 계층구조 뷰용 섹션 (필드/BOM 포함)
*/
export interface SectionWithDetails {
id: number;
title: string;
section_type: SectionType;
page_id: number | null;
order_no: number;
is_collapsible?: boolean;
is_default_open?: boolean;
fields: FieldEntity[];
bom_items: BOMItemEntity[];
}

File diff suppressed because it is too large Load Diff

View File

@@ -617,12 +617,14 @@ export interface TabColumnResponse {
// ============================================
/**
* 단위 옵션 생성 요청
* 단위 옵션 생성/수정 요청
* POST /v1/item-master/units
*/
export interface UnitOptionRequest {
label: string;
value: string;
unit_code: string;
unit_name: string;
description?: string;
is_active?: boolean;
}
/**
@@ -631,8 +633,78 @@ export interface UnitOptionRequest {
export interface UnitOptionResponse {
id: number;
tenant_id: number;
label: string;
value: string;
unit_code: string;
unit_name: string;
description?: string;
is_active: boolean;
created_by: number | null;
created_at: string;
updated_at: string;
}
// ============================================
// 재질 옵션
// ============================================
export type MaterialType = 'STEEL' | 'ALUMINUM' | 'PLASTIC' | 'OTHER';
/**
* 재질 옵션 생성/수정 요청
*/
export interface MaterialOptionRequest {
material_code: string;
material_name: string;
material_type: MaterialType;
thickness?: string;
description?: string;
is_active?: boolean;
}
/**
* 재질 옵션 응답
*/
export interface MaterialOptionResponse {
id: number;
tenant_id: number;
material_code: string;
material_name: string;
material_type: MaterialType;
thickness?: string;
description?: string;
is_active: boolean;
created_by: number | null;
created_at: string;
updated_at: string;
}
// ============================================
// 표면처리 옵션
// ============================================
export type TreatmentType = 'PAINTING' | 'COATING' | 'PLATING' | 'NONE';
/**
* 표면처리 옵션 생성/수정 요청
*/
export interface TreatmentOptionRequest {
treatment_code: string;
treatment_name: string;
treatment_type: TreatmentType;
description?: string;
is_active?: boolean;
}
/**
* 표면처리 옵션 응답
*/
export interface TreatmentOptionResponse {
id: number;
tenant_id: number;
treatment_code: string;
treatment_name: string;
treatment_type: TreatmentType;
description?: string;
is_active: boolean;
created_by: number | null;
created_at: string;
updated_at: string;

View File

@@ -0,0 +1,392 @@
/**
* 품목기준관리 타입 정의
* ItemMasterContext에서 분리됨 (2026-01-06)
*/
// ===== 기본 타입 =====
// 전개도 상세 정보
export interface BendingDetail {
id: string;
no: number; // 번호
input: number; // 입력
elongation: number; // 연신율 (기본값 -1)
calculated: number; // 연신율 계산 후
sum: number; // 합계
shaded: boolean; // 음영 여부
aAngle?: number; // A각
}
// 부품구성표(BOM, Bill of Materials) - 자재 명세서
export interface BOMLine {
id: string;
childItemCode: string; // 구성 품목 코드
childItemName: string; // 구성 품목명
quantity: number; // 기준 수량
unit: string; // 단위
unitPrice?: number; // 단가
quantityFormula?: string; // 수량 계산식 (예: "W * 2", "H + 100")
note?: string; // 비고
// 절곡품 관련 (하위 절곡 부품용)
isBending?: boolean;
bendingDiagram?: string; // 전개도 이미지 URL
bendingDetails?: BendingDetail[]; // 전개도 상세 데이터
}
// 규격 마스터 (원자재/부자재용)
export interface SpecificationMaster {
id: string;
specificationCode: string; // 규격 코드 (예: 1.6T x 1219 x 2438)
itemType: 'RM' | 'SM'; // 원자재 | 부자재
itemName?: string; // 품목명 (예: SPHC-SD, SPCC-SD) - 품목명별 규격 필터링용
fieldCount: '1' | '2' | '3'; // 너비 입력 개수
thickness: string; // 두께
widthA: string; // 너비A
widthB?: string; // 너비B
widthC?: string; // 너비C
length: string; // 길이
description?: string; // 설명
isActive: boolean; // 활성 여부
createdAt?: string;
updatedAt?: string;
}
// 원자재/부자재 품목명 마스터
export interface MaterialItemName {
id: string;
itemType: 'RM' | 'SM'; // 원자재 | 부자재
itemName: string; // 품목명 (예: "SPHC-SD", "STS430")
category?: string; // 분류 (예: "냉연", "열연", "스테인리스")
description?: string; // 설명
isActive: boolean; // 활성 여부
createdAt: string;
updatedAt?: string;
}
// 품목 수정 이력
export interface ItemRevision {
revisionNumber: number; // 수정 차수 (1차, 2차, 3차...)
revisionDate: string; // 수정일
revisionBy: string; // 수정자
revisionReason?: string; // 수정 사유
previousData: any; // 이전 버전의 전체 데이터
}
// 품목 마스터
export interface ItemMaster {
id: string;
itemCode: string;
itemName: string;
itemType: 'FG' | 'PT' | 'SM' | 'RM' | 'CS'; // 제품, 부품, 부자재, 원자재, 소모품
productCategory?: 'SCREEN' | 'STEEL'; // 제품 카테고리 (스크린/철재)
partType?: 'ASSEMBLY' | 'BENDING' | 'PURCHASED'; // 부품 유형 (조립/절곡/구매)
partUsage?: 'GUIDE_RAIL' | 'BOTTOM_FINISH' | 'CASE' | 'DOOR' | 'BRACKET' | 'GENERAL'; // 부품 용도
unit: string;
category1?: string;
category2?: string;
category3?: string;
specification?: string;
isVariableSize?: boolean;
isActive?: boolean; // 품목 활성/비활성 (제품/부품/원자재/부자재만 사용)
lotAbbreviation?: string; // 로트 약자 (제품만 사용)
purchasePrice?: number;
marginRate?: number;
processingCost?: number;
laborCost?: number;
installCost?: number;
salesPrice?: number;
safetyStock?: number;
leadTime?: number;
bom?: BOMLine[]; // 부품구성표(BOM) - 자재 명세서
bomCategories?: string[]; // 견적산출용 샘플 제품의 BOM 카테고리 (예: ['motor', 'guide-rail'])
// 인정 정보
certificationNumber?: string; // 인정번호
certificationStartDate?: string; // 인정 유효기간 시작일
certificationEndDate?: string; // 인정 유효기간 종료일
specificationFile?: string; // 시방서 파일 (Base64 또는 URL)
specificationFileName?: string; // 시방서 파일명
certificationFile?: string; // 인정서 파일 (Base64 또는 URL)
certificationFileName?: string; // 인정서 파일명
note?: string; // 비고 (제품만 사용)
// 조립 부품 관련 필드
installationType?: string; // 설치 유형 (wall: 벽면형, side: 측면형, steel: 스틸, iron: 철재)
assemblyType?: string; // 종류 (M, T, C, D, S, U 등)
sideSpecWidth?: string; // 측면 규격 가로 (mm)
sideSpecHeight?: string; // 측면 규격 세로 (mm)
assemblyLength?: string; // 길이 (2438, 3000, 3500, 4000, 4300 등)
// 가이드레일 관련 필드
guideRailModelType?: string; // 가이드레일 모델 유형
guideRailModel?: string; // 가이드레일 모델
// 절곡품 관련 (부품 유형이 BENDING인 경우)
bendingDiagram?: string; // 전개도 이미지 URL
bendingDetails?: BendingDetail[]; // 전개도 상세 데이터
material?: string; // 재질 (EGI 1.55T, SUS 1.2T 등)
length?: string; // 길이/목함 (mm)
// 버전 관리
currentRevision: number; // 현재 차수 (0 = 최초, 1 = 1차 수정...)
revisions?: ItemRevision[]; // 수정 이력
isFinal: boolean; // 최종 확정 여부
finalizedDate?: string; // 최종 확정일
finalizedBy?: string; // 최종 확정자
createdAt: string;
}
// ===== 품목 기준정보 관리 (Master Data) =====
export interface ItemCategory {
id: string;
categoryType: 'PRODUCT' | 'PART' | 'MATERIAL' | 'SUB_MATERIAL'; // 품목 구분
category1: string; // 대분류
category2?: string; // 중분류
category3?: string; // 소분류
code?: string; // 코드 (자동생성 또는 수동입력)
description?: string;
isActive: boolean;
createdAt: string;
updatedAt?: string;
}
export interface ItemUnit {
id: string;
unitCode: string; // 단위 코드 (EA, SET, M, KG, L 등)
unitName: string; // 단위명
description?: string;
isActive: boolean;
createdAt: string;
updatedAt?: string;
}
export interface ItemMaterial {
id: string;
materialCode: string; // 재질 코드
materialName: string; // 재질명 (EGI 1.55T, SUS 1.2T 등)
materialType: 'STEEL' | 'ALUMINUM' | 'PLASTIC' | 'OTHER'; // 재질 유형
thickness?: string; // 두께 (1.2T, 1.6T 등)
description?: string;
isActive: boolean;
createdAt: string;
updatedAt?: string;
}
export interface SurfaceTreatment {
id: string;
treatmentCode: string; // 처리 코드
treatmentName: string; // 처리명 (무도장, 파우더도장, 아노다이징 등)
treatmentType: 'PAINTING' | 'COATING' | 'PLATING' | 'NONE'; // 처리 유형
description?: string;
isActive: boolean;
createdAt: string;
updatedAt?: string;
}
export interface PartTypeOption {
id: string;
partType: 'ASSEMBLY' | 'BENDING' | 'PURCHASED'; // 부품 유형
optionCode: string; // 옵션 코드
optionName: string; // 옵션명
description?: string;
isActive: boolean;
createdAt: string;
updatedAt?: string;
}
export interface PartUsageOption {
id: string;
usageCode: string; // 용도 코드
usageName: string; // 용도명 (가이드레일, 하단마감재, 케이스 등)
description?: string;
isActive: boolean;
createdAt: string;
updatedAt?: string;
}
export interface GuideRailOption {
id: string;
optionType: 'MODEL_TYPE' | 'MODEL' | 'CERTIFICATION' | 'SHAPE' | 'FINISH' | 'LENGTH'; // 옵션 유형
optionCode: string; // 옵션 코드
optionName: string; // 옵션명
parentOption?: string; // 상위 옵션 (종속 관계)
description?: string;
isActive: boolean;
createdAt: string;
updatedAt?: string;
}
// ===== 품목기준관리 계층구조 =====
// 항목 속성
export interface ItemFieldProperty {
id?: string; // 속성 ID (properties 배열에서 사용)
key?: string; // 속성 키 (properties 배열에서 사용)
label?: string; // 속성 라벨 (properties 배열에서 사용)
type?: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea'; // 속성 타입 (properties 배열에서 사용)
inputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea' | 'section'; // 입력방식
required: boolean; // 필수 여부
row: number; // 행 위치
col: number; // 열 위치
options?: string[]; // 드롭다운 옵션 (입력방식이 dropdown일 경우)
defaultValue?: string; // 기본값
placeholder?: string; // 플레이스홀더
multiColumn?: boolean; // 다중 컬럼 사용 여부
columnCount?: number; // 컬럼 개수
columnNames?: string[]; // 각 컬럼의 이름
}
// 항목 마스터 (재사용 가능한 항목 템플릿) - MasterFieldResponse와 정확히 일치
export interface ItemMasterField {
id: number;
tenant_id: number;
field_name: string;
field_key?: string | null; // 2025-11-28: field_key 추가 (형식: {ID}_{사용자입력})
field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea'; // API와 동일
category: string | null;
description: string | null;
is_common: boolean; // 공통 필드 여부
is_required?: boolean; // 필수 여부 (API에서 반환)
default_value: string | null; // 기본값
options: Array<{ label: string; value: string }> | null; // dropdown 옵션
validation_rules: Record<string, any> | null; // 검증 규칙
properties: Record<string, any> | null; // 추가 속성
created_by: number | null;
updated_by: number | null;
created_at: string;
updated_at: string;
}
// 조건부 표시 설정
export interface FieldDisplayCondition {
targetType: 'field' | 'section'; // 조건 대상 타입
// 일반항목 조건 (여러 개 가능)
fieldConditions?: Array<{
fieldKey: string; // 조건이 되는 필드의 키
expectedValue: string; // 예상되는 값
}>;
// 섹션 조건 (여러 개 가능)
sectionIds?: string[]; // 표시할 섹션 ID 배열
}
// 항목 (Field) - API 응답 구조에 맞춰 수정
export interface ItemField {
id: number; // 서버 생성 ID (string → number)
tenant_id?: number; // 백엔드에서 자동 추가
group_id?: number | null; // 그룹 ID (독립 필드용)
section_id: number | null; // 외래키 - 섹션 ID (독립 필드는 null)
master_field_id?: number | null; // 마스터 항목 ID (마스터에서 가져온 경우)
field_name: string; // 항목명 (name → field_name)
field_key?: string | null; // 2025-11-28: 필드 키 (형식: {ID}_{사용자입력}, 백엔드에서 생성)
field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea'; // 필드 타입
order_no: number; // 항목 순서 (order → order_no, required)
is_required: boolean; // 필수 여부
placeholder?: string | null; // 플레이스홀더
default_value?: string | null; // 기본값
display_condition?: Record<string, any> | null; // 조건부 표시 설정 (displayCondition → display_condition)
validation_rules?: Record<string, any> | null; // 검증 규칙
options?: Array<{ label: string; value: string }> | null; // dropdown 옵션
properties?: Record<string, any> | null; // 추가 속성
// 2025-11-28 추가: 잠금 기능
is_locked?: boolean; // 잠금 여부
locked_by?: number | null; // 잠금 설정자
locked_at?: string | null; // 잠금 시간
created_by?: number | null; // 생성자 ID 추가
updated_by?: number | null; // 수정자 ID 추가
created_at: string; // 생성일 (camelCase → snake_case)
updated_at: string; // 수정일 추가
}
// BOM 아이템 타입 - API 응답 구조에 맞춰 수정
export interface BOMItem {
id: number; // 서버 생성 ID (string → number)
tenant_id?: number; // 백엔드에서 자동 추가
group_id?: number | null; // 그룹 ID (독립 BOM용)
section_id: number | null; // 외래키 - 섹션 ID (독립 BOM은 null)
item_code?: string | null; // 품목 코드 (itemCode → item_code, optional)
item_name: string; // 품목명 (itemName → item_name)
quantity: number; // 수량
unit?: string | null; // 단위 (optional)
unit_price?: number | null; // 단가 추가
total_price?: number | null; // 총액 추가
spec?: string | null; // 규격/사양 추가
note?: string | null; // 비고 (optional)
created_by?: number | null; // 생성자 ID 추가
updated_by?: number | null; // 수정자 ID 추가
created_at: string; // 생성일 (createdAt → created_at)
updated_at: string; // 수정일 추가
}
// 섹션 (Section) - API 응답 구조에 맞춰 수정
export interface ItemSection {
id: number; // 서버 생성 ID (string → number)
tenant_id?: number; // 백엔드에서 자동 추가
group_id?: number | null; // 그룹 ID (독립 섹션 그룹화용) - 2025-11-26 추가
page_id: number | null; // 외래키 - 페이지 ID (null이면 독립 섹션) - 2025-11-26 수정
title: string; // 섹션 제목 (API 필드명과 일치하도록 section_name → title)
section_type: 'BASIC' | 'BOM' | 'CUSTOM'; // 섹션 타입 (type → section_type, 값 변경)
description?: string | null; // 설명
order_no: number; // 섹션 순서 (order → order_no)
is_template: boolean; // 템플릿 여부 (section_templates 통합) - 2025-11-26 추가
is_default: boolean; // 기본 템플릿 여부 - 2025-11-26 추가
is_collapsible?: boolean; // 접기/펼치기 가능 여부 (프론트엔드 전용, optional)
is_default_open?: boolean; // 기본 열림 상태 (프론트엔드 전용, optional)
created_by?: number | null; // 생성자 ID 추가
updated_by?: number | null; // 수정자 ID 추가
created_at: string; // 생성일 (camelCase → snake_case)
updated_at: string; // 수정일 추가
fields?: ItemField[]; // 섹션에 포함된 항목들 (optional로 변경)
bom_items?: BOMItem[]; // BOM 타입일 경우 BOM 품목 목록 (bomItems → bom_items)
}
// 페이지 (Page) - API 응답 구조에 맞춰 수정
export interface ItemPage {
id: number; // 서버 생성 ID (string → number)
tenant_id?: number; // 백엔드에서 자동 추가
page_name: string; // 페이지명 (camelCase → snake_case)
item_type: 'FG' | 'PT' | 'SM' | 'RM' | 'CS'; // 품목유형
description?: string | null; // 설명 추가
absolute_path: string; // 절대경로 (camelCase → snake_case)
is_active: boolean; // 사용 여부 (camelCase → snake_case)
order_no: number; // 순서 번호 추가
created_by?: number | null; // 생성자 ID 추가
updated_by?: number | null; // 수정자 ID 추가
created_at: string; // 생성일 (camelCase → snake_case)
updated_at: string; // 수정일 (camelCase → snake_case)
sections: ItemSection[]; // 페이지에 포함된 섹션들 (Nested)
}
// 템플릿 필드 (로컬 관리용 - API에서 제공하지 않음)
export interface TemplateField {
id: string;
name: string;
fieldKey: string;
property: {
inputType: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
required: boolean;
options?: string[];
multiColumn?: boolean;
columnCount?: number;
columnNames?: string[];
};
description?: string;
}
// 섹션 템플릿 (재사용 가능한 섹션) - Transformer 출력과 UI 요구사항에 맞춤
export interface SectionTemplate {
id: number;
tenant_id: number;
template_name: string; // transformer가 title → template_name으로 변환
section_type: 'BASIC' | 'BOM' | 'CUSTOM'; // transformer가 type → section_type으로 변환
description: string | null;
default_fields: TemplateField[] | null; // 기본 필드 (로컬 관리)
category?: string[]; // 적용 카테고리 (로컬 관리)
fields?: TemplateField[]; // 템플릿에 포함된 필드 (로컬 관리)
bomItems?: BOMItem[]; // BOM 타입일 경우 BOM 품목 (로컬 관리)
created_by: number | null;
updated_by: number | null;
created_at: string;
updated_at: string;
}