공통코드관리 구현 #13
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>>;
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue