공통코드관리 구현 #13

Merged
hyeonsu merged 19 commits from commonCodeMng into dev 2025-09-04 10:05:28 +09:00
37 changed files with 5244 additions and 23 deletions

View File

@ -5145,3 +5145,50 @@ model screen_menu_assignments {
@@unique([screen_id, menu_objid, company_code])
@@index([company_code])
}
// =====================================================
// 공통코드 관리 시스템 모델
// =====================================================
/// 공통코드 카테고리 테이블
model code_category {
category_code String @id @db.VarChar(50)
category_name String @db.VarChar(100)
category_name_eng String? @db.VarChar(100)
description String? @db.Text
sort_order Int @default(0)
is_active String @default("Y") @db.Char(1)
created_date DateTime? @default(now()) @db.Timestamp(6)
created_by String? @db.VarChar(50)
updated_date DateTime? @default(now()) @db.Timestamp(6)
updated_by String? @db.VarChar(50)
// 관계 - 코드 상세 정보
codes code_info[]
@@index([is_active])
@@index([sort_order])
}
/// 공통코드 상세 정보 테이블
model code_info {
code_category String @db.VarChar(50)
code_value String @db.VarChar(50)
code_name String @db.VarChar(100)
code_name_eng String? @db.VarChar(100)
description String? @db.Text
sort_order Int @default(0)
is_active String @default("Y") @db.Char(1)
created_date DateTime? @default(now()) @db.Timestamp(6)
created_by String? @db.VarChar(50)
updated_date DateTime? @default(now()) @db.Timestamp(6)
updated_by String? @db.VarChar(50)
// 관계 - 코드 카테고리
category code_category @relation(fields: [code_category], references: [category_code], onDelete: Cascade, onUpdate: Cascade)
@@id([code_category, code_value])
@@index([code_category])
@@index([is_active])
@@index([code_category, sort_order])
}

View File

@ -14,6 +14,7 @@ import adminRoutes from "./routes/adminRoutes";
import multilangRoutes from "./routes/multilangRoutes";
import tableManagementRoutes from "./routes/tableManagementRoutes";
import screenManagementRoutes from "./routes/screenManagementRoutes";
import commonCodeRoutes from "./routes/commonCodeRoutes";
// import userRoutes from './routes/userRoutes';
// import menuRoutes from './routes/menuRoutes';
@ -65,6 +66,7 @@ app.use("/api/admin", adminRoutes);
app.use("/api/multilang", multilangRoutes);
app.use("/api/table-management", tableManagementRoutes);
app.use("/api/screen-management", screenManagementRoutes);
app.use("/api/common-codes", commonCodeRoutes);
// app.use('/api/users', userRoutes);
// app.use('/api/menus', menuRoutes);

View File

