Files
sam-react-prod/src/components/process-management/RuleModal.tsx
유병철 012a661a19 refactor(WEB): 회계/결재/건설 등 공통화 3차 및 검색/상태 유틸 추가
- search.ts: 범용 검색 유틸리티 추출 (텍스트/날짜/상태 필터링)
- status-config.ts: 상태 설정 공통 유틸 추가
- 회계 모듈 types 간소화 및 컬럼 설정 공통 패턴 적용
- 회계 page.tsx 통일 (bad-debt/bills/deposits/sales 등 9개)
- 결재함(승인/기안/참조) 공통 패턴 적용
- 건설 모듈 견적/인수인계/이슈/기성 등 코드 정리
- IntegratedListTemplateV2 개선
- LanguageSelect/ThemeSelect 정리
- 체크리스트 문서 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:26:27 +09:00

353 lines
12 KiB
TypeScript

'use client';
import { useState, useEffect, useCallback } from 'react';
import { getItemList, getItemTypeOptions, 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 { 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 } from 'lucide-react';
import type { ClassificationRule } from '@/types/process';
import { PROCESS_CATEGORY_OPTIONS } from '@/types/process';
import { toast } from 'sonner';
// 공정 필터 옵션
const PROCESS_FILTER_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: '스크린', label: '스크린' },
{ value: '슬릿', label: '슬릿' },
{ value: '절곡', label: '절곡' },
];
// 공정 필터에 따른 구분 필터 옵션
function getCategoryFilterOptions(processFilter: string): { value: string; label: string }[] {
if (processFilter === 'all') {
return [{ value: 'all', label: '전체' }];
}
const categories = PROCESS_CATEGORY_OPTIONS[processFilter];
if (!categories || categories.length === 0) {
return [{ value: 'all', label: '전체' }];
}
// 스크린의 경우 '없음'만 있으므로 전체만 표시
if (categories.length === 1 && categories[0].value === '없음') {
return [{ value: 'all', label: '전체' }];
}
return [{ value: 'all', label: '전체' }, ...categories];
}
interface RuleModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdd: (rule: Omit<ClassificationRule, 'id' | 'createdAt'>) => void;
editRule?: ClassificationRule;
/** 현재 공정 ID (다른 공정에 이미 배정된 품목 제외용) */
processId?: string;
/** 현재 공정명 (하단 안내 문구용) */
processName?: string;
/** 이미 등록된 품목 ID 목록 (검색 결과에서 제외) */
registeredItemIds?: Set<string>;
}
export function RuleModal({ open, onOpenChange, onAdd, editRule, processId, processName, registeredItemIds }: RuleModalProps) {
// 검색/필터 상태
const [searchKeyword, setSearchKeyword] = useState('');
const [selectedItemType, setSelectedItemType] = useState('all');
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
// 공정/구분 필터 상태
const [processFilter, setProcessFilter] = useState('all');
const [categoryFilter, setCategoryFilter] = useState('all');
// 품목 목록 API 상태
const [itemList, setItemList] = useState<ItemOption[]>([]);
const [isItemsLoading, setIsItemsLoading] = useState(false);
// 품목 유형 옵션 (common_codes에서 동적 조회)
const [itemTypeOptions, setItemTypeOptions] = useState<Array<{ value: string; label: string }>>([
{ value: 'all', label: '전체' },
]);
// 구분 필터 옵션 (공정 필터에 따라 변경)
const categoryFilterOptions = getCategoryFilterOptions(processFilter);
// 품목 목록 로드
const loadItems = useCallback(async (q?: string, itemType?: string) => {
setIsItemsLoading(true);
const items = await getItemList({
q: q || undefined,
itemType: itemType === 'all' ? undefined : itemType,
size: 1000,
excludeProcessId: processId,
});
// 이미 등록된 품목 필터링
const filtered = registeredItemIds && registeredItemIds.size > 0
? items.filter((item) => !registeredItemIds.has(item.id))
: items;
setItemList(filtered);
setIsItemsLoading(false);
}, [processId, registeredItemIds]);
// 검색어 유효성 검사 함수
const isValidSearchKeyword = (keyword: string): boolean => {
if (!keyword || keyword.trim() === '') return false;
const trimmed = keyword.trim();
const hasKorean = /[가-힣]/.test(trimmed);
if (hasKorean) return trimmed.length >= 1;
return trimmed.length >= 2;
};
// 검색어 변경 시 API 호출 (debounce)
useEffect(() => {
if (!isValidSearchKeyword(searchKeyword)) {
setItemList([]);
return;
}
const timer = setTimeout(() => {
loadItems(searchKeyword, selectedItemType);
}, 300);
return () => clearTimeout(timer);
}, [searchKeyword, selectedItemType, loadItems]);
// 모달 열릴 때 초기화
useEffect(() => {
if (open) {
// 품목유형 옵션 로드
getItemTypeOptions().then((options) => {
setItemTypeOptions([{ value: 'all', label: '전체' }, ...options]);
});
if (editRule) {
// 수정 모드: 기존 선택된 품목 ID 설정
if (editRule.registrationType === 'individual') {
const ids = editRule.conditionValue.split(',').filter(Boolean);
setSelectedItemIds(new Set(ids));
} else {
setSelectedItemIds(new Set());
}
} else {
setSelectedItemIds(new Set());
}
setSearchKeyword('');
setSelectedItemType('all');
setProcessFilter('all');
setCategoryFilter('all');
setItemList([]);
}
}, [open, editRule]);
// 공정 필터 변경 시 구분 필터 리셋
useEffect(() => {
setCategoryFilter('all');
}, [processFilter]);
// 체크박스 토글
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 handleSubmit = () => {
if (selectedItemIds.size === 0) {
toast.warning('품목을 최소 1개 이상 선택해주세요.');
return;
}
const finalConditionValue = Array.from(selectedItemIds).join(',');
onAdd({
registrationType: 'individual',
ruleType: '품목코드',
matchingType: 'equals',
conditionValue: finalConditionValue,
priority: 10,
description: undefined,
isActive: true,
});
// Reset
setSearchKeyword('');
setSelectedItemType('all');
setSelectedItemIds(new Set());
setProcessFilter('all');
setCategoryFilter('all');
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4 overflow-y-auto flex-1">
{/* 검색 입력 */}
<div className="relative">
<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 className="flex items-center justify-between gap-3">
<div className="text-sm text-muted-foreground whitespace-nowrap">
{isItemsLoading ? (
'로딩 중...'
) : (
<>
{itemList.length}{' '}
{selectedItemIds.size > 0 && (
<span className="text-primary font-medium">
{selectedItemIds.size}
</span>
)}
</>
)}
</div>
<div className="flex items-center gap-2">
<Select value={processFilter} onValueChange={setProcessFilter}>
<SelectTrigger className="min-w-[100px] w-auto h-8 text-xs">
<SelectValue placeholder="공정" />
</SelectTrigger>
<SelectContent>
{PROCESS_FILTER_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
key={`category-${processFilter}`}
value={categoryFilter}
onValueChange={setCategoryFilter}
disabled={categoryFilterOptions.length <= 1}
>
<SelectTrigger className="min-w-[100px] w-auto h-8 text-xs">
<SelectValue placeholder="구분" />
</SelectTrigger>
<SelectContent>
{categoryFilterOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 품목 테이블 */}
<div className="border rounded-lg max-h-[340px] overflow-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-[40px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
{/* TODO: API에서 process_name, process_category 필드 지원 후 실제 데이터 표시 */}
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isItemsLoading ? (
<TableRow key="loading">
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
...
</TableCell>
</TableRow>
) : itemList.length === 0 ? (
<TableRow key="empty">
<TableCell colSpan={6} 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>{item.type}</TableCell>
<TableCell className="font-medium">{item.code}</TableCell>
<TableCell>{item.fullName}</TableCell>
{/* TODO: API 지원 후 item.processName / item.processCategory 표시 */}
<TableCell className="text-muted-foreground">-</TableCell>
<TableCell className="text-muted-foreground">-</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 안내 문구 */}
<p className="text-xs text-muted-foreground">
{' '}
<span className="font-medium text-foreground">
{processName || '해당'}
</span>{' '}
.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSubmit}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}