공통코드 관리 시스템 개선 완료

This commit is contained in:
hyeonsu 2025-09-03 11:20:43 +09:00
parent 14eb0b62e7
commit 63c7b80391
12 changed files with 665 additions and 41 deletions

View File

@ -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",
});
}
}
}

View File

@ -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)

View File

@ -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;
}
}
}

View File

@ -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%)

View File

@ -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" />

View File

@ -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" />

View File

@ -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>
);
}

View File

@ -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",
},
});
}

View File

@ -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,
};
}
};

View File

@ -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: {
/**

View File

@ -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;

View File

@ -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 이하여야 합니다"),
});