Files
sam-react-prod/src/components/process-management/RuleModal.tsx
유병철 835c06ce94 feat(WEB): 입력 컴포넌트 공통화 및 UI 개선
- 숫자/통화/전화번호/사업자번호 등 특수 입력 컴포넌트 추가
- MobileCard 컴포넌트 통합 (ListMobileCard 제거)
- IntegratedListTemplateV2 페이지네이션 버그 수정 (NaN 이슈)
- IntegratedDetailTemplate 타이틀 중복 수정
- 문서 시스템 컴포넌트 추가
- 헤더 벨 아이콘 포커스 스타일 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:56:17 +09:00

517 lines
18 KiB
TypeScript

'use client';
import { useState, useEffect, useCallback } from 'react';
import { getItemList, type ItemOption } from './actions';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { QuantityInput } from '@/components/ui/quantity-input';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Switch } from '@/components/ui/switch';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Search, Package } from 'lucide-react';
import type {
ClassificationRule,
RuleRegistrationType,
RuleType,
MatchingType,
} from '@/types/process';
import { RULE_TYPE_OPTIONS, MATCHING_TYPE_OPTIONS } from '@/types/process';
// 품목 유형 옵션
const ITEM_TYPE_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: '제품', label: '제품' },
{ value: '반제품', label: '반제품' },
{ value: '원자재', label: '원자재' },
{ value: '부자재', label: '부자재' },
];
interface RuleModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdd: (rule: Omit<ClassificationRule, 'id' | 'createdAt'>) => void;
editRule?: ClassificationRule;
}
export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProps) {
// 공통 상태
const [registrationType, setRegistrationType] = useState<RuleRegistrationType>(
editRule?.registrationType || 'pattern'
);
const [description, setDescription] = useState(editRule?.description || '');
// 패턴 규칙용 상태
const [ruleType, setRuleType] = useState<RuleType>(editRule?.ruleType || '품목코드');
const [matchingType, setMatchingType] = useState<MatchingType>(
editRule?.matchingType || 'startsWith'
);
const [conditionValue, setConditionValue] = useState(editRule?.conditionValue || '');
const [priority, setPriority] = useState(editRule?.priority || 10);
const [isActive, setIsActive] = useState(editRule?.isActive ?? true);
// 개별 품목용 상태
const [searchKeyword, setSearchKeyword] = useState('');
const [selectedItemType, setSelectedItemType] = useState('all');
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
// 품목 목록 API 상태
const [itemList, setItemList] = useState<ItemOption[]>([]);
const [isItemsLoading, setIsItemsLoading] = useState(false);
// 품목 목록 로드 (debounced)
const loadItems = useCallback(async (q?: string, itemType?: string) => {
setIsItemsLoading(true);
const items = await getItemList({
q: q || undefined,
itemType: itemType === 'all' ? undefined : itemType,
size: 1000, // 전체 품목 조회
});
setItemList(items);
setIsItemsLoading(false);
}, []);
// 검색어 유효성 검사 함수
const isValidSearchKeyword = (keyword: string): boolean => {
if (!keyword || keyword.trim() === '') return false;
const trimmed = keyword.trim();
// 한글이 포함되어 있으면 1자 이상
const hasKorean = /[가-힣]/.test(trimmed);
if (hasKorean) return trimmed.length >= 1;
// 영어/숫자만 있으면 2자 이상
return trimmed.length >= 2;
};
// 검색어/품목유형 변경 시 API 호출 (debounce)
useEffect(() => {
if (registrationType !== 'individual') return;
// 검색어 유효성 검사 - 유효하지 않으면 빈 목록
if (!isValidSearchKeyword(searchKeyword)) {
setItemList([]);
return;
}
const timer = setTimeout(() => {
loadItems(searchKeyword, selectedItemType);
}, 300);
return () => clearTimeout(timer);
}, [searchKeyword, selectedItemType, registrationType, loadItems]);
// 품목유형 변경 시 검색어가 유효하면 재검색
useEffect(() => {
if (registrationType !== 'individual') return;
if (!isValidSearchKeyword(searchKeyword)) return;
loadItems(searchKeyword, selectedItemType);
}, [selectedItemType]);
// 모달 열릴 때 품목 목록 초기화 (초기 로드 안함)
useEffect(() => {
if (open && registrationType === 'individual') {
setItemList([]);
setSearchKeyword('');
}
}, [open, registrationType]);
// 체크박스 토글
const handleToggleItem = (id: string) => {
setSelectedItemIds((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
};
// 전체 선택
const handleSelectAll = () => {
const allIds = itemList.map((item) => item.id);
setSelectedItemIds(new Set(allIds));
};
// 초기화
const handleResetSelection = () => {
setSelectedItemIds(new Set());
};
// 모달 열릴 때 초기화 또는 수정 데이터 로드
useEffect(() => {
if (open) {
if (editRule) {
// 수정 모드: 기존 데이터 로드
setRegistrationType(editRule.registrationType);
setDescription(editRule.description || '');
setRuleType(editRule.ruleType);
setMatchingType(editRule.matchingType);
setConditionValue(editRule.conditionValue);
setPriority(editRule.priority);
setIsActive(editRule.isActive);
setSearchKeyword('');
setSelectedItemType('all');
// 개별 품목인 경우 선택된 품목 ID 설정
if (editRule.registrationType === 'individual') {
const ids = editRule.conditionValue.split(',').filter(Boolean);
setSelectedItemIds(new Set(ids));
} else {
setSelectedItemIds(new Set());
}
} else {
// 추가 모드: 초기화
setRegistrationType('pattern');
setDescription('');
setRuleType('품목코드');
setMatchingType('startsWith');
setConditionValue('');
setPriority(10);
setIsActive(true);
setSearchKeyword('');
setSelectedItemType('all');
setSelectedItemIds(new Set());
}
}
}, [open, editRule]);
const handleSubmit = () => {
if (registrationType === 'pattern') {
if (!conditionValue.trim()) {
alert('조건 값을 입력해주세요.');
return;
}
} else {
if (selectedItemIds.size === 0) {
alert('품목을 최소 1개 이상 선택해주세요.');
return;
}
}
// 개별 품목의 경우 conditionValue에 품목코드들을 저장
const finalConditionValue =
registrationType === 'individual'
? Array.from(selectedItemIds).join(',')
: conditionValue.trim();
onAdd({
registrationType,
ruleType: registrationType === 'individual' ? '품목코드' : ruleType,
matchingType: registrationType === 'individual' ? 'equals' : matchingType,
conditionValue: finalConditionValue,
priority: registrationType === 'individual' ? 10 : priority,
description: description.trim() || undefined,
isActive: registrationType === 'individual' ? true : isActive,
});
// Reset form
setRegistrationType('pattern');
setDescription('');
setRuleType('품목코드');
setMatchingType('startsWith');
setConditionValue('');
setPriority(10);
setIsActive(true);
setSearchKeyword('');
setSelectedItemType('all');
setSelectedItemIds(new Set());
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={registrationType === 'individual' ? 'max-w-xl max-h-[90vh] flex flex-col' : 'max-w-md'}>
<DialogHeader>
<DialogTitle>{editRule ? '규칙 수정' : '규칙 추가'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4 overflow-y-auto flex-1">
{/* 등록 방식 */}
<div className="space-y-3">
<Label> *</Label>
<RadioGroup
value={registrationType}
onValueChange={(v) => setRegistrationType(v as RuleRegistrationType)}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="pattern" id="pattern" />
<Label htmlFor="pattern" className="font-normal">
(/ )
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="individual" id="individual" />
<Label htmlFor="individual" className="font-normal">
( )
</Label>
</div>
</RadioGroup>
</div>
{/* 패턴 규칙 UI */}
{registrationType === 'pattern' && (
<>
{/* 규칙 유형 */}
<div className="space-y-2">
<Label> *</Label>
<Select value={ruleType} onValueChange={(v) => setRuleType(v as RuleType)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{RULE_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 매칭 방식 */}
<div className="space-y-2">
<Label> *</Label>
<Select
value={matchingType}
onValueChange={(v) => setMatchingType(v as MatchingType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{MATCHING_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 조건 값 */}
<div className="space-y-2">
<Label> *</Label>
<div className="flex gap-2">
<Input
value={conditionValue}
onChange={(e) => setConditionValue(e.target.value)}
placeholder="예: SCR-, E-, STEEL-"
className="flex-1"
/>
<Button variant="outline" size="icon">
<Search className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Enter
</p>
</div>
{/* 우선순위 - 패턴 규칙에서만 표시 */}
<div className="space-y-2">
<Label></Label>
<QuantityInput
value={priority}
onChange={(value) => setPriority(value ?? 1)}
min={1}
max={100}
className="w-24"
/>
<p className="text-xs text-muted-foreground"> </p>
</div>
{/* 설명 - 패턴 규칙 */}
<div className="space-y-2">
<Label></Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="규칙에 대한 설명"
/>
</div>
{/* 활성 상태 - 패턴 규칙에서만 표시 */}
<div className="flex items-center justify-between">
<Label> </Label>
<Switch checked={isActive} onCheckedChange={setIsActive} />
</div>
</>
)}
{/* 개별 품목 UI - 기획서 기준 */}
{registrationType === 'individual' && (
<>
{/* 설명 (선택) */}
<div className="space-y-2">
<Label> ()</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="이 품목 그룹에 대한 설명"
/>
</div>
{/* 품목 검색 + 품목 유형 필터 */}
<div className="flex gap-3">
<div className="flex-1">
<Label className="text-sm"> </Label>
<div className="relative mt-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
placeholder="품목코드 또는 품목명으로 검색..."
className="pl-9"
/>
</div>
</div>
<div className="w-32">
<Label className="text-sm"> </Label>
<Select value={selectedItemType} onValueChange={setSelectedItemType}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ITEM_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 품목 목록 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm">
<Package className="h-4 w-4" />
<span>
{isItemsLoading ? (
'로딩 중...'
) : (
<> ({itemList.length}) | ({selectedItemIds.size})</>
)}
</span>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleSelectAll}
className="text-xs h-7"
disabled={isItemsLoading || itemList.length === 0}
>
</Button>
<span className="text-muted-foreground">|</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleResetSelection}
className="text-xs h-7"
disabled={selectedItemIds.size === 0}
>
</Button>
</div>
</div>
{/* 품목 테이블 */}
<div className="border rounded-lg max-h-[280px] overflow-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-[40px]"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isItemsLoading ? (
<TableRow key="loading">
<TableCell colSpan={4} className="text-center text-muted-foreground py-8">
...
</TableCell>
</TableRow>
) : itemList.length === 0 ? (
<TableRow key="empty">
<TableCell colSpan={4} className="text-center text-muted-foreground py-8">
{searchKeyword.trim() === ''
? '품목을 검색해주세요 (한글 1자 이상, 영문 2자 이상)'
: '검색 결과가 없습니다'}
</TableCell>
</TableRow>
) : (
itemList.map((item) => (
<TableRow
key={item.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleToggleItem(item.id)}
>
<TableCell>
<Checkbox
checked={selectedItemIds.has(item.id)}
onCheckedChange={() => handleToggleItem(item.id)}
onClick={(e) => e.stopPropagation()}
/>
</TableCell>
<TableCell className="font-medium">{item.code}</TableCell>
<TableCell>{item.fullName}</TableCell>
<TableCell>{item.type}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 안내 문구 */}
<p className="text-xs text-muted-foreground">
. .
</p>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSubmit}>{editRule ? '수정' : '추가'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}