@ -0,0 +1,504 @@
import { Request, Response } from "express";
import {
CommonCodeService,
CreateCategoryData,
CreateCodeData,
} from "../services/commonCodeService";
import { AuthenticatedRequest } from "../types/auth";
import { logger } from "../utils/logger";
export class CommonCodeController {
private commonCodeService: CommonCodeService;
constructor() {
this.commonCodeService = new CommonCodeService();
}
/**
*
* GET /api/common-codes/categories
*/
async getCategories(req: AuthenticatedRequest, res: Response) {
try {
const { search, isActive, page = "1", size = "20" } = req.query;
const categories = await this.commonCodeService.getCategories({
search: search as string,
isActive:
isActive === "true" ? true : isActive === "false" ? false : undefined,
page: parseInt(page as string),
size: parseInt(size as string),
});
return res.json({
success: true,
data: categories.data,
total: categories.total,
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
*/
async getCodes(req: AuthenticatedRequest, res: Response) {
try {
const { categoryCode } = req.params;
const { search, isActive, page, size } = req.query;
const result = await this.commonCodeService.getCodes(categoryCode, {
search: search as string,
isActive:
isActive === "true" ? true : isActive === "false" ? false : undefined,
page: page ? parseInt(page as string) : undefined,
size: size ? parseInt(size as string) : undefined,
});
return res.json({
success: true,
data: result.data,
total: result.total,
message: `코드 목록 조회 성공 (${categoryCode})`,
});
} catch (error) {
logger.error(`코드 목록 조회 실패 (${req.params.categoryCode}):`, error);
return res.status(500).json({
success: false,
message: "코드 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
*
* POST /api/common-codes/categories
*/
async createCategory(req: AuthenticatedRequest, res: Response) {
try {
const categoryData: CreateCategoryData = req.body;
const userId = req.user?.userId || "SYSTEM"; // 인증 미들웨어에서 설정된 사용자 ID
// 입력값 검증
if (!categoryData.categoryCode || !categoryData.categoryName) {
return res.status(400).json({
success: false,
message: "카테고리 코드와 이름은 필수입니다.",
});
}
const category = await this.commonCodeService.createCategory(
categoryData,
userId
);
return res.status(201).json({
success: true,
data: category,
message: "카테고리 생성 성공",
});
} catch (error) {
logger.error("카테고리 생성 실패:", error);
// Prisma 에러 처리
if (
error instanceof Error &&
error.message.includes("Unique constraint")
) {
return res.status(409).json({
success: false,
message: "이미 존재하는 카테고리 코드입니다.",
});
}
return res.status(500).json({
success: false,
message: "카테고리 생성 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
*
* PUT /api/common-codes/categories/:categoryCode
*/
async updateCategory(req: AuthenticatedRequest, res: Response) {
try {
const { categoryCode } = req.params;
const categoryData: Partial<CreateCategoryData> = req.body;
const userId = req.user?.userId || "SYSTEM";
const category = await this.commonCodeService.updateCategory(
categoryCode,
categoryData,
userId
);
return res.json({
success: true,
data: category,
message: "카테고리 수정 성공",
});
} catch (error) {
logger.error(`카테고리 수정 실패 (${req.params.categoryCode}):`, error);
if (
error instanceof Error &&
error.message.includes("Record to update not found")
) {
return res.status(404).json({
success: false,
message: "존재하지 않는 카테고리입니다.",
});
}
return res.status(500).json({
success: false,
message: "카테고리 수정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
*
* DELETE /api/common-codes/categories/:categoryCode
*/
async deleteCategory(req: AuthenticatedRequest, res: Response) {
try {
const { categoryCode } = req.params;
await this.commonCodeService.deleteCategory(categoryCode);
return res.json({
success: true,
message: "카테고리 삭제 성공",
});
} catch (error) {
logger.error(`카테고리 삭제 실패 (${req.params.categoryCode}):`, error);
if (
error instanceof Error &&
error.message.includes("Record to delete does not exist")
) {
return res.status(404).json({
success: false,
message: "존재하지 않는 카테고리입니다.",
});
}
return res.status(500).json({
success: false,
message: "카테고리 삭제 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
*
* POST /api/common-codes/categories/:categoryCode/codes
*/
async createCode(req: AuthenticatedRequest, res: Response) {
try {
const { categoryCode } = req.params;
const codeData: CreateCodeData = req.body;
const userId = req.user?.userId || "SYSTEM";
// 입력값 검증
if (!codeData.codeValue || !codeData.codeName) {
return res.status(400).json({
success: false,
message: "코드값과 코드명은 필수입니다.",
});
}
const code = await this.commonCodeService.createCode(
categoryCode,
codeData,
userId
);
return res.status(201).json({
success: true,
data: code,
message: "코드 생성 성공",
});
} catch (error) {
logger.error(`코드 생성 실패 (${req.params.categoryCode}):`, error);
if (
error instanceof Error &&
error.message.includes("Unique constraint")
) {
return res.status(409).json({
success: false,
message: "이미 존재하는 코드값입니다.",
});
}
return res.status(500).json({
success: false,
message: "코드 생성 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
*
* PUT /api/common-codes/categories/:categoryCode/codes/:codeValue
*/
async updateCode(req: AuthenticatedRequest, res: Response) {
try {
const { categoryCode, codeValue } = req.params;
const codeData: Partial<CreateCodeData> = req.body;
const userId = req.user?.userId || "SYSTEM";
const code = await this.commonCodeService.updateCode(
categoryCode,
codeValue,
codeData,
userId
);
return res.json({
success: true,
data: code,
message: "코드 수정 성공",
});
} catch (error) {
logger.error(
`코드 수정 실패 (${req.params.categoryCode}.${req.params.codeValue}):`,
error
);
if (
error instanceof Error &&
error.message.includes("Record to update not found")
) {
return res.status(404).json({
success: false,
message: "존재하지 않는 코드입니다.",
});
}
return res.status(500).json({
success: false,
message: "코드 수정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
*
* DELETE /api/common-codes/categories/:categoryCode/codes/:codeValue
*/
async deleteCode(req: AuthenticatedRequest, res: Response) {
try {
const { categoryCode, codeValue } = req.params;
await this.commonCodeService.deleteCode(categoryCode, codeValue);
return res.json({
success: true,
message: "코드 삭제 성공",
});
} catch (error) {
logger.error(
`코드 삭제 실패 (${req.params.categoryCode}.${req.params.codeValue}):`,
error
);
if (
error instanceof Error &&
error.message.includes("Record to delete does not exist")
) {
return res.status(404).json({
success: false,
message: "존재하지 않는 코드입니다.",
});
}
return res.status(500).json({
success: false,
message: "코드 삭제 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* ()
* GET /api/common-codes/categories/:categoryCode/options
*/
async getCodeOptions(req: AuthenticatedRequest, res: Response) {
try {
const { categoryCode } = req.params;
const options = await this.commonCodeService.getCodeOptions(categoryCode);
return res.json({
success: true,
data: options,
message: `코드 옵션 조회 성공 (${categoryCode})`,
});
} catch (error) {
logger.error(`코드 옵션 조회 실패 (${req.params.categoryCode}):`, error);
return res.status(500).json({
success: false,
message: "코드 옵션 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
*
* PUT /api/common-codes/categories/:categoryCode/codes/reorder
*/
async reorderCodes(req: AuthenticatedRequest, res: Response) {
try {
const { categoryCode } = req.params;
const { codes } = req.body as {
codes: Array<{ codeValue: string; sortOrder: number }>;
};
const userId = req.user?.userId || "SYSTEM";
if (!codes || !Array.isArray(codes)) {
return res.status(400).json({
success: false,
message: "코드 순서 정보가 올바르지 않습니다.",
});
}
await this.commonCodeService.reorderCodes(categoryCode, codes, userId);
return res.json({
success: true,
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",
});
}
}
/**
*
* 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

@ -0,0 +1,61 @@
import { Router } from "express";
import { CommonCodeController } from "../controllers/commonCodeController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
const commonCodeController = new CommonCodeController();
// 모든 공통코드 API는 인증이 필요
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)
);
router.put("/categories/:categoryCode", (req, res) =>
commonCodeController.updateCategory(req, res)
);
router.delete("/categories/:categoryCode", (req, res) =>
commonCodeController.deleteCategory(req, res)
);
// 코드 관련 라우트
router.get("/categories/:categoryCode/codes", (req, res) =>
commonCodeController.getCodes(req, res)
);
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)
);
router.put("/categories/:categoryCode/codes/:codeValue", (req, res) =>
commonCodeController.updateCode(req, res)
);
router.delete("/categories/:categoryCode/codes/:codeValue", (req, res) =>
commonCodeController.deleteCode(req, res)
);
// 화면관리용 옵션 조회
router.get("/categories/:categoryCode/options", (req, res) =>
commonCodeController.getCodeOptions(req, res)
);
export default router;

View File

@ -0,0 +1,565 @@
import { PrismaClient } from "@prisma/client";
import { logger } from "../utils/logger";
const prisma = new PrismaClient();
export interface CodeCategory {
category_code: string;
category_name: string;
category_name_eng?: string | null;
description?: string | null;
sort_order: number;
is_active: string;
created_date?: Date | null;
created_by?: string | null;
updated_date?: Date | null;
updated_by?: string | null;
}
export interface CodeInfo {
code_category: string;
code_value: string;
code_name: string;
code_name_eng?: string | null;
description?: string | null;
sort_order: number;
is_active: string;
created_date?: Date | null;
created_by?: string | null;
updated_date?: Date | null;
updated_by?: string | null;
}
export interface GetCategoriesParams {
search?: string;
isActive?: boolean;
page?: number;
size?: number;
}
export interface GetCodesParams {
search?: string;
isActive?: boolean;
page?: number;
size?: number;
}
export interface CreateCategoryData {
categoryCode: string;
categoryName: string;
categoryNameEng?: string;
description?: string;
sortOrder?: number;
isActive?: string;
}
export interface CreateCodeData {
codeValue: string;
codeName: string;
codeNameEng?: string;
description?: string;
sortOrder?: number;
isActive?: string;
}
export class CommonCodeService {
/**
*
*/
async getCategories(params: GetCategoriesParams) {
try {
const { search, isActive, page = 1, size = 20 } = params;
let whereClause: any = {};
if (search) {
whereClause.OR = [
{ category_name: { contains: search, mode: "insensitive" } },
{ category_code: { contains: search, mode: "insensitive" } },
];
}
if (isActive !== undefined) {
whereClause.is_active = isActive ? "Y" : "N";
}
const offset = (page - 1) * size;
const [categories, total] = await Promise.all([
prisma.code_category.findMany({
where: whereClause,
orderBy: [{ sort_order: "asc" }, { category_code: "asc" }],
skip: offset,
take: size,
}),
prisma.code_category.count({ where: whereClause }),
]);
logger.info(
`카테고리 조회 완료: ${categories.length}개, 전체: ${total}`
);
return {
data: categories,
total,
};
} catch (error) {
logger.error("카테고리 조회 중 오류:", error);
throw error;
}
}
/**
*
*/
async getCodes(categoryCode: string, params: GetCodesParams) {
try {
const { search, isActive, page = 1, size = 20 } = params;
let whereClause: any = {
code_category: categoryCode,
};
if (search) {
whereClause.OR = [
{ code_name: { contains: search, mode: "insensitive" } },
{ code_value: { contains: search, mode: "insensitive" } },
];
}
if (isActive !== undefined) {
whereClause.is_active = isActive ? "Y" : "N";
}
const offset = (page - 1) * size;
const [codes, total] = await Promise.all([
prisma.code_info.findMany({
where: whereClause,
orderBy: [{ sort_order: "asc" }, { code_value: "asc" }],
skip: offset,
take: size,
}),
prisma.code_info.count({ where: whereClause }),
]);
logger.info(
`코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}`
);
return { data: codes, total };
} catch (error) {
logger.error(`코드 조회 중 오류 (${categoryCode}):`, error);
throw error;
}
}
/**
*
*/
async createCategory(data: CreateCategoryData, createdBy: string) {
try {
const category = await prisma.code_category.create({
data: {
category_code: data.categoryCode,
category_name: data.categoryName,
category_name_eng: data.categoryNameEng,
description: data.description,
sort_order: data.sortOrder || 0,
is_active: "Y",
created_by: createdBy,
updated_by: createdBy,
},
});
logger.info(`카테고리 생성 완료: ${data.categoryCode}`);
return category;
} catch (error) {
logger.error("카테고리 생성 중 오류:", error);
throw error;
}
}
/**
*
*/
async updateCategory(
categoryCode: string,
data: Partial<CreateCategoryData>,
updatedBy: string
) {
try {
// 디버깅: 받은 데이터 로그
logger.info(`카테고리 수정 데이터:`, { categoryCode, data });
const category = await prisma.code_category.update({
where: { category_code: categoryCode },
data: {
category_name: data.categoryName,
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(),
},
});
logger.info(`카테고리 수정 완료: ${categoryCode}`);
return category;
} catch (error) {
logger.error(`카테고리 수정 중 오류 (${categoryCode}):`, error);
throw error;
}
}
/**
*
*/
async deleteCategory(categoryCode: string) {
try {
await prisma.code_category.delete({
where: { category_code: categoryCode },
});
logger.info(`카테고리 삭제 완료: ${categoryCode}`);
} catch (error) {
logger.error(`카테고리 삭제 중 오류 (${categoryCode}):`, error);
throw error;
}
}
/**
*
*/
async createCode(
categoryCode: string,
data: CreateCodeData,
createdBy: string
) {
try {
const code = await prisma.code_info.create({
data: {
code_category: categoryCode,
code_value: data.codeValue,
code_name: data.codeName,
code_name_eng: data.codeNameEng,
description: data.description,
sort_order: data.sortOrder || 0,
is_active: "Y",
created_by: createdBy,
updated_by: createdBy,
},
});
logger.info(`코드 생성 완료: ${categoryCode}.${data.codeValue}`);
return code;
} catch (error) {
logger.error(
`코드 생성 중 오류 (${categoryCode}.${data.codeValue}):`,
error
);
throw error;
}
}
/**
*
*/
async updateCode(
categoryCode: string,
codeValue: string,
data: Partial<CreateCodeData>,
updatedBy: string
) {
try {
// 디버깅: 받은 데이터 로그
logger.info(`코드 수정 데이터:`, { categoryCode, codeValue, data });
const code = await prisma.code_info.update({
where: {
code_category_code_value: {
code_category: categoryCode,
code_value: codeValue,
},
},
data: {
code_name: data.codeName,
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(),
},
});
logger.info(`코드 수정 완료: ${categoryCode}.${codeValue}`);
return code;
} catch (error) {
logger.error(`코드 수정 중 오류 (${categoryCode}.${codeValue}):`, error);
throw error;
}
}
/**
*
*/
async deleteCode(categoryCode: string, codeValue: string) {
try {
await prisma.code_info.delete({
where: {
code_category_code_value: {
code_category: categoryCode,
code_value: codeValue,
},
},
});
logger.info(`코드 삭제 완료: ${categoryCode}.${codeValue}`);
} catch (error) {
logger.error(`코드 삭제 중 오류 (${categoryCode}.${codeValue}):`, error);
throw error;
}
}
/**
* ()
*/
async getCodeOptions(categoryCode: string) {
try {
const codes = await prisma.code_info.findMany({
where: {
code_category: categoryCode,
is_active: "Y",
},
select: {
code_value: true,
code_name: true,
code_name_eng: true,
sort_order: true,
},
orderBy: [{ sort_order: "asc" }, { code_value: "asc" }],
});
const options = codes.map((code) => ({
value: code.code_value,
label: code.code_name,
labelEng: code.code_name_eng,
}));
logger.info(`코드 옵션 조회 완료: ${categoryCode} - ${options.length}`);
return options;
} catch (error) {
logger.error(`코드 옵션 조회 중 오류 (${categoryCode}):`, error);
throw error;
}
}
/**
*
*/
async reorderCodes(
categoryCode: string,
codes: Array<{ codeValue: string; sortOrder: number }>,
updatedBy: string
) {
try {
// 먼저 존재하는 코드들을 확인
const existingCodes = await prisma.code_info.findMany({
where: {
code_category: categoryCode,
code_value: { in: codes.map((c) => c.codeValue) },
},
select: { code_value: true },
});
const existingCodeValues = existingCodes.map((c) => c.code_value);
const validCodes = codes.filter((c) =>
existingCodeValues.includes(c.codeValue)
);
if (validCodes.length === 0) {
throw new Error(
`카테고리 ${categoryCode}에 순서를 변경할 유효한 코드가 없습니다.`
);
}
const updatePromises = validCodes.map(({ codeValue, sortOrder }) =>
prisma.code_info.update({
where: {
code_category_code_value: {
code_category: categoryCode,
code_value: codeValue,
},
},
data: {
sort_order: sortOrder,
updated_by: updatedBy,
updated_date: new Date(),
},
})
);
await Promise.all(updatePromises);
const skippedCodes = codes.filter(
(c) => !existingCodeValues.includes(c.codeValue)
);
if (skippedCodes.length > 0) {
logger.warn(
`코드 순서 변경 시 존재하지 않는 코드들을 건너뜀: ${skippedCodes.map((c) => c.codeValue).join(", ")}`
);
}
logger.info(
`코드 순서 변경 완료: ${categoryCode} - ${validCodes.length}개 (전체 ${codes.length}개 중)`
);
} catch (error) {
logger.error(`코드 순서 변경 중 오류 (${categoryCode}):`, error);
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

@ -0,0 +1,93 @@
// 공통코드 관련 타입 정의
export interface CodeCategory {
category_code: string;
category_name: string;
category_name_eng?: string | null;
description?: string | null;
sort_order: number;
is_active: string;
created_date?: Date | null;
created_by?: string | null;
updated_date?: Date | null;
updated_by?: string | null;
}
export interface CodeInfo {
code_category: string;
code_value: string;
code_name: string;
code_name_eng?: string | null;
description?: string | null;
sort_order: number;
is_active: string;
created_date?: Date | null;
created_by?: string | null;
updated_date?: Date | null;
updated_by?: string | null;
}
export interface CreateCategoryRequest {
categoryCode: string;
categoryName: string;
categoryNameEng?: string;
description?: string;
sortOrder?: number;
}
export interface UpdateCategoryRequest {
categoryName?: string;
categoryNameEng?: string;
description?: string;
sortOrder?: number;
isActive?: boolean;
}
export interface CreateCodeRequest {
codeValue: string;
codeName: string;
codeNameEng?: string;
description?: string;
sortOrder?: number;
}
export interface UpdateCodeRequest {
codeName?: string;
codeNameEng?: string;
description?: string;
sortOrder?: number;
isActive?: boolean;
}
export interface CodeOption {
value: string;
label: string;
labelEng?: string | null;
}
export interface ReorderCodesRequest {
codes: Array<{
codeValue: string;
sortOrder: number;
}>;
}
export interface GetCategoriesQuery {
search?: string;
isActive?: string;
page?: string;
size?: string;
}
export interface GetCodesQuery {
search?: string;
isActive?: string;
}
export interface ApiResponse<T = any> {
success: boolean;
data?: T;
message: string;
error?: string;
total?: number;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,57 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CodeCategoryPanel } from "@/components/admin/CodeCategoryPanel";
import { CodeDetailPanel } from "@/components/admin/CodeDetailPanel";
import { useSelectedCategory } from "@/hooks/useSelectedCategory";
// import { useMultiLang } from "@/hooks/useMultiLang"; // 무한 루프 방지를 위해 임시 제거
export default function CommonCodeManagementPage() {
// const { getText } = useMultiLang(); // 무한 루프 방지를 위해 임시 제거
const { selectedCategoryCode, selectCategory } = useSelectedCategory();
return (
<div className="container mx-auto space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground"> </p>
</div>
</div>
{/* 메인 콘텐츠 */}
{/* 반응형 레이아웃: PC는 가로, 모바일은 세로 */}
<div className="flex flex-col gap-6 lg:flex-row lg:gap-8">
{/* 카테고리 패널 - PC에서 좌측 고정 너비, 모바일에서 전체 너비 */}
<div className="w-full lg:w-80 lg:flex-shrink-0">
<Card className="h-full">
<CardHeader>
<CardTitle className="flex items-center gap-2">📂 </CardTitle>
</CardHeader>
<CardContent className="p-0">
<CodeCategoryPanel selectedCategoryCode={selectedCategoryCode} onSelectCategory={selectCategory} />
</CardContent>
</Card>
</div>
{/* 코드 상세 패널 - PC에서 나머지 공간, 모바일에서 전체 너비 */}
<div className="min-w-0 flex-1">
<Card className="h-fit">
<CardHeader>
<CardTitle className="flex items-center gap-2">
📋
{selectedCategoryCode && (
<span className="text-muted-foreground text-sm font-normal">({selectedCategoryCode})</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<CodeDetailPanel categoryCode={selectedCategoryCode} />
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@ -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({
</head>
<body className={`${inter.variable} ${jetbrainsMono.variable} h-full bg-white font-sans antialiased`}>
<div id="root" className="h-full">
{children}
<QueryProvider>
{children}
</QueryProvider>
</div>
</body>
</html>

View File

@ -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 (
<div
className={cn(
"group cursor-pointer rounded-lg border p-3 transition-all hover:shadow-sm",
isSelected ? "border-gray-300 bg-gray-100" : "border-gray-200 bg-white hover:bg-gray-50",
)}
onClick={onSelect}
>
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="font-medium text-gray-900">{category.category_name}</h3>
<Badge
variant={category.is_active === "Y" ? "default" : "secondary"}
className={cn(
"cursor-pointer transition-colors",
category.is_active === "Y"
? "bg-green-100 text-green-800 hover:bg-green-200 hover:text-green-900"
: "bg-gray-100 text-gray-600 hover:bg-gray-200 hover:text-gray-700",
updateCategoryMutation.isPending && "cursor-not-allowed opacity-50",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!updateCategoryMutation.isPending) {
handleToggleActive(category.is_active !== "Y");
}
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{category.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</div>
<p className="mt-1 text-sm text-gray-600">{category.category_code}</p>
{category.description && <p className="mt-1 text-sm text-gray-500">{category.description}</p>}
</div>
{/* 액션 버튼 */}
{isSelected && (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Button size="sm" variant="ghost" onClick={onEdit}>
<Edit className="h-3 w-3" />
</Button>
<Button size="sm" variant="ghost" onClick={onDelete}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,378 @@
"use client";
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 { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { ValidationMessage } from "@/components/common/ValidationMessage";
import { useCreateCategory, useUpdateCategory } from "@/hooks/queries/useCategories";
import type { CodeCategory } from "@/types/commonCode";
import { useCheckCategoryDuplicate } from "@/hooks/queries/useValidation";
import { useFormValidation } from "@/hooks/useFormValidation";
import {
createCategorySchema,
updateCategorySchema,
type CreateCategoryData,
type UpdateCategoryData,
} from "@/lib/schemas/commonCode";
interface CodeCategoryFormModalProps {
isOpen: boolean;
onClose: () => void;
editingCategoryCode?: string;
categories: CodeCategory[];
}
export function CodeCategoryFormModal({
isOpen,
onClose,
editingCategoryCode,
categories,
}: CodeCategoryFormModalProps) {
const createCategoryMutation = useCreateCategory();
const updateCategoryMutation = useUpdateCategory();
const isEditing = !!editingCategoryCode;
const editingCategory = categories.find((c) => c.category_code === editingCategoryCode);
// 검증 상태 관리
const formValidation = useFormValidation({
fields: ["categoryCode", "categoryName", "categoryNameEng", "description"],
});
// 중복 검사 훅들
const categoryCodeCheck = useCheckCategoryDuplicate(
"categoryCode",
formValidation.getFieldValue("categoryCode"),
isEditing ? editingCategoryCode : undefined,
formValidation.isFieldValidated("categoryCode"),
);
const categoryNameCheck = useCheckCategoryDuplicate(
"categoryName",
formValidation.getFieldValue("categoryName"),
isEditing ? editingCategoryCode : undefined,
formValidation.isFieldValidated("categoryName"),
);
const categoryNameEngCheck = useCheckCategoryDuplicate(
"categoryNameEng",
formValidation.getFieldValue("categoryNameEng"),
isEditing ? editingCategoryCode : undefined,
formValidation.isFieldValidated("categoryNameEng"),
);
// 중복 검사 결과 확인 (수정 시에는 카테고리 코드 검사 제외)
const hasDuplicateErrors =
(!isEditing && categoryCodeCheck.data?.isDuplicate && formValidation.isFieldValidated("categoryCode")) ||
(categoryNameCheck.data?.isDuplicate && formValidation.isFieldValidated("categoryName")) ||
(categoryNameEngCheck.data?.isDuplicate && formValidation.isFieldValidated("categoryNameEng"));
// 중복 검사 로딩 중인지 확인 (수정 시에는 카테고리 코드 검사 제외)
const isDuplicateChecking =
(!isEditing && categoryCodeCheck.isLoading) || categoryNameCheck.isLoading || categoryNameEngCheck.isLoading;
// 필수 필드들이 모두 검증되었는지 확인 (생성 시에만 적용)
// 생성과 수정을 위한 별도 폼 설정
const createForm = useForm<CreateCategoryData>({
resolver: zodResolver(createCategorySchema),
mode: "onChange",
defaultValues: {
categoryCode: "",
categoryName: "",
categoryNameEng: "",
description: "",
sortOrder: 1,
},
});
const updateForm = useForm<UpdateCategoryData>({
resolver: zodResolver(updateCategorySchema),
mode: "onChange",
defaultValues: {
categoryName: "",
categoryNameEng: "",
description: "",
sortOrder: 1,
isActive: "Y",
},
});
// 폼은 조건부로 직접 사용
// 편집 모드일 때 기존 데이터 로드
useEffect(() => {
if (isOpen) {
if (isEditing && editingCategory) {
// 수정 모드: 기존 데이터 로드
updateForm.reset({
categoryName: editingCategory.category_name,
categoryNameEng: editingCategory.category_name_eng || "",
description: editingCategory.description || "",
sortOrder: editingCategory.sort_order,
isActive: editingCategory.is_active as "Y" | "N", // 타입 안전한 캐스팅
});
} else {
// 새 카테고리 모드: 자동 순서 계산
const maxSortOrder = categories.length > 0 ? Math.max(...categories.map((c) => c.sort_order)) : 0;
createForm.reset({
categoryCode: "",
categoryName: "",
categoryNameEng: "",
description: "",
sortOrder: maxSortOrder + 1,
});
}
}
}, [isOpen, isEditing, editingCategory, categories]);
const handleSubmit = isEditing
? updateForm.handleSubmit(async (data) => {
try {
await updateCategoryMutation.mutateAsync({
categoryCode: editingCategoryCode!,
data: data as UpdateCategoryData,
});
onClose();
updateForm.reset();
} catch (error) {
console.error("카테고리 수정 실패:", error);
}
})
: createForm.handleSubmit(async (data) => {
try {
await createCategoryMutation.mutateAsync(data as CreateCategoryData);
onClose();
createForm.reset();
} catch (error) {
console.error("카테고리 생성 실패:", error);
}
});
const isLoading = createCategoryMutation.isPending || updateCategoryMutation.isPending;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{isEditing ? "카테고리 수정" : "새 카테고리"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{/* 카테고리 코드 */}
{!isEditing && (
<div className="space-y-2">
<Label htmlFor="categoryCode"> *</Label>
<Input
id="categoryCode"
{...createForm.register("categoryCode")}
disabled={isLoading}
placeholder="카테고리 코드를 입력하세요"
className={createForm.formState.errors.categoryCode ? "border-red-500" : ""}
onBlur={formValidation.createBlurHandler("categoryCode")}
/>
{createForm.formState.errors.categoryCode && (
<p className="text-sm text-red-600">{createForm.formState.errors.categoryCode.message}</p>
)}
{!createForm.formState.errors.categoryCode && (
<ValidationMessage
message={categoryCodeCheck.data?.message}
isValid={!categoryCodeCheck.data?.isDuplicate}
isLoading={categoryCodeCheck.isLoading}
/>
)}
</div>
)}
{/* 카테고리 코드 표시 (수정 시) */}
{isEditing && editingCategory && (
<div className="space-y-2">
<Label htmlFor="categoryCodeDisplay"> </Label>
<Input id="categoryCodeDisplay" value={editingCategory.category_code} disabled className="bg-gray-50" />
<p className="text-sm text-gray-500"> .</p>
</div>
)}
{/* 카테고리명 */}
<div className="space-y-2">
<Label htmlFor="categoryName"> *</Label>
<Input
id="categoryName"
{...(isEditing ? updateForm.register("categoryName") : createForm.register("categoryName"))}
disabled={isLoading}
placeholder="카테고리명을 입력하세요"
className={
isEditing
? updateForm.formState.errors.categoryName
? "border-red-500"
: ""
: createForm.formState.errors.categoryName
? "border-red-500"
: ""
}
onBlur={formValidation.createBlurHandler("categoryName")}
/>
{isEditing
? updateForm.formState.errors.categoryName && (
<p className="text-sm text-red-600">{updateForm.formState.errors.categoryName.message}</p>
)
: createForm.formState.errors.categoryName && (
<p className="text-sm text-red-600">{createForm.formState.errors.categoryName.message}</p>
)}
{!(isEditing ? updateForm.formState.errors.categoryName : createForm.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>
<Input
id="categoryNameEng"
{...(isEditing ? updateForm.register("categoryNameEng") : createForm.register("categoryNameEng"))}
disabled={isLoading}
placeholder="카테고리 영문명을 입력하세요"
className={
isEditing
? updateForm.formState.errors.categoryNameEng
? "border-red-500"
: ""
: createForm.formState.errors.categoryNameEng
? "border-red-500"
: ""
}
onBlur={formValidation.createBlurHandler("categoryNameEng")}
/>
{isEditing
? updateForm.formState.errors.categoryNameEng && (
<p className="text-sm text-red-600">{updateForm.formState.errors.categoryNameEng.message}</p>
)
: createForm.formState.errors.categoryNameEng && (
<p className="text-sm text-red-600">{createForm.formState.errors.categoryNameEng.message}</p>
)}
{!(isEditing
? updateForm.formState.errors.categoryNameEng
: createForm.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>
<Textarea
id="description"
{...(isEditing ? updateForm.register("description") : createForm.register("description"))}
disabled={isLoading}
placeholder="설명을 입력하세요"
rows={3}
className={
isEditing
? updateForm.formState.errors.description
? "border-red-500"
: ""
: createForm.formState.errors.description
? "border-red-500"
: ""
}
onBlur={formValidation.createBlurHandler("description")}
/>
{isEditing
? updateForm.formState.errors.description && (
<p className="text-sm text-red-600">{updateForm.formState.errors.description.message}</p>
)
: createForm.formState.errors.description && (
<p className="text-sm text-red-600">{createForm.formState.errors.description.message}</p>
)}
</div>
{/* 정렬 순서 */}
<div className="space-y-2">
<Label htmlFor="sortOrder"> </Label>
<Input
id="sortOrder"
type="number"
{...(isEditing
? updateForm.register("sortOrder", { valueAsNumber: true })
: createForm.register("sortOrder", { valueAsNumber: true }))}
disabled={isLoading}
min={1}
className={
isEditing
? updateForm.formState.errors.sortOrder
? "border-red-500"
: ""
: createForm.formState.errors.sortOrder
? "border-red-500"
: ""
}
/>
{isEditing
? updateForm.formState.errors.sortOrder && (
<p className="text-sm text-red-600">{updateForm.formState.errors.sortOrder.message}</p>
)
: createForm.formState.errors.sortOrder && (
<p className="text-sm text-red-600">{createForm.formState.errors.sortOrder.message}</p>
)}
</div>
{/* 활성 상태 (수정 시에만) */}
{isEditing && (
<div className="flex items-center space-x-2">
<Switch
id="isActive"
checked={updateForm.watch("isActive") === "Y"}
onCheckedChange={(checked) => updateForm.setValue("isActive", checked ? "Y" : "N")}
disabled={isLoading}
/>
<Label htmlFor="isActive">{updateForm.watch("isActive") === "Y" ? "활성" : "비활성"}</Label>
</div>
)}
{/* 버튼 */}
<div className="flex justify-end space-x-2 pt-4">
<Button type="button" variant="outline" onClick={onClose} disabled={isLoading}>
</Button>
<Button
type="submit"
disabled={
isLoading ||
!(isEditing ? updateForm.formState.isValid : createForm.formState.isValid) ||
hasDuplicateErrors ||
isDuplicateChecking
}
>
{isLoading ? (
<>
<LoadingSpinner size="sm" className="mr-2" />
{isEditing ? "수정 중..." : "저장 중..."}
</>
) : isEditing ? (
"카테고리 수정"
) : (
"카테고리 저장"
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,196 @@
"use client";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { CodeCategoryFormModal } from "./CodeCategoryFormModal";
import { CategoryItem } from "./CategoryItem";
import { AlertModal } from "@/components/common/AlertModal";
import { Search, Plus } from "lucide-react";
import { useDeleteCategory } from "@/hooks/queries/useCategories";
import { useCategoriesInfinite } from "@/hooks/queries/useCategoriesInfinite";
interface CodeCategoryPanelProps {
selectedCategoryCode: string;
onSelectCategory: (categoryCode: string) => void;
}
export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: CodeCategoryPanelProps) {
// 검색 및 필터 상태 (먼저 선언)
const [searchTerm, setSearchTerm] = useState("");
const [showActiveOnly, setShowActiveOnly] = useState(false);
// React Query로 카테고리 데이터 관리 (무한 스크롤)
const {
data: categories = [],
isLoading,
error,
handleScroll,
isFetchingNextPage,
hasNextPage,
} = useCategoriesInfinite({
search: searchTerm || undefined,
active: showActiveOnly || undefined, // isActive -> active로 수정
});
const deleteCategoryMutation = useDeleteCategory();
// 모달 상태
const [showFormModal, setShowFormModal] = useState(false);
const [editingCategory, setEditingCategory] = useState<string>("");
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deletingCategory, setDeletingCategory] = useState<string>("");
// 새 카테고리 생성
const handleNewCategory = () => {
setEditingCategory("");
setShowFormModal(true);
};
// 카테고리 수정
const handleEditCategory = (categoryCode: string) => {
setEditingCategory(categoryCode);
setShowFormModal(true);
};
// 카테고리 삭제 확인
const handleDeleteCategory = (categoryCode: string) => {
setDeletingCategory(categoryCode);
setShowDeleteModal(true);
};
// 카테고리 삭제 실행
const handleConfirmDelete = async () => {
if (!deletingCategory) return;
try {
await deleteCategoryMutation.mutateAsync(deletingCategory);
// 삭제된 카테고리가 선택된 상태라면 선택 해제
if (selectedCategoryCode === deletingCategory) {
onSelectCategory("");
}
setShowDeleteModal(false);
setDeletingCategory("");
} catch (error) {
console.error("카테고리 삭제 실패:", error);
}
};
if (error) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-red-600"> .</p>
<Button variant="outline" onClick={() => window.location.reload()} className="mt-2">
</Button>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col">
{/* 검색 및 필터 */}
<div className="border-b p-4">
<div className="space-y-3">
{/* 검색 */}
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="카테고리 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
{/* 활성 필터 */}
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="activeOnly"
checked={showActiveOnly}
onChange={(e) => setShowActiveOnly(e.target.checked)}
className="rounded border-gray-300"
/>
<label htmlFor="activeOnly" className="text-sm text-gray-600">
</label>
</div>
{/* 새 카테고리 버튼 */}
<Button onClick={handleNewCategory} className="w-full" size="sm">
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
{/* 카테고리 목록 (무한 스크롤) */}
<div className="h-96 overflow-y-auto" onScroll={handleScroll}>
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<LoadingSpinner />
</div>
) : categories.length === 0 ? (
<div className="p-4 text-center text-gray-500">
{searchTerm ? "검색 결과가 없습니다." : "카테고리가 없습니다."}
</div>
) : (
<>
<div className="space-y-1 p-2">
{categories.map((category, index) => (
<CategoryItem
key={`${category.category_code}-${index}`}
category={category}
isSelected={selectedCategoryCode === category.category_code}
onSelect={() => onSelectCategory(category.category_code)}
onEdit={() => handleEditCategory(category.category_code)}
onDelete={() => handleDeleteCategory(category.category_code)}
/>
))}
</div>
{/* 추가 로딩 표시 */}
{isFetchingNextPage && (
<div className="flex justify-center py-4">
<LoadingSpinner size="sm" />
<span className="ml-2 text-sm text-gray-500"> ...</span>
</div>
)}
{/* 더 이상 데이터가 없을 때 */}
{!hasNextPage && categories.length > 0 && (
<div className="py-4 text-center text-sm text-gray-400"> .</div>
)}
</>
)}
</div>
{/* 카테고리 폼 모달 */}
{showFormModal && (
<CodeCategoryFormModal
isOpen={showFormModal}
onClose={() => setShowFormModal(false)}
editingCategoryCode={editingCategory}
categories={categories}
/>
)}
{/* 삭제 확인 모달 */}
{showDeleteModal && (
<AlertModal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
type="error"
title="카테고리 삭제"
message="정말로 이 카테고리를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
confirmText="삭제"
onConfirm={handleConfirmDelete}
/>
)}
</div>
);
}

View File

@ -0,0 +1,282 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { CodeFormModal } from "./CodeFormModal";
import { SortableCodeItem } from "./SortableCodeItem";
import { AlertModal } from "@/components/common/AlertModal";
import { Search, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { useDeleteCode, useReorderCodes } from "@/hooks/queries/useCodes";
import { useCodesInfinite } from "@/hooks/queries/useCodesInfinite";
import type { CodeInfo } from "@/types/commonCode";
// Drag and Drop
import { DndContext, DragOverlay } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useDragAndDrop } from "@/hooks/useDragAndDrop";
import { useSearchAndFilter } from "@/hooks/useSearchAndFilter";
interface CodeDetailPanelProps {
categoryCode: string;
}
export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
// 검색 및 필터 상태 (먼저 선언)
const [searchTerm, setSearchTerm] = useState("");
const [showActiveOnly, setShowActiveOnly] = useState(false);
// React Query로 코드 데이터 관리 (무한 스크롤)
const {
data: codes = [],
isLoading,
error,
handleScroll,
isFetchingNextPage,
hasNextPage,
} = useCodesInfinite(categoryCode, {
search: searchTerm || undefined,
active: showActiveOnly || undefined,
});
const deleteCodeMutation = useDeleteCode();
const reorderCodesMutation = useReorderCodes();
// 드래그앤드롭을 위해 필터링된 코드 목록 사용
const { filteredItems: filteredCodes } = useSearchAndFilter(codes, {
searchFields: ["code_name", "code_value"],
});
// 모달 상태
const [showFormModal, setShowFormModal] = useState(false);
const [editingCode, setEditingCode] = useState<CodeInfo | null>(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deletingCode, setDeletingCode] = useState<CodeInfo | null>(null);
// 드래그 앤 드롭 훅 사용
const dragAndDrop = useDragAndDrop<CodeInfo>({
items: filteredCodes,
onReorder: async (reorderedItems) => {
await reorderCodesMutation.mutateAsync({
categoryCode,
codes: reorderedItems.map((item) => ({
codeValue: item.id,
sortOrder: item.sortOrder,
})),
});
},
getItemId: (code: CodeInfo) => code.code_value,
});
// 새 코드 생성
const handleNewCode = () => {
setEditingCode(null);
setShowFormModal(true);
};
// 코드 수정
const handleEditCode = (code: CodeInfo) => {
setEditingCode(code);
setShowFormModal(true);
};
// 코드 삭제 확인
const handleDeleteCode = (code: CodeInfo) => {
setDeletingCode(code);
setShowDeleteModal(true);
};
// 코드 삭제 실행
const handleConfirmDelete = async () => {
if (!deletingCode) return;
try {
await deleteCodeMutation.mutateAsync({
categoryCode,
codeValue: deletingCode.code_value,
});
setShowDeleteModal(false);
setDeletingCode(null);
} catch (error) {
console.error("코드 삭제 실패:", error);
}
};
// 드래그 앤 드롭 로직은 useDragAndDrop 훅에서 처리
if (!categoryCode) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-gray-500">
<p> </p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-red-600"> .</p>
<Button variant="outline" onClick={() => window.location.reload()} className="mt-2">
</Button>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col">
{/* 검색 및 필터 */}
<div className="border-b p-4">
<div className="space-y-3">
{/* 검색 */}
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="코드 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
{/* 활성 필터 */}
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="activeOnlyCodes"
checked={showActiveOnly}
onChange={(e) => setShowActiveOnly(e.target.checked)}
className="rounded border-gray-300"
/>
<label htmlFor="activeOnlyCodes" className="text-sm text-gray-600">
</label>
</div>
{/* 새 코드 버튼 */}
<Button onClick={handleNewCode} className="w-full" size="sm">
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
{/* 코드 목록 (무한 스크롤) */}
<div className="h-96 overflow-y-auto" onScroll={handleScroll}>
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<LoadingSpinner />
</div>
) : filteredCodes.length === 0 ? (
<div className="p-4 text-center text-gray-500">
{codes.length === 0 ? "코드가 없습니다." : "검색 결과가 없습니다."}
</div>
) : (
<>
<div className="p-2">
<DndContext {...dragAndDrop.dndContextProps}>
<SortableContext
items={filteredCodes.map((code) => code.code_value)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{filteredCodes.map((code, index) => (
<SortableCodeItem
key={`${code.code_value}-${index}`}
code={code}
categoryCode={categoryCode}
onEdit={() => handleEditCode(code)}
onDelete={() => handleDeleteCode(code)}
/>
))}
</div>
</SortableContext>
<DragOverlay dropAnimation={null}>
{dragAndDrop.activeItem ? (
<div className="cursor-grabbing rounded-lg border border-gray-300 bg-white p-3 shadow-lg">
{(() => {
const activeCode = dragAndDrop.activeItem;
if (!activeCode) return null;
return (
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="font-medium text-gray-900">{activeCode.code_name}</h3>
<Badge
variant={activeCode.is_active === "Y" ? "default" : "secondary"}
className={cn(
"transition-colors",
activeCode.is_active === "Y"
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-600",
)}
>
{activeCode.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</div>
<p className="mt-1 text-sm text-gray-600">{activeCode.code_value}</p>
{activeCode.description && (
<p className="mt-1 text-sm text-gray-500">{activeCode.description}</p>
)}
</div>
</div>
);
})()}
</div>
) : null}
</DragOverlay>
</DndContext>
</div>
{/* 무한 스크롤 로딩 인디케이터 */}
{isFetchingNextPage && (
<div className="flex items-center justify-center py-4">
<LoadingSpinner size="sm" />
<span className="ml-2 text-sm text-gray-500"> ...</span>
</div>
)}
{/* 모든 코드 로드 완료 메시지 */}
{!hasNextPage && codes.length > 0 && (
<div className="py-4 text-center text-sm text-gray-500"> .</div>
)}
</>
)}
</div>
{/* 코드 폼 모달 */}
{showFormModal && (
<CodeFormModal
isOpen={showFormModal}
onClose={() => {
setShowFormModal(false);
setEditingCode(null);
}}
categoryCode={categoryCode}
editingCode={editingCode}
codes={codes}
/>
)}
{/* 삭제 확인 모달 */}
{showDeleteModal && (
<AlertModal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
type="error"
title="코드 삭제"
message="정말로 이 코드를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
confirmText="삭제"
onConfirm={handleConfirmDelete}
/>
)}
</div>
);
}

View File

@ -0,0 +1,326 @@
"use client";
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";
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 { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { ValidationMessage } from "@/components/common/ValidationMessage";
import { 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";
interface CodeFormModalProps {
isOpen: boolean;
onClose: () => void;
categoryCode: string;
editingCode?: CodeInfo | null;
codes: CodeInfo[];
}
// 에러 메시지를 안전하게 문자열로 변환하는 헬퍼 함수
const getErrorMessage = (error: FieldError | undefined): string => {
if (!error) return "";
if (typeof error === "string") return error;
return error.message || "";
};
export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, codes }: CodeFormModalProps) {
const createCodeMutation = useCreateCode();
const updateCodeMutation = useUpdateCode();
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;
const form = useForm({
resolver: zodResolver(schema),
mode: "onChange", // 실시간 검증 활성화
defaultValues: {
codeValue: "",
codeName: "",
codeNameEng: "",
description: "",
sortOrder: 1,
...(isEditing && { isActive: "Y" as const }),
},
});
// 편집 모드일 때 기존 데이터 로드
useEffect(() => {
if (isOpen) {
if (isEditing && editingCode) {
// 수정 모드: 기존 데이터 로드 (codeValue는 표시용으로만 설정)
form.reset({
codeName: editingCode.code_name,
codeNameEng: editingCode.code_name_eng || "",
description: editingCode.description || "",
sortOrder: editingCode.sort_order,
isActive: editingCode.is_active as "Y" | "N", // 타입 캐스팅
});
// codeValue는 별도로 설정 (표시용)
form.setValue("codeValue" as any, editingCode.code_value);
} else {
// 새 코드 모드: 자동 순서 계산
const maxSortOrder = codes.length > 0 ? Math.max(...codes.map((c) => c.sort_order)) : 0;
form.reset({
codeValue: "",
codeName: "",
codeNameEng: "",
description: "",
sortOrder: maxSortOrder + 1,
});
}
}
}, [isOpen, isEditing, editingCode, codes]);
const handleSubmit = form.handleSubmit(async (data) => {
try {
if (isEditing && editingCode) {
// 수정
await updateCodeMutation.mutateAsync({
categoryCode,
codeValue: editingCode.code_value,
data: data as UpdateCodeData,
});
} else {
// 생성
await createCodeMutation.mutateAsync({
categoryCode,
data: data as CreateCodeData,
});
}
onClose();
form.reset();
} catch (error) {
console.error("코드 저장 실패:", error);
}
});
const isLoading = createCodeMutation.isPending || updateCodeMutation.isPending;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{isEditing ? "코드 수정" : "새 코드"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{/* 코드값 */}
<div className="space-y-2">
<Label htmlFor="codeValue"> *</Label>
<Input
id="codeValue"
{...form.register("codeValue")}
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>
{/* 코드명 */}
<div className="space-y-2">
<Label htmlFor="codeName"> *</Label>
<Input
id="codeName"
{...form.register("codeName")}
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>
{/* 영문명 */}
<div className="space-y-2">
<Label htmlFor="codeNameEng"> *</Label>
<Input
id="codeNameEng"
{...form.register("codeNameEng")}
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>
{/* 설명 */}
<div className="space-y-2">
<Label htmlFor="description"> *</Label>
<Textarea
id="description"
{...form.register("description")}
disabled={isLoading}
placeholder="설명을 입력하세요"
rows={3}
className={form.formState.errors.description ? "border-red-500" : ""}
/>
{form.formState.errors.description && (
<p className="text-sm text-red-600">{getErrorMessage(form.formState.errors.description)}</p>
)}
</div>
{/* 정렬 순서 */}
<div className="space-y-2">
<Label htmlFor="sortOrder"> </Label>
<Input
id="sortOrder"
type="number"
{...form.register("sortOrder", { valueAsNumber: true })}
disabled={isLoading}
min={1}
className={form.formState.errors.sortOrder ? "border-red-500" : ""}
/>
{form.formState.errors.sortOrder && (
<p className="text-sm text-red-600">{getErrorMessage(form.formState.errors.sortOrder)}</p>
)}
</div>
{/* 활성 상태 (수정 시에만) */}
{isEditing && (
<div className="flex items-center space-x-2">
<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>
)}
{/* 버튼 */}
<div className="flex justify-end space-x-2 pt-4">
<Button type="button" variant="outline" onClick={onClose} disabled={isLoading}>
</Button>
<Button
type="submit"
disabled={isLoading || !form.formState.isValid || hasDuplicateErrors || isDuplicateChecking}
>
{isLoading ? (
<>
<LoadingSpinner size="sm" className="mr-2" />
{isEditing ? "수정 중..." : "저장 중..."}
</>
) : isEditing ? (
"코드 수정"
) : (
"코드 저장"
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,132 @@
"use client";
import React from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Edit, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useUpdateCode } from "@/hooks/queries/useCodes";
import type { CodeInfo } from "@/types/commonCode";
interface SortableCodeItemProps {
code: CodeInfo;
categoryCode: string;
onEdit: () => void;
onDelete: () => void;
isDragOverlay?: boolean;
}
export function SortableCodeItem({
code,
categoryCode,
onEdit,
onDelete,
isDragOverlay = false,
}: SortableCodeItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: code.code_value,
disabled: isDragOverlay,
});
const updateCodeMutation = useUpdateCode();
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
// 활성/비활성 토글 핸들러
const handleToggleActive = async (checked: boolean) => {
try {
await updateCodeMutation.mutateAsync({
categoryCode,
codeValue: code.code_value,
data: {
codeName: code.code_name,
codeNameEng: code.code_name_eng || "",
description: code.description || "",
sortOrder: code.sort_order,
isActive: checked ? "Y" : "N",
},
});
} catch (error) {
console.error("코드 활성 상태 변경 실패:", error);
}
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={cn(
"group cursor-grab rounded-lg border p-3 transition-all hover:shadow-sm",
"border-gray-200 bg-white hover:bg-gray-50",
isDragging && "cursor-grabbing opacity-50",
)}
>
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="font-medium text-gray-900">{code.code_name}</h3>
<Badge
variant={code.is_active === "Y" ? "default" : "secondary"}
className={cn(
"cursor-pointer transition-colors",
code.is_active === "Y"
? "bg-green-100 text-green-800 hover:bg-green-200 hover:text-green-900"
: "bg-gray-100 text-gray-600 hover:bg-gray-200 hover:text-gray-700",
updateCodeMutation.isPending && "cursor-not-allowed opacity-50",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!updateCodeMutation.isPending) {
handleToggleActive(code.is_active !== "Y");
}
}}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{code.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</div>
<p className="mt-1 text-sm text-gray-600">{code.code_value}</p>
{code.description && <p className="mt-1 text-sm text-gray-500">{code.description}</p>}
</div>
{/* 액션 버튼 */}
<div
className="flex items-center gap-1"
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onEdit();
}}
>
<Edit className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
</div>
);
}

View File

@ -29,21 +29,25 @@ const alertConfig = {
icon: CheckCircle,
iconColor: "text-green-500",
titleColor: "text-green-700",
buttonVariant: "default" as const,
},
error: {
icon: XCircle,
iconColor: "text-red-500",
titleColor: "text-red-700",
buttonVariant: "destructive" as const,
},
warning: {
icon: AlertTriangle,
iconColor: "text-yellow-500",
titleColor: "text-yellow-700",
buttonVariant: "default" as const,
},
info: {
icon: Info,
iconColor: "text-blue-500",
titleColor: "text-blue-700",
buttonVariant: "default" as const,
},
};

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

@ -6,7 +6,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className)}
className={cn("bg-card text-card-foreground flex flex-col rounded-xl border py-6 shadow-sm", className)}
{...props}
/>
);

View File

@ -11,8 +11,8 @@ function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimi
data-slot="switch"
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-sm transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"data-[state=checked]:bg-blue-500 data-[state=unchecked]:bg-gray-300",
"hover:data-[state=checked]:bg-blue-600 hover:data-[state=unchecked]:bg-gray-400",
"data-[state=checked]:bg-green-500 data-[state=unchecked]:bg-gray-300",
"hover:data-[state=checked]:bg-green-600 hover:data-[state=unchecked]:bg-gray-400",
"focus-visible:border-ring focus-visible:ring-ring/50",
className,
)}

View File

@ -0,0 +1,78 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { commonCodeApi } from "@/lib/api/commonCode";
import { queryKeys } from "@/lib/queryKeys";
import type { CategoryFilter, CreateCategoryData, UpdateCategoryData } from "@/lib/schemas/commonCode";
/**
*
*/
export function useCategories(filters?: CategoryFilter) {
return useQuery({
queryKey: queryKeys.categories.list(filters),
queryFn: () => commonCodeApi.categories.getList(filters),
select: (data) => data.data || [],
});
}
/**
*
*/
export function useCreateCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateCategoryData) => commonCodeApi.categories.create(data),
onSuccess: () => {
// 모든 카테고리 쿼리 무효화
queryClient.invalidateQueries({ queryKey: queryKeys.categories.all });
},
onError: (error) => {
console.error("카테고리 생성 실패:", error);
},
});
}
/**
*
*/
export function useUpdateCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ categoryCode, data }: { categoryCode: string; data: UpdateCategoryData }) =>
commonCodeApi.categories.update(categoryCode, data),
onSuccess: (_, variables) => {
// 해당 카테고리 상세 쿼리 무효화
queryClient.invalidateQueries({
queryKey: queryKeys.categories.detail(variables.categoryCode),
});
// 모든 카테고리 관련 쿼리 무효화 (일반 목록 + 무한 스크롤)
queryClient.invalidateQueries({ queryKey: queryKeys.categories.all });
},
onError: (error) => {
console.error("카테고리 수정 실패:", error);
},
});
}
/**
*
*/
export function useDeleteCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (categoryCode: string) => commonCodeApi.categories.delete(categoryCode),
onSuccess: (_, categoryCode) => {
// 해당 카테고리와 관련된 모든 쿼리 무효화
queryClient.invalidateQueries({ queryKey: queryKeys.categories.all });
queryClient.invalidateQueries({ queryKey: queryKeys.codes.all });
// 해당 카테고리의 캐시 제거
queryClient.removeQueries({ queryKey: queryKeys.categories.detail(categoryCode) });
},
onError: (error) => {
console.error("카테고리 삭제 실패:", error);
},
});
}

View File

@ -0,0 +1,43 @@
import { commonCodeApi } from "@/lib/api/commonCode";
import { queryKeys } from "@/lib/queryKeys";
import { useInfiniteScroll } from "@/hooks/useInfiniteScroll";
import type { CategoryFilter } from "@/lib/schemas/commonCode";
import type { CodeCategory } from "@/types/commonCode";
/**
*
*/
export function useCategoriesInfinite(filters?: CategoryFilter) {
return useInfiniteScroll<CodeCategory, CategoryFilter>({
queryKey: queryKeys.categories.infiniteList(filters),
queryFn: async ({ pageParam, ...params }) => {
// 첫 페이지는 20개, 이후는 10개씩
const pageSize = pageParam === 1 ? 20 : 10;
const response = await commonCodeApi.categories.getList({
...params,
page: pageParam,
size: pageSize,
});
return {
data: response.data || [],
total: response.total,
currentPage: pageParam,
pageSize: pageSize,
};
},
initialPageParam: 1,
pageSize: 20, // 첫 페이지 기준
params: filters,
staleTime: 5 * 60 * 1000, // 5분 캐싱
// 커스텀 getNextPageParam 제공
getNextPageParam: (lastPage, allPages, lastPageParam) => {
// 마지막 페이지의 데이터 개수가 요청한 페이지 크기보다 작으면 더 이상 페이지 없음
const currentPageSize = lastPage.pageSize || (lastPageParam === 1 ? 20 : 10);
if ((lastPage.data?.length || 0) < currentPageSize) {
return undefined;
}
return lastPageParam + 1;
},
});
}

View File

@ -0,0 +1,169 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { commonCodeApi } from "@/lib/api/commonCode";
import { queryKeys } from "@/lib/queryKeys";
import type { CodeFilter, CreateCodeData, UpdateCodeData } from "@/lib/schemas/commonCode";
/**
*
*/
export function useCodes(categoryCode: string, filters?: CodeFilter) {
return useQuery({
queryKey: queryKeys.codes.list(categoryCode, filters),
queryFn: () => commonCodeApi.codes.getList(categoryCode, filters),
select: (data) => data.data || [],
enabled: !!categoryCode, // categoryCode가 있을 때만 실행
});
}
/**
*
*/
export function useCreateCode() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ categoryCode, data }: { categoryCode: string; data: CreateCodeData }) =>
commonCodeApi.codes.create(categoryCode, data),
onSuccess: (_, variables) => {
// 해당 카테고리의 모든 코드 관련 쿼리 무효화 (일반 목록 + 무한 스크롤)
queryClient.invalidateQueries({
queryKey: queryKeys.codes.all,
});
// 무한 스크롤 쿼리도 명시적으로 무효화
queryClient.invalidateQueries({
queryKey: queryKeys.codes.infiniteList(variables.categoryCode),
});
},
onError: (error) => {
console.error("코드 생성 실패:", error);
},
});
}
/**
*
*/
export function useUpdateCode() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
categoryCode,
codeValue,
data,
}: {
categoryCode: string;
codeValue: string;
data: UpdateCodeData;
}) => commonCodeApi.codes.update(categoryCode, codeValue, data),
onSuccess: (_, variables) => {
// 해당 코드 상세 쿼리 무효화
queryClient.invalidateQueries({
queryKey: queryKeys.codes.detail(variables.categoryCode, variables.codeValue),
});
// 해당 카테고리의 모든 코드 관련 쿼리 무효화 (일반 목록 + 무한 스크롤)
queryClient.invalidateQueries({
queryKey: queryKeys.codes.all,
});
// 무한 스크롤 쿼리도 명시적으로 무효화
queryClient.invalidateQueries({
queryKey: queryKeys.codes.infiniteList(variables.categoryCode),
});
},
onError: (error) => {
console.error("코드 수정 실패:", error);
},
});
}
/**
*
*/
export function useDeleteCode() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ categoryCode, codeValue }: { categoryCode: string; codeValue: string }) =>
commonCodeApi.codes.delete(categoryCode, codeValue),
onSuccess: (_, variables) => {
// 해당 코드 관련 쿼리 무효화 및 캐시 제거
queryClient.invalidateQueries({
queryKey: queryKeys.codes.all,
});
// 무한 스크롤 쿼리도 명시적으로 무효화
queryClient.invalidateQueries({
queryKey: queryKeys.codes.infiniteList(variables.categoryCode),
});
queryClient.removeQueries({
queryKey: queryKeys.codes.detail(variables.categoryCode, variables.codeValue),
});
},
onError: (error) => {
console.error("코드 삭제 실패:", error);
},
});
}
/**
*
*/
export function useReorderCodes() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
categoryCode,
codes,
}: {
categoryCode: string;
codes: Array<{ codeValue: string; sortOrder: number }>;
}) => commonCodeApi.codes.reorder(categoryCode, codes),
onMutate: async ({ categoryCode, codes }) => {
// 진행 중인 쿼리들을 취소해서 optimistic update가 덮어쓰이지 않도록 함
await queryClient.cancelQueries({ queryKey: queryKeys.codes.list(categoryCode) });
// 이전 데이터를 백업
const previousCodes = queryClient.getQueryData(queryKeys.codes.list(categoryCode));
// Optimistic update: 새로운 순서로 즉시 업데이트
if (previousCodes && (previousCodes as any).data && Array.isArray((previousCodes as any).data)) {
const previousCodesArray = (previousCodes as any).data;
// 기존 데이터를 복사하고 sort_order만 업데이트
const updatedCodes = [...previousCodesArray].map((code: any) => {
const newCodeData = codes.find((c) => c.codeValue === code.code_value);
return newCodeData ? { ...code, sort_order: newCodeData.sortOrder } : code;
});
// sort_order로 정렬
updatedCodes.sort((a: any, b: any) => a.sort_order - b.sort_order);
// API 응답 형태로 캐시에 저장 (기존 구조 유지)
queryClient.setQueryData(queryKeys.codes.list(categoryCode), {
...(previousCodes as any),
data: updatedCodes,
});
}
// 롤백용 데이터 반환
return { previousCodes };
},
onError: (error, variables, context) => {
console.error("코드 순서 변경 실패:", error);
// 에러 시 이전 데이터로 롤백
if (context?.previousCodes) {
queryClient.setQueryData(queryKeys.codes.list(variables.categoryCode), context.previousCodes);
}
},
onSettled: (_, __, variables) => {
// 성공/실패와 관계없이 최종적으로 서버 데이터로 동기화
queryClient.invalidateQueries({
queryKey: queryKeys.codes.all,
});
// 무한 스크롤 쿼리도 명시적으로 무효화
queryClient.invalidateQueries({
queryKey: queryKeys.codes.infiniteList(variables.categoryCode),
});
},
});
}

View File

@ -0,0 +1,49 @@
import { commonCodeApi } from "@/lib/api/commonCode";
import { queryKeys } from "@/lib/queryKeys";
import { useInfiniteScroll } from "@/hooks/useInfiniteScroll";
import type { CodeFilter } from "@/lib/schemas/commonCode";
import type { CodeInfo } from "@/types/commonCode";
/**
*
*
*/
export function useCodesInfinite(categoryCode: string, filters?: CodeFilter) {
return useInfiniteScroll<CodeInfo, CodeFilter>({
queryKey: queryKeys.codes.infiniteList(categoryCode, filters),
queryFn: async ({ pageParam, ...params }) => {
// 첫 페이지는 20개, 이후는 10개씩
const pageSize = pageParam === 1 ? 20 : 10;
const response = await commonCodeApi.codes.getList(categoryCode, {
...params,
page: pageParam,
size: pageSize,
});
return {
data: response.data || [],
total: response.total,
currentPage: pageParam,
pageSize: pageSize,
};
},
initialPageParam: 1,
pageSize: 20, // 첫 페이지 기준
params: filters,
staleTime: 5 * 60 * 1000, // 5분 캐싱
enabled: !!categoryCode, // categoryCode가 있을 때만 실행
// 커스텀 getNextPageParam 제공
getNextPageParam: (lastPage, allPages, lastPageParam) => {
// 마지막 페이지의 데이터 개수가 요청한 페이지 크기보다 작으면 더 이상 페이지 없음
const currentPageSize = lastPageParam === 1 ? 20 : 10;
const dataLength = lastPage.data?.length || 0;
// 받은 데이터가 요청한 크기보다 작으면 마지막 페이지
if (dataLength < currentPageSize) {
return undefined;
}
return lastPageParam + 1;
},
});
}

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

@ -0,0 +1,92 @@
import { useState } from "react";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragStartEvent,
type DragEndEvent,
} from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
export interface DragAndDropItem {
[key: string]: any;
}
export interface UseDragAndDropProps<T extends DragAndDropItem> {
items: T[];
onReorder: (reorderedItems: Array<{ id: string; sortOrder: number }>) => Promise<void>;
getItemId: (item: T) => string;
}
export function useDragAndDrop<T extends DragAndDropItem>({ items, onReorder, getItemId }: UseDragAndDropProps<T>) {
const [activeId, setActiveId] = useState<string | null>(null);
// 드래그 센서 설정
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // 8px 이동 후 드래그 시작
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
// 드래그 시작 핸들러
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
// 드래그 종료 핸들러
const handleDragEnd = async (event: DragEndEvent) => {
setActiveId(null);
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = items.findIndex((item) => getItemId(item) === active.id);
const newIndex = items.findIndex((item) => getItemId(item) === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
const newOrder = arrayMove(items, oldIndex, newIndex);
// 순서 업데이트를 위한 데이터 준비
const reorderedItems = newOrder.map((item, index) => ({
id: getItemId(item),
sortOrder: index + 1,
}));
try {
await onReorder(reorderedItems);
} catch (error) {
console.error("순서 변경 실패:", error);
}
}
}
};
// 현재 드래그 중인 아이템 찾기
const activeItem = activeId ? items.find((item) => getItemId(item) === activeId) : null;
return {
// 상태
activeId,
activeItem,
// 센서 및 핸들러
sensors,
handleDragStart,
handleDragEnd,
// DndContext props
dndContextProps: {
sensors,
collisionDetection: closestCenter,
onDragStart: handleDragStart,
onDragEnd: handleDragEnd,
},
};
}

View File

@ -0,0 +1,89 @@
import { useState } from "react";
export interface ValidationState {
enabled: boolean;
value: string;
}
export interface ValidationStates {
[fieldName: string]: ValidationState;
}
export interface UseFormValidationProps {
fields: string[];
initialStates?: Partial<ValidationStates>;
}
export function useFormValidation({ fields, initialStates = {} }: UseFormValidationProps) {
// 검증 상태 초기화
const initValidationStates = (): ValidationStates => {
const states: ValidationStates = {};
fields.forEach((field) => {
states[field] = initialStates[field] || { enabled: false, value: "" };
});
return states;
};
const [validationStates, setValidationStates] = useState<ValidationStates>(initValidationStates);
// 특정 필드의 검증 상태 업데이트
const updateFieldValidation = (fieldName: string, value: string) => {
setValidationStates((prev) => ({
...prev,
[fieldName]: { enabled: true, value: value.trim() },
}));
};
// onBlur 핸들러 생성
const createBlurHandler =
(fieldName: string) => (event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const value = event.target.value.trim();
if (value) {
updateFieldValidation(fieldName, value);
}
};
// 모든 필수 필드가 검증되었는지 확인
const areAllFieldsValidated = (requiredFields?: string[]) => {
const fieldsToCheck = requiredFields || fields;
return fieldsToCheck.every((field) => validationStates[field]?.enabled);
};
// 검증 상태 초기화
const resetValidation = (newStates?: Partial<ValidationStates>) => {
if (newStates) {
setValidationStates((prev) => {
const updated = { ...prev };
Object.entries(newStates).forEach(([key, value]) => {
if (value !== undefined) {
updated[key] = value;
}
});
return updated;
});
} else {
setValidationStates(initValidationStates());
}
};
// 특정 필드 검증 상태 확인
const isFieldValidated = (fieldName: string) => validationStates[fieldName]?.enabled || false;
// 필드 값 가져오기
const getFieldValue = (fieldName: string) => validationStates[fieldName]?.value || "";
return {
// 상태
validationStates,
// 액션
updateFieldValidation,
resetValidation,
// 유틸리티
createBlurHandler,
areAllFieldsValidated,
isFieldValidated,
getFieldValue,
};
}

View File

@ -0,0 +1,125 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { useMemo, useCallback } from "react";
export interface InfiniteScrollConfig<TData, TParams = Record<string, any>> {
queryKey: any[];
queryFn: (params: { pageParam: number } & TParams) => Promise<{
data: TData[];
total?: number;
hasMore?: boolean;
}>;
initialPageParam?: number;
getNextPageParam?: (lastPage: any, allPages: any[], lastPageParam: number) => number | undefined;
pageSize?: number;
enabled?: boolean;
staleTime?: number;
params?: TParams;
}
export function useInfiniteScroll<TData, TParams = Record<string, any>>({
queryKey,
queryFn,
initialPageParam = 1,
getNextPageParam,
pageSize = 20,
enabled = true,
staleTime = 5 * 60 * 1000, // 5분
params = {} as TParams,
}: InfiniteScrollConfig<TData, TParams>) {
// React Query의 useInfiniteQuery 사용
const infiniteQuery = useInfiniteQuery({
queryKey: [...queryKey, params],
queryFn: ({ pageParam }) => queryFn({ pageParam, ...params }),
initialPageParam,
getNextPageParam:
getNextPageParam ||
((lastPage, allPages, lastPageParam) => {
// 기본 페이지네이션 로직
if (lastPage.data.length < pageSize) {
return undefined; // 더 이상 페이지 없음
}
return lastPageParam + 1;
}),
enabled,
staleTime,
});
// 모든 페이지의 데이터를 평탄화
const flatData = useMemo(() => {
if (!infiniteQuery.data?.pages) return [];
const allData = infiniteQuery.data.pages.flatMap((page) => page.data);
// 중복 제거 - code_value 또는 category_code를 기준으로
const uniqueData = allData.filter((item, index, self) => {
const key = (item as any).code_value || (item as any).category_code;
if (!key) return true; // key가 없으면 그대로 유지
return (
index ===
self.findIndex((t) => {
const tKey = (t as any).code_value || (t as any).category_code;
return tKey === key;
})
);
});
return uniqueData;
}, [infiniteQuery.data]);
// 총 개수 계산 (첫 번째 페이지의 total 사용)
const totalCount = useMemo(() => {
return infiniteQuery.data?.pages[0]?.total || 0;
}, [infiniteQuery.data]);
// 다음 페이지 로드 함수
const loadMore = useCallback(() => {
if (infiniteQuery.hasNextPage && !infiniteQuery.isFetchingNextPage) {
infiniteQuery.fetchNextPage();
}
}, [infiniteQuery]);
// 스크롤 이벤트 핸들러
const handleScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
// 스크롤이 하단에서 100px 이내에 도달하면 다음 페이지 로드
if (scrollHeight - scrollTop <= clientHeight + 100) {
loadMore();
}
},
[loadMore],
);
// 무한 스크롤 상태 정보
const infiniteScrollState = {
// 데이터
data: flatData,
totalCount,
// 로딩 상태
isLoading: infiniteQuery.isLoading,
isFetchingNextPage: infiniteQuery.isFetchingNextPage,
hasNextPage: infiniteQuery.hasNextPage,
// 에러 상태
error: infiniteQuery.error,
isError: infiniteQuery.isError,
// 기타 상태
isSuccess: infiniteQuery.isSuccess,
isFetching: infiniteQuery.isFetching,
};
return {
...infiniteScrollState,
loadMore,
handleScroll,
refetch: infiniteQuery.refetch,
invalidate: infiniteQuery.refetch,
};
}
// 편의를 위한 타입 정의
export type InfiniteScrollReturn<TData> = ReturnType<typeof useInfiniteScroll<TData>>;

View File

@ -0,0 +1,81 @@
import { useState, useMemo } from "react";
export interface SearchAndFilterOptions {
searchFields: string[];
initialSearchTerm?: string;
initialShowActiveOnly?: boolean;
}
export interface SearchAndFilterResult<T> {
// 상태
searchTerm: string;
showActiveOnly: boolean;
filteredItems: T[];
// 액션
setSearchTerm: (term: string) => void;
setShowActiveOnly: (show: boolean) => void;
// 유틸리티
clearSearch: () => void;
toggleActiveFilter: () => void;
}
/**
*
* @param items
* @param options ( , )
*/
export function useSearchAndFilter<T extends { is_active: string }>(
items: T[],
options: SearchAndFilterOptions,
): SearchAndFilterResult<T> {
const { searchFields, initialSearchTerm = "", initialShowActiveOnly = false } = options;
// 검색 및 필터 상태
const [searchTerm, setSearchTerm] = useState(initialSearchTerm);
const [showActiveOnly, setShowActiveOnly] = useState(initialShowActiveOnly);
// 필터링된 아이템 계산
const filteredItems = useMemo(() => {
return items.filter((item) => {
// 검색 조건 확인
const matchesSearch =
searchTerm.trim() === "" ||
searchFields.some((field) => {
const fieldValue = (item as any)[field];
return fieldValue && fieldValue.toString().toLowerCase().includes(searchTerm.toLowerCase());
});
// 활성 상태 조건 확인
const matchesActive = !showActiveOnly || item.is_active === "Y";
return matchesSearch && matchesActive;
});
}, [items, searchTerm, showActiveOnly, searchFields]);
// 검색어 초기화
const clearSearch = () => {
setSearchTerm("");
};
// 활성 필터 토글
const toggleActiveFilter = () => {
setShowActiveOnly((prev) => !prev);
};
return {
// 상태
searchTerm,
showActiveOnly,
filteredItems,
// 액션
setSearchTerm,
setShowActiveOnly,
// 유틸리티
clearSearch,
toggleActiveFilter,
};
}

View File

@ -0,0 +1,51 @@
import { useState, useCallback, useMemo } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/queryKeys";
import type { CategoryInfo } from "@/types/commonCode";
/**
*
* React Query
*/
export function useSelectedCategory() {
const queryClient = useQueryClient();
const [selectedCategoryCode, setSelectedCategoryCode] = useState<string>("");
// 현재 선택된 카테고리 정보를 React Query 캐시에서 가져오기
const selectedCategory = useMemo(() => {
if (!selectedCategoryCode) {
return null;
}
const categories = queryClient.getQueryData<CategoryInfo[]>(queryKeys.categories.list());
if (!categories || !Array.isArray(categories)) {
return null;
}
return categories.find((category) => category.category_code === selectedCategoryCode) || null;
}, [selectedCategoryCode, queryClient]);
// 카테고리 선택 함수
const selectCategory = useCallback((categoryCode: string) => {
setSelectedCategoryCode(categoryCode);
}, []);
// 카테고리 선택 해제 함수
const clearSelection = useCallback(() => {
setSelectedCategoryCode("");
}, []);
// 선택된 카테고리가 있는지 확인
const hasSelection = Boolean(selectedCategoryCode);
return {
// 상태
selectedCategoryCode,
selectedCategory,
hasSelection,
// 액션
selectCategory,
clearSelection,
};
}

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;
@ -186,7 +206,7 @@ export const getCurrentUser = async (): Promise<ApiResponse<UserInfo>> => {
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({
@ -195,12 +215,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

@ -0,0 +1,168 @@
import { apiClient } from "./client";
import {
CodeCategory,
CodeInfo,
CodeOption,
CreateCategoryRequest,
UpdateCategoryRequest,
CreateCodeRequest,
UpdateCodeRequest,
GetCategoriesQuery,
GetCodesQuery,
ApiResponse,
} from "@/types/commonCode";
/**
* API
*/
export const commonCodeApi = {
// 카테고리 관련 API
categories: {
/**
*
*/
async getList(params?: GetCategoriesQuery): Promise<ApiResponse<CodeCategory[]>> {
const searchParams = new URLSearchParams();
if (params?.search) searchParams.append("search", params.search);
if (params?.isActive !== undefined) searchParams.append("isActive", params.isActive.toString());
if (params?.page) searchParams.append("page", params.page.toString());
if (params?.size) searchParams.append("size", params.size.toString());
const queryString = searchParams.toString();
const url = `/common-codes/categories${queryString ? `?${queryString}` : ""}`;
const response = await apiClient.get(url);
return response.data;
},
/**
*
*/
async create(data: CreateCategoryRequest): Promise<ApiResponse<CodeCategory>> {
const response = await apiClient.post("/common-codes/categories", data);
return response.data;
},
/**
*
*/
async update(categoryCode: string, data: UpdateCategoryRequest): Promise<ApiResponse<CodeCategory>> {
const response = await apiClient.put(`/common-codes/categories/${categoryCode}`, data);
return response.data;
},
/**
*
*/
async delete(categoryCode: string): Promise<ApiResponse> {
const response = await apiClient.delete(`/common-codes/categories/${categoryCode}`);
return response.data;
},
},
// 코드 관련 API
codes: {
/**
*
*/
async getList(categoryCode: string, params?: GetCodesQuery): Promise<ApiResponse<CodeInfo[]>> {
const searchParams = new URLSearchParams();
if (params?.search) searchParams.append("search", params.search);
if (params?.isActive !== undefined) searchParams.append("isActive", params.isActive.toString());
if (params?.page !== undefined) searchParams.append("page", params.page.toString());
if (params?.size !== undefined) searchParams.append("size", params.size.toString());
const queryString = searchParams.toString();
const url = `/common-codes/categories/${categoryCode}/codes${queryString ? `?${queryString}` : ""}`;
const response = await apiClient.get(url);
return response.data;
},
/**
*
*/
async create(categoryCode: string, data: CreateCodeRequest): Promise<ApiResponse<CodeInfo>> {
const response = await apiClient.post(`/common-codes/categories/${categoryCode}/codes`, data);
return response.data;
},
/**
*
*/
async update(categoryCode: string, codeValue: string, data: UpdateCodeRequest): Promise<ApiResponse<CodeInfo>> {
const response = await apiClient.put(`/common-codes/categories/${categoryCode}/codes/${codeValue}`, data);
return response.data;
},
/**
*
*/
async delete(categoryCode: string, codeValue: string): Promise<ApiResponse> {
const response = await apiClient.delete(`/common-codes/categories/${categoryCode}/codes/${codeValue}`);
return response.data;
},
/**
*
*/
async reorder(categoryCode: string, codes: Array<{ codeValue: string; sortOrder: number }>): Promise<ApiResponse> {
const data = { codes }; // 백엔드가 기대하는 형식으로 래핑
const response = await apiClient.put(`/common-codes/categories/${categoryCode}/codes/reorder`, data);
return response.data;
},
},
// 중복 검사 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: {
/**
*
*/
async getOptions(categoryCode: string): Promise<ApiResponse<CodeOption[]>> {
const response = await apiClient.get(`/common-codes/categories/${categoryCode}/options`);
return response.data;
},
},
};

49
frontend/lib/queryKeys.ts Normal file
View File

@ -0,0 +1,49 @@
/**
* React Query Key Factory
*
*/
export const queryKeys = {
// 카테고리 관련 쿼리 키
categories: {
all: ["categories"] as const,
lists: () => [...queryKeys.categories.all, "list"] as const,
list: (filters?: { active?: boolean; search?: string }) => [...queryKeys.categories.lists(), filters] as const,
infinite: (filters?: { active?: boolean; search?: string }) =>
[...queryKeys.categories.all, "infinite", filters] as const,
infiniteList: (filters?: { active?: boolean; search?: string }) =>
[...queryKeys.categories.all, "infiniteList", filters] as const,
details: () => [...queryKeys.categories.all, "detail"] as const,
detail: (categoryCode: string) => [...queryKeys.categories.details(), categoryCode] as const,
},
// 코드 관련 쿼리 키
codes: {
all: ["codes"] as const,
lists: () => [...queryKeys.codes.all, "list"] as const,
list: (categoryCode: string, filters?: { active?: boolean; search?: string }) =>
[...queryKeys.codes.lists(), categoryCode, filters] as const,
infinite: (categoryCode: string, filters?: { active?: boolean; search?: string }) =>
[...queryKeys.codes.all, "infinite", categoryCode, filters] as const,
infiniteList: (categoryCode: string, filters?: { active?: boolean; search?: string }) =>
[...queryKeys.codes.all, "infiniteList", categoryCode, filters] as const,
details: () => [...queryKeys.codes.all, "detail"] as const,
detail: (categoryCode: string, codeValue: string) =>
[...queryKeys.codes.details(), categoryCode, codeValue] as const,
},
// 옵션 관련 쿼리 키 (향후 화면관리 연계용)
options: {
all: ["options"] as const,
byCategory: (categoryCode: string) => [...queryKeys.options.all, categoryCode] 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

@ -0,0 +1,73 @@
import { z } from "zod";
/**
* Zod
*/
// 카테고리 스키마
export const categorySchema = z.object({
categoryCode: z
.string()
.min(1, "카테고리 코드는 필수입니다")
.max(50, "카테고리 코드는 50자 이하여야 합니다")
.regex(/^[A-Z0-9_]+$/, "대문자, 숫자, 언더스코어(_)만 사용 가능합니다"),
categoryName: z.string().min(1, "카테고리명은 필수입니다").max(100, "카테고리명은 100자 이하여야 합니다"),
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 이하여야 합니다"),
});
// 카테고리 생성 스키마
export const createCategorySchema = categorySchema;
// 카테고리 수정 스키마 (카테고리 코드 제외)
export const updateCategorySchema = categorySchema.omit({ categoryCode: true }).extend({
isActive: z.enum(["Y", "N"]),
});
// 코드 스키마
export const codeSchema = z.object({
codeValue: z
.string()
.min(1, "코드값은 필수입니다")
.max(50, "코드값은 50자 이하여야 합니다")
.regex(/^[A-Z0-9_]+$/, "대문자, 숫자, 언더스코어(_)만 사용 가능합니다"),
codeName: z.string().min(1, "코드명은 필수입니다").max(100, "코드명은 100자 이하여야 합니다"),
codeNameEng: z.string().min(1, "영문 코드명은 필수입니다").max(100, "영문 코드명은 100자 이하여야 합니다"),
description: z.string().min(1, "설명은 필수입니다").max(500, "설명은 500자 이하여야 합니다"),
sortOrder: z.number().min(1, "정렬 순서는 1 이상이어야 합니다").max(9999, "정렬 순서는 9999 이하여야 합니다"),
});
// 코드 생성 스키마
export const createCodeSchema = codeSchema;
// 코드 수정 스키마 (코드값 제외)
export const updateCodeSchema = codeSchema.omit({ codeValue: true }).extend({
isActive: z.enum(["Y", "N"]),
});
// TypeScript 타입 추론
export type CategoryFormData = z.infer<typeof categorySchema>;
export type CreateCategoryData = z.infer<typeof createCategorySchema>;
export type UpdateCategoryData = z.infer<typeof updateCategorySchema>;
export type CodeFormData = z.infer<typeof codeSchema>;
export type CreateCodeData = z.infer<typeof createCodeSchema>;
export type UpdateCodeData = z.infer<typeof updateCodeSchema>;
// 검색 필터 스키마
export const categoryFilterSchema = z.object({
search: z.string().optional(),
active: z.boolean().optional(),
});
export const codeFilterSchema = z.object({
search: z.string().optional(),
active: z.boolean().optional(),
});
export type CategoryFilter = z.infer<typeof categoryFilterSchema>;
export type CodeFilter = z.infer<typeof codeFilterSchema>;

View File

@ -8,7 +8,10 @@
"name": "frontend",
"version": "0.1.0",
"dependencies": {
"@hookform/resolvers": "^5.1.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.1",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.3.3",
@ -23,6 +26,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.12",
"@tanstack/react-query": "^5.85.6",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
@ -33,14 +37,15 @@
"react": "19.1.0",
"react-day-picker": "^9.9.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.61.1",
"react-hook-form": "^7.62.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"zod": "^4.0.10"
"zod": "^4.1.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@tanstack/react-query-devtools": "^5.85.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@ -75,6 +80,59 @@
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT"
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz",
@ -2402,6 +2460,61 @@
"tailwindcss": "4.1.12"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.85.6",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.6.tgz",
"integrity": "sha512-hCj0TktzdCv2bCepIdfwqVwUVWb+GSHm1Jnn8w+40lfhQ3m7lCO7ADRUJy+2unxQ/nzjh2ipC6ye69NDW3l73g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-devtools": {
"version": "5.84.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.84.0.tgz",
"integrity": "sha512-fbF3n+z1rqhvd9EoGp5knHkv3p5B2Zml1yNRjh7sNXklngYI5RVIWUrUjZ1RIcEoscarUb0+bOvIs5x9dwzOXQ==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.85.6",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.6.tgz",
"integrity": "sha512-VUAag4ERjh+qlmg0wNivQIVCZUrYndqYu3/wPCVZd4r0E+1IqotbeyGTc+ICroL/PqbpSaGZg02zSWYfcvxbdA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.85.6"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-query-devtools": {
"version": "5.85.6",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.85.6.tgz",
"integrity": "sha512-A6rE39FypFV7eonefk4fxC/vuV/7YJMAcQT94CFAvCpiw65QZX8MOuUpdLBeG1cXajy4Pj8T8sEWHigccntJqg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tanstack/query-devtools": "5.84.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.85.6",
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",

View File

@ -13,7 +13,10 @@
"format:check": "prettier --check ."
},
"dependencies": {
"@hookform/resolvers": "^5.1.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.1",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.3.3",
@ -28,6 +31,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.12",
"@tanstack/react-query": "^5.85.6",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
@ -38,14 +42,15 @@
"react": "19.1.0",
"react-day-picker": "^9.9.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.61.1",
"react-hook-form": "^7.62.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"zod": "^4.0.10"
"zod": "^4.1.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@tanstack/react-query-devtools": "^5.85.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",

View File

@ -0,0 +1,42 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";
interface QueryProviderProps {
children: React.ReactNode;
}
export function QueryProvider({ children }: QueryProviderProps) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// 5분간 캐시 유지
staleTime: 1000 * 60 * 5,
// 30분간 가비지 컬렉션 방지
gcTime: 1000 * 60 * 30,
// 에러 시 재시도 1번만
retry: 1,
// 윈도우 포커스 시 자동 리페칭 비활성화
refetchOnWindowFocus: false,
},
mutations: {
// 뮤테이션 에러 시 재시도 안함
retry: false,
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>
{children}
{/* 개발 환경에서만 DevTools 표시 */}
{process.env.NODE_ENV === "development" && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>
);
}

View File

@ -0,0 +1,98 @@
// 공통코드 관련 타입 정의
export interface CodeCategory {
category_code: string;
category_name: string;
category_name_eng?: string | null;
description?: string | null;
sort_order: number;
is_active: string;
created_date?: string | null;
created_by?: string | null;
updated_date?: string | null;
updated_by?: string | null;
}
// CategoryInfo는 CodeCategory의 별칭
export type CategoryInfo = CodeCategory;
export interface CodeInfo {
code_category: string;
code_value: string;
code_name: string;
code_name_eng?: string | null;
description?: string | null;
sort_order: number;
is_active: string;
created_date?: string | null;
created_by?: string | null;
updated_date?: string | null;
updated_by?: string | null;
}
export interface CreateCategoryRequest {
categoryCode: string;
categoryName: string;
categoryNameEng?: string;
description?: string;
sortOrder?: number;
}
export interface UpdateCategoryRequest {
categoryName?: string;
categoryNameEng?: string;
description?: string;
sortOrder?: number;
isActive?: "Y" | "N"; // 백엔드에서 기대하는 문자열 타입
}
export interface CreateCodeRequest {
codeValue: string;
codeName: string;
codeNameEng?: string;
description?: string;
sortOrder?: number;
}
export interface UpdateCodeRequest {
codeName?: string;
codeNameEng?: string;
description?: string;
sortOrder?: number;
isActive?: "Y" | "N"; // 백엔드에서 기대하는 문자열 타입
}
export interface CodeOption {
value: string;
label: string;
labelEng?: string | null;
}
export interface ReorderCodesRequest {
codes: Array<{
codeValue: string;
sortOrder: number;
}>;
}
export interface GetCategoriesQuery {
search?: string;
isActive?: boolean;
page?: number;
size?: number;
}
export interface GetCodesQuery {
search?: string;
isActive?: boolean;
page?: number;
size?: number;
}
export interface ApiResponse<T = any> {
success: boolean;
data?: T;
message: string;
error?: string;
total?: number;
}