fix(WEB): 토큰 만료 시 무한 로딩 대신 로그인 리다이렉트 처리

- 52개 이상의 컴포넌트에 isNextRedirectError 처리 추가
- Server Action의 redirect() 에러가 catch 블록에서 삼켜지는 문제 해결
- access_token + refresh_token 모두 만료 시 정상적으로 로그인 페이지로 리다이렉트

수정된 영역:
- accounting: 10개 컴포넌트
- production: 12개 컴포넌트
- hr: 5개 컴포넌트
- settings: 8개 컴포넌트
- approval: 5개 컴포넌트
- items: 20개+ 컴포넌트
- board: 5개 컴포넌트
- quality: 4개 컴포넌트
- material, outbound, quotes 등 기타 컴포넌트

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2026-01-11 17:19:11 +09:00
parent 8bc4b90fe9
commit e56b7d53a4
131 changed files with 3320 additions and 1979 deletions

View File

@@ -1,7 +1,7 @@
'use client';
import type { ItemPage, SectionTemplate, ItemMasterField, ItemSection, BOMItem } from '@/contexts/ItemMasterContext';
import { FieldDialog } from '../dialogs/FieldDialog';
import { FieldDialog, type InputType } from '../dialogs/FieldDialog';
import { FieldDrawer } from '../dialogs/FieldDrawer';
import { TabManagementDialogs } from '../dialogs/TabManagementDialogs';
import { OptionDialog } from '../dialogs/OptionDialog';
@@ -17,30 +17,18 @@ 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';
import type { OptionColumn } from '../types';
import type { ConditionalFieldConfig } from '../components/ConditionalDisplayUI';
import type { SectionUsageResponse, FieldUsageResponse } from '@/types/item-master-api';
// 텍스트박스 칼럼 타입 (FieldDialog와 동일)
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';
}
// 속성 컬럼 타입 (OptionDialog/ColumnManageDialog 호환)
interface AttributeColumn {
id: string;
name: string;
@@ -49,6 +37,26 @@ interface AttributeColumn {
required: boolean;
}
// 조건부 표시 필드 타입 - 유연한 타입 (FieldDialog.tsx의 FlexibleConditionField와 호환)
interface ConditionField {
fieldId?: string;
fieldKey?: string;
fieldName?: string;
operator?: string;
value?: string;
expectedValue?: string;
logicOperator?: 'AND' | 'OR';
}
// 조건부 표시 섹션 타입 - 유연한 타입 (FieldDialog.tsx와 호환)
interface ConditionSection {
sectionId?: string;
sectionTitle?: string;
operator?: string;
value?: string;
logicOperator?: 'AND' | 'OR';
}
export interface ItemMasterDialogsProps {
isMobile: boolean;
selectedPage: ItemPage | null;
@@ -108,8 +116,9 @@ export interface ItemMasterDialogsProps {
setNewOptionInputType: (type: string) => void;
newOptionRequired: boolean;
setNewOptionRequired: (required: boolean) => void;
newOptionOptions: string[];
setNewOptionOptions: (options: string[]) => void;
// string | string[] 모두 지원
newOptionOptions: string | string[];
setNewOptionOptions: (options: string | string[]) => void;
newOptionPlaceholder: string;
setNewOptionPlaceholder: (placeholder: string) => void;
newOptionDefaultValue: string;
@@ -158,12 +167,13 @@ export interface ItemMasterDialogsProps {
newSectionDescription: string;
setNewSectionDescription: (description: string) => void;
handleAddSection: () => void;
sectionInputMode: 'new' | 'existing';
setSectionInputMode: (mode: 'new' | 'existing') => void;
// 'new'/'existing' 또는 'custom'/'template' 모두 지원
sectionInputMode: 'new' | 'existing' | 'custom' | 'template';
setSectionInputMode: (mode: 'new' | 'existing' | 'custom' | 'template') => void;
sectionsAsTemplates: SectionTemplate[];
selectedSectionTemplateId: number | null;
setSelectedSectionTemplateId: (id: number | null) => void;
handleLinkTemplate: () => void;
handleLinkTemplate: ((template: SectionTemplate) => void | Promise<void>) | (() => void | Promise<void>);
// Field Dialog
isFieldDialogOpen: boolean;
@@ -171,22 +181,26 @@ export interface ItemMasterDialogsProps {
selectedSectionForField: number | null;
editingFieldId: number | null;
setEditingFieldId: (id: number | null) => void;
fieldInputMode: 'new' | 'existing';
setFieldInputMode: (mode: 'new' | 'existing') => void;
// 'new'/'existing' 또는 'custom'/'master' 모두 지원
fieldInputMode: 'new' | 'existing' | 'custom' | 'master';
setFieldInputMode: (mode: 'new' | 'existing' | 'custom' | 'master') => void;
showMasterFieldList: boolean;
setShowMasterFieldList: (show: boolean) => void;
selectedMasterFieldId: number | null;
setSelectedMasterFieldId: (id: number | null) => void;
// string 또는 number | null 모두 지원
selectedMasterFieldId: string | number | null;
setSelectedMasterFieldId: (id: string | 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[]>>;
// 유연한 조건부 필드 타입
newFieldConditionFields: ConditionField[] | ConditionalFieldConfig[];
setNewFieldConditionFields: React.Dispatch<React.SetStateAction<ConditionField[]>> | React.Dispatch<React.SetStateAction<ConditionalFieldConfig[]>>;
// 유연한 조건부 섹션 타입
newFieldConditionSections: ConditionSection[] | string[];
setNewFieldConditionSections: React.Dispatch<React.SetStateAction<ConditionSection[]>> | React.Dispatch<React.SetStateAction<string[]>>;
tempConditionValue: string;
setTempConditionValue: (value: string) => void;
newFieldName: string;
@@ -199,8 +213,9 @@ export interface ItemMasterDialogsProps {
setNewFieldRequired: (required: boolean) => void;
newFieldDescription: string;
setNewFieldDescription: (description: string) => void;
newFieldOptions: string[];
setNewFieldOptions: React.Dispatch<React.SetStateAction<string[]>>;
// string | string[] 모두 지원
newFieldOptions: string | string[];
setNewFieldOptions: ((options: string | string[]) => void) | React.Dispatch<React.SetStateAction<string[]>>;
itemMasterFields: ItemMasterField[];
handleAddField: () => void;
isColumnDialogOpen: boolean;
@@ -251,8 +266,9 @@ export interface ItemMasterDialogsProps {
setNewSectionTemplateTitle: (title: string) => void;
newSectionTemplateDescription: string;
setNewSectionTemplateDescription: (description: string) => void;
newSectionTemplateCategory: string;
setNewSectionTemplateCategory: (category: string) => void;
// string 또는 string[] 모두 지원
newSectionTemplateCategory: string | string[];
setNewSectionTemplateCategory: (category: string | string[]) => void;
newSectionTemplateType: 'fields' | 'bom';
setNewSectionTemplateType: (type: 'fields' | 'bom') => void;
handleUpdateSectionTemplate: () => void;
@@ -281,20 +297,24 @@ export interface ItemMasterDialogsProps {
setTemplateFieldColumnCount: (count: number) => void;
templateFieldColumnNames: string[];
setTemplateFieldColumnNames: React.Dispatch<React.SetStateAction<string[]>>;
handleAddTemplateField: () => void;
templateFieldInputMode: 'new' | 'existing';
setTemplateFieldInputMode: (mode: 'new' | 'existing') => void;
// () => void 또는 () => Promise<void> 모두 지원
handleAddTemplateField: (() => void) | (() => Promise<void>);
// 'new'/'existing' 또는 'custom'/'master' 모두 지원
templateFieldInputMode: 'new' | 'existing' | 'custom' | 'master';
setTemplateFieldInputMode: (mode: 'new' | 'existing' | 'custom' | 'master') => void;
templateFieldShowMasterFieldList: boolean;
setTemplateFieldShowMasterFieldList: (show: boolean) => void;
templateFieldSelectedMasterFieldId: number | null;
setTemplateFieldSelectedMasterFieldId: (id: number | null) => void;
// string 또는 number | null 모두 지원
templateFieldSelectedMasterFieldId: string | number | null;
setTemplateFieldSelectedMasterFieldId: (id: string | number | null) => void;
// Load Template Dialog
isLoadTemplateDialogOpen: boolean;
setIsLoadTemplateDialogOpen: (open: boolean) => void;
sectionTemplates: SectionTemplate[];
selectedTemplateId: number | null;
setSelectedTemplateId: (id: number | null) => void;
// string | number | null 모두 지원
selectedTemplateId: string | number | null;
setSelectedTemplateId: (id: string | number | null) => void;
handleLoadTemplate: () => void;
// Import Section Dialog
@@ -303,18 +323,22 @@ export interface ItemMasterDialogsProps {
independentSections: ItemSection[];
selectedImportSectionId: number | null;
setSelectedImportSectionId: (id: number | null) => void;
handleImportSection: () => Promise<void>;
// () => void 또는 () => Promise<void> 모두 지원
handleImportSection: (() => void) | (() => Promise<void>);
refreshIndependentSections: () => void;
getSectionUsage: (sectionId: number) => Promise<{ pages: { id: number; name: string }[] }>;
// 유연한 반환 타입 - SectionUsageResponse 또는 간단한 객체
getSectionUsage: (sectionId: number) => Promise<SectionUsageResponse | { 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>;
// () => void 또는 () => Promise<void> 모두 지원
handleImportField: (() => void) | (() => Promise<void>);
refreshIndependentFields: () => void;
getFieldUsage: (fieldId: number) => Promise<{ sections: { id: number; title: string }[] }>;
// 유연한 반환 타입 - FieldUsageResponse 또는 간단한 객체
getFieldUsage: (fieldId: number) => Promise<FieldUsageResponse | { sections: { id: number; title: string }[] }>;
importFieldTargetSectionId: number | null;
}
@@ -918,8 +942,8 @@ export function ItemMasterDialogs({
selectedSectionId={selectedImportSectionId}
setSelectedSectionId={setSelectedImportSectionId}
onImport={handleImportSection}
onRefresh={refreshIndependentSections}
onGetUsage={getSectionUsage}
onRefresh={refreshIndependentSections as () => Promise<void>}
onGetUsage={getSectionUsage as (sectionId: number) => Promise<SectionUsageResponse>}
/>
{/* 필드 불러오기 다이얼로그 */}
@@ -930,8 +954,8 @@ export function ItemMasterDialogs({
selectedFieldId={selectedImportFieldId}
setSelectedFieldId={setSelectedImportFieldId}
onImport={handleImportField}
onRefresh={refreshIndependentFields}
onGetUsage={getFieldUsage}
onRefresh={refreshIndependentFields as () => Promise<void>}
onGetUsage={getFieldUsage as (fieldId: number) => Promise<FieldUsageResponse>}
targetSectionTitle={
importFieldTargetSectionId
? selectedPage?.sections.find(s => s.id === importFieldTargetSectionId)?.title

View File

@@ -9,7 +9,6 @@ import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import type { OptionColumn } from '../types';
interface AttributeSubTab {
id: string;
@@ -19,19 +18,30 @@ interface AttributeSubTab {
isDefault?: boolean;
}
// 유연한 OptionColumn 타입 - 다양한 소스에서 사용 가능
interface FlexibleOptionColumn {
id: string;
name: string;
key: string;
type: string;
required: boolean;
}
interface ColumnManageDialogProps {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
managingColumnType: string | null;
attributeSubTabs: AttributeSubTab[];
attributeColumns: Record<string, OptionColumn[]>;
setAttributeColumns: React.Dispatch<React.SetStateAction<Record<string, OptionColumn[]>>>;
// FlexibleOptionColumn 또는 OptionColumn 모두 허용
attributeColumns: Record<string, FlexibleOptionColumn[]>;
setAttributeColumns: React.Dispatch<React.SetStateAction<Record<string, FlexibleOptionColumn[]>>>;
newColumnName: string;
setNewColumnName: (name: string) => void;
newColumnKey: string;
setNewColumnKey: (key: string) => void;
newColumnType: 'text' | 'number';
setNewColumnType: (type: 'text' | 'number') => void;
// string 타입으로 유연하게 처리
newColumnType: string;
setNewColumnType: (type: string) => void;
newColumnRequired: boolean;
setNewColumnRequired: (required: boolean) => void;
}
@@ -134,7 +144,7 @@ export function ColumnManageDialog({
</div>
<div>
<Label></Label>
<Select value={newColumnType} onValueChange={(value: 'text' | 'number') => setNewColumnType(value)}>
<Select value={newColumnType} onValueChange={(value: string) => setNewColumnType(value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
@@ -162,7 +172,7 @@ export function ColumnManageDialog({
}
if (managingColumnType) {
const newColumn: OptionColumn = {
const newColumn: FlexibleOptionColumn = {
id: `col-${Date.now()}`,
name: newColumnName,
key: newColumnKey,

View File

@@ -18,6 +18,9 @@ import { fieldService } from '../services';
// 입력 타입 정의
export type InputType = 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
// 입력 모드 타입: 'new'/'existing' 또는 'custom'/'master' 모두 지원
type FieldInputModeType = 'new' | 'existing' | 'custom' | 'master';
// 텍스트박스 칼럼 타입 (단순 구조)
interface OptionColumn {
id: string;
@@ -25,6 +28,17 @@ interface OptionColumn {
key: string;
}
// 유연한 조건부 필드 설정 타입
interface FlexibleConditionField {
fieldId?: string;
fieldKey?: string;
fieldName?: string;
operator?: string;
value?: string;
expectedValue?: string;
logicOperator?: 'AND' | 'OR';
}
const INPUT_TYPE_OPTIONS: Array<{ value: InputType; label: string }> = [
{ value: 'textbox', label: '텍스트박스' },
{ value: 'dropdown', label: '드롭다운' },
@@ -39,36 +53,42 @@ interface FieldDialogProps {
onOpenChange: (open: boolean) => void;
editingFieldId: number | null;
setEditingFieldId: (id: number | null) => void;
fieldInputMode: 'custom' | 'master';
setFieldInputMode: (mode: 'custom' | 'master') => void;
// 'new'/'existing' 또는 'custom'/'master' 모두 지원
fieldInputMode: FieldInputModeType;
setFieldInputMode: (mode: FieldInputModeType) => void;
showMasterFieldList: boolean;
setShowMasterFieldList: (show: boolean) => void;
selectedMasterFieldId: string;
setSelectedMasterFieldId: (id: string) => void;
// string 또는 number | null 모두 지원
selectedMasterFieldId: string | number | null;
setSelectedMasterFieldId: (id: string | number | null) => void;
textboxColumns: OptionColumn[];
setTextboxColumns: React.Dispatch<React.SetStateAction<OptionColumn[]>>;
newFieldConditionEnabled: boolean;
setNewFieldConditionEnabled: (enabled: boolean) => void;
newFieldConditionTargetType: 'field' | 'section';
setNewFieldConditionTargetType: (type: 'field' | 'section') => void;
newFieldConditionFields: ConditionalFieldConfig[];
setNewFieldConditionFields: React.Dispatch<React.SetStateAction<ConditionalFieldConfig[]>>;
newFieldConditionSections: string[];
setNewFieldConditionSections: React.Dispatch<React.SetStateAction<string[]>>;
// 유연한 조건부 필드 설정 - ConditionalFieldConfig 또는 ConditionField 모두 지원
newFieldConditionFields: FlexibleConditionField[] | ConditionalFieldConfig[];
setNewFieldConditionFields: React.Dispatch<React.SetStateAction<FlexibleConditionField[]>> | React.Dispatch<React.SetStateAction<ConditionalFieldConfig[]>>;
// 유연한 섹션 조건 - string[] 또는 ConditionSection[] 모두 지원
newFieldConditionSections: string[] | Array<{ sectionId?: string; sectionTitle?: string; operator?: string; value?: string; logicOperator?: 'AND' | 'OR' }>;
setNewFieldConditionSections: React.Dispatch<React.SetStateAction<string[]>> | React.Dispatch<React.SetStateAction<Array<{ sectionId?: string; sectionTitle?: string; operator?: string; value?: string; logicOperator?: 'AND' | 'OR' }>>>;
tempConditionValue: string;
setTempConditionValue: (value: string) => void;
newFieldName: string;
setNewFieldName: (name: string) => void;
newFieldKey: string;
setNewFieldKey: (key: string) => void;
newFieldInputType: InputType;
setNewFieldInputType: (type: InputType) => void;
// string 타입으로 유연하게 처리
newFieldInputType: string;
setNewFieldInputType: (type: string) => void;
newFieldRequired: boolean;
setNewFieldRequired: (required: boolean) => void;
newFieldDescription: string;
setNewFieldDescription: (description: string) => void;
newFieldOptions: string;
setNewFieldOptions: (options: string) => void;
// string 또는 string[] 모두 지원
newFieldOptions: string | string[];
setNewFieldOptions: ((options: string | string[]) => void) | React.Dispatch<React.SetStateAction<string[]>> | React.Dispatch<React.SetStateAction<string>>;
selectedSectionForField: ItemSection | null;
selectedPage: ItemPage | null;
itemMasterFields: ItemMasterField[];
@@ -125,6 +145,22 @@ export function FieldDialog({
}: FieldDialogProps) {
const [isSubmitted, setIsSubmitted] = useState(false);
// 입력 모드 정규화: 'new' → 'custom', 'existing' → 'master'
const normalizedInputMode =
fieldInputMode === 'new' ? 'custom' :
fieldInputMode === 'existing' ? 'master' :
fieldInputMode;
const isCustomMode = normalizedInputMode === 'custom';
const isMasterMode = normalizedInputMode === 'master';
// 옵션을 문자열로 변환하여 처리
const optionsString = Array.isArray(newFieldOptions) ? newFieldOptions.join(', ') : newFieldOptions;
// setNewFieldOptions 래퍼 - union type 호환성 해결
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleSetNewFieldOptions = (options: string | string[]) => (setNewFieldOptions as any)(options);
// fieldService를 사용한 유효성 검사
const nameValidation = fieldService.validateFieldName(newFieldName);
const keyValidation = fieldService.validateFieldKey(newFieldKey);
@@ -151,7 +187,7 @@ export function FieldDialog({
setNewFieldKey('');
setNewFieldInputType('textbox');
setNewFieldRequired(false);
setNewFieldOptions('');
handleSetNewFieldOptions('');
setNewFieldDescription('');
};
@@ -169,7 +205,7 @@ export function FieldDialog({
{!editingFieldId && (
<div className="flex gap-2 p-1 bg-gray-100 rounded">
<Button
variant={fieldInputMode === 'custom' ? 'default' : 'ghost'}
variant={isCustomMode ? 'default' : 'ghost'}
size="sm"
onClick={() => setFieldInputMode('custom')}
className="flex-1"
@@ -177,7 +213,7 @@ export function FieldDialog({
</Button>
<Button
variant={fieldInputMode === 'master' ? 'default' : 'ghost'}
variant={isMasterMode ? 'default' : 'ghost'}
size="sm"
onClick={() => {
setFieldInputMode('master');
@@ -191,7 +227,7 @@ export function FieldDialog({
)}
{/* 항목 목록 */}
{fieldInputMode === 'master' && !editingFieldId && showMasterFieldList && (
{isMasterMode && !editingFieldId && showMasterFieldList && (
<div className="border rounded p-3 space-y-2 max-h-[400px] overflow-y-auto">
<div className="flex items-center justify-between mb-2">
<Label> </Label>
@@ -225,7 +261,7 @@ export function FieldDialog({
setNewFieldRequired(field.properties?.required || false);
setNewFieldDescription(field.description || '');
// options는 {label, value}[] 배열이므로 label만 추출
setNewFieldOptions(field.options?.map(opt => opt.label).join(', ') || '');
handleSetNewFieldOptions(field.options?.map(opt => opt.label).join(', ') || '');
if (field.properties?.multiColumn && field.properties?.columnNames) {
setTextboxColumns(
field.properties.columnNames.map((name: string, idx: number) => ({
@@ -271,7 +307,7 @@ export function FieldDialog({
)}
{/* 직접 입력 폼 */}
{(fieldInputMode === 'custom' || editingFieldId) && (
{(isCustomMode || editingFieldId) && (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
@@ -321,8 +357,8 @@ export function FieldDialog({
<div>
<Label> </Label>
<Input
value={newFieldOptions}
onChange={(e) => setNewFieldOptions(e.target.value)}
value={optionsString}
onChange={(e) => handleSetNewFieldOptions(e.target.value)}
placeholder="옵션1, 옵션2, 옵션3 (쉼표로 구분)"
/>
</div>
@@ -404,18 +440,18 @@ export function FieldDialog({
)}
{/* 조건부 표시 설정 - 모든 입력방식에서 사용 가능 */}
{(fieldInputMode === 'custom' || editingFieldId) && (
{(isCustomMode || editingFieldId) && (
<ConditionalDisplayUI
newFieldConditionEnabled={newFieldConditionEnabled}
setNewFieldConditionEnabled={setNewFieldConditionEnabled}
newFieldConditionTargetType={newFieldConditionTargetType}
setNewFieldConditionTargetType={setNewFieldConditionTargetType}
newFieldConditionFields={newFieldConditionFields}
setNewFieldConditionFields={setNewFieldConditionFields}
newFieldConditionFields={newFieldConditionFields as ConditionalFieldConfig[]}
setNewFieldConditionFields={setNewFieldConditionFields as (value: ConditionalFieldConfig[] | ((prev: ConditionalFieldConfig[]) => ConditionalFieldConfig[])) => void}
tempConditionValue={tempConditionValue}
setTempConditionValue={setTempConditionValue}
newFieldKey={newFieldKey}
newFieldInputType={newFieldInputType}
newFieldInputType={newFieldInputType as InputType}
selectedPage={selectedPage}
selectedSectionForField={selectedSectionForField}
editingFieldId={editingFieldId}
@@ -438,7 +474,7 @@ export function FieldDialog({
});
setIsSubmitted(true);
// 2025-11-28: field_key validation 추가
const shouldValidate = fieldInputMode === 'custom' || editingFieldId;
const shouldValidate = isCustomMode || editingFieldId;
console.log('[FieldDialog] 🔵 shouldValidate:', shouldValidate);
if (shouldValidate && (isNameEmpty || isKeyEmpty || isKeyInvalid)) {
console.log('[FieldDialog] ❌ 유효성 검사 실패로 return');

View File

@@ -27,45 +27,74 @@ const INPUT_TYPE_OPTIONS = [
{ value: 'textarea', label: '텍스트영역' }
];
// 유연한 조건부 필드 타입
interface FlexibleConditionField {
fieldId?: string;
fieldKey?: string;
fieldName?: string;
operator?: string;
value?: string;
expectedValue?: string;
logicOperator?: 'AND' | 'OR';
}
// 유연한 조건부 섹션 타입
interface FlexibleConditionSection {
sectionId?: string;
sectionTitle?: string;
operator?: string;
value?: string;
logicOperator?: 'AND' | 'OR';
}
// 입력 모드 타입: 'new'/'existing' 또는 'custom'/'master' 모두 지원
type FieldInputModeType = 'new' | 'existing' | 'custom' | 'master';
interface FieldDrawerProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
editingFieldId: number | null;
setEditingFieldId: (id: number | null) => void;
fieldInputMode: 'custom' | 'master';
setFieldInputMode: (mode: 'custom' | 'master') => void;
// 'new'/'existing' 또는 'custom'/'master' 모두 지원
fieldInputMode: FieldInputModeType;
setFieldInputMode: (mode: FieldInputModeType) => void;
showMasterFieldList: boolean;
setShowMasterFieldList: (show: boolean) => void;
selectedMasterFieldId: string;
setSelectedMasterFieldId: (id: string) => void;
// string 또는 number | null 모두 지원
selectedMasterFieldId: string | number | null;
setSelectedMasterFieldId: (id: string | number | null) => void;
textboxColumns: OptionColumn[];
setTextboxColumns: React.Dispatch<React.SetStateAction<OptionColumn[]>>;
newFieldConditionEnabled: boolean;
setNewFieldConditionEnabled: (enabled: boolean) => void;
newFieldConditionTargetType: 'field' | 'section';
setNewFieldConditionTargetType: (type: 'field' | 'section') => void;
newFieldConditionFields: Array<{ fieldKey: string; expectedValue: string }>;
setNewFieldConditionFields: React.Dispatch<React.SetStateAction<Array<{ fieldKey: string; expectedValue: string }>>>;
newFieldConditionSections: string[];
setNewFieldConditionSections: React.Dispatch<React.SetStateAction<string[]>>;
// 유연한 조건부 필드 타입
newFieldConditionFields: FlexibleConditionField[] | Array<{ fieldKey: string; expectedValue: string }>;
setNewFieldConditionFields: React.Dispatch<React.SetStateAction<FlexibleConditionField[]>> | React.Dispatch<React.SetStateAction<Array<{ fieldKey: string; expectedValue: string }>>>;
// 유연한 조건부 섹션 타입
newFieldConditionSections: string[] | FlexibleConditionSection[];
setNewFieldConditionSections: React.Dispatch<React.SetStateAction<string[]>> | React.Dispatch<React.SetStateAction<FlexibleConditionSection[]>>;
tempConditionValue: string;
setTempConditionValue: (value: string) => void;
newFieldName: string;
setNewFieldName: (name: string) => void;
newFieldKey: string;
setNewFieldKey: (key: string) => void;
newFieldInputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
setNewFieldInputType: (type: any) => void;
// string 타입으로 유연하게 처리
newFieldInputType: string;
setNewFieldInputType: (type: string) => void;
newFieldRequired: boolean;
setNewFieldRequired: (required: boolean) => void;
newFieldDescription: string;
setNewFieldDescription: (description: string) => void;
newFieldOptions: string;
setNewFieldOptions: (options: string) => void;
// string | string[] 모두 지원
newFieldOptions: string | string[];
setNewFieldOptions: ((options: string) => void) | React.Dispatch<React.SetStateAction<string[]>>;
selectedSectionForField: ItemSection | null;
selectedPage: ItemPage | null;
itemMasterFields: ItemMasterField[];
handleAddField: () => Promise<void>;
handleAddField: () => void | Promise<void>;
setIsColumnDialogOpen: (open: boolean) => void;
setEditingColumnId: (id: string | null) => void;
setColumnName: (name: string) => void;
@@ -116,6 +145,30 @@ export function FieldDrawer({
setColumnName,
setColumnKey
}: FieldDrawerProps) {
// 입력 모드 정규화: 'new' → 'custom', 'existing' → 'master'
const normalizedInputMode =
fieldInputMode === 'new' ? 'custom' :
fieldInputMode === 'existing' ? 'master' :
fieldInputMode;
const isCustomMode = normalizedInputMode === 'custom';
const isMasterMode = normalizedInputMode === 'master';
// 옵션을 문자열로 변환하여 처리
const optionsString = Array.isArray(newFieldOptions) ? newFieldOptions.join(', ') : newFieldOptions;
// setNewFieldOptions 래퍼 - union type 호환성 해결
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleSetNewFieldOptions = (options: string) => (setNewFieldOptions as any)(options);
// setNewFieldConditionFields 래퍼 - union type 호환성 해결
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleSetNewFieldConditionFields = (updater: any) => (setNewFieldConditionFields as any)(updater);
// setNewFieldConditionSections 래퍼 - union type 호환성 해결
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleSetNewFieldConditionSections = (updater: any) => (setNewFieldConditionSections as any)(updater);
const handleClose = () => {
onOpenChange(false);
setEditingFieldId(null);
@@ -145,7 +198,7 @@ export function FieldDrawer({
{!editingFieldId && (
<div className="flex gap-2 p-1 bg-gray-100 rounded">
<Button
variant={fieldInputMode === 'custom' ? 'default' : 'ghost'}
variant={isCustomMode ? 'default' : 'ghost'}
size="sm"
onClick={() => setFieldInputMode('custom')}
className="flex-1"
@@ -153,7 +206,7 @@ export function FieldDrawer({
</Button>
<Button
variant={fieldInputMode === 'master' ? 'default' : 'ghost'}
variant={isMasterMode ? 'default' : 'ghost'}
size="sm"
onClick={() => {
setFieldInputMode('master');
@@ -167,7 +220,7 @@ export function FieldDrawer({
)}
{/* 항목 목록 */}
{fieldInputMode === 'master' && !editingFieldId && showMasterFieldList && (
{isMasterMode && !editingFieldId && showMasterFieldList && (
<div className="border rounded p-3 space-y-2 max-h-[400px] overflow-y-auto">
<div className="flex items-center justify-between mb-2">
<Label> </Label>
@@ -200,7 +253,7 @@ export function FieldDrawer({
setNewFieldInputType(field.field_type);
setNewFieldRequired((field.properties as any)?.required ?? false);
setNewFieldDescription(field.description || '');
setNewFieldOptions(field.options?.map(o => o.value).join(', ') || '');
handleSetNewFieldOptions(field.options?.map(o => o.value).join(', ') || '');
const props = field.properties as any;
if (props?.multiColumn && props?.columnNames) {
setTextboxColumns(
@@ -247,7 +300,7 @@ export function FieldDrawer({
)}
{/* 직접 입력 폼 */}
{(fieldInputMode === 'custom' || editingFieldId) && (
{(isCustomMode || editingFieldId) && (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
@@ -286,8 +339,8 @@ export function FieldDrawer({
<div>
<Label> </Label>
<Input
value={newFieldOptions}
onChange={(e) => setNewFieldOptions(e.target.value)}
value={optionsString}
onChange={(e) => handleSetNewFieldOptions(e.target.value)}
placeholder="옵션1, 옵션2, 옵션3 (쉼표로 구분)"
/>
</div>
@@ -369,7 +422,7 @@ export function FieldDrawer({
)}
{/* 조건부 표시 설정 - 모든 입력방식에서 사용 가능 */}
{(fieldInputMode === 'custom' || editingFieldId) && (
{(isCustomMode || editingFieldId) && (
<div className="border-t pt-4 space-y-3">
<div className="space-y-2">
<div className="flex items-center gap-2">
@@ -450,7 +503,7 @@ export function FieldDrawer({
variant="ghost"
size="sm"
onClick={() => {
setNewFieldConditionFields(prev => prev.filter((_, i) => i !== index));
handleSetNewFieldConditionFields((prev: FlexibleConditionField[]) => prev.filter((_: FlexibleConditionField, i: number) => i !== index));
toast.success('조건이 제거되었습니다.');
}}
className="h-8 w-8 p-0"
@@ -481,7 +534,7 @@ export function FieldDrawer({
size="sm"
onClick={() => {
if (tempConditionValue) {
setNewFieldConditionFields(prev => [...prev, {
handleSetNewFieldConditionFields((prev: FlexibleConditionField[]) => [...prev, {
fieldKey: newFieldKey, // 현재 항목 자신의 키를 사용
expectedValue: tempConditionValue
}]);
@@ -533,7 +586,7 @@ export function FieldDrawer({
onClick={() => {
if (tempConditionValue) {
if (!newFieldConditionFields.find(f => f.expectedValue === tempConditionValue)) {
setNewFieldConditionFields(prev => [...prev, {
handleSetNewFieldConditionFields((prev: FlexibleConditionField[]) => [...prev, {
fieldKey: newFieldKey,
expectedValue: tempConditionValue
}]);
@@ -563,7 +616,7 @@ export function FieldDrawer({
{condition.expectedValue}
<button
onClick={() => {
setNewFieldConditionFields(prev => prev.filter((_, i) => i !== index));
handleSetNewFieldConditionFields((prev: FlexibleConditionField[]) => prev.filter((_: FlexibleConditionField, i: number) => i !== index));
toast.success('조건값이 제거되었습니다.');
}}
className="ml-1 hover:text-red-500"
@@ -593,9 +646,9 @@ export function FieldDrawer({
onChange={(e) => {
const sectionIdStr = String(section.id);
if (e.target.checked) {
setNewFieldConditionSections(prev => [...prev, sectionIdStr]);
handleSetNewFieldConditionSections((prev: string[]) => [...prev, sectionIdStr]);
} else {
setNewFieldConditionSections(prev => prev.filter(id => id !== sectionIdStr));
handleSetNewFieldConditionSections((prev: string[]) => prev.filter((id: string) => id !== sectionIdStr));
}
}}
className="cursor-pointer"

View File

@@ -16,8 +16,10 @@ import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { FormInput, Search, Info, Loader2, Hash, Calendar, CheckSquare, ChevronDown, Type, AlignLeft, Database } from 'lucide-react';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import type { ItemField, ItemMasterField } from '@/contexts/ItemMasterContext';
import type { FieldUsageResponse } from '@/types/item-master-api';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
interface ImportFieldDialogProps {
isOpen: boolean;
@@ -109,6 +111,7 @@ export function ImportFieldDialog({
const usage = await onGetUsage(selectedFieldId);
setUsageInfo(usage);
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Failed to load usage info:', error);
setUsageInfo(null);
} finally {
@@ -154,10 +157,7 @@ export function ImportFieldDialog({
<div className="space-y-4">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground"> ...</span>
</div>
<ContentLoadingSpinner text="필드 목록을 불러오는 중..." />
) : filteredFields.length === 0 ? (
<div className="text-center py-8">
<FormInput className="h-12 w-12 mx-auto text-muted-foreground mb-4" />

View File

@@ -7,8 +7,10 @@ import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Package, Folder, Search, Info, Loader2 } from 'lucide-react';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import type { ItemSection } from '@/contexts/ItemMasterContext';
import type { SectionUsageResponse } from '@/types/item-master-api';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
interface ImportSectionDialogProps {
isOpen: boolean;
@@ -61,6 +63,7 @@ export function ImportSectionDialog({
const usage = await onGetUsage(selectedSectionId);
setUsageInfo(usage);
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Failed to load usage info:', error);
setUsageInfo(null);
} finally {
@@ -106,10 +109,7 @@ export function ImportSectionDialog({
<div className="space-y-4">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground"> ...</span>
</div>
<ContentLoadingSpinner text="섹션 목록을 불러오는 중..." />
) : filteredSections.length === 0 ? (
<div className="text-center py-8">
<Folder className="h-12 w-12 mx-auto text-muted-foreground mb-4" />

View File

@@ -10,8 +10,9 @@ interface LoadTemplateDialogProps {
isLoadTemplateDialogOpen: boolean;
setIsLoadTemplateDialogOpen: (open: boolean) => void;
sectionTemplates: SectionTemplate[];
selectedTemplateId: string | null;
setSelectedTemplateId: (id: string | null) => void;
// string 또는 number | null 모두 지원
selectedTemplateId: string | number | null;
setSelectedTemplateId: (id: string | number | null) => void;
handleLoadTemplate: () => void;
}

View File

@@ -23,24 +23,27 @@ interface MasterFieldDialogProps {
setNewMasterFieldName: (name: string) => void;
newMasterFieldKey: string;
setNewMasterFieldKey: (key: string) => void;
newMasterFieldInputType: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
setNewMasterFieldInputType: (type: any) => void;
// string 타입으로 유연하게 처리
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: (options: string) => void;
newMasterFieldAttributeType: 'custom' | 'unit' | 'material' | 'surface';
setNewMasterFieldAttributeType: (type: 'custom' | 'unit' | 'material' | 'surface') => void;
// string 또는 string[] 모두 지원
newMasterFieldOptions: string | string[];
setNewMasterFieldOptions: ((options: string) => void) | React.Dispatch<React.SetStateAction<string[]>>;
// string 타입으로 유연하게 처리
newMasterFieldAttributeType: string;
setNewMasterFieldAttributeType: (type: string) => void;
newMasterFieldMultiColumn: boolean;
setNewMasterFieldMultiColumn: (multi: boolean) => void;
newMasterFieldColumnCount: number;
setNewMasterFieldColumnCount: (count: number) => void;
newMasterFieldColumnNames: string[];
setNewMasterFieldColumnNames: (names: string[]) => void;
setNewMasterFieldColumnNames: ((names: string[]) => void) | React.Dispatch<React.SetStateAction<string[]>>;
handleUpdateMasterField: () => void;
handleAddMasterField: () => void;
}
@@ -77,6 +80,13 @@ export function MasterFieldDialog({
}: MasterFieldDialogProps) {
const [isSubmitted, setIsSubmitted] = useState(false);
// 옵션을 문자열로 변환하여 처리
const optionsString = Array.isArray(newMasterFieldOptions) ? newMasterFieldOptions.join(', ') : newMasterFieldOptions;
// setNewMasterFieldOptions 래퍼 - union type 호환성 해결
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleSetNewMasterFieldOptions = (options: string) => (setNewMasterFieldOptions as any)(options);
// 2025-12-01: masterFieldService 사용으로 유효성 검사 중앙화
const nameValidation = masterFieldService.validateFieldName(newMasterFieldName);
const keyValidation = masterFieldService.validateFieldKey(newMasterFieldKey);
@@ -93,7 +103,7 @@ export function MasterFieldDialog({
setNewMasterFieldRequired(false);
setNewMasterFieldCategory('공통');
setNewMasterFieldDescription('');
setNewMasterFieldOptions('');
handleSetNewMasterFieldOptions('');
setNewMasterFieldAttributeType('custom');
setNewMasterFieldMultiColumn(false);
setNewMasterFieldColumnCount(2);
@@ -267,8 +277,8 @@ export function MasterFieldDialog({
)}
</div>
<Textarea
value={newMasterFieldOptions}
onChange={(e) => setNewMasterFieldOptions(e.target.value)}
value={optionsString}
onChange={(e) => handleSetNewMasterFieldOptions(e.target.value)}
placeholder="제품,부품,원자재 (쉼표로 구분)"
disabled={newMasterFieldAttributeType !== 'custom'}
className="min-h-[80px]"

View File

@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
// 유연한 타입 정의 - 다양한 소스에서 사용 가능
interface OptionColumn {
id: string;
name: string;
@@ -33,12 +34,14 @@ interface OptionDialogProps {
setNewOptionLabel: (label: string) => void;
newOptionColumnValues: Record<string, string>;
setNewOptionColumnValues: (values: Record<string, string>) => void;
newOptionInputType: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
setNewOptionInputType: (type: any) => void;
// string 타입으로 유연하게 처리 (textbox, number, dropdown, checkbox, date, textarea)
newOptionInputType: string;
setNewOptionInputType: (type: string) => void;
newOptionRequired: boolean;
setNewOptionRequired: (required: boolean) => void;
newOptionOptions: string;
setNewOptionOptions: (options: string) => void;
// string[] 또는 string 모두 지원
newOptionOptions: string | string[];
setNewOptionOptions: (options: string | string[]) => void;
newOptionPlaceholder: string;
setNewOptionPlaceholder: (placeholder: string) => void;
newOptionDefaultValue: string;
@@ -75,10 +78,13 @@ export function OptionDialog({
}: OptionDialogProps) {
const [isSubmitted, setIsSubmitted] = useState(false);
// 옵션을 문자열로 변환하여 처리
const optionsString = Array.isArray(newOptionOptions) ? newOptionOptions.join(', ') : newOptionOptions;
// 유효성 검사
const isValueEmpty = !newOptionValue.trim();
const isLabelEmpty = !newOptionLabel.trim();
const isDropdownOptionsEmpty = newOptionInputType === 'dropdown' && !newOptionOptions.trim();
const isDropdownOptionsEmpty = newOptionInputType === 'dropdown' && !optionsString.trim();
const handleClose = () => {
setIsOpen(false);
@@ -176,7 +182,7 @@ export function OptionDialog({
<span className="text-red-500">*</span>
</Label>
<Input
value={newOptionOptions}
value={optionsString}
onChange={(e) => setNewOptionOptions(e.target.value)}
placeholder="옵션1, 옵션2, 옵션3 (쉼표로 구분)"
className={isSubmitted && isDropdownOptionsEmpty ? 'border-red-500 focus-visible:ring-red-500' : ''}

View File

@@ -9,6 +9,9 @@ import { Textarea } from '@/components/ui/textarea';
import { FileText, Package, Check } from 'lucide-react';
import type { SectionTemplate } from '@/contexts/ItemMasterContext';
// 입력 모드 타입: 'new'/'existing' 또는 'custom'/'template' 모두 지원
type InputModeType = 'new' | 'existing' | 'custom' | 'template';
interface SectionDialogProps {
isSectionDialogOpen: boolean;
setIsSectionDialogOpen: (open: boolean) => void;
@@ -19,13 +22,13 @@ interface SectionDialogProps {
newSectionDescription: string;
setNewSectionDescription: (description: string) => void;
handleAddSection: () => void;
// 템플릿 선택 관련 props
sectionInputMode: 'custom' | 'template';
setSectionInputMode: (mode: 'custom' | 'template') => void;
// 템플릿 선택 관련 props - 유연한 타입 지원
sectionInputMode: InputModeType;
setSectionInputMode: (mode: InputModeType) => void;
sectionTemplates: SectionTemplate[];
selectedTemplateId: number | null;
setSelectedTemplateId: (id: number | null) => void;
handleLinkTemplate: (template: SectionTemplate) => void;
handleLinkTemplate: (template: SectionTemplate) => void | Promise<void> | ((template?: SectionTemplate) => void | Promise<void>);
}
export function SectionDialog({
@@ -47,6 +50,15 @@ export function SectionDialog({
}: SectionDialogProps) {
const [isSubmitted, setIsSubmitted] = useState(false);
// 입력 모드 정규화: 'new' → 'custom', 'existing' → 'template'
const normalizedInputMode =
sectionInputMode === 'new' ? 'custom' :
sectionInputMode === 'existing' ? 'template' :
sectionInputMode;
const isCustomMode = normalizedInputMode === 'custom';
const isTemplateMode = normalizedInputMode === 'template';
// 유효성 검사
const isTitleEmpty = !newSectionTitle.trim();
@@ -62,7 +74,7 @@ export function SectionDialog({
const handleSubmit = () => {
setIsSubmitted(true);
if (sectionInputMode === 'custom' && !isTitleEmpty) {
if (isCustomMode && !isTitleEmpty) {
handleAddSection();
setIsSubmitted(false);
}
@@ -152,7 +164,7 @@ export function SectionDialog({
{/* 2. 입력 모드 선택 */}
<div className="flex gap-2 p-1 bg-gray-100 rounded">
<Button
variant={sectionInputMode === 'custom' ? 'default' : 'ghost'}
variant={isCustomMode ? 'default' : 'ghost'}
size="sm"
onClick={() => {
setSectionInputMode('custom');
@@ -165,7 +177,7 @@ export function SectionDialog({
</Button>
<Button
variant={sectionInputMode === 'template' ? 'default' : 'ghost'}
variant={isTemplateMode ? 'default' : 'ghost'}
size="sm"
onClick={() => {
setSectionInputMode('template');
@@ -180,7 +192,7 @@ export function SectionDialog({
</div>
{/* 3. 템플릿 목록 - 선택된 섹션 타입에 따라 필터링 */}
{sectionInputMode === 'template' && (
{isTemplateMode && (
<div className="border rounded p-3 space-y-2 max-h-[250px] overflow-y-auto">
<div className="flex items-center justify-between mb-2">
<Label className="text-sm font-medium">
@@ -248,7 +260,7 @@ export function SectionDialog({
)}
{/* 4. 직접 입력 폼 */}
{sectionInputMode === 'custom' && (
{isCustomMode && (
<>
<div>
<Label> *</Label>
@@ -283,7 +295,7 @@ export function SectionDialog({
)}
{/* 5. 선택된 템플릿 정보 표시 */}
{sectionInputMode === 'template' && selectedTemplateId && (
{isTemplateMode && selectedTemplateId && (
<div className="bg-green-50 p-3 rounded-md border border-green-200">
<p className="text-sm text-green-700">
<strong> 릿:</strong> "{newSectionTitle}"() .
@@ -297,7 +309,7 @@ export function SectionDialog({
<Button variant="outline" onClick={handleClose} className="w-full sm:w-auto">
</Button>
{sectionInputMode === 'template' && selectedTemplateId ? (
{isTemplateMode && selectedTemplateId ? (
<Button
onClick={() => {
const template = sectionTemplates.find(t => t.id === selectedTemplateId);
@@ -311,7 +323,7 @@ export function SectionDialog({
<Button
onClick={handleSubmit}
className="w-full sm:w-auto"
disabled={sectionInputMode === 'template' && !selectedTemplateId}
disabled={isTemplateMode && !selectedTemplateId}
>
</Button>

View File

@@ -24,8 +24,9 @@ interface SectionTemplateDialogProps {
setNewSectionTemplateTitle: (title: string) => void;
newSectionTemplateDescription: string;
setNewSectionTemplateDescription: (description: string) => void;
newSectionTemplateCategory: string[];
setNewSectionTemplateCategory: (category: string[]) => void;
// string 또는 string[] 모두 지원
newSectionTemplateCategory: string | string[];
setNewSectionTemplateCategory: (category: string | string[]) => void;
newSectionTemplateType: 'fields' | 'bom';
setNewSectionTemplateType: (type: 'fields' | 'bom') => void;
handleUpdateSectionTemplate: () => void;
@@ -50,6 +51,11 @@ export function SectionTemplateDialog({
}: SectionTemplateDialogProps) {
const [isSubmitted, setIsSubmitted] = useState(false);
// 카테고리 정규화: string → string[] 변환
const categoryArray = Array.isArray(newSectionTemplateCategory)
? newSectionTemplateCategory
: newSectionTemplateCategory ? [newSectionTemplateCategory] : [];
// 유효성 검사
const isTitleEmpty = !newSectionTemplateTitle.trim();
@@ -140,12 +146,12 @@ export function SectionTemplateDialog({
<input
type="checkbox"
id={`cat-${type.value}`}
checked={newSectionTemplateCategory.includes(type.value)}
checked={categoryArray.includes(type.value)}
onChange={(e) => {
if (e.target.checked) {
setNewSectionTemplateCategory([...newSectionTemplateCategory, type.value]);
setNewSectionTemplateCategory([...categoryArray, type.value]);
} else {
setNewSectionTemplateCategory(newSectionTemplateCategory.filter(c => c !== type.value));
setNewSectionTemplateCategory(categoryArray.filter(c => c !== type.value));
}
}}
className="rounded"

View File

@@ -21,21 +21,27 @@ const INPUT_TYPE_OPTIONS = [
{ value: 'textarea', label: '긴 텍스트' },
];
// 입력 모드 타입: 'new'/'existing' 또는 'custom'/'master' 모두 지원
type TemplateFieldInputModeType = 'new' | 'existing' | 'custom' | 'master';
interface TemplateFieldDialogProps {
isTemplateFieldDialogOpen: boolean;
setIsTemplateFieldDialogOpen: (open: boolean) => void;
editingTemplateFieldId: number | null;
setEditingTemplateFieldId: (id: number | null) => void;
// string 또는 number | null 모두 지원
editingTemplateFieldId: string | number | null;
setEditingTemplateFieldId: ((id: string | null) => void) | ((id: number | null) => void);
templateFieldName: string;
setTemplateFieldName: (name: string) => void;
templateFieldKey: string;
setTemplateFieldKey: (key: string) => void;
templateFieldInputType: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
setTemplateFieldInputType: (type: any) => void;
// string 타입으로 유연하게 처리
templateFieldInputType: string;
setTemplateFieldInputType: (type: string) => void;
templateFieldRequired: boolean;
setTemplateFieldRequired: (required: boolean) => void;
templateFieldOptions: string;
setTemplateFieldOptions: (options: string) => void;
// string 또는 string[] 모두 지원
templateFieldOptions: string | string[];
setTemplateFieldOptions: ((options: string) => void) | React.Dispatch<React.SetStateAction<string[]>>;
templateFieldDescription: string;
setTemplateFieldDescription: (description: string) => void;
templateFieldMultiColumn: boolean;
@@ -43,16 +49,17 @@ interface TemplateFieldDialogProps {
templateFieldColumnCount: number;
setTemplateFieldColumnCount: (count: number) => void;
templateFieldColumnNames: string[];
setTemplateFieldColumnNames: (names: string[]) => void;
setTemplateFieldColumnNames: ((names: string[]) => void) | React.Dispatch<React.SetStateAction<string[]>>;
handleAddTemplateField: () => void | Promise<void>;
// 항목 관련 props
// 항목 관련 props - 유연한 타입 지원
itemMasterFields?: ItemMasterField[];
templateFieldInputMode?: 'custom' | 'master';
setTemplateFieldInputMode?: (mode: 'custom' | 'master') => void;
templateFieldInputMode?: TemplateFieldInputModeType;
setTemplateFieldInputMode?: (mode: TemplateFieldInputModeType) => void;
showMasterFieldList?: boolean;
setShowMasterFieldList?: (show: boolean) => void;
selectedMasterFieldId?: string;
setSelectedMasterFieldId?: (id: string) => void;
// string 또는 number | null 모두 지원
selectedMasterFieldId?: string | number | null;
setSelectedMasterFieldId?: ((id: string) => void) | ((id: number | null) => void);
}
export function TemplateFieldDialog({
@@ -90,6 +97,23 @@ export function TemplateFieldDialog({
}: TemplateFieldDialogProps) {
const [isSubmitted, setIsSubmitted] = useState(false);
// 입력 모드 정규화: 'new' → 'custom', 'existing' → 'master'
const normalizedInputMode =
templateFieldInputMode === 'new' ? 'custom' :
templateFieldInputMode === 'existing' ? 'master' :
templateFieldInputMode;
// 옵션을 문자열로 변환하여 처리
const optionsString = Array.isArray(templateFieldOptions) ? templateFieldOptions.join(', ') : templateFieldOptions;
// 래퍼 함수들 - union type 호환성 해결
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleSetTemplateFieldOptions = (options: string) => (setTemplateFieldOptions as any)(options);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleSetEditingTemplateFieldId = (id: string | null) => (setEditingTemplateFieldId as any)(id);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleSetSelectedMasterFieldId = (id: string) => setSelectedMasterFieldId && (setSelectedMasterFieldId as any)(id);
// 유효성 검사
const isNameEmpty = !templateFieldName.trim();
const isKeyEmpty = !templateFieldKey.trim();
@@ -97,12 +121,12 @@ export function TemplateFieldDialog({
const handleClose = () => {
setIsSubmitted(false);
setIsTemplateFieldDialogOpen(false);
setEditingTemplateFieldId(null);
handleSetEditingTemplateFieldId(null);
setTemplateFieldName('');
setTemplateFieldKey('');
setTemplateFieldInputType('textbox');
setTemplateFieldRequired(false);
setTemplateFieldOptions('');
handleSetTemplateFieldOptions('');
setTemplateFieldDescription('');
setTemplateFieldMultiColumn(false);
setTemplateFieldColumnCount(2);
@@ -110,18 +134,18 @@ export function TemplateFieldDialog({
// 항목 관련 상태 초기화
setTemplateFieldInputMode?.('custom');
setShowMasterFieldList?.(false);
setSelectedMasterFieldId?.('');
handleSetSelectedMasterFieldId('');
};
const handleSelectMasterField = (field: ItemMasterField) => {
setSelectedMasterFieldId?.(String(field.id));
handleSetSelectedMasterFieldId(String(field.id));
setTemplateFieldName(field.field_name);
setTemplateFieldKey(field.id.toString());
setTemplateFieldInputType(field.field_type);
setTemplateFieldRequired(field.properties?.required || false);
setTemplateFieldDescription(field.description || '');
// options는 {label, value}[] 배열이므로 label만 추출
setTemplateFieldOptions(field.options?.map(opt => opt.label).join(', ') || '');
handleSetTemplateFieldOptions(field.options?.map(opt => opt.label).join(', ') || '');
if (field.properties?.multiColumn && field.properties?.columnNames) {
setTemplateFieldMultiColumn(true);
setTemplateFieldColumnCount(field.properties.columnNames.length);
@@ -145,7 +169,7 @@ export function TemplateFieldDialog({
{!editingTemplateFieldId && setTemplateFieldInputMode && (
<div className="flex gap-2 p-1 bg-gray-100 rounded">
<Button
variant={templateFieldInputMode === 'custom' ? 'default' : 'ghost'}
variant={normalizedInputMode === 'custom' ? 'default' : 'ghost'}
size="sm"
onClick={() => setTemplateFieldInputMode('custom')}
className="flex-1"
@@ -153,7 +177,7 @@ export function TemplateFieldDialog({
</Button>
<Button
variant={templateFieldInputMode === 'master' ? 'default' : 'ghost'}
variant={normalizedInputMode === 'master' ? 'default' : 'ghost'}
size="sm"
onClick={() => {
setTemplateFieldInputMode('master');
@@ -167,7 +191,7 @@ export function TemplateFieldDialog({
)}
{/* 항목 목록 */}
{templateFieldInputMode === 'master' && !editingTemplateFieldId && showMasterFieldList && (
{normalizedInputMode === 'master' && !editingTemplateFieldId && showMasterFieldList && (
<div className="border rounded p-3 space-y-2 max-h-[400px] overflow-y-auto">
<div className="flex items-center justify-between mb-2">
<Label> </Label>
@@ -276,8 +300,8 @@ export function TemplateFieldDialog({
<div>
<Label> </Label>
<Input
value={templateFieldOptions}
onChange={(e) => setTemplateFieldOptions(e.target.value)}
value={optionsString}
onChange={(e) => handleSetTemplateFieldOptions(e.target.value)}
placeholder="옵션1, 옵션2, 옵션3 (쉼표로 구분)"
/>
</div>

View File

@@ -30,12 +30,14 @@ export interface UseAttributeManagementReturn {
setNewOptionLabel: (label: string) => void;
newOptionColumnValues: Record<string, string>;
setNewOptionColumnValues: React.Dispatch<React.SetStateAction<Record<string, string>>>;
newOptionInputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
setNewOptionInputType: (type: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea') => void;
// string 타입으로 유연하게 처리 (다양한 컴포넌트에서 사용)
newOptionInputType: string;
setNewOptionInputType: (type: string) => void;
newOptionRequired: boolean;
setNewOptionRequired: (required: boolean) => void;
newOptionOptions: string;
setNewOptionOptions: (options: string) => void;
// string 또는 string[] 모두 지원 (다양한 컴포넌트에서 사용)
newOptionOptions: string | string[];
setNewOptionOptions: (options: string | string[]) => void;
newOptionPlaceholder: string;
setNewOptionPlaceholder: (placeholder: string) => void;
newOptionDefaultValue: string;
@@ -54,8 +56,9 @@ export interface UseAttributeManagementReturn {
setNewColumnName: (name: string) => void;
newColumnKey: string;
setNewColumnKey: (key: string) => void;
newColumnType: 'text' | 'number';
setNewColumnType: (type: 'text' | 'number') => void;
// string 타입으로 유연하게 처리
newColumnType: string;
setNewColumnType: (type: string) => void;
newColumnRequired: boolean;
setNewColumnRequired: (required: boolean) => void;
@@ -114,7 +117,7 @@ export function useAttributeManagement(): UseAttributeManagementReturn {
const [newOptionValue, setNewOptionValue] = useState('');
const [newOptionLabel, setNewOptionLabel] = useState('');
const [newOptionColumnValues, setNewOptionColumnValues] = useState<Record<string, string>>({});
const [newOptionInputType, setNewOptionInputType] = useState<'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea'>('textbox');
const [newOptionInputType, setNewOptionInputType] = useState<string>('textbox');
const [newOptionRequired, setNewOptionRequired] = useState(false);
const [newOptionOptions, setNewOptionOptions] = useState('');
const [newOptionPlaceholder, setNewOptionPlaceholder] = useState('');
@@ -128,7 +131,7 @@ export function useAttributeManagement(): UseAttributeManagementReturn {
// 칼럼 폼 상태
const [newColumnName, setNewColumnName] = useState('');
const [newColumnKey, setNewColumnKey] = useState('');
const [newColumnType, setNewColumnType] = useState<'text' | 'number'>('text');
const [newColumnType, setNewColumnType] = useState<string>('text');
const [newColumnRequired, setNewColumnRequired] = useState(false);
// 이전 옵션 값 추적용 ref (무한 루프 방지)
@@ -314,6 +317,15 @@ export function useAttributeManagement(): UseAttributeManagementReturn {
setNewColumnRequired(false);
};
// 유연한 타입 래퍼: string | string[] → string 변환
const handleSetNewOptionOptions = (options: string | string[]) => {
if (Array.isArray(options)) {
setNewOptionOptions(options.join(', '));
} else {
setNewOptionOptions(options);
}
};
return {
// 속성 옵션 상태
unitOptions,
@@ -343,7 +355,7 @@ export function useAttributeManagement(): UseAttributeManagementReturn {
newOptionRequired,
setNewOptionRequired,
newOptionOptions,
setNewOptionOptions,
setNewOptionOptions: handleSetNewOptionOptions,
newOptionPlaceholder,
setNewOptionPlaceholder,
newOptionDefaultValue,

View File

@@ -6,6 +6,7 @@ import { toast } from 'sonner';
import type { ItemPage, BOMItem } from '@/contexts/ItemMasterContext';
import type { CustomTab, AttributeSubTab } from './useTabManagement';
import type { MasterOption, OptionColumn } from '../types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
// 타입 alias (기존 호환성)
type UnitOption = MasterOption;
@@ -64,6 +65,7 @@ export function useDeleteManagement({ itemPages }: UseDeleteManagementProps): Us
await unlinkFieldFromSection(Number(sectionId), Number(fieldId));
console.log('필드 연결 해제 완료:', fieldId);
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('필드 연결 해제 실패:', error);
toast.error('필드 연결 해제에 실패했습니다');
}
@@ -110,6 +112,7 @@ export function useDeleteManagement({ itemPages }: UseDeleteManagementProps): Us
window.location.reload();
}, 1500);
} catch (error) {
if (isNextRedirectError(error)) throw error;
toast.error('초기화 중 오류가 발생했습니다');
console.error('Reset error:', error);
}

View File

@@ -8,6 +8,13 @@ import type { ItemPage, ItemField, ItemMasterField, FieldDisplayCondition } from
import { type ConditionalFieldConfig } from '../components/ConditionalDisplayUI';
import { fieldService } from '../services';
import { ApiError } from '@/lib/api/error-handler';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
// 필드 타입 정의 - field_type 캐스팅용
type FieldTypeValue = 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
// 입력 모드 타입: 'new'/'existing' 또는 'custom'/'master' 모두 지원
type FieldInputModeType = 'new' | 'existing' | 'custom' | 'master';
export interface UseFieldManagementReturn {
// 다이얼로그 상태
@@ -19,20 +26,22 @@ export interface UseFieldManagementReturn {
setEditingFieldId: (id: number | null) => void;
// 입력 모드
fieldInputMode: 'master' | 'custom';
setFieldInputMode: (mode: 'master' | 'custom') => void;
fieldInputMode: FieldInputModeType;
setFieldInputMode: (mode: FieldInputModeType) => void;
showMasterFieldList: boolean;
setShowMasterFieldList: (show: boolean) => void;
selectedMasterFieldId: string;
setSelectedMasterFieldId: (id: string) => void;
// string 또는 number | null 모두 지원
selectedMasterFieldId: string | number | null;
setSelectedMasterFieldId: (id: string | number | null) => void;
// 필드 폼 상태
newFieldName: string;
setNewFieldName: (name: string) => void;
newFieldKey: string;
setNewFieldKey: (key: string) => void;
newFieldInputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
setNewFieldInputType: (type: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea') => void;
// string 타입으로 유연하게 처리
newFieldInputType: string;
setNewFieldInputType: (type: string) => void;
newFieldRequired: boolean;
setNewFieldRequired: (required: boolean) => void;
newFieldOptions: string;
@@ -90,14 +99,14 @@ export function useFieldManagement(): UseFieldManagementReturn {
const [editingFieldId, setEditingFieldId] = useState<number | null>(null);
// 입력 모드
const [fieldInputMode, setFieldInputMode] = useState<'master' | 'custom'>('custom');
const [fieldInputMode, setFieldInputMode] = useState<FieldInputModeType>('custom');
const [showMasterFieldList, setShowMasterFieldList] = useState(false);
const [selectedMasterFieldId, setSelectedMasterFieldId] = useState('');
const [selectedMasterFieldId, setSelectedMasterFieldId] = useState<string | number | null>('');
// 필드 폼 상태
const [newFieldName, setNewFieldName] = useState('');
const [newFieldKey, setNewFieldKey] = useState('');
const [newFieldInputType, setNewFieldInputType] = useState<'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea'>('textbox');
const [newFieldInputType, setNewFieldInputType] = useState<string>('textbox');
const [newFieldRequired, setNewFieldRequired] = useState(false);
const [newFieldOptions, setNewFieldOptions] = useState('');
const [newFieldDescription, setNewFieldDescription] = useState('');
@@ -191,7 +200,7 @@ export function useFieldManagement(): UseFieldManagementReturn {
master_field_id: masterFieldId,
field_name: newFieldName,
field_key: newFieldKey, // 2025-11-28: field_key 추가 (백엔드에서 {ID}_{입력값} 형태로 저장)
field_type: newFieldInputType,
field_type: newFieldInputType as FieldTypeValue,
order_no: 0,
is_required: newFieldRequired,
placeholder: newFieldDescription || null,
@@ -242,6 +251,7 @@ export function useFieldManagement(): UseFieldManagementReturn {
resetFieldForm();
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('필드 처리 실패:', error);
// 422 ValidationException 상세 메시지 처리 (field_key 중복/예약어, field_name 중복 등)

View File

@@ -5,6 +5,7 @@ import { useItemMaster } from '@/contexts/ItemMasterContext';
import { getErrorMessage } from '@/lib/api/error-handler';
import { toast } from 'sonner';
import type { ItemPage } from '@/contexts/ItemMasterContext';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
export interface UseImportManagementReturn {
// 섹션 Import 상태
@@ -52,6 +53,7 @@ export function useImportManagement(): UseImportManagementReturn {
toast.success('섹션을 불러왔습니다.');
setSelectedImportSectionId(null);
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('섹션 불러오기 실패:', error);
toast.error(getErrorMessage(error));
}
@@ -73,6 +75,7 @@ export function useImportManagement(): UseImportManagementReturn {
setSelectedImportFieldId(null);
setImportFieldTargetSectionId(null);
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('필드 불러오기 실패:', error);
toast.error(getErrorMessage(error));
}
@@ -84,6 +87,7 @@ export function useImportManagement(): UseImportManagementReturn {
await cloneSection(sectionId);
toast.success('섹션이 복제되었습니다.');
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('섹션 복제 실패:', error);
toast.error(getErrorMessage(error));
}

View File

@@ -7,6 +7,10 @@ import { useErrorAlert } from '../contexts';
import type { ItemMasterField } from '@/contexts/ItemMasterContext';
import { masterFieldService } from '../services';
import { ApiError } from '@/lib/api/error-handler';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
// 필드 타입 정의 - field_type 캐스팅용
type FieldTypeValue = 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
/**
* @deprecated 2025-11-27: item_fields로 통합됨.
@@ -26,8 +30,9 @@ export interface UseMasterFieldManagementReturn {
setNewMasterFieldName: (name: string) => void;
newMasterFieldKey: string;
setNewMasterFieldKey: (key: string) => void;
newMasterFieldInputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
setNewMasterFieldInputType: (type: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea') => void;
// string 타입으로 유연하게 처리
newMasterFieldInputType: string;
setNewMasterFieldInputType: (type: string) => void;
newMasterFieldRequired: boolean;
setNewMasterFieldRequired: (required: boolean) => void;
newMasterFieldCategory: string;
@@ -36,8 +41,9 @@ export interface UseMasterFieldManagementReturn {
setNewMasterFieldDescription: (desc: string) => void;
newMasterFieldOptions: string;
setNewMasterFieldOptions: (options: string) => void;
newMasterFieldAttributeType: 'custom' | 'unit' | 'material' | 'surface';
setNewMasterFieldAttributeType: (type: 'custom' | 'unit' | 'material' | 'surface') => void;
// string 타입으로 유연하게 처리
newMasterFieldAttributeType: string;
setNewMasterFieldAttributeType: (type: string) => void;
newMasterFieldMultiColumn: boolean;
setNewMasterFieldMultiColumn: (multi: boolean) => void;
newMasterFieldColumnCount: number;
@@ -71,12 +77,12 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn {
// 폼 상태
const [newMasterFieldName, setNewMasterFieldName] = useState('');
const [newMasterFieldKey, setNewMasterFieldKey] = useState('');
const [newMasterFieldInputType, setNewMasterFieldInputType] = useState<'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea'>('textbox');
const [newMasterFieldInputType, setNewMasterFieldInputType] = useState<string>('textbox');
const [newMasterFieldRequired, setNewMasterFieldRequired] = useState(false);
const [newMasterFieldCategory, setNewMasterFieldCategory] = useState('공통');
const [newMasterFieldDescription, setNewMasterFieldDescription] = useState('');
const [newMasterFieldOptions, setNewMasterFieldOptions] = useState('');
const [newMasterFieldAttributeType, setNewMasterFieldAttributeType] = useState<'custom' | 'unit' | 'material' | 'surface'>('custom');
const [newMasterFieldAttributeType, setNewMasterFieldAttributeType] = useState<string>('custom');
const [newMasterFieldMultiColumn, setNewMasterFieldMultiColumn] = useState(false);
const [newMasterFieldColumnCount, setNewMasterFieldColumnCount] = useState(2);
const [newMasterFieldColumnNames, setNewMasterFieldColumnNames] = useState<string[]>(['컬럼1', '컬럼2']);
@@ -93,7 +99,7 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn {
const newMasterFieldData: Omit<ItemMasterField, 'id' | 'tenant_id' | 'created_by' | 'updated_by' | 'created_at' | 'updated_at'> = {
field_name: newMasterFieldName,
field_key: newMasterFieldKey, // 2025-11-28: field_key 추가
field_type: newMasterFieldInputType,
field_type: newMasterFieldInputType as FieldTypeValue,
category: newMasterFieldCategory || null,
description: newMasterFieldDescription || null,
is_common: false,
@@ -116,6 +122,7 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn {
resetMasterFieldForm();
toast.success('항목이 추가되었습니다');
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('항목 추가 실패:', error);
// 422 ValidationException 상세 메시지 처리 (field_key 중복/예약어) → AlertDialog로 표시
@@ -170,7 +177,7 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn {
const updateData: Partial<ItemMasterField> = {
field_name: newMasterFieldName,
field_key: newMasterFieldKey, // 2025-11-28: field_key 추가
field_type: newMasterFieldInputType,
field_type: newMasterFieldInputType as FieldTypeValue,
category: newMasterFieldCategory || null,
description: newMasterFieldDescription || null,
options: newMasterFieldInputType === 'dropdown' && newMasterFieldOptions.trim()
@@ -190,6 +197,7 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn {
resetMasterFieldForm();
toast.success('항목이 수정되었습니다');
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('항목 수정 실패:', error);
// 422 ValidationException 상세 메시지 처리 (field_key 중복/예약어, field_name 중복 등) → AlertDialog로 표시
@@ -218,6 +226,7 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn {
await deleteItemMasterField(id);
toast.success('항목이 삭제되었습니다');
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('항목 삭제 실패:', error);
if (error instanceof ApiError) {

View File

@@ -4,6 +4,7 @@ import { useCallback } from 'react';
import { useItemMaster } from '@/contexts/ItemMasterContext';
import { toast } from 'sonner';
import type { ItemPage } from '@/contexts/ItemMasterContext';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
export interface UseReorderManagementReturn {
moveSection: (selectedPage: ItemPage | null, dragIndex: number, hoverIndex: number) => Promise<void>;
@@ -34,6 +35,7 @@ export function useReorderManagement(): UseReorderManagementReturn {
await reorderSections(selectedPage.id, sectionIds);
toast.success('섹션 순서가 변경되었습니다');
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('섹션 순서 변경 실패:', error);
toast.error('섹션 순서 변경에 실패했습니다');
}
@@ -73,6 +75,7 @@ export function useReorderManagement(): UseReorderManagementReturn {
await reorderFields(sectionId, newFieldIds);
toast.success('항목 순서가 변경되었습니다');
} catch (error) {
if (isNextRedirectError(error)) throw error;
toast.error('항목 순서 변경에 실패했습니다');
}
}, [reorderFields]);

View File

@@ -5,6 +5,10 @@ import { toast } from 'sonner';
import { useItemMaster } from '@/contexts/ItemMasterContext';
import type { ItemPage, ItemSection, SectionTemplate } from '@/contexts/ItemMasterContext';
import { sectionService } from '../services';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
// 입력 모드 타입: 'new'/'existing' 또는 'custom'/'template' 모두 지원
type SectionInputModeType = 'new' | 'existing' | 'custom' | 'template';
export interface UseSectionManagementReturn {
// 상태
@@ -20,8 +24,8 @@ export interface UseSectionManagementReturn {
setNewSectionDescription: (desc: string) => void;
newSectionType: 'fields' | 'bom';
setNewSectionType: (type: 'fields' | 'bom') => void;
sectionInputMode: 'custom' | 'template';
setSectionInputMode: (mode: 'custom' | 'template') => void;
sectionInputMode: SectionInputModeType;
setSectionInputMode: (mode: SectionInputModeType) => void;
selectedSectionTemplateId: number | null;
setSelectedSectionTemplateId: (id: number | null) => void;
expandedSections: Record<string, boolean>;
@@ -55,7 +59,7 @@ export function useSectionManagement(): UseSectionManagementReturn {
const [newSectionTitle, setNewSectionTitle] = useState('');
const [newSectionDescription, setNewSectionDescription] = useState('');
const [newSectionType, setNewSectionType] = useState<'fields' | 'bom'>('fields');
const [sectionInputMode, setSectionInputMode] = useState<'custom' | 'template'>('custom');
const [sectionInputMode, setSectionInputMode] = useState<SectionInputModeType>('custom');
const [selectedSectionTemplateId, setSelectedSectionTemplateId] = useState<number | null>(null);
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
@@ -102,6 +106,7 @@ export function useSectionManagement(): UseSectionManagementReturn {
resetSectionForm();
toast.success(`${newSectionType === 'bom' ? 'BOM' : '일반'} 섹션이 추가되었습니다!`);
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('섹션 추가 실패:', error);
toast.error('섹션 추가에 실패했습니다. 다시 시도해주세요.');
}
@@ -135,6 +140,7 @@ export function useSectionManagement(): UseSectionManagementReturn {
resetSectionForm();
toast.success(`"${template.template_name}" 섹션이 페이지에 연결되었습니다!`);
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('섹션 연결 실패:', error);
toast.error('섹션 연결에 실패했습니다. 다시 시도해주세요.');
}
@@ -159,6 +165,7 @@ export function useSectionManagement(): UseSectionManagementReturn {
setEditingSectionTitle('');
toast.success('섹션 제목이 수정되었습니다!');
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('섹션 제목 수정 실패:', error);
toast.error('섹션 제목 수정에 실패했습니다.');
}
@@ -172,6 +179,7 @@ export function useSectionManagement(): UseSectionManagementReturn {
console.log('섹션 연결 해제 완료:', { pageId, sectionId });
toast.success('섹션 연결이 해제되었습니다');
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('섹션 연결 해제 실패:', error);
toast.error('섹션 연결 해제에 실패했습니다.');
}
@@ -191,6 +199,7 @@ export function useSectionManagement(): UseSectionManagementReturn {
});
toast.success('섹션이 삭제되었습니다!');
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('섹션 삭제 실패:', error);
toast.error('섹션 삭제에 실패했습니다.');
}

View File

@@ -7,6 +7,10 @@ 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';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
// 필드 타입 정의 - field_type 캐스팅용
type FieldTypeValue = 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
export interface UseTemplateManagementReturn {
// 섹션 템플릿 다이얼로그 상태
@@ -44,8 +48,9 @@ export interface UseTemplateManagementReturn {
setTemplateFieldName: (name: string) => void;
templateFieldKey: string;
setTemplateFieldKey: (key: string) => void;
templateFieldInputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
setTemplateFieldInputType: (type: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea') => void;
// string 타입으로 유연하게 처리
templateFieldInputType: string;
setTemplateFieldInputType: (type: string) => void;
templateFieldRequired: boolean;
setTemplateFieldRequired: (required: boolean) => void;
templateFieldOptions: string;
@@ -59,9 +64,9 @@ export interface UseTemplateManagementReturn {
templateFieldColumnNames: string[];
setTemplateFieldColumnNames: React.Dispatch<React.SetStateAction<string[]>>;
// 템플릿 필드 마스터 항목 관련
templateFieldInputMode: 'custom' | 'master';
setTemplateFieldInputMode: (mode: 'custom' | 'master') => void;
// 템플릿 필드 마스터 항목 관련 - 유연한 타입 지원
templateFieldInputMode: 'custom' | 'master' | 'new' | 'existing';
setTemplateFieldInputMode: (mode: 'custom' | 'master' | 'new' | 'existing') => void;
templateFieldShowMasterFieldList: boolean;
setTemplateFieldShowMasterFieldList: (show: boolean) => void;
templateFieldSelectedMasterFieldId: string;
@@ -139,7 +144,7 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
// 템플릿 필드 폼 상태
const [templateFieldName, setTemplateFieldName] = useState('');
const [templateFieldKey, setTemplateFieldKey] = useState('');
const [templateFieldInputType, setTemplateFieldInputType] = useState<'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea'>('textbox');
const [templateFieldInputType, setTemplateFieldInputType] = useState<string>('textbox');
const [templateFieldRequired, setTemplateFieldRequired] = useState(false);
const [templateFieldOptions, setTemplateFieldOptions] = useState('');
const [templateFieldDescription, setTemplateFieldDescription] = useState('');
@@ -147,8 +152,8 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
const [templateFieldColumnCount, setTemplateFieldColumnCount] = useState(2);
const [templateFieldColumnNames, setTemplateFieldColumnNames] = useState<string[]>(['컬럼1', '컬럼2']);
// 템플릿 필드 마스터 항목 관련
const [templateFieldInputMode, setTemplateFieldInputMode] = useState<'custom' | 'master'>('custom');
// 템플릿 필드 마스터 항목 관련 - 'new'/'existing' 호환을 위해 유연하게 처리
const [templateFieldInputMode, setTemplateFieldInputMode] = useState<'custom' | 'master' | 'new' | 'existing'>('custom');
const [templateFieldShowMasterFieldList, setTemplateFieldShowMasterFieldList] = useState(false);
const [templateFieldSelectedMasterFieldId, setTemplateFieldSelectedMasterFieldId] = useState('');
@@ -176,6 +181,7 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
resetSectionTemplateForm();
toast.success('섹션이 추가되었습니다!');
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('섹션 추가 실패:', error);
toast.error('섹션 추가에 실패했습니다.');
}
@@ -212,6 +218,7 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
resetSectionTemplateForm();
toast.success('섹션이 수정되었습니다!');
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('섹션 수정 실패:', error);
toast.error('섹션 수정에 실패했습니다.');
}
@@ -225,6 +232,7 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
await deleteSection(id);
toast.success('섹션이 삭제되었습니다!');
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('섹션 삭제 실패:', error);
toast.error('섹션 삭제에 실패했습니다.');
}
@@ -282,7 +290,7 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
const updateData = {
field_name: templateFieldName,
field_key: templateFieldKey, // 2025-11-28: field_key 추가
field_type: templateFieldInputType,
field_type: templateFieldInputType as FieldTypeValue,
is_required: templateFieldRequired,
placeholder: templateFieldDescription || null,
options: templateFieldInputType === 'dropdown' && templateFieldOptions.trim()
@@ -326,7 +334,7 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
master_field_id: null,
field_name: templateFieldName,
field_key: templateFieldKey,
field_type: templateFieldInputType,
field_type: templateFieldInputType as FieldTypeValue,
order_no: 0,
is_required: templateFieldRequired,
placeholder: templateFieldDescription || null,
@@ -352,6 +360,7 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
resetTemplateFieldForm();
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('항목 처리 실패:', error);
// 422 ValidationException 상세 메시지 처리 (field_key 중복/예약어, field_name 중복 등) → AlertDialog로 표시
@@ -402,6 +411,7 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
await unlinkFieldFromSection(templateId, Number(fieldId));
toast.success('항목 연결이 해제되었습니다');
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('항목 연결 해제 실패:', error);
toast.error('항목 연결 해제에 실패했습니다.');
}
@@ -415,6 +425,7 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
await addBOMItem(templateId, item);
// toast는 BOMManagementSection 컴포넌트에서 처리
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('BOM 항목 추가 실패:', error);
toast.error('BOM 항목 추가에 실패했습니다');
}
@@ -427,6 +438,7 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
await updateBOMItem(itemId, item);
// toast는 BOMManagementSection 컴포넌트에서 처리
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('BOM 항목 수정 실패:', error);
toast.error('BOM 항목 수정에 실패했습니다');
}
@@ -439,6 +451,7 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
await deleteBOMItem(itemId);
// toast는 BOMManagementSection 컴포넌트에서 처리
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('BOM 항목 삭제 실패:', error);
toast.error('BOM 항목 삭제에 실패했습니다');
}

View File

@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Plus, Edit, Trash2, Link, Copy, Download } from 'lucide-react';
import { toast } from 'sonner';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { DraggableSection, DraggableField } from '../../components';
import { BOMManagementSection } from '@/components/items/BOMManagementSection';
@@ -385,6 +386,7 @@ export function HierarchyTab({
console.log('[HierarchyTab] BOM 추가 성공');
toast.success('BOM 항목이 추가되었습니다');
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[HierarchyTab] BOM 추가 실패:', error);
toast.error('BOM 항목 추가에 실패했습니다. 백엔드 API를 확인하세요.');
}

View File

@@ -7,12 +7,12 @@
* - ItemFieldProperty, SectionTemplate
*/
// 옵션 칼럼 타입
// 옵션 칼럼 타입 (string 타입으로 유연하게 처리)
export interface OptionColumn {
id: string;
name: string;
key: string;
type: 'text' | 'number';
type: string; // 'text' | 'number' 등 다양한 타입 지원
required: boolean;
}
@@ -22,8 +22,8 @@ export interface MasterOption {
value: string;
label: string;
isActive: boolean;
// 입력 방식 및 속성
inputType?: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
// 입력 방식 및 속성 (string 타입으로 유연하게 처리)
inputType?: string; // 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea'
required?: boolean;
options?: string[]; // dropdown일 경우 선택 옵션
defaultValue?: string | number | boolean;