From 62bf081adb6ab9cb8658665233168a34eeec51e7 Mon Sep 17 00:00:00 2001 From: kent Date: Tue, 30 Dec 2025 17:21:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20=EA=B3=B5=EC=A0=95=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProcessDetail: 공정 상세 정보 표시 개선 - ProcessForm: 공정 등록/수정 폼 유효성 검사 강화 - RuleModal: 공정 규칙 설정 모달 리팩토링 --- .../process-management/ProcessDetail.tsx | 85 +++++++++++-- .../process-management/ProcessForm.tsx | 40 +++--- .../process-management/RuleModal.tsx | 118 +++++++++++------- 3 files changed, 171 insertions(+), 72 deletions(-) diff --git a/src/components/process-management/ProcessDetail.tsx b/src/components/process-management/ProcessDetail.tsx index 0bc3e340..bb8ba47e 100644 --- a/src/components/process-management/ProcessDetail.tsx +++ b/src/components/process-management/ProcessDetail.tsx @@ -1,14 +1,15 @@ 'use client'; -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { useRouter } from 'next/navigation'; -import { List, Edit, Wrench } from 'lucide-react'; +import { List, Edit, Wrench, Package } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { PageLayout } from '@/components/organisms/PageLayout'; import { ProcessWorkLogPreviewModal } from './ProcessWorkLogPreviewModal'; import type { Process } from '@/types/process'; +import { MATCHING_TYPE_OPTIONS } from '@/types/process'; interface ProcessDetailProps { process: Process; @@ -18,6 +19,23 @@ export function ProcessDetail({ process }: ProcessDetailProps) { const router = useRouter(); const [workLogModalOpen, setWorkLogModalOpen] = useState(false); + // 패턴 규칙과 개별 품목 분리 + const { patternRules, individualItems } = useMemo(() => { + const patterns = process.classificationRules.filter( + (rule) => rule.registrationType === 'pattern' + ); + const individuals = process.classificationRules.filter( + (rule) => rule.registrationType === 'individual' + ); + return { patternRules: patterns, individualItems: individuals }; + }, [process.classificationRules]); + + // 매칭 타입 라벨 + const getMatchingTypeLabel = (type: string) => { + const option = MATCHING_TYPE_OPTIONS.find((opt) => opt.value === type); + return option?.label || type; + }; + const handleEdit = () => { router.push(`/ko/master-data/process-management/${process.id}/edit`); }; @@ -110,20 +128,23 @@ export function ProcessDetail({ process }: ProcessDetailProps) { - {/* 자동 분류 규칙 */} + {/* 자동 분류 규칙 (패턴 기반) */} - 자동 분류 규칙 + + + 자동 분류 규칙 + - {process.classificationRules.length === 0 ? ( -
- -

등록된 자동 분류 규칙이 없습니다

+ {patternRules.length === 0 ? ( +
+ +

등록된 자동 분류 규칙이 없습니다

) : (
- {process.classificationRules.map((rule) => ( + {patternRules.map((rule) => (
- {rule.ruleType} - "{rule.conditionValue}" + {rule.ruleType} · {getMatchingTypeLabel(rule.matchingType)} · "{rule.conditionValue}" +
+ {rule.description && ( +
+ {rule.description} +
+ )} +
+
+ 우선순위: {rule.priority} +
+ ))} +
+ )} +
+
+ + {/* 개별 품목 */} + + + + + 개별 품목 + + + + {individualItems.length === 0 ? ( +
+ +

등록된 개별 품목이 없습니다

+
+ ) : ( +
+ {individualItems.map((rule) => ( +
+
+ + {rule.isActive ? '활성' : '비활성'} + +
+
+ {rule.conditionValue}
{rule.description && (
diff --git a/src/components/process-management/ProcessForm.tsx b/src/components/process-management/ProcessForm.tsx index 34999c1f..8a61338b 100644 --- a/src/components/process-management/ProcessForm.tsx +++ b/src/components/process-management/ProcessForm.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { X, Save, Plus, Wrench, Trash2, Loader2, Pencil } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -22,16 +22,7 @@ import { RuleModal } from './RuleModal'; import { toast } from 'sonner'; import type { Process, ClassificationRule, ProcessType } from '@/types/process'; import { PROCESS_TYPE_OPTIONS, MATCHING_TYPE_OPTIONS } from '@/types/process'; -import { createProcess, updateProcess } from './actions'; - -// 담당부서 옵션 (추후 API 연동 가능) -const DEPARTMENT_OPTIONS = [ - { value: '스크린생산부서', label: '스크린생산부서' }, - { value: '절곡생산부서', label: '절곡생산부서' }, - { value: '슬랫생산부서', label: '슬랫생산부서' }, - { value: '품질관리부서', label: '품질관리부서' }, - { value: '포장/출하부서', label: '포장/출하부서' }, -]; +import { createProcess, updateProcess, getDepartmentOptions, type DepartmentOption } from './actions'; // 작업일지 양식 옵션 (추후 API 연동 가능) const WORK_LOG_OPTIONS = [ @@ -72,10 +63,27 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) { const [isActive, setIsActive] = useState(initialData ? initialData.status === '사용중' : true); const [isLoading, setIsLoading] = useState(false); + // 부서 목록 상태 + const [departmentOptions, setDepartmentOptions] = useState([]); + const [isDepartmentsLoading, setIsDepartmentsLoading] = useState(true); + // 규칙 모달 상태 const [ruleModalOpen, setRuleModalOpen] = useState(false); const [editingRule, setEditingRule] = useState(undefined); + // 부서 목록 로드 + useEffect(() => { + const loadDepartments = async () => { + setIsDepartmentsLoading(true); + const result = await getDepartmentOptions(); + if (result.success && result.data) { + setDepartmentOptions(result.data); + } + setIsDepartmentsLoading(false); + }; + loadDepartments(); + }, []); + // 규칙 추가/수정 const handleSaveRule = useCallback( (ruleData: Omit) => { @@ -242,14 +250,14 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) {
- - + - {DEPARTMENT_OPTIONS.map((opt) => ( - - {opt.label} + {departmentOptions.map((opt) => ( + + {opt.name} ))} diff --git a/src/components/process-management/RuleModal.tsx b/src/components/process-management/RuleModal.tsx index b522e7cc..9621dabf 100644 --- a/src/components/process-management/RuleModal.tsx +++ b/src/components/process-management/RuleModal.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; +import { getItemList, type ItemOption } from './actions'; import { Dialog, DialogContent, @@ -47,20 +48,6 @@ const ITEM_TYPE_OPTIONS = [ { value: '부자재', label: '부자재' }, ]; -// Mock 품목 데이터 (추후 API 연동) -const MOCK_ITEMS = [ - { code: '스크린원단-0.3T', name: '스크린원단', type: '부자재' }, - { code: '스크린원단-0.5T', name: '스크린원단', type: '부자재' }, - { code: '슬랫-75mm', name: '슬랫', type: '부자재' }, - { code: '슬랫-100mm', name: '슬랫', type: '부자재' }, - { code: '가이드레일-60×60', name: '가이드레일', type: '부자재' }, - { code: '가이드레일-80×80', name: '가이드레일', type: '부자재' }, - { code: 'SCR-001', name: '스크린A', type: '제품' }, - { code: 'SCR-002', name: '스크린B', type: '제품' }, - { code: 'STEEL-001', name: '철판A', type: '원자재' }, - { code: 'STEEL-002', name: '철판B', type: '원자재' }, -]; - interface RuleModalProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -89,15 +76,41 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp const [selectedItemType, setSelectedItemType] = useState('all'); const [selectedItemCodes, setSelectedItemCodes] = useState>(new Set()); - // 필터된 품목 목록 - const filteredItems = MOCK_ITEMS.filter((item) => { - const matchesType = selectedItemType === 'all' || item.type === selectedItemType; - const matchesSearch = - !searchKeyword || - item.code.toLowerCase().includes(searchKeyword.toLowerCase()) || - item.name.toLowerCase().includes(searchKeyword.toLowerCase()); - return matchesType && matchesSearch; - }); + // 품목 목록 API 상태 + const [itemList, setItemList] = useState([]); + const [isItemsLoading, setIsItemsLoading] = useState(false); + + // 품목 목록 로드 (debounced) + const loadItems = useCallback(async (q?: string, itemType?: string) => { + setIsItemsLoading(true); + const result = await getItemList({ + q: q || undefined, + itemType: itemType === 'all' ? undefined : itemType, + size: 100, + }); + if (result.success && result.data) { + setItemList(result.data); + } + setIsItemsLoading(false); + }, []); + + // 검색어/품목유형 변경 시 API 호출 (debounce) + useEffect(() => { + if (registrationType !== 'individual') return; + + const timer = setTimeout(() => { + loadItems(searchKeyword, selectedItemType); + }, 300); + + return () => clearTimeout(timer); + }, [searchKeyword, selectedItemType, registrationType, loadItems]); + + // 모달 열릴 때 품목 목록 초기 로드 + useEffect(() => { + if (open && registrationType === 'individual') { + loadItems('', 'all'); + } + }, [open, registrationType, loadItems]); // 체크박스 토글 const handleToggleItem = (code: string) => { @@ -114,7 +127,7 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp // 전체 선택 const handleSelectAll = () => { - const allCodes = filteredItems.map((item) => item.code); + const allCodes = itemList.map((item) => item.code); setSelectedItemCodes(new Set(allCodes)); }; @@ -207,12 +220,12 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp return ( - + {editRule ? '규칙 수정' : '규칙 추가'} -
+
{/* 등록 방식 */}
@@ -375,7 +388,11 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
- 품목 목록 ({filteredItems.length}개) | 선택됨 ({selectedItemCodes.size}개) + {isItemsLoading ? ( + '로딩 중...' + ) : ( + <>품목 목록 ({itemList.length}개) | 선택됨 ({selectedItemCodes.size}개) + )}
@@ -385,6 +402,7 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp size="sm" onClick={handleSelectAll} className="text-xs h-7" + disabled={isItemsLoading || itemList.length === 0} > 전체 선택 @@ -395,6 +413,7 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp size="sm" onClick={handleResetSelection} className="text-xs h-7" + disabled={selectedItemCodes.size === 0} > 초기화 @@ -413,30 +432,37 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp - {filteredItems.map((item) => ( - handleToggleItem(item.code)} - > - - handleToggleItem(item.code)} - onClick={(e) => e.stopPropagation()} - /> + {isItemsLoading ? ( + + + 품목 목록을 불러오는 중... - {item.code} - {item.name} - {item.type} - ))} - {filteredItems.length === 0 && ( - + ) : itemList.length === 0 ? ( + 검색 결과가 없습니다 + ) : ( + itemList.map((item) => ( + handleToggleItem(item.code)} + > + + handleToggleItem(item.code)} + onClick={(e) => e.stopPropagation()} + /> + + {item.code} + {item.fullName} + {item.type} + + )) )}