diff --git a/backend-node/src/services/commonCodeService.ts b/backend-node/src/services/commonCodeService.ts index 153af7db..bfb93532 100644 --- a/backend-node/src/services/commonCodeService.ts +++ b/backend-node/src/services/commonCodeService.ts @@ -48,6 +48,7 @@ export interface CreateCategoryData { categoryNameEng?: string; description?: string; sortOrder?: number; + isActive?: string; } export interface CreateCodeData { @@ -56,6 +57,7 @@ export interface CreateCodeData { codeNameEng?: string; description?: string; sortOrder?: number; + isActive?: string; } export class CommonCodeService { @@ -176,6 +178,8 @@ export class CommonCodeService { updatedBy: string ) { try { + // 디버깅: 받은 데이터 로그 + logger.info(`카테고리 수정 데이터:`, { categoryCode, data }); const category = await prisma.code_category.update({ where: { category_code: categoryCode }, data: { @@ -183,6 +187,12 @@ export class CommonCodeService { category_name_eng: data.categoryNameEng, description: data.description, sort_order: data.sortOrder, + is_active: + typeof data.isActive === "boolean" + ? data.isActive + ? "Y" + : "N" + : data.isActive, // boolean이면 "Y"/"N"으로 변환 updated_by: updatedBy, updated_date: new Date(), }, @@ -256,6 +266,8 @@ export class CommonCodeService { updatedBy: string ) { try { + // 디버깅: 받은 데이터 로그 + logger.info(`코드 수정 데이터:`, { categoryCode, codeValue, data }); const code = await prisma.code_info.update({ where: { code_category_code_value: { @@ -268,6 +280,12 @@ export class CommonCodeService { code_name_eng: data.codeNameEng, description: data.description, sort_order: data.sortOrder, + is_active: + typeof data.isActive === "boolean" + ? data.isActive + ? "Y" + : "N" + : data.isActive, // boolean이면 "Y"/"N"으로 변환 updated_by: updatedBy, updated_date: new Date(), }, diff --git a/docs/공통코드_관리_시스템_설계.md b/docs/공통코드_관리_시스템_설계.md index 7d503bf6..3b0d5286 100644 --- a/docs/공통코드_관리_시스템_설계.md +++ b/docs/공통코드_관리_시스템_설계.md @@ -784,6 +784,122 @@ export class CommonCodeService { **목표 기간**: 1일 → **실제 소요**: 1일 +### ✅ Phase 4.7: 현대적 라이브러리 도입 (완료!) + +- [x] React Query 도입으로 데이터 페칭 최적화 +- [x] React Hook Form 도입으로 폼 관리 개선 +- [x] Zod 도입으로 스키마 기반 유효성 검사 +- [x] Query Key 기반 캐시 무효화로 CRUD 업데이트 자동화 +- [x] 컴포넌트 모듈화 (CategoryItem, SortableCodeItem 분리) +- [x] 로컬 상태 제거 및 서버 상태 단일화 +- [x] 린터 오류 0개 달성 + +**구현 완료 내역:** + +1. **React Query (@tanstack/react-query)** + + - [x] 자동 캐싱 및 백그라운드 리페칭 + - [x] Query Key 기반 캐시 무효화 (`frontend/lib/queryKeys.ts`) + - [x] Optimistic Updates (드래그앤드롭 순서 변경) + - [x] 로딩/에러 상태 자동 관리 + - [x] 네트워크 요청 최적화 + - [x] 커스텀 훅 구현: `useCategories`, `useCodes`, `useCreateCode`, `useUpdateCode`, `useDeleteCode`, `useReorderCodes` + +2. **React Hook Form** + + - [x] 성능 최적화 (불필요한 리렌더링 방지) + - [x] 간단한 폼 API 적용 (`CodeFormModal`, `CodeCategoryFormModal`) + - [x] Zod와 완벽 연동 + - [x] TypeScript 완벽 지원 + - [x] 실시간 검증 및 에러 메시지 표시 + +3. **Zod 스키마 검증** + - [x] 스키마 기반 데이터 구조 정의 (`frontend/lib/schemas/commonCode.ts`) + - [x] TypeScript 타입 자동 생성 + - [x] 런타임 검증 + 컴파일 타입 안전성 + - [x] 자동화된 에러 메시지 + - [x] 카테고리/코드 생성/수정 스키마 분리 + +**주요 성과:** + +- 🚀 **현대적 아키텍처 도입**: React Query + React Hook Form + Zod 완벽 통합 +- 📈 **성능 최적화**: 불필요한 리렌더링 제거, 효율적 캐싱, Optimistic Updates +- 🔒 **타입 안전성**: 완벽한 TypeScript 지원으로 런타임 오류 방지 +- 🧩 **컴포넌트 모듈화**: CategoryItem, SortableCodeItem 분리로 재사용성 향상 +- 🎯 **상태 관리 단순화**: 서버 상태와 클라이언트 상태 명확히 분리 +- ✨ **사용자 경험**: 즉시 반영되는 CRUD, 부드러운 드래그앤드롭 + +**목표 기간**: 2일 → **실제 소요**: 1일 + +**구현 계획 (완료):** + +1. **1단계: 의존성 설치 및 설정** + + ```bash + npm install @tanstack/react-query react-hook-form @hookform/resolvers zod + ``` + +2. **2단계: React Query 설정** + + - QueryClient 설정 및 Provider 추가 + - Query Key factory 함수 생성 + - 커스텀 훅 생성 (`useCategories`, `useCodes`, `useCreateCode` 등) + +3. **3단계: Zod 스키마 정의** + + ```typescript + const categorySchema = z.object({ + categoryCode: z.string().regex(/^[A-Z0-9_]+$/, "대문자, 숫자, _만 가능"), + categoryName: z.string().min(1, "필수 입력").max(20, "20자 이하"), + categoryNameEng: z.string().max(20, "20자 이하"), + description: z.string().max(50, "50자 이하"), + sortOrder: z.number().min(1, "1 이상"), + }); + + const codeSchema = z.object({ + codeValue: z.string().regex(/^[A-Z0-9_]+$/, "대문자, 숫자, _만 가능"), + codeName: z.string().min(1, "필수 입력").max(20, "20자 이하"), + codeNameEng: z.string().max(20, "20자 이하"), + description: z.string().max(50, "50자 이하"), + sortOrder: z.number().min(1, "1 이상"), + }); + ``` + +4. **4단계: Query Key 전략** + + ```typescript + // 카테고리 관련 + ["categories"][ // 모든 카테고리 + ("categories", { active: true }) + ][ // 활성 카테고리만 + // 코드 관련 + ("codes", categoryCode) + ][ // 특정 카테고리의 모든 코드 + ("codes", categoryCode, { active: true }) + ][("code", categoryCode, codeValue)]; // 특정 카테고리의 활성 코드만 // 특정 코드 상세 + ``` + +5. **5단계: React Hook Form 적용** + + - `CodeFormModal`, `CodeCategoryFormModal` 리팩토링 + - 기존 수동 검증 로직 제거 + - Zod resolver 적용 + +6. **6단계: 기존 코드 정리** + - `useCommonCode` 훅 단순화 + - 수동 상태 관리 코드 제거 + - 수동 Optimistic Updates 로직 제거 + +**예상 개선 효과:** + +- **코드량 40-50% 감소**: 보일러플레이트 코드 대폭 감소 +- **타입 안전성 100% 보장**: 런타임 + 컴파일 타임 검증 +- **성능 최적화**: 자동 캐싱, 불필요한 리렌더링 방지 +- **개발자 경험 향상**: 자동화된 폼 검증, 에러 처리 +- **유지보수성 향상**: 표준화된 패턴, 명확한 데이터 흐름 + +**목표 기간**: 2일 + ### ⏳ Phase 5: 화면관리 연계 (예정) - [ ] column_labels와 연동 확인 @@ -804,7 +920,7 @@ export class CommonCodeService { ## 🎯 현재 구현 상태 -### 📊 **전체 진행률: 83%** 🎉 +### 📊 **전체 진행률: 85%** 🎉 - ✅ **Phase 1**: 기본 구조 및 데이터베이스 (100%) - **완료!** - ✅ **Phase 2**: 백엔드 API 구현 (100%) - **완료!** @@ -812,6 +928,7 @@ export class CommonCodeService { - ✅ **Phase 4**: 고급 기능 구현 (100%) - **완료!** - ✅ **Phase 4.5**: UX/UI 개선 (100%) - **완료!** - ✅ **Phase 4.6**: CRUD 즉시 반영 개선 (100%) - **완료!** +- ✅ **Phase 4.7**: 현대적 라이브러리 도입 (100%) - **완료!** - ⏳ **Phase 5**: 화면관리 연계 (0%) - ⏳ **Phase 6**: 테스트 및 최적화 (0%) diff --git a/frontend/app/(main)/admin/commonCode/page.tsx b/frontend/app/(main)/admin/commonCode/page.tsx index 2c0c7896..b1941bb2 100644 --- a/frontend/app/(main)/admin/commonCode/page.tsx +++ b/frontend/app/(main)/admin/commonCode/page.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; import { CodeCategoryPanel } from "@/components/admin/CodeCategoryPanel"; import { CodeDetailPanel } from "@/components/admin/CodeDetailPanel"; // import { useMultiLang } from "@/hooks/useMultiLang"; // 무한 루프 방지를 위해 임시 제거 @@ -26,7 +25,7 @@ export default function CommonCodeManagementPage() {
{/* 카테고리 패널 - PC에서 좌측 고정 너비, 모바일에서 전체 너비 */}
- + 📂 코드 카테고리 diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index f5d93a6b..c7fb1143 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata, Viewport } from "next"; import { Inter, JetBrains_Mono } from "next/font/google"; import "./globals.css"; +import { QueryProvider } from "@/providers/QueryProvider"; const inter = Inter({ subsets: ["latin"], @@ -39,7 +40,9 @@ export default function RootLayout({
- {children} + + {children} +
diff --git a/frontend/components/admin/CategoryItem.tsx b/frontend/components/admin/CategoryItem.tsx new file mode 100644 index 00000000..92b074b0 --- /dev/null +++ b/frontend/components/admin/CategoryItem.tsx @@ -0,0 +1,92 @@ +"use client"; + +import React from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Edit, Trash2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useUpdateCategory } from "@/hooks/queries/useCategories"; +import type { CategoryInfo } from "@/types/commonCode"; + +interface CategoryItemProps { + category: CategoryInfo; + isSelected: boolean; + onSelect: () => void; + onEdit: () => void; + onDelete: () => void; +} + +export function CategoryItem({ category, isSelected, onSelect, onEdit, onDelete }: CategoryItemProps) { + const updateCategoryMutation = useUpdateCategory(); + + // 활성/비활성 토글 핸들러 + const handleToggleActive = async (checked: boolean) => { + try { + await updateCategoryMutation.mutateAsync({ + categoryCode: category.category_code, + data: { + categoryName: category.category_name, + categoryNameEng: category.category_name_eng || "", + description: category.description || "", + sortOrder: category.sort_order, + isActive: checked ? "Y" : "N", + }, + }); + } catch (error) { + console.error("카테고리 활성 상태 변경 실패:", error); + } + }; + + return ( +
+
+
+
+

{category.category_name}

+ { + e.preventDefault(); + e.stopPropagation(); + if (!updateCategoryMutation.isPending) { + handleToggleActive(category.is_active !== "Y"); + } + }} + onPointerDown={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + {category.is_active === "Y" ? "활성" : "비활성"} + +
+

{category.category_code}

+ {category.description &&

{category.description}

} +
+ + {/* 액션 버튼 */} + {isSelected && ( +
e.stopPropagation()}> + + +
+ )} +
+
+ ); +} diff --git a/frontend/components/admin/CodeCategoryFormModal.tsx b/frontend/components/admin/CodeCategoryFormModal.tsx index b79c8454..44996bc8 100644 --- a/frontend/components/admin/CodeCategoryFormModal.tsx +++ b/frontend/components/admin/CodeCategoryFormModal.tsx @@ -1,248 +1,213 @@ "use client"; -import { useState, useEffect } from "react"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; -// import { useCommonCode } from "@/hooks/useCommonCode"; // 제거: 상태 공유 문제 해결 -// import { useMultiLang } from "@/hooks/useMultiLang"; // 무한 루프 방지를 위해 임시 제거 import { LoadingSpinner } from "@/components/common/LoadingSpinner"; -import { CodeCategory, CreateCategoryRequest, UpdateCategoryRequest } from "@/types/commonCode"; +import { useCategories, useCreateCategory, useUpdateCategory } from "@/hooks/queries/useCategories"; +import { + createCategorySchema, + updateCategorySchema, + type CreateCategoryData, + type UpdateCategoryData, +} from "@/lib/schemas/commonCode"; +import type { CodeCategory } from "@/types/commonCode"; interface CodeCategoryFormModalProps { isOpen: boolean; onClose: () => void; editingCategoryCode?: string; - categories: CodeCategory[]; - onCreateCategory: (data: CreateCategoryRequest) => Promise; - onUpdateCategory: (categoryCode: string, data: UpdateCategoryRequest) => Promise; } -export function CodeCategoryFormModal({ - isOpen, - onClose, - editingCategoryCode, - categories, - onCreateCategory, - onUpdateCategory, -}: CodeCategoryFormModalProps) { - // const { getText } = useMultiLang(); // 무한 루프 방지를 위해 임시 제거 - // const { categories, createCategory, updateCategory } = useCommonCode(); // 제거: props로 전달받음 +export function CodeCategoryFormModal({ isOpen, onClose, editingCategoryCode }: CodeCategoryFormModalProps) { + const { data: categories = [] } = useCategories(); + const createCategoryMutation = useCreateCategory(); + const updateCategoryMutation = useUpdateCategory(); - // 폼 상태 - const [formData, setFormData] = useState({ - categoryCode: "", - categoryName: "", - categoryNameEng: "", - description: "", - sortOrder: 1, - isActive: true, + const isEditing = !!editingCategoryCode; + const editingCategory = categories.find((c) => c.category_code === editingCategoryCode); + + // 폼 스키마 선택 (생성/수정에 따라) + const schema = isEditing ? updateCategorySchema : createCategorySchema; + + const form = useForm({ + resolver: zodResolver(schema), + mode: "onChange", // 실시간 검증 활성화 + defaultValues: { + categoryCode: "", + categoryName: "", + categoryNameEng: "", + description: "", + sortOrder: 1, + ...(isEditing && { isActive: true }), + }, }); - const [loading, setLoading] = useState(false); - const [errors, setErrors] = useState>({}); - - // 모달 열릴 때 데이터 초기화 + // 편집 모드일 때 기존 데이터 로드 useEffect(() => { if (isOpen) { - if (editingCategoryCode && categories.length > 0) { + if (isEditing && editingCategory) { // 수정 모드: 기존 데이터 로드 - const category = categories.find((c) => c.category_code === editingCategoryCode); - if (category) { - console.log("🔄 카테고리 수정 모드 - 기존 데이터 로드:", category); - setFormData({ - categoryCode: category.category_code, - categoryName: category.category_name, - categoryNameEng: category.category_name_eng || "", - description: category.description || "", - sortOrder: category.sort_order, - isActive: category.is_active === "Y", - }); - } + form.reset({ + categoryName: editingCategory.category_name, + categoryNameEng: editingCategory.category_name_eng || "", + description: editingCategory.description || "", + sortOrder: editingCategory.sort_order, + isActive: editingCategory.is_active === "Y", + }); } else { - // 새 카테고리 모드: 초기값 설정 및 자동 순서 계산 + // 새 카테고리 모드: 자동 순서 계산 const maxSortOrder = categories.length > 0 ? Math.max(...categories.map((c) => c.sort_order)) : 0; - console.log("✨ 새 카테고리 모드 - 초기값 설정, 다음 순서:", maxSortOrder + 1); - setFormData({ + + form.reset({ categoryCode: "", categoryName: "", categoryNameEng: "", description: "", sortOrder: maxSortOrder + 1, - isActive: true, }); } - setErrors({}); } - }, [isOpen, editingCategoryCode, categories]); - - // 입력값 검증 - const validateForm = () => { - const newErrors: Record = {}; - - if (!formData.categoryCode.trim()) { - newErrors.categoryCode = "필수 입력 항목입니다."; - } - - if (!formData.categoryName.trim()) { - newErrors.categoryName = "필수 입력 항목입니다."; - } - - setErrors(newErrors); - return Object.keys(newErrors).length === 0; - }; - - // 폼 제출 핸들러 - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!validateForm()) return; - - setLoading(true); + }, [isOpen, isEditing, editingCategory, categories, form]); + const handleSubmit = form.handleSubmit(async (data) => { try { - if (editingCategoryCode) { + if (isEditing && editingCategoryCode) { // 수정 - await onUpdateCategory(editingCategoryCode, { - categoryName: formData.categoryName, - categoryNameEng: formData.categoryNameEng, - description: formData.description, - sortOrder: formData.sortOrder, - isActive: formData.isActive, + await updateCategoryMutation.mutateAsync({ + categoryCode: editingCategoryCode, + data: data as UpdateCategoryData, }); } else { // 생성 - await onCreateCategory({ - categoryCode: formData.categoryCode, - categoryName: formData.categoryName, - categoryNameEng: formData.categoryNameEng, - description: formData.description, - sortOrder: formData.sortOrder, - }); + await createCategoryMutation.mutateAsync(data as CreateCategoryData); } onClose(); + form.reset(); } catch (error) { - console.error("카테고리 저장 오류:", error); - // 에러 처리는 useCommonCode 훅에서 처리됨 - } finally { - setLoading(false); + console.error("카테고리 저장 실패:", error); } - }; + }); - // 입력값 변경 핸들러 - const handleChange = (field: string, value: any) => { - setFormData((prev) => ({ ...prev, [field]: value })); - - // 에러 제거 - if (errors[field]) { - setErrors((prev) => ({ ...prev, [field]: "" })); - } - }; + const isLoading = createCategoryMutation.isPending || updateCategoryMutation.isPending; return ( - {editingCategoryCode ? "카테고리 수정" : "새 카테고리"} + {isEditing ? "카테고리 수정" : "새 카테고리"}
- {/* 카테고리 코드 */} -
- - handleChange("categoryCode", e.target.value)} - disabled={!!editingCategoryCode || loading} - placeholder={"카테고리 코드를 입력하세요"} - className={errors.categoryCode ? "border-red-500" : ""} - /> - {errors.categoryCode &&

{errors.categoryCode}

} -
+ {/* 카테고리 코드 (생성 시에만) */} + {!isEditing && ( +
+ + + {form.formState.errors.categoryCode && ( +

{form.formState.errors.categoryCode.message}

+ )} +
+ )} {/* 카테고리명 */}
- + handleChange("categoryName", e.target.value)} - disabled={loading} - placeholder={"카테고리명을 입력하세요"} - className={errors.categoryName ? "border-red-500" : ""} + {...form.register("categoryName")} + disabled={isLoading} + placeholder="카테고리명을 입력하세요" + className={form.formState.errors.categoryName ? "border-red-500" : ""} /> - {errors.categoryName &&

{errors.categoryName}

} + {form.formState.errors.categoryName && ( +

{form.formState.errors.categoryName.message}

+ )}
{/* 영문명 */}
- + handleChange("categoryNameEng", e.target.value)} - disabled={loading} - placeholder={"카테고리 영문명을 입력하세요"} + {...form.register("categoryNameEng")} + disabled={isLoading} + placeholder="카테고리 영문명을 입력하세요" + className={form.formState.errors.categoryNameEng ? "border-red-500" : ""} /> + {form.formState.errors.categoryNameEng && ( +

{form.formState.errors.categoryNameEng.message}

+ )}
{/* 설명 */}
- +