- 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>
353 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|