- 숫자/통화/전화번호/사업자번호 등 특수 입력 컴포넌트 추가 - MobileCard 컴포넌트 통합 (ListMobileCard 제거) - IntegratedListTemplateV2 페이지네이션 버그 수정 (NaN 이슈) - IntegratedDetailTemplate 타이틀 중복 수정 - 문서 시스템 컴포넌트 추가 - 헤더 벨 아이콘 포커스 스타일 개선 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
517 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|