From 6d8116713f109db63cb03b498d48be57625d0d3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Mon, 9 Feb 2026 21:31:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20=EA=B3=B5=EC=A0=95=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=ED=92=88=EB=AA=A9=20=EC=A0=9C=EA=B1=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EB=B0=8F=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProcessDetail: 개별 품목 제거(removeProcessItem) 기능 추가 - ProcessDetail: onProcessUpdate 콜백으로 부모 컴포넌트 동기화 - ProcessDetail: 삭제 다이얼로그 제거, 품목 목록 flatMap 추출 방식 개선 - ProcessForm: 규칙 모달 관련 코드 추가 - RuleModal: UI 개선 - actions.ts: removeProcessItem API 함수 추가 --- .../process-management/ProcessDetail.tsx | 95 ++++++++++--------- .../ProcessDetailClientV2.tsx | 2 +- .../process-management/ProcessForm.tsx | 85 ++++++++++++++++- .../process-management/RuleModal.tsx | 12 ++- src/components/process-management/actions.ts | 15 +++ 5 files changed, 154 insertions(+), 55 deletions(-) diff --git a/src/components/process-management/ProcessDetail.tsx b/src/components/process-management/ProcessDetail.tsx index 857368f6..c2bab488 100644 --- a/src/components/process-management/ProcessDetail.tsx +++ b/src/components/process-management/ProcessDetail.tsx @@ -20,15 +20,15 @@ import { PageHeader } from '@/components/organisms/PageHeader'; import { useMenuStore } from '@/store/menuStore'; import { usePermission } from '@/hooks/usePermission'; import { toast } from 'sonner'; -import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog'; -import { getProcessSteps, reorderProcessSteps, deleteProcess } from './actions'; +import { getProcessSteps, reorderProcessSteps, removeProcessItem } from './actions'; import type { Process, ProcessStep } from '@/types/process'; interface ProcessDetailProps { process: Process; + onProcessUpdate?: (process: Process) => void; } -export function ProcessDetail({ process }: ProcessDetailProps) { +export function ProcessDetail({ process, onProcessUpdate }: ProcessDetailProps) { const router = useRouter(); const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed); const { canUpdate } = usePermission(); @@ -37,19 +37,16 @@ export function ProcessDetail({ process }: ProcessDetailProps) { const [steps, setSteps] = useState([]); const [isStepsLoading, setIsStepsLoading] = useState(true); - // 삭제 다이얼로그 상태 - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - // 드래그 상태 const [dragIndex, setDragIndex] = useState(null); const [dragOverIndex, setDragOverIndex] = useState(null); const dragNodeRef = useRef(null); - // 품목 개수 계산 (기존 classificationRules에서 individual 품목) - const itemCount = process.classificationRules + // 개별 품목 목록 추출 + const individualItems = process.classificationRules .filter((r) => r.registrationType === 'individual') - .reduce((sum, r) => sum + (r.items?.length || 0), 0); + .flatMap((r) => r.items || []); + const itemCount = individualItems.length; // 단계 목록 로드 useEffect(() => { @@ -64,6 +61,21 @@ export function ProcessDetail({ process }: ProcessDetailProps) { loadSteps(); }, [process.id]); + // 품목 삭제 + const handleRemoveItem = async (itemId: string) => { + const remainingIds = individualItems + .filter((item) => item.id !== itemId) + .map((item) => parseInt(item.id, 10)); + + const result = await removeProcessItem(process.id, remainingIds); + if (result.success && result.data) { + toast.success('품목이 제거되었습니다.'); + onProcessUpdate?.(result.data); + } else { + toast.error(result.error || '품목 제거에 실패했습니다.'); + } + }; + // 네비게이션 const handleEdit = () => { router.push(`/ko/master-data/process-management/${process.id}?mode=edit`); @@ -81,24 +93,6 @@ export function ProcessDetail({ process }: ProcessDetailProps) { router.push(`/ko/master-data/process-management/${process.id}/steps/${stepId}`); }; - const handleDelete = async () => { - setIsDeleting(true); - try { - const result = await deleteProcess(process.id); - if (result.success) { - toast.success('공정이 삭제되었습니다.'); - router.push('/ko/master-data/process-management'); - } else { - toast.error(result.error || '삭제에 실패했습니다.'); - } - } catch { - toast.error('삭제 중 오류가 발생했습니다.'); - } finally { - setIsDeleting(false); - setDeleteDialogOpen(false); - } - }; - // ===== 드래그&드롭 (HTML5 네이티브) ===== const handleDragStart = useCallback((e: React.DragEvent, index: number) => { setDragIndex(index); @@ -225,6 +219,28 @@ export function ProcessDetail({ process }: ProcessDetailProps) { + {individualItems.length > 0 && ( + +
+ {individualItems.map((item) => ( +
+ {item.code} + {item.name} + +
+ ))} +
+
+ )} {/* 단계 테이블 */} @@ -367,27 +383,12 @@ export function ProcessDetail({ process }: ProcessDetailProps) { 목록으로 {canUpdate && ( -
- - -
+ )} - - {/* 삭제 확인 다이얼로그 */} - ); } diff --git a/src/components/process-management/ProcessDetailClientV2.tsx b/src/components/process-management/ProcessDetailClientV2.tsx index 30aa9684..2de27134 100644 --- a/src/components/process-management/ProcessDetailClientV2.tsx +++ b/src/components/process-management/ProcessDetailClientV2.tsx @@ -120,7 +120,7 @@ export function ProcessDetailClientV2({ processId, initialMode }: ProcessDetailC // 상세 보기 모드 if (mode === 'view' && processData) { - return ; + return ; } // 데이터 없음 (should not reach here) diff --git a/src/components/process-management/ProcessForm.tsx b/src/components/process-management/ProcessForm.tsx index 550e102f..99dcfd1b 100644 --- a/src/components/process-management/ProcessForm.tsx +++ b/src/components/process-management/ProcessForm.tsx @@ -106,10 +106,23 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) { } }, [categoryOptions]); // eslint-disable-line react-hooks/exhaustive-deps - // 품목 개수 계산 - const itemCount = classificationRules + // 개별 품목 목록 추출 + const individualItems = classificationRules .filter((r) => r.registrationType === 'individual') - .reduce((sum, r) => sum + (r.items?.length || 0), 0); + .flatMap((r) => r.items || []); + const itemCount = individualItems.length; + + // 품목 삭제 (로컬 상태에서 제거) + const handleRemoveItem = useCallback((itemId: string) => { + setClassificationRules((prev) => + prev.map((rule) => { + if (rule.registrationType !== 'individual') return rule; + const filtered = (rule.items || []).filter((item) => item.id !== itemId); + const newCondition = filtered.map((item) => item.id).join(','); + return { ...rule, items: filtered, conditionValue: newCondition }; + }).filter((rule) => rule.registrationType !== 'individual' || (rule.items && rule.items.length > 0)) + ); + }, []); // 부서 목록 + 단계 목록 로드 useEffect(() => { @@ -136,13 +149,51 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) { } }, [isEdit, initialData?.id]); - // 품목 규칙 추가/수정 + // 이미 등록된 품목 ID 목록 (RuleModal에서 필터링용) + const registeredItemIds = useMemo(() => { + const ids = new Set(); + classificationRules + .filter((r) => r.registrationType === 'individual') + .forEach((r) => { + // API에서 로드된 items + (r.items || []).forEach((item) => ids.add(item.id)); + // conditionValue (새로 선택된 것 포함) + r.conditionValue.split(',').filter(Boolean).forEach((id) => ids.add(id)); + }); + return ids; + }, [classificationRules]); + + // 품목 규칙 추가/수정 (기존 individual 규칙과 병합하여 중복 방지) const handleSaveRule = useCallback( (ruleData: Omit) => { if (editingRule) { setClassificationRules((prev) => prev.map((r) => (r.id === editingRule.id ? { ...r, ...ruleData } : r)) ); + } else if (ruleData.registrationType === 'individual') { + // 새로 선택된 품목 ID + const newItemIds = ruleData.conditionValue.split(',').filter(Boolean); + + setClassificationRules((prev) => { + const existingIndividualRule = prev.find((r) => r.registrationType === 'individual'); + if (existingIndividualRule) { + // 기존 individual 규칙에 병합 (중복 제거) + const existingIds = existingIndividualRule.conditionValue.split(',').filter(Boolean); + const mergedIds = [...new Set([...existingIds, ...newItemIds])]; + return prev.map((r) => + r.id === existingIndividualRule.id + ? { ...r, conditionValue: mergedIds.join(',') } + : r + ); + } else { + // 새 규칙 생성 + return [...prev, { + ...ruleData, + id: `rule-${Date.now()}`, + createdAt: new Date().toISOString(), + }]; + } + }); } else { const newRule: ClassificationRule = { ...ruleData, @@ -443,6 +494,28 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) { + {individualItems.length > 0 && ( + +
+ {individualItems.map((item) => ( +
+ {item.code} + {item.name} + +
+ ))} +
+
+ )} {/* 단계 테이블 */} @@ -593,6 +666,7 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) { editRule={editingRule} processId={initialData?.id} processName={processName} + registeredItemIds={registeredItemIds} /> ), @@ -613,6 +687,9 @@ export function ProcessForm({ mode, initialData }: ProcessFormProps) { departmentOptions, isDepartmentsLoading, itemCount, + individualItems, + registeredItemIds, + handleRemoveItem, dragIndex, dragOverIndex, handleSaveRule, diff --git a/src/components/process-management/RuleModal.tsx b/src/components/process-management/RuleModal.tsx index ed95d772..207148b2 100644 --- a/src/components/process-management/RuleModal.tsx +++ b/src/components/process-management/RuleModal.tsx @@ -64,9 +64,11 @@ interface RuleModalProps { processId?: string; /** 현재 공정명 (하단 안내 문구용) */ processName?: string; + /** 이미 등록된 품목 ID 목록 (검색 결과에서 제외) */ + registeredItemIds?: Set; } -export function RuleModal({ open, onOpenChange, onAdd, editRule, processId, processName }: RuleModalProps) { +export function RuleModal({ open, onOpenChange, onAdd, editRule, processId, processName, registeredItemIds }: RuleModalProps) { // 검색/필터 상태 const [searchKeyword, setSearchKeyword] = useState(''); const [selectedItemType, setSelectedItemType] = useState('all'); @@ -97,9 +99,13 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule, processId, proc size: 1000, excludeProcessId: processId, }); - setItemList(items); + // 이미 등록된 품목 필터링 + const filtered = registeredItemIds && registeredItemIds.size > 0 + ? items.filter((item) => !registeredItemIds.has(item.id)) + : items; + setItemList(filtered); setIsItemsLoading(false); - }, [processId]); + }, [processId, registeredItemIds]); // 검색어 유효성 검사 함수 const isValidSearchKeyword = (keyword: string): boolean => { diff --git a/src/components/process-management/actions.ts b/src/components/process-management/actions.ts index 521578c8..ba1645e3 100644 --- a/src/components/process-management/actions.ts +++ b/src/components/process-management/actions.ts @@ -279,6 +279,21 @@ export async function updateProcess(id: string, data: ProcessFormData): Promise< return { success: result.success, data: result.data, error: result.error }; } +/** + * 공정 품목 제거 (item_ids만 업데이트) + */ +export async function removeProcessItem(processId: string, remainingItemIds: number[]): Promise<{ success: boolean; data?: Process; error?: string; __authError?: boolean }> { + const result = await executeServerAction({ + url: `${API_URL}/api/v1/processes/${processId}`, + method: 'PUT', + body: { item_ids: remainingItemIds }, + transform: (d: ApiProcess) => transformApiToFrontend(d), + errorMessage: '품목 제거에 실패했습니다.', + }); + if (result.__authError) return { success: false, __authError: true }; + return { success: result.success, data: result.data, error: result.error }; +} + /** * 공정 삭제 */