Files
sam-react-prod/src/components/items/ItemMasterDataManagement/dialogs/TemplateFieldDialog.tsx
byeongcheolryu e56b7d53a4 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>
2026-01-11 17:19:11 +09:00

392 lines
16 KiB
TypeScript

'use client';
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { Check, X } from 'lucide-react';
import type { ItemMasterField } from '@/contexts/ItemMasterContext';
const INPUT_TYPE_OPTIONS = [
{ value: 'textbox', label: '텍스트 입력' },
{ value: 'number', label: '숫자 입력' },
{ value: 'dropdown', label: '드롭다운' },
{ value: 'checkbox', label: '체크박스' },
{ value: 'date', label: '날짜' },
{ value: 'textarea', label: '긴 텍스트' },
];
// 입력 모드 타입: 'new'/'existing' 또는 'custom'/'master' 모두 지원
type TemplateFieldInputModeType = 'new' | 'existing' | 'custom' | 'master';
interface TemplateFieldDialogProps {
isTemplateFieldDialogOpen: boolean;
setIsTemplateFieldDialogOpen: (open: boolean) => 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;
// string 타입으로 유연하게 처리
templateFieldInputType: string;
setTemplateFieldInputType: (type: string) => void;
templateFieldRequired: boolean;
setTemplateFieldRequired: (required: boolean) => void;
// string 또는 string[] 모두 지원
templateFieldOptions: string | string[];
setTemplateFieldOptions: ((options: string) => void) | React.Dispatch<React.SetStateAction<string[]>>;
templateFieldDescription: string;
setTemplateFieldDescription: (description: string) => void;
templateFieldMultiColumn: boolean;
setTemplateFieldMultiColumn: (multi: boolean) => void;
templateFieldColumnCount: number;
setTemplateFieldColumnCount: (count: number) => void;
templateFieldColumnNames: string[];
setTemplateFieldColumnNames: ((names: string[]) => void) | React.Dispatch<React.SetStateAction<string[]>>;
handleAddTemplateField: () => void | Promise<void>;
// 항목 관련 props - 유연한 타입 지원
itemMasterFields?: ItemMasterField[];
templateFieldInputMode?: TemplateFieldInputModeType;
setTemplateFieldInputMode?: (mode: TemplateFieldInputModeType) => void;
showMasterFieldList?: boolean;
setShowMasterFieldList?: (show: boolean) => void;
// string 또는 number | null 모두 지원
selectedMasterFieldId?: string | number | null;
setSelectedMasterFieldId?: ((id: string) => void) | ((id: number | null) => void);
}
export function TemplateFieldDialog({
isTemplateFieldDialogOpen,
setIsTemplateFieldDialogOpen,
editingTemplateFieldId,
setEditingTemplateFieldId,
templateFieldName,
setTemplateFieldName,
templateFieldKey,
setTemplateFieldKey,
templateFieldInputType,
setTemplateFieldInputType,
templateFieldRequired,
setTemplateFieldRequired,
templateFieldOptions,
setTemplateFieldOptions,
templateFieldDescription,
setTemplateFieldDescription,
templateFieldMultiColumn,
setTemplateFieldMultiColumn,
templateFieldColumnCount,
setTemplateFieldColumnCount,
templateFieldColumnNames,
setTemplateFieldColumnNames,
handleAddTemplateField,
// 항목 관련 props (optional)
itemMasterFields = [],
templateFieldInputMode = 'custom',
setTemplateFieldInputMode,
showMasterFieldList = false,
setShowMasterFieldList,
selectedMasterFieldId = '',
setSelectedMasterFieldId,
}: 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();
const handleClose = () => {
setIsSubmitted(false);
setIsTemplateFieldDialogOpen(false);
handleSetEditingTemplateFieldId(null);
setTemplateFieldName('');
setTemplateFieldKey('');
setTemplateFieldInputType('textbox');
setTemplateFieldRequired(false);
handleSetTemplateFieldOptions('');
setTemplateFieldDescription('');
setTemplateFieldMultiColumn(false);
setTemplateFieldColumnCount(2);
setTemplateFieldColumnNames(['컬럼1', '컬럼2']);
// 항목 관련 상태 초기화
setTemplateFieldInputMode?.('custom');
setShowMasterFieldList?.(false);
handleSetSelectedMasterFieldId('');
};
const handleSelectMasterField = (field: ItemMasterField) => {
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만 추출
handleSetTemplateFieldOptions(field.options?.map(opt => opt.label).join(', ') || '');
if (field.properties?.multiColumn && field.properties?.columnNames) {
setTemplateFieldMultiColumn(true);
setTemplateFieldColumnCount(field.properties.columnNames.length);
setTemplateFieldColumnNames(field.properties.columnNames);
} else {
setTemplateFieldMultiColumn(false);
}
};
return (
<Dialog open={isTemplateFieldDialogOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingTemplateFieldId ? '항목 수정' : '항목 추가'}</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 입력 모드 선택 (편집 시에는 표시 안 함) */}
{!editingTemplateFieldId && setTemplateFieldInputMode && (
<div className="flex gap-2 p-1 bg-gray-100 rounded">
<Button
variant={normalizedInputMode === 'custom' ? 'default' : 'ghost'}
size="sm"
onClick={() => setTemplateFieldInputMode('custom')}
className="flex-1"
>
</Button>
<Button
variant={normalizedInputMode === 'master' ? 'default' : 'ghost'}
size="sm"
onClick={() => {
setTemplateFieldInputMode('master');
setShowMasterFieldList?.(true);
}}
className="flex-1"
>
</Button>
</div>
)}
{/* 항목 목록 */}
{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>
<Button
variant="ghost"
size="sm"
onClick={() => setShowMasterFieldList?.(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
{itemMasterFields.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
</p>
) : (
<div className="space-y-2">
{itemMasterFields.map((field, index) => (
<div
key={`master-${field.id}-${index}`}
className={`p-3 border rounded cursor-pointer transition-colors ${
selectedMasterFieldId === String(field.id)
? 'bg-blue-50 border-blue-300'
: 'hover:bg-gray-50'
}`}
onClick={() => handleSelectMasterField(field)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{field.field_name}</span>
<Badge variant="outline" className="text-xs">
{INPUT_TYPE_OPTIONS.find(o => o.value === field.field_type)?.label || field.field_type}
</Badge>
{field.properties?.required && (
<Badge variant="destructive" className="text-xs"></Badge>
)}
</div>
{field.description && (
<p className="text-xs text-muted-foreground mt-1">{field.description}</p>
)}
{field.category && (
<div className="flex gap-1 mt-1">
<Badge variant="secondary" className="text-xs">
{field.category}
</Badge>
</div>
)}
</div>
{selectedMasterFieldId === String(field.id) && (
<Check className="h-5 w-5 text-blue-600" />
)}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* 직접 입력 폼 */}
{(templateFieldInputMode === 'custom' || editingTemplateFieldId || !setTemplateFieldInputMode) && (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label> *</Label>
<Input
value={templateFieldName}
onChange={(e) => setTemplateFieldName(e.target.value)}
placeholder="예: 품목명"
className={isSubmitted && isNameEmpty ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{isSubmitted && isNameEmpty && (
<p className="text-xs text-red-500 mt-1"> </p>
)}
</div>
<div>
<Label> *</Label>
<Input
value={templateFieldKey}
onChange={(e) => setTemplateFieldKey(e.target.value)}
placeholder="예: itemName"
className={isSubmitted && isKeyEmpty ? 'border-red-500 focus-visible:ring-red-500' : ''}
/>
{isSubmitted && isKeyEmpty && (
<p className="text-xs text-red-500 mt-1"> </p>
)}
</div>
</div>
<div>
<Label> *</Label>
<Select value={templateFieldInputType} onValueChange={(v: any) => setTemplateFieldInputType(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{INPUT_TYPE_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{templateFieldInputType === 'dropdown' && (
<div>
<Label> </Label>
<Input
value={optionsString}
onChange={(e) => handleSetTemplateFieldOptions(e.target.value)}
placeholder="옵션1, 옵션2, 옵션3 (쉼표로 구분)"
/>
</div>
)}
{(templateFieldInputType === 'textbox' || templateFieldInputType === 'textarea') && (
<div className="space-y-3 border rounded-lg p-4 bg-muted/30">
<div className="flex items-center gap-2">
<Switch
checked={templateFieldMultiColumn}
onCheckedChange={setTemplateFieldMultiColumn}
/>
<Label> </Label>
</div>
{templateFieldMultiColumn && (
<>
<div>
<Label> </Label>
<Input
type="number"
min={2}
max={10}
value={templateFieldColumnCount}
onChange={(e) => {
const count = parseInt(e.target.value) || 2;
setTemplateFieldColumnCount(count);
const newNames = Array.from({ length: count }, (_, i) =>
templateFieldColumnNames[i] || `컬럼${i + 1}`
);
setTemplateFieldColumnNames(newNames);
}}
/>
</div>
<div className="space-y-2">
<Label></Label>
{Array.from({ length: templateFieldColumnCount }).map((_, idx) => (
<Input
key={idx}
placeholder={`컬럼 ${idx + 1}`}
value={templateFieldColumnNames[idx] || ''}
onChange={(e) => {
const newNames = [...templateFieldColumnNames];
newNames[idx] = e.target.value;
setTemplateFieldColumnNames(newNames);
}}
/>
))}
</div>
</>
)}
</div>
)}
<div>
<Label> ()</Label>
<Textarea
value={templateFieldDescription}
onChange={(e) => setTemplateFieldDescription(e.target.value)}
placeholder="항목에 대한 설명"
/>
</div>
<div className="flex items-center gap-2">
<Switch checked={templateFieldRequired} onCheckedChange={setTemplateFieldRequired} />
<Label> </Label>
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose}></Button>
<Button onClick={() => {
setIsSubmitted(true);
const shouldValidate = templateFieldInputMode === 'custom' || editingTemplateFieldId || !setTemplateFieldInputMode;
if (shouldValidate && (isNameEmpty || isKeyEmpty)) return;
handleAddTemplateField();
setIsSubmitted(false);
}}>
{editingTemplateFieldId ? '수정' : '추가'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}