각 회사별 데이터 분리
This commit is contained in:
parent
783ce5594e
commit
29c49d7f07
|
|
@ -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"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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: "권한 확인 중 오류가 발생했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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); // 사용자 비밀번호 초기화
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
||||
// 회사 코드 필터 (선택적)
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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:", {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
// @ts-nocheck
|
||||
/**
|
||||
* 플로우 연결 서비스
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
// @ts-nocheck
|
||||
/**
|
||||
* 플로우 실행 서비스
|
||||
* 단계별 데이터 카운트 및 리스트 조회
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
// @ts-nocheck
|
||||
/**
|
||||
* 플로우 단계 서비스
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 반환값)
|
||||
|
|
|
|||
|
|
@ -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 사용)
|
||||
}
|
||||
|
||||
// 플로우 정의 수정 요청
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
@ -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. **권한 체크 로직 통합** → 미들웨어 개선
|
||||
|
||||
이 설계를 구현하시겠습니까?
|
||||
|
|
@ -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` - 메뉴 권한 마이그레이션
|
||||
|
||||
---
|
||||
|
||||
## 문의사항
|
||||
|
||||
기술적 문의사항이나 추가 기능 요청은 개발팀에 문의하세요.
|
||||
|
|
@ -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`
|
||||
|
|
@ -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`
|
||||
|
|
@ -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`
|
||||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">기본키 컬럼명입니다. 대부분 "id"입니다.</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">
|
||||
이력 모달에서 "ID 123의 이력" 대신 "홍길동의 이력" 처럼 표시할 때 사용
|
||||
</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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 연결 목록 조회 (외부 커넥션에서)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue