공통코드 관리 시스템 개선 완료
This commit is contained in:
parent
14eb0b62e7
commit
63c7b80391
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }:
|
|||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* 카테고리 코드 (생성 시에만) */}
|
||||
{!isEditing && (
|
||||
{/* 카테고리 코드 */}
|
||||
{
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="categoryCode">카테고리 코드 *</Label>
|
||||
<Input
|
||||
id="categoryCode"
|
||||
{...form.register("categoryCode")}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isEditing} // 수정 시에는 비활성화
|
||||
placeholder="카테고리 코드를 입력하세요"
|
||||
className={form.formState.errors.categoryCode ? "border-red-500" : ""}
|
||||
onBlur={(e) => {
|
||||
const value = e.target.value.trim();
|
||||
if (value) {
|
||||
setValidationStates((prev) => ({
|
||||
...prev,
|
||||
categoryCode: { enabled: true, value },
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{form.formState.errors.categoryCode && (
|
||||
<p className="text-sm text-red-600">{form.formState.errors.categoryCode.message}</p>
|
||||
)}
|
||||
{!isEditing && !form.formState.errors.categoryCode && (
|
||||
<ValidationMessage
|
||||
message={categoryCodeCheck.data?.message}
|
||||
isValid={!categoryCodeCheck.data?.isDuplicate}
|
||||
isLoading={categoryCodeCheck.isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
|
||||
{/* 카테고리명 */}
|
||||
<div className="space-y-2">
|
||||
|
|
@ -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 && (
|
||||
<p className="text-sm text-red-600">{form.formState.errors.categoryName.message}</p>
|
||||
)}
|
||||
{!form.formState.errors.categoryName && (
|
||||
<ValidationMessage
|
||||
message={categoryNameCheck.data?.message}
|
||||
isValid={!categoryNameCheck.data?.isDuplicate}
|
||||
isLoading={categoryNameCheck.isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 영문명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="categoryNameEng">카테고리 영문명</Label>
|
||||
<Label htmlFor="categoryNameEng">카테고리 영문명 *</Label>
|
||||
<Input
|
||||
id="categoryNameEng"
|
||||
{...form.register("categoryNameEng")}
|
||||
disabled={isLoading}
|
||||
placeholder="카테고리 영문명을 입력하세요"
|
||||
className={form.formState.errors.categoryNameEng ? "border-red-500" : ""}
|
||||
onBlur={(e) => {
|
||||
const value = e.target.value.trim();
|
||||
if (value) {
|
||||
setValidationStates((prev) => ({
|
||||
...prev,
|
||||
categoryNameEng: { enabled: true, value },
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{form.formState.errors.categoryNameEng && (
|
||||
<p className="text-sm text-red-600">{form.formState.errors.categoryNameEng.message}</p>
|
||||
)}
|
||||
{!form.formState.errors.categoryNameEng && (
|
||||
<ValidationMessage
|
||||
message={categoryNameEngCheck.data?.message}
|
||||
isValid={!categoryNameEngCheck.data?.isDuplicate}
|
||||
isLoading={categoryNameEngCheck.isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Label htmlFor="description">설명 *</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
{...form.register("description")}
|
||||
|
|
@ -163,6 +262,15 @@ export function CodeCategoryFormModal({ isOpen, onClose, editingCategoryCode }:
|
|||
placeholder="설명을 입력하세요"
|
||||
rows={3}
|
||||
className={form.formState.errors.description ? "border-red-500" : ""}
|
||||
onBlur={(e) => {
|
||||
const value = e.target.value.trim();
|
||||
if (value) {
|
||||
setValidationStates((prev) => ({
|
||||
...prev,
|
||||
description: { enabled: true, value },
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{form.formState.errors.description && (
|
||||
<p className="text-sm text-red-600">{form.formState.errors.description.message}</p>
|
||||
|
|
@ -188,8 +296,13 @@ export function CodeCategoryFormModal({ isOpen, onClose, editingCategoryCode }:
|
|||
{/* 활성 상태 (수정 시에만) */}
|
||||
{isEditing && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch id="isActive" {...form.register("isActive")} disabled={isLoading} />
|
||||
<Label htmlFor="isActive">활성</Label>
|
||||
<Switch
|
||||
id="isActive"
|
||||
checked={form.watch("isActive") === "Y"}
|
||||
onCheckedChange={(checked) => form.setValue("isActive", checked ? "Y" : "N")}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Label htmlFor="isActive">{form.watch("isActive") === "Y" ? "활성" : "비활성"}</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -198,7 +311,10 @@ export function CodeCategoryFormModal({ isOpen, onClose, editingCategoryCode }:
|
|||
<Button type="button" variant="outline" onClick={onClose} disabled={isLoading}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading || !form.formState.isValid}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || !form.formState.isValid || hasDuplicateErrors || isDuplicateChecking}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<LoadingSpinner size="sm" className="mr-2" />
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -11,7 +11,9 @@ 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 { useCodes, useCreateCode, useUpdateCode } from "@/hooks/queries/useCodes";
|
||||
import { useCheckCodeDuplicate } from "@/hooks/queries/useValidation";
|
||||
import { createCodeSchema, updateCodeSchema, type CreateCodeData, type UpdateCodeData } from "@/lib/schemas/commonCode";
|
||||
import type { CodeInfo } from "@/types/commonCode";
|
||||
import type { FieldError } from "react-hook-form";
|
||||
|
|
@ -37,6 +39,47 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode }: Co
|
|||
|
||||
const isEditing = !!editingCode;
|
||||
|
||||
// 검증 상태 관리
|
||||
const [validationStates, setValidationStates] = useState({
|
||||
codeValue: { enabled: false, value: "" },
|
||||
codeName: { enabled: false, value: "" },
|
||||
codeNameEng: { enabled: false, value: "" },
|
||||
});
|
||||
|
||||
// 중복 검사 훅들
|
||||
const codeValueCheck = useCheckCodeDuplicate(
|
||||
categoryCode,
|
||||
"codeValue",
|
||||
validationStates.codeValue.value,
|
||||
isEditing ? editingCode?.code_value : undefined,
|
||||
validationStates.codeValue.enabled,
|
||||
);
|
||||
|
||||
const codeNameCheck = useCheckCodeDuplicate(
|
||||
categoryCode,
|
||||
"codeName",
|
||||
validationStates.codeName.value,
|
||||
isEditing ? editingCode?.code_value : undefined,
|
||||
validationStates.codeName.enabled,
|
||||
);
|
||||
|
||||
const codeNameEngCheck = useCheckCodeDuplicate(
|
||||
categoryCode,
|
||||
"codeNameEng",
|
||||
validationStates.codeNameEng.value,
|
||||
isEditing ? editingCode?.code_value : undefined,
|
||||
validationStates.codeNameEng.enabled,
|
||||
);
|
||||
|
||||
// 중복 검사 결과 확인
|
||||
const hasDuplicateErrors =
|
||||
(codeValueCheck.data?.isDuplicate && validationStates.codeValue.enabled) ||
|
||||
(codeNameCheck.data?.isDuplicate && validationStates.codeName.enabled) ||
|
||||
(codeNameEngCheck.data?.isDuplicate && validationStates.codeNameEng.enabled);
|
||||
|
||||
// 중복 검사 로딩 중인지 확인
|
||||
const isDuplicateChecking = codeValueCheck.isLoading || codeNameCheck.isLoading || codeNameEngCheck.isLoading;
|
||||
|
||||
// 폼 스키마 선택 (생성/수정에 따라)
|
||||
const schema = isEditing ? updateCodeSchema : createCodeSchema;
|
||||
|
||||
|
|
@ -126,10 +169,26 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode }: Co
|
|||
disabled={isLoading || isEditing} // 수정 시에는 비활성화
|
||||
placeholder="코드값을 입력하세요"
|
||||
className={(form.formState.errors as any)?.codeValue ? "border-red-500" : ""}
|
||||
onBlur={(e) => {
|
||||
const value = e.target.value.trim();
|
||||
if (value && !isEditing) {
|
||||
setValidationStates((prev) => ({
|
||||
...prev,
|
||||
codeValue: { enabled: true, value },
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{(form.formState.errors as any)?.codeValue && (
|
||||
<p className="text-sm text-red-600">{getErrorMessage((form.formState.errors as any)?.codeValue)}</p>
|
||||
)}
|
||||
{!isEditing && !(form.formState.errors as any)?.codeValue && (
|
||||
<ValidationMessage
|
||||
message={codeValueCheck.data?.message}
|
||||
isValid={!codeValueCheck.data?.isDuplicate}
|
||||
isLoading={codeValueCheck.isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 코드명 */}
|
||||
|
|
@ -141,10 +200,26 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode }: Co
|
|||
disabled={isLoading}
|
||||
placeholder="코드명을 입력하세요"
|
||||
className={form.formState.errors.codeName ? "border-red-500" : ""}
|
||||
onBlur={(e) => {
|
||||
const value = e.target.value.trim();
|
||||
if (value) {
|
||||
setValidationStates((prev) => ({
|
||||
...prev,
|
||||
codeName: { enabled: true, value },
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{form.formState.errors.codeName && (
|
||||
<p className="text-sm text-red-600">{getErrorMessage(form.formState.errors.codeName)}</p>
|
||||
)}
|
||||
{!form.formState.errors.codeName && (
|
||||
<ValidationMessage
|
||||
message={codeNameCheck.data?.message}
|
||||
isValid={!codeNameCheck.data?.isDuplicate}
|
||||
isLoading={codeNameCheck.isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 영문명 */}
|
||||
|
|
@ -156,10 +231,26 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode }: Co
|
|||
disabled={isLoading}
|
||||
placeholder="코드 영문명을 입력하세요"
|
||||
className={form.formState.errors.codeNameEng ? "border-red-500" : ""}
|
||||
onBlur={(e) => {
|
||||
const value = e.target.value.trim();
|
||||
if (value) {
|
||||
setValidationStates((prev) => ({
|
||||
...prev,
|
||||
codeNameEng: { enabled: true, value },
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{form.formState.errors.codeNameEng && (
|
||||
<p className="text-sm text-red-600">{getErrorMessage(form.formState.errors.codeNameEng)}</p>
|
||||
)}
|
||||
{!form.formState.errors.codeNameEng && (
|
||||
<ValidationMessage
|
||||
message={codeNameEngCheck.data?.message}
|
||||
isValid={!codeNameEngCheck.data?.isDuplicate}
|
||||
isLoading={codeNameEngCheck.isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
|
|
@ -212,7 +303,10 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode }: Co
|
|||
<Button type="button" variant="outline" onClick={onClose} disabled={isLoading}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading || !form.formState.isValid}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || !form.formState.isValid || hasDuplicateErrors || isDuplicateChecking}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<LoadingSpinner size="sm" className="mr-2" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ValidationMessageProps {
|
||||
message?: string;
|
||||
isValid?: boolean;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ValidationMessage({ message, isValid, isLoading, className }: ValidationMessageProps) {
|
||||
if (isLoading) {
|
||||
return <p className={cn("text-sm text-gray-500", className)}>검사 중...</p>;
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p className={cn("text-sm transition-colors", isValid ? "text-green-600" : "text-red-600", className)}>{message}</p>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
/**
|
||||
* 카테고리 중복 검사 훅
|
||||
*/
|
||||
export function useCheckCategoryDuplicate(
|
||||
field: "categoryCode" | "categoryName" | "categoryNameEng",
|
||||
value: string,
|
||||
excludeCode?: string,
|
||||
enabled = true,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.validation.categoryDuplicate(field, value, excludeCode),
|
||||
queryFn: () => commonCodeApi.validation.checkCategoryDuplicate(field, value, excludeCode),
|
||||
enabled: enabled && !!value && value.trim().length > 0,
|
||||
staleTime: 0, // 항상 최신 데이터 확인
|
||||
retry: false, // 중복 검사는 재시도하지 않음
|
||||
select: (data) => data.data,
|
||||
meta: {
|
||||
// React Query 에러 로깅 비활성화
|
||||
errorPolicy: "silent",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 중복 검사 훅
|
||||
*/
|
||||
export function useCheckCodeDuplicate(
|
||||
categoryCode: string,
|
||||
field: "codeValue" | "codeName" | "codeNameEng",
|
||||
value: string,
|
||||
excludeCode?: string,
|
||||
enabled = true,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.validation.codeDuplicate(categoryCode, field, value, excludeCode),
|
||||
queryFn: () => commonCodeApi.validation.checkCodeDuplicate(categoryCode, field, value, excludeCode),
|
||||
enabled: enabled && !!categoryCode && !!value && value.trim().length > 0,
|
||||
staleTime: 0, // 항상 최신 데이터 확인
|
||||
retry: false, // 중복 검사는 재시도하지 않음
|
||||
select: (data) => data.data,
|
||||
meta: {
|
||||
// React Query 에러 로깅 비활성화
|
||||
errorPolicy: "silent",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -66,8 +66,8 @@ apiClient.interceptors.request.use(
|
|||
|
||||
if (typeof window !== "undefined") {
|
||||
// 1순위: 전역 변수에서 확인
|
||||
if ((window as any).__GLOBAL_USER_LANG) {
|
||||
currentLang = (window as any).__GLOBAL_USER_LANG;
|
||||
if ((window as unknown as { __GLOBAL_USER_LANG?: string }).__GLOBAL_USER_LANG) {
|
||||
currentLang = (window as unknown as { __GLOBAL_USER_LANG: string }).__GLOBAL_USER_LANG;
|
||||
}
|
||||
// 2순위: localStorage에서 확인 (새 창이나 페이지 새로고침 시)
|
||||
else {
|
||||
|
|
@ -80,7 +80,7 @@ apiClient.interceptors.request.use(
|
|||
|
||||
console.log("🌐 API 요청 시 언어 정보:", {
|
||||
currentLang,
|
||||
globalVar: (window as any).__GLOBAL_USER_LANG,
|
||||
globalVar: (window as unknown as { __GLOBAL_USER_LANG?: string }).__GLOBAL_USER_LANG,
|
||||
localStorage: typeof window !== "undefined" ? localStorage.getItem("userLocale") : null,
|
||||
url: config.url,
|
||||
});
|
||||
|
|
@ -109,19 +109,39 @@ apiClient.interceptors.response.use(
|
|||
return response;
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
const status = error.response?.status;
|
||||
const url = error.config?.url;
|
||||
|
||||
// 409 에러 (중복 데이터)는 조용하게 처리
|
||||
if (status === 409) {
|
||||
// 중복 검사 API는 완전히 조용하게 처리
|
||||
if (url?.includes("/check-duplicate")) {
|
||||
// 중복 검사는 정상적인 비즈니스 로직이므로 콘솔 출력 없음
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 일반 409 에러는 간단한 로그만 출력
|
||||
console.warn("⚠️ 데이터 중복:", {
|
||||
url: url,
|
||||
message: (error.response?.data as { message?: string })?.message || "중복된 데이터입니다.",
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 다른 에러들은 기존처럼 상세 로그 출력
|
||||
console.error("❌ API 응답 오류:", {
|
||||
status: error.response?.status,
|
||||
status: status,
|
||||
statusText: error.response?.statusText,
|
||||
url: error.config?.url,
|
||||
url: url,
|
||||
data: error.response?.data,
|
||||
message: error.message,
|
||||
headers: error.config?.headers,
|
||||
});
|
||||
|
||||
// 401 에러 시 상세 정보 출력
|
||||
if (error.response?.status === 401) {
|
||||
if (status === 401) {
|
||||
console.error("🚨 401 Unauthorized 오류 상세 정보:", {
|
||||
url: error.config?.url,
|
||||
url: url,
|
||||
method: error.config?.method,
|
||||
headers: error.config?.headers,
|
||||
requestData: error.config?.data,
|
||||
|
|
@ -131,7 +151,7 @@ apiClient.interceptors.response.use(
|
|||
}
|
||||
|
||||
// 401 에러 시 토큰 제거 및 로그인 페이지로 리다이렉트
|
||||
if (error.response?.status === 401 && typeof window !== "undefined") {
|
||||
if (status === 401 && typeof window !== "undefined") {
|
||||
console.log("🔄 401 에러 감지 - 토큰 제거 및 로그인 페이지로 리다이렉트");
|
||||
localStorage.removeItem("authToken");
|
||||
|
||||
|
|
@ -146,7 +166,7 @@ apiClient.interceptors.response.use(
|
|||
);
|
||||
|
||||
// 공통 응답 타입
|
||||
export interface ApiResponse<T = any> {
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
|
|
@ -157,7 +177,7 @@ export interface ApiResponse<T = any> {
|
|||
export const apiCall = async <T>(
|
||||
method: "GET" | "POST" | "PUT" | "DELETE",
|
||||
url: string,
|
||||
data?: any,
|
||||
data?: unknown,
|
||||
): Promise<ApiResponse<T>> => {
|
||||
try {
|
||||
const response = await apiClient.request({
|
||||
|
|
@ -166,12 +186,16 @@ export const apiCall = async <T>(
|
|||
data,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error("API 호출 실패:", error);
|
||||
const axiosError = error as AxiosError;
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message || "알 수 없는 오류가 발생했습니다.",
|
||||
errorCode: error.response?.data?.errorCode,
|
||||
message:
|
||||
(axiosError.response?.data as { message?: string })?.message ||
|
||||
axiosError.message ||
|
||||
"알 수 없는 오류가 발생했습니다.",
|
||||
errorCode: (axiosError.response?.data as { errorCode?: string })?.errorCode,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -114,6 +114,46 @@ export const commonCodeApi = {
|
|||
},
|
||||
},
|
||||
|
||||
// 중복 검사 API
|
||||
validation: {
|
||||
/**
|
||||
* 카테고리 중복 검사
|
||||
*/
|
||||
async checkCategoryDuplicate(
|
||||
field: "categoryCode" | "categoryName" | "categoryNameEng",
|
||||
value: string,
|
||||
excludeCode?: string,
|
||||
): Promise<ApiResponse<{ isDuplicate: boolean; message: string; field: string; value: string }>> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("field", field);
|
||||
params.append("value", value);
|
||||
if (excludeCode) params.append("excludeCode", excludeCode);
|
||||
|
||||
const response = await apiClient.get(`/common-codes/categories/check-duplicate?${params}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 코드 중복 검사
|
||||
*/
|
||||
async checkCodeDuplicate(
|
||||
categoryCode: string,
|
||||
field: "codeValue" | "codeName" | "codeNameEng",
|
||||
value: string,
|
||||
excludeCode?: string,
|
||||
): Promise<
|
||||
ApiResponse<{ isDuplicate: boolean; message: string; categoryCode: string; field: string; value: string }>
|
||||
> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("field", field);
|
||||
params.append("value", value);
|
||||
if (excludeCode) params.append("excludeCode", excludeCode);
|
||||
|
||||
const response = await apiClient.get(`/common-codes/categories/${categoryCode}/codes/check-duplicate?${params}`);
|
||||
return response.data;
|
||||
},
|
||||
},
|
||||
|
||||
// 옵션 조회 API (화면관리용)
|
||||
options: {
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -29,5 +29,13 @@ export const queryKeys = {
|
|||
all: ["options"] as const,
|
||||
byCategory: (categoryCode: string) => [...queryKeys.options.all, categoryCode] as const,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// 검증 관련 쿼리 키
|
||||
validation: {
|
||||
all: ["validation"] as const,
|
||||
categoryDuplicate: (field: string, value: string, excludeCode?: string) =>
|
||||
[...queryKeys.validation.all, "category", field, value, excludeCode] as const,
|
||||
codeDuplicate: (categoryCode: string, field: string, value: string, excludeCode?: string) =>
|
||||
[...queryKeys.validation.all, "code", categoryCode, field, value, excludeCode] as const,
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -12,8 +12,11 @@ export const categorySchema = z.object({
|
|||
.max(50, "카테고리 코드는 50자 이하여야 합니다")
|
||||
.regex(/^[A-Z0-9_]+$/, "대문자, 숫자, 언더스코어(_)만 사용 가능합니다"),
|
||||
categoryName: z.string().min(1, "카테고리명은 필수입니다").max(100, "카테고리명은 100자 이하여야 합니다"),
|
||||
categoryNameEng: z.string().max(100, "영문 카테고리명은 100자 이하여야 합니다").optional().or(z.literal("")),
|
||||
description: z.string().max(500, "설명은 500자 이하여야 합니다").optional().or(z.literal("")),
|
||||
categoryNameEng: z
|
||||
.string()
|
||||
.min(1, "영문 카테고리명은 필수입니다")
|
||||
.max(100, "영문 카테고리명은 100자 이하여야 합니다"),
|
||||
description: z.string().min(1, "설명은 필수입니다").max(500, "설명은 500자 이하여야 합니다"),
|
||||
sortOrder: z.number().min(1, "정렬 순서는 1 이상이어야 합니다").max(9999, "정렬 순서는 9999 이하여야 합니다"),
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue