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

@@ -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>