From 63c7b80391e75c840df2904249cf177ff2f0be4c Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Wed, 3 Sep 2025 11:20:43 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B3=B5=ED=86=B5=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/commonCodeController.ts | 101 +++++++++++++ backend-node/src/routes/commonCodeRoutes.ts | 11 ++ .../src/services/commonCodeService.ts | 131 +++++++++++++++++ docs/공통코드_관리_시스템_설계.md | 47 ++++-- .../admin/CodeCategoryFormModal.tsx | 138 ++++++++++++++++-- frontend/components/admin/CodeFormModal.tsx | 98 ++++++++++++- .../components/common/ValidationMessage.tsx | 23 +++ frontend/hooks/queries/useValidation.ts | 50 +++++++ frontend/lib/api/client.ts | 50 +++++-- frontend/lib/api/commonCode.ts | 40 +++++ frontend/lib/queryKeys.ts | 10 +- frontend/lib/schemas/commonCode.ts | 7 +- 12 files changed, 665 insertions(+), 41 deletions(-) create mode 100644 frontend/components/common/ValidationMessage.tsx create mode 100644 frontend/hooks/queries/useValidation.ts diff --git a/backend-node/src/controllers/commonCodeController.ts b/backend-node/src/controllers/commonCodeController.ts index d38251b6..0d83306f 100644 --- a/backend-node/src/controllers/commonCodeController.ts +++ b/backend-node/src/controllers/commonCodeController.ts @@ -395,4 +395,105 @@ export class CommonCodeController { }); } } + + /** + * 카테고리 중복 검사 + * GET /api/common-codes/categories/check-duplicate?field=categoryCode&value=USER_STATUS&excludeCode=OLD_CODE + */ + async checkCategoryDuplicate(req: AuthenticatedRequest, res: Response) { + try { + const { field, value, excludeCode } = req.query; + + // 입력값 검증 + if (!field || !value) { + return res.status(400).json({ + success: false, + message: "field와 value 파라미터가 필요합니다.", + }); + } + + const validFields = ['categoryCode', 'categoryName', 'categoryNameEng']; + if (!validFields.includes(field as string)) { + return res.status(400).json({ + success: false, + message: "field는 categoryCode, categoryName, categoryNameEng 중 하나여야 합니다.", + }); + } + + const result = await this.commonCodeService.checkCategoryDuplicate( + field as 'categoryCode' | 'categoryName' | 'categoryNameEng', + value as string, + excludeCode as string + ); + + return res.json({ + success: true, + data: { + ...result, + field, + value + }, + message: "카테고리 중복 검사 완료", + }); + } catch (error) { + logger.error("카테고리 중복 검사 실패:", error); + return res.status(500).json({ + success: false, + message: "카테고리 중복 검사 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + /** + * 코드 중복 검사 + * GET /api/common-codes/categories/:categoryCode/codes/check-duplicate?field=codeValue&value=ACTIVE&excludeCode=OLD_CODE + */ + async checkCodeDuplicate(req: AuthenticatedRequest, res: Response) { + try { + const { categoryCode } = req.params; + const { field, value, excludeCode } = req.query; + + // 입력값 검증 + if (!field || !value) { + return res.status(400).json({ + success: false, + message: "field와 value 파라미터가 필요합니다.", + }); + } + + const validFields = ['codeValue', 'codeName', 'codeNameEng']; + if (!validFields.includes(field as string)) { + return res.status(400).json({ + success: false, + message: "field는 codeValue, codeName, codeNameEng 중 하나여야 합니다.", + }); + } + + const result = await this.commonCodeService.checkCodeDuplicate( + categoryCode, + field as 'codeValue' | 'codeName' | 'codeNameEng', + value as string, + excludeCode as string + ); + + return res.json({ + success: true, + data: { + ...result, + categoryCode, + field, + value + }, + message: "코드 중복 검사 완료", + }); + } catch (error) { + logger.error(`코드 중복 검사 실패 (${req.params.categoryCode}):`, error); + return res.status(500).json({ + success: false, + message: "코드 중복 검사 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } } diff --git a/backend-node/src/routes/commonCodeRoutes.ts b/backend-node/src/routes/commonCodeRoutes.ts index 0f320621..6772a6e9 100644 --- a/backend-node/src/routes/commonCodeRoutes.ts +++ b/backend-node/src/routes/commonCodeRoutes.ts @@ -12,6 +12,12 @@ router.use(authenticateToken); router.get("/categories", (req, res) => commonCodeController.getCategories(req, res) ); + +// 카테고리 중복 검사 (구체적인 경로를 먼저 배치) +router.get("/categories/check-duplicate", (req, res) => + commonCodeController.checkCategoryDuplicate(req, res) +); + router.post("/categories", (req, res) => commonCodeController.createCategory(req, res) ); @@ -30,6 +36,11 @@ router.post("/categories/:categoryCode/codes", (req, res) => commonCodeController.createCode(req, res) ); +// 코드 중복 검사 (구체적인 경로를 먼저 배치) +router.get("/categories/:categoryCode/codes/check-duplicate", (req, res) => + commonCodeController.checkCodeDuplicate(req, res) +); + // 코드 순서 변경 (구체적인 경로를 먼저 배치) router.put("/categories/:categoryCode/codes/reorder", (req, res) => commonCodeController.reorderCodes(req, res) diff --git a/backend-node/src/services/commonCodeService.ts b/backend-node/src/services/commonCodeService.ts index bfb93532..6df0bb91 100644 --- a/backend-node/src/services/commonCodeService.ts +++ b/backend-node/src/services/commonCodeService.ts @@ -417,4 +417,135 @@ export class CommonCodeService { throw error; } } + + /** + * 카테고리 중복 검사 + */ + async checkCategoryDuplicate( + field: 'categoryCode' | 'categoryName' | 'categoryNameEng', + value: string, + excludeCategoryCode?: string + ): Promise<{ isDuplicate: boolean; message: string }> { + try { + if (!value || !value.trim()) { + return { + isDuplicate: false, + message: "값을 입력해주세요." + }; + } + + const trimmedValue = value.trim(); + let whereCondition: any = {}; + + // 필드별 검색 조건 설정 + switch (field) { + case 'categoryCode': + whereCondition.category_code = trimmedValue; + break; + case 'categoryName': + whereCondition.category_name = trimmedValue; + break; + case 'categoryNameEng': + whereCondition.category_name_eng = trimmedValue; + break; + } + + // 수정 시 자기 자신 제외 + if (excludeCategoryCode) { + whereCondition.category_code = { + ...whereCondition.category_code, + not: excludeCategoryCode + }; + } + + const existingCategory = await prisma.code_category.findFirst({ + where: whereCondition, + select: { category_code: true } + }); + + const isDuplicate = !!existingCategory; + const fieldNames = { + categoryCode: '카테고리 코드', + categoryName: '카테고리명', + categoryNameEng: '카테고리 영문명' + }; + + return { + isDuplicate, + message: isDuplicate + ? `이미 사용 중인 ${fieldNames[field]}입니다.` + : `사용 가능한 ${fieldNames[field]}입니다.` + }; + } catch (error) { + logger.error(`카테고리 중복 검사 중 오류 (${field}: ${value}):`, error); + throw error; + } + } + + /** + * 코드 중복 검사 + */ + async checkCodeDuplicate( + categoryCode: string, + field: 'codeValue' | 'codeName' | 'codeNameEng', + value: string, + excludeCodeValue?: string + ): Promise<{ isDuplicate: boolean; message: string }> { + try { + if (!value || !value.trim()) { + return { + isDuplicate: false, + message: "값을 입력해주세요." + }; + } + + const trimmedValue = value.trim(); + let whereCondition: any = { + code_category: categoryCode + }; + + // 필드별 검색 조건 설정 + switch (field) { + case 'codeValue': + whereCondition.code_value = trimmedValue; + break; + case 'codeName': + whereCondition.code_name = trimmedValue; + break; + case 'codeNameEng': + whereCondition.code_name_eng = trimmedValue; + break; + } + + // 수정 시 자기 자신 제외 + if (excludeCodeValue) { + whereCondition.code_value = { + ...whereCondition.code_value, + not: excludeCodeValue + }; + } + + const existingCode = await prisma.code_info.findFirst({ + where: whereCondition, + select: { code_value: true } + }); + + const isDuplicate = !!existingCode; + const fieldNames = { + codeValue: '코드값', + codeName: '코드명', + codeNameEng: '코드 영문명' + }; + + return { + isDuplicate, + message: isDuplicate + ? `이미 사용 중인 ${fieldNames[field]}입니다.` + : `사용 가능한 ${fieldNames[field]}입니다.` + }; + } catch (error) { + logger.error(`코드 중복 검사 중 오류 (${categoryCode}, ${field}: ${value}):`, error); + throw error; + } + } } diff --git a/docs/공통코드_관리_시스템_설계.md b/docs/공통코드_관리_시스템_설계.md index ff248bf5..6e73ada6 100644 --- a/docs/공통코드_관리_시스템_설계.md +++ b/docs/공통코드_관리_시스템_설계.md @@ -831,21 +831,44 @@ export class CommonCodeService { **목표 기간**: 2일 → **실제 소요**: 1일 -### ⏳ Phase 4.8: 공통코드 관리 시스템 개선 (진행중) +### ✅ Phase 4.8: 공통코드 관리 시스템 개선 (완료!) + +**구현 완료 내역:** + +1. **중복 검사 기능 구현** + + - [x] 백엔드 중복 검사 API 추가 (`checkCategoryDuplicate`, `checkCodeDuplicate`) + - [x] REST API 엔드포인트: `/check-duplicate` 라우트 구현 + - [x] 프론트엔드 API 클라이언트 (`commonCodeApi.validation`) + - [x] React Query 훅 (`useCheckCategoryDuplicate`, `useCheckCodeDuplicate`) + - [x] onBlur 검증 UI 구현 (초록색 성공/빨간색 실패 메시지) + - [x] 409 에러 조용한 처리 (콘솔 에러 출력 억제) + +2. **폼 검증 시스템 개선** + + - [x] 중복/유효성 검사 실패 시 저장 버튼 자동 비활성화 + - [x] 메시지 우선순위 시스템 (유효성 검사 > 중복 검사) + - [x] 카테고리 영문명/설명 필수 필드로 변경 + - [x] 수정 시 카테고리 코드값 표시 + - [x] `isActive` 필드 타입 불일치 문제 해결 (boolean ↔ string) + +3. **코드 품질 및 구조 개선** + - [x] 컴포넌트 분리: `CategoryItem`, `SortableCodeItem` 별도 파일화 + - [x] TypeScript 타입 안전성 강화 (`any` → `unknown`/구체적 타입) + - [x] 린터 에러 완전 제거 (`client.ts` 타입 에러 해결) + - [x] 로컬 상태 제거 및 React Query 캐시 직접 사용 + +### ⏳ Phase 4.9: 추가 최적화 작업 (대기중) -- [ ] 중복 검사 기능 구현 - - [ ] 백엔드 중복 검사 API 추가 (categoryCode, categoryName, categoryNameEng) - - [ ] 프론트엔드 onBlur 검증 UI 구현 (초록색 성공 메시지) - - [ ] 409 에러 조용한 처리 (콘솔 에러 출력 억제) - [ ] React Query 최적화 - - [ ] 불필요한 props drilling 제거 + - [ ] 불필요한 props drilling 완전 제거 - [ ] 데이터 흐름 최적화 - [ ] 레이아웃 개선 - - [ ] 내부 스크롤 처리 + - [ ] 내부 스크롤 처리 (패널별 독립 스크롤) - [ ] 반응형 레이아웃 적용 -- [ ] 코드 리팩터링 - - [ ] 커스텀 훅 분리 (드래그앤드롭, 공통 로직) - - [ ] 컴포넌트 구조 최적화 +- [ ] 커스텀 훅 분리 + - [ ] 드래그앤드롭 로직 분리 (`useDragAndDrop`) + - [ ] 공통 폼 검증 로직 분리 (`useFormValidation`) **개선 목표:** @@ -957,7 +980,7 @@ export class CommonCodeService { ## 🎯 현재 구현 상태 -### 📊 **전체 진행률: 85%** 🎉 +### 📊 **전체 진행률: 90%** 🎉 - ✅ **Phase 1**: 기본 구조 및 데이터베이스 (100%) - **완료!** - ✅ **Phase 2**: 백엔드 API 구현 (100%) - **완료!** @@ -966,7 +989,7 @@ export class CommonCodeService { - ✅ **Phase 4.5**: UX/UI 개선 (100%) - **완료!** - ✅ **Phase 4.6**: CRUD 즉시 반영 개선 (100%) - **완료!** - ✅ **Phase 4.7**: 현대적 라이브러리 도입 (100%) - **완료!** -- ⏳ **Phase 4.8**: 공통코드 관리 시스템 개선 (0%) +- ✅ **Phase 4.8**: 공통코드 관리 시스템 개선 (100%) - **완료!** - ⏳ **Phase 5**: 화면관리 연계 (0%) - ⏳ **Phase 6**: 테스트 및 최적화 (0%) diff --git a/frontend/components/admin/CodeCategoryFormModal.tsx b/frontend/components/admin/CodeCategoryFormModal.tsx index 44996bc8..3c61be1a 100644 --- a/frontend/components/admin/CodeCategoryFormModal.tsx +++ b/frontend/components/admin/CodeCategoryFormModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; @@ -10,7 +10,9 @@ import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { LoadingSpinner } from "@/components/common/LoadingSpinner"; +import { ValidationMessage } from "@/components/common/ValidationMessage"; import { useCategories, useCreateCategory, useUpdateCategory } from "@/hooks/queries/useCategories"; +import { useCheckCategoryDuplicate } from "@/hooks/queries/useValidation"; import { createCategorySchema, updateCategorySchema, @@ -33,6 +35,54 @@ export function CodeCategoryFormModal({ isOpen, onClose, editingCategoryCode }: const isEditing = !!editingCategoryCode; const editingCategory = categories.find((c) => c.category_code === editingCategoryCode); + // 검증 상태 관리 + const [validationStates, setValidationStates] = useState({ + categoryCode: { enabled: false, value: "" }, + categoryName: { enabled: false, value: "" }, + categoryNameEng: { enabled: false, value: "" }, + description: { enabled: false, value: "" }, // 설명 필드 추가 + }); + + // 중복 검사 훅들 + const categoryCodeCheck = useCheckCategoryDuplicate( + "categoryCode", + validationStates.categoryCode.value, + isEditing ? editingCategoryCode : undefined, + validationStates.categoryCode.enabled, + ); + + const categoryNameCheck = useCheckCategoryDuplicate( + "categoryName", + validationStates.categoryName.value, + isEditing ? editingCategoryCode : undefined, + validationStates.categoryName.enabled, + ); + + const categoryNameEngCheck = useCheckCategoryDuplicate( + "categoryNameEng", + validationStates.categoryNameEng.value, + isEditing ? editingCategoryCode : undefined, + validationStates.categoryNameEng.enabled, + ); + + // 중복 검사 결과 확인 (수정 시에는 카테고리 코드 검사 제외) + const hasDuplicateErrors = + (!isEditing && categoryCodeCheck.data?.isDuplicate && validationStates.categoryCode.enabled) || + (categoryNameCheck.data?.isDuplicate && validationStates.categoryName.enabled) || + (categoryNameEngCheck.data?.isDuplicate && validationStates.categoryNameEng.enabled); + + // 중복 검사 로딩 중인지 확인 (수정 시에는 카테고리 코드 검사 제외) + const isDuplicateChecking = + (!isEditing && categoryCodeCheck.isLoading) || categoryNameCheck.isLoading || categoryNameEngCheck.isLoading; + + // 필수 필드들이 모두 검증되었는지 확인 (생성 시에만 적용) + const requiredFieldsValidated = + isEditing || + (validationStates.categoryCode.enabled && + validationStates.categoryName.enabled && + validationStates.categoryNameEng.enabled && + validationStates.description.enabled); + // 폼 스키마 선택 (생성/수정에 따라) const schema = isEditing ? updateCategorySchema : createCategorySchema; @@ -55,11 +105,12 @@ export function CodeCategoryFormModal({ isOpen, onClose, editingCategoryCode }: if (isEditing && editingCategory) { // 수정 모드: 기존 데이터 로드 form.reset({ + categoryCode: editingCategory.category_code, // 카테고리 코드도 표시 categoryName: editingCategory.category_name, categoryNameEng: editingCategory.category_name_eng || "", description: editingCategory.description || "", sortOrder: editingCategory.sort_order, - isActive: editingCategory.is_active === "Y", + isActive: editingCategory.is_active, // 🔧 "Y"/"N" 문자열 그대로 사용 }); } else { // 새 카테고리 모드: 자동 순서 계산 @@ -106,22 +157,38 @@ export function CodeCategoryFormModal({ isOpen, onClose, editingCategoryCode }:
- {/* 카테고리 코드 (생성 시에만) */} - {!isEditing && ( + {/* 카테고리 코드 */} + {
{ + const value = e.target.value.trim(); + if (value) { + setValidationStates((prev) => ({ + ...prev, + categoryCode: { enabled: true, value }, + })); + } + }} /> {form.formState.errors.categoryCode && (

{form.formState.errors.categoryCode.message}

)} + {!isEditing && !form.formState.errors.categoryCode && ( + + )}
- )} + } {/* 카테고리명 */}
@@ -132,30 +199,62 @@ export function CodeCategoryFormModal({ isOpen, onClose, editingCategoryCode }: disabled={isLoading} placeholder="카테고리명을 입력하세요" className={form.formState.errors.categoryName ? "border-red-500" : ""} + onBlur={(e) => { + const value = e.target.value.trim(); + if (value) { + setValidationStates((prev) => ({ + ...prev, + categoryName: { enabled: true, value }, + })); + } + }} /> {form.formState.errors.categoryName && (

{form.formState.errors.categoryName.message}

)} + {!form.formState.errors.categoryName && ( + + )}
{/* 영문명 */}
- + { + const value = e.target.value.trim(); + if (value) { + setValidationStates((prev) => ({ + ...prev, + categoryNameEng: { enabled: true, value }, + })); + } + }} /> {form.formState.errors.categoryNameEng && (

{form.formState.errors.categoryNameEng.message}

)} + {!form.formState.errors.categoryNameEng && ( + + )}
{/* 설명 */}
- +