각 회사별 데이터 분리

This commit is contained in:
kjs 2025-10-27 16:40:59 +09:00
parent 783ce5594e
commit 29c49d7f07
59 changed files with 8698 additions and 585 deletions

View File

@ -1,15 +1,6 @@
{
"watch": ["src"],
"ignore": [
"src/**/*.spec.ts",
"src/**/*.test.ts",
"data/**",
"uploads/**",
"logs/**",
"*.log"
],
"ext": "ts,json",
"exec": "ts-node src/app.ts",
"delay": 2000
"ignore": ["src/**/*.spec.ts"],
"exec": "node -r ts-node/register/transpile-only src/app.ts"
}

View File

@ -63,6 +63,7 @@ import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@ -220,6 +221,7 @@ app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우
app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지)
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
// app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes);

View File

@ -195,6 +195,8 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
search_email,
deptCode,
status,
companyCode, // 회사 코드 필터 추가
size, // countPerPage 대신 사용 가능
} = req.query;
// Raw Query를 사용한 사용자 목록 조회
@ -203,6 +205,14 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
let queryParams: any[] = [];
let paramIndex = 1;
// 회사 코드 필터 (권한 그룹 멤버 관리 시 사용)
if (companyCode && typeof companyCode === "string" && companyCode.trim()) {
whereConditions.push(`company_code = $${paramIndex}`);
queryParams.push(companyCode.trim());
paramIndex++;
logger.info("회사 코드 필터 적용", { companyCode });
}
// 검색 조건 처리
if (search && typeof search === "string" && search.trim()) {
// 통합 검색
@ -303,6 +313,16 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
}
}
// 현재 로그인한 사용자의 회사 코드 필터 (슈퍼관리자가 아닌 경우)
if (req.user && req.user.companyCode !== "*" && !companyCode) {
whereConditions.push(`company_code = $${paramIndex}`);
queryParams.push(req.user.companyCode);
paramIndex++;
logger.info("사용자 회사 코드 필터 적용", {
companyCode: req.user.companyCode,
});
}
// 기존 필터들
if (deptCode) {
whereConditions.push(`dept_code = $${paramIndex}`);
@ -331,7 +351,8 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
const totalCount = parseInt(countResult[0]?.total || "0", 10);
// 사용자 목록 조회
const offset = (Number(page) - 1) * Number(countPerPage);
const limit = size ? Number(size) : Number(countPerPage);
const offset = (Number(page) - 1) * limit;
const usersQuery = `
SELECT
sabun,
@ -357,11 +378,7 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
const users = await query<any>(usersQuery, [
...queryParams,
Number(countPerPage),
offset,
]);
const users = await query<any>(usersQuery, [...queryParams, limit, offset]);
// 응답 데이터 가공
const processedUsers = users.map((user) => ({
@ -393,8 +410,8 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
searchType,
pagination: {
page: Number(page),
limit: Number(countPerPage),
totalPages: Math.ceil(totalCount / Number(countPerPage)),
limit: limit,
totalPages: Math.ceil(totalCount / limit),
},
message: "사용자 목록 조회 성공",
};
@ -404,7 +421,8 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
returnedCount: processedUsers.length,
searchType,
currentPage: Number(page),
countPerPage: Number(countPerPage),
limit: limit,
companyCode: companyCode || "all",
});
res.status(200).json(response);
@ -1379,7 +1397,7 @@ export const getDepartmentList = async (
// 회사 코드 필터
if (companyCode) {
whereConditions.push(`company_name = $${paramIndex}`);
whereConditions.push(`company_code = $${paramIndex}`);
queryParams.push(companyCode);
paramIndex++;
}
@ -1420,6 +1438,7 @@ export const getDepartmentList = async (
data_type,
status,
sales_yn,
company_code,
company_name
FROM dept_info
${whereClause}
@ -1445,6 +1464,7 @@ export const getDepartmentList = async (
dataType: dept.data_type,
status: dept.status || "active",
salesYn: dept.sales_yn,
companyCode: dept.company_code,
companyName: dept.company_name,
children: [],
});
@ -1480,6 +1500,7 @@ export const getDepartmentList = async (
dataType: dept.data_type,
status: dept.status || "active",
salesYn: dept.sales_yn,
companyCode: dept.company_code,
companyName: dept.company_name,
})),
},
@ -1947,10 +1968,23 @@ export const changeUserStatus = async (
export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
try {
const userData = req.body;
logger.info("사용자 저장 요청", { userData, user: req.user });
const isUpdate = req.method === "PUT"; // PUT 요청이면 수정
logger.info("사용자 저장 요청", {
userData,
user: req.user,
isUpdate,
method: req.method,
});
// 필수 필드 검증
const requiredFields = ["userId", "userName", "userPassword"];
let requiredFields = ["userId", "userName"];
// 신규 등록 시에만 비밀번호 필수
if (!isUpdate) {
requiredFields.push("userPassword");
}
for (const field of requiredFields) {
if (!userData[field] || userData[field].trim() === "") {
res.status(400).json({
@ -1965,10 +1999,15 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
}
}
// 비밀번호 암호화
const encryptedPassword = await EncryptUtil.encrypt(userData.userPassword);
// 비밀번호 암호화 (비밀번호가 제공된 경우에만)
let encryptedPassword = null;
if (userData.userPassword) {
encryptedPassword = await EncryptUtil.encrypt(userData.userPassword);
}
// Raw Query를 사용한 사용자 저장 (upsert with ON CONFLICT)
const updatePasswordClause = encryptedPassword ? "user_password = $4," : "";
const [savedUser] = await query<any>(
`INSERT INTO user_info (
user_id, user_name, user_name_eng, user_password,
@ -1979,7 +2018,7 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
ON CONFLICT (user_id) DO UPDATE SET
user_name = $2,
user_name_eng = $3,
user_password = $4,
${updatePasswordClause}
dept_code = $5,
dept_name = $6,
position_code = $7,
@ -1998,7 +2037,7 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
userData.userId,
userData.userName,
userData.userNameEng || null,
encryptedPassword,
encryptedPassword || "", // 빈 문자열로 넣되, UPDATE에서는 조건부로 제외
userData.deptCode || null,
userData.deptName || null,
userData.positionCode || null,
@ -2017,23 +2056,26 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
);
// 기존 사용자인지 새 사용자인지 확인 (regdate로 판단)
const isUpdate =
const isExistingUser =
savedUser.regdate &&
new Date(savedUser.regdate).getTime() < Date.now() - 1000;
logger.info(isUpdate ? "사용자 정보 수정 완료" : "새 사용자 등록 완료", {
userId: userData.userId,
});
logger.info(
isExistingUser ? "사용자 정보 수정 완료" : "새 사용자 등록 완료",
{
userId: userData.userId,
}
);
const response = {
success: true,
result: true,
message: isUpdate
message: isExistingUser
? "사용자 정보가 수정되었습니다."
: "사용자가 등록되었습니다.",
data: {
userId: userData.userId,
isUpdate,
isUpdate: isExistingUser,
},
};

View File

@ -1,4 +1,5 @@
import { Request, Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import {
getDataflowDiagrams as getDataflowDiagramsService,
getDataflowDiagramById as getDataflowDiagramByIdService,
@ -12,15 +13,33 @@ import { logger } from "../utils/logger";
/**
* ()
*/
export const getDataflowDiagrams = async (req: Request, res: Response) => {
export const getDataflowDiagrams = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const page = parseInt(req.query.page as string) || 1;
const size = parseInt(req.query.size as string) || 20;
const searchTerm = req.query.searchTerm as string;
const companyCode =
(req.query.companyCode as string) ||
(req.headers["x-company-code"] as string) ||
"*";
const userCompanyCode = req.user?.companyCode;
// 슈퍼 관리자는 쿼리 파라미터로 회사 지정 가능, 일반/회사 관리자는 자신의 회사만
let companyCode: string;
if (userCompanyCode === "*") {
// 슈퍼 관리자: 쿼리 파라미터 사용 또는 전체
companyCode = (req.query.companyCode as string) || "*";
} else {
// 회사 관리자/일반 사용자: 강제로 자신의 회사 코드 적용
companyCode = userCompanyCode || "*";
}
logger.info("관계도 목록 조회", {
userId: req.user?.userId,
userCompanyCode,
filterCompanyCode: companyCode,
page,
size,
});
const result = await getDataflowDiagramsService(
companyCode,
@ -46,13 +65,21 @@ export const getDataflowDiagrams = async (req: Request, res: Response) => {
/**
*
*/
export const getDataflowDiagramById = async (req: Request, res: Response) => {
export const getDataflowDiagramById = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const diagramId = parseInt(req.params.diagramId);
const companyCode =
(req.query.companyCode as string) ||
(req.headers["x-company-code"] as string) ||
"*";
const userCompanyCode = req.user?.companyCode;
// 슈퍼 관리자는 쿼리 파라미터로 회사 지정 가능, 일반/회사 관리자는 자신의 회사만
let companyCode: string;
if (userCompanyCode === "*") {
companyCode = (req.query.companyCode as string) || "*";
} else {
companyCode = userCompanyCode || "*";
}
if (isNaN(diagramId)) {
return res.status(400).json({
@ -87,7 +114,10 @@ export const getDataflowDiagramById = async (req: Request, res: Response) => {
/**
*
*/
export const createDataflowDiagram = async (req: Request, res: Response) => {
export const createDataflowDiagram = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const {
diagram_name,
@ -96,27 +126,31 @@ export const createDataflowDiagram = async (req: Request, res: Response) => {
category,
control,
plan,
company_code,
created_by,
updated_by,
} = req.body;
logger.info(`새 관계도 생성 요청:`, { diagram_name, company_code });
const userCompanyCode = req.user?.companyCode;
const userId = req.user?.userId || "SYSTEM";
// 회사 코드는 로그인한 사용자의 회사 코드 사용 (슈퍼 관리자는 요청 body에서 지정 가능)
let companyCode: string;
if (userCompanyCode === "*" && req.body.company_code) {
// 슈퍼 관리자가 특정 회사로 생성하는 경우
companyCode = req.body.company_code;
} else {
// 일반 사용자/회사 관리자는 자신의 회사로 생성
companyCode = userCompanyCode || "*";
}
logger.info(`새 관계도 생성 요청:`, {
diagram_name,
companyCode,
userId,
userCompanyCode,
});
logger.info(`node_positions:`, node_positions);
logger.info(`category:`, category);
logger.info(`control:`, control);
logger.info(`plan:`, plan);
logger.info(`전체 요청 Body:`, JSON.stringify(req.body, null, 2));
const companyCode =
company_code ||
(req.query.companyCode as string) ||
(req.headers["x-company-code"] as string) ||
"*";
const userId =
created_by ||
updated_by ||
(req.headers["x-user-id"] as string) ||
"SYSTEM";
if (!diagram_name || !relationships) {
return res.status(400).json({
@ -184,24 +218,31 @@ export const createDataflowDiagram = async (req: Request, res: Response) => {
/**
*
*/
export const updateDataflowDiagram = async (req: Request, res: Response) => {
export const updateDataflowDiagram = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const diagramId = parseInt(req.params.diagramId);
const { updated_by } = req.body;
const companyCode =
(req.query.companyCode as string) ||
(req.headers["x-company-code"] as string) ||
"*";
const userId =
updated_by || (req.headers["x-user-id"] as string) || "SYSTEM";
const userCompanyCode = req.user?.companyCode;
const userId = req.user?.userId || "SYSTEM";
logger.info(`관계도 수정 요청 - ID: ${diagramId}, Company: ${companyCode}`);
// 슈퍼 관리자는 쿼리 파라미터로 회사 지정 가능, 일반/회사 관리자는 자신의 회사만
let companyCode: string;
if (userCompanyCode === "*") {
companyCode = (req.query.companyCode as string) || "*";
} else {
companyCode = userCompanyCode || "*";
}
logger.info(`관계도 수정 요청`, {
diagramId,
companyCode,
userId,
userCompanyCode,
});
logger.info(`요청 Body:`, JSON.stringify(req.body, null, 2));
logger.info(`node_positions:`, req.body.node_positions);
logger.info(`요청 Body 키들:`, Object.keys(req.body));
logger.info(`요청 Body 타입:`, typeof req.body);
logger.info(`node_positions 타입:`, typeof req.body.node_positions);
logger.info(`node_positions 값:`, req.body.node_positions);
if (isNaN(diagramId)) {
return res.status(400).json({
@ -265,13 +306,21 @@ export const updateDataflowDiagram = async (req: Request, res: Response) => {
/**
*
*/
export const deleteDataflowDiagram = async (req: Request, res: Response) => {
export const deleteDataflowDiagram = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const diagramId = parseInt(req.params.diagramId);
const companyCode =
(req.query.companyCode as string) ||
(req.headers["x-company-code"] as string) ||
"*";
const userCompanyCode = req.user?.companyCode;
// 슈퍼 관리자는 쿼리 파라미터로 회사 지정 가능, 일반/회사 관리자는 자신의 회사만
let companyCode: string;
if (userCompanyCode === "*") {
companyCode = (req.query.companyCode as string) || "*";
} else {
companyCode = userCompanyCode || "*";
}
if (isNaN(diagramId)) {
return res.status(400).json({
@ -306,21 +355,25 @@ export const deleteDataflowDiagram = async (req: Request, res: Response) => {
/**
*
*/
export const copyDataflowDiagram = async (req: Request, res: Response) => {
export const copyDataflowDiagram = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const diagramId = parseInt(req.params.diagramId);
const {
new_name,
companyCode: bodyCompanyCode,
userId: bodyUserId,
} = req.body;
const companyCode =
bodyCompanyCode ||
(req.query.companyCode as string) ||
(req.headers["x-company-code"] as string) ||
"*";
const userId =
bodyUserId || (req.headers["x-user-id"] as string) || "SYSTEM";
const { new_name } = req.body;
const userCompanyCode = req.user?.companyCode;
const userId = req.user?.userId || "SYSTEM";
// 회사 코드는 로그인한 사용자의 회사 코드 사용
let companyCode: string;
if (userCompanyCode === "*" && req.body.companyCode) {
// 슈퍼 관리자가 특정 회사로 복제하는 경우
companyCode = req.body.companyCode;
} else {
// 일반 사용자/회사 관리자는 자신의 회사로 복제
companyCode = userCompanyCode || "*";
}
if (isNaN(diagramId)) {
return res.status(400).json({

View File

@ -1,3 +1,4 @@
// @ts-nocheck
/**
*
*/
@ -34,6 +35,7 @@ export class FlowController {
const { name, description, tableName, dbSourceType, dbConnectionId } =
req.body;
const userId = (req as any).user?.userId || "system";
const userCompanyCode = (req as any).user?.companyCode;
console.log("🔍 createFlowDefinition called with:", {
name,
@ -41,6 +43,7 @@ export class FlowController {
tableName,
dbSourceType,
dbConnectionId,
userCompanyCode,
});
if (!name) {
@ -66,7 +69,8 @@ export class FlowController {
const flowDef = await this.flowDefinitionService.create(
{ name, description, tableName, dbSourceType, dbConnectionId },
userId
userId,
userCompanyCode
);
res.json({
@ -88,12 +92,25 @@ export class FlowController {
getFlowDefinitions = async (req: Request, res: Response): Promise<void> => {
try {
const { tableName, isActive } = req.query;
const user = (req as any).user;
const userCompanyCode = user?.companyCode;
console.log("🎯 getFlowDefinitions called:", {
userId: user?.userId,
userCompanyCode: userCompanyCode,
userType: user?.userType,
tableName,
isActive,
});
const flows = await this.flowDefinitionService.findAll(
tableName as string | undefined,
isActive !== undefined ? isActive === "true" : undefined
isActive !== undefined ? isActive === "true" : undefined,
userCompanyCode
);
console.log(`✅ Returning ${flows.length} flows to user ${user?.userId}`);
res.json({
success: true,
data: flows,

View File

@ -0,0 +1,864 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { ApiResponse } from "../types/common";
import { RoleService } from "../services/roleService";
import { logger } from "../utils/logger";
import {
isSuperAdmin,
isCompanyAdmin,
canAccessCompanyData,
} from "../utils/permissionUtils";
/**
*
* - 관리자: 자기
* - 관리자: 모든 (companyCode )
*/
export const getRoleGroups = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const search = req.query.search as string | undefined;
const companyCode = req.query.companyCode as string | undefined;
// 최고 관리자가 아닌 경우 자기 회사만 조회
let targetCompanyCode: string | undefined;
if (isSuperAdmin(req.user)) {
// 최고 관리자: companyCode 파라미터가 있으면 해당 회사만, 없으면 전체 조회
targetCompanyCode = companyCode;
logger.info("권한 그룹 목록 조회 (최고 관리자)", {
userId: req.user?.userId,
targetCompanyCode: targetCompanyCode || "전체",
search,
});
} else {
// 일반 관리자: 자기 회사만 조회
targetCompanyCode = req.user?.companyCode;
if (!targetCompanyCode) {
res.status(400).json({
success: false,
message: "회사 코드가 필요합니다",
});
return;
}
logger.info("권한 그룹 목록 조회 (회사 관리자)", {
userId: req.user?.userId,
companyCode: targetCompanyCode,
search,
});
}
const roleGroups = await RoleService.getRoleGroups(
targetCompanyCode,
search
);
const response: ApiResponse<any[]> = {
success: true,
message: "권한 그룹 목록 조회 성공",
data: roleGroups,
};
res.status(200).json(response);
} catch (error) {
logger.error("권한 그룹 목록 조회 실패", { error });
res.status(500).json({
success: false,
message: "권한 그룹 목록 조회 중 오류가 발생했습니다",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/**
*
*/
export const getRoleGroupById = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const objid = parseInt(req.params.id, 10);
if (isNaN(objid)) {
res.status(400).json({
success: false,
message: "유효하지 않은 권한 그룹 ID입니다",
});
return;
}
const roleGroup = await RoleService.getRoleGroupById(objid);
if (!roleGroup) {
res.status(404).json({
success: false,
message: "권한 그룹을 찾을 수 없습니다",
});
return;
}
// 권한 체크: 슈퍼관리자 또는 해당 회사 관리자만 조회 가능
if (
!isSuperAdmin(req.user) &&
!canAccessCompanyData(req.user, roleGroup.companyCode)
) {
res.status(403).json({
success: false,
message: "권한이 없습니다",
});
return;
}
const response: ApiResponse<any> = {
success: true,
message: "권한 그룹 상세 조회 성공",
data: roleGroup,
};
res.status(200).json(response);
} catch (error) {
logger.error("권한 그룹 상세 조회 실패", { error });
res.status(500).json({
success: false,
message: "권한 그룹 상세 조회 중 오류가 발생했습니다",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/**
*
* - 관리자: 자기
* - 관리자: 모든
*/
export const createRoleGroup = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { authName, authCode, companyCode } = req.body;
if (!authName || !authCode || !companyCode) {
res.status(400).json({
success: false,
message: "필수 정보가 누락되었습니다 (authName, authCode, companyCode)",
});
return;
}
// 권한 체크: 회사 관리자 이상만 생성 가능
if (!isSuperAdmin(req.user) && !isCompanyAdmin(req.user)) {
res.status(403).json({
success: false,
message: "권한 그룹 생성 권한이 없습니다",
});
return;
}
// 회사 관리자는 자기 회사에만 권한 그룹 생성 가능
if (!isSuperAdmin(req.user) && req.user?.companyCode !== companyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 권한 그룹을 생성할 수 없습니다",
});
return;
}
const roleGroup = await RoleService.createRoleGroup({
authName,
authCode,
companyCode,
writer: req.user?.userId || "SYSTEM",
});
const response: ApiResponse<any> = {
success: true,
message: "권한 그룹 생성 성공",
data: roleGroup,
};
res.status(201).json(response);
} catch (error) {
logger.error("권한 그룹 생성 실패", { error });
res.status(500).json({
success: false,
message: "권한 그룹 생성 중 오류가 발생했습니다",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/**
*
*/
export const updateRoleGroup = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const objid = parseInt(req.params.id, 10);
const { authName, authCode, status } = req.body;
if (isNaN(objid)) {
res.status(400).json({
success: false,
message: "유효하지 않은 권한 그룹 ID입니다",
});
return;
}
// 기존 권한 그룹 조회
const existingRoleGroup = await RoleService.getRoleGroupById(objid);
if (!existingRoleGroup) {
res.status(404).json({
success: false,
message: "권한 그룹을 찾을 수 없습니다",
});
return;
}
// 권한 체크
if (
!isSuperAdmin(req.user) &&
!canAccessCompanyData(req.user, existingRoleGroup.companyCode)
) {
res.status(403).json({
success: false,
message: "권한 그룹 수정 권한이 없습니다",
});
return;
}
const roleGroup = await RoleService.updateRoleGroup(objid, {
authName,
authCode,
status,
});
const response: ApiResponse<any> = {
success: true,
message: "권한 그룹 수정 성공",
data: roleGroup,
};
res.status(200).json(response);
} catch (error) {
logger.error("권한 그룹 수정 실패", { error });
res.status(500).json({
success: false,
message: "권한 그룹 수정 중 오류가 발생했습니다",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/**
*
*/
export const deleteRoleGroup = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const objid = parseInt(req.params.id, 10);
if (isNaN(objid)) {
res.status(400).json({
success: false,
message: "유효하지 않은 권한 그룹 ID입니다",
});
return;
}
// 기존 권한 그룹 조회
const existingRoleGroup = await RoleService.getRoleGroupById(objid);
if (!existingRoleGroup) {
res.status(404).json({
success: false,
message: "권한 그룹을 찾을 수 없습니다",
});
return;
}
// 권한 체크
if (
!isSuperAdmin(req.user) &&
!canAccessCompanyData(req.user, existingRoleGroup.companyCode)
) {
res.status(403).json({
success: false,
message: "권한 그룹 삭제 권한이 없습니다",
});
return;
}
await RoleService.deleteRoleGroup(objid);
const response: ApiResponse<null> = {
success: true,
message: "권한 그룹 삭제 성공",
data: null,
};
res.status(200).json(response);
} catch (error) {
logger.error("권한 그룹 삭제 실패", { error });
res.status(500).json({
success: false,
message: "권한 그룹 삭제 중 오류가 발생했습니다",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/**
*
*/
export const getRoleMembers = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const masterObjid = parseInt(req.params.id, 10);
if (isNaN(masterObjid)) {
res.status(400).json({
success: false,
message: "유효하지 않은 권한 그룹 ID입니다",
});
return;
}
// 기존 권한 그룹 조회
const roleGroup = await RoleService.getRoleGroupById(masterObjid);
if (!roleGroup) {
res.status(404).json({
success: false,
message: "권한 그룹을 찾을 수 없습니다",
});
return;
}
// 권한 체크
if (
!isSuperAdmin(req.user) &&
!canAccessCompanyData(req.user, roleGroup.companyCode)
) {
res.status(403).json({
success: false,
message: "권한 그룹 멤버 조회 권한이 없습니다",
});
return;
}
const members = await RoleService.getRoleMembers(masterObjid);
const response: ApiResponse<any[]> = {
success: true,
message: "권한 그룹 멤버 조회 성공",
data: members,
};
res.status(200).json(response);
} catch (error) {
logger.error("권한 그룹 멤버 조회 실패", { error });
res.status(500).json({
success: false,
message: "권한 그룹 멤버 조회 중 오류가 발생했습니다",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/**
*
*/
export const addRoleMembers = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const masterObjid = parseInt(req.params.id, 10);
const { userIds } = req.body;
if (isNaN(masterObjid)) {
res.status(400).json({
success: false,
message: "유효하지 않은 권한 그룹 ID입니다",
});
return;
}
if (!Array.isArray(userIds) || userIds.length === 0) {
res.status(400).json({
success: false,
message: "추가할 사용자 ID 목록이 필요합니다",
});
return;
}
// 기존 권한 그룹 조회
const roleGroup = await RoleService.getRoleGroupById(masterObjid);
if (!roleGroup) {
res.status(404).json({
success: false,
message: "권한 그룹을 찾을 수 없습니다",
});
return;
}
// 권한 체크
if (
!isSuperAdmin(req.user) &&
!canAccessCompanyData(req.user, roleGroup.companyCode)
) {
res.status(403).json({
success: false,
message: "권한 그룹 멤버 추가 권한이 없습니다",
});
return;
}
await RoleService.addRoleMembers(
masterObjid,
userIds,
req.user?.userId || "SYSTEM"
);
const response: ApiResponse<null> = {
success: true,
message: "권한 그룹 멤버 추가 성공",
data: null,
};
res.status(200).json(response);
} catch (error) {
logger.error("권한 그룹 멤버 추가 실패", { error });
res.status(500).json({
success: false,
message: "권한 그룹 멤버 추가 중 오류가 발생했습니다",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/**
*
*/
export const updateRoleMembers = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const masterObjid = parseInt(req.params.id, 10);
const { userIds } = req.body;
if (isNaN(masterObjid)) {
res.status(400).json({
success: false,
message: "유효하지 않은 권한 그룹 ID입니다",
});
return;
}
if (!Array.isArray(userIds)) {
res.status(400).json({
success: false,
message: "사용자 ID 배열이 필요합니다",
});
return;
}
// 기존 권한 그룹 조회
const roleGroup = await RoleService.getRoleGroupById(masterObjid);
if (!roleGroup) {
res.status(404).json({
success: false,
message: "권한 그룹을 찾을 수 없습니다",
});
return;
}
// 권한 체크
if (
!isSuperAdmin(req.user) &&
!canAccessCompanyData(req.user, roleGroup.companyCode)
) {
res.status(403).json({
success: false,
message: "권한 그룹 멤버 수정 권한이 없습니다",
});
return;
}
// 기존 멤버 조회
const existingMembers = await RoleService.getRoleMembers(masterObjid);
const existingUserIds = existingMembers.map((m: any) => m.userId);
// 추가할 멤버 (새로 추가된 것들)
const toAdd = userIds.filter((id: string) => !existingUserIds.includes(id));
// 제거할 멤버 (기존에 있었는데 없어진 것들)
const toRemove = existingUserIds.filter(
(id: string) => !userIds.includes(id)
);
// 추가
if (toAdd.length > 0) {
await RoleService.addRoleMembers(
masterObjid,
toAdd,
req.user?.userId || "SYSTEM"
);
}
// 제거
if (toRemove.length > 0) {
await RoleService.removeRoleMembers(
masterObjid,
toRemove,
req.user?.userId || "SYSTEM"
);
}
logger.info("권한 그룹 멤버 일괄 업데이트 성공", {
masterObjid,
added: toAdd.length,
removed: toRemove.length,
});
const response: ApiResponse<null> = {
success: true,
message: "권한 그룹 멤버가 업데이트되었습니다",
data: null,
};
res.status(200).json(response);
} catch (error) {
logger.error("권한 그룹 멤버 업데이트 실패", { error });
res.status(500).json({
success: false,
message: "권한 그룹 멤버 업데이트 중 오류가 발생했습니다",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/**
*
*/
export const removeRoleMembers = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const masterObjid = parseInt(req.params.id, 10);
const { userIds } = req.body;
if (isNaN(masterObjid)) {
res.status(400).json({
success: false,
message: "유효하지 않은 권한 그룹 ID입니다",
});
return;
}
if (!Array.isArray(userIds) || userIds.length === 0) {
res.status(400).json({
success: false,
message: "제거할 사용자 ID 목록이 필요합니다",
});
return;
}
// 기존 권한 그룹 조회
const roleGroup = await RoleService.getRoleGroupById(masterObjid);
if (!roleGroup) {
res.status(404).json({
success: false,
message: "권한 그룹을 찾을 수 없습니다",
});
return;
}
// 권한 체크
if (
!isSuperAdmin(req.user) &&
!canAccessCompanyData(req.user, roleGroup.companyCode)
) {
res.status(403).json({
success: false,
message: "권한 그룹 멤버 제거 권한이 없습니다",
});
return;
}
await RoleService.removeRoleMembers(
masterObjid,
userIds,
req.user?.userId || "SYSTEM"
);
const response: ApiResponse<null> = {
success: true,
message: "권한 그룹 멤버 제거 성공",
data: null,
};
res.status(200).json(response);
} catch (error) {
logger.error("권한 그룹 멤버 제거 실패", { error });
res.status(500).json({
success: false,
message: "권한 그룹 멤버 제거 중 오류가 발생했습니다",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/**
*
*/
export const getMenuPermissions = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const authObjid = parseInt(req.params.id, 10);
if (isNaN(authObjid)) {
res.status(400).json({
success: false,
message: "유효하지 않은 권한 그룹 ID입니다",
});
return;
}
// 기존 권한 그룹 조회
const roleGroup = await RoleService.getRoleGroupById(authObjid);
if (!roleGroup) {
res.status(404).json({
success: false,
message: "권한 그룹을 찾을 수 없습니다",
});
return;
}
// 권한 체크
if (
!isSuperAdmin(req.user) &&
!canAccessCompanyData(req.user, roleGroup.companyCode)
) {
res.status(403).json({
success: false,
message: "메뉴 권한 조회 권한이 없습니다",
});
return;
}
const permissions = await RoleService.getMenuPermissions(authObjid);
const response: ApiResponse<any[]> = {
success: true,
message: "메뉴 권한 조회 성공",
data: permissions,
};
res.status(200).json(response);
} catch (error) {
logger.error("메뉴 권한 조회 실패", { error });
res.status(500).json({
success: false,
message: "메뉴 권한 조회 중 오류가 발생했습니다",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/**
*
*/
export const setMenuPermissions = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const authObjid = parseInt(req.params.id, 10);
const { permissions } = req.body;
if (isNaN(authObjid)) {
res.status(400).json({
success: false,
message: "유효하지 않은 권한 그룹 ID입니다",
});
return;
}
if (!Array.isArray(permissions)) {
res.status(400).json({
success: false,
message: "권한 목록이 필요합니다",
});
return;
}
// 기존 권한 그룹 조회
const roleGroup = await RoleService.getRoleGroupById(authObjid);
if (!roleGroup) {
res.status(404).json({
success: false,
message: "권한 그룹을 찾을 수 없습니다",
});
return;
}
// 권한 체크
if (
!isSuperAdmin(req.user) &&
!canAccessCompanyData(req.user, roleGroup.companyCode)
) {
res.status(403).json({
success: false,
message: "메뉴 권한 설정 권한이 없습니다",
});
return;
}
await RoleService.setMenuPermissions(
authObjid,
permissions,
req.user?.userId || "SYSTEM"
);
const response: ApiResponse<null> = {
success: true,
message: "메뉴 권한 설정 성공",
data: null,
};
res.status(200).json(response);
} catch (error) {
logger.error("메뉴 권한 설정 실패", { error });
res.status(500).json({
success: false,
message: "메뉴 권한 설정 중 오류가 발생했습니다",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/**
*
*/
export const getUserRoleGroups = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const userId = req.params.userId || req.user?.userId;
const companyCode = req.user?.companyCode;
if (!userId || !companyCode) {
res.status(400).json({
success: false,
message: "사용자 ID 또는 회사 코드가 필요합니다",
});
return;
}
const roleGroups = await RoleService.getUserRoleGroups(userId, companyCode);
const response: ApiResponse<any[]> = {
success: true,
message: "사용자 권한 그룹 조회 성공",
data: roleGroups,
};
res.status(200).json(response);
} catch (error) {
logger.error("사용자 권한 그룹 조회 실패", { error });
res.status(500).json({
success: false,
message: "사용자 권한 그룹 조회 중 오류가 발생했습니다",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/**
* ( )
*/
export const getAllMenus = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const requestedCompanyCode = req.query.companyCode as string | undefined;
logger.info("🔍 [getAllMenus] API 호출", {
userId: req.user?.userId,
userType: req.user?.userType,
userCompanyCode: req.user?.companyCode,
requestedCompanyCode,
});
// 권한 체크
if (!isSuperAdmin(req.user) && !isCompanyAdmin(req.user)) {
logger.warn("❌ [getAllMenus] 권한 없음", {
userId: req.user?.userId,
userType: req.user?.userType,
});
res.status(403).json({
success: false,
message: "관리자 권한이 필요합니다",
});
return;
}
// 회사 코드 결정: 최고 관리자는 요청한 코드 사용, 회사 관리자는 자기 회사만
let companyCode: string | undefined;
if (isSuperAdmin(req.user)) {
// 최고 관리자: 요청한 회사 코드 사용 (없으면 전체)
companyCode = requestedCompanyCode;
logger.info("✅ [getAllMenus] 최고 관리자 - 요청된 회사 코드 사용", {
companyCode: companyCode || "전체",
});
} else {
// 회사 관리자: 자기 회사 코드만 사용
companyCode = req.user?.companyCode;
logger.info("✅ [getAllMenus] 회사 관리자 - 자기 회사 코드 적용", {
companyCode,
});
}
logger.info("✅ [getAllMenus] 관리자 권한 확인 완료", {
isSuperAdmin: isSuperAdmin(req.user),
isCompanyAdmin: isCompanyAdmin(req.user),
finalCompanyCode: companyCode || "전체",
});
const menus = await RoleService.getAllMenus(companyCode);
logger.info("✅ [getAllMenus] API 응답 준비", {
menuCount: menus.length,
companyCode: companyCode || "전체",
});
const response: ApiResponse<any[]> = {
success: true,
message: "메뉴 목록 조회 성공",
data: menus,
};
res.status(200).json(response);
} catch (error) {
logger.error("❌ [getAllMenus] 메뉴 목록 조회 실패", { error });
res.status(500).json({
success: false,
message: "메뉴 목록 조회 중 오류가 발생했습니다",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};

View File

@ -0,0 +1,430 @@
/**
*
* 3 적용: SUPER_ADMIN / COMPANY_ADMIN / USER
*/
import { Request, Response, NextFunction } from "express";
import { PersonBean } from "../types/auth";
import {
isSuperAdmin,
isCompanyAdmin,
isAdmin,
canExecuteDDL,
canManageUsers,
canManageCompanySettings,
canManageCompanies,
canAccessCompanyData,
PermissionLevel,
createPermissionError,
} from "../utils/permissionUtils";
import { logger } from "../utils/logger";
/**
*
*/
export interface AuthenticatedRequest extends Request {
user?: PersonBean;
}
/**
*
*/
export const requireSuperAdmin = (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): void => {
try {
if (!req.user) {
logger.warn("슈퍼관리자 권한 필요 - 인증되지 않은 사용자", {
ip: req.ip,
url: req.originalUrl,
});
res.status(401).json({
success: false,
error: {
code: "AUTHENTICATION_REQUIRED",
details: "인증이 필요합니다.",
},
});
return;
}
if (!isSuperAdmin(req.user)) {
logger.warn("슈퍼관리자 권한 부족", {
userId: req.user.userId,
companyCode: req.user.companyCode,
userType: req.user.userType,
ip: req.ip,
url: req.originalUrl,
});
res.status(403).json(createPermissionError(PermissionLevel.SUPER_ADMIN));
return;
}
logger.info("슈퍼관리자 권한 확인 완료", {
userId: req.user.userId,
url: req.originalUrl,
});
next();
} catch (error) {
logger.error("슈퍼관리자 권한 확인 중 오류:", error);
res.status(500).json({
success: false,
error: {
code: "AUTHORIZATION_ERROR",
details: "권한 확인 중 오류가 발생했습니다.",
},
});
}
};
/**
* ( + )
*/
export const requireAdmin = (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): void => {
try {
if (!req.user) {
res.status(401).json({
success: false,
error: {
code: "AUTHENTICATION_REQUIRED",
details: "인증이 필요합니다.",
},
});
return;
}
if (!isAdmin(req.user)) {
logger.warn("관리자 권한 부족", {
userId: req.user.userId,
userType: req.user.userType,
companyCode: req.user.companyCode,
ip: req.ip,
url: req.originalUrl,
});
res
.status(403)
.json(createPermissionError(PermissionLevel.COMPANY_ADMIN));
return;
}
logger.info("관리자 권한 확인 완료", {
userId: req.user.userId,
userType: req.user.userType,
url: req.originalUrl,
});
next();
} catch (error) {
logger.error("관리자 권한 확인 중 오류:", error);
res.status(500).json({
success: false,
error: {
code: "AUTHORIZATION_ERROR",
details: "권한 확인 중 오류가 발생했습니다.",
},
});
}
};
/**
*
* req.params.companyCode req.query.companyCode
*/
export const requireCompanyAccess = (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): void => {
try {
if (!req.user) {
res.status(401).json({
success: false,
error: {
code: "AUTHENTICATION_REQUIRED",
details: "인증이 필요합니다.",
},
});
return;
}
const targetCompanyCode =
(req.params.companyCode as string) ||
(req.query.companyCode as string) ||
(req.body.companyCode as string);
if (!targetCompanyCode) {
res.status(400).json({
success: false,
error: {
code: "COMPANY_CODE_REQUIRED",
details: "회사 코드가 필요합니다.",
},
});
return;
}
if (!canAccessCompanyData(req.user, targetCompanyCode)) {
logger.warn("회사 데이터 접근 권한 없음", {
userId: req.user.userId,
userCompanyCode: req.user.companyCode,
targetCompanyCode,
ip: req.ip,
url: req.originalUrl,
});
res.status(403).json({
success: false,
error: {
code: "COMPANY_ACCESS_DENIED",
details: "해당 회사의 데이터에 접근할 권한이 없습니다.",
},
});
return;
}
next();
} catch (error) {
logger.error("회사 데이터 접근 권한 확인 중 오류:", error);
res.status(500).json({
success: false,
error: {
code: "AUTHORIZATION_ERROR",
details: "권한 확인 중 오류가 발생했습니다.",
},
});
}
};
/**
*
*/
export const requireUserManagement = (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): void => {
try {
if (!req.user) {
res.status(401).json({
success: false,
error: {
code: "AUTHENTICATION_REQUIRED",
details: "인증이 필요합니다.",
},
});
return;
}
const targetCompanyCode =
(req.params.companyCode as string) ||
(req.query.companyCode as string) ||
(req.body.companyCode as string);
if (!canManageUsers(req.user, targetCompanyCode)) {
logger.warn("사용자 관리 권한 없음", {
userId: req.user.userId,
userCompanyCode: req.user.companyCode,
targetCompanyCode,
ip: req.ip,
url: req.originalUrl,
});
res.status(403).json({
success: false,
error: {
code: "USER_MANAGEMENT_DENIED",
details: "사용자 관리 권한이 없습니다.",
},
});
return;
}
next();
} catch (error) {
logger.error("사용자 관리 권한 확인 중 오류:", error);
res.status(500).json({
success: false,
error: {
code: "AUTHORIZATION_ERROR",
details: "권한 확인 중 오류가 발생했습니다.",
},
});
}
};
/**
*
*/
export const requireCompanySettingsManagement = (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): void => {
try {
if (!req.user) {
res.status(401).json({
success: false,
error: {
code: "AUTHENTICATION_REQUIRED",
details: "인증이 필요합니다.",
},
});
return;
}
const targetCompanyCode =
(req.params.companyCode as string) ||
(req.query.companyCode as string) ||
(req.body.companyCode as string);
if (!canManageCompanySettings(req.user, targetCompanyCode)) {
logger.warn("회사 설정 변경 권한 없음", {
userId: req.user.userId,
userCompanyCode: req.user.companyCode,
targetCompanyCode,
ip: req.ip,
url: req.originalUrl,
});
res.status(403).json({
success: false,
error: {
code: "COMPANY_SETTINGS_DENIED",
details: "회사 설정 변경 권한이 없습니다.",
},
});
return;
}
next();
} catch (error) {
logger.error("회사 설정 변경 권한 확인 중 오류:", error);
res.status(500).json({
success: false,
error: {
code: "AUTHORIZATION_ERROR",
details: "권한 확인 중 오류가 발생했습니다.",
},
});
}
};
/**
* / ( )
*/
export const requireCompanyManagement = (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): void => {
try {
if (!req.user) {
res.status(401).json({
success: false,
error: {
code: "AUTHENTICATION_REQUIRED",
details: "인증이 필요합니다.",
},
});
return;
}
if (!canManageCompanies(req.user)) {
logger.warn("회사 관리 권한 없음", {
userId: req.user.userId,
userType: req.user.userType,
companyCode: req.user.companyCode,
ip: req.ip,
url: req.originalUrl,
});
res.status(403).json({
success: false,
error: {
code: "COMPANY_MANAGEMENT_DENIED",
details: "회사 생성/삭제는 최고 관리자만 가능합니다.",
},
});
return;
}
next();
} catch (error) {
logger.error("회사 관리 권한 확인 중 오류:", error);
res.status(500).json({
success: false,
error: {
code: "AUTHORIZATION_ERROR",
details: "권한 확인 중 오류가 발생했습니다.",
},
});
}
};
/**
* DDL ( )
*/
export const requireDDLPermission = (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): void => {
try {
if (!req.user) {
res.status(401).json({
success: false,
error: {
code: "AUTHENTICATION_REQUIRED",
details: "인증이 필요합니다.",
},
});
return;
}
if (!canExecuteDDL(req.user)) {
logger.warn("DDL 실행 권한 없음", {
userId: req.user.userId,
userType: req.user.userType,
companyCode: req.user.companyCode,
ip: req.ip,
url: req.originalUrl,
});
res.status(403).json({
success: false,
error: {
code: "DDL_EXECUTION_DENIED",
details:
"DDL 실행은 최고 관리자만 가능합니다. 데이터베이스 스키마 변경은 company_code가 '*'이고 user_type이 'SUPER_ADMIN'인 사용자만 수행할 수 있습니다.",
},
});
return;
}
logger.info("DDL 실행 권한 확인 완료", {
userId: req.user.userId,
url: req.originalUrl,
});
next();
} catch (error) {
logger.error("DDL 실행 권한 확인 중 오류:", error);
res.status(500).json({
success: false,
error: {
code: "AUTHORIZATION_ERROR",
details: "권한 확인 중 오류가 발생했습니다.",
},
});
}
};

View File

@ -45,7 +45,8 @@ router.get("/users", getUserList);
router.get("/users/:userId", getUserInfo); // 사용자 상세 조회
router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회
router.patch("/users/:userId/status", changeUserStatus); // 사용자 상태 변경
router.post("/users", saveUser); // 사용자 등록/수정
router.post("/users", saveUser); // 사용자 등록/수정 (기존)
router.put("/users/:userId", saveUser); // 사용자 수정 (REST API)
router.put("/profile", updateProfile); // 프로필 수정
router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크
router.post("/users/reset-password", resetUserPassword); // 사용자 비밀번호 초기화

View File

@ -6,27 +6,39 @@ import { Router, Request, Response } from "express";
import { query, queryOne } from "../../database/db";
import { logger } from "../../utils/logger";
import { NodeFlowExecutionService } from "../../services/nodeFlowExecutionService";
import { AuthenticatedRequest } from "../../types/auth";
const router = Router();
/**
*
*/
router.get("/", async (req: Request, res: Response) => {
router.get("/", async (req: AuthenticatedRequest, res: Response) => {
try {
const flows = await query(
`
const userCompanyCode = req.user?.companyCode;
let sqlQuery = `
SELECT
flow_id as "flowId",
flow_name as "flowName",
flow_description as "flowDescription",
company_code as "companyCode",
created_at as "createdAt",
updated_at as "updatedAt"
FROM node_flows
ORDER BY updated_at DESC
`,
[]
);
`;
const params: any[] = [];
// 슈퍼 관리자가 아니면 회사별 필터링
if (userCompanyCode && userCompanyCode !== "*") {
sqlQuery += ` WHERE company_code = $1`;
params.push(userCompanyCode);
}
sqlQuery += ` ORDER BY updated_at DESC`;
const flows = await query(sqlQuery, params);
return res.json({
success: true,
@ -86,9 +98,10 @@ router.get("/:flowId", async (req: Request, res: Response) => {
/**
* ()
*/
router.post("/", async (req: Request, res: Response) => {
router.post("/", async (req: AuthenticatedRequest, res: Response) => {
try {
const { flowName, flowDescription, flowData } = req.body;
const userCompanyCode = req.user?.companyCode || "*";
if (!flowName || !flowData) {
return res.status(400).json({
@ -99,14 +112,16 @@ router.post("/", async (req: Request, res: Response) => {
const result = await queryOne(
`
INSERT INTO node_flows (flow_name, flow_description, flow_data)
VALUES ($1, $2, $3)
INSERT INTO node_flows (flow_name, flow_description, flow_data, company_code)
VALUES ($1, $2, $3, $4)
RETURNING flow_id as "flowId"
`,
[flowName, flowDescription || "", flowData]
[flowName, flowDescription || "", flowData, userCompanyCode]
);
logger.info(`플로우 저장 성공: ${result.flowId}`);
logger.info(
`플로우 저장 성공: ${result.flowId} (회사: ${userCompanyCode})`
);
return res.json({
success: true,

View File

@ -9,6 +9,7 @@ import {
} from "../types/externalDbTypes";
import { authenticateToken } from "../middleware/authMiddleware";
import { AuthenticatedRequest } from "../types/auth";
import logger from "../utils/logger";
const router = Router();
@ -53,10 +54,22 @@ router.get(
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
try {
const userCompanyCode = req.user?.companyCode;
// 슈퍼 관리자는 쿼리 파라미터로 회사 지정 가능, 일반/회사 관리자는 자신의 회사만
let companyCodeFilter: string | undefined;
if (userCompanyCode === "*") {
// 슈퍼 관리자: 쿼리 파라미터 사용 또는 전체
companyCodeFilter = req.query.company_code as string;
} else {
// 회사 관리자/일반 사용자: 강제로 자신의 회사 코드 적용
companyCodeFilter = userCompanyCode;
}
const filter: ExternalDbConnectionFilter = {
db_type: req.query.db_type as string,
is_active: req.query.is_active as string,
company_code: req.query.company_code as string,
company_code: companyCodeFilter,
search: req.query.search as string,
};
@ -67,6 +80,13 @@ router.get(
}
});
logger.info("외부 DB 연결 목록 조회", {
userId: req.user?.userId,
userCompanyCode,
filterCompanyCode: companyCodeFilter,
filter,
});
const result = await ExternalDbConnectionService.getConnections(filter);
if (result.success) {
@ -470,12 +490,32 @@ router.get(
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
try {
// 로그인한 사용자의 회사 코드 가져오기
const userCompanyCode = req.user?.companyCode;
// 슈퍼 관리자는 쿼리 파라미터로 지정한 회사 또는 전체(*) 조회 가능
// 일반 사용자/회사 관리자는 자신의 회사만 조회 가능
let companyCodeFilter: string;
if (userCompanyCode === "*") {
// 슈퍼 관리자
companyCodeFilter = (req.query.company_code as string) || "*";
} else {
// 회사 관리자 또는 일반 사용자
companyCodeFilter = userCompanyCode || "*";
}
// 활성 상태의 외부 커넥션 조회
const filter: ExternalDbConnectionFilter = {
is_active: "Y",
company_code: (req.query.company_code as string) || "*",
company_code: companyCodeFilter,
};
logger.info("제어관리용 활성 커넥션 조회", {
userId: req.user?.userId,
userCompanyCode,
filterCompanyCode: companyCodeFilter,
});
const externalConnections =
await ExternalDbConnectionService.getConnections(filter);

View File

@ -9,6 +9,9 @@ import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
const flowController = new FlowController();
// 모든 플로우 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// ==================== 플로우 정의 ====================
router.post("/definitions", flowController.createFlowDefinition);
router.get("/definitions", flowController.getFlowDefinitions);
@ -33,8 +36,8 @@ router.get("/:flowId/step/:stepId/list", flowController.getStepDataList);
router.get("/:flowId/steps/counts", flowController.getAllStepCounts);
// ==================== 데이터 이동 ====================
router.post("/move", authenticateToken, flowController.moveData);
router.post("/move-batch", authenticateToken, flowController.moveBatchData);
router.post("/move", flowController.moveData);
router.post("/move-batch", flowController.moveBatchData);
// ==================== 오딧 로그 ====================
router.get("/audit/:flowId/:recordId", flowController.getAuditLogs);

View File

@ -0,0 +1,79 @@
import { Router } from "express";
import {
getRoleGroups,
getRoleGroupById,
createRoleGroup,
updateRoleGroup,
deleteRoleGroup,
getRoleMembers,
addRoleMembers,
updateRoleMembers,
removeRoleMembers,
getMenuPermissions,
setMenuPermissions,
getUserRoleGroups,
getAllMenus,
} from "../controllers/roleController";
import { authenticateToken } from "../middleware/authMiddleware";
import { requireAdmin } from "../middleware/permissionMiddleware";
const router = Router();
// 모든 role 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
/**
* CRUD
*/
// 권한 그룹 목록 조회 (회사별)
router.get("/", requireAdmin, getRoleGroups);
// 권한 그룹 상세 조회
router.get("/:id", requireAdmin, getRoleGroupById);
// 권한 그룹 생성 (회사 관리자 이상)
router.post("/", requireAdmin, createRoleGroup);
// 권한 그룹 수정 (회사 관리자 이상)
router.put("/:id", requireAdmin, updateRoleGroup);
// 권한 그룹 삭제 (회사 관리자 이상)
router.delete("/:id", requireAdmin, deleteRoleGroup);
/**
*
*/
// 권한 그룹 멤버 목록 조회
router.get("/:id/members", requireAdmin, getRoleMembers);
// 권한 그룹 멤버 일괄 업데이트 (전체 교체)
router.put("/:id/members", requireAdmin, updateRoleMembers);
// 권한 그룹 멤버 추가 (여러 명)
router.post("/:id/members", requireAdmin, addRoleMembers);
// 권한 그룹 멤버 제거 (여러 명)
router.delete("/:id/members", requireAdmin, removeRoleMembers);
/**
*
*/
// 전체 메뉴 목록 조회 (권한 설정용)
router.get("/menus/all", requireAdmin, getAllMenus);
// 메뉴 권한 목록 조회
router.get("/:id/menu-permissions", requireAdmin, getMenuPermissions);
// 메뉴 권한 설정
router.put("/:id/menu-permissions", requireAdmin, setMenuPermissions);
/**
*
*/
// 현재 사용자가 속한 권한 그룹 조회
router.get("/user/my-groups", getUserRoleGroups);
// 특정 사용자가 속한 권한 그룹 조회
router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups);
export default router;

View File

@ -0,0 +1,554 @@
import { query } from "../database/db";
import { logger } from "../utils/logger";
/**
*
*/
export interface RoleGroup {
objid: number;
authName: string;
authCode: string;
companyCode: string;
status: string;
writer: string;
regdate: Date;
memberCount?: number;
menuCount?: number;
memberNames?: string;
}
/**
*
*/
export interface RoleMember {
objid: number;
masterObjid: number;
userId: string;
userName?: string;
deptName?: string;
positionName?: string;
writer: string;
regdate: Date;
}
/**
*
*/
export interface MenuPermission {
objid: number;
menuObjid: number;
authObjid: number;
menuName?: string;
createYn: string;
readYn: string;
updateYn: string;
deleteYn: string;
writer: string;
regdate: Date;
}
/**
*
*/
export class RoleService {
/**
*
* @param companyCode - (undefined )
* @param search -
*/
static async getRoleGroups(
companyCode?: string,
search?: string
): Promise<RoleGroup[]> {
try {
let sql = `
SELECT
objid,
auth_name AS "authName",
auth_code AS "authCode",
company_code AS "companyCode",
status,
writer,
regdate,
(SELECT COUNT(*) FROM authority_sub_user asu WHERE asu.master_objid = am.objid) AS "memberCount",
(SELECT COUNT(*) FROM rel_menu_auth rma WHERE rma.auth_objid = am.objid) AS "menuCount",
(SELECT STRING_AGG(ui.user_name, ', ' ORDER BY ui.user_name)
FROM authority_sub_user asu
JOIN user_info ui ON asu.user_id = ui.user_id
WHERE asu.master_objid = am.objid) AS "memberNames"
FROM authority_master am
WHERE 1=1
`;
const params: any[] = [];
let paramIndex = 1;
// 회사 코드 필터 (companyCode가 undefined면 전체 조회)
if (companyCode) {
sql += ` AND company_code = $${paramIndex}`;
params.push(companyCode);
paramIndex++;
}
// 검색어 필터
if (search && search.trim()) {
sql += ` AND (auth_name ILIKE $${paramIndex} OR auth_code ILIKE $${paramIndex})`;
params.push(`%${search.trim()}%`);
paramIndex++;
}
sql += ` ORDER BY regdate DESC`;
logger.info("권한 그룹 조회 SQL", { sql, params });
const result = await query<RoleGroup>(sql, params);
logger.info("권한 그룹 조회 결과", { count: result.length });
return result;
} catch (error) {
logger.error("권한 그룹 목록 조회 실패", { error, companyCode, search });
throw error;
}
}
/**
*
*/
static async getRoleGroupById(objid: number): Promise<RoleGroup | null> {
try {
const sql = `
SELECT
objid,
auth_name AS "authName",
auth_code AS "authCode",
company_code AS "companyCode",
status,
writer,
regdate
FROM authority_master
WHERE objid = $1
`;
const result = await query<RoleGroup>(sql, [objid]);
return result.length > 0 ? result[0] : null;
} catch (error) {
logger.error("권한 그룹 상세 조회 실패", { error, objid });
throw error;
}
}
/**
*
*/
static async createRoleGroup(data: {
authName: string;
authCode: string;
companyCode: string;
writer: string;
}): Promise<RoleGroup> {
try {
const sql = `
INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status, writer, regdate)
VALUES (nextval('seq_authority_master'), $1, $2, $3, 'active', $4, NOW())
RETURNING objid, auth_name AS "authName", auth_code AS "authCode",
company_code AS "companyCode", status, writer, regdate
`;
const result = await query<RoleGroup>(sql, [
data.authName,
data.authCode,
data.companyCode,
data.writer,
]);
logger.info("권한 그룹 생성 성공", {
objid: result[0].objid,
authName: data.authName,
});
return result[0];
} catch (error) {
logger.error("권한 그룹 생성 실패", { error, data });
throw error;
}
}
/**
*
*/
static async updateRoleGroup(
objid: number,
data: {
authName?: string;
authCode?: string;
status?: string;
}
): Promise<RoleGroup> {
try {
const updates: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (data.authName !== undefined) {
updates.push(`auth_name = $${paramIndex}`);
params.push(data.authName);
paramIndex++;
}
if (data.authCode !== undefined) {
updates.push(`auth_code = $${paramIndex}`);
params.push(data.authCode);
paramIndex++;
}
if (data.status !== undefined) {
updates.push(`status = $${paramIndex}`);
params.push(data.status);
paramIndex++;
}
if (updates.length === 0) {
throw new Error("수정할 데이터가 없습니다");
}
params.push(objid);
const sql = `
UPDATE authority_master
SET ${updates.join(", ")}
WHERE objid = $${paramIndex}
RETURNING objid, auth_name AS "authName", auth_code AS "authCode",
company_code AS "companyCode", status, writer, regdate
`;
const result = await query<RoleGroup>(sql, params);
if (result.length === 0) {
throw new Error("권한 그룹을 찾을 수 없습니다");
}
logger.info("권한 그룹 수정 성공", { objid, updates });
return result[0];
} catch (error) {
logger.error("권한 그룹 수정 실패", { error, objid, data });
throw error;
}
}
/**
*
*/
static async deleteRoleGroup(objid: number): Promise<void> {
try {
// CASCADE로 연결된 데이터도 함께 삭제됨 (authority_sub_user, rel_menu_auth)
await query("DELETE FROM authority_master WHERE objid = $1", [objid]);
logger.info("권한 그룹 삭제 성공", { objid });
} catch (error) {
logger.error("권한 그룹 삭제 실패", { error, objid });
throw error;
}
}
/**
*
*/
static async getRoleMembers(masterObjid: number): Promise<RoleMember[]> {
try {
const sql = `
SELECT
asu.objid,
asu.master_objid AS "masterObjid",
asu.user_id AS "userId",
ui.user_name AS "userName",
ui.dept_name AS "deptName",
ui.position_name AS "positionName",
asu.writer,
asu.regdate
FROM authority_sub_user asu
JOIN user_info ui ON asu.user_id = ui.user_id
WHERE asu.master_objid = $1
ORDER BY ui.user_name
`;
const result = await query<RoleMember>(sql, [masterObjid]);
return result;
} catch (error) {
logger.error("권한 그룹 멤버 조회 실패", { error, masterObjid });
throw error;
}
}
/**
* ( )
*/
static async addRoleMembers(
masterObjid: number,
userIds: string[],
writer: string
): Promise<void> {
try {
// 이미 존재하는 멤버 제외
const existingSql = `
SELECT user_id
FROM authority_sub_user
WHERE master_objid = $1 AND user_id = ANY($2)
`;
const existing = await query<{ user_id: string }>(existingSql, [
masterObjid,
userIds,
]);
const existingIds = new Set(
existing.map((row: { user_id: string }) => row.user_id)
);
const newUserIds = userIds.filter((userId) => !existingIds.has(userId));
if (newUserIds.length === 0) {
logger.info("추가할 신규 멤버가 없습니다", { masterObjid, userIds });
return;
}
// 배치 삽입
const values = newUserIds
.map(
(_, index) =>
`(nextval('seq_authority_sub_user'), $1, $${index + 2}, $${newUserIds.length + 2}, NOW())`
)
.join(", ");
const sql = `
INSERT INTO authority_sub_user (objid, master_objid, user_id, writer, regdate)
VALUES ${values}
`;
await query(sql, [masterObjid, ...newUserIds, writer]);
// 히스토리 기록
for (const userId of newUserIds) {
await this.insertAuthorityHistory(masterObjid, userId, "ADD", writer);
}
logger.info("권한 그룹 멤버 추가 성공", {
masterObjid,
count: newUserIds.length,
});
} catch (error) {
logger.error("권한 그룹 멤버 추가 실패", { error, masterObjid, userIds });
throw error;
}
}
/**
* ( )
*/
static async removeRoleMembers(
masterObjid: number,
userIds: string[],
writer: string
): Promise<void> {
try {
await query(
"DELETE FROM authority_sub_user WHERE master_objid = $1 AND user_id = ANY($2)",
[masterObjid, userIds]
);
// 히스토리 기록
for (const userId of userIds) {
await this.insertAuthorityHistory(
masterObjid,
userId,
"REMOVE",
writer
);
}
logger.info("권한 그룹 멤버 제거 성공", {
masterObjid,
count: userIds.length,
});
} catch (error) {
logger.error("권한 그룹 멤버 제거 실패", { error, masterObjid, userIds });
throw error;
}
}
/**
*
*/
private static async insertAuthorityHistory(
masterObjid: number,
userId: string,
historyType: "ADD" | "REMOVE",
writer: string
): Promise<void> {
try {
const sql = `
INSERT INTO authority_master_history
(objid, parent_objid, parent_name, parent_code, user_id, active, history_type, writer, reg_date)
SELECT
nextval('seq_authority_master'),
$1,
am.auth_name,
am.auth_code,
$2,
am.status,
$3,
$4,
NOW()
FROM authority_master am
WHERE am.objid = $1
`;
await query(sql, [masterObjid, userId, historyType, writer]);
} catch (error) {
logger.error("권한 히스토리 기록 실패", {
error,
masterObjid,
userId,
historyType,
});
// 히스토리 기록 실패는 메인 작업을 중단하지 않음
}
}
/**
*
*/
static async getMenuPermissions(
authObjid: number
): Promise<MenuPermission[]> {
try {
const sql = `
SELECT
rma.objid,
rma.menu_objid AS "menuObjid",
rma.auth_objid AS "authObjid",
mi.menu_name_kor AS "menuName",
mi.menu_code AS "menuCode",
mi.menu_url AS "menuUrl",
rma.create_yn AS "createYn",
rma.read_yn AS "readYn",
rma.update_yn AS "updateYn",
rma.delete_yn AS "deleteYn",
rma.execute_yn AS "executeYn",
rma.export_yn AS "exportYn",
rma.writer,
rma.regdate
FROM rel_menu_auth rma
LEFT JOIN menu_info mi ON rma.menu_objid = mi.objid
WHERE rma.auth_objid = $1
ORDER BY mi.menu_name_kor
`;
const result = await query<MenuPermission>(sql, [authObjid]);
return result;
} catch (error) {
logger.error("메뉴 권한 조회 실패", { error, authObjid });
throw error;
}
}
/**
* ( )
*/
static async setMenuPermissions(
authObjid: number,
permissions: Array<{
menuObjid: number;
createYn: string;
readYn: string;
updateYn: string;
deleteYn: string;
}>,
writer: string
): Promise<void> {
try {
// 기존 권한 삭제
await query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [
authObjid,
]);
// 새로운 권한 삽입
if (permissions.length > 0) {
const values = permissions
.map(
(_, index) =>
`(nextval('seq_rel_menu_auth'), $${index * 5 + 2}, $1, $${index * 5 + 3}, $${index * 5 + 4}, $${index * 5 + 5}, $${index * 5 + 6}, $${permissions.length * 5 + 2}, NOW())`
)
.join(", ");
const params = permissions.flatMap((p) => [
p.menuObjid,
p.createYn,
p.readYn,
p.updateYn,
p.deleteYn,
]);
const sql = `
INSERT INTO rel_menu_auth (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer, regdate)
VALUES ${values}
`;
await query(sql, [authObjid, ...params, writer]);
}
logger.info("메뉴 권한 설정 성공", {
authObjid,
count: permissions.length,
});
} catch (error) {
logger.error("메뉴 권한 설정 실패", { error, authObjid, permissions });
throw error;
}
}
/**
*
*/
static async getUserRoleGroups(
userId: string,
companyCode: string
): Promise<RoleGroup[]> {
try {
const sql = `
SELECT
am.objid,
am.auth_name AS "authName",
am.auth_code AS "authCode",
am.company_code AS "companyCode",
am.status,
am.writer,
am.regdate
FROM authority_master am
JOIN authority_sub_user asu ON am.objid = asu.master_objid
WHERE asu.user_id = $1
AND am.company_code = $2
AND am.status = 'active'
ORDER BY am.auth_name
`;
const result = await query<RoleGroup>(sql, [userId, companyCode]);
return result;
} catch (error) {
logger.error("사용자 권한 그룹 조회 실패", {
error,
userId,
companyCode,
});
throw error;
}
}
/**
* ( )
*/
/**
* ( )
*/
static async getAllMenus(companyCode?: string): Promise<any[]> {
try {
logger.info("🔍 전체 메뉴 목록 조회 시작", { companyCode });
let whereConditions: string[] = ["status = 'active'"];
const params: any[] = [];
let paramIndex = 1;
// 회사 코드 필터 (선택적)

View File

@ -0,0 +1,66 @@
/**
* ( )
*/
static async getAllMenus(companyCode?: string): Promise<any[]> {
try {
logger.info("🔍 전체 메뉴 목록 조회 시작", { companyCode });
let whereConditions: string[] = ["status = 'active'"];
const params: any[] = [];
let paramIndex = 1;
// 회사 코드 필터 (선택적)
// 공통 메뉴(*)와 특정 회사 메뉴를 모두 조회
if (companyCode) {
whereConditions.push(`(company_code = \$${paramIndex} OR company_code = '*')`);
params.push(companyCode);
paramIndex++;
logger.info("📋 회사 코드 필터 적용", { companyCode });
} else {
logger.info("📋 회사 코드 필터 없음 (전체 조회)");
}
const whereClause = whereConditions.join(" AND ");
const sql = `
SELECT
objid,
menu_name_kor AS "menuName",
menu_name_eng AS "menuNameEng",
menu_code AS "menuCode",
menu_url AS "menuUrl",
menu_type AS "menuType",
parent_obj_id AS "parentObjid",
seq AS "sortOrder",
company_code AS "companyCode"
FROM menu_info
WHERE ${whereClause}
ORDER BY seq, menu_name_kor
`;
logger.info("🔍 SQL 쿼리 실행", {
whereClause,
params,
sql: sql.substring(0, 200) + "...",
});
const result = await query<any>(sql, params);
logger.info("✅ 메뉴 목록 조회 성공", {
count: result.length,
companyCode,
menus: result.map((m) => ({
objid: m.objid,
name: m.menuName,
code: m.menuCode,
companyCode: m.companyCode,
})),
});
return result;
} catch (error) {
logger.error("❌ 메뉴 목록 조회 실패", { error, companyCode });
throw error;
}
}
}

View File

@ -185,6 +185,9 @@ export class AuthService {
//});
// PersonBean 형태로 변환 (null 값을 undefined로 변환)
const companyCode = userInfo.company_code || "ILSHIN";
const userType = userInfo.user_type || "USER";
const personBean: PersonBean = {
userId: userInfo.user_id,
userName: userInfo.user_name || "",
@ -197,15 +200,21 @@ export class AuthService {
email: userInfo.email || undefined,
tel: userInfo.tel || undefined,
cellPhone: userInfo.cell_phone || undefined,
userType: userInfo.user_type || undefined,
userType: userType,
userTypeName: userInfo.user_type_name || undefined,
partnerObjid: userInfo.partner_objid || undefined,
authName: authNames || undefined,
companyCode: userInfo.company_code || "ILSHIN",
companyCode: companyCode,
photo: userInfo.photo
? `data:image/jpeg;base64,${Buffer.from(userInfo.photo).toString("base64")}`
: undefined,
locale: userInfo.locale || "KR",
// 권한 레벨 정보 추가 (3단계 체계)
isSuperAdmin: companyCode === "*" && userType === "SUPER_ADMIN",
isCompanyAdmin: userType === "COMPANY_ADMIN" && companyCode !== "*",
isAdmin:
(companyCode === "*" && userType === "SUPER_ADMIN") ||
userType === "COMPANY_ADMIN",
};
//console.log("📦 AuthService - 최종 PersonBean:", {

View File

@ -1,3 +1,4 @@
// @ts-nocheck
/**
*
*/

View File

@ -1,3 +1,4 @@
// @ts-nocheck
/**
*
*/
@ -15,20 +16,24 @@ export class FlowDefinitionService {
*/
async create(
request: CreateFlowDefinitionRequest,
userId: string
userId: string,
userCompanyCode?: string
): Promise<FlowDefinition> {
const companyCode = request.companyCode || userCompanyCode || "*";
console.log("🔥 flowDefinitionService.create called with:", {
name: request.name,
description: request.description,
tableName: request.tableName,
dbSourceType: request.dbSourceType,
dbConnectionId: request.dbConnectionId,
companyCode,
userId,
});
const query = `
INSERT INTO flow_definition (name, description, table_name, db_source_type, db_connection_id, created_by)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO flow_definition (name, description, table_name, db_source_type, db_connection_id, company_code, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
`;
@ -38,6 +43,7 @@ export class FlowDefinitionService {
request.tableName || null,
request.dbSourceType || "internal",
request.dbConnectionId || null,
companyCode,
userId,
];
@ -53,12 +59,29 @@ export class FlowDefinitionService {
*/
async findAll(
tableName?: string,
isActive?: boolean
isActive?: boolean,
companyCode?: string
): Promise<FlowDefinition[]> {
console.log("🔍 flowDefinitionService.findAll called with:", {
tableName,
isActive,
companyCode,
});
let query = "SELECT * FROM flow_definition WHERE 1=1";
const params: any[] = [];
let paramIndex = 1;
// 회사 코드 필터링
if (companyCode && companyCode !== "*") {
query += ` AND company_code = $${paramIndex}`;
params.push(companyCode);
paramIndex++;
console.log(`✅ Company filter applied: company_code = ${companyCode}`);
} else {
console.log(`⚠️ No company filter (companyCode: ${companyCode})`);
}
if (tableName) {
query += ` AND table_name = $${paramIndex}`;
params.push(tableName);
@ -73,7 +96,11 @@ export class FlowDefinitionService {
query += " ORDER BY created_at DESC";
console.log("📋 Final query:", query);
console.log("📋 Query params:", params);
const result = await db.query(query, params);
console.log(`📊 Found ${result.length} flow definitions`);
return result.map(this.mapToFlowDefinition);
}
@ -179,6 +206,7 @@ export class FlowDefinitionService {
tableName: row.table_name,
dbSourceType: row.db_source_type || "internal",
dbConnectionId: row.db_connection_id,
companyCode: row.company_code || "*",
isActive: row.is_active,
createdBy: row.created_by,
createdAt: row.created_at,

View File

@ -1,3 +1,4 @@
// @ts-nocheck
/**
*
*

View File

@ -1,3 +1,4 @@
// @ts-nocheck
/**
*
*/

View File

@ -0,0 +1,610 @@
import { query } from "../database/db";
import { logger } from "../utils/logger";
/**
*
*/
export interface RoleGroup {
objid: number;
authName: string;
authCode: string;
companyCode: string;
status: string;
writer: string;
regdate: Date;
memberCount?: number;
menuCount?: number;
memberNames?: string;
}
/**
*
*/
export interface RoleMember {
objid: number;
masterObjid: number;
userId: string;
userName?: string;
deptName?: string;
positionName?: string;
writer: string;
regdate: Date;
}
/**
*
*/
export interface MenuPermission {
objid: number;
menuObjid: number;
authObjid: number;
menuName?: string;
createYn: string;
readYn: string;
updateYn: string;
deleteYn: string;
writer: string;
regdate: Date;
}
/**
*
*/
export class RoleService {
/**
*
* @param companyCode - (undefined )
* @param search -
*/
static async getRoleGroups(
companyCode?: string,
search?: string
): Promise<RoleGroup[]> {
try {
let sql = `
SELECT
objid,
auth_name AS "authName",
auth_code AS "authCode",
company_code AS "companyCode",
status,
writer,
regdate,
(SELECT COUNT(*) FROM authority_sub_user asu WHERE asu.master_objid = am.objid) AS "memberCount",
(SELECT COUNT(*) FROM rel_menu_auth rma WHERE rma.auth_objid = am.objid) AS "menuCount",
(SELECT STRING_AGG(ui.user_name, ', ' ORDER BY ui.user_name)
FROM authority_sub_user asu
JOIN user_info ui ON asu.user_id = ui.user_id
WHERE asu.master_objid = am.objid) AS "memberNames"
FROM authority_master am
WHERE 1=1
`;
const params: any[] = [];
let paramIndex = 1;
// 회사 코드 필터 (companyCode가 undefined면 전체 조회)
if (companyCode) {
sql += ` AND company_code = $${paramIndex}`;
params.push(companyCode);
paramIndex++;
}
// 검색어 필터
if (search && search.trim()) {
sql += ` AND (auth_name ILIKE $${paramIndex} OR auth_code ILIKE $${paramIndex})`;
params.push(`%${search.trim()}%`);
paramIndex++;
}
sql += ` ORDER BY regdate DESC`;
logger.info("권한 그룹 조회 SQL", { sql, params });
const result = await query<RoleGroup>(sql, params);
logger.info("권한 그룹 조회 결과", { count: result.length });
return result;
} catch (error) {
logger.error("권한 그룹 목록 조회 실패", { error, companyCode, search });
throw error;
}
}
/**
*
*/
static async getRoleGroupById(objid: number): Promise<RoleGroup | null> {
try {
const sql = `
SELECT
objid,
auth_name AS "authName",
auth_code AS "authCode",
company_code AS "companyCode",
status,
writer,
regdate
FROM authority_master
WHERE objid = $1
`;
const result = await query<RoleGroup>(sql, [objid]);
return result.length > 0 ? result[0] : null;
} catch (error) {
logger.error("권한 그룹 상세 조회 실패", { error, objid });
throw error;
}
}
/**
*
*/
static async createRoleGroup(data: {
authName: string;
authCode: string;
companyCode: string;
writer: string;
}): Promise<RoleGroup> {
try {
const sql = `
INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status, writer, regdate)
VALUES (nextval('seq_authority_master'), $1, $2, $3, 'active', $4, NOW())
RETURNING objid, auth_name AS "authName", auth_code AS "authCode",
company_code AS "companyCode", status, writer, regdate
`;
const result = await query<RoleGroup>(sql, [
data.authName,
data.authCode,
data.companyCode,
data.writer,
]);
logger.info("권한 그룹 생성 성공", {
objid: result[0].objid,
authName: data.authName,
});
return result[0];
} catch (error) {
logger.error("권한 그룹 생성 실패", { error, data });
throw error;
}
}
/**
*
*/
static async updateRoleGroup(
objid: number,
data: {
authName?: string;
authCode?: string;
status?: string;
}
): Promise<RoleGroup> {
try {
const updates: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (data.authName !== undefined) {
updates.push(`auth_name = $${paramIndex}`);
params.push(data.authName);
paramIndex++;
}
if (data.authCode !== undefined) {
updates.push(`auth_code = $${paramIndex}`);
params.push(data.authCode);
paramIndex++;
}
if (data.status !== undefined) {
updates.push(`status = $${paramIndex}`);
params.push(data.status);
paramIndex++;
}
if (updates.length === 0) {
throw new Error("수정할 데이터가 없습니다");
}
params.push(objid);
const sql = `
UPDATE authority_master
SET ${updates.join(", ")}
WHERE objid = $${paramIndex}
RETURNING objid, auth_name AS "authName", auth_code AS "authCode",
company_code AS "companyCode", status, writer, regdate
`;
const result = await query<RoleGroup>(sql, params);
if (result.length === 0) {
throw new Error("권한 그룹을 찾을 수 없습니다");
}
logger.info("권한 그룹 수정 성공", { objid, updates });
return result[0];
} catch (error) {
logger.error("권한 그룹 수정 실패", { error, objid, data });
throw error;
}
}
/**
*
*/
static async deleteRoleGroup(objid: number): Promise<void> {
try {
// CASCADE로 연결된 데이터도 함께 삭제됨 (authority_sub_user, rel_menu_auth)
await query("DELETE FROM authority_master WHERE objid = $1", [objid]);
logger.info("권한 그룹 삭제 성공", { objid });
} catch (error) {
logger.error("권한 그룹 삭제 실패", { error, objid });
throw error;
}
}
/**
*
*/
static async getRoleMembers(masterObjid: number): Promise<RoleMember[]> {
try {
const sql = `
SELECT
asu.objid,
asu.master_objid AS "masterObjid",
asu.user_id AS "userId",
ui.user_name AS "userName",
ui.dept_name AS "deptName",
ui.position_name AS "positionName",
asu.writer,
asu.regdate
FROM authority_sub_user asu
JOIN user_info ui ON asu.user_id = ui.user_id
WHERE asu.master_objid = $1
ORDER BY ui.user_name
`;
const result = await query<RoleMember>(sql, [masterObjid]);
return result;
} catch (error) {
logger.error("권한 그룹 멤버 조회 실패", { error, masterObjid });
throw error;
}
}
/**
* ( )
*/
static async addRoleMembers(
masterObjid: number,
userIds: string[],
writer: string
): Promise<void> {
try {
// 이미 존재하는 멤버 제외
const existingSql = `
SELECT user_id
FROM authority_sub_user
WHERE master_objid = $1 AND user_id = ANY($2)
`;
const existing = await query<{ user_id: string }>(existingSql, [
masterObjid,
userIds,
]);
const existingIds = new Set(
existing.map((row: { user_id: string }) => row.user_id)
);
const newUserIds = userIds.filter((userId) => !existingIds.has(userId));
if (newUserIds.length === 0) {
logger.info("추가할 신규 멤버가 없습니다", { masterObjid, userIds });
return;
}
// 배치 삽입
const values = newUserIds
.map(
(_, index) =>
`(nextval('seq_authority_sub_user'), $1, $${index + 2}, $${newUserIds.length + 2}, NOW())`
)
.join(", ");
const sql = `
INSERT INTO authority_sub_user (objid, master_objid, user_id, writer, regdate)
VALUES ${values}
`;
await query(sql, [masterObjid, ...newUserIds, writer]);
// 히스토리 기록
for (const userId of newUserIds) {
await this.insertAuthorityHistory(masterObjid, userId, "ADD", writer);
}
logger.info("권한 그룹 멤버 추가 성공", {
masterObjid,
count: newUserIds.length,
});
} catch (error) {
logger.error("권한 그룹 멤버 추가 실패", { error, masterObjid, userIds });
throw error;
}
}
/**
* ( )
*/
static async removeRoleMembers(
masterObjid: number,
userIds: string[],
writer: string
): Promise<void> {
try {
await query(
"DELETE FROM authority_sub_user WHERE master_objid = $1 AND user_id = ANY($2)",
[masterObjid, userIds]
);
// 히스토리 기록
for (const userId of userIds) {
await this.insertAuthorityHistory(
masterObjid,
userId,
"REMOVE",
writer
);
}
logger.info("권한 그룹 멤버 제거 성공", {
masterObjid,
count: userIds.length,
});
} catch (error) {
logger.error("권한 그룹 멤버 제거 실패", { error, masterObjid, userIds });
throw error;
}
}
/**
*
*/
private static async insertAuthorityHistory(
masterObjid: number,
userId: string,
historyType: "ADD" | "REMOVE",
writer: string
): Promise<void> {
try {
const sql = `
INSERT INTO authority_master_history
(objid, parent_objid, parent_name, parent_code, user_id, active, history_type, writer, reg_date)
SELECT
nextval('seq_authority_master'),
$1,
am.auth_name,
am.auth_code,
$2,
am.status,
$3,
$4,
NOW()
FROM authority_master am
WHERE am.objid = $1
`;
await query(sql, [masterObjid, userId, historyType, writer]);
} catch (error) {
logger.error("권한 히스토리 기록 실패", {
error,
masterObjid,
userId,
historyType,
});
// 히스토리 기록 실패는 메인 작업을 중단하지 않음
}
}
/**
*
*/
static async getMenuPermissions(
authObjid: number
): Promise<MenuPermission[]> {
try {
const sql = `
SELECT
rma.objid,
rma.menu_objid AS "menuObjid",
rma.auth_objid AS "authObjid",
mi.menu_name_kor AS "menuName",
mi.menu_code AS "menuCode",
mi.menu_url AS "menuUrl",
rma.create_yn AS "createYn",
rma.read_yn AS "readYn",
rma.update_yn AS "updateYn",
rma.delete_yn AS "deleteYn",
rma.execute_yn AS "executeYn",
rma.export_yn AS "exportYn",
rma.writer,
rma.regdate
FROM rel_menu_auth rma
LEFT JOIN menu_info mi ON rma.menu_objid = mi.objid
WHERE rma.auth_objid = $1
ORDER BY mi.menu_name_kor
`;
const result = await query<MenuPermission>(sql, [authObjid]);
return result;
} catch (error) {
logger.error("메뉴 권한 조회 실패", { error, authObjid });
throw error;
}
}
/**
* ( )
*/
static async setMenuPermissions(
authObjid: number,
permissions: Array<{
menuObjid: number;
createYn: string;
readYn: string;
updateYn: string;
deleteYn: string;
}>,
writer: string
): Promise<void> {
try {
// 기존 권한 삭제
await query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [
authObjid,
]);
// 새로운 권한 삽입
if (permissions.length > 0) {
const values = permissions
.map(
(_, index) =>
`(nextval('seq_rel_menu_auth'), $${index * 5 + 2}, $1, $${index * 5 + 3}, $${index * 5 + 4}, $${index * 5 + 5}, $${index * 5 + 6}, $${permissions.length * 5 + 2}, NOW())`
)
.join(", ");
const params = permissions.flatMap((p) => [
p.menuObjid,
p.createYn,
p.readYn,
p.updateYn,
p.deleteYn,
]);
const sql = `
INSERT INTO rel_menu_auth (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer, regdate)
VALUES ${values}
`;
await query(sql, [authObjid, ...params, writer]);
}
logger.info("메뉴 권한 설정 성공", {
authObjid,
count: permissions.length,
});
} catch (error) {
logger.error("메뉴 권한 설정 실패", { error, authObjid, permissions });
throw error;
}
}
/**
*
*/
static async getUserRoleGroups(
userId: string,
companyCode: string
): Promise<RoleGroup[]> {
try {
const sql = `
SELECT
am.objid,
am.auth_name AS "authName",
am.auth_code AS "authCode",
am.company_code AS "companyCode",
am.status,
am.writer,
am.regdate
FROM authority_master am
JOIN authority_sub_user asu ON am.objid = asu.master_objid
WHERE asu.user_id = $1
AND am.company_code = $2
AND am.status = 'active'
ORDER BY am.auth_name
`;
const result = await query<RoleGroup>(sql, [userId, companyCode]);
return result;
} catch (error) {
logger.error("사용자 권한 그룹 조회 실패", {
error,
userId,
companyCode,
});
throw error;
}
}
/**
* ( )
*/
/**
* ( )
*/
static async getAllMenus(companyCode?: string): Promise<any[]> {
try {
logger.info("🔍 전체 메뉴 목록 조회 시작", { companyCode });
let whereConditions: string[] = ["status = 'active'"];
const params: any[] = [];
let paramIndex = 1;
// 회사 코드 필터 (선택적)
// 공통 메뉴(*)와 특정 회사 메뉴를 모두 조회
// 회사 코드 필터 (선택적)
if (companyCode) {
// 특정 회사 메뉴만 조회 (공통 메뉴 제외)
whereConditions.push(`company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
logger.info("📋 회사 코드 필터 적용 (공통 메뉴 제외)", { companyCode });
} else {
logger.info("📋 회사 코드 필터 없음 (전체 조회)");
}
const whereClause = whereConditions.join(" AND ");
const sql = `
SELECT
objid,
menu_name_kor AS "menuName",
menu_name_eng AS "menuNameEng",
menu_code AS "menuCode",
menu_url AS "menuUrl",
menu_type AS "menuType",
parent_obj_id AS "parentObjid",
seq AS "sortOrder",
company_code AS "companyCode"
FROM menu_info
WHERE ${whereClause}
ORDER BY seq, menu_name_kor
`;
logger.info("🔍 SQL 쿼리 실행", {
whereClause,
params,
sql: sql.substring(0, 200) + "...",
});
const result = await query<any>(sql, params);
logger.info("✅ 메뉴 목록 조회 성공", {
count: result.length,
companyCode,
menus: result.map((m) => ({
objid: m.objid,
name: m.menuName,
code: m.menuCode,
companyCode: m.companyCode,
})),
});
return result;
} catch (error) {
logger.error("❌ 메뉴 목록 조회 실패", { error, companyCode });
throw error;
}
}
}

View File

@ -7,6 +7,15 @@ export interface LoginRequest {
password: string;
}
// 사용자 권한 레벨 (3단계 체계)
export enum UserRole {
SUPER_ADMIN = "SUPER_ADMIN", // 최고 관리자 (전체 시스템)
COMPANY_ADMIN = "COMPANY_ADMIN", // 회사 관리자 (자기 회사만)
USER = "USER", // 일반 사용자
GUEST = "GUEST", // 게스트
PARTNER = "PARTNER", // 협력업체
}
// 기존 ApiLoginController.UserInfo 클래스 포팅
export interface UserInfo {
userId: string;
@ -18,7 +27,9 @@ export interface UserInfo {
email?: string;
photo?: string;
locale?: string;
isAdmin?: boolean;
isAdmin?: boolean; // 하위 호환성 유지
isSuperAdmin?: boolean; // 슈퍼관리자 여부 (company_code === '*' && userType === 'SUPER_ADMIN')
isCompanyAdmin?: boolean; // 회사 관리자 여부 (userType === 'COMPANY_ADMIN')
}
// 기존 ApiLoginController.ApiResponse 클래스 포팅
@ -52,6 +63,10 @@ export interface PersonBean {
companyCode?: string;
photo?: string;
locale?: string;
// 권한 레벨 정보 (3단계 체계)
isSuperAdmin?: boolean; // 최고 관리자 (company_code === '*' && userType === 'SUPER_ADMIN')
isCompanyAdmin?: boolean; // 회사 관리자 (userType === 'COMPANY_ADMIN')
isAdmin?: boolean; // 관리자 (슈퍼관리자 + 회사관리자)
}
// 로그인 결과 타입 (기존 LoginService.loginPwdCheck 반환값)

View File

@ -10,6 +10,7 @@ export interface FlowDefinition {
tableName: string;
dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입
dbConnectionId?: number; // 외부 DB 연결 ID (external인 경우)
companyCode: string; // 회사 코드 (* = 공통)
isActive: boolean;
createdBy?: string;
createdAt: Date;
@ -23,6 +24,7 @@ export interface CreateFlowDefinitionRequest {
tableName: string;
dbSourceType?: "internal" | "external"; // 데이터베이스 소스 타입
dbConnectionId?: number; // 외부 DB 연결 ID
companyCode?: string; // 회사 코드 (미제공 시 사용자의 company_code 사용)
}
// 플로우 정의 수정 요청

View File

@ -1,18 +0,0 @@
declare module 'oracledb' {
export interface Connection {
execute(sql: string, bindParams?: any, options?: any): Promise<any>;
close(): Promise<void>;
}
export interface ConnectionConfig {
user: string;
password: string;
connectString: string;
}
export function getConnection(config: ConnectionConfig): Promise<Connection>;
export function createPool(config: any): Promise<any>;
export function getPool(): any;
export function close(): Promise<void>;
}

View File

@ -0,0 +1,230 @@
/**
*
* 3 체계: SUPER_ADMIN / COMPANY_ADMIN / USER
*/
import { PersonBean } from "../types/auth";
/**
* Enum
*/
export enum PermissionLevel {
SUPER_ADMIN = "SUPER_ADMIN", // 최고 관리자 (전체 시스템)
COMPANY_ADMIN = "COMPANY_ADMIN", // 회사 관리자 (자기 회사만)
USER = "USER", // 일반 사용자
}
/**
*
* @param user
* @returns
*/
export function isSuperAdmin(user?: PersonBean | null): boolean {
if (!user) return false;
return user.companyCode === "*" && user.userType === "SUPER_ADMIN";
}
/**
* ( )
* @param user
* @returns
*/
export function isCompanyAdmin(user?: PersonBean | null): boolean {
if (!user) return false;
return user.userType === "COMPANY_ADMIN" && user.companyCode !== "*";
}
/**
* ( + )
* @param user
* @returns
*/
export function isAdmin(user?: PersonBean | null): boolean {
return isSuperAdmin(user) || isCompanyAdmin(user);
}
/**
*
* @param user
* @returns
*/
export function isRegularUser(user?: PersonBean | null): boolean {
if (!user) return false;
return (
user.userType === "USER" ||
user.userType === "GUEST" ||
user.userType === "PARTNER"
);
}
/**
*
* @param user
* @returns
*/
export function getUserPermissionLevel(
user?: PersonBean | null
): PermissionLevel | null {
if (!user) return null;
if (isSuperAdmin(user)) {
return PermissionLevel.SUPER_ADMIN;
}
if (isCompanyAdmin(user)) {
return PermissionLevel.COMPANY_ADMIN;
}
return PermissionLevel.USER;
}
/**
* DDL ()
* @param user
* @returns DDL
*/
export function canExecuteDDL(user?: PersonBean | null): boolean {
return isSuperAdmin(user);
}
/**
*
* @param user
* @param targetCompanyCode
* @returns
*/
export function canAccessCompanyData(
user?: PersonBean | null,
targetCompanyCode?: string
): boolean {
if (!user) return false;
// 슈퍼관리자는 모든 회사 데이터 접근 가능
if (isSuperAdmin(user)) {
return true;
}
// 자기 회사 데이터만 접근 가능
return user.companyCode === targetCompanyCode;
}
/**
* ()
* @param user
* @param targetCompanyCode
* @returns
*/
export function canManageUsers(
user?: PersonBean | null,
targetCompanyCode?: string
): boolean {
if (!user) return false;
// 슈퍼관리자는 모든 회사 사용자 관리 가능
if (isSuperAdmin(user)) {
return true;
}
// 회사 관리자는 자기 회사 사용자만 관리 가능
if (isCompanyAdmin(user)) {
return user.companyCode === targetCompanyCode;
}
return false;
}
/**
* ()
* @param user
* @param targetCompanyCode
* @returns
*/
export function canManageCompanySettings(
user?: PersonBean | null,
targetCompanyCode?: string
): boolean {
return canManageUsers(user, targetCompanyCode);
}
/**
* / ()
* @param user
* @returns /
*/
export function canManageCompanies(user?: PersonBean | null): boolean {
return isSuperAdmin(user);
}
/**
* ()
* @param user
* @returns
*/
export function canManageSystemSettings(user?: PersonBean | null): boolean {
return isSuperAdmin(user);
}
/**
*
* @param requiredLevel
* @returns
*/
export function getPermissionErrorMessage(
requiredLevel: PermissionLevel
): string {
const messages: Record<PermissionLevel, string> = {
[PermissionLevel.SUPER_ADMIN]:
"최고 관리자 권한이 필요합니다. 전체 시스템을 관리할 수 있는 권한이 없습니다.",
[PermissionLevel.COMPANY_ADMIN]:
"관리자 권한이 필요합니다. 회사 관리자 이상의 권한이 필요합니다.",
[PermissionLevel.USER]: "인증된 사용자 권한이 필요합니다.",
};
return messages[requiredLevel] || "권한이 부족합니다.";
}
/**
*
* @param requiredLevel
* @returns
*/
export function createPermissionError(requiredLevel: PermissionLevel) {
return {
success: false,
error: {
code: "INSUFFICIENT_PERMISSION",
details: getPermissionErrorMessage(requiredLevel),
},
};
}
/**
*
* @param user
* @returns
*/
export function getUserPermissionSummary(user?: PersonBean | null) {
if (!user) {
return {
level: null,
isSuperAdmin: false,
isCompanyAdmin: false,
isAdmin: false,
canExecuteDDL: false,
canManageUsers: false,
canManageCompanies: false,
canManageSystemSettings: false,
};
}
return {
level: getUserPermissionLevel(user),
isSuperAdmin: isSuperAdmin(user),
isCompanyAdmin: isCompanyAdmin(user),
isAdmin: isAdmin(user),
canExecuteDDL: canExecuteDDL(user),
canManageUsers: isAdmin(user),
canManageCompanies: canManageCompanies(user),
canManageSystemSettings: canManageSystemSettings(user),
};
}

View File

@ -1,38 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2022"],
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"removeComments": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "node",
"baseUrl": "./",
"paths": {
"@/*": ["src/*"],
"@/config/*": ["src/config/*"],
"@/controllers/*": ["src/controllers/*"],
"@/services/*": ["src/services/*"],
"@/models/*": ["src/models/*"],
"@/middleware/*": ["src/middleware/*"],
"@/utils/*": ["src/utils/*"],
"@/types/*": ["src/types/*"],
"@/validators/*": ["src/validators/*"]
}
"allowSyntheticDefaultImports": true,
"noImplicitReturns": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noEmitOnError": false,
"noImplicitAny": false
},
"include": ["src/**/*", "src/types/**/*.d.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"],
"ts-node": {
"transpileOnly": true,
"compilerOptions": {
"module": "commonjs"
}
}
}

View File

@ -0,0 +1,104 @@
# 027 마이그레이션 실행 가이드
## 개요
`dept_info` 테이블에 `company_code` 컬럼을 추가하는 마이그레이션입니다.
## 실행 방법
### 방법 1: Docker Compose를 통한 실행 (권장)
```bash
# 1. 현재 사용 중인 Docker Compose 파일 확인
cd /Users/kimjuseok/ERP-node
# 2. DB 컨테이너 이름 확인
docker ps | grep postgres
# 3. 마이그레이션 실행
docker exec -i <DB_CONTAINER_NAME> psql -U postgres -d ilshin < db/migrations/027_add_company_code_to_dept_info.sql
# 예시 (컨테이너 이름이 'erp-node-db-1'인 경우):
docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/migrations/027_add_company_code_to_dept_info.sql
```
### 방법 2: pgAdmin 또는 DBeaver를 통한 실행
1. pgAdmin 또는 DBeaver 실행
2. PostgreSQL 서버 연결:
- Host: `39.117.244.52`
- Port: `11132`
- Database: `plm`
- Username: `postgres`
- Password: `ph0909!!`
3. `db/migrations/027_add_company_code_to_dept_info.sql` 파일 내용을 복사
4. SQL 쿼리 창에 붙여넣기
5. 실행 (F5 또는 Execute 버튼)
### 방법 3: psql CLI를 통한 직접 연결
```bash
# 1. psql 설치 확인
psql --version
# 2. 직접 연결하여 마이그레이션 실행
psql -h 39.117.244.52 -p 11132 -U postgres -d plm -f db/migrations/027_add_company_code_to_dept_info.sql
```
## 마이그레이션 검증
마이그레이션이 성공적으로 실행되었는지 확인:
```sql
-- 1. company_code 컬럼 추가 확인
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'dept_info' AND column_name = 'company_code';
-- 2. 인덱스 생성 확인
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'dept_info' AND indexname = 'idx_dept_info_company_code';
-- 3. 데이터 마이그레이션 확인 (company_code가 모두 채워졌는지)
SELECT company_code, COUNT(*) as dept_count
FROM dept_info
GROUP BY company_code
ORDER BY company_code;
-- 4. NULL 값이 있는지 확인 (없어야 정상)
SELECT COUNT(*) as null_count
FROM dept_info
WHERE company_code IS NULL;
```
## 롤백 방법 (문제 발생 시)
```sql
-- 1. 인덱스 제거
DROP INDEX IF EXISTS idx_dept_info_company_code;
-- 2. company_code 컬럼 제거
ALTER TABLE dept_info DROP COLUMN IF EXISTS company_code;
```
## 주의사항
1. **백업 필수**: 마이그레이션 실행 전 반드시 데이터베이스 백업
2. **운영 환경**: 운영 환경에서는 점검 시간에 실행 권장
3. **트랜잭션**: 마이그레이션은 하나의 트랜잭션으로 실행됨 (실패 시 자동 롤백)
4. **성능**: `dept_info` 테이블 크기에 따라 실행 시간이 다를 수 있음
## 마이그레이션 내용 요약
1. `company_code` 컬럼 추가 (VARCHAR(20))
2. `company_code` 인덱스 생성
3. 기존 데이터 마이그레이션 (`hq_name` → `company_code`)
4. `company_code`를 NOT NULL로 변경
5. 기본값 'ILSHIN' 설정
## 관련 파일
- 마이그레이션 파일: `db/migrations/027_add_company_code_to_dept_info.sql`
- 백엔드 API 수정: `backend-node/src/controllers/adminController.ts`
- 프론트엔드 API: `frontend/lib/api/user.ts`

View File

@ -0,0 +1,317 @@
# 권한 그룹 시스템 설계 (RBAC)
## 개요
회사 내에서 **역할 기반 접근 제어(RBAC - Role-Based Access Control)**를 통해 세밀한 권한 관리를 제공합니다.
## 기존 시스템 분석
### 현재 테이블 구조
#### 1. `authority_master` - 권한 그룹 마스터
```sql
CREATE TABLE authority_master (
objid NUMERIC PRIMARY KEY,
auth_name VARCHAR, -- 권한 그룹 이름 (예: "영업팀 권한", "개발팀 권한")
auth_code VARCHAR, -- 권한 코드 (예: "SALES_TEAM", "DEV_TEAM")
writer VARCHAR,
regdate TIMESTAMP,
status VARCHAR
);
```
#### 2. `authority_sub_user` - 권한 그룹 멤버
```sql
CREATE TABLE authority_sub_user (
objid NUMERIC PRIMARY KEY,
master_objid NUMERIC, -- authority_master.objid 참조
user_id VARCHAR, -- user_info.user_id 참조
writer VARCHAR,
regdate TIMESTAMP
);
```
#### 3. `rel_menu_auth` - 메뉴 권한 매핑
```sql
CREATE TABLE rel_menu_auth (
objid NUMERIC,
menu_objid NUMERIC, -- menu_info.objid 참조
auth_objid NUMERIC, -- authority_master.objid 참조
writer VARCHAR,
regdate TIMESTAMP,
create_yn VARCHAR, -- 생성 권한 (Y/N)
read_yn VARCHAR, -- 조회 권한 (Y/N)
update_yn VARCHAR, -- 수정 권한 (Y/N)
delete_yn VARCHAR -- 삭제 권한 (Y/N)
);
```
## 개선 사항
### 1. 회사별 권한 그룹 지원
**현재 문제점:**
- `authority_master` 테이블에 `company_code` 컬럼이 없음
- 모든 회사가 권한 그룹을 공유하게 됨
**해결 방안:**
```sql
-- 마이그레이션 028
ALTER TABLE authority_master ADD COLUMN company_code VARCHAR(20);
CREATE INDEX idx_authority_master_company ON authority_master(company_code);
-- 기존 데이터 마이그레이션 (기본값 설정)
UPDATE authority_master SET company_code = 'ILSHIN' WHERE company_code IS NULL;
```
### 2. 권한 레벨과 권한 그룹의 차이
| 구분 | 권한 레벨 (userType) | 권한 그룹 (authority_master) |
| ---------- | -------------------------------- | ------------------------------ |
| **목적** | 시스템 레벨 권한 | 메뉴별 세부 권한 |
| **범위** | 전역 (시스템 전체) | 회사별 (회사 내부) |
| **관리자** | 최고 관리자 (SUPER_ADMIN) | 회사 관리자 (COMPANY_ADMIN) |
| **예시** | SUPER_ADMIN, COMPANY_ADMIN, USER | "영업팀", "개발팀", "관리자팀" |
### 3. 2단계 권한 체계
```
┌─────────────────────────────────────────────────────────────┐
│ 1단계: 권한 레벨 (userType) │
│ - SUPER_ADMIN: 모든 회사 관리, DDL 실행 │
│ - COMPANY_ADMIN: 자기 회사 관리, 권한 그룹 생성 │
│ - USER: 자기 회사 데이터 조회/수정 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 2단계: 권한 그룹 (authority_master) │
│ - 회사 내부에서 메뉴별 세부 권한 설정 │
│ - 생성(C), 조회(R), 수정(U), 삭제(D) 권한 제어 │
└─────────────────────────────────────────────────────────────┘
```
## 사용 시나리오
### 시나리오 1: 영업팀 권한 그룹
**요구사항:**
- 영업팀은 고객 관리, 계약 관리 메뉴만 접근 가능
- 고객 정보는 조회/수정 가능하지만 삭제 불가
- 계약은 생성/조회/수정 가능
**구현:**
```sql
-- 1. 권한 그룹 생성
INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status)
VALUES (nextval('seq_authority'), '영업팀 권한', 'SALES_TEAM', 'COMPANY_1', 'active');
-- 2. 사용자 추가
INSERT INTO authority_sub_user (objid, master_objid, user_id)
VALUES
(nextval('seq_auth_sub'), 1, 'user1'),
(nextval('seq_auth_sub'), 1, 'user2');
-- 3. 메뉴 권한 설정
-- 고객 관리 메뉴
INSERT INTO rel_menu_auth (menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn)
VALUES (100, 1, 'N', 'Y', 'Y', 'N');
-- 계약 관리 메뉴
INSERT INTO rel_menu_auth (menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn)
VALUES (101, 1, 'Y', 'Y', 'Y', 'N');
```
### 시나리오 2: 개발팀 권한 그룹
**요구사항:**
- 개발팀은 모든 기술 메뉴 접근 가능
- 프로젝트, 코드 관리 메뉴는 모든 권한 보유
- 시스템 설정은 조회만 가능
**구현:**
```sql
-- 1. 권한 그룹 생성
INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status)
VALUES (nextval('seq_authority'), '개발팀 권한', 'DEV_TEAM', 'COMPANY_1', 'active');
-- 2. 메뉴 권한 설정
INSERT INTO rel_menu_auth (menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn)
VALUES
(200, 2, 'Y', 'Y', 'Y', 'Y'), -- 프로젝트 관리 (모든 권한)
(201, 2, 'Y', 'Y', 'Y', 'Y'), -- 코드 관리 (모든 권한)
(202, 2, 'N', 'Y', 'N', 'N'); -- 시스템 설정 (조회만)
```
## 구현 단계
### Phase 1: 데이터베이스 마이그레이션
- [ ] `authority_master``company_code` 추가
- [ ] 기존 데이터 마이그레이션
- [ ] 인덱스 생성
### Phase 2: 백엔드 API
- [ ] 권한 그룹 CRUD API
- `GET /api/admin/roles` - 회사별 권한 그룹 목록
- `POST /api/admin/roles` - 권한 그룹 생성
- `PUT /api/admin/roles/:id` - 권한 그룹 수정
- `DELETE /api/admin/roles/:id` - 권한 그룹 삭제
- [ ] 권한 그룹 멤버 관리 API
- `GET /api/admin/roles/:id/members` - 멤버 목록
- `POST /api/admin/roles/:id/members` - 멤버 추가
- `DELETE /api/admin/roles/:id/members/:userId` - 멤버 제거
- [ ] 메뉴 권한 매핑 API
- `GET /api/admin/roles/:id/menu-permissions` - 메뉴 권한 목록
- `PUT /api/admin/roles/:id/menu-permissions` - 메뉴 권한 설정
### Phase 3: 프론트엔드 UI
- [ ] 권한 그룹 관리 페이지 (`/admin/roles`)
- 권한 그룹 목록 (회사별 필터링)
- 권한 그룹 생성/수정/삭제
- [ ] 권한 그룹 상세 페이지 (`/admin/roles/:id`)
- 멤버 관리 (사용자 추가/제거)
- 메뉴 권한 설정 (CRUD 권한 토글)
- [ ] 사용자 관리 페이지 연동
- 사용자별 권한 그룹 할당
### Phase 4: 권한 체크 로직
- [ ] 미들웨어 개선
- 권한 레벨 체크 (기존)
- 권한 그룹 체크 (신규)
- 메뉴별 CRUD 권한 체크 (신규)
- [ ] 프론트엔드 가드
- 메뉴 표시/숨김
- 버튼 활성화/비활성화
## 권한 체크 플로우
```
사용자 요청
1. 인증 체크 (로그인 여부)
2. 권한 레벨 체크 (userType)
- SUPER_ADMIN: 모든 접근 허용
- COMPANY_ADMIN: 자기 회사만
- USER: 권한 그룹 체크로 이동
3. 권한 그룹 체크 (authority_sub_user)
- 사용자가 속한 권한 그룹 조회
4. 메뉴 권한 체크 (rel_menu_auth)
- 요청한 메뉴에 대한 권한 확인
- CRUD 권한 체크
5. 접근 허용/거부
```
## 예상 UI 구조
### 권한 그룹 관리 페이지
```
┌─────────────────────────────────────────────────────────┐
│ 권한 그룹 관리 │
├─────────────────────────────────────────────────────────┤
│ [회사 선택: COMPANY_1 ▼] [검색: ____] [+ 그룹 생성] │
├─────────────────────────────────────────────────────────┤
│ ┌───────────────┬──────────┬──────────┬────────┐ │
│ │ 권한 그룹명 │ 코드 │ 멤버 수 │ 액션 │ │
│ ├───────────────┼──────────┼──────────┼────────┤ │
│ │ 영업팀 권한 │ SALES │ 5명 │ [수정] │ │
│ │ 개발팀 권한 │ DEV │ 8명 │ [수정] │ │
│ │ 관리자팀 │ ADMIN │ 2명 │ [수정] │ │
│ └───────────────┴──────────┴──────────┴────────┘ │
└─────────────────────────────────────────────────────────┘
```
### 권한 그룹 상세 페이지
```
┌─────────────────────────────────────────────────────────┐
│ 영업팀 권한 (SALES_TEAM) │
├─────────────────────────────────────────────────────────┤
│ 【 멤버 관리 】 │
│ [+ 멤버 추가] │
│ ┌──────────┬──────────┬────────┐ │
│ │ 사용자 ID │ 이름 │ 액션 │ │
│ ├──────────┼──────────┼────────┤ │
│ │ user1 │ 김철수 │ [제거] │ │
│ │ user2 │ 이영희 │ [제거] │ │
│ └──────────┴──────────┴────────┘ │
├─────────────────────────────────────────────────────────┤
│ 【 메뉴 권한 설정 】 │
│ ┌─────────────┬────┬────┬────┬────┐ │
│ │ 메뉴 │ 생성│ 조회│ 수정│ 삭제│ │
│ ├─────────────┼────┼────┼────┼────┤ │
│ │ 고객 관리 │ □ │ ☑ │ ☑ │ □ │ │
│ │ 계약 관리 │ ☑ │ ☑ │ ☑ │ □ │ │
│ │ 매출 분석 │ □ │ ☑ │ □ │ □ │ │
│ └─────────────┴────┴────┴────┴────┘ │
│ [저장] [취소] │
└─────────────────────────────────────────────────────────┘
```
## 마이그레이션 계획
### 028_add_company_code_to_authority_master.sql
```sql
-- 권한 그룹 테이블에 회사 코드 추가
ALTER TABLE authority_master ADD COLUMN IF NOT EXISTS company_code VARCHAR(20);
-- 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_authority_master_company ON authority_master(company_code);
-- 기존 데이터 마이그레이션
UPDATE authority_master
SET company_code = 'ILSHIN'
WHERE company_code IS NULL;
-- NOT NULL 제약 조건 추가
ALTER TABLE authority_master ALTER COLUMN company_code SET NOT NULL;
ALTER TABLE authority_master ALTER COLUMN company_code SET DEFAULT 'ILSHIN';
-- 주석 추가
COMMENT ON COLUMN authority_master.company_code IS '회사 코드 (회사별 권한 그룹 격리)';
```
## 참고 사항
### 권한 우선순위
1. **SUPER_ADMIN**: 모든 권한 (권한 그룹 체크 생략)
2. **COMPANY_ADMIN**: 회사 내 모든 권한 (권한 그룹 체크 생략)
3. **USER**: 권한 그룹에 따른 메뉴별 권한
### 권한 그룹 vs 권한 레벨
- **권한 레벨**: 사용자 등록 시 최초 1회 설정 (최고 관리자가 변경)
- **권한 그룹**: 회사 관리자가 자유롭게 생성/관리, 사용자는 여러 그룹에 속할 수 있음
### 보안 고려사항
- 회사 관리자는 자기 회사의 권한 그룹만 관리 가능
- 최고 관리자는 모든 회사의 권한 그룹 관리 가능
- 권한 그룹 삭제 시 연결된 사용자/메뉴 권한도 함께 삭제 (CASCADE)
## 다음 단계
1. **마이그레이션 028 실행**`company_code` 추가
2. **백엔드 API 개발** → 권한 그룹 CRUD
3. **프론트엔드 UI 개발** → 권한 그룹 관리 페이지
4. **권한 체크 로직 통합** → 미들웨어 개선
이 설계를 구현하시겠습니까?

View File

@ -0,0 +1,307 @@
# 권한 시스템 마이그레이션 완료 보고서
## 실행 완료 ✅
날짜: 2025-10-27
대상 데이터베이스: `plm` (39.117.244.52:11132)
---
## 실행된 마이그레이션
### 1. **028_add_company_code_to_authority_master.sql**
**목적**: 권한 그룹 시스템 개선 (회사별 격리)
**주요 변경사항**:
- `authority_master.company_code` 컬럼 추가 (회사별 권한 그룹 격리)
- 외래 키 제약 조건 추가 (`authority_sub_user` ↔ `authority_master`, `user_info`)
- 권한 요약 뷰 생성 (`v_authority_group_summary`)
- 유틸리티 함수 생성 (`get_user_authority_groups`)
### 2. **031_add_menu_auth_columns.sql**
**목적**: 메뉴 기반 권한 시스템 개선 (동적 화면 대응)
**주요 변경사항**:
- `menu_info.screen_code`, `menu_info.menu_code` 컬럼 추가
- `rel_menu_auth.execute_yn`, `rel_menu_auth.export_yn` 컬럼 추가
- 화면 생성 시 자동 메뉴 추가 트리거 (`auto_create_menu_for_screen`)
- 화면 삭제 시 자동 메뉴 비활성화 트리거 (`auto_deactivate_menu_for_screen`)
- 권한 체크 함수 (`check_menu_crud_permission`)
- 사용자 메뉴 조회 함수 (`get_user_menus_with_permissions`)
- 권한 요약 뷰 (`v_menu_permission_summary`)
---
## 현재 데이터베이스 구조
### 1. 권한 그룹 시스템
#### `authority_master` (권한 그룹)
```
objid | NUMERIC | 권한 그룹 ID (PK)
auth_name | VARCHAR(50) | 권한 그룹 이름
auth_code | VARCHAR(50) | 권한 그룹 코드
company_code | VARCHAR(20) | 회사 코드 ⭐ (회사별 격리)
status | VARCHAR(20) | 활성/비활성
```
#### `authority_sub_user` (권한 그룹 멤버)
```
master_objid | NUMERIC | 권한 그룹 ID (FK)
user_id | VARCHAR(50) | 사용자 ID (FK)
```
#### 현재 권한 그룹 현황
- COMPANY_1: 2개 그룹
- COMPANY_2: 2개 그룹
- COMPANY_3: 7개 그룹
- COMPANY_4: 2개 그룹
- ILSHIN: 3개 그룹
### 2. 메뉴 권한 시스템
#### `menu_info` (메뉴 정보)
```
objid | NUMERIC | 메뉴 ID (PK)
menu_name_kor | VARCHAR(64) | 메뉴 이름 (한글)
menu_name_eng | VARCHAR(64) | 메뉴 이름 (영어)
menu_code | VARCHAR(50) | 메뉴 코드 ⭐ (신규)
menu_url | VARCHAR(256) | 메뉴 URL
menu_type | NUMERIC | 메뉴 타입 (0=일반, 1=시스템, 2=동적생성 ⭐)
screen_code | VARCHAR(50) | 화면 코드 ⭐ (동적 메뉴 연동)
company_code | VARCHAR(50) | 회사 코드
parent_obj_id | NUMERIC | 부모 메뉴 ID
seq | NUMERIC | 정렬 순서
status | VARCHAR(32) | 상태
```
#### `rel_menu_auth` (메뉴별 권한)
```
menu_objid | NUMERIC | 메뉴 ID (FK)
auth_objid | NUMERIC | 권한 그룹 ID (FK)
create_yn | VARCHAR(50) | 생성 권한
read_yn | VARCHAR(50) | 읽기 권한
update_yn | VARCHAR(50) | 수정 권한
delete_yn | VARCHAR(50) | 삭제 권한
execute_yn | CHAR(1) | 실행 권한 ⭐ (신규)
export_yn | CHAR(1) | 내보내기 권한 ⭐ (신규)
```
---
## 자동화 기능
### 1. 화면 생성 시 자동 메뉴 추가 🤖
```sql
-- 사용자가 화면 생성
INSERT INTO screen_definitions (screen_name, screen_code, company_code, ...)
VALUES ('계약 관리', 'SCR_CONTRACT', 'ILSHIN', ...);
-- ↓ 트리거 자동 실행 ↓
-- menu_info에 자동 추가됨!
-- menu_type = 2 (동적 생성)
-- screen_code = 'SCR_CONTRACT'
-- menu_url = '/screen/SCR_CONTRACT'
```
### 2. 화면 삭제 시 자동 메뉴 비활성화 🤖
```sql
-- 화면 삭제
UPDATE screen_definitions
SET is_active = 'D'
WHERE screen_code = 'SCR_CONTRACT';
-- ↓ 트리거 자동 실행 ↓
-- 메뉴 비활성화됨!
UPDATE menu_info
SET status = 'inactive'
WHERE screen_code = 'SCR_CONTRACT';
```
---
## 사용 가이드
### 1. 권한 그룹 생성
```sql
-- 예: ILSHIN 회사의 "개발팀" 권한 그룹 생성
INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status, writer, regdate)
VALUES (nextval('seq_authority_master'), '개발팀', 'DEV_TEAM', 'ILSHIN', 'active', 'admin', NOW());
```
### 2. 권한 그룹에 멤버 추가
```sql
-- 예: '개발팀'에 사용자 'dev1' 추가
INSERT INTO authority_sub_user (master_objid, user_id)
VALUES (
(SELECT objid FROM authority_master WHERE auth_code = 'DEV_TEAM' AND company_code = 'ILSHIN'),
'dev1'
);
```
### 3. 메뉴 권한 설정
```sql
-- 예: '개발팀'에게 특정 메뉴의 CRUD 권한 부여
INSERT INTO rel_menu_auth (menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, execute_yn, export_yn, writer)
VALUES (
1005, -- 메뉴 ID
(SELECT objid FROM authority_master WHERE auth_code = 'DEV_TEAM' AND company_code = 'ILSHIN'),
'Y', 'Y', 'Y', 'Y', 'Y', 'N', -- CRUD + Execute 권한
'admin'
);
```
### 4. 사용자 권한 확인
```sql
-- 예: 'dev1' 사용자가 메뉴 1005를 수정할 수 있는지 확인
SELECT check_menu_crud_permission('dev1', 1005, 'update');
-- 결과: TRUE 또는 FALSE
-- 예: 'dev1' 사용자가 접근 가능한 모든 메뉴 조회
SELECT * FROM get_user_menus_with_permissions('dev1', 'ILSHIN');
```
---
## 다음 단계
### 1. 백엔드 API 구현
**필요한 API**:
- `GET /api/roles/:id/menu-permissions` - 권한 그룹의 메뉴 권한 조회
- `POST /api/roles/:id/menu-permissions` - 메뉴 권한 설정
- `GET /api/users/menus` - 현재 사용자가 접근 가능한 메뉴 목록
- `POST /api/menu-permissions/check` - 특정 메뉴에 대한 권한 확인
**구현 파일**:
- `backend-node/src/services/RoleService.ts`
- `backend-node/src/controllers/roleController.ts`
- `backend-node/src/middleware/permissionMiddleware.ts`
### 2. 프론트엔드 UI 개발
**필요한 페이지/컴포넌트**:
1. **권한 그룹 상세 페이지** (`/admin/roles/[id]`)
- 기본 정보 (이름, 코드, 회사)
- 멤버 관리 (Dual List Box) ✅ 이미 구현됨
- **메뉴 권한 설정** (체크박스 그리드) ⬅️ 신규 개발 필요
2. **메뉴 권한 설정 그리드**
```
┌─────────────────┬────────┬────────┬────────┬────────┬────────┬────────┐
│ 메뉴 │ 생성 │ 읽기 │ 수정 │ 삭제 │ 실행 │ 내보내기│
├─────────────────┼────────┼────────┼────────┼────────┼────────┼────────┤
│ 대시보드 │ ☐ │ ☑ │ ☐ │ ☐ │ ☐ │ ☐ │
│ 계약 관리 │ ☑ │ ☑ │ ☑ │ ☐ │ ☐ │ ☑ │
│ 사용자 관리 │ ☐ │ ☑ │ ☐ │ ☐ │ ☐ │ ☐ │
└─────────────────┴────────┴────────┴────────┴────────┴────────┴────────┘
```
3. **네비게이션 메뉴** (사용자별 권한 필터링)
- `get_user_menus_with_permissions` 함수 활용
- 읽기 권한이 있는 메뉴만 표시
4. **버튼/액션 권한 제어**
- 생성 버튼: `can_create`
- 수정 버튼: `can_update`
- 삭제 버튼: `can_delete`
- 실행 버튼: `can_execute` (플로우, DDL)
- 내보내기 버튼: `can_export`
**구현 파일**:
- `frontend/components/admin/RoleDetailManagement.tsx` (메뉴 권한 탭 추가)
- `frontend/components/admin/MenuPermissionGrid.tsx` (신규)
- `frontend/lib/api/role.ts` (메뉴 권한 API 추가)
- `frontend/hooks/useMenuPermission.ts` (신규)
### 3. 테스트 시나리오
**시나리오 1: 영업팀 권한 설정**
1. 영업팀 권한 그룹 생성
2. 멤버 추가 (3명)
3. 메뉴 권한 설정:
- 대시보드: 읽기만
- 계약 관리: CRUD + 내보내기
- 플로우 관리: 읽기 + 실행
4. 영업팀 사용자로 로그인하여 검증
**시나리오 2: 동적 화면 생성 및 권한 설정**
1. "배송 현황" 화면 생성
2. 자동으로 메뉴 추가 확인
3. 영업팀에게 읽기 권한 부여
4. 영업팀 사용자 로그인하여 메뉴 표시 확인
---
## 주의사항
### 1. 기존 데이터 호환성
- 기존 `menu_info` 테이블 구조는 그대로 유지
- 새로운 컬럼만 추가되어 기존 데이터에 영향 없음
### 2. 권한 타입 매핑
- `menu_type``numeric`에서 `VARCHAR`로 변경되지 않음 (기존 구조 유지)
- `menu_type = 2`가 동적 생성 메뉴를 의미
### 3. 데이터 마이그레이션 불필요
- 기존 권한 데이터는 그대로 유지
- 새로운 권한 그룹은 수동으로 설정 필요
---
## 검증 체크리스트
- [x] `authority_master.company_code` 컬럼 존재 확인
- [x] `menu_info.screen_code`, `menu_info.menu_code` 컬럼 존재 확인
- [x] `rel_menu_auth.execute_yn`, `rel_menu_auth.export_yn` 컬럼 존재 확인
- [x] 트리거 함수 생성 확인 (`auto_create_menu_for_screen`, `auto_deactivate_menu_for_screen`)
- [x] 권한 체크 함수 생성 확인 (`check_menu_crud_permission`)
- [x] 사용자 메뉴 조회 함수 생성 확인 (`get_user_menus_with_permissions`)
- [x] 권한 요약 뷰 생성 확인 (`v_menu_permission_summary`)
- [ ] 백엔드 API 구현
- [ ] 프론트엔드 UI 구현
- [ ] 테스트 시나리오 실행
---
## 관련 문서
- `docs/메뉴_기반_권한_시스템_가이드.md` - 사용자 가이드
- `docs/권한_체계_가이드.md` - 3단계 권한 체계 개요
- `db/migrations/028_add_company_code_to_authority_master.sql` - 권한 그룹 마이그레이션
- `db/migrations/031_add_menu_auth_columns.sql` - 메뉴 권한 마이그레이션
---
## 문의사항
기술적 문의사항이나 추가 기능 요청은 개발팀에 문의하세요.

View File

@ -0,0 +1,589 @@
# 3단계 권한 체계 가이드
## 📋 목차
1. [권한 체계 개요](#권한-체계-개요)
2. [권한 레벨 상세](#권한-레벨-상세)
3. [데이터베이스 설정](#데이터베이스-설정)
4. [백엔드 구현](#백엔드-구현)
5. [프론트엔드 구현](#프론트엔드-구현)
6. [실무 예제](#실무-예제)
7. [FAQ](#faq)
---
## 권한 체계 개요
### 3단계 권한 구조
```
┌────────────────────┬──────────────┬─────────────────┬────────────────────────┐
│ 권한 레벨 │ company_code │ user_type │ 접근 범위 │
├────────────────────┼──────────────┼─────────────────┼────────────────────────┤
│ 최고 관리자 │ * │ SUPER_ADMIN │ ✅ 전체 회사 데이터 │
│ (Super Admin) │ │ │ ✅ DDL 실행 권한 │
│ │ │ │ ✅ 회사 생성/삭제 │
│ │ │ │ ✅ 시스템 설정 │
├────────────────────┼──────────────┼─────────────────┼────────────────────────┤
│ 회사 관리자 │ 20 │ COMPANY_ADMIN │ ✅ 자기 회사 데이터 │
│ (Company Admin) │ │ │ ✅ 회사 사용자 관리 │
│ │ │ │ ✅ 회사 설정 변경 │
│ │ │ │ ❌ DDL 실행 불가 │
│ │ │ │ ❌ 타회사 접근 불가 │
├────────────────────┼──────────────┼─────────────────┼────────────────────────┤
│ 일반 사용자 │ 20 │ USER │ ✅ 자기 회사 데이터 │
│ (User) │ │ │ ❌ 사용자 관리 불가 │
│ │ │ │ ❌ 설정 변경 불가 │
└────────────────────┴──────────────┴─────────────────┴────────────────────────┘
```
### 핵심 원칙
1. **company_code = "\*"** → 전체 시스템 접근 (슈퍼관리자 전용)
2. **company_code = "특정코드"** → 해당 회사만 접근
3. **user_type** → 회사 내 권한 레벨 결정
---
## 권한 레벨 상세
### 1⃣ 슈퍼관리자 (SUPER_ADMIN)
**조건:**
- `company_code = '*'`
- `user_type = 'SUPER_ADMIN'`
**권한:**
- ✅ 모든 회사 데이터 조회/수정
- ✅ DDL 실행 (CREATE TABLE, ALTER TABLE 등)
- ✅ 회사 생성/삭제
- ✅ 시스템 설정 변경
- ✅ 모든 사용자 관리
- ✅ 코드 관리, 템플릿 관리 등 전역 설정
**사용 사례:**
- 시스템 전체 관리자
- 데이터베이스 스키마 변경
- 새로운 회사 추가
- 전사 공통 설정 관리
**계정 예시:**
```sql
INSERT INTO user_info (user_id, user_name, company_code, user_type)
VALUES ('super_admin', '시스템 관리자', '*', 'SUPER_ADMIN');
```
---
### 2⃣ 회사 관리자 (COMPANY_ADMIN)
**조건:**
- `company_code = '특정 회사 코드'` (예: '20')
- `user_type = 'COMPANY_ADMIN'`
**권한:**
- ✅ 자기 회사 데이터 조회/수정
- ✅ 자기 회사 사용자 관리 (추가/수정/삭제)
- ✅ 자기 회사 설정 변경
- ✅ 자기 회사 대시보드/화면 관리
- ❌ DDL 실행 불가
- ❌ 타 회사 데이터 접근 불가
- ❌ 시스템 전역 설정 변경 불가
**사용 사례:**
- 각 회사의 IT 관리자
- 회사 내 사용자 계정 관리
- 회사별 커스터마이징 설정
**계정 예시:**
```sql
INSERT INTO user_info (user_id, user_name, company_code, user_type)
VALUES ('company_admin_20', '회사20 관리자', '20', 'COMPANY_ADMIN');
```
---
### 3⃣ 일반 사용자 (USER)
**조건:**
- `company_code = '특정 회사 코드'` (예: '20')
- `user_type = 'USER'`
**권한:**
- ✅ 자기 회사 데이터 조회/수정
- ✅ 자신이 만든 화면/대시보드 관리
- ❌ 사용자 관리 불가
- ❌ 회사 설정 변경 불가
- ❌ 타 회사 데이터 접근 불가
**사용 사례:**
- 일반 업무 사용자
- 데이터 입력/조회
- 개인 대시보드 생성
**계정 예시:**
```sql
INSERT INTO user_info (user_id, user_name, company_code, user_type)
VALUES ('user_kim', '김철수', '20', 'USER');
```
---
## 데이터베이스 설정
### 마이그레이션 실행
```bash
# 권한 체계 마이그레이션 실행
psql -U postgres -d your_database -f db/migrations/026_add_user_type_hierarchy.sql
```
### 주요 변경사항
1. **코드 테이블 업데이트:**
- `ADMIN``COMPANY_ADMIN` 으로 변경
- `SUPER_ADMIN` 신규 추가
2. **PostgreSQL 함수 추가:**
- `is_super_admin(user_id)` - 슈퍼관리자 확인
- `is_company_admin(user_id, company_code)` - 회사 관리자 확인
- `can_access_company_data(user_id, company_code)` - 데이터 접근 권한
3. **권한 뷰 생성:**
- `v_user_permissions` - 사용자별 권한 요약
---
## 백엔드 구현
### 1. 권한 체크 유틸리티 사용
```typescript
import {
isSuperAdmin,
isCompanyAdmin,
isAdmin,
canExecuteDDL,
canAccessCompanyData,
canManageUsers,
} from "../utils/permissionUtils";
// 슈퍼관리자 확인
if (isSuperAdmin(req.user)) {
// 전체 데이터 조회
}
// 회사 데이터 접근 권한 확인
if (canAccessCompanyData(req.user, targetCompanyCode)) {
// 해당 회사 데이터 조회
}
// 사용자 관리 권한 확인
if (canManageUsers(req.user, targetCompanyCode)) {
// 사용자 추가/수정/삭제
}
```
### 2. 미들웨어 사용
```typescript
import {
requireSuperAdmin,
requireAdmin,
requireCompanyAccess,
requireUserManagement,
requireDDLPermission,
} from "../middleware/permissionMiddleware";
// 슈퍼관리자 전용 엔드포인트
router.post(
"/api/admin/ddl/execute",
authenticate,
requireDDLPermission,
ddlController.execute
);
// 관리자 전용 엔드포인트 (슈퍼관리자 + 회사관리자)
router.get(
"/api/admin/users",
authenticate,
requireAdmin,
userController.getUserList
);
// 회사 데이터 접근 체크
router.get(
"/api/data/:companyCode/orders",
authenticate,
requireCompanyAccess,
orderController.getOrders
);
// 사용자 관리 권한 체크
router.post(
"/api/admin/users/:companyCode",
authenticate,
requireUserManagement,
userController.createUser
);
```
### 3. 서비스 레이어 구현
```typescript
// ❌ 잘못된 방법 - 하드코딩된 회사 코드
async getOrders(companyCode: string) {
return query("SELECT * FROM orders WHERE company_code = $1", [companyCode]);
}
// ✅ 올바른 방법 - 권한 체크 포함
async getOrders(user: PersonBean, companyCode: string) {
// 권한 확인
if (!canAccessCompanyData(user, companyCode)) {
throw new Error("해당 회사 데이터에 접근할 권한이 없습니다.");
}
// 슈퍼관리자는 모든 데이터 조회 가능
if (isSuperAdmin(user)) {
if (companyCode === "*") {
return query("SELECT * FROM orders"); // 전체 조회
}
}
// 일반 사용자/회사 관리자는 자기 회사만
return query("SELECT * FROM orders WHERE company_code = $1", [companyCode]);
}
```
---
## 프론트엔드 구현
### 1. 사용자 타입 정의
```typescript
// frontend/types/user.ts
export interface UserInfo {
userId: string;
userName: string;
companyCode: string;
userType: string; // 'SUPER_ADMIN' | 'COMPANY_ADMIN' | 'USER'
isSuperAdmin?: boolean;
isCompanyAdmin?: boolean;
isAdmin?: boolean;
}
```
### 2. 권한 기반 UI 렌더링
```tsx
import { useAuth } from "@/hooks/useAuth";
function AdminPanel() {
const { user } = useAuth();
return (
<div>
{/* 슈퍼관리자만 표시 */}
{user?.isSuperAdmin && (
<Button onClick={handleDDLExecution}>DDL 실행</Button>
)}
{/* 관리자만 표시 (슈퍼관리자 + 회사관리자) */}
{user?.isAdmin && (
<Button onClick={handleUserManagement}>사용자 관리</Button>
)}
{/* 모든 사용자 표시 */}
<Button onClick={handleDataView}>데이터 조회</Button>
</div>
);
}
```
### 3. 권한 체크 Hook
```typescript
// frontend/hooks/usePermissions.ts
export function usePermissions() {
const { user } = useAuth();
return {
isSuperAdmin: user?.isSuperAdmin ?? false,
isCompanyAdmin: user?.isCompanyAdmin ?? false,
isAdmin: user?.isAdmin ?? false,
canExecuteDDL: user?.isSuperAdmin ?? false,
canManageUsers: user?.isAdmin ?? false,
canAccessCompany: (companyCode: string) => {
if (user?.isSuperAdmin) return true;
return user?.companyCode === companyCode;
},
};
}
// 사용 예시
function DataTable({ companyCode }: { companyCode: string }) {
const { canAccessCompany } = usePermissions();
if (!canAccessCompany(companyCode)) {
return <div>접근 권한이 없습니다.</div>;
}
return <Table data={data} />;
}
```
---
## 실무 예제
### 예제 1: 주문 데이터 조회
**시나리오:**
- 슈퍼관리자: 모든 회사의 주문 조회
- 회사20 관리자: 회사20의 주문만 조회
- 회사20 사용자: 회사20의 주문만 조회
**백엔드 구현:**
```typescript
// orders.service.ts
export class OrderService {
async getOrders(user: PersonBean, companyCode?: string) {
let sql = "SELECT * FROM orders WHERE 1=1";
const params: any[] = [];
// 슈퍼관리자가 아닌 경우 회사 필터 적용
if (!isSuperAdmin(user)) {
sql += " AND company_code = $1";
params.push(user.companyCode);
} else if (companyCode && companyCode !== "*") {
// 슈퍼관리자가 특정 회사를 지정한 경우
sql += " AND company_code = $1";
params.push(companyCode);
}
return query(sql, params);
}
}
```
**프론트엔드 구현:**
```tsx
function OrderList() {
const { user } = useAuth();
const [selectedCompany, setSelectedCompany] = useState(user?.companyCode);
// 슈퍼관리자는 회사 선택 가능
const showCompanySelector = user?.isSuperAdmin;
return (
<div>
{showCompanySelector && (
<Select value={selectedCompany} onChange={setSelectedCompany}>
<option value="*">전체 회사</option>
<option value="20">회사 20</option>
<option value="30">회사 30</option>
</Select>
)}
<OrderTable companyCode={selectedCompany} />
</div>
);
}
```
---
### 예제 2: 사용자 관리
**시나리오:**
- 슈퍼관리자: 모든 회사의 사용자 관리
- 회사20 관리자: 회사20 사용자만 관리
- 회사20 사용자: 사용자 관리 불가
**백엔드 구현:**
```typescript
// users.controller.ts
router.post("/api/admin/users", authenticate, async (req, res) => {
const { companyCode, userId, userName } = req.body;
// 권한 확인
if (!canManageUsers(req.user, companyCode)) {
return res.status(403).json({
success: false,
error: "사용자 관리 권한이 없습니다.",
});
}
// 슈퍼관리자가 아닌 경우, 자기 회사만 가능
if (!isSuperAdmin(req.user) && companyCode !== req.user.companyCode) {
return res.status(403).json({
success: false,
error: "다른 회사의 사용자를 생성할 수 없습니다.",
});
}
// 사용자 생성
await UserService.createUser({ companyCode, userId, userName });
res.json({ success: true });
});
```
---
### 예제 3: DDL 실행 (테이블 생성)
**시나리오:**
- 슈퍼관리자만 DDL 실행 가능
- 다른 모든 사용자는 차단
**백엔드 구현:**
```typescript
// ddl.controller.ts
router.post(
"/api/admin/ddl/execute",
authenticate,
requireDDLPermission, // 슈퍼관리자 체크 미들웨어
async (req, res) => {
const { sql } = req.body;
// 추가 보안 검증
if (!canExecuteDDL(req.user)) {
return res.status(403).json({
success: false,
error: "DDL 실행 권한이 없습니다.",
});
}
// DDL 실행
await query(sql);
// 감사 로그 기록
await AuditService.logDDL({
userId: req.user.userId,
sql,
timestamp: new Date(),
});
res.json({ success: true });
}
);
```
**프론트엔드 구현:**
```tsx
function DDLExecutor() {
const { user } = useAuth();
// 슈퍼관리자가 아니면 컴포넌트 자체를 숨김
if (!user?.isSuperAdmin) {
return null;
}
return (
<div>
<h2>DDL 실행 (슈퍼관리자 전용)</h2>
<textarea placeholder="SQL 입력" />
<Button onClick={handleExecute}>실행</Button>
</div>
);
}
```
---
## FAQ
### Q1: 기존 ADMIN 계정은 어떻게 되나요?
**A:** 마이그레이션 스크립트가 자동으로 처리합니다:
- `company_code = '*'`인 ADMIN → `SUPER_ADMIN`으로 변경
- `company_code = '특정코드'`인 ADMIN → `COMPANY_ADMIN`으로 변경
### Q2: 슈퍼관리자 계정은 몇 개가 적절한가요?
**A:** 보안상 최소 1개, 최대 2-3개를 권장합니다. 모든 DDL 실행이 감사 로그에 기록되므로 책임 추적이 가능합니다.
### Q3: 회사 관리자가 다른 회사 데이터를 조회하려면?
**A:** 불가능합니다. 회사 간 데이터 격리가 필수입니다. 필요시 슈퍼관리자에게 요청하거나, API 통합 기능을 사용해야 합니다.
### Q4: USER가 COMPANY_ADMIN으로 승격하려면?
**A:**
1. 슈퍼관리자 또는 해당 회사의 관리자가 처리
2. `UPDATE user_info SET user_type = 'COMPANY_ADMIN' WHERE user_id = 'xxx'`
3. 사용자 재로그인 필요
### Q5: 회사 코드 '\*'의 의미는?
**A:** 와일드카드로, "모든 회사"를 의미합니다. 슈퍼관리자 전용 코드이며, 일반 회사 코드로는 사용할 수 없습니다.
### Q6: 권한 체크는 어디서 해야 하나요?
**A:**
- **백엔드 (필수)**: 미들웨어 + 서비스 레이어 모두
- **프론트엔드 (선택)**: UI 렌더링 최적화용 (보안 목적 아님)
### Q7: 테이블에 회사 필터링을 추가하려면?
**A:**
1. 테이블에 `company_code` 컬럼 추가
2. `backend-node/src/services/dataService.ts``COMPANY_FILTERED_TABLES` 배열에 테이블명 추가
3. 자동으로 회사 필터링 적용됨
---
## 체크리스트
### 새로운 엔드포인트 추가 시
- [ ] 적절한 권한 미들웨어 적용 (`requireSuperAdmin`, `requireAdmin` 등)
- [ ] 서비스 레이어에서 `canAccessCompanyData()` 체크
- [ ] 감사 로그 기록 (중요 작업의 경우)
- [ ] 프론트엔드 UI에 권한 기반 렌더링 적용
- [ ] 에러 메시지에 필요한 권한 레벨 명시
### 새로운 테이블 생성 시
- [ ] `company_code` 컬럼 추가 (회사별 데이터인 경우)
- [ ] `COMPANY_FILTERED_TABLES` 배열에 등록
- [ ] 인덱스 생성: `CREATE INDEX ON table_name(company_code)`
- [ ] Row Level Security 정책 고려 (선택사항)
---
## 참고 파일
- 마이그레이션: `/db/migrations/026_add_user_type_hierarchy.sql`
- 권한 유틸: `/backend-node/src/utils/permissionUtils.ts`
- 미들웨어: `/backend-node/src/middleware/permissionMiddleware.ts`
- 타입 정의: `/backend-node/src/types/auth.ts`
- 인증 서비스: `/backend-node/src/services/authService.ts`

View File

@ -0,0 +1,416 @@
# 리소스 기반 권한 시스템 가이드
## 개요
동적으로 화면과 테이블을 생성하는 Low-Code 플랫폼에 맞춘 **리소스 기반 권한 시스템**입니다.
전통적인 "메뉴" 개념 대신, **"리소스 타입"**(화면, 테이블, 플로우 등)에 대한 **세밀한 CRUD 권한**을 관리합니다.
## 왜 메뉴 기반이 아닌가?
### 문제점
- 현재 시스템은 **동적으로 화면(`screen_definitions`)을 생성**
- 사용자가 **DDL을 실행하여 테이블을 동적으로 생성**
- **메뉴는 고정되어 있지 않음** (사용자가 생성한 화면 = 새로운 "메뉴")
### 해결책
- **리소스 타입** (SCREEN, TABLE, FLOW, DASHBOARD 등) 기반 권한
- **특정 리소스 ID** 또는 **전체 타입**에 대한 권한 부여
- **6가지 세밀한 권한**: Create, Read, Update, Delete, Execute, Export
---
## 시스템 구조
### 1. 리소스 타입 (`resource_types`)
| type_code | type_name | description |
| --------- | --------- | ------------------------------ |
| SCREEN | 화면 | 동적으로 생성된 화면 |
| TABLE | 테이블 | 동적으로 생성된 데이터 테이블 |
| FLOW | 플로우 | 데이터 플로우 |
| DASHBOARD | 대시보드 | 대시보드 |
| REPORT | 리포트 | 리포트 |
| API | API | 외부 API 호출 |
| FILE | 파일 | 파일 업로드/다운로드 |
| SYSTEM | 시스템 | 시스템 설정 (SUPER_ADMIN 전용) |
### 2. 권한 그룹 (`authority_master`)
기존 테이블 활용 (회사별 격리 지원):
- `objid`: 권한 그룹 ID
- `auth_name`: 권한 그룹 이름 (예: "영업팀", "개발팀")
- `auth_code`: 권한 그룹 코드
- `company_code`: 회사 코드
- `status`: 활성/비활성
### 3. 리소스별 권한 (`resource_permissions`)
| 컬럼 | 타입 | 설명 |
| ------------- | ------------ | --------------------------------- |
| role_group_id | INTEGER | 권한 그룹 ID (FK) |
| resource_type | VARCHAR(50) | 리소스 타입 (SCREEN, TABLE 등) |
| resource_id | VARCHAR(255) | 특정 리소스 ID (**NULL = 전체**) |
| can_create | BOOLEAN | 생성 권한 |
| can_read | BOOLEAN | 읽기 권한 |
| can_update | BOOLEAN | 수정 권한 |
| can_delete | BOOLEAN | 삭제 권한 |
| can_execute | BOOLEAN | 실행 권한 (플로우 실행, DDL 실행) |
| can_export | BOOLEAN | 내보내기 권한 |
**핵심**: `resource_id`가 **NULL**이면 해당 타입 **전체**에 대한 권한
### 4. 사용자별 직접 권한 (`user_resource_permissions`)
권한 그룹 외에 **개별 사용자에게 직접 권한** 부여 가능 (보조적 사용)
---
## 권한 체크 로직
### 우선순위
1. **SUPER_ADMIN** (`company_code = '*'`, `user_type = 'SUPER_ADMIN'`)
- 모든 권한 (무조건 TRUE)
2. **COMPANY_ADMIN** (`user_type = 'COMPANY_ADMIN'`)
- 자기 회사 모든 리소스 권한 (단, `SYSTEM` 타입 제외)
3. **권한 그룹 기반 권한** (`authority_sub_user` → `resource_permissions`)
- 사용자가 속한 권한 그룹의 권한
4. **개별 권한** (`user_resource_permissions`)
- 사용자에게 직접 부여된 권한
**최종 판정**: `권한 그룹 권한 OR 개별 권한` (하나라도 TRUE이면 허용)
---
## 사용 예시
### 예시 1: 영업팀에게 모든 화면 읽기 권한 부여
```sql
-- 1. 영업팀 권한 그룹 ID 조회
SELECT objid FROM authority_master
WHERE auth_code = 'SALES_TEAM' AND company_code = 'ILSHIN';
-- 결과: objid = 1001
-- 2. 화면(SCREEN) 전체에 대한 읽기 권한 부여
INSERT INTO resource_permissions (role_group_id, resource_type, resource_id, can_read, created_by)
VALUES (1001, 'SCREEN', NULL, TRUE, 'admin');
-- ^^^^ ^^^^ NULL = 모든 화면
```
### 예시 2: 특정 화면에만 수정 권한 부여
```sql
-- 특정 화면 ID: 'SCR_SALES_REPORT' (screen_definitions.screen_code)
INSERT INTO resource_permissions (role_group_id, resource_type, resource_id, can_read, can_update, created_by)
VALUES (1001, 'SCREEN', 'SCR_SALES_REPORT', TRUE, TRUE, 'admin');
-- ^^^^^^^^^^^^^^^^^ 특정 화면만
```
### 예시 3: 테이블 CRUD 권한 부여 (삭제 제외)
```sql
-- 모든 테이블에 대해 CRU (Create, Read, Update) 권한 부여
INSERT INTO resource_permissions (
role_group_id, resource_type, resource_id,
can_create, can_read, can_update, can_delete,
created_by
)
VALUES (1001, 'TABLE', NULL, TRUE, TRUE, TRUE, FALSE, 'admin');
```
### 예시 4: 플로우 실행 권한 부여
```sql
-- 특정 플로우만 실행 가능
INSERT INTO resource_permissions (
role_group_id, resource_type, resource_id,
can_read, can_execute,
created_by
)
VALUES (1001, 'FLOW', '29', TRUE, TRUE, 'admin');
-- ^^ flow_definition.id
```
### 예시 5: 개별 사용자에게 직접 권한 부여
```sql
-- 'john.doe' 사용자에게 시스템 설정 읽기 권한
INSERT INTO user_resource_permissions (
user_id, resource_type, resource_id, can_read, created_by
)
VALUES ('john.doe', 'SYSTEM', NULL, TRUE, 'admin');
```
---
## 백엔드 API 사용법
### 1. 권한 체크 함수
```sql
-- 사용자 'john.doe'가 화면 'SCR_SALES_REPORT'를 읽을 수 있는지 확인
SELECT check_user_resource_permission('john.doe', 'SCREEN', 'SCR_SALES_REPORT', 'read');
-- 결과: TRUE 또는 FALSE
-- 테이블 'contract_mgmt'를 삭제할 수 있는지 확인
SELECT check_user_resource_permission('john.doe', 'TABLE', 'contract_mgmt', 'delete');
```
### 2. 접근 가능한 리소스 목록 조회
```sql
-- 사용자 'john.doe'가 읽을 수 있는 모든 화면 목록
SELECT * FROM get_user_accessible_resources('john.doe', 'SCREEN', 'read');
-- 결과 예시:
-- resource_id | can_create | can_read | can_update | can_delete | can_execute | can_export
-- ------------+------------+----------+------------+------------+-------------+-----------
-- * | FALSE | TRUE | FALSE | FALSE | FALSE | FALSE
-- SCR_SALES | FALSE | TRUE | TRUE | FALSE | FALSE | TRUE
```
---
## 프론트엔드 통합
### React Hook 예시
```typescript
// hooks/usePermission.ts
import { useState, useEffect } from "react";
import { checkResourcePermission } from "@/lib/api/permission";
export function usePermission(
resourceType: string,
resourceId: string | null,
permissionType: "create" | "read" | "update" | "delete" | "execute" | "export"
) {
const [hasPermission, setHasPermission] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const checkPermission = async () => {
setIsLoading(true);
try {
const response = await checkResourcePermission({
resourceType,
resourceId,
permissionType,
});
setHasPermission(response.success && response.data?.hasPermission);
} catch (error) {
console.error("권한 확인 오류:", error);
setHasPermission(false);
} finally {
setIsLoading(false);
}
};
checkPermission();
}, [resourceType, resourceId, permissionType]);
return { hasPermission, isLoading };
}
```
### 컴포넌트에서 사용
```tsx
// components/ScreenDetail.tsx
import { usePermission } from "@/hooks/usePermission";
import { Button } from "@/components/ui/button";
export function ScreenDetail({ screenCode }: { screenCode: string }) {
const { hasPermission: canUpdate } = usePermission(
"SCREEN",
screenCode,
"update"
);
const { hasPermission: canDelete } = usePermission(
"SCREEN",
screenCode,
"delete"
);
return (
<div>
<h1>{screenCode}</h1>
{canUpdate && <Button>수정</Button>}
{canDelete && <Button variant="destructive">삭제</Button>}
</div>
);
}
```
---
## 실전 시나리오
### 시나리오 1: 영업팀 권한 설정
**요구사항**:
- 모든 화면 조회 가능
- 계약 테이블(`contract_mgmt`) CRUD 전체
- 영업 플로우만 실행 가능
- 데이터 내보내기 가능
```sql
-- 영업팀 ID: 1001
INSERT INTO resource_permissions (role_group_id, resource_type, resource_id, can_create, can_read, can_update, can_delete, can_execute, can_export, created_by)
VALUES
-- 모든 화면 읽기
(1001, 'SCREEN', NULL, FALSE, TRUE, FALSE, FALSE, FALSE, FALSE, 'admin'),
-- 계약 테이블 CRUD
(1001, 'TABLE', 'contract_mgmt', TRUE, TRUE, TRUE, TRUE, FALSE, TRUE, 'admin'),
-- 영업 플로우 실행
(1001, 'FLOW', 'sales_flow', FALSE, TRUE, FALSE, FALSE, TRUE, FALSE, 'admin');
```
### 시나리오 2: 읽기 전용 사용자
**요구사항**:
- 모든 리소스 읽기만 가능
- 수정/삭제/생성 불가
```sql
-- 읽기 전용 권한 그룹 생성
INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status, writer, regdate)
VALUES (nextval('seq_authority_master'), '읽기 전용', 'READ_ONLY', 'ILSHIN', 'active', 'admin', NOW());
-- 권한 부여
INSERT INTO resource_permissions (role_group_id, resource_type, resource_id, can_read, created_by)
SELECT
(SELECT objid FROM authority_master WHERE auth_code = 'READ_ONLY' AND company_code = 'ILSHIN'),
type_code,
NULL,
TRUE,
'admin'
FROM resource_types
WHERE type_code != 'SYSTEM'; -- 시스템 제외
```
### 시나리오 3: 개발팀 (DDL 실행 권한)
**요구사항**:
- 테이블 생성/삭제 가능 (DDL 실행)
- 모든 화면 CRUD
- 플로우 생성/실행
```sql
-- 개발팀 ID: 1002
INSERT INTO resource_permissions (role_group_id, resource_type, resource_id, can_create, can_read, can_update, can_delete, can_execute, created_by)
VALUES
-- 화면 CRUD
(1002, 'SCREEN', NULL, TRUE, TRUE, TRUE, TRUE, FALSE, 'admin'),
-- 테이블 CRUD + 실행(DDL)
(1002, 'TABLE', NULL, TRUE, TRUE, TRUE, TRUE, TRUE, 'admin'),
-- 플로우 CRUD + 실행
(1002, 'FLOW', NULL, TRUE, TRUE, TRUE, TRUE, TRUE, 'admin');
```
---
## 마이그레이션 실행
```bash
# Docker Compose 환경
docker exec -i <DB_CONTAINER_NAME> psql -U postgres -d ilshin < db/migrations/028_add_company_code_to_authority_master.sql
docker exec -i <DB_CONTAINER_NAME> psql -U postgres -d ilshin < db/migrations/029_create_resource_based_permission_system.sql
# 검증
docker exec -it <DB_CONTAINER_NAME> psql -U postgres -d ilshin -c "SELECT * FROM resource_types;"
docker exec -it <DB_CONTAINER_NAME> psql -U postgres -d ilshin -c "SELECT * FROM v_role_permissions_summary;"
```
---
## 추가 기능 확장 아이디어
### 1. 시간 기반 권한
```sql
ALTER TABLE resource_permissions ADD COLUMN valid_from TIMESTAMP;
ALTER TABLE resource_permissions ADD COLUMN valid_until TIMESTAMP;
```
### 2. 조건부 권한 (Row-Level Security)
```sql
-- 예: 자신이 생성한 데이터만 수정 가능
ALTER TABLE resource_permissions ADD COLUMN row_condition TEXT;
-- 'created_by = :user_id'
```
### 3. 권한 요청/승인 워크플로우
```sql
CREATE TABLE permission_requests (
request_id SERIAL PRIMARY KEY,
user_id VARCHAR(50),
resource_type VARCHAR(50),
resource_id VARCHAR(255),
permission_type VARCHAR(20),
reason TEXT,
status VARCHAR(20), -- 'pending', 'approved', 'rejected'
approved_by VARCHAR(50),
approved_date TIMESTAMP
);
```
---
## FAQ
### Q1: 메뉴 기반 권한과 무엇이 다른가요?
**A**: 메뉴는 고정된 화면을 가정하지만, 이 시스템은 사용자가 **동적으로 생성한 화면/테이블**에도 권한을 부여할 수 있습니다. 예를 들어, 사용자 A가 "계약 관리" 화면을 생성하면, 권한 그룹 B에게 그 화면의 읽기 권한을 즉시 부여할 수 있습니다.
### Q2: `resource_id`가 NULL인 경우와 특정 ID인 경우의 차이는?
**A**:
- `resource_id = NULL`: **해당 타입의 모든 리소스**에 대한 권한
- `resource_id = 'SCR_001'`: **특정 리소스만** 권한
예: `(SCREEN, NULL, read)` = 모든 화면 읽기
예: `(SCREEN, 'SCR_001', read)` = SCR_001 화면만 읽기
### Q3: 권한 그룹과 개별 권한의 우선순위는?
**A**: **OR 연산**입니다. 권한 그룹에서 허용되거나, 개별 권한에서 허용되면 최종적으로 허용됩니다.
### Q4: COMPANY_ADMIN은 왜 SYSTEM 타입 권한이 없나요?
**A**: SYSTEM 타입은 **시스템 전체 설정**(예: 회사 생성/삭제, 전체 사용자 관리)이므로 SUPER_ADMIN만 접근 가능합니다.
### Q5: 동적으로 생성된 화면의 `resource_id`는 무엇인가요?
**A**: `screen_definitions.screen_code`를 사용합니다. 예: `'SCR_CONTRACT_MGMT'`
### Q6: 플로우의 `resource_id`는?
**A**: `flow_definition.id` (숫자)를 문자열로 변환하여 사용합니다. 예: `'29'`
---
## 관련 파일
- **마이그레이션**: `db/migrations/028_add_company_code_to_authority_master.sql`
- **마이그레이션**: `db/migrations/029_create_resource_based_permission_system.sql`
- **백엔드 서비스**: `backend-node/src/services/RoleService.ts`
- **프론트엔드 API**: `frontend/lib/api/role.ts`
- **권한 체계 가이드**: `docs/권한_체계_가이드.md`

View File

@ -0,0 +1,359 @@
# 메뉴 기반 권한 시스템 가이드 (동적 화면 대응)
## 개요
**기존 메뉴 기반 권한 시스템을 유지**하면서 **동적으로 생성되는 화면에도 대응**하는 개선된 시스템입니다.
### 핵심 아이디어 💡
```
사용자가 화면 생성
자동으로 메뉴 추가 (menu_info)
권한 관리자가 메뉴 권한 설정 (rel_menu_auth)
사용자는 "메뉴"로만 권한 확인 (직관적!)
```
---
## 시스템 구조
### 1. `menu_info` (메뉴 정보)
| 컬럼 | 타입 | 설명 |
| ---------------- | ------------ | ------------------------------------------------------------------ |
| objid | INTEGER | 메뉴 ID (PK) |
| menu_name | VARCHAR(100) | 메뉴 이름 |
| menu_code | VARCHAR(50) | 메뉴 코드 |
| menu_url | VARCHAR(255) | 메뉴 URL |
| **menu_type** | VARCHAR(20) | **'static'**(고정 메뉴) 또는 **'dynamic'**(화면 생성 시 자동 추가) |
| **screen_code** | VARCHAR(50) | 동적 메뉴인 경우 `screen_definitions.screen_code` |
| **company_code** | VARCHAR(20) | 회사 코드 (회사별 메뉴 격리) |
| parent_objid | INTEGER | 부모 메뉴 ID (계층 구조) |
| is_active | BOOLEAN | 활성/비활성 |
### 2. `rel_menu_auth` (메뉴별 권한)
| 컬럼 | 타입 | 설명 |
| -------------- | ------- | ----------------------------------------- |
| menu_objid | INTEGER | 메뉴 ID (FK) |
| auth_objid | INTEGER | 권한 그룹 ID (FK) |
| **create_yn** | CHAR(1) | 생성 권한 ('Y'/'N') |
| **read_yn** | CHAR(1) | 읽기 권한 ('Y'/'N') |
| **update_yn** | CHAR(1) | 수정 권한 ('Y'/'N') |
| **delete_yn** | CHAR(1) | 삭제 권한 ('Y'/'N') |
| **execute_yn** | CHAR(1) | 실행 권한 ('Y'/'N') - 플로우 실행, DDL 등 |
| **export_yn** | CHAR(1) | 내보내기 권한 ('Y'/'N') |
---
## 자동화 기능 🤖
### 1. 화면 생성 시 자동 메뉴 추가
```sql
-- 사용자가 화면 생성
INSERT INTO screen_definitions (screen_name, screen_code, company_code, ...)
VALUES ('계약 관리', 'SCR_CONTRACT', 'ILSHIN', ...);
-- ↓ 트리거가 자동 실행 ↓
-- menu_info에 자동 추가됨!
-- menu_name = '계약 관리'
-- menu_code = 'SCR_CONTRACT'
-- menu_url = '/screen/SCR_CONTRACT'
-- menu_type = 'dynamic'
-- company_code = 'ILSHIN'
```
### 2. 화면 삭제 시 자동 메뉴 비활성화
```sql
-- 화면 삭제
UPDATE screen_definitions
SET is_active = 'D'
WHERE screen_code = 'SCR_CONTRACT';
-- ↓ 트리거가 자동 실행 ↓
-- 해당 메뉴도 비활성화됨!
UPDATE menu_info
SET is_active = FALSE
WHERE screen_code = 'SCR_CONTRACT';
```
---
## 사용 예시
### 예시 1: 영업팀에게 계약 관리 화면 읽기 권한 부여
```sql
-- 1. 계약 관리 메뉴 ID 조회 (화면 생성 시 자동으로 추가됨)
SELECT objid FROM menu_info
WHERE menu_code = 'SCR_CONTRACT';
-- 결과: objid = 1005
-- 2. 영업팀 권한 그룹 ID 조회
SELECT objid FROM authority_master
WHERE auth_code = 'SALES_TEAM' AND company_code = 'ILSHIN';
-- 결과: objid = 1001
-- 3. 읽기 권한 부여
INSERT INTO rel_menu_auth (menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer)
VALUES (1005, 1001, 'N', 'Y', 'N', 'N', 'admin');
```
### 예시 2: 개발팀에게 플로우 관리 전체 권한 부여
```sql
-- 플로우 관리 메뉴에 CRUD + 실행 권한
INSERT INTO rel_menu_auth (menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, execute_yn, writer)
VALUES (
(SELECT objid FROM menu_info WHERE menu_code = 'MENU_FLOW_MGMT'),
(SELECT objid FROM authority_master WHERE auth_code = 'DEV_TEAM'),
'Y', 'Y', 'Y', 'Y', 'Y', 'admin'
);
```
### 예시 3: 권한 확인
```sql
-- 'john.doe' 사용자가 계약 관리 메뉴를 읽을 수 있는지 확인
SELECT check_user_menu_permission('john.doe', 1005, 'read');
-- 결과: TRUE 또는 FALSE
-- 'john.doe' 사용자가 접근 가능한 모든 메뉴 조회
SELECT * FROM get_user_accessible_menus('john.doe', 'ILSHIN');
```
---
## 프론트엔드 통합
### React Hook
```typescript
// hooks/useMenuPermission.ts
import { useState, useEffect } from "react";
import { checkMenuPermission } from "@/lib/api/menu";
export function useMenuPermission(
menuObjid: number,
permissionType: "create" | "read" | "update" | "delete" | "execute" | "export"
) {
const [hasPermission, setHasPermission] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const checkPermission = async () => {
try {
const response = await checkMenuPermission(menuObjid, permissionType);
setHasPermission(response.success && response.data?.hasPermission);
} catch (error) {
console.error("권한 확인 오류:", error);
setHasPermission(false);
} finally {
setIsLoading(false);
}
};
checkPermission();
}, [menuObjid, permissionType]);
return { hasPermission, isLoading };
}
```
### 사용자 메뉴 렌더링
```tsx
// components/Navigation.tsx
import { useEffect, useState } from "react";
import { getUserAccessibleMenus } from "@/lib/api/menu";
import { useAuth } from "@/hooks/useAuth";
export function Navigation() {
const { user } = useAuth();
const [menus, setMenus] = useState([]);
useEffect(() => {
const loadMenus = async () => {
if (!user) return;
const response = await getUserAccessibleMenus(
user.userId,
user.companyCode
);
if (response.success) {
setMenus(response.data);
}
};
loadMenus();
}, [user]);
return (
<nav>
{menus.map((menu) => (
<NavItem key={menu.menuObjid} menu={menu} />
))}
</nav>
);
}
```
### 버튼 권한 제어
```tsx
// components/ContractDetail.tsx
import { useMenuPermission } from "@/hooks/useMenuPermission";
export function ContractDetail({ menuObjid }: { menuObjid: number }) {
const { hasPermission: canUpdate } = useMenuPermission(menuObjid, "update");
const { hasPermission: canDelete } = useMenuPermission(menuObjid, "delete");
return (
<div>
<h1>계약 상세</h1>
{canUpdate && <Button>수정</Button>}
{canDelete && <Button variant="destructive">삭제</Button>}
</div>
);
}
```
---
## 권한 관리 UI 설계
### 권한 그룹 상세 페이지에서 메뉴 권한 설정
```tsx
// 체크박스 그리드 형태
┌─────────────────┬────────┬────────┬────────┬────────┬────────┬────────┐
│ 메뉴 │ 생성 │ 읽기 │ 수정 │ 삭제 │ 실행 │ 내보내기│
├─────────────────┼────────┼────────┼────────┼────────┼────────┼────────┤
│ 대시보드 │ ☐ │ ☑ │ ☐ │ ☐ │ ☐ │ ☐ │
│ 계약 관리 │ ☑ │ ☑ │ ☑ │ ☐ │ ☐ │ ☑ │
│ 사용자 관리 │ ☐ │ ☑ │ ☐ │ ☐ │ ☐ │ ☐ │
│ 플로우 관리 │ ☐ │ ☑ │ ☐ │ ☐ │ ☑ │ ☐ │
└─────────────────┴────────┴────────┴────────┴────────┴────────┴────────┘
```
---
## 실전 시나리오
### 시나리오: 사용자가 "배송 현황" 화면 생성 → 권한 설정
```sql
-- 1단계: 사용자가 화면 생성
INSERT INTO screen_definitions (screen_name, screen_code, company_code, created_by)
VALUES ('배송 현황', 'SCR_DELIVERY', 'ILSHIN', 'admin');
-- 2단계: 트리거가 자동으로 메뉴 추가 (자동!)
-- menu_info에 'SCR_DELIVERY' 메뉴가 자동 생성됨
-- 3단계: 권한 관리자가 영업팀에게 읽기 권한 부여
INSERT INTO rel_menu_auth (
menu_objid,
auth_objid,
read_yn,
export_yn,
writer
)
VALUES (
(SELECT objid FROM menu_info WHERE menu_code = 'SCR_DELIVERY'),
(SELECT objid FROM authority_master WHERE auth_code = 'SALES_TEAM'),
'Y',
'Y',
'admin'
);
-- 4단계: 영업팀 사용자가 로그인하면 "배송 현황" 메뉴가 보임!
SELECT * FROM get_user_accessible_menus('sales_user', 'ILSHIN');
```
---
## 장점
### ✅ 사용자 친화적
- **"메뉴" 개념으로 권한 관리** (직관적)
- 기존 시스템과 동일한 UI/UX
### ✅ 자동화
- 화면 생성 시 **자동으로 메뉴 추가**
- 화면 삭제 시 **자동으로 메뉴 비활성화**
### ✅ 세밀한 권한
- 메뉴별 **6가지 권한** (Create, Read, Update, Delete, Execute, Export)
- 권한 그룹 단위 관리
### ✅ 회사별 격리
- `menu_info.company_code`로 회사별 메뉴 분리
- 슈퍼관리자는 모든 회사 메뉴 관리
---
## 마이그레이션 실행
```bash
# 1. 권한 그룹 시스템 개선
docker exec -i <DB_CONTAINER_NAME> psql -U postgres -d ilshin < db/migrations/028_add_company_code_to_authority_master.sql
# 2. 메뉴 기반 권한 시스템 개선
docker exec -i <DB_CONTAINER_NAME> psql -U postgres -d ilshin < db/migrations/030_improve_menu_auth_system.sql
# 검증
docker exec -it <DB_CONTAINER_NAME> psql -U postgres -d ilshin -c "SELECT * FROM menu_info WHERE menu_type = 'dynamic';"
docker exec -it <DB_CONTAINER_NAME> psql -U postgres -d ilshin -c "SELECT * FROM v_menu_auth_summary;"
```
---
## FAQ
### Q1: 동적 메뉴와 정적 메뉴의 차이는?
**A**:
- **정적 메뉴** (`menu_type='static'`): 수동으로 추가한 고정 메뉴 (예: 대시보드, 사용자 관리)
- **동적 메뉴** (`menu_type='dynamic'`): 화면 생성 시 자동 추가된 메뉴
### Q2: 화면을 삭제하면 메뉴도 삭제되나요?
**A**: 메뉴는 **삭제되지 않고 비활성화**(`is_active=FALSE`)됩니다. 나중에 복구 가능합니다.
### Q3: 같은 화면에 대해 회사마다 다른 권한을 설정할 수 있나요?
**A**: 네! `menu_info.company_code``authority_master.company_code`로 회사별 격리됩니다.
### Q4: 기존 메뉴 시스템과 호환되나요?
**A**: 완전히 호환됩니다. 기존 `menu_info``rel_menu_auth`를 그대로 사용하며, 새로운 컬럼만 추가됩니다.
---
## 다음 단계
1. ✅ 마이그레이션 실행 (028, 030)
2. 🔄 백엔드 API 구현 (권한 체크 미들웨어)
3. 🔄 프론트엔드 UI 개발 (메뉴 권한 설정 그리드)
4. 🔄 테스트 (영업팀 시나리오)
---
## 관련 파일
- **마이그레이션**: `db/migrations/028_add_company_code_to_authority_master.sql`
- **마이그레이션**: `db/migrations/030_improve_menu_auth_system.sql`
- **백엔드 서비스**: `backend-node/src/services/RoleService.ts`
- **프론트엔드 API**: `frontend/lib/api/role.ts`

View File

@ -30,8 +30,10 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { formatErrorMessage } from "@/lib/utils/errorUtils";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { ScrollToTop } from "@/components/common/ScrollToTop";
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
export default function FlowManagementPage() {
const router = useRouter();
@ -74,7 +76,7 @@ export default function FlowManagementPage() {
} else {
toast({
title: "조회 실패",
description: response.error || "플로우 목록을 불러올 수 없습니다.",
description: formatErrorMessage(response.error, "플로우 목록을 불러올 수 없습니다."),
variant: "destructive",
});
}
@ -116,28 +118,14 @@ export default function FlowManagementPage() {
useEffect(() => {
const loadConnections = async () => {
try {
const token = localStorage.getItem("authToken");
if (!token) {
console.warn("No auth token found");
return;
}
const response = await ExternalDbConnectionAPI.getActiveControlConnections();
const response = await fetch("/api/external-db-connections/control/active", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response && response.ok) {
const data = await response.json();
if (data.success && data.data) {
// 메인 데이터베이스(현재 시스템) 제외 - connection_name에 "메인" 또는 "현재 시스템"이 포함된 것 필터링
const filtered = data.data.filter(
(conn: { connection_name: string }) =>
!conn.connection_name.includes("메인") && !conn.connection_name.includes("현재 시스템"),
);
setExternalConnections(filtered);
}
if (response.success && response.data) {
// 메인 데이터베이스(현재 시스템) 제외 - connection_name에 "메인" 또는 "현재 시스템"이 포함된 것 필터링
const filtered = response.data.filter(
(conn) => !conn.connection_name.includes("메인") && !conn.connection_name.includes("현재 시스템"),
);
setExternalConnections(filtered);
}
} catch (error) {
console.error("Failed to load external connections:", error);
@ -228,7 +216,7 @@ export default function FlowManagementPage() {
} else {
toast({
title: "생성 실패",
description: response.error || response.message,
description: formatErrorMessage(response.error || response.message, "플로우 생성 중 오류가 발생했습니다."),
variant: "destructive",
});
}
@ -258,7 +246,7 @@ export default function FlowManagementPage() {
} else {
toast({
title: "삭제 실패",
description: response.error,
description: formatErrorMessage(response.error, "플로우 삭제 중 오류가 발생했습니다."),
variant: "destructive",
});
}

View File

@ -0,0 +1,30 @@
"use client";
import { use } from "react";
import { RoleDetailManagement } from "@/components/admin/RoleDetailManagement";
import { ScrollToTop } from "@/components/common/ScrollToTop";
/**
*
* URL: /admin/roles/[id]
*
* :
* - (Dual List Box)
* - (CRUD )
*/
export default function RoleDetailPage({ params }: { params: Promise<{ id: string }> }) {
// Next.js 15: params는 Promise이므로 React.use()로 unwrap
const { id } = use(params);
return (
<div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6">
{/* 메인 컨텐츠 */}
<RoleDetailManagement roleId={id} />
</div>
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
<ScrollToTop />
</div>
);
}

View File

@ -0,0 +1,39 @@
"use client";
import { RoleManagement } from "@/components/admin/RoleManagement";
import { ScrollToTop } from "@/components/common/ScrollToTop";
/**
*
* URL: /admin/roles
*
* shadcn/ui
*
* :
* -
* - //
* - (Dual List Box)
* - (CRUD )
*/
export default function RolesPage() {
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground">
( )
</p>
</div>
{/* 메인 컨텐츠 */}
<RoleManagement />
</div>
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
<ScrollToTop />
</div>
);
}

View File

@ -0,0 +1,31 @@
"use client";
import { UserAuthManagement } from "@/components/admin/UserAuthManagement";
import { ScrollToTop } from "@/components/common/ScrollToTop";
/**
*
* URL: /admin/userAuth
*
*
* (SUPER_ADMIN, COMPANY_ADMIN, USER )
*/
export default function UserAuthPage() {
return (
<div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground text-sm"> . ( )</p>
</div>
{/* 메인 컨텐츠 */}
<UserAuthManagement />
</div>
{/* Scroll to Top 버튼 (모바일/태블릿 전용) */}
<ScrollToTop />
</div>
);
}

View File

@ -0,0 +1,347 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Search, ChevronRight, ChevronDown } from "lucide-react";
import { RoleGroup, roleAPI } from "@/lib/api/role";
interface MenuPermission {
menuObjid: number;
menuName: string;
menuPath?: string;
parentObjid?: number;
createYn: string;
readYn: string;
updateYn: string;
deleteYn: string;
children?: MenuPermission[];
}
interface MenuPermissionsTableProps {
permissions: any[];
onPermissionsChange: (permissions: any[]) => void;
roleGroup: RoleGroup;
}
/**
*
*
* :
* -
* - CRUD
* - /
* -
*/
export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGroup }: MenuPermissionsTableProps) {
const [searchText, setSearchText] = useState("");
const [expandedMenus, setExpandedMenus] = useState<Set<number>>(new Set());
const [allMenus, setAllMenus] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
// 전체 메뉴 목록 로드
useEffect(() => {
const loadAllMenus = async () => {
console.log("🔍 [MenuPermissionsTable] 전체 메뉴 로드 시작", {
companyCode: roleGroup.companyCode,
});
try {
setIsLoading(true);
const response = await roleAPI.getAllMenus(roleGroup.companyCode);
console.log("✅ [MenuPermissionsTable] 전체 메뉴 로드 응답", {
success: response.success,
count: response.data?.length,
data: response.data,
});
if (response.success && response.data) {
setAllMenus(response.data);
console.log("✅ [MenuPermissionsTable] 메뉴 상태 업데이트 완료", {
count: response.data.length,
});
}
} catch (error) {
console.error("❌ [MenuPermissionsTable] 메뉴 로드 실패", error);
} finally {
setIsLoading(false);
}
};
loadAllMenus();
}, [roleGroup.companyCode]);
// 메뉴 권한 상태 (로컬 상태 관리)
const [menuPermissions, setMenuPermissions] = useState<Map<number, MenuPermission>>(new Map());
const [isInitialized, setIsInitialized] = useState(false);
// allMenus가 로드되면 초기 권한 상태 설정 (한 번만)
useEffect(() => {
if (allMenus.length > 0 && !isInitialized) {
const permissionsMap = new Map<number, MenuPermission>();
allMenus.forEach((menu) => {
// 기존 권한이 있으면 사용, 없으면 기본값
const existingPermission = permissions.find((p) => p.menuObjid === menu.objid);
permissionsMap.set(menu.objid, {
menuObjid: menu.objid,
menuName: menu.menuName,
menuPath: menu.menuUrl,
parentObjid: menu.parentObjid,
createYn: existingPermission?.createYn || "N",
readYn: existingPermission?.readYn || "N",
updateYn: existingPermission?.updateYn || "N",
deleteYn: existingPermission?.deleteYn || "N",
});
});
setMenuPermissions(permissionsMap);
setIsInitialized(true);
console.log("✅ [MenuPermissionsTable] 권한 상태 초기화", {
count: permissionsMap.size,
});
}
}, [allMenus, permissions, isInitialized]);
// 메뉴 트리 구조 생성 (menuPermissions에서)
const menuTree: MenuPermission[] = Array.from(menuPermissions.values());
// 메뉴 펼치기/접기 토글
const toggleExpand = useCallback((menuObjid: number) => {
setExpandedMenus((prev) => {
const newSet = new Set(prev);
if (newSet.has(menuObjid)) {
newSet.delete(menuObjid);
} else {
newSet.add(menuObjid);
}
return newSet;
});
}, []);
// 권한 변경 핸들러
const handlePermissionChange = useCallback(
(menuObjid: number, permission: "createYn" | "readYn" | "updateYn" | "deleteYn", checked: boolean) => {
setMenuPermissions((prev) => {
const newMap = new Map(prev);
const menuPerm = newMap.get(menuObjid);
if (menuPerm) {
newMap.set(menuObjid, {
...menuPerm,
[permission]: checked ? "Y" : "N",
});
}
// 변경된 상태를 부모에 전달
const updatedPermissions = Array.from(newMap.values());
onPermissionsChange(updatedPermissions);
return newMap;
});
console.log("✅ 권한 변경:", { menuObjid, permission, checked });
},
[onPermissionsChange],
);
// 전체 선택/해제
const handleSelectAll = useCallback(
(permission: "createYn" | "readYn" | "updateYn" | "deleteYn", checked: boolean) => {
setMenuPermissions((prev) => {
const newMap = new Map(prev);
newMap.forEach((menuPerm, menuObjid) => {
newMap.set(menuObjid, {
...menuPerm,
[permission]: checked ? "Y" : "N",
});
});
// 변경된 상태를 부모에 전달
const updatedPermissions = Array.from(newMap.values());
onPermissionsChange(updatedPermissions);
return newMap;
});
console.log("✅ 전체 선택:", { permission, checked });
},
[onPermissionsChange],
);
// 메뉴 행 렌더링
const renderMenuRow = (menu: MenuPermission, level: number = 0) => {
const hasChildren = menu.children && menu.children.length > 0;
const isExpanded = expandedMenus.has(menu.menuObjid);
const paddingLeft = level * 24;
return (
<React.Fragment key={menu.menuObjid}>
<TableRow className="hover:bg-muted/50 border-b transition-colors">
{/* 메뉴명 */}
<TableCell className="h-12" style={{ paddingLeft: `${paddingLeft + 16}px` }}>
<div className="flex items-center gap-2">
{hasChildren && (
<button onClick={() => toggleExpand(menu.menuObjid)} className="transition-transform">
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
)}
<span className={`text-sm ${hasChildren ? "font-semibold" : ""}`}>{menu.menuName}</span>
</div>
</TableCell>
{/* 생성(Create) */}
<TableCell className="h-12 text-center">
<div className="flex justify-center">
<Checkbox
checked={menu.createYn === "Y"}
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "createYn", checked as boolean)}
/>
</div>
</TableCell>
{/* 조회(Read) */}
<TableCell className="h-12 text-center">
<div className="flex justify-center">
<Checkbox
checked={menu.readYn === "Y"}
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "readYn", checked as boolean)}
/>
</div>
</TableCell>
{/* 수정(Update) */}
<TableCell className="h-12 text-center">
<div className="flex justify-center">
<Checkbox
checked={menu.updateYn === "Y"}
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "updateYn", checked as boolean)}
/>
</div>
</TableCell>
{/* 삭제(Delete) */}
<TableCell className="h-12 text-center">
<div className="flex justify-center">
<Checkbox
checked={menu.deleteYn === "Y"}
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "deleteYn", checked as boolean)}
/>
</div>
</TableCell>
</TableRow>
{/* 자식 메뉴 렌더링 */}
{hasChildren && isExpanded && menu.children!.map((child) => renderMenuRow(child, level + 1))}
</React.Fragment>
);
};
return (
<div className="space-y-4">
{/* 검색 */}
<div className="flex items-center gap-4">
<div className="relative flex-1 sm:max-w-[400px]">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="메뉴 검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="h-10 pl-10 text-sm"
/>
</div>
</div>
{/* 데스크톱 테이블 */}
<div className="bg-card hidden rounded-lg border shadow-sm lg:block">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
<TableHead className="h-12 w-[40%] text-sm font-semibold"></TableHead>
<TableHead className="h-12 w-[15%] text-center text-sm font-semibold">
<div className="flex flex-col items-center gap-1">
<span> (C)</span>
<Checkbox
onCheckedChange={(checked) => handleSelectAll("createYn", checked as boolean)}
className="mt-1"
/>
</div>
</TableHead>
<TableHead className="h-12 w-[15%] text-center text-sm font-semibold">
<div className="flex flex-col items-center gap-1">
<span> (R)</span>
<Checkbox
onCheckedChange={(checked) => handleSelectAll("readYn", checked as boolean)}
className="mt-1"
/>
</div>
</TableHead>
<TableHead className="h-12 w-[15%] text-center text-sm font-semibold">
<div className="flex flex-col items-center gap-1">
<span> (U)</span>
<Checkbox
onCheckedChange={(checked) => handleSelectAll("updateYn", checked as boolean)}
className="mt-1"
/>
</div>
</TableHead>
<TableHead className="h-12 w-[15%] text-center text-sm font-semibold">
<div className="flex flex-col items-center gap-1">
<span> (D)</span>
<Checkbox
onCheckedChange={(checked) => handleSelectAll("deleteYn", checked as boolean)}
className="mt-1"
/>
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>{menuTree.map((menu) => renderMenuRow(menu))}</TableBody>
</Table>
</div>
{/* 모바일 카드 뷰 */}
<div className="grid gap-4 lg:hidden">
{menuTree.map((menu) => (
<div key={menu.menuObjid} className="bg-card rounded-lg border p-4 shadow-sm">
<h3 className="mb-3 text-base font-semibold">{menu.menuName}</h3>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground"> (C)</span>
<Checkbox
checked={menu.createYn === "Y"}
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "createYn", checked as boolean)}
/>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground"> (R)</span>
<Checkbox
checked={menu.readYn === "Y"}
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "readYn", checked as boolean)}
/>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground"> (U)</span>
<Checkbox
checked={menu.updateYn === "Y"}
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "updateYn", checked as boolean)}
/>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground"> (D)</span>
<Checkbox
checked={menu.deleteYn === "Y"}
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "deleteYn", checked as boolean)}
/>
</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,150 @@
"use client";
import React, { useState, useCallback } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { roleAPI, RoleGroup } from "@/lib/api/role";
import { AlertTriangle } from "lucide-react";
interface RoleDeleteModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
role: RoleGroup | null;
}
/**
*
*
* :
* -
* - CASCADE (, )
*
* shadcn/ui
*/
export function RoleDeleteModal({ isOpen, onClose, onSuccess, role }: RoleDeleteModalProps) {
const [isLoading, setIsLoading] = useState(false);
const [showAlert, setShowAlert] = useState(false);
const [alertMessage, setAlertMessage] = useState("");
const [alertType, setAlertType] = useState<"success" | "error">("error");
// 알림 표시
const displayAlert = useCallback((message: string, type: "success" | "error") => {
setAlertMessage(message);
setAlertType(type);
setShowAlert(true);
setTimeout(() => setShowAlert(false), 3000);
}, []);
// 삭제 핸들러
const handleDelete = useCallback(async () => {
if (!role) return;
setIsLoading(true);
try {
const response = await roleAPI.delete(role.objid);
if (response.success) {
displayAlert("권한 그룹이 삭제되었습니다.", "success");
setTimeout(() => {
onClose();
onSuccess?.();
}, 1500);
} else {
displayAlert(response.message || "삭제에 실패했습니다.", "error");
}
} catch (error) {
console.error("권한 그룹 삭제 오류:", error);
displayAlert("권한 그룹 삭제 중 오류가 발생했습니다.", "error");
} finally {
setIsLoading(false);
}
}, [role, onClose, onSuccess, displayAlert]);
if (!role) return null;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* 경고 메시지 */}
<div className="rounded-lg border border-orange-300 bg-orange-50 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-5 w-5 flex-shrink-0 text-orange-600" />
<div className="space-y-2">
<p className="text-sm font-semibold text-orange-900"> ?</p>
<p className="text-xs text-orange-800">
. :
</p>
<ul className="list-inside list-disc space-y-1 text-xs text-orange-800">
<li> ({role.memberCount || 0})</li>
<li> ({role.menuCount || 0})</li>
</ul>
</div>
</div>
</div>
{/* 삭제할 권한 그룹 정보 */}
<div className="bg-muted/50 rounded-lg border p-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"> </span>
<span className="font-medium">{role.authName}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"> </span>
<span className="font-mono font-medium">{role.authCode}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{role.companyCode}</span>
</div>
{role.memberNames && (
<div className="border-t pt-2">
<span className="text-muted-foreground text-xs">:</span>
<p className="mt-1 text-xs">{role.memberNames}</p>
</div>
)}
</div>
</div>
{/* 알림 메시지 */}
{showAlert && (
<div
className={`rounded-lg border p-3 text-sm ${
alertType === "success"
? "border-green-300 bg-green-50 text-green-800"
: "border-destructive/50 bg-destructive/10 text-destructive"
}`}
>
{alertMessage}
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={onClose}
disabled={isLoading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={isLoading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isLoading ? "삭제중..." : "삭제"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,337 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Users, Menu as MenuIcon, Save } from "lucide-react";
import { roleAPI, RoleGroup } from "@/lib/api/role";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { AlertCircle } from "lucide-react";
import { DualListBox } from "@/components/common/DualListBox";
import { MenuPermissionsTable } from "./MenuPermissionsTable";
interface RoleDetailManagementProps {
roleId: string;
}
/**
*
*
* :
* -
* - (Dual List Box)
* - (CRUD )
*/
export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
const { user: currentUser } = useAuth();
const router = useRouter();
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
// 상태 관리
const [roleGroup, setRoleGroup] = useState<RoleGroup | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 탭 상태
const [activeTab, setActiveTab] = useState<"members" | "permissions">("members");
// 멤버 관리 상태
const [availableUsers, setAvailableUsers] = useState<Array<{ id: string; name: string; dept?: string }>>([]);
const [selectedUsers, setSelectedUsers] = useState<Array<{ id: string; name: string; dept?: string }>>([]);
const [isSavingMembers, setIsSavingMembers] = useState(false);
// 메뉴 권한 상태
const [menuPermissions, setMenuPermissions] = useState<any[]>([]);
const [isSavingPermissions, setIsSavingPermissions] = useState(false);
// 데이터 로드
const loadRoleGroup = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await roleAPI.getById(parseInt(roleId, 10));
if (response.success && response.data) {
setRoleGroup(response.data);
} else {
setError(response.message || "권한 그룹 정보를 불러오는데 실패했습니다.");
}
} catch (err) {
console.error("권한 그룹 정보 로드 오류:", err);
setError("권한 그룹 정보를 불러오는 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
}, [roleId]);
// 멤버 목록 로드
const loadMembers = useCallback(async () => {
if (!roleGroup) return;
try {
// 1. 권한 그룹 멤버 조회
const membersResponse = await roleAPI.getMembers(roleGroup.objid);
if (membersResponse.success && membersResponse.data) {
setSelectedUsers(
membersResponse.data.map((member: any) => ({
id: member.userId,
label: member.userName || member.userId,
description: member.deptName,
})),
);
}
// 2. 전체 사용자 목록 조회 (같은 회사)
const userAPI = await import("@/lib/api/user");
console.log("🔍 사용자 목록 조회 요청:", {
companyCode: roleGroup.companyCode,
size: 1000,
});
const usersResponse = await userAPI.userAPI.getList({
companyCode: roleGroup.companyCode,
size: 1000, // 대량 조회
});
console.log("✅ 사용자 목록 응답:", {
success: usersResponse.success,
count: usersResponse.data?.length,
total: usersResponse.total,
});
if (usersResponse.success && usersResponse.data) {
setAvailableUsers(
usersResponse.data.map((user: any) => ({
id: user.userId,
label: user.userName || user.userId,
description: user.deptName,
})),
);
console.log("📋 설정된 전체 사용자 수:", usersResponse.data.length);
}
} catch (err) {
console.error("멤버 목록 로드 오류:", err);
}
}, [roleGroup]);
// 메뉴 권한 로드
const loadMenuPermissions = useCallback(async () => {
if (!roleGroup) return;
console.log("🔍 [loadMenuPermissions] 메뉴 권한 로드 시작", {
roleGroupId: roleGroup.objid,
roleGroupName: roleGroup.authName,
companyCode: roleGroup.companyCode,
});
try {
const response = await roleAPI.getMenuPermissions(roleGroup.objid);
console.log("✅ [loadMenuPermissions] API 응답", {
success: response.success,
dataCount: response.data?.length,
data: response.data,
});
if (response.success && response.data) {
setMenuPermissions(response.data);
console.log("✅ [loadMenuPermissions] 상태 업데이트 완료", {
count: response.data.length,
});
} else {
console.warn("⚠️ [loadMenuPermissions] 응답 실패", {
message: response.message,
});
}
} catch (err) {
console.error("❌ [loadMenuPermissions] 메뉴 권한 로드 오류:", err);
}
}, [roleGroup]);
useEffect(() => {
loadRoleGroup();
}, [loadRoleGroup]);
useEffect(() => {
if (roleGroup && activeTab === "members") {
loadMembers();
} else if (roleGroup && activeTab === "permissions") {
loadMenuPermissions();
}
}, [roleGroup, activeTab, loadMembers, loadMenuPermissions]);
// 멤버 저장 핸들러
const handleSaveMembers = useCallback(async () => {
if (!roleGroup) return;
setIsSavingMembers(true);
try {
// 현재 선택된 사용자 ID 목록
const selectedUserIds = selectedUsers.map((user) => user.id);
// 멤버 업데이트 API 호출
const response = await roleAPI.updateMembers(roleGroup.objid, selectedUserIds);
if (response.success) {
alert("멤버가 성공적으로 저장되었습니다.");
loadMembers(); // 새로고침
} else {
alert(response.message || "멤버 저장에 실패했습니다.");
}
} catch (err) {
console.error("멤버 저장 오류:", err);
alert("멤버 저장 중 오류가 발생했습니다.");
} finally {
setIsSavingMembers(false);
}
}, [roleGroup, selectedUsers, loadMembers]);
// 메뉴 권한 저장 핸들러
const handleSavePermissions = useCallback(async () => {
if (!roleGroup) return;
setIsSavingPermissions(true);
try {
const response = await roleAPI.setMenuPermissions(roleGroup.objid, menuPermissions);
if (response.success) {
alert("메뉴 권한이 성공적으로 저장되었습니다.");
loadMenuPermissions(); // 새로고침
} else {
alert(response.message || "메뉴 권한 저장에 실패했습니다.");
}
} catch (err) {
console.error("메뉴 권한 저장 오류:", err);
alert("메뉴 권한 저장 중 오류가 발생했습니다.");
} finally {
setIsSavingPermissions(false);
}
}, [roleGroup, menuPermissions, loadMenuPermissions]);
if (isLoading) {
return (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-12 shadow-sm">
<div className="flex flex-col items-center gap-4">
<div className="border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent"></div>
<p className="text-muted-foreground text-sm"> ...</p>
</div>
</div>
);
}
if (error || !roleGroup) {
return (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-6 shadow-sm">
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
<h3 className="mb-2 text-lg font-semibold"> </h3>
<p className="text-muted-foreground mb-4 text-center text-sm">{error || "권한 그룹을 찾을 수 없습니다."}</p>
<Button variant="outline" onClick={() => router.push("/admin/roles")}>
</Button>
</div>
);
}
return (
<>
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.push("/admin/roles")} className="h-10 w-10">
<ArrowLeft className="h-5 w-5" />
</Button>
<div className="flex-1">
<h1 className="text-3xl font-bold tracking-tight">{roleGroup.authName}</h1>
<p className="text-muted-foreground text-sm">
{roleGroup.authCode} {roleGroup.companyCode}
</p>
</div>
<span
className={`rounded-full px-3 py-1 text-sm font-medium ${
roleGroup.status === "active" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
}`}
>
{roleGroup.status === "active" ? "활성" : "비활성"}
</span>
</div>
</div>
{/* 탭 네비게이션 */}
<div className="flex gap-4 border-b">
<button
onClick={() => setActiveTab("members")}
className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
activeTab === "members"
? "border-primary text-primary"
: "text-muted-foreground hover:text-foreground border-transparent"
}`}
>
<Users className="h-4 w-4" />
({selectedUsers.length})
</button>
<button
onClick={() => setActiveTab("permissions")}
className={`flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors ${
activeTab === "permissions"
? "border-primary text-primary"
: "text-muted-foreground hover:text-foreground border-transparent"
}`}
>
<MenuIcon className="h-4 w-4" />
({menuPermissions.length})
</button>
</div>
{/* 탭 컨텐츠 */}
<div className="space-y-6">
{activeTab === "members" && (
<>
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold"> </h2>
<p className="text-muted-foreground text-sm"> </p>
</div>
<Button onClick={handleSaveMembers} disabled={isSavingMembers} className="gap-2">
<Save className="h-4 w-4" />
{isSavingMembers ? "저장 중..." : "멤버 저장"}
</Button>
</div>
<DualListBox
availableItems={availableUsers}
selectedItems={selectedUsers}
onSelectionChange={setSelectedUsers}
availableLabel="전체 사용자"
selectedLabel="그룹 멤버"
enableSearch
/>
</>
)}
{activeTab === "permissions" && (
<>
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold"> </h2>
<p className="text-muted-foreground text-sm"> </p>
</div>
<Button onClick={handleSavePermissions} disabled={isSavingPermissions} className="gap-2">
<Save className="h-4 w-4" />
{isSavingPermissions ? "저장 중..." : "권한 저장"}
</Button>
</div>
<MenuPermissionsTable
permissions={menuPermissions}
onPermissionsChange={setMenuPermissions}
roleGroup={roleGroup}
/>
</>
)}
</div>
</>
);
}

View File

@ -0,0 +1,375 @@
"use client";
import React, { useState, useCallback, useEffect, useMemo } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { roleAPI, RoleGroup } from "@/lib/api/role";
import { useAuth } from "@/hooks/useAuth";
import { AlertCircle, Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { companyAPI } from "@/lib/api/company";
interface RoleFormModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
editingRole?: RoleGroup | null;
}
/**
* /
*
* :
* - (authName, authCode, companyCode)
* - (authName, authCode, status)
* -
*
* shadcn/ui
*/
export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleFormModalProps) {
const { user: currentUser } = useAuth();
const isEditMode = !!editingRole;
// 최고 관리자 여부
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
// 폼 데이터
const [formData, setFormData] = useState({
authName: "",
authCode: "",
companyCode: currentUser?.companyCode || "",
status: "active",
});
// 상태 관리
const [isLoading, setIsLoading] = useState(false);
const [showAlert, setShowAlert] = useState(false);
const [alertMessage, setAlertMessage] = useState("");
const [alertType, setAlertType] = useState<"success" | "error" | "info">("info");
// 회사 목록 (최고 관리자용)
const [companies, setCompanies] = useState<Array<{ company_code: string; company_name: string }>>([]);
const [isLoadingCompanies, setIsLoadingCompanies] = useState(false);
const [companyComboOpen, setCompanyComboOpen] = useState(false);
// 폼 유효성 검사
const isFormValid = useMemo(() => {
return formData.authName.trim() !== "" && formData.authCode.trim() !== "" && formData.companyCode.trim() !== "";
}, [formData]);
// 알림 표시
const displayAlert = useCallback((message: string, type: "success" | "error" | "info") => {
setAlertMessage(message);
setAlertType(type);
setShowAlert(true);
setTimeout(() => setShowAlert(false), 3000);
}, []);
// 회사 목록 로드 (최고 관리자만)
const loadCompanies = useCallback(async () => {
if (!isSuperAdmin) return;
setIsLoadingCompanies(true);
try {
// companyAPI.getList()는 Promise<Company[]>를 반환하므로 직접 사용
const companies = await companyAPI.getList();
console.log("📋 회사 목록 로드 성공:", companies);
setCompanies(companies);
} catch (error) {
console.error("❌ 회사 목록 로드 오류:", error);
displayAlert("회사 목록을 불러오는데 실패했습니다.", "error");
} finally {
setIsLoadingCompanies(false);
}
}, [isSuperAdmin, displayAlert]);
// 초기화
useEffect(() => {
if (isOpen) {
// 최고 관리자이고 생성 모드일 때만 회사 목록 로드
if (isSuperAdmin && !isEditMode) {
loadCompanies();
}
if (isEditMode && editingRole) {
// 수정 모드: 기존 데이터 로드
setFormData({
authName: editingRole.authName || "",
authCode: editingRole.authCode || "",
companyCode: editingRole.companyCode || "",
status: editingRole.status || "active",
});
} else {
// 생성 모드: 초기화
setFormData({
authName: "",
authCode: "",
companyCode: currentUser?.companyCode || "",
status: "active",
});
}
setShowAlert(false);
}
}, [isOpen, isEditMode, editingRole, currentUser?.companyCode, isSuperAdmin, loadCompanies]);
// 입력 핸들러
const handleInputChange = useCallback((field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
}, []);
// 제출 핸들러
const handleSubmit = useCallback(async () => {
if (!isFormValid) {
displayAlert("모든 필수 항목을 입력해주세요.", "error");
return;
}
setIsLoading(true);
try {
let response;
if (isEditMode && editingRole) {
// 수정
response = await roleAPI.update(editingRole.objid, {
authName: formData.authName,
authCode: formData.authCode,
status: formData.status,
});
} else {
// 생성
response = await roleAPI.create({
authName: formData.authName,
authCode: formData.authCode,
companyCode: formData.companyCode,
});
}
if (response.success) {
displayAlert(isEditMode ? "권한 그룹이 수정되었습니다." : "권한 그룹이 생성되었습니다.", "success");
setTimeout(() => {
onClose();
onSuccess?.();
}, 1500);
} else {
displayAlert(response.message || "작업에 실패했습니다.", "error");
}
} catch (error) {
console.error("권한 그룹 저장 오류:", error);
displayAlert("권한 그룹 저장 중 오류가 발생했습니다.", "error");
} finally {
setIsLoading(false);
}
}, [isFormValid, isEditMode, editingRole, formData, onClose, onSuccess, displayAlert]);
// Enter 키 핸들러
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && isFormValid && !isLoading) {
handleSubmit();
}
},
[isFormValid, isLoading, handleSubmit],
);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">{isEditMode ? "권한 그룹 수정" : "권한 그룹 생성"}</DialogTitle>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{/* 권한 그룹명 */}
<div>
<Label htmlFor="authName" className="text-xs sm:text-sm">
<span className="text-red-500">*</span>
</Label>
<Input
id="authName"
value={formData.authName}
onChange={(e) => handleInputChange("authName", e.target.value)}
onKeyDown={handleKeyDown}
placeholder="예: 영업팀 권한"
className="h-8 text-xs sm:h-10 sm:text-sm"
disabled={isLoading}
/>
</div>
{/* 권한 코드 */}
<div>
<Label htmlFor="authCode" className="text-xs sm:text-sm">
<span className="text-red-500">*</span>
</Label>
<Input
id="authCode"
value={formData.authCode}
onChange={(e) => handleInputChange("authCode", e.target.value)}
onKeyDown={handleKeyDown}
placeholder="예: SALES_TEAM (영문/숫자/언더스코어만)"
className="h-8 text-xs sm:h-10 sm:text-sm"
disabled={isLoading}
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
. , , .
</p>
</div>
{/* 회사 (수정 모드에서는 비활성화) */}
{isEditMode ? (
<div>
<Label htmlFor="companyCode" className="text-xs sm:text-sm">
</Label>
<Input
id="companyCode"
value={formData.companyCode}
disabled
className="bg-muted h-8 cursor-not-allowed text-xs sm:h-10 sm:text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs"> .</p>
</div>
) : (
<div>
<Label htmlFor="companyCode" className="text-xs sm:text-sm">
<span className="text-red-500">*</span>
</Label>
{isSuperAdmin ? (
<>
<Popover open={companyComboOpen} onOpenChange={setCompanyComboOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={companyComboOpen}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={isLoading || isLoadingCompanies}
>
{formData.companyCode
? companies.find((company) => company.company_code === formData.companyCode)?.company_name ||
formData.companyCode
: "회사 선택..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="회사 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm">
{isLoadingCompanies ? "로딩 중..." : "회사를 찾을 수 없습니다."}
</CommandEmpty>
<CommandGroup>
{companies.map((company) => (
<CommandItem
key={company.company_code}
value={`${company.company_code} ${company.company_name}`}
onSelect={() => {
handleInputChange("companyCode", company.company_code);
setCompanyComboOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.companyCode === company.company_code ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{company.company_name}</span>
<span className="text-muted-foreground text-[10px]">{company.company_code}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
.
</p>
</>
) : (
<>
<Input
id="companyCode"
value={formData.companyCode}
disabled
className="bg-muted h-8 cursor-not-allowed text-xs sm:h-10 sm:text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
.
</p>
</>
)}
</div>
)}
{/* 상태 (수정 모드에서만 표시) */}
{isEditMode && (
<div>
<Label htmlFor="status" className="text-xs sm:text-sm">
</Label>
<Select value={formData.status} onValueChange={(value) => handleInputChange("status", value)}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active"></SelectItem>
<SelectItem value="inactive"></SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* 알림 메시지 */}
{showAlert && (
<div
className={`rounded-lg border p-3 text-sm ${
alertType === "success"
? "border-green-300 bg-green-50 text-green-800"
: alertType === "error"
? "border-destructive/50 bg-destructive/10 text-destructive"
: "border-blue-300 bg-blue-50 text-blue-800"
}`}
>
<div className="flex items-start gap-2">
{alertType === "error" && <AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />}
<span className="text-xs sm:text-sm">{alertMessage}</span>
</div>
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={onClose}
disabled={isLoading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSubmit}
disabled={isLoading || !isFormValid}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isLoading ? "처리중..." : isEditMode ? "수정" : "생성"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,335 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Plus, Edit, Trash2, Users, Menu, Filter, X } from "lucide-react";
import { roleAPI, RoleGroup } from "@/lib/api/role";
import { useAuth } from "@/hooks/useAuth";
import { AlertCircle } from "lucide-react";
import { RoleFormModal } from "./RoleFormModal";
import { RoleDeleteModal } from "./RoleDeleteModal";
import { useRouter } from "next/navigation";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { companyAPI } from "@/lib/api/company";
/**
*
*
* :
* - ()
* - //
* - ( + )
*/
export function RoleManagement() {
const { user: currentUser } = useAuth();
const router = useRouter();
// 회사 관리자 또는 최고 관리자 여부
const isAdmin =
(currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN") ||
currentUser?.userType === "COMPANY_ADMIN";
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
// 상태 관리
const [roleGroups, setRoleGroups] = useState<RoleGroup[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 회사 필터 (최고 관리자 전용)
const [companies, setCompanies] = useState<Array<{ company_code: string; company_name: string }>>([]);
const [selectedCompany, setSelectedCompany] = useState<string>("all");
// 모달 상태
const [formModal, setFormModal] = useState({
isOpen: false,
editingRole: null as RoleGroup | null,
});
const [deleteModal, setDeleteModal] = useState({
isOpen: false,
role: null as RoleGroup | null,
});
// 회사 목록 로드 (최고 관리자만)
const loadCompanies = useCallback(async () => {
if (!isSuperAdmin) return;
try {
const companies = await companyAPI.getList();
setCompanies(companies);
} catch (error) {
console.error("회사 목록 로드 오류:", error);
}
}, [isSuperAdmin]);
// 데이터 로드
const loadRoleGroups = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
// 최고 관리자: selectedCompany에 따라 필터링 (all이면 전체 조회)
// 회사 관리자: 자기 회사만 조회
const companyFilter =
isSuperAdmin && selectedCompany !== "all"
? selectedCompany
: isSuperAdmin
? undefined
: currentUser?.companyCode;
console.log("권한 그룹 목록 조회:", { isSuperAdmin, selectedCompany, companyFilter });
const response = await roleAPI.getList({
companyCode: companyFilter,
});
if (response.success && response.data) {
setRoleGroups(response.data);
console.log("권한 그룹 조회 성공:", response.data.length, "개");
} else {
setError(response.message || "권한 그룹 목록을 불러오는데 실패했습니다.");
}
} catch (err) {
console.error("권한 그룹 목록 로드 오류:", err);
setError("권한 그룹 목록을 불러오는 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
}, [isSuperAdmin, selectedCompany, currentUser?.companyCode]);
useEffect(() => {
if (isAdmin) {
if (isSuperAdmin) {
loadCompanies(); // 최고 관리자는 회사 목록 먼저 로드
}
loadRoleGroups();
} else {
setIsLoading(false);
}
}, [isAdmin, isSuperAdmin, loadRoleGroups, loadCompanies]);
// 권한 그룹 생성 핸들러
const handleCreateRole = useCallback(() => {
setFormModal({ isOpen: true, editingRole: null });
}, []);
// 권한 그룹 수정 핸들러
const handleEditRole = useCallback((role: RoleGroup) => {
setFormModal({ isOpen: true, editingRole: role });
}, []);
// 권한 그룹 삭제 핸들러
const handleDeleteRole = useCallback((role: RoleGroup) => {
setDeleteModal({ isOpen: true, role });
}, []);
// 폼 모달 닫기
const handleFormModalClose = useCallback(() => {
setFormModal({ isOpen: false, editingRole: null });
}, []);
// 삭제 모달 닫기
const handleDeleteModalClose = useCallback(() => {
setDeleteModal({ isOpen: false, role: null });
}, []);
// 모달 성공 후 새로고침
const handleModalSuccess = useCallback(() => {
loadRoleGroups();
}, [loadRoleGroups]);
// 상세 페이지로 이동
const handleViewDetail = useCallback(
(role: RoleGroup) => {
router.push(`/admin/roles/${role.objid}`);
},
[router],
);
// 관리자가 아니면 접근 제한
if (!isAdmin) {
return (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-6 shadow-sm">
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
<h3 className="mb-2 text-lg font-semibold"> </h3>
<p className="text-muted-foreground mb-4 text-center text-sm">
.
</p>
<Button variant="outline" onClick={() => window.history.back()}>
</Button>
</div>
);
}
return (
<>
{/* 에러 메시지 */}
{error && (
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
<div className="flex items-center justify-between">
<p className="text-destructive text-sm font-semibold"> </p>
<button
onClick={() => setError(null)}
className="text-destructive hover:text-destructive/80 transition-colors"
aria-label="에러 메시지 닫기"
>
</button>
</div>
<p className="text-destructive/80 mt-1.5 text-sm">{error}</p>
</div>
)}
{/* 액션 버튼 영역 */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-4">
<h2 className="text-xl font-semibold"> ({roleGroups.length})</h2>
{/* 최고 관리자 전용: 회사 필터 */}
{isSuperAdmin && (
<div className="flex items-center gap-2">
<Filter className="text-muted-foreground h-4 w-4" />
<Select value={selectedCompany} onValueChange={(value) => setSelectedCompany(value)}>
<SelectTrigger className="h-10 w-[200px]">
<SelectValue placeholder="회사 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
{companies.map((company) => (
<SelectItem key={company.company_code} value={company.company_code}>
{company.company_name}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedCompany !== "all" && (
<Button variant="ghost" size="sm" onClick={() => setSelectedCompany("all")} className="h-8 w-8 p-0">
<X className="h-4 w-4" />
</Button>
)}
</div>
)}
</div>
<Button onClick={handleCreateRole} className="gap-2">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 권한 그룹 목록 */}
{isLoading ? (
<div className="bg-card rounded-lg border p-12 shadow-sm">
<div className="flex flex-col items-center justify-center gap-4">
<div className="border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent"></div>
<p className="text-muted-foreground text-sm"> ...</p>
</div>
</div>
) : roleGroups.length === 0 ? (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-muted-foreground text-sm"> .</p>
<p className="text-muted-foreground text-xs"> .</p>
</div>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{roleGroups.map((role) => (
<div key={role.objid} className="bg-card rounded-lg border shadow-sm transition-colors">
{/* 헤더 (클릭 시 상세 페이지) */}
<div
className="hover:bg-muted/50 cursor-pointer p-4 transition-colors"
onClick={() => handleViewDetail(role)}
>
<div className="mb-4 flex items-start justify-between">
<div className="flex-1">
<h3 className="text-base font-semibold">{role.authName}</h3>
<p className="text-muted-foreground mt-1 font-mono text-sm">{role.authCode}</p>
</div>
<span
className={`rounded-full px-2 py-1 text-xs font-medium ${
role.status === "active" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
}`}
>
{role.status === "active" ? "활성" : "비활성"}
</span>
</div>
{/* 정보 */}
<div className="space-y-2 border-t pt-4">
{/* 최고 관리자는 회사명 표시 */}
{isSuperAdmin && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">
{companies.find((c) => c.company_code === role.companyCode)?.company_name || role.companyCode}
</span>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-muted-foreground flex items-center gap-1">
<Users className="h-3 w-3" />
</span>
<span className="font-medium">{role.memberCount || 0}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground flex items-center gap-1">
<Menu className="h-3 w-3" />
</span>
<span className="font-medium">{role.menuCount || 0}</span>
</div>
</div>
</div>
{/* 액션 버튼 */}
<div className="flex gap-2 border-t p-3">
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleEditRole(role);
}}
className="flex-1 gap-1 text-xs"
>
<Edit className="h-3 w-3" />
</Button>
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleDeleteRole(role);
}}
className="text-destructive hover:bg-destructive hover:text-destructive-foreground gap-1 text-xs"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
)}
{/* 모달들 */}
<RoleFormModal
isOpen={formModal.isOpen}
onClose={handleFormModalClose}
onSuccess={handleModalSuccess}
editingRole={formModal.editingRole}
/>
<RoleDeleteModal
isOpen={deleteModal.isOpen}
onClose={handleDeleteModalClose}
onSuccess={handleModalSuccess}
role={deleteModal.role}
/>
</>
);
}

View File

@ -0,0 +1,211 @@
"use client";
import React, { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { userAPI } from "@/lib/api/user";
import { Shield, ShieldCheck, User, Users, Building2, AlertTriangle } from "lucide-react";
interface UserAuthEditModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
user: any | null;
}
/**
*
*
* ( )
*/
export function UserAuthEditModal({ isOpen, onClose, onSuccess, user }: UserAuthEditModalProps) {
const [selectedUserType, setSelectedUserType] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [showConfirmation, setShowConfirmation] = useState(false);
// 모달 열릴 때 현재 권한 설정
useEffect(() => {
if (isOpen && user) {
setSelectedUserType(user.userType || "USER");
setShowConfirmation(false);
}
}, [isOpen, user]);
// 권한 정보
const userTypeOptions = [
{
value: "SUPER_ADMIN",
label: "최고 관리자",
description: "모든 회사 관리, DDL 실행, 회사 생성/삭제 가능",
icon: <ShieldCheck className="h-4 w-4" />,
color: "text-purple-600",
},
{
value: "COMPANY_ADMIN",
label: "회사 관리자",
description: "자기 회사 데이터 및 사용자 관리 가능",
icon: <Building2 className="h-4 w-4" />,
color: "text-blue-600",
},
{
value: "USER",
label: "일반 사용자",
description: "자기 회사 데이터 조회/수정만 가능",
icon: <User className="h-4 w-4" />,
color: "text-gray-600",
},
{
value: "GUEST",
label: "게스트",
description: "제한된 조회 권한",
icon: <Users className="h-4 w-4" />,
color: "text-green-600",
},
{
value: "PARTNER",
label: "협력업체",
description: "협력업체 전용 권한",
icon: <Shield className="h-4 w-4" />,
color: "text-orange-600",
},
];
const selectedOption = userTypeOptions.find((opt) => opt.value === selectedUserType);
// 권한 변경 여부 확인
const isUserTypeChanged = user && selectedUserType !== user.userType;
// 권한 변경 처리
const handleSave = async () => {
if (!user || !isUserTypeChanged) {
onClose();
return;
}
// SUPER_ADMIN 변경 시 확인
if (selectedUserType === "SUPER_ADMIN" && !showConfirmation) {
setShowConfirmation(true);
return;
}
setIsLoading(true);
try {
const response = await userAPI.update({
userId: user.userId,
userName: user.userName,
companyCode: user.companyCode,
deptCode: user.deptCode,
userType: selectedUserType,
});
if (response.success) {
onSuccess?.();
} else {
alert(response.message || "권한 변경에 실패했습니다.");
}
} catch (error) {
console.error("권한 변경 오류:", error);
alert("권한 변경 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
if (!user) return null;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* 사용자 정보 */}
<div className="bg-muted/50 rounded-lg border p-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"> ID</span>
<span className="font-mono font-medium">{user.userId}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{user.userName}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{user.companyName || user.companyCode}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"> </span>
<span className="font-medium">
{userTypeOptions.find((opt) => opt.value === user.userType)?.label || user.userType}
</span>
</div>
</div>
</div>
{/* 권한 선택 */}
<div className="space-y-2">
<Label htmlFor="userType" className="text-sm font-medium">
<span className="text-red-500">*</span>
</Label>
<Select value={selectedUserType} onValueChange={setSelectedUserType}>
<SelectTrigger className="h-10">
<SelectValue placeholder="권한 선택" />
</SelectTrigger>
<SelectContent>
{userTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<span className={option.color}>{option.icon}</span>
<span>{option.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{selectedOption && <p className="text-muted-foreground text-xs">{selectedOption.description}</p>}
</div>
{/* SUPER_ADMIN 경고 */}
{showConfirmation && selectedUserType === "SUPER_ADMIN" && (
<div className="rounded-lg border border-orange-300 bg-orange-50 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-5 w-5 flex-shrink-0 text-orange-600" />
<div className="space-y-2">
<p className="text-sm font-semibold text-orange-900"> </p>
<p className="text-xs text-orange-800">
, DDL을 , /
. .
</p>
</div>
</div>
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={onClose}
disabled={isLoading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSave}
disabled={isLoading || !isUserTypeChanged}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isLoading ? "처리중..." : showConfirmation ? "확인 및 저장" : "저장"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,157 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { UserAuthTable } from "./UserAuthTable";
import { UserAuthEditModal } from "./UserAuthEditModal";
import { userAPI } from "@/lib/api/user";
import { useAuth } from "@/hooks/useAuth";
import { AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
/**
*
*
* :
* - ( )
* -
* -
*/
export function UserAuthManagement() {
const { user: currentUser } = useAuth();
// 최고 관리자 여부
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
// 상태 관리
const [users, setUsers] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [paginationInfo, setPaginationInfo] = useState({
currentPage: 1,
pageSize: 20,
totalItems: 0,
totalPages: 0,
});
// 권한 변경 모달
const [authEditModal, setAuthEditModal] = useState({
isOpen: false,
user: null as any | null,
});
// 데이터 로드
const loadUsers = useCallback(
async (page: number = 1) => {
setIsLoading(true);
setError(null);
try {
const response = await userAPI.getList({
page,
size: paginationInfo.pageSize,
});
if (response.success && response.data) {
setUsers(response.data);
setPaginationInfo({
currentPage: response.currentPage || page,
pageSize: response.pageSize || paginationInfo.pageSize,
totalItems: response.total || 0,
totalPages: Math.ceil((response.total || 0) / (response.pageSize || paginationInfo.pageSize)),
});
} else {
setError(response.message || "사용자 목록을 불러오는데 실패했습니다.");
}
} catch (err) {
console.error("사용자 목록 로드 오류:", err);
setError("사용자 목록을 불러오는 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
},
[paginationInfo.pageSize],
);
useEffect(() => {
loadUsers(1);
}, []);
// 권한 변경 핸들러
const handleEditAuth = (user: any) => {
setAuthEditModal({
isOpen: true,
user,
});
};
// 권한 변경 모달 닫기
const handleAuthEditClose = () => {
setAuthEditModal({
isOpen: false,
user: null,
});
};
// 권한 변경 성공
const handleAuthEditSuccess = () => {
loadUsers(paginationInfo.currentPage);
handleAuthEditClose();
};
// 페이지 변경
const handlePageChange = (page: number) => {
loadUsers(page);
};
// 최고 관리자가 아닌 경우
if (!isSuperAdmin) {
return (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-6 shadow-sm">
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
<h3 className="mb-2 text-lg font-semibold"> </h3>
<p className="text-muted-foreground mb-4 text-center text-sm"> .</p>
<Button variant="outline" onClick={() => window.history.back()}>
</Button>
</div>
);
}
return (
<div className="space-y-6">
{/* 에러 메시지 */}
{error && (
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
<div className="flex items-center justify-between">
<p className="text-destructive text-sm font-semibold"> </p>
<button
onClick={() => setError(null)}
className="text-destructive hover:text-destructive/80 transition-colors"
aria-label="에러 메시지 닫기"
>
</button>
</div>
<p className="text-destructive/80 mt-1.5 text-sm">{error}</p>
</div>
)}
{/* 사용자 권한 테이블 */}
<UserAuthTable
users={users}
isLoading={isLoading}
paginationInfo={paginationInfo}
onEditAuth={handleEditAuth}
onPageChange={handlePageChange}
/>
{/* 권한 변경 모달 */}
<UserAuthEditModal
isOpen={authEditModal.isOpen}
onClose={handleAuthEditClose}
onSuccess={handleAuthEditSuccess}
user={authEditModal.user}
/>
</div>
);
}

View File

@ -0,0 +1,254 @@
"use client";
import React from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Shield, ShieldCheck, User as UserIcon, Users, Building2 } from "lucide-react";
interface UserAuthTableProps {
users: any[];
isLoading: boolean;
paginationInfo: {
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
};
onEditAuth: (user: any) => void;
onPageChange: (page: number) => void;
}
/**
*
*
*
*/
export function UserAuthTable({ users, isLoading, paginationInfo, onEditAuth, onPageChange }: UserAuthTableProps) {
// 권한 레벨 표시
const getUserTypeInfo = (userType: string) => {
switch (userType) {
case "SUPER_ADMIN":
return {
label: "최고 관리자",
icon: <ShieldCheck className="h-3 w-3" />,
className: "bg-purple-100 text-purple-800 border-purple-300",
};
case "COMPANY_ADMIN":
return {
label: "회사 관리자",
icon: <Building2 className="h-3 w-3" />,
className: "bg-blue-100 text-blue-800 border-blue-300",
};
case "USER":
return {
label: "일반 사용자",
icon: <UserIcon className="h-3 w-3" />,
className: "bg-gray-100 text-gray-800 border-gray-300",
};
case "GUEST":
return {
label: "게스트",
icon: <Users className="h-3 w-3" />,
className: "bg-green-100 text-green-800 border-green-300",
};
case "PARTNER":
return {
label: "협력업체",
icon: <Shield className="h-3 w-3" />,
className: "bg-orange-100 text-orange-800 border-orange-300",
};
default:
return {
label: userType || "미지정",
icon: <UserIcon className="h-3 w-3" />,
className: "bg-gray-100 text-gray-800 border-gray-300",
};
}
};
// 행 번호 계산
const getRowNumber = (index: number) => {
return (paginationInfo.currentPage - 1) * paginationInfo.pageSize + index + 1;
};
// 로딩 스켈레톤
if (isLoading) {
return (
<div className="bg-card hidden rounded-lg border shadow-sm lg:block">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 border-b">
<TableHead className="h-12 w-[80px] text-center text-sm font-semibold">No</TableHead>
<TableHead className="h-12 text-sm font-semibold"> ID</TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-center text-sm font-semibold"> </TableHead>
<TableHead className="h-12 w-[120px] text-center text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 10 }).map((_, index) => (
<TableRow key={index} className="border-b">
<TableCell className="h-16">
<div className="bg-muted h-4 animate-pulse rounded"></div>
</TableCell>
<TableCell className="h-16">
<div className="bg-muted h-4 animate-pulse rounded"></div>
</TableCell>
<TableCell className="h-16">
<div className="bg-muted h-4 animate-pulse rounded"></div>
</TableCell>
<TableCell className="h-16">
<div className="bg-muted h-4 animate-pulse rounded"></div>
</TableCell>
<TableCell className="h-16">
<div className="bg-muted h-4 animate-pulse rounded"></div>
</TableCell>
<TableCell className="h-16">
<div className="bg-muted mx-auto h-6 w-24 animate-pulse rounded-full"></div>
</TableCell>
<TableCell className="h-16">
<div className="bg-muted mx-auto h-8 w-20 animate-pulse rounded"></div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
// 빈 상태
if (users.length === 0) {
return (
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<p className="text-muted-foreground text-sm"> .</p>
</div>
);
}
// 실제 데이터 렌더링
return (
<div className="space-y-4">
{/* 데스크톱 테이블 */}
<div className="bg-card hidden rounded-lg border shadow-sm lg:block">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
<TableHead className="h-12 w-[80px] text-center text-sm font-semibold">No</TableHead>
<TableHead className="h-12 text-sm font-semibold"> ID</TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-center text-sm font-semibold"> </TableHead>
<TableHead className="h-12 w-[120px] text-center text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user, index) => {
const typeInfo = getUserTypeInfo(user.userType);
return (
<TableRow key={user.userId} className="hover:bg-muted/50 border-b transition-colors">
<TableCell className="h-16 text-center text-sm">{getRowNumber(index)}</TableCell>
<TableCell className="h-16 font-mono text-sm">{user.userId}</TableCell>
<TableCell className="h-16 text-sm">{user.userName}</TableCell>
<TableCell className="h-16 text-sm">{user.companyName || user.companyCode}</TableCell>
<TableCell className="h-16 text-sm">{user.deptName || "-"}</TableCell>
<TableCell className="h-16 text-center">
<Badge variant="outline" className={`gap-1 ${typeInfo.className}`}>
{typeInfo.icon}
{typeInfo.label}
</Badge>
</TableCell>
<TableCell className="h-16 text-center">
<Button variant="outline" size="sm" onClick={() => onEditAuth(user)} className="h-8 gap-1 text-sm">
<Shield className="h-3 w-3" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{/* 모바일 카드 뷰 */}
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
{users.map((user, index) => {
const typeInfo = getUserTypeInfo(user.userType);
return (
<div
key={user.userId}
className="bg-card hover:bg-muted/50 rounded-lg border p-4 shadow-sm transition-colors"
>
{/* 헤더 */}
<div className="mb-4 flex items-start justify-between">
<div className="flex-1">
<h3 className="text-base font-semibold">{user.userName}</h3>
<p className="text-muted-foreground mt-1 font-mono text-sm">{user.userId}</p>
</div>
<Badge variant="outline" className={`gap-1 ${typeInfo.className}`}>
{typeInfo.icon}
{typeInfo.label}
</Badge>
</div>
{/* 정보 */}
<div className="space-y-2 border-t pt-4">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{user.companyName || user.companyCode}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{user.deptName || "-"}</span>
</div>
</div>
{/* 액션 */}
<div className="mt-4 border-t pt-4">
<Button
variant="outline"
size="sm"
onClick={() => onEditAuth(user)}
className="h-9 w-full gap-2 text-sm"
>
<Shield className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
</div>
{/* 페이지네이션 */}
{paginationInfo.totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(paginationInfo.currentPage - 1)}
disabled={paginationInfo.currentPage === 1}
>
</Button>
<span className="text-muted-foreground text-sm">
{paginationInfo.currentPage} / {paginationInfo.totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(paginationInfo.currentPage + 1)}
disabled={paginationInfo.currentPage === paginationInfo.totalPages}
>
</Button>
</div>
)}
</div>
);
}

View File

@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Eye, EyeOff } from "lucide-react";
import { userAPI } from "@/lib/api/user";
import { useAuth } from "@/hooks/useAuth";
// 알림 모달 컴포넌트
interface AlertModalProps {
@ -37,7 +38,7 @@ function AlertModal({ isOpen, onClose, title, message, type = "info" }: AlertMod
<DialogTitle className={getTypeColor()}>{title}</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">{message}</p>
<p className="text-muted-foreground text-sm">{message}</p>
</div>
<div className="flex justify-end">
<Button onClick={onClose} className="w-20">
@ -53,6 +54,7 @@ interface UserFormModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
editingUser?: any | null;
}
interface CompanyOption {
@ -83,7 +85,15 @@ interface DepartmentOption {
[key: string]: any; // 기타 필드들
}
export function UserFormModal({ isOpen, onClose, onSuccess }: UserFormModalProps) {
export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserFormModalProps) {
// 현재 로그인한 사용자 정보
const { user: currentUser } = useAuth();
// 최고 관리자 여부 (company_code === '*' && userType === 'SUPER_ADMIN')
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
// 수정 모드 여부
const isEditMode = !!editingUser;
const [isLoading, setIsLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [companies, setCompanies] = useState<CompanyOption[]>([]);
@ -121,6 +131,7 @@ export function UserFormModal({ isOpen, onClose, onSuccess }: UserFormModalProps
positionName: "",
companyCode: "",
deptCode: "",
userType: "USER", // 기본값: 일반 사용자
sabun: null, // 항상 null로 설정
});
@ -132,20 +143,26 @@ export function UserFormModal({ isOpen, onClose, onSuccess }: UserFormModalProps
// 필수 필드 검증 (실시간)
const isFormValid = useMemo(() => {
const requiredFields = [
formData.userId.trim(),
formData.userPassword.trim(),
formData.userName.trim(),
formData.companyCode,
formData.deptCode,
];
// 수정 모드에서는 비밀번호 선택 사항 (변경할 경우만 입력)
const requiredFields = isEditMode
? [formData.userId.trim(), formData.userName.trim(), formData.companyCode, formData.deptCode]
: [
formData.userId.trim(),
formData.userPassword.trim(),
formData.userName.trim(),
formData.companyCode,
formData.deptCode,
];
// 모든 필수 필드가 입력되고 ID 중복체크가 완료되었는지 확인
// 모든 필수 필드가 입력되었는지 확인
const allFieldsFilled = requiredFields.every((field) => field);
const duplicateCheckValid = isUserIdChecked && lastCheckedUserId === formData.userId;
// 수정 모드: ID 중복체크 불필요 (이미 존재하는 사용자)
// 등록 모드: ID 중복체크 필수
const duplicateCheckValid = isEditMode || (isUserIdChecked && lastCheckedUserId === formData.userId);
return allFieldsFilled && duplicateCheckValid;
}, [formData, isUserIdChecked, lastCheckedUserId]);
}, [formData, isUserIdChecked, lastCheckedUserId, isEditMode]);
// 회사 목록 로드
const loadCompanies = useCallback(async () => {
@ -172,13 +189,52 @@ export function UserFormModal({ isOpen, onClose, onSuccess }: UserFormModalProps
[showAlert],
);
// 모달이 열릴 때 회사 목록 및 부서 목록 로드
// 모달이 열릴 때 회사 목록 및 부서 목록 로드, 수정 모드면 데이터 로드
useEffect(() => {
if (isOpen) {
loadCompanies();
loadDepartments(); // 전체 부서 목록 로드
// 수정 모드: 기존 사용자 정보 로드
if (isEditMode && editingUser) {
setFormData({
userId: editingUser.userId || "",
userPassword: "", // 수정 시 비밀번호는 비워둠 (변경 원할 경우만 입력)
userName: editingUser.userName || "",
email: editingUser.email || "",
tel: editingUser.tel || "",
cellPhone: editingUser.cellPhone || "",
positionName: editingUser.positionName || "",
companyCode: editingUser.companyCode || "",
deptCode: editingUser.deptCode || "",
userType: editingUser.userType || "USER",
sabun: editingUser.sabun || null,
});
// 수정 모드에서는 ID 중복체크 불필요
setIsUserIdChecked(true);
setLastCheckedUserId(editingUser.userId);
} else {
// 등록 모드: 폼 초기화
setFormData({
userId: "",
userPassword: "",
userName: "",
email: "",
tel: "",
cellPhone: "",
positionName: "",
companyCode: "",
deptCode: "",
userType: "USER",
sabun: null,
});
setIsUserIdChecked(false);
setLastCheckedUserId("");
setDuplicateCheckMessage("");
setDuplicateCheckType("");
}
}
}, [isOpen, loadCompanies, loadDepartments]);
}, [isOpen, isEditMode, editingUser, loadCompanies, loadDepartments]);
// 회사 선택 시 부서 목록 업데이트
useEffect(() => {
@ -304,29 +360,51 @@ export function UserFormModal({ isOpen, onClose, onSuccess }: UserFormModalProps
positionName: formData.positionName || null,
companyCode: formData.companyCode,
deptCode: formData.deptCode || null,
userType: formData.userType, // 권한 타입 추가
sabun: null, // 항상 null (테이블 1번 컬럼)
status: "active", // 기본값 (테이블 18번 컬럼)
};
const response = await userAPI.create(userDataToSend);
let response;
if (isEditMode) {
// 수정 모드: 비밀번호 필드 제외 (비밀번호 초기화 기능 별도 제공)
const updateData = { ...userDataToSend };
delete updateData.userPassword;
response = await userAPI.update(updateData);
} else {
// 등록 모드
response = await userAPI.create(userDataToSend);
}
if (response.success) {
showAlert("등록 완료", "사용자가 성공적으로 등록되었습니다.", "success");
showAlert(
isEditMode ? "수정 완료" : "등록 완료",
isEditMode ? "사용자 정보가 성공적으로 수정되었습니다." : "사용자가 성공적으로 등록되었습니다.",
"success",
);
// 성공 시 모달을 바로 닫지 않고 사용자가 확인 후 닫도록 수정
setTimeout(() => {
onClose();
onSuccess?.();
}, 1500); // 1.5초 후 자동으로 모달 닫기
} else {
showAlert("등록 실패", response.message || "사용자 등록에 실패했습니다.", "error");
showAlert(
isEditMode ? "수정 실패" : "등록 실패",
response.message || (isEditMode ? "사용자 정보 수정에 실패했습니다." : "사용자 등록에 실패했습니다."),
"error",
);
}
} catch (error) {
console.error("사용자 등록 오류:", error);
showAlert("오류 발생", "사용자 등록 중 오류가 발생했습니다.", "error");
console.error(isEditMode ? "사용자 수정 오류:" : "사용자 등록 오류:", error);
showAlert(
"오류 발생",
isEditMode ? "사용자 정보 수정 중 오류가 발생했습니다." : "사용자 등록 중 오류가 발생했습니다.",
"error",
);
} finally {
setIsLoading(false);
}
}, [formData, validateForm, onSuccess, onClose, showAlert]);
}, [formData, validateForm, onSuccess, onClose, showAlert, isEditMode]);
// 모달 닫기
const handleClose = useCallback(() => {
@ -366,7 +444,7 @@ export function UserFormModal({ isOpen, onClose, onSuccess }: UserFormModalProps
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogTitle>{isEditMode ? "사용자 정보 수정" : "사용자 등록"}</DialogTitle>
</DialogHeader>
<div className="space-y-6 py-4">
@ -376,32 +454,38 @@ export function UserFormModal({ isOpen, onClose, onSuccess }: UserFormModalProps
<Label htmlFor="userId" className="text-sm font-medium">
ID <span className="text-red-500">*</span>
</Label>
<div className="flex gap-2">
<Input
id="userId"
placeholder="사용자 ID 입력"
value={formData.userId}
onChange={(e) => handleInputChange("userId", e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1"
/>
<Button
type="button"
variant={isUserIdChecked && lastCheckedUserId === formData.userId ? "default" : "outline"}
onClick={checkUserIdDuplicate}
disabled={!formData.userId.trim() || isLoading}
className="whitespace-nowrap"
>
{isUserIdChecked && lastCheckedUserId === formData.userId ? "확인완료" : "중복확인"}
</Button>
</div>
{/* 중복확인 결과 메시지 */}
{duplicateCheckMessage && (
<div
className={`mt-1 text-sm ${duplicateCheckType === "success" ? "text-green-600" : "text-destructive"}`}
>
{duplicateCheckMessage}
</div>
{isEditMode ? (
<Input id="userId" value={formData.userId} disabled className="bg-muted cursor-not-allowed" />
) : (
<>
<div className="flex gap-2">
<Input
id="userId"
placeholder="사용자 ID 입력"
value={formData.userId}
onChange={(e) => handleInputChange("userId", e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1"
/>
<Button
type="button"
variant={isUserIdChecked && lastCheckedUserId === formData.userId ? "default" : "outline"}
onClick={checkUserIdDuplicate}
disabled={!formData.userId.trim() || isLoading}
className="whitespace-nowrap"
>
{isUserIdChecked && lastCheckedUserId === formData.userId ? "확인완료" : "중복확인"}
</Button>
</div>
{/* 중복확인 결과 메시지 */}
{duplicateCheckMessage && (
<div
className={`mt-1 text-sm ${duplicateCheckType === "success" ? "text-green-600" : "text-destructive"}`}
>
{duplicateCheckMessage}
</div>
)}
</>
)}
</div>
@ -419,53 +503,82 @@ export function UserFormModal({ isOpen, onClose, onSuccess }: UserFormModalProps
</div>
</div>
{/* 비밀번호 */}
<div className="space-y-2">
<Label htmlFor="userPassword" className="text-sm font-medium">
<span className="text-red-500">*</span>
</Label>
<div className="relative">
<Input
id="userPassword"
type={showPassword ? "text" : "password"}
placeholder="비밀번호 입력"
value={formData.userPassword}
onChange={(e) => handleInputChange("userPassword", e.target.value)}
onKeyDown={handleKeyDown}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowPassword(!showPassword)}
className="absolute top-0 right-0 h-full px-3 hover:bg-transparent"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
{/* 비밀번호 - 등록 모드에만 표시 */}
{!isEditMode && (
<div className="space-y-2">
<Label htmlFor="userPassword" className="text-sm font-medium">
<span className="text-red-500">*</span>
</Label>
<div className="relative">
<Input
id="userPassword"
type={showPassword ? "text" : "password"}
placeholder="비밀번호 입력"
value={formData.userPassword}
onChange={(e) => handleInputChange("userPassword", e.target.value)}
onKeyDown={handleKeyDown}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowPassword(!showPassword)}
className="absolute top-0 right-0 h-full px-3 hover:bg-transparent"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
<p className="text-muted-foreground text-xs">
.
</p>
</div>
)}
{/* 회사 선택 */}
<div className="space-y-2">
<Label htmlFor="companyCode" className="text-sm font-medium">
<span className="text-red-500">*</span>
</Label>
{isSuperAdmin ? (
<>
<Select
value={formData.companyCode}
onValueChange={(value) => handleInputChange("companyCode", value)}
>
<SelectTrigger>
<SelectValue placeholder="회사 선택" />
</SelectTrigger>
<SelectContent>
{companies.map((company) => (
<SelectItem key={company.company_code} value={company.company_code}>
{company.company_name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
.
</p>
</>
) : (
<>
<Input
id="companyCode"
value={
companies.find((c) => c.company_code === formData.companyCode)?.company_name ||
formData.companyCode
}
disabled
className="bg-muted cursor-not-allowed"
/>
<p className="text-muted-foreground text-xs"> .</p>
</>
)}
</div>
{/* 회사 정보 */}
{/* 부서 정보 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="companyCode" className="text-sm font-medium">
<span className="text-red-500">*</span>
</Label>
<Select value={formData.companyCode} onValueChange={(value) => handleInputChange("companyCode", value)}>
<SelectTrigger>
<SelectValue placeholder="회사 선택" />
</SelectTrigger>
<SelectContent>
{companies.map((company) => (
<SelectItem key={company.company_code} value={company.company_code}>
{company.company_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="deptCode" className="text-sm font-medium">
<span className="text-red-500">*</span>
@ -568,7 +681,7 @@ export function UserFormModal({ isOpen, onClose, onSuccess }: UserFormModalProps
</Button>
<Button type="button" onClick={handleSubmit} disabled={isLoading || !isFormValid} className="min-w-[80px]">
{isLoading ? "처리중..." : "등록"}
{isLoading ? "처리중..." : isEditMode ? "수정" : "등록"}
</Button>
</div>
</DialogContent>

View File

@ -45,20 +45,37 @@ export function UserManagement() {
userName: null as string | null,
});
// 사용자 등록 모달 상태
const [isUserFormModalOpen, setIsUserFormModalOpen] = useState(false);
// 사용자 등록/수정 모달 상태
const [userFormModal, setUserFormModal] = useState({
isOpen: false,
editingUser: null as any | null,
});
// 사용자 등록 핸들러
const handleCreateUser = () => {
setIsUserFormModalOpen(true);
setUserFormModal({
isOpen: true,
editingUser: null,
});
};
// 사용자 등록 모달 닫기
// 사용자 수정 핸들러
const handleEditUser = (user: any) => {
setUserFormModal({
isOpen: true,
editingUser: user,
});
};
// 사용자 등록/수정 모달 닫기
const handleUserFormClose = () => {
setIsUserFormModalOpen(false);
setUserFormModal({
isOpen: false,
editingUser: null,
});
};
// 사용자 등록 성공 핸들러
// 사용자 등록/수정 성공 핸들러
const handleUserFormSuccess = () => {
refreshData(); // 목록 새로고침
handleUserFormClose();
@ -101,18 +118,18 @@ export function UserManagement() {
{/* 에러 메시지 */}
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-destructive"> </p>
<p className="text-destructive text-sm font-semibold"> </p>
<button
onClick={clearError}
className="text-destructive transition-colors hover:text-destructive/80"
className="text-destructive hover:text-destructive/80 transition-colors"
aria-label="에러 메시지 닫기"
>
</button>
</div>
<p className="mt-1.5 text-sm text-destructive/80">{error}</p>
<p className="text-destructive/80 mt-1.5 text-sm">{error}</p>
</div>
)}
@ -123,6 +140,7 @@ export function UserManagement() {
paginationInfo={paginationInfo}
onStatusToggle={handleStatusToggle}
onPasswordReset={handlePasswordReset}
onEdit={handleEditUser}
/>
{/* 페이지네이션 */}
@ -137,8 +155,13 @@ export function UserManagement() {
/>
)}
{/* 사용자 등록 모달 */}
<UserFormModal isOpen={isUserFormModalOpen} onClose={handleUserFormClose} onSuccess={handleUserFormSuccess} />
{/* 사용자 등록/수정 모달 */}
<UserFormModal
isOpen={userFormModal.isOpen}
onClose={handleUserFormClose}
onSuccess={handleUserFormSuccess}
editingUser={userFormModal.editingUser}
/>
{/* 비밀번호 초기화 모달 */}
<UserPasswordResetModal

View File

@ -1,4 +1,4 @@
import { Key, History } from "lucide-react";
import { Key, History, Edit } from "lucide-react";
import { useState } from "react";
import { User } from "@/types/user";
import { USER_TABLE_COLUMNS } from "@/constants/user";
@ -15,12 +15,20 @@ interface UserTableProps {
paginationInfo: PaginationInfo;
onStatusToggle: (user: User, newStatus: string) => void;
onPasswordReset: (userId: string, userName: string) => void;
onEdit: (user: User) => void;
}
/**
*
*/
export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, onPasswordReset }: UserTableProps) {
export function UserTable({
users,
isLoading,
paginationInfo,
onStatusToggle,
onPasswordReset,
onEdit,
}: UserTableProps) {
// 확인 모달 상태 관리
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
@ -100,10 +108,10 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on
return (
<>
{/* 데스크톱 테이블 스켈레톤 */}
<div className="hidden rounded-lg border bg-card shadow-sm lg:block">
<div className="bg-card hidden rounded-lg border shadow-sm lg:block">
<Table>
<TableHeader>
<TableRow className="border-b bg-muted/50">
<TableRow className="bg-muted/50 border-b">
{USER_TABLE_COLUMNS.map((column) => (
<TableHead key={column.key} style={{ width: column.width }} className="h-12 text-sm font-semibold">
{column.label}
@ -117,13 +125,13 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on
<TableRow key={index} className="border-b">
{USER_TABLE_COLUMNS.map((column) => (
<TableCell key={column.key} className="h-16">
<div className="h-4 animate-pulse rounded bg-muted"></div>
<div className="bg-muted h-4 animate-pulse rounded"></div>
</TableCell>
))}
<TableCell className="h-16">
<div className="flex gap-2">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="h-8 w-8 animate-pulse rounded bg-muted"></div>
<div key={i} className="bg-muted h-8 w-8 animate-pulse rounded"></div>
))}
</div>
</TableCell>
@ -136,25 +144,25 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on
{/* 모바일/태블릿 카드 스켈레톤 */}
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="rounded-lg border bg-card p-4 shadow-sm">
<div key={index} className="bg-card rounded-lg border p-4 shadow-sm">
<div className="mb-4 flex items-start justify-between">
<div className="flex-1 space-y-2">
<div className="h-5 w-32 animate-pulse rounded bg-muted"></div>
<div className="h-4 w-24 animate-pulse rounded bg-muted"></div>
<div className="bg-muted h-5 w-32 animate-pulse rounded"></div>
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
</div>
<div className="h-6 w-11 animate-pulse rounded-full bg-muted"></div>
<div className="bg-muted h-6 w-11 animate-pulse rounded-full"></div>
</div>
<div className="space-y-2 border-t pt-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex justify-between">
<div className="h-4 w-16 animate-pulse rounded bg-muted"></div>
<div className="h-4 w-32 animate-pulse rounded bg-muted"></div>
<div className="bg-muted h-4 w-16 animate-pulse rounded"></div>
<div className="bg-muted h-4 w-32 animate-pulse rounded"></div>
</div>
))}
</div>
<div className="mt-4 flex gap-2 border-t pt-4">
<div className="h-9 flex-1 animate-pulse rounded bg-muted"></div>
<div className="h-9 flex-1 animate-pulse rounded bg-muted"></div>
<div className="bg-muted h-9 flex-1 animate-pulse rounded"></div>
<div className="bg-muted h-9 flex-1 animate-pulse rounded"></div>
</div>
</div>
))}
@ -166,9 +174,9 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on
// 데이터가 없을 때
if (users.length === 0) {
return (
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-sm text-muted-foreground"> .</p>
<p className="text-muted-foreground text-sm"> .</p>
</div>
</div>
);
@ -178,10 +186,10 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on
return (
<>
{/* 데스크톱 테이블 뷰 (lg 이상) */}
<div className="hidden rounded-lg border bg-card shadow-sm lg:block">
<div className="bg-card hidden rounded-lg border shadow-sm lg:block">
<Table>
<TableHeader>
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
{USER_TABLE_COLUMNS.map((column) => (
<TableHead key={column.key} style={{ width: column.width }} className="h-12 text-sm font-semibold">
{column.label}
@ -192,10 +200,7 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on
</TableHeader>
<TableBody>
{users.map((user, index) => (
<TableRow
key={`${user.userId}-${index}`}
className="border-b transition-colors hover:bg-muted/50"
>
<TableRow key={`${user.userId}-${index}`} className="hover:bg-muted/50 border-b transition-colors">
<TableCell className="h-16 font-mono text-sm font-medium">{getRowNumber(index)}</TableCell>
<TableCell className="h-16 font-mono text-sm">{user.sabun || "-"}</TableCell>
<TableCell className="h-16 text-sm font-medium">{user.companyCode || "-"}</TableCell>
@ -219,6 +224,15 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on
</TableCell>
<TableCell className="h-16">
<div className="flex gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => onEdit(user)}
className="h-8 w-8"
title="사용자 정보 수정"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
@ -250,13 +264,13 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on
{users.map((user, index) => (
<div
key={`${user.userId}-${index}`}
className="rounded-lg border bg-card p-4 shadow-sm transition-colors hover:bg-muted/50"
className="bg-card hover:bg-muted/50 rounded-lg border p-4 shadow-sm transition-colors"
>
{/* 헤더: 이름과 상태 */}
<div className="mb-4 flex items-start justify-between">
<div className="flex-1">
<h3 className="text-base font-semibold">{user.userName}</h3>
<p className="mt-1 font-mono text-sm text-muted-foreground">{user.userId}</p>
<p className="text-muted-foreground mt-1 font-mono text-sm">{user.userId}</p>
</div>
<Switch
checked={user.status === "active"}
@ -311,23 +325,27 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on
{/* 액션 버튼 */}
<div className="mt-4 flex gap-2 border-t pt-4">
<Button variant="outline" size="sm" onClick={() => onEdit(user)} className="h-9 flex-1 gap-2 text-sm">
<Edit className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onPasswordReset(user.userId, user.userName || user.userId)}
className="h-9 flex-1 gap-2 text-sm"
className="h-9 w-9 p-0"
title="비밀번호 초기화"
>
<Key className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleOpenHistoryModal(user)}
className="h-9 flex-1 gap-2 text-sm"
className="h-9 w-9 p-0"
title="변경이력"
>
<History className="h-4 w-4" />
</Button>
</div>
</div>

View File

@ -0,0 +1,379 @@
"use client";
import React, { useState, useMemo, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { ChevronRight, ChevronLeft, ChevronsRight, ChevronsLeft, Search } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* DualListBox
*/
export interface DualListBoxItem {
id: string;
label: string;
description?: string;
disabled?: boolean;
[key: string]: any;
}
/**
* DualListBox Props
*/
interface DualListBoxProps {
// 데이터
availableItems: DualListBoxItem[];
selectedItems: DualListBoxItem[];
// 이벤트
onSelectionChange: (selectedItems: DualListBoxItem[]) => void;
// 라벨
availableLabel?: string;
selectedLabel?: string;
// 검색 활성화
enableSearch?: boolean;
// 높이 설정
height?: string;
// 비활성화
disabled?: boolean;
// 커스텀 렌더링
renderItem?: (item: DualListBoxItem) => React.ReactNode;
// 스타일
className?: string;
}
/**
* Dual List Box ( )
*
* :
* ```tsx
* <DualListBox
* availableItems={allUsers}
* selectedItems={groupMembers}
* onSelectionChange={setGroupMembers}
* availableLabel="전체 사용자"
* selectedLabel="그룹 멤버"
* enableSearch
* />
* ```
*/
export function DualListBox({
availableItems = [],
selectedItems = [],
onSelectionChange,
availableLabel = "사용 가능한 항목",
selectedLabel = "선택된 항목",
enableSearch = true,
height = "400px",
disabled = false,
renderItem,
className,
}: DualListBoxProps) {
// 선택된 아이템 ID 목록 (안전하게 처리)
const selectedItemIds = useMemo(() => new Set((selectedItems || []).map((item) => item.id)), [selectedItems]);
// 사용 가능한 아이템 (선택되지 않은 것들만)
const available = useMemo(
() => (availableItems || []).filter((item) => !selectedItemIds.has(item.id)),
[availableItems, selectedItemIds],
);
// 좌측 체크된 아이템
const [leftChecked, setLeftChecked] = useState<Set<string>>(new Set());
// 우측 체크된 아이템
const [rightChecked, setRightChecked] = useState<Set<string>>(new Set());
// 좌측 검색어
const [leftSearch, setLeftSearch] = useState("");
// 우측 검색어
const [rightSearch, setRightSearch] = useState("");
// 검색 필터링
const filterItems = useCallback((items: DualListBoxItem[], searchTerm: string) => {
if (!searchTerm.trim()) return items;
const term = searchTerm.toLowerCase();
return items.filter(
(item) => item.label.toLowerCase().includes(term) || item.description?.toLowerCase().includes(term),
);
}, []);
const filteredAvailable = useMemo(() => filterItems(available, leftSearch), [available, leftSearch, filterItems]);
const filteredSelected = useMemo(
() => filterItems(selectedItems, rightSearch),
[selectedItems, rightSearch, filterItems],
);
// 좌측 체크박스 토글
const handleLeftCheck = useCallback((itemId: string, checked: boolean) => {
setLeftChecked((prev) => {
const newSet = new Set(prev);
if (checked) {
newSet.add(itemId);
} else {
newSet.delete(itemId);
}
return newSet;
});
}, []);
// 우측 체크박스 토글
const handleRightCheck = useCallback((itemId: string, checked: boolean) => {
setRightChecked((prev) => {
const newSet = new Set(prev);
if (checked) {
newSet.add(itemId);
} else {
newSet.delete(itemId);
}
return newSet;
});
}, []);
// 좌측 전체 선택/해제
const handleLeftSelectAll = useCallback(() => {
if (leftChecked.size === filteredAvailable.length) {
setLeftChecked(new Set());
} else {
setLeftChecked(new Set(filteredAvailable.map((item) => item.id)));
}
}, [leftChecked.size, filteredAvailable]);
// 우측 전체 선택/해제
const handleRightSelectAll = useCallback(() => {
if (rightChecked.size === filteredSelected.length) {
setRightChecked(new Set());
} else {
setRightChecked(new Set(filteredSelected.map((item) => item.id)));
}
}, [rightChecked.size, filteredSelected]);
// 선택된 항목 오른쪽으로 이동
const moveToRight = useCallback(() => {
const itemsToMove = available.filter((item) => leftChecked.has(item.id));
onSelectionChange([...selectedItems, ...itemsToMove]);
setLeftChecked(new Set());
}, [available, leftChecked, selectedItems, onSelectionChange]);
// 선택된 항목 왼쪽으로 이동
const moveToLeft = useCallback(() => {
const newSelected = selectedItems.filter((item) => !rightChecked.has(item.id));
onSelectionChange(newSelected);
setRightChecked(new Set());
}, [selectedItems, rightChecked, onSelectionChange]);
// 모든 항목 오른쪽으로 이동
const moveAllToRight = useCallback(() => {
onSelectionChange([...selectedItems, ...available]);
setLeftChecked(new Set());
}, [available, selectedItems, onSelectionChange]);
// 모든 항목 왼쪽으로 이동
const moveAllToLeft = useCallback(() => {
onSelectionChange([]);
setRightChecked(new Set());
}, [onSelectionChange]);
// 기본 아이템 렌더링
const defaultRenderItem = useCallback((item: DualListBoxItem) => {
return (
<div className="flex flex-col">
<span className="text-sm font-medium">{item.label}</span>
{item.description && <span className="text-muted-foreground text-xs">{item.description}</span>}
</div>
);
}, []);
const itemRenderer = renderItem || defaultRenderItem;
return (
<div className={cn("flex gap-4", className)}>
{/* 좌측 리스트 (사용 가능한 항목) */}
<div className="flex flex-1 flex-col gap-2">
<Label className="text-sm font-semibold">{availableLabel}</Label>
{/* 검색 */}
{enableSearch && (
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="검색..."
value={leftSearch}
onChange={(e) => setLeftSearch(e.target.value)}
className="h-9 pl-9 text-sm"
disabled={disabled}
/>
</div>
)}
{/* 전체 선택 */}
<div className="flex items-center gap-2">
<Checkbox
id="left-select-all"
checked={filteredAvailable.length > 0 && leftChecked.size === filteredAvailable.length}
onCheckedChange={handleLeftSelectAll}
disabled={disabled || filteredAvailable.length === 0}
/>
<Label htmlFor="left-select-all" className="text-muted-foreground cursor-pointer text-xs">
({filteredAvailable.length})
</Label>
</div>
{/* 아이템 리스트 */}
<div className="bg-background overflow-y-auto rounded-lg border" style={{ height }}>
{filteredAvailable.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
{leftSearch ? "검색 결과가 없습니다" : "사용 가능한 항목이 없습니다"}
</p>
</div>
) : (
<div className="space-y-1 p-2">
{filteredAvailable.map((item) => (
<div
key={item.id}
className={cn(
"hover:bg-muted/50 flex cursor-pointer items-start gap-2 rounded-md p-2 transition-colors",
leftChecked.has(item.id) && "bg-muted",
item.disabled && "cursor-not-allowed opacity-50",
)}
onClick={() => !item.disabled && !disabled && handleLeftCheck(item.id, !leftChecked.has(item.id))}
>
<Checkbox
checked={leftChecked.has(item.id)}
onCheckedChange={(checked) => handleLeftCheck(item.id, checked as boolean)}
disabled={disabled || item.disabled}
className="mt-0.5"
/>
{itemRenderer(item)}
</div>
))}
</div>
)}
</div>
</div>
{/* 중앙 버튼 (이동) */}
<div
className="flex flex-col items-center justify-center gap-2"
style={{ marginTop: enableSearch ? "80px" : "48px" }}
>
<Button
variant="outline"
size="icon"
onClick={moveAllToRight}
disabled={disabled || available.length === 0}
title="모두 오른쪽으로 이동"
className="h-9 w-9"
>
<ChevronsRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={moveToRight}
disabled={disabled || leftChecked.size === 0}
title="선택된 항목 오른쪽으로 이동"
className="h-9 w-9"
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={moveToLeft}
disabled={disabled || rightChecked.size === 0}
title="선택된 항목 왼쪽으로 이동"
className="h-9 w-9"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={moveAllToLeft}
disabled={disabled || selectedItems.length === 0}
title="모두 왼쪽으로 이동"
className="h-9 w-9"
>
<ChevronsLeft className="h-4 w-4" />
</Button>
</div>
{/* 우측 리스트 (선택된 항목) */}
<div className="flex flex-1 flex-col gap-2">
<Label className="text-sm font-semibold">{selectedLabel}</Label>
{/* 검색 */}
{enableSearch && (
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="검색..."
value={rightSearch}
onChange={(e) => setRightSearch(e.target.value)}
className="h-9 pl-9 text-sm"
disabled={disabled}
/>
</div>
)}
{/* 전체 선택 */}
<div className="flex items-center gap-2">
<Checkbox
id="right-select-all"
checked={filteredSelected.length > 0 && rightChecked.size === filteredSelected.length}
onCheckedChange={handleRightSelectAll}
disabled={disabled || filteredSelected.length === 0}
/>
<Label htmlFor="right-select-all" className="text-muted-foreground cursor-pointer text-xs">
({filteredSelected.length})
</Label>
</div>
{/* 아이템 리스트 */}
<div className="bg-background overflow-y-auto rounded-lg border" style={{ height }}>
{filteredSelected.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
{rightSearch ? "검색 결과가 없습니다" : "선택된 항목이 없습니다"}
</p>
</div>
) : (
<div className="space-y-1 p-2">
{filteredSelected.map((item) => (
<div
key={item.id}
className={cn(
"hover:bg-muted/50 flex cursor-pointer items-start gap-2 rounded-md p-2 transition-colors",
rightChecked.has(item.id) && "bg-muted",
item.disabled && "cursor-not-allowed opacity-50",
)}
onClick={() => !item.disabled && !disabled && handleRightCheck(item.id, !rightChecked.has(item.id))}
>
<Checkbox
checked={rightChecked.has(item.id)}
onCheckedChange={(checked) => handleRightCheck(item.id, checked as boolean)}
disabled={disabled || item.disabled}
className="mt-0.5"
/>
{itemRenderer(item)}
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -18,6 +18,7 @@ import { FlowStep } from "@/types/flow";
import { FlowConditionBuilder } from "./FlowConditionBuilder";
import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement";
import { cn } from "@/lib/utils";
import { formatErrorMessage } from "@/lib/utils/errorUtils";
import { flowExternalDbApi } from "@/lib/api/flowExternalDb";
import {
FlowExternalDbConnection,
@ -27,6 +28,7 @@ import {
} from "@/types/flowExternalDb";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
interface FlowStepPanelProps {
step: FlowStep;
@ -128,38 +130,20 @@ export function FlowStepPanel({
return;
}
const response = await fetch("/api/external-db-connections/control/active", {
credentials: "include",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}).catch((err) => {
console.warn("외부 DB 연결 목록 fetch 실패:", err);
return null;
});
const response = await ExternalDbConnectionAPI.getActiveControlConnections();
if (response && response.ok) {
const result = await response.json();
if (result.success && result.data) {
// 메인 DB 제외하고 외부 DB만 필터링
const externalOnly = result.data.filter((conn: any) => conn.id !== 0);
setExternalConnections(externalOnly);
} else {
setExternalConnections([]);
}
} else {
// 401 오류 시 빈 배열로 처리 (리다이렉트 방지)
console.warn("외부 DB 연결 목록 조회 실패:", response?.status || "네트워크 오류");
setExternalConnections([]);
if (response.success && response.data) {
const filtered = response.data.filter(
(conn) => !conn.connection_name.includes("메인") && !conn.connection_name.includes("현재 시스템"),
);
setExternalConnections(filtered);
}
} catch (error: any) {
} catch (error) {
console.error("Failed to load external connections:", error);
setExternalConnections([]);
} finally {
setLoadingConnections(false);
}
};
loadConnections();
}, []);
@ -463,14 +447,14 @@ export function FlowStepPanel({
} else {
toast({
title: "저장 실패",
description: response.error,
description: formatErrorMessage(response.error, "단계 저장 중 오류가 발생했습니다."),
variant: "destructive",
});
}
} catch (error: any) {
toast({
title: "오류 발생",
description: error.message,
description: error.message || "단계 저장 중 오류가 발생했습니다.",
variant: "destructive",
});
}
@ -494,14 +478,14 @@ export function FlowStepPanel({
} else {
toast({
title: "삭제 실패",
description: response.error,
description: formatErrorMessage(response.error, "단계 삭제 중 오류가 발생했습니다."),
variant: "destructive",
});
}
} catch (error: any) {
toast({
title: "오류 발생",
description: error.message,
description: error.message || "단계 삭제 중 오류가 발생했습니다.",
variant: "destructive",
});
}

View File

@ -15,8 +15,14 @@ export function AdminButton({ user }: AdminButtonProps) {
console.log("user?.isAdmin:", user?.isAdmin);
console.log("user?.userId:", user?.userId);
// 관리자 권한 확인 로직 개선
const isAdmin = user?.isAdmin || user?.userType === "ADMIN" || user?.userId === "plm_admin";
// 관리자 권한 확인 로직 (3단계 권한 체계)
const isAdmin =
user?.isAdmin ||
user?.userType === "SUPER_ADMIN" ||
user?.userType === "COMPANY_ADMIN" ||
user?.userType === "ADMIN" ||
user?.userType === "admin" ||
user?.userId === "plm_admin";
console.log("최종 관리자 권한 확인:", isAdmin);

View File

@ -413,10 +413,12 @@ function AppLayoutInner({ children }: AppLayoutProps) {
isMobile
? (sidebarOpen ? "translate-x-0" : "-translate-x-full") + " fixed top-14 left-0 z-40"
: "relative top-0 z-auto translate-x-0"
} flex h-[calc(100vh-3.5rem)] w-72 max-w-72 min-w-72 flex-col border-r border-slate-200 bg-white transition-transform duration-300`}
} flex h-[calc(100vh-3.5rem)] w-[240px] max-w-[240px] min-w-[240px] flex-col border-r border-slate-200 bg-white transition-transform duration-300`}
>
{/* 사이드바 상단 - Admin/User 모드 전환 버튼 (관리자만) */}
{(user as ExtendedUserInfo)?.userType === "admin" && (
{((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" ||
(user as ExtendedUserInfo)?.userType === "COMPANY_ADMIN" ||
(user as ExtendedUserInfo)?.userType === "admin") && (
<div className="border-b border-slate-200 p-3">
<Button
onClick={handleModeSwitch}

View File

@ -93,7 +93,7 @@ const panelConfigs: PanelConfig[] = [
id: "components",
title: "컴포넌트",
defaultPosition: "left",
defaultWidth: 400,
defaultWidth: 240,
defaultHeight: 700,
shortcutKey: "c",
},
@ -102,7 +102,7 @@ const panelConfigs: PanelConfig[] = [
id: "properties",
title: "편집",
defaultPosition: "left",
defaultWidth: 400,
defaultWidth: 240,
defaultHeight: 700,
shortcutKey: "p",
},
@ -4119,7 +4119,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
{/* 열린 패널들 (좌측에서 우측으로 누적) */}
{panelStates.components?.isOpen && (
<div className="border-border bg-card flex h-full w-[400px] flex-col border-r shadow-sm">
<div className="border-border bg-card flex h-full w-[240px] flex-col border-r shadow-sm">
<div className="border-border flex items-center justify-between border-b px-6 py-4">
<h3 className="text-foreground text-lg font-semibold"></h3>
<button
@ -4150,7 +4150,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
)}
{panelStates.properties?.isOpen && (
<div className="border-border bg-card flex h-full w-[400px] flex-col border-r shadow-sm">
<div className="border-border bg-card flex h-full w-[240px] flex-col border-r shadow-sm">
<div className="border-border flex items-center justify-between border-b px-6 py-4">
<h3 className="text-foreground text-lg font-semibold"></h3>
<button

View File

@ -220,48 +220,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
/>
</div>
<div>
<Label htmlFor="button-variant"> </Label>
<Select
value={component.componentConfig?.variant || "default"}
onValueChange={(value) => {
onUpdateProperty("componentConfig.variant", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="버튼 스타일 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="primary"> (Primary)</SelectItem>
<SelectItem value="secondary"> (Secondary)</SelectItem>
<SelectItem value="danger"> (Danger)</SelectItem>
<SelectItem value="success"> (Success)</SelectItem>
<SelectItem value="outline"> (Outline)</SelectItem>
<SelectItem value="ghost"> (Ghost)</SelectItem>
<SelectItem value="link"> (Link)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="button-size"> </Label>
<Select
value={component.componentConfig?.size || "md"}
onValueChange={(value) => {
onUpdateProperty("componentConfig.size", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="버튼 글씨 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"> (Small)</SelectItem>
<SelectItem value="md"> (Default)</SelectItem>
<SelectItem value="lg"> (Large)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="button-action"> </Label>
<Select
@ -556,72 +514,11 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
{/* 테이블 이력 보기 액션 설정 */}
{(component.componentConfig?.action?.type || "save") === "view_table_history" && (
<div className="mt-4 space-y-4 rounded-lg border bg-blue-50 p-4">
<h4 className="text-sm font-medium text-blue-900">📜 </h4>
<div className="mt-4 space-y-4">
<h4 className="text-sm font-medium">📜 </h4>
<div>
<Label htmlFor="history-table-name"> ()</Label>
<Input
id="history-table-name"
placeholder="자동 감지 (비워두면 현재 화면의 테이블 사용)"
value={config.action?.historyTableName || ""}
onChange={(e) => {
onUpdateProperty("componentConfig.action.historyTableName", e.target.value);
}}
/>
<p className="mt-1 text-xs text-gray-600"> </p>
</div>
<div>
<Label htmlFor="history-record-id-field"> ID </Label>
<Input
id="history-record-id-field"
placeholder="id (기본값)"
value={config.action?.historyRecordIdField || ""}
onChange={(e) => {
onUpdateProperty("componentConfig.action.historyRecordIdField", e.target.value);
}}
/>
<p className="mt-1 text-xs text-gray-600"> . &quot;id&quot;.</p>
</div>
<div>
<Label htmlFor="history-record-id-source"> ID </Label>
<Select
value={config.action?.historyRecordIdSource || "selected_row"}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.historyRecordIdSource", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="레코드 ID 소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="selected_row"> ()</SelectItem>
<SelectItem value="form_field"> </SelectItem>
<SelectItem value="context"> ( )</SelectItem>
</SelectContent>
</Select>
<p className="mt-1 text-xs text-gray-600"> ID를 </p>
</div>
<div>
<Label htmlFor="history-record-label-field"> ()</Label>
<Input
id="history-record-label-field"
placeholder="예: name, title, device_code 등"
value={config.action?.historyRecordLabelField || ""}
onChange={(e) => {
onUpdateProperty("componentConfig.action.historyRecordLabelField", e.target.value);
}}
/>
<p className="mt-1 text-xs text-gray-600">
&quot;ID 123 &quot; &quot; &quot;
</p>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<Label className="text-blue-900">
<Label>
() <span className="text-red-600">*</span>
</Label>
@ -802,13 +699,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div>
)}
{/* 🔥 NEW: 제어관리 기능 섹션 */}
{/* 제어 기능 섹션 */}
<div className="mt-8 border-t border-gray-200 pt-6">
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-900">🔧 </h3>
<p className="text-muted-foreground mt-1 text-sm"> </p>
</div>
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
</div>

View File

@ -1,7 +1,6 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
@ -233,18 +232,16 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<div className="space-y-4">
<div className="space-y-1">
<h4 className="flex items-center gap-2 text-sm font-medium">
<Workflow className="h-4 w-4" />
</CardTitle>
<CardDescription className="text-xs">
</CardDescription>
</CardHeader>
</h4>
<p className="text-muted-foreground text-xs"> </p>
</div>
<CardContent className="space-y-4">
<div className="space-y-4">
{/* 활성화 체크박스 */}
<div className="flex items-center gap-2">
<Checkbox
@ -305,13 +302,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
<div className="flex items-center space-x-2">
<RadioGroupItem value="whitelist" id="mode-whitelist" />
<Label htmlFor="mode-whitelist" className="text-sm font-normal">
( )
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="blacklist" id="mode-blacklist" />
<Label htmlFor="mode-blacklist" className="text-sm font-normal">
( )
</Label>
</div>
<div className="flex items-center space-x-2">
@ -327,9 +318,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
{mode !== "all" && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
{mode === "whitelist" ? "표시할 단계" : "숨길 단계"}
</Label>
<Label className="text-sm font-medium"> </Label>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={selectAll} className="h-7 px-2 text-xs">
@ -346,8 +335,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
{/* 스텝 체크박스 목록 */}
<div className="bg-muted/30 space-y-2 rounded-lg border p-3">
{flowSteps.map((step) => {
const isChecked =
mode === "whitelist" ? visibleSteps.includes(step.id) : hiddenSteps.includes(step.id);
const isChecked = visibleSteps.includes(step.id);
return (
<div key={step.id} className="flex items-center gap-2">
@ -361,11 +349,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
Step {step.stepOrder}
</Badge>
<span>{step.stepName}</span>
{isChecked && (
<CheckCircle
className={`ml-auto h-4 w-4 ${mode === "whitelist" ? "text-green-500" : "text-red-500"}`}
/>
)}
{isChecked && <CheckCircle className="ml-auto h-4 w-4 text-green-500" />}
</Label>
</div>
);
@ -401,7 +385,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
{/* 🆕 그룹 설정 (auto-compact 모드일 때만 표시) */}
{layoutBehavior === "auto-compact" && (
<div className="space-y-4 rounded-lg border border-blue-200 bg-blue-50 p-4">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
@ -569,7 +553,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
)}
</>
)}
</CardContent>
</Card>
</div>
</div>
);
};

View File

@ -1,14 +1,11 @@
"use client";
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Settings, Clock, Zap, Info, Workflow } from "lucide-react";
import { Settings, Clock, Info, Workflow } from "lucide-react";
import { ComponentData } from "@/types/screen";
import { getNodeFlows, NodeFlow } from "@/lib/api/nodeFlows";
@ -75,25 +72,14 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
}
};
/**
* 🔥
*/
const handleControlTypeChange = (controlType: string) => {
// 기존 설정 초기화
onUpdateProperty("webTypeConfig.dataflowConfig", {
controlMode: controlType,
flowConfig: controlType === "flow" ? undefined : null,
});
};
return (
<div className="space-y-6">
{/* 🔥 제어관리 활성화 스위치 */}
<div className="bg-accent flex items-center justify-between rounded-lg border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Settings className="text-primary h-4 w-4" />
<div>
<Label className="text-sm font-medium">🎮 </Label>
<Label className="text-sm font-medium"> </Label>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
</div>
@ -105,59 +91,38 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
{/* 🔥 제어관리가 활성화된 경우에만 설정 표시 */}
{config.enableDataflowControl && (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<Tabs value={dataflowConfig.controlMode || "none"} onValueChange={handleControlTypeChange}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="none"> </TabsTrigger>
<TabsTrigger value="flow"> </TabsTrigger>
</TabsList>
<div className="space-y-4">
<FlowSelector
flows={flows}
selectedFlowId={dataflowConfig.flowConfig?.flowId}
onSelect={handleFlowSelect}
loading={loading}
/>
<TabsContent value="none" className="mt-4">
<div className="py-8 text-center text-gray-500">
<Zap className="mx-auto mb-2 h-8 w-8 opacity-50" />
<p> .</p>
</div>
</TabsContent>
{dataflowConfig.flowConfig && (
<div className="space-y-4">
<Separator />
<ExecutionTimingSelector
value={dataflowConfig.flowConfig.executionTiming}
onChange={(timing) =>
onUpdateProperty("webTypeConfig.dataflowConfig.flowConfig.executionTiming", timing)
}
/>
<TabsContent value="flow" className="mt-4">
<FlowSelector
flows={flows}
selectedFlowId={dataflowConfig.flowConfig?.flowId}
onSelect={handleFlowSelect}
loading={loading}
/>
{dataflowConfig.flowConfig && (
<div className="mt-4 space-y-4">
<Separator />
<ExecutionTimingSelector
value={dataflowConfig.flowConfig.executionTiming}
onChange={(timing) =>
onUpdateProperty("webTypeConfig.dataflowConfig.flowConfig.executionTiming", timing)
}
/>
<div className="rounded bg-green-50 p-3">
<div className="flex items-start space-x-2">
<Info className="mt-0.5 h-4 w-4 text-green-600" />
<div className="text-xs text-green-800">
<p className="font-medium"> :</p>
<p className="mt-1"> / .</p>
<p className="mt-1"> 트랜잭션: /</p>
<p> 중단: 부모 </p>
</div>
</div>
</div>
<div className="rounded bg-green-50 p-3">
<div className="flex items-start space-x-2">
<Info className="mt-0.5 h-4 w-4 text-green-600" />
<div className="text-xs text-green-800">
<p className="font-medium"> :</p>
<p className="mt-1"> / .</p>
<p className="mt-1"> 트랜잭션: /</p>
<p> 중단: 부모 </p>
</div>
)}
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
</div>
</div>
)}
</div>
)}
</div>
);

View File

@ -312,6 +312,23 @@ export class ExternalDbConnectionAPI {
}
}
/**
* ( )
*/
static async getActiveControlConnections(): Promise<ApiResponse<ExternalDbConnection[]>> {
try {
const response = await apiClient.get<ApiResponse<ExternalDbConnection[]>>(`${this.BASE_PATH}/control/active`);
return response.data;
} catch (error) {
console.error("활성 제어 커넥션 조회 오류:", error);
return {
success: false,
message: error instanceof Error ? error.message : "활성 제어 커넥션 조회에 실패했습니다.",
data: [],
};
}
}
/**
* REST API ( )
*/

View File

@ -53,6 +53,16 @@ function getAuthToken(): string | null {
return localStorage.getItem("authToken") || sessionStorage.getItem("authToken");
}
// 인증 헤더 생성 헬퍼
function getAuthHeaders(): HeadersInit {
const token = getAuthToken();
const headers: HeadersInit = {};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
return headers;
}
// ============================================
// 플로우 정의 API
// ============================================
@ -72,6 +82,7 @@ export async function getFlowDefinitions(params?: {
const url = `${API_BASE}/flow/definitions${queryParams.toString() ? `?${queryParams.toString()}` : ""}`;
const response = await fetch(url, {
headers: getAuthHeaders(),
credentials: "include",
});
@ -90,6 +101,7 @@ export async function getFlowDefinitions(params?: {
export async function getFlowDefinition(id: number): Promise<ApiResponse<FlowDefinition>> {
try {
const response = await fetch(`${API_BASE}/flow/definitions/${id}`, {
headers: getAuthHeaders(),
credentials: "include",
});
@ -114,9 +126,7 @@ export async function createFlowDefinition(data: CreateFlowDefinitionRequest): P
try {
const response = await fetch(`${API_BASE}/flow/definitions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: getAuthHeaders(),
credentials: "include",
body: JSON.stringify(data),
});
@ -140,9 +150,7 @@ export async function updateFlowDefinition(
try {
const response = await fetch(`${API_BASE}/flow/definitions/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
headers: getAuthHeaders(),
credentials: "include",
body: JSON.stringify(data),
});
@ -163,6 +171,7 @@ export async function deleteFlowDefinition(id: number): Promise<ApiResponse<{ su
try {
const response = await fetch(`${API_BASE}/flow/definitions/${id}`, {
method: "DELETE",
headers: getAuthHeaders(),
credentials: "include",
});
@ -185,6 +194,7 @@ export async function deleteFlowDefinition(id: number): Promise<ApiResponse<{ su
export async function getFlowSteps(flowId: number): Promise<ApiResponse<FlowStep[]>> {
try {
const response = await fetch(`${API_BASE}/flow/definitions/${flowId}/steps`, {
headers: getAuthHeaders(),
credentials: "include",
});
@ -204,9 +214,7 @@ export async function createFlowStep(flowId: number, data: CreateFlowStepRequest
try {
const response = await fetch(`${API_BASE}/flow/definitions/${flowId}/steps`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: getAuthHeaders(),
credentials: "include",
body: JSON.stringify(data),
});
@ -227,9 +235,7 @@ export async function updateFlowStep(stepId: number, data: UpdateFlowStepRequest
try {
const response = await fetch(`${API_BASE}/flow/steps/${stepId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
headers: getAuthHeaders(),
credentials: "include",
body: JSON.stringify(data),
});
@ -250,6 +256,7 @@ export async function deleteFlowStep(stepId: number): Promise<ApiResponse<{ succ
try {
const response = await fetch(`${API_BASE}/flow/steps/${stepId}`, {
method: "DELETE",
headers: getAuthHeaders(),
credentials: "include",
});
@ -272,6 +279,7 @@ export async function deleteFlowStep(stepId: number): Promise<ApiResponse<{ succ
export async function getFlowConnections(flowId: number): Promise<ApiResponse<FlowStepConnection[]>> {
try {
const response = await fetch(`${API_BASE}/flow/connections/${flowId}`, {
headers: getAuthHeaders(),
credentials: "include",
});
@ -293,9 +301,7 @@ export async function createFlowConnection(
try {
const response = await fetch(`${API_BASE}/flow/connections`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: getAuthHeaders(),
credentials: "include",
body: JSON.stringify(data),
});
@ -316,6 +322,7 @@ export async function deleteFlowConnection(connectionId: number): Promise<ApiRes
try {
const response = await fetch(`${API_BASE}/flow/connections/${connectionId}`, {
method: "DELETE",
headers: getAuthHeaders(),
credentials: "include",
});
@ -338,6 +345,7 @@ export async function deleteFlowConnection(connectionId: number): Promise<ApiRes
export async function getStepDataCount(flowId: number, stepId: number): Promise<ApiResponse<FlowStepDataCount>> {
try {
const response = await fetch(`${API_BASE}/flow/${flowId}/step/${stepId}/count`, {
headers: getAuthHeaders(),
credentials: "include",
});
@ -361,6 +369,7 @@ export async function getStepDataList(
): Promise<ApiResponse<FlowStepDataList>> {
try {
const response = await fetch(`${API_BASE}/flow/${flowId}/step/${stepId}/list?page=${page}&pageSize=${pageSize}`, {
headers: getAuthHeaders(),
credentials: "include",
});
@ -379,6 +388,7 @@ export async function getStepDataList(
export async function getAllStepCounts(flowId: number): Promise<ApiResponse<FlowStepDataCount[]>> {
try {
const response = await fetch(`${API_BASE}/flow/${flowId}/steps/counts`, {
headers: getAuthHeaders(),
credentials: "include",
});
@ -399,10 +409,9 @@ export async function moveData(data: MoveDataRequest): Promise<ApiResponse<{ suc
const token = getAuthToken();
const response = await fetch(`${API_BASE}/flow/move`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),
},
headers: getAuthHeaders(),
...(token && { Authorization: `Bearer ${token}` }),
headers: getAuthHeaders(),
credentials: "include",
body: JSON.stringify(data),
});
@ -441,10 +450,9 @@ export async function moveBatchData(
const token = getAuthToken();
const response = await fetch(`${API_BASE}/flow/move-batch`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),
},
headers: getAuthHeaders(),
...(token && { Authorization: `Bearer ${token}` }),
headers: getAuthHeaders(),
credentials: "include",
body: JSON.stringify(data),
});
@ -468,6 +476,7 @@ export async function moveBatchData(
export async function getAuditLogs(flowId: number, recordId: string): Promise<ApiResponse<FlowAuditLog[]>> {
try {
const response = await fetch(`${API_BASE}/flow/audit/${flowId}/${recordId}`, {
headers: getAuthHeaders(),
credentials: "include",
});
@ -486,6 +495,7 @@ export async function getAuditLogs(flowId: number, recordId: string): Promise<Ap
export async function getFlowAuditLogs(flowId: number, limit: number = 100): Promise<ApiResponse<FlowAuditLog[]>> {
try {
const response = await fetch(`${API_BASE}/flow/audit/${flowId}?limit=${limit}`, {
headers: getAuthHeaders(),
credentials: "include",
});

289
frontend/lib/api/role.ts Normal file
View File

@ -0,0 +1,289 @@
import { apiClient } from "./client";
import { ApiResponse } from "@/types/api";
/**
*
*/
export interface RoleGroup {
objid: number;
authName: string;
authCode: string;
companyCode: string;
status: string;
writer: string;
regdate: string;
memberCount?: number;
menuCount?: number;
memberNames?: string;
}
/**
*
*/
export interface RoleMember {
objid: number;
masterObjid: number;
userId: string;
userName: string;
deptName?: string;
positionName?: string;
writer: string;
regdate: string;
}
/**
*
*/
export interface MenuPermission {
objid: number;
menuObjid: number;
authObjid: number;
menuName?: string;
createYn: string;
readYn: string;
updateYn: string;
deleteYn: string;
writer: string;
regdate: string;
}
/**
* API
*/
export const roleAPI = {
/**
*
*/
async getList(params?: { companyCode?: string; search?: string }): Promise<ApiResponse<RoleGroup[]>> {
try {
const response = await apiClient.get("/roles", { params });
return response.data;
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || "권한 그룹 목록 조회 실패",
error: error.response?.data?.error || error.message,
};
}
},
/**
*
*/
async getById(id: number): Promise<ApiResponse<RoleGroup>> {
try {
const response = await apiClient.get(`/roles/${id}`);
return response.data;
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || "권한 그룹 상세 조회 실패",
error: error.response?.data?.error || error.message,
};
}
},
/**
*
*/
async create(data: { authName: string; authCode: string; companyCode: string }): Promise<ApiResponse<RoleGroup>> {
try {
const response = await apiClient.post("/roles", data);
return response.data;
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || "권한 그룹 생성 실패",
error: error.response?.data?.error || error.message,
};
}
},
/**
*
*/
async update(
id: number,
data: {
authName?: string;
authCode?: string;
status?: string;
},
): Promise<ApiResponse<RoleGroup>> {
try {
const response = await apiClient.put(`/roles/${id}`, data);
return response.data;
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || "권한 그룹 수정 실패",
error: error.response?.data?.error || error.message,
};
}
},
/**
*
*/
async delete(id: number): Promise<ApiResponse<null>> {
try {
const response = await apiClient.delete(`/roles/${id}`);
return response.data;
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || "권한 그룹 삭제 실패",
error: error.response?.data?.error || error.message,
};
}
},
/**
*
*/
async getMembers(roleId: number): Promise<ApiResponse<RoleMember[]>> {
try {
const response = await apiClient.get(`/roles/${roleId}/members`);
return response.data;
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || "멤버 목록 조회 실패",
error: error.response?.data?.error || error.message,
};
}
},
/**
* ( )
*/
async addMembers(roleId: number, userIds: string[]): Promise<ApiResponse<null>> {
try {
const response = await apiClient.post(`/roles/${roleId}/members`, { userIds });
return response.data;
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || "멤버 추가 실패",
error: error.response?.data?.error || error.message,
};
}
},
/**
* ( )
*/
async removeMembers(roleId: number, userIds: string[]): Promise<ApiResponse<null>> {
try {
const response = await apiClient.delete(`/roles/${roleId}/members`, { data: { userIds } });
return response.data;
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || "멤버 제거 실패",
error: error.response?.data?.error || error.message,
};
}
},
/**
* ( )
*/
async updateMembers(roleId: number, userIds: string[]): Promise<ApiResponse<null>> {
try {
const response = await apiClient.put(`/roles/${roleId}/members`, { userIds });
return response.data;
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || "멤버 업데이트 실패",
error: error.response?.data?.error || error.message,
};
}
},
/**
* ( )
*/
async getAllMenus(companyCode?: string): Promise<ApiResponse<any[]>> {
try {
console.log("🔍 [roleAPI.getAllMenus] API 호출", { companyCode });
const url = companyCode ? `/roles/menus/all?companyCode=${companyCode}` : "/roles/menus/all";
const response = await apiClient.get(url);
console.log("✅ [roleAPI.getAllMenus] API 응답", {
success: response.data.success,
count: response.data.data?.length,
});
return response.data;
} catch (error: any) {
console.error("❌ [roleAPI.getAllMenus] API 에러", error);
return {
success: false,
message: error.response?.data?.message || "전체 메뉴 목록 조회 실패",
error: error.response?.data?.error || error.message,
};
}
},
/**
*
*/
async getMenuPermissions(roleId: number): Promise<ApiResponse<MenuPermission[]>> {
try {
const response = await apiClient.get(`/roles/${roleId}/menu-permissions`);
return response.data;
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || "메뉴 권한 조회 실패",
error: error.response?.data?.error || error.message,
};
}
},
/**
*
*/
async setMenuPermissions(
roleId: number,
permissions: Array<{
menuObjid: number;
createYn: string;
readYn: string;
updateYn: string;
deleteYn: string;
}>,
): Promise<ApiResponse<null>> {
try {
const response = await apiClient.put(`/roles/${roleId}/menu-permissions`, { permissions });
return response.data;
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || "메뉴 권한 설정 실패",
error: error.response?.data?.error || error.message,
};
}
},
/**
*
*/
async getUserRoleGroups(userId?: string): Promise<ApiResponse<RoleGroup[]>> {
try {
const url = userId ? `/roles/user/${userId}/groups` : "/roles/user/my-groups";
const response = await apiClient.get(url);
return response.data;
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || "사용자 권한 그룹 조회 실패",
error: error.response?.data?.error || error.message,
};
}
},
};

View File

@ -83,6 +83,23 @@ export async function createUser(userData: any) {
// 사용자 수정 기능 제거됨
/**
*
*/
export async function updateUser(userData: {
userId: string;
userName?: string;
companyCode?: string;
deptCode?: string;
userType?: string;
status?: string;
[key: string]: any;
}) {
const response = await apiClient.put(`/admin/users/${userData.userId}`, userData);
return response.data;
}
/**
* ( )
*/
@ -171,6 +188,7 @@ export const userAPI = {
getList: getUserList,
getInfo: getUserInfo,
create: createUser,
update: updateUser,
updateStatus: updateUserStatus,
getHistory: getUserHistory,
resetPassword: resetUserPassword,

View File

@ -0,0 +1,40 @@
/**
*
*/
/**
* API
* @param error - API (string | object | undefined)
* @param defaultMessage -
* @returns
*/
export function formatErrorMessage(error: any, defaultMessage: string = "오류가 발생했습니다."): string {
// undefined/null 체크
if (!error) {
return defaultMessage;
}
// 이미 문자열인 경우
if (typeof error === "string") {
return error;
}
// 객체인 경우
if (typeof error === "object") {
// { code, details } 형태
if (error.details) {
return typeof error.details === "string" ? error.details : defaultMessage;
}
// { message } 형태
if (error.message) {
return typeof error.message === "string" ? error.message : defaultMessage;
}
// 기타 객체는 기본 메시지 반환
return defaultMessage;
}
// 그 외의 경우
return defaultMessage;
}