feat: Integrate audit logging for various operations
- Added audit logging functionality across multiple controllers, including menu, user, department, flow, screen, and table management. - Implemented logging for create, update, and delete actions, capturing relevant details such as company code, user information, and changes made. - Enhanced the category tree service with a new endpoint to check if category values are in use, improving data integrity checks. - Updated routes to include new functionalities and ensure proper logging for batch operations and individual record changes. - This integration improves traceability and accountability for data modifications within the application.
This commit is contained in:
parent
f04d224b09
commit
b4d5367e2b
|
|
@ -125,6 +125,7 @@ import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다
|
||||||
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
|
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
|
||||||
import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트)
|
import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트)
|
||||||
import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준
|
import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준
|
||||||
|
import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력
|
||||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||||
|
|
@ -308,6 +309,7 @@ app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계
|
||||||
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
|
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
|
||||||
app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트)
|
app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트)
|
||||||
app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준
|
app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준
|
||||||
|
app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력
|
||||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { EncryptUtil } from "../utils/encryptUtil";
|
||||||
import { FileSystemManager } from "../utils/fileSystemManager";
|
import { FileSystemManager } from "../utils/fileSystemManager";
|
||||||
import { validateBusinessNumber } from "../utils/businessNumberValidator";
|
import { validateBusinessNumber } from "../utils/businessNumberValidator";
|
||||||
import { MenuCopyService } from "../services/menuCopyService";
|
import { MenuCopyService } from "../services/menuCopyService";
|
||||||
|
import { auditLogService } from "../services/auditLogService";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 관리자 메뉴 목록 조회
|
* 관리자 메뉴 목록 조회
|
||||||
|
|
@ -1177,7 +1178,7 @@ export async function saveMenu(
|
||||||
success: true,
|
success: true,
|
||||||
message: "메뉴가 성공적으로 저장되었습니다.",
|
message: "메뉴가 성공적으로 저장되었습니다.",
|
||||||
data: {
|
data: {
|
||||||
objid: savedMenu.objid.toString(), // BigInt를 문자열로 변환
|
objid: savedMenu.objid.toString(),
|
||||||
menuNameKor: savedMenu.menu_name_kor,
|
menuNameKor: savedMenu.menu_name_kor,
|
||||||
menuNameEng: savedMenu.menu_name_eng,
|
menuNameEng: savedMenu.menu_name_eng,
|
||||||
menuUrl: savedMenu.menu_url,
|
menuUrl: savedMenu.menu_url,
|
||||||
|
|
@ -1188,6 +1189,20 @@ export async function saveMenu(
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "CREATE",
|
||||||
|
resourceType: "MENU",
|
||||||
|
resourceId: savedMenu.objid?.toString(),
|
||||||
|
resourceName: savedMenu.menu_name_kor,
|
||||||
|
summary: `메뉴 "${savedMenu.menu_name_kor}" 생성`,
|
||||||
|
changes: { after: { menuNameKor: savedMenu.menu_name_kor, menuUrl: savedMenu.menu_url, status: savedMenu.status } },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(200).json(response);
|
res.status(200).json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("메뉴 저장 실패:", error);
|
logger.error("메뉴 저장 실패:", error);
|
||||||
|
|
@ -1375,6 +1390,23 @@ export async function updateMenu(
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: req.user?.companyCode || updatedMenu.company_code || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "UPDATE",
|
||||||
|
resourceType: "MENU",
|
||||||
|
resourceId: updatedMenu.objid?.toString(),
|
||||||
|
resourceName: updatedMenu.menu_name_kor,
|
||||||
|
summary: `메뉴 "${updatedMenu.menu_name_kor}" 수정`,
|
||||||
|
changes: {
|
||||||
|
before: { menuNameKor: currentMenu.menu_name_kor, menuUrl: currentMenu.menu_url, status: currentMenu.status },
|
||||||
|
after: { menuNameKor: updatedMenu.menu_name_kor, menuUrl: updatedMenu.menu_url, status: updatedMenu.status },
|
||||||
|
},
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(200).json(response);
|
res.status(200).json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("메뉴 수정 실패:", error);
|
logger.error("메뉴 수정 실패:", error);
|
||||||
|
|
@ -1554,6 +1586,20 @@ export async function deleteMenu(
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: currentMenu.company_code || req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "DELETE",
|
||||||
|
resourceType: "MENU",
|
||||||
|
resourceId: menuObjid.toString(),
|
||||||
|
resourceName: currentMenu.menu_name_kor,
|
||||||
|
summary: `메뉴 "${currentMenu.menu_name_kor}" 삭제 (하위 ${childMenuIds.length}개 포함, 총 ${allMenuIdsToDelete.length}건)`,
|
||||||
|
changes: { before: { menuNameKor: currentMenu.menu_name_kor } },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(200).json(response);
|
res.status(200).json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("메뉴 삭제 실패:", error);
|
logger.error("메뉴 삭제 실패:", error);
|
||||||
|
|
@ -1717,6 +1763,20 @@ export async function deleteMenusBatch(
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (deletedCount > 0) {
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "DELETE",
|
||||||
|
resourceType: "MENU",
|
||||||
|
summary: `메뉴 일괄 삭제: ${deletedCount}개 삭제, ${failedCount}개 실패`,
|
||||||
|
changes: { before: { deletedMenus, failedMenuIds } },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.status(200).json(response);
|
res.status(200).json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("메뉴 일괄 삭제 실패:", error);
|
logger.error("메뉴 일괄 삭제 실패:", error);
|
||||||
|
|
@ -1813,6 +1873,20 @@ export async function toggleMenuStatus(
|
||||||
data: result,
|
data: result,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: currentMenu.company_code || req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "STATUS_CHANGE",
|
||||||
|
resourceType: "MENU",
|
||||||
|
resourceId: String(menuId),
|
||||||
|
resourceName: currentMenu.menu_name_kor,
|
||||||
|
summary: `메뉴 "${currentMenu.menu_name_kor}" 상태 변경: ${currentStatus} → ${newStatus}`,
|
||||||
|
changes: { before: { status: currentStatus }, after: { status: newStatus }, fields: ["status"] },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(200).json(response);
|
res.status(200).json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("메뉴 상태 토글 실패:", error);
|
logger.error("메뉴 상태 토글 실패:", error);
|
||||||
|
|
@ -2442,6 +2516,20 @@ export const changeUserStatus = async (
|
||||||
updatedBy: req.user?.userId,
|
updatedBy: req.user?.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: currentUser.company_code || req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "STATUS_CHANGE",
|
||||||
|
resourceType: "USER",
|
||||||
|
resourceId: userId,
|
||||||
|
resourceName: currentUser.user_name,
|
||||||
|
summary: `사용자 "${currentUser.user_name}" 상태 변경: ${currentUser.status} → ${status}`,
|
||||||
|
changes: { before: { status: currentUser.status }, after: { status }, fields: ["status"] },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
result: true,
|
result: true,
|
||||||
msg: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`,
|
msg: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`,
|
||||||
|
|
@ -2579,6 +2667,20 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: userData.companyCode || req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: isExistingUser ? "UPDATE" : "CREATE",
|
||||||
|
resourceType: "USER",
|
||||||
|
resourceId: userData.userId,
|
||||||
|
resourceName: userData.userName,
|
||||||
|
summary: isExistingUser ? `사용자 "${userData.userName}" 정보 수정` : `사용자 "${userData.userName}" 등록`,
|
||||||
|
changes: { after: { userId: userData.userId, userName: userData.userName, deptName: userData.deptName, status: userData.status } },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(200).json(response);
|
res.status(200).json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("사용자 저장 실패", { error, userData: req.body });
|
logger.error("사용자 저장 실패", { error, userData: req.body });
|
||||||
|
|
@ -2769,6 +2871,20 @@ export const createCompany = async (
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: createdCompany.company_code,
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "CREATE",
|
||||||
|
resourceType: "COMPANY",
|
||||||
|
resourceId: createdCompany.company_code,
|
||||||
|
resourceName: createdCompany.company_name,
|
||||||
|
summary: `회사 "${createdCompany.company_name}" (${createdCompany.company_code}) 등록`,
|
||||||
|
changes: { after: { company_code: createdCompany.company_code, company_name: createdCompany.company_name } },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(201).json(response);
|
res.status(201).json(response);
|
||||||
} finally {
|
} finally {
|
||||||
await client.end();
|
await client.end();
|
||||||
|
|
@ -2938,7 +3054,11 @@ export const updateCompany = async (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw Query로 회사 정보 수정
|
const beforeCompany = await queryOne<any>(
|
||||||
|
`SELECT company_name, status FROM company_mng WHERE company_code = $1`,
|
||||||
|
[companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
const result = await query<any>(
|
const result = await query<any>(
|
||||||
`UPDATE company_mng
|
`UPDATE company_mng
|
||||||
SET
|
SET
|
||||||
|
|
@ -2994,6 +3114,23 @@ export const updateCompany = async (
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: updatedCompany.company_code,
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "UPDATE",
|
||||||
|
resourceType: "COMPANY",
|
||||||
|
resourceId: updatedCompany.company_code,
|
||||||
|
resourceName: updatedCompany.company_name,
|
||||||
|
summary: `회사 "${updatedCompany.company_name}" 정보 수정`,
|
||||||
|
changes: {
|
||||||
|
before: { company_name: beforeCompany?.company_name, status: beforeCompany?.status },
|
||||||
|
after: { company_name: updatedCompany.company_name, status: updatedCompany.status },
|
||||||
|
},
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(200).json(response);
|
res.status(200).json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("회사 정보 수정 실패", { error, body: req.body });
|
logger.error("회사 정보 수정 실패", { error, body: req.body });
|
||||||
|
|
@ -3055,6 +3192,20 @@ export const deleteCompany = async (
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: deletedCompany.company_code,
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "DELETE",
|
||||||
|
resourceType: "COMPANY",
|
||||||
|
resourceId: deletedCompany.company_code,
|
||||||
|
resourceName: deletedCompany.company_name,
|
||||||
|
summary: `회사 "${deletedCompany.company_name}" (${deletedCompany.company_code}) 삭제`,
|
||||||
|
changes: { before: { company_code: deletedCompany.company_code, company_name: deletedCompany.company_name } },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(200).json(response);
|
res.status(200).json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("회사 삭제 실패", { error });
|
logger.error("회사 삭제 실패", { error });
|
||||||
|
|
@ -3221,6 +3372,20 @@ export const updateProfile = async (
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "UPDATE",
|
||||||
|
resourceType: "USER",
|
||||||
|
resourceId: userId,
|
||||||
|
resourceName: updatedUser?.user_name || "",
|
||||||
|
summary: `프로필 수정 (${updateFields.length}개 항목)`,
|
||||||
|
changes: { after: { userName, email, tel, cellPhone, locale } },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
result: true,
|
result: true,
|
||||||
message: "프로필이 성공적으로 업데이트되었습니다.",
|
message: "프로필이 성공적으로 업데이트되었습니다.",
|
||||||
|
|
@ -3334,6 +3499,20 @@ export const resetUserPassword = async (
|
||||||
updatedBy: req.user?.userId,
|
updatedBy: req.user?.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "UPDATE",
|
||||||
|
resourceType: "USER",
|
||||||
|
resourceId: userId,
|
||||||
|
resourceName: currentUser.user_name,
|
||||||
|
summary: `사용자 "${currentUser.user_name}" 비밀번호 초기화`,
|
||||||
|
changes: { fields: ["user_password"] },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
result: true,
|
result: true,
|
||||||
|
|
@ -3535,6 +3714,19 @@ export async function copyMenu(
|
||||||
|
|
||||||
logger.info("✅ 메뉴 복사 API 성공");
|
logger.info("✅ 메뉴 복사 API 성공");
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || userId,
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "COPY",
|
||||||
|
resourceType: "MENU",
|
||||||
|
resourceId: menuObjid,
|
||||||
|
summary: `메뉴(${menuObjid}) → 회사 "${targetCompanyCode}"로 복사`,
|
||||||
|
changes: { after: { targetCompanyCode, menuObjid } },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "메뉴 복사 완료",
|
message: "메뉴 복사 완료",
|
||||||
|
|
@ -3849,6 +4041,20 @@ export const saveUserWithDept = async (
|
||||||
isUpdate: isExistingUser,
|
isUpdate: isExistingUser,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: isExistingUser ? "UPDATE" : "CREATE",
|
||||||
|
resourceType: "USER",
|
||||||
|
resourceId: userInfo.user_id,
|
||||||
|
resourceName: userInfo.user_name,
|
||||||
|
summary: `사용자 "${userInfo.user_name}" ${isExistingUser ? "수정" : "등록"} (부서: ${mainDept?.dept_name || "없음"})`,
|
||||||
|
changes: { after: { userName: userInfo.user_name, email: userInfo.email, deptName: mainDept?.dept_name, status: userInfo.status } },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: isExistingUser ? "사원 정보가 수정되었습니다." : "사원이 등록되었습니다.",
|
message: isExistingUser ? "사원 정보가 수정되었습니다." : "사원이 등록되었습니다.",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { Response } from "express";
|
||||||
|
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||||
|
import { auditLogService } from "../services/auditLogService";
|
||||||
|
import { query } from "../database/db";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
export const getAuditLogs = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const userCompanyCode = req.user?.companyCode;
|
||||||
|
const isSuperAdmin = userCompanyCode === "*";
|
||||||
|
|
||||||
|
const {
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
resourceType,
|
||||||
|
action,
|
||||||
|
tableName,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
search,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const result = await auditLogService.queryLogs(
|
||||||
|
{
|
||||||
|
companyCode: (companyCode as string) || (isSuperAdmin ? undefined : userCompanyCode),
|
||||||
|
userId: userId as string,
|
||||||
|
resourceType: resourceType as string,
|
||||||
|
action: action as string,
|
||||||
|
tableName: tableName as string,
|
||||||
|
dateFrom: dateFrom as string,
|
||||||
|
dateTo: dateTo as string,
|
||||||
|
search: search as string,
|
||||||
|
page: page ? parseInt(page as string, 10) : 1,
|
||||||
|
limit: limit ? parseInt(limit as string, 10) : 50,
|
||||||
|
},
|
||||||
|
isSuperAdmin
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
total: result.total,
|
||||||
|
page: page ? parseInt(page as string, 10) : 1,
|
||||||
|
limit: limit ? parseInt(limit as string, 10) : 50,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("감사 로그 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "감사 로그 조회 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAuditLogStats = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const userCompanyCode = req.user?.companyCode;
|
||||||
|
const isSuperAdmin = userCompanyCode === "*";
|
||||||
|
const { companyCode, days } = req.query;
|
||||||
|
|
||||||
|
const targetCompany = isSuperAdmin
|
||||||
|
? (companyCode as string) || undefined
|
||||||
|
: userCompanyCode;
|
||||||
|
|
||||||
|
const stats = await auditLogService.getStats(
|
||||||
|
targetCompany,
|
||||||
|
days ? parseInt(days as string, 10) : 30
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true, data: stats });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("감사 로그 통계 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "감사 로그 통계 조회 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAuditLogUsers = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const userCompanyCode = req.user?.companyCode;
|
||||||
|
const isSuperAdmin = userCompanyCode === "*";
|
||||||
|
const { companyCode } = req.query;
|
||||||
|
|
||||||
|
const conditions: string[] = ["LOWER(u.status) = 'active'"];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (!isSuperAdmin) {
|
||||||
|
conditions.push(`u.company_code = $${paramIndex++}`);
|
||||||
|
params.push(userCompanyCode);
|
||||||
|
} else if (companyCode) {
|
||||||
|
conditions.push(`u.company_code = $${paramIndex++}`);
|
||||||
|
params.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSuperAdmin) {
|
||||||
|
conditions.push(`u.company_code != '*'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||||
|
|
||||||
|
const users = await query<{ user_id: string; user_name: string; count: number }>(
|
||||||
|
`SELECT
|
||||||
|
u.user_id,
|
||||||
|
u.user_name,
|
||||||
|
COALESCE(sal.log_count, 0)::int as count
|
||||||
|
FROM user_info u
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT user_id, COUNT(*) as log_count
|
||||||
|
FROM system_audit_log
|
||||||
|
GROUP BY user_id
|
||||||
|
) sal ON u.user_id = sal.user_id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY count DESC, u.user_name ASC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true, data: users });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("감사 로그 사용자 목록 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "사용자 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -205,6 +205,31 @@ router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 값 삭제 가능 여부 사전 확인
|
||||||
|
* GET /api/category-tree/test/value/:valueId/can-delete
|
||||||
|
*/
|
||||||
|
router.get("/test/value/:valueId/can-delete", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { valueId } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
const result = await categoryTreeService.checkCanDelete(companyCode, Number(valueId));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
logger.error("카테고리 삭제 가능 여부 확인 API 오류", { error: err.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 카테고리 값 삭제
|
* 카테고리 값 삭제
|
||||||
* DELETE /api/category-tree/test/value/:valueId
|
* DELETE /api/category-tree/test/value/:valueId
|
||||||
|
|
@ -229,6 +254,16 @@ router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Res
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
|
|
||||||
|
if (err.message.startsWith("VALIDATION:")) {
|
||||||
|
const validationMessage = err.message.replace("VALIDATION:", "");
|
||||||
|
logger.warn("카테고리 값 삭제 검증 실패", { valueId: req.params.valueId, reason: validationMessage });
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: validationMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
logger.error("카테고리 값 삭제 API 오류", { error: err.message });
|
logger.error("카테고리 값 삭제 API 오류", { error: err.message });
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
} from "../services/commonCodeService";
|
} from "../services/commonCodeService";
|
||||||
import { AuthenticatedRequest } from "../types/auth";
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
import { auditLogService } from "../services/auditLogService";
|
||||||
|
|
||||||
export class CommonCodeController {
|
export class CommonCodeController {
|
||||||
private commonCodeService: CommonCodeService;
|
private commonCodeService: CommonCodeService;
|
||||||
|
|
@ -163,6 +164,18 @@ export class CommonCodeController {
|
||||||
Number(menuObjid)
|
Number(menuObjid)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: companyCode || "",
|
||||||
|
userId: userId || "",
|
||||||
|
action: "CREATE",
|
||||||
|
resourceType: "CODE_CATEGORY",
|
||||||
|
resourceId: category?.categoryCode,
|
||||||
|
resourceName: category?.categoryName || categoryData.categoryName,
|
||||||
|
summary: `코드 카테고리 "${category?.categoryName || categoryData.categoryName}" 생성`,
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: category,
|
data: category,
|
||||||
|
|
@ -208,6 +221,18 @@ export class CommonCodeController {
|
||||||
companyCode
|
companyCode
|
||||||
);
|
);
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: companyCode || "",
|
||||||
|
userId: userId || "",
|
||||||
|
action: "UPDATE",
|
||||||
|
resourceType: "CODE_CATEGORY",
|
||||||
|
resourceId: categoryCode,
|
||||||
|
resourceName: category?.categoryName,
|
||||||
|
summary: `코드 카테고리 "${categoryCode}" 수정`,
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: category,
|
data: category,
|
||||||
|
|
@ -245,6 +270,17 @@ export class CommonCodeController {
|
||||||
|
|
||||||
await this.commonCodeService.deleteCategory(categoryCode, companyCode);
|
await this.commonCodeService.deleteCategory(categoryCode, companyCode);
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
action: "DELETE",
|
||||||
|
resourceType: "CODE_CATEGORY",
|
||||||
|
resourceId: categoryCode,
|
||||||
|
summary: `코드 카테고리 "${categoryCode}" 삭제`,
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "카테고리 삭제 성공",
|
message: "카테고리 삭제 성공",
|
||||||
|
|
@ -303,6 +339,18 @@ export class CommonCodeController {
|
||||||
effectiveMenuObjid
|
effectiveMenuObjid
|
||||||
);
|
);
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: companyCode || "",
|
||||||
|
userId: userId || "",
|
||||||
|
action: "CREATE",
|
||||||
|
resourceType: "CODE",
|
||||||
|
resourceId: codeData.codeValue,
|
||||||
|
resourceName: codeData.codeName,
|
||||||
|
summary: `코드 "${codeData.codeName}" (${categoryCode}) 생성`,
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: code,
|
data: code,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { DDLExecutionService } from "../services/ddlExecutionService";
|
||||||
import { DDLAuditLogger } from "../services/ddlAuditLogger";
|
import { DDLAuditLogger } from "../services/ddlAuditLogger";
|
||||||
import { CreateTableRequest, AddColumnRequest } from "../types/ddl";
|
import { CreateTableRequest, AddColumnRequest } from "../types/ddl";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
import { auditLogService } from "../services/auditLogService";
|
||||||
|
|
||||||
export class DDLController {
|
export class DDLController {
|
||||||
/**
|
/**
|
||||||
|
|
@ -59,6 +60,20 @@ export class DDLController {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: userCompanyCode || "",
|
||||||
|
userId,
|
||||||
|
action: "CREATE",
|
||||||
|
resourceType: "TABLE",
|
||||||
|
resourceId: tableName,
|
||||||
|
resourceName: tableName,
|
||||||
|
tableName,
|
||||||
|
summary: `테이블 "${tableName}" 생성 (${columns.length}개 컬럼)`,
|
||||||
|
changes: { after: { tableName, columnCount: columns.length, description } },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: result.message,
|
message: result.message,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { logger } from "../utils/logger";
|
||||||
import { AuthenticatedRequest } from "../types/auth";
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
import { ApiResponse } from "../types/common";
|
import { ApiResponse } from "../types/common";
|
||||||
import { query, queryOne } from "../database/db";
|
import { query, queryOne } from "../database/db";
|
||||||
|
import { auditLogService } from "../services/auditLogService";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 부서 목록 조회 (회사별)
|
* 부서 목록 조회 (회사별)
|
||||||
|
|
@ -170,6 +171,21 @@ export async function createDepartment(req: AuthenticatedRequest, res: Response)
|
||||||
|
|
||||||
logger.info("부서 생성 성공", { deptCode, dept_name });
|
logger.info("부서 생성 성공", { deptCode, dept_name });
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: companyCode || req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "CREATE",
|
||||||
|
resourceType: "DATA",
|
||||||
|
resourceId: deptCode,
|
||||||
|
resourceName: dept_name.trim(),
|
||||||
|
tableName: "dept_info",
|
||||||
|
summary: `부서 "${dept_name.trim()}" 생성`,
|
||||||
|
changes: { after: { deptCode, deptName: dept_name.trim(), parentDeptCode: parent_dept_code } },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "부서가 생성되었습니다.",
|
message: "부서가 생성되었습니다.",
|
||||||
|
|
@ -219,6 +235,21 @@ export async function updateDepartment(req: AuthenticatedRequest, res: Response)
|
||||||
|
|
||||||
logger.info("부서 수정 성공", { deptCode });
|
logger.info("부서 수정 성공", { deptCode });
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "UPDATE",
|
||||||
|
resourceType: "DATA",
|
||||||
|
resourceId: deptCode,
|
||||||
|
resourceName: dept_name.trim(),
|
||||||
|
tableName: "dept_info",
|
||||||
|
summary: `부서 "${dept_name.trim()}" 수정`,
|
||||||
|
changes: { after: { deptName: dept_name.trim(), parentDeptCode: parent_dept_code } },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "부서가 수정되었습니다.",
|
message: "부서가 수정되었습니다.",
|
||||||
|
|
@ -285,6 +316,21 @@ export async function deleteDepartment(req: AuthenticatedRequest, res: Response)
|
||||||
deletedMemberCount: memberCount
|
deletedMemberCount: memberCount
|
||||||
});
|
});
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "DELETE",
|
||||||
|
resourceType: "DATA",
|
||||||
|
resourceId: deptCode,
|
||||||
|
resourceName: result[0].dept_name,
|
||||||
|
tableName: "dept_info",
|
||||||
|
summary: `부서 "${result[0].dept_name}" 삭제${memberCount > 0 ? ` (부서원 ${memberCount}명 제외)` : ""}`,
|
||||||
|
changes: { before: { deptCode, deptName: result[0].dept_name } },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: memberCount > 0
|
message: memberCount > 0
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { FlowConnectionService } from "../services/flowConnectionService";
|
||||||
import { FlowExecutionService } from "../services/flowExecutionService";
|
import { FlowExecutionService } from "../services/flowExecutionService";
|
||||||
import { FlowDataMoveService } from "../services/flowDataMoveService";
|
import { FlowDataMoveService } from "../services/flowDataMoveService";
|
||||||
import { FlowProcedureService } from "../services/flowProcedureService";
|
import { FlowProcedureService } from "../services/flowProcedureService";
|
||||||
|
import { auditLogService } from "../services/auditLogService";
|
||||||
|
|
||||||
export class FlowController {
|
export class FlowController {
|
||||||
private flowDefinitionService: FlowDefinitionService;
|
private flowDefinitionService: FlowDefinitionService;
|
||||||
|
|
@ -86,12 +87,25 @@ export class FlowController {
|
||||||
restApiConnectionId,
|
restApiConnectionId,
|
||||||
restApiEndpoint,
|
restApiEndpoint,
|
||||||
restApiJsonPath,
|
restApiJsonPath,
|
||||||
restApiConnections: req.body.restApiConnections, // 다중 REST API 설정
|
restApiConnections: req.body.restApiConnections,
|
||||||
},
|
},
|
||||||
userId,
|
userId,
|
||||||
userCompanyCode
|
userCompanyCode
|
||||||
);
|
);
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: userCompanyCode || "",
|
||||||
|
userId: userId || "",
|
||||||
|
action: "CREATE",
|
||||||
|
resourceType: "FLOW",
|
||||||
|
resourceId: String(flowDef?.id || ""),
|
||||||
|
resourceName: flowDef?.name || name,
|
||||||
|
summary: `플로우 "${flowDef?.name || name}" 생성`,
|
||||||
|
changes: { after: { name, tableName } },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: flowDef,
|
data: flowDef,
|
||||||
|
|
@ -188,6 +202,7 @@ export class FlowController {
|
||||||
const { name, description, isActive } = req.body;
|
const { name, description, isActive } = req.body;
|
||||||
const userCompanyCode = (req as any).user?.companyCode;
|
const userCompanyCode = (req as any).user?.companyCode;
|
||||||
|
|
||||||
|
const beforeFlow = await this.flowDefinitionService.findById(flowId);
|
||||||
const flowDef = await this.flowDefinitionService.update(flowId, {
|
const flowDef = await this.flowDefinitionService.update(flowId, {
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
|
|
@ -202,6 +217,22 @@ export class FlowController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: userCompanyCode || "",
|
||||||
|
userId: (req as any).user?.userId || "",
|
||||||
|
action: "UPDATE",
|
||||||
|
resourceType: "FLOW",
|
||||||
|
resourceId: String(flowId),
|
||||||
|
resourceName: flowDef?.name || name,
|
||||||
|
summary: `플로우 "${flowDef?.name || name}" 수정`,
|
||||||
|
changes: {
|
||||||
|
before: { name: beforeFlow?.name, description: beforeFlow?.description, isActive: beforeFlow?.isActive },
|
||||||
|
after: { name, description, isActive },
|
||||||
|
},
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: flowDef,
|
data: flowDef,
|
||||||
|
|
@ -234,6 +265,17 @@ export class FlowController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: userCompanyCode || "",
|
||||||
|
userId: (req as any).user?.userId || "",
|
||||||
|
action: "DELETE",
|
||||||
|
resourceType: "FLOW",
|
||||||
|
resourceId: String(flowId),
|
||||||
|
summary: `플로우(ID:${flowId}) 삭제`,
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "Flow definition deleted successfully",
|
message: "Flow definition deleted successfully",
|
||||||
|
|
@ -321,6 +363,19 @@ export class FlowController {
|
||||||
positionY,
|
positionY,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: userCompanyCode || "",
|
||||||
|
userId: (req as any).user?.userId || "",
|
||||||
|
action: "CREATE",
|
||||||
|
resourceType: "FLOW_STEP",
|
||||||
|
resourceId: String(step?.id || ""),
|
||||||
|
resourceName: stepName,
|
||||||
|
summary: `플로우 스텝 "${stepName}" 생성 (플로우 ID:${flowDefinitionId})`,
|
||||||
|
changes: { after: { stepName, tableName, stepOrder } },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: step,
|
data: step,
|
||||||
|
|
@ -373,6 +428,7 @@ export class FlowController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const beforeStep = existingStep;
|
||||||
const step = await this.flowStepService.update(id, {
|
const step = await this.flowStepService.update(id, {
|
||||||
stepName,
|
stepName,
|
||||||
stepOrder,
|
stepOrder,
|
||||||
|
|
@ -399,6 +455,22 @@ export class FlowController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: userCompanyCode || "",
|
||||||
|
userId: (req as any).user?.userId || "",
|
||||||
|
action: "UPDATE",
|
||||||
|
resourceType: "FLOW_STEP",
|
||||||
|
resourceId: String(id),
|
||||||
|
resourceName: step?.stepName || stepName,
|
||||||
|
summary: `플로우 스텝 "${step?.stepName || stepName}" 수정`,
|
||||||
|
changes: {
|
||||||
|
before: { stepName: beforeStep?.stepName, tableName: beforeStep?.tableName, stepOrder: beforeStep?.stepOrder },
|
||||||
|
after: { stepName, tableName, stepOrder },
|
||||||
|
},
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: step,
|
data: step,
|
||||||
|
|
@ -444,6 +516,18 @@ export class FlowController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: userCompanyCode || "",
|
||||||
|
userId: (req as any).user?.userId || "",
|
||||||
|
action: "DELETE",
|
||||||
|
resourceType: "FLOW_STEP",
|
||||||
|
resourceId: String(id),
|
||||||
|
resourceName: existingStep?.stepName,
|
||||||
|
summary: `플로우 스텝 "${existingStep?.stepName || id}" 삭제`,
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "Flow step deleted successfully",
|
message: "Flow step deleted successfully",
|
||||||
|
|
@ -530,6 +614,19 @@ export class FlowController {
|
||||||
label,
|
label,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: userCompanyCode || "",
|
||||||
|
userId: (req as any).user?.userId || "",
|
||||||
|
action: "CREATE",
|
||||||
|
resourceType: "FLOW",
|
||||||
|
resourceId: String(flowDefinitionId),
|
||||||
|
resourceName: flowDef?.name || "",
|
||||||
|
summary: `플로우 "${flowDef?.name}" 연결 생성 (${fromStep?.stepName} → ${toStep?.stepName})`,
|
||||||
|
changes: { after: { fromStepId, toStepId, label } },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: connection,
|
data: connection,
|
||||||
|
|
@ -575,6 +672,18 @@ export class FlowController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: userCompanyCode || "",
|
||||||
|
userId: (req as any).user?.userId || "",
|
||||||
|
action: "DELETE",
|
||||||
|
resourceType: "FLOW",
|
||||||
|
resourceId: String(existingConn?.flowDefinitionId || id),
|
||||||
|
summary: `플로우 연결 삭제 (ID: ${id})`,
|
||||||
|
changes: { before: { connectionId: id } },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "Connection deleted successfully",
|
message: "Connection deleted successfully",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
} from "../middleware/authMiddleware";
|
} from "../middleware/authMiddleware";
|
||||||
import { numberingRuleService } from "../services/numberingRuleService";
|
import { numberingRuleService } from "../services/numberingRuleService";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
import { auditLogService } from "../services/auditLogService";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
|
@ -189,6 +190,19 @@ router.post(
|
||||||
menuObjid: newRule.menuObjid,
|
menuObjid: newRule.menuObjid,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
action: "CREATE",
|
||||||
|
resourceType: "NUMBERING_RULE",
|
||||||
|
resourceId: String(newRule.ruleId),
|
||||||
|
resourceName: ruleConfig.ruleName,
|
||||||
|
summary: `채번 규칙 "${ruleConfig.ruleName}" 생성`,
|
||||||
|
changes: { after: { ruleName: ruleConfig.ruleName, prefix: ruleConfig.prefix } },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
return res.status(201).json({ success: true, data: newRule });
|
return res.status(201).json({ success: true, data: newRule });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.code === "23505") {
|
if (error.code === "23505") {
|
||||||
|
|
@ -218,12 +232,29 @@ router.put(
|
||||||
logger.info("채번 규칙 수정 요청", { ruleId, companyCode, updates });
|
logger.info("채번 규칙 수정 요청", { ruleId, companyCode, updates });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const beforeRule = await numberingRuleService.getRuleById(ruleId, companyCode);
|
||||||
const updatedRule = await numberingRuleService.updateRule(
|
const updatedRule = await numberingRuleService.updateRule(
|
||||||
ruleId,
|
ruleId,
|
||||||
updates,
|
updates,
|
||||||
companyCode
|
companyCode
|
||||||
);
|
);
|
||||||
logger.info("채번 규칙 수정 성공", { ruleId, companyCode });
|
logger.info("채번 규칙 수정 성공", { ruleId, companyCode });
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode,
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
action: "UPDATE",
|
||||||
|
resourceType: "NUMBERING_RULE",
|
||||||
|
resourceId: ruleId,
|
||||||
|
summary: `채번 규칙(ID:${ruleId}) 수정`,
|
||||||
|
changes: {
|
||||||
|
before: { ruleName: beforeRule?.ruleName, prefix: beforeRule?.prefix },
|
||||||
|
after: updates,
|
||||||
|
},
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
return res.json({ success: true, data: updatedRule });
|
return res.json({ success: true, data: updatedRule });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error("채번 규칙 수정 실패", {
|
logger.error("채번 규칙 수정 실패", {
|
||||||
|
|
@ -250,6 +281,18 @@ router.delete(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await numberingRuleService.deleteRule(ruleId, companyCode);
|
await numberingRuleService.deleteRule(ruleId, companyCode);
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode,
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
action: "DELETE",
|
||||||
|
resourceType: "NUMBERING_RULE",
|
||||||
|
resourceId: ruleId,
|
||||||
|
summary: `채번 규칙(ID:${ruleId}) 삭제`,
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
return res.json({ success: true, message: "규칙이 삭제되었습니다" });
|
return res.json({ success: true, message: "규칙이 삭제되었습니다" });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.message.includes("찾을 수 없거나")) {
|
if (error.message.includes("찾을 수 없거나")) {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
isCompanyAdmin,
|
isCompanyAdmin,
|
||||||
canAccessCompanyData,
|
canAccessCompanyData,
|
||||||
} from "../utils/permissionUtils";
|
} from "../utils/permissionUtils";
|
||||||
|
import { auditLogService } from "../services/auditLogService";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 권한 그룹 목록 조회
|
* 권한 그룹 목록 조회
|
||||||
|
|
@ -179,6 +180,20 @@ export const createRoleGroup = async (
|
||||||
data: roleGroup,
|
data: roleGroup,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: companyCode || req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "CREATE",
|
||||||
|
resourceType: "ROLE",
|
||||||
|
resourceId: String(roleGroup?.objid || ""),
|
||||||
|
resourceName: authName,
|
||||||
|
summary: `권한 그룹 "${authName}" 생성`,
|
||||||
|
changes: { after: { authName, authCode, companyCode } },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(201).json(response);
|
res.status(201).json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("권한 그룹 생성 실패", { error });
|
logger.error("권한 그룹 생성 실패", { error });
|
||||||
|
|
@ -243,6 +258,23 @@ export const updateRoleGroup = async (
|
||||||
data: roleGroup,
|
data: roleGroup,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "UPDATE",
|
||||||
|
resourceType: "ROLE",
|
||||||
|
resourceId: String(objid),
|
||||||
|
resourceName: authName,
|
||||||
|
summary: `권한 그룹 "${authName}" 수정`,
|
||||||
|
changes: {
|
||||||
|
before: { authName: existingRoleGroup.authName, authCode: existingRoleGroup.authCode, status: existingRoleGroup.status },
|
||||||
|
after: { authName, authCode, status },
|
||||||
|
},
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(200).json(response);
|
res.status(200).json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("권한 그룹 수정 실패", { error });
|
logger.error("권한 그룹 수정 실패", { error });
|
||||||
|
|
@ -302,6 +334,19 @@ export const deleteRoleGroup = async (
|
||||||
data: null,
|
data: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: existingRoleGroup.companyCode || req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "DELETE",
|
||||||
|
resourceType: "ROLE",
|
||||||
|
resourceId: String(objid),
|
||||||
|
resourceName: existingRoleGroup.authName,
|
||||||
|
summary: `권한 그룹 "${existingRoleGroup.authName}" 삭제`,
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(200).json(response);
|
res.status(200).json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("권한 그룹 삭제 실패", { error });
|
logger.error("권한 그룹 삭제 실패", { error });
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
import { screenManagementService } from "../services/screenManagementService";
|
import { screenManagementService } from "../services/screenManagementService";
|
||||||
import { AuthenticatedRequest } from "../types/auth";
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
import { auditLogService } from "../services/auditLogService";
|
||||||
|
|
||||||
// 화면 목록 조회
|
// 화면 목록 조회
|
||||||
export const getScreens = async (req: AuthenticatedRequest, res: Response) => {
|
export const getScreens = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
|
@ -108,6 +109,21 @@ export const createScreen = async (
|
||||||
screenData,
|
screenData,
|
||||||
companyCode
|
companyCode
|
||||||
);
|
);
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode,
|
||||||
|
userId: (req.user as any)?.userId || "",
|
||||||
|
userName: (req.user as any)?.userName || "",
|
||||||
|
action: "CREATE",
|
||||||
|
resourceType: "SCREEN",
|
||||||
|
resourceId: String(newScreen?.id || ""),
|
||||||
|
resourceName: newScreen?.screenName || screenData.screenName,
|
||||||
|
summary: `화면 "${newScreen?.screenName || screenData.screenName}" 생성`,
|
||||||
|
changes: { after: { screenName: newScreen?.screenName, tableName: newScreen?.tableName } },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(201).json({ success: true, data: newScreen });
|
res.status(201).json({ success: true, data: newScreen });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("화면 생성 실패:", error);
|
console.error("화면 생성 실패:", error);
|
||||||
|
|
@ -125,12 +141,31 @@ export const updateScreen = async (
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { companyCode } = req.user as any;
|
const { companyCode } = req.user as any;
|
||||||
|
const beforeScreen = await screenManagementService.getScreenById(parseInt(id));
|
||||||
const updateData = { ...req.body, companyCode };
|
const updateData = { ...req.body, companyCode };
|
||||||
const updatedScreen = await screenManagementService.updateScreen(
|
const updatedScreen = await screenManagementService.updateScreen(
|
||||||
parseInt(id),
|
parseInt(id),
|
||||||
updateData,
|
updateData,
|
||||||
companyCode
|
companyCode
|
||||||
);
|
);
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode,
|
||||||
|
userId: (req.user as any)?.userId || "",
|
||||||
|
userName: (req.user as any)?.userName || "",
|
||||||
|
action: "UPDATE",
|
||||||
|
resourceType: "SCREEN",
|
||||||
|
resourceId: id,
|
||||||
|
resourceName: updatedScreen?.screenName || updateData.screenName,
|
||||||
|
summary: `화면 "${updatedScreen?.screenName || updateData.screenName}" 수정`,
|
||||||
|
changes: {
|
||||||
|
before: { screenName: beforeScreen?.screenName, tableName: beforeScreen?.tableName, isActive: beforeScreen?.isActive },
|
||||||
|
after: { screenName: updateData.screenName, tableName: updateData.tableName, isActive: updateData.isActive },
|
||||||
|
},
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({ success: true, data: updatedScreen });
|
res.json({ success: true, data: updatedScreen });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("화면 수정 실패:", error);
|
console.error("화면 수정 실패:", error);
|
||||||
|
|
@ -140,6 +175,33 @@ export const updateScreen = async (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 화면 테이블명 변경
|
||||||
|
export const updateScreenTableName = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { screenId } = req.params;
|
||||||
|
const { companyCode } = req.user as any;
|
||||||
|
const { tableName } = req.body;
|
||||||
|
|
||||||
|
if (!tableName) {
|
||||||
|
return res.status(400).json({ success: false, message: "테이블명이 필요합니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
await screenManagementService.updateScreenTableName(
|
||||||
|
parseInt(screenId),
|
||||||
|
tableName,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true, message: "테이블명이 변경되었습니다." });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블명 변경 실패:", error);
|
||||||
|
res.status(500).json({ success: false, message: "테이블명 변경에 실패했습니다." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 화면 정보 수정 (메타데이터만)
|
// 화면 정보 수정 (메타데이터만)
|
||||||
export const updateScreenInfo = async (
|
export const updateScreenInfo = async (
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
|
|
@ -170,6 +232,8 @@ export const updateScreenInfo = async (
|
||||||
restApiJsonPath,
|
restApiJsonPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const beforeScreen = await screenManagementService.getScreenById(parseInt(id));
|
||||||
|
|
||||||
await screenManagementService.updateScreenInfo(
|
await screenManagementService.updateScreenInfo(
|
||||||
parseInt(id),
|
parseInt(id),
|
||||||
{
|
{
|
||||||
|
|
@ -186,6 +250,24 @@ export const updateScreenInfo = async (
|
||||||
},
|
},
|
||||||
companyCode
|
companyCode
|
||||||
);
|
);
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode,
|
||||||
|
userId: (req.user as any)?.userId || "",
|
||||||
|
userName: (req.user as any)?.userName || "",
|
||||||
|
action: "UPDATE",
|
||||||
|
resourceType: "SCREEN",
|
||||||
|
resourceId: id,
|
||||||
|
resourceName: screenName,
|
||||||
|
summary: `화면 "${screenName}" 정보 수정`,
|
||||||
|
changes: {
|
||||||
|
before: { screenName: beforeScreen?.screenName, tableName: beforeScreen?.tableName, description: beforeScreen?.description, isActive: beforeScreen?.isActive },
|
||||||
|
after: { screenName, tableName, description, isActive },
|
||||||
|
},
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({ success: true, message: "화면 정보가 수정되었습니다." });
|
res.json({ success: true, message: "화면 정보가 수정되었습니다." });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("화면 정보 수정 실패:", error);
|
console.error("화면 정보 수정 실패:", error);
|
||||||
|
|
@ -227,6 +309,9 @@ export const deleteScreen = async (
|
||||||
const { companyCode, userId } = req.user as any;
|
const { companyCode, userId } = req.user as any;
|
||||||
const { deleteReason, force } = req.body;
|
const { deleteReason, force } = req.body;
|
||||||
|
|
||||||
|
const screenInfo = await screenManagementService.getScreenById(parseInt(id));
|
||||||
|
const screenName = screenInfo?.screenName || "";
|
||||||
|
|
||||||
await screenManagementService.deleteScreen(
|
await screenManagementService.deleteScreen(
|
||||||
parseInt(id),
|
parseInt(id),
|
||||||
companyCode,
|
companyCode,
|
||||||
|
|
@ -234,6 +319,21 @@ export const deleteScreen = async (
|
||||||
deleteReason,
|
deleteReason,
|
||||||
force || false
|
force || false
|
||||||
);
|
);
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode,
|
||||||
|
userId: userId || "",
|
||||||
|
userName: (req.user as any)?.userName || "",
|
||||||
|
action: "DELETE",
|
||||||
|
resourceType: "SCREEN",
|
||||||
|
resourceId: id,
|
||||||
|
resourceName: screenName,
|
||||||
|
summary: `화면(ID:${id}, ${screenName}) 삭제 (사유: ${deleteReason || "없음"})`,
|
||||||
|
changes: { before: { deleteReason, force } },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({ success: true, message: "화면이 휴지통으로 이동되었습니다." });
|
res.json({ success: true, message: "화면이 휴지통으로 이동되었습니다." });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("화면 삭제 실패:", error);
|
console.error("화면 삭제 실패:", error);
|
||||||
|
|
@ -513,6 +613,20 @@ export const copyScreenWithModals = async (
|
||||||
modalScreens: modalScreens || [],
|
modalScreens: modalScreens || [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: targetCompanyCode || companyCode,
|
||||||
|
userId: userId || "",
|
||||||
|
userName: (req.user as any)?.userName || "",
|
||||||
|
action: "COPY",
|
||||||
|
resourceType: "SCREEN",
|
||||||
|
resourceId: id,
|
||||||
|
resourceName: mainScreen?.screenName,
|
||||||
|
summary: `화면 일괄 복사 (메인 1개 + 모달 ${result.modalScreens.length}개, 원본 ID:${id})`,
|
||||||
|
changes: { after: { sourceScreenId: id, targetCompanyCode, mainScreenName: mainScreen?.screenName } },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: result,
|
data: result,
|
||||||
|
|
@ -548,6 +662,20 @@ export const copyScreen = async (
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode,
|
||||||
|
userId: userId || "",
|
||||||
|
userName: (req.user as any)?.userName || "",
|
||||||
|
action: "COPY",
|
||||||
|
resourceType: "SCREEN",
|
||||||
|
resourceId: String(copiedScreen?.id || ""),
|
||||||
|
resourceName: screenName,
|
||||||
|
summary: `화면 "${screenName}" 복사 (원본 ID:${id})`,
|
||||||
|
changes: { after: { sourceScreenId: id, screenName, screenCode } },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: copiedScreen,
|
data: copiedScreen,
|
||||||
|
|
@ -647,6 +775,21 @@ export const saveLayout = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
layoutData,
|
layoutData,
|
||||||
companyCode
|
companyCode
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const screenInfo = await screenManagementService.getScreenById(parseInt(screenId));
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode,
|
||||||
|
userId: (req.user as any)?.userId || "",
|
||||||
|
userName: (req.user as any)?.userName || "",
|
||||||
|
action: "UPDATE",
|
||||||
|
resourceType: "SCREEN_LAYOUT",
|
||||||
|
resourceId: screenId,
|
||||||
|
resourceName: screenInfo?.screenName || "",
|
||||||
|
summary: `화면(ID:${screenId}, ${screenInfo?.screenName || ""}) 레이아웃 저장`,
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({ success: true, data: savedLayout });
|
res.json({ success: true, data: savedLayout });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("레이아웃 저장 실패:", error);
|
console.error("레이아웃 저장 실패:", error);
|
||||||
|
|
@ -723,6 +866,21 @@ export const saveLayoutV2 = async (req: AuthenticatedRequest, res: Response) =>
|
||||||
layoutData,
|
layoutData,
|
||||||
companyCode
|
companyCode
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const screenInfo = await screenManagementService.getScreenById(parseInt(screenId));
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode,
|
||||||
|
userId: (req.user as any)?.userId || "",
|
||||||
|
userName: (req.user as any)?.userName || "",
|
||||||
|
action: "UPDATE",
|
||||||
|
resourceType: "SCREEN_LAYOUT",
|
||||||
|
resourceId: screenId,
|
||||||
|
resourceName: screenInfo?.screenName || "",
|
||||||
|
summary: `화면(ID:${screenId}, ${screenInfo?.screenName || ""}) V2 레이아웃 저장`,
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({ success: true, message: "V2 레이아웃이 저장되었습니다." });
|
res.json({ success: true, message: "V2 레이아웃이 저장되었습니다." });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("V2 레이아웃 저장 실패:", error);
|
console.error("V2 레이아웃 저장 실패:", error);
|
||||||
|
|
@ -895,6 +1053,21 @@ export const saveLayoutPop = async (req: AuthenticatedRequest, res: Response) =>
|
||||||
companyCode,
|
companyCode,
|
||||||
userId
|
userId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const screenInfo = await screenManagementService.getScreenById(parseInt(screenId));
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode,
|
||||||
|
userId: userId || "",
|
||||||
|
userName: (req.user as any)?.userName || "",
|
||||||
|
action: "UPDATE",
|
||||||
|
resourceType: "SCREEN_LAYOUT",
|
||||||
|
resourceId: screenId,
|
||||||
|
resourceName: screenInfo?.screenName || "",
|
||||||
|
summary: `화면(ID:${screenId}, ${screenInfo?.screenName || ""}) POP 레이아웃 저장`,
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({ success: true, message: "POP 레이아웃이 저장되었습니다." });
|
res.json({ success: true, message: "POP 레이아웃이 저장되었습니다." });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("POP 레이아웃 저장 실패:", error);
|
console.error("POP 레이아웃 저장 실패:", error);
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ import {
|
||||||
ColumnListResponse,
|
ColumnListResponse,
|
||||||
ColumnSettingsResponse,
|
ColumnSettingsResponse,
|
||||||
} from "../types/tableManagement";
|
} from "../types/tableManagement";
|
||||||
import { query } from "../database/db"; // 🆕 query 함수 import
|
import { query } from "../database/db";
|
||||||
|
import { auditLogService } from "../services/auditLogService";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블 목록 조회
|
* 테이블 목록 조회
|
||||||
|
|
@ -962,6 +963,21 @@ export async function addTableData(
|
||||||
|
|
||||||
logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`);
|
logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`);
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "CREATE",
|
||||||
|
resourceType: "DATA",
|
||||||
|
resourceId: result.insertedId || "",
|
||||||
|
resourceName: tableName,
|
||||||
|
tableName,
|
||||||
|
summary: `${tableName} 데이터 추가`,
|
||||||
|
changes: { after: data },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
const response: ApiResponse<{ id: string | null }> = {
|
const response: ApiResponse<{ id: string | null }> = {
|
||||||
success: true,
|
success: true,
|
||||||
message: "테이블 데이터를 성공적으로 추가했습니다.",
|
message: "테이블 데이터를 성공적으로 추가했습니다.",
|
||||||
|
|
@ -1080,6 +1096,16 @@ export async function editTableData(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 변경된 필드만 추출
|
||||||
|
const changedBefore: Record<string, any> = {};
|
||||||
|
const changedAfter: Record<string, any> = {};
|
||||||
|
for (const key of Object.keys(updatedData)) {
|
||||||
|
if (String(originalData[key] ?? "") !== String(updatedData[key] ?? "")) {
|
||||||
|
changedBefore[key] = originalData[key];
|
||||||
|
changedAfter[key] = updatedData[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 데이터 수정
|
// 데이터 수정
|
||||||
await tableManagementService.editTableData(
|
await tableManagementService.editTableData(
|
||||||
tableName,
|
tableName,
|
||||||
|
|
@ -1089,6 +1115,23 @@ export async function editTableData(
|
||||||
|
|
||||||
logger.info(`테이블 데이터 수정 완료: ${tableName}`);
|
logger.info(`테이블 데이터 수정 완료: ${tableName}`);
|
||||||
|
|
||||||
|
if (Object.keys(changedAfter).length > 0) {
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "UPDATE",
|
||||||
|
resourceType: "DATA",
|
||||||
|
resourceId: originalData.id?.toString() || "",
|
||||||
|
resourceName: tableName,
|
||||||
|
tableName,
|
||||||
|
summary: `${tableName} 데이터 수정`,
|
||||||
|
changes: { before: changedBefore, after: changedAfter },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const response: ApiResponse<null> = {
|
const response: ApiResponse<null> = {
|
||||||
success: true,
|
success: true,
|
||||||
message: "테이블 데이터를 성공적으로 수정했습니다.",
|
message: "테이블 데이터를 성공적으로 수정했습니다.",
|
||||||
|
|
@ -1406,6 +1449,22 @@ export async function deleteTableData(
|
||||||
`테이블 데이터 삭제 완료: ${tableName}, ${deletedCount}건 삭제`
|
`테이블 데이터 삭제 완료: ${tableName}, ${deletedCount}건 삭제`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const deleteItems = Array.isArray(data) ? data : [data];
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "DELETE",
|
||||||
|
resourceType: "DATA",
|
||||||
|
resourceId: deleteItems[0]?.id?.toString() || "",
|
||||||
|
resourceName: tableName,
|
||||||
|
tableName,
|
||||||
|
summary: `${tableName} 데이터 삭제 (${deletedCount}건)`,
|
||||||
|
changes: { before: { deletedCount, items: deleteItems.length } },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
const response: ApiResponse<{ deletedCount: number }> = {
|
const response: ApiResponse<{ deletedCount: number }> = {
|
||||||
success: true,
|
success: true,
|
||||||
message: `테이블 데이터를 성공적으로 삭제했습니다. (${deletedCount}건)`,
|
message: `테이블 데이터를 성공적으로 삭제했습니다. (${deletedCount}건)`,
|
||||||
|
|
@ -2285,6 +2344,21 @@ export async function multiTableSave(
|
||||||
subTableResultsCount: subTableResults.length,
|
subTableResultsCount: subTableResults.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: isUpdate ? "UPDATE" : "CREATE",
|
||||||
|
resourceType: "DATA",
|
||||||
|
resourceId: savedPkValue?.toString() || "",
|
||||||
|
resourceName: mainTableName,
|
||||||
|
tableName: mainTableName,
|
||||||
|
summary: `${mainTableName} 데이터 ${isUpdate ? "수정" : "생성"}${subTableResults.length > 0 ? ` (서브 테이블 ${subTableResults.length}건)` : ""}`,
|
||||||
|
changes: { after: mainData },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "다중 테이블 저장이 완료되었습니다.",
|
message: "다중 테이블 저장이 완료되었습니다.",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
import { getAuditLogs, getAuditLogStats, getAuditLogUsers } from "../controllers/auditLogController";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get("/", authenticateToken, getAuditLogs);
|
||||||
|
router.get("/stats", authenticateToken, getAuditLogStats);
|
||||||
|
router.get("/users", authenticateToken, getAuditLogUsers);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -3,6 +3,7 @@ import { dataService } from "../services/dataService";
|
||||||
import { masterDetailExcelService } from "../services/masterDetailExcelService";
|
import { masterDetailExcelService } from "../services/masterDetailExcelService";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
import { AuthenticatedRequest } from "../types/auth";
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
import { auditLogService } from "../services/auditLogService";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
|
@ -736,17 +737,39 @@ router.post(
|
||||||
return res.status(400).json(result);
|
return res.status(400).json(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inserted = result.data?.inserted || 0;
|
||||||
|
const updated = result.data?.updated || 0;
|
||||||
|
const deleted = result.data?.deleted || 0;
|
||||||
|
|
||||||
console.log(`✅ 그룹화된 데이터 UPSERT 성공: ${tableName}`, {
|
console.log(`✅ 그룹화된 데이터 UPSERT 성공: ${tableName}`, {
|
||||||
inserted: result.data?.inserted || 0,
|
inserted, updated, deleted,
|
||||||
updated: result.data?.updated || 0,
|
|
||||||
deleted: result.data?.deleted || 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (inserted > 0) parts.push(`${inserted}건 생성`);
|
||||||
|
if (updated > 0) parts.push(`${updated}건 수정`);
|
||||||
|
if (deleted > 0) parts.push(`${deleted}건 삭제`);
|
||||||
|
|
||||||
|
if (parts.length > 0) {
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: inserted > 0 && updated === 0 && deleted === 0 ? "BATCH_CREATE" : "UPDATE",
|
||||||
|
resourceType: "DATA",
|
||||||
|
tableName,
|
||||||
|
summary: `${tableName} 테이블 배치 처리: ${parts.join(", ")}`,
|
||||||
|
changes: { after: { inserted, updated, deleted } },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "데이터가 저장되었습니다.",
|
message: "데이터가 저장되었습니다.",
|
||||||
inserted: result.data?.inserted || 0,
|
inserted,
|
||||||
updated: result.data?.updated || 0,
|
updated,
|
||||||
deleted: result.data?.deleted || 0,
|
deleted: result.data?.deleted || 0,
|
||||||
savedIds: result.data?.savedIds || [],
|
savedIds: result.data?.savedIds || [],
|
||||||
});
|
});
|
||||||
|
|
@ -824,6 +847,19 @@ router.post(
|
||||||
|
|
||||||
console.log(`✅ 레코드 생성 성공: ${tableName}`);
|
console.log(`✅ 레코드 생성 성공: ${tableName}`);
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "CREATE",
|
||||||
|
resourceType: "DATA",
|
||||||
|
resourceId: result.data?.id ? String(result.data.id) : undefined,
|
||||||
|
tableName,
|
||||||
|
summary: `${tableName} 테이블에 데이터 1건 생성`,
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: result.data,
|
data: result.data,
|
||||||
|
|
@ -880,6 +916,20 @@ router.put(
|
||||||
|
|
||||||
console.log(`✅ 레코드 수정 성공: ${tableName}/${id}`);
|
console.log(`✅ 레코드 수정 성공: ${tableName}/${id}`);
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "UPDATE",
|
||||||
|
resourceType: "DATA",
|
||||||
|
resourceId: String(id),
|
||||||
|
tableName,
|
||||||
|
summary: `${tableName} 테이블 데이터 수정 (ID:${id})`,
|
||||||
|
changes: { after: data, fields: Object.keys(data || {}) },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: result.data,
|
data: result.data,
|
||||||
|
|
@ -940,6 +990,20 @@ router.post(
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✅ 레코드 삭제 성공: ${tableName}`);
|
console.log(`✅ 레코드 삭제 성공: ${tableName}`);
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "DELETE",
|
||||||
|
resourceType: "DATA",
|
||||||
|
tableName,
|
||||||
|
summary: `${tableName} 테이블 데이터 삭제 (복합키)`,
|
||||||
|
changes: { before: compositeKey },
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
return res.json(result);
|
return res.json(result);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`레코드 삭제 오류 (${req.params.tableName}):`, error);
|
console.error(`레코드 삭제 오류 (${req.params.tableName}):`, error);
|
||||||
|
|
@ -1032,6 +1096,19 @@ router.delete(
|
||||||
|
|
||||||
console.log(`✅ 레코드 삭제 성공: ${tableName}/${id}`);
|
console.log(`✅ 레코드 삭제 성공: ${tableName}/${id}`);
|
||||||
|
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "DELETE",
|
||||||
|
resourceType: "DATA",
|
||||||
|
resourceId: String(id),
|
||||||
|
tableName,
|
||||||
|
summary: `${tableName} 테이블 데이터 삭제 (ID:${id})`,
|
||||||
|
ipAddress: (req as any).ip,
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "레코드가 삭제되었습니다.",
|
message: "레코드가 삭제되었습니다.",
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
createScreen,
|
createScreen,
|
||||||
updateScreen,
|
updateScreen,
|
||||||
updateScreenInfo,
|
updateScreenInfo,
|
||||||
|
updateScreenTableName,
|
||||||
deleteScreen,
|
deleteScreen,
|
||||||
bulkDeleteScreens,
|
bulkDeleteScreens,
|
||||||
checkScreenDependencies,
|
checkScreenDependencies,
|
||||||
|
|
@ -65,6 +66,7 @@ router.get("/screens/:id/menu", getScreenMenu); // 화면에 할당된 메뉴
|
||||||
router.post("/screens", createScreen);
|
router.post("/screens", createScreen);
|
||||||
router.put("/screens/:id", updateScreen);
|
router.put("/screens/:id", updateScreen);
|
||||||
router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정
|
router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정
|
||||||
|
router.patch("/screens/:screenId/table-name", updateScreenTableName); // 화면 테이블명 변경
|
||||||
router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크
|
router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크
|
||||||
router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동
|
router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동
|
||||||
router.delete("/screens/bulk/delete", bulkDeleteScreens); // 활성 화면 일괄 삭제 (휴지통으로 이동)
|
router.delete("/screens/bulk/delete", bulkDeleteScreens); // 활성 화면 일괄 삭제 (휴지통으로 이동)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,296 @@
|
||||||
|
import { query, pool } from "../database/db";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
export type AuditAction =
|
||||||
|
| "CREATE"
|
||||||
|
| "UPDATE"
|
||||||
|
| "DELETE"
|
||||||
|
| "COPY"
|
||||||
|
| "LOGIN"
|
||||||
|
| "STATUS_CHANGE"
|
||||||
|
| "BATCH_CREATE"
|
||||||
|
| "BATCH_UPDATE"
|
||||||
|
| "BATCH_DELETE";
|
||||||
|
|
||||||
|
export type AuditResourceType =
|
||||||
|
| "MENU"
|
||||||
|
| "SCREEN"
|
||||||
|
| "SCREEN_LAYOUT"
|
||||||
|
| "FLOW"
|
||||||
|
| "FLOW_STEP"
|
||||||
|
| "USER"
|
||||||
|
| "ROLE"
|
||||||
|
| "PERMISSION"
|
||||||
|
| "COMPANY"
|
||||||
|
| "CODE_CATEGORY"
|
||||||
|
| "CODE"
|
||||||
|
| "DATA"
|
||||||
|
| "TABLE"
|
||||||
|
| "NUMBERING_RULE"
|
||||||
|
| "BATCH";
|
||||||
|
|
||||||
|
export interface AuditLogParams {
|
||||||
|
companyCode: string;
|
||||||
|
userId: string;
|
||||||
|
userName?: string;
|
||||||
|
action: AuditAction;
|
||||||
|
resourceType: AuditResourceType;
|
||||||
|
resourceId?: string;
|
||||||
|
resourceName?: string;
|
||||||
|
tableName?: string;
|
||||||
|
summary?: string;
|
||||||
|
changes?: {
|
||||||
|
before?: Record<string, any>;
|
||||||
|
after?: Record<string, any>;
|
||||||
|
fields?: string[];
|
||||||
|
};
|
||||||
|
ipAddress?: string;
|
||||||
|
requestPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogEntry {
|
||||||
|
id: number;
|
||||||
|
company_code: string;
|
||||||
|
user_id: string;
|
||||||
|
user_name: string | null;
|
||||||
|
action: string;
|
||||||
|
resource_type: string;
|
||||||
|
resource_id: string | null;
|
||||||
|
resource_name: string | null;
|
||||||
|
table_name: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
changes: any;
|
||||||
|
ip_address: string | null;
|
||||||
|
request_path: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogFilters {
|
||||||
|
companyCode?: string;
|
||||||
|
userId?: string;
|
||||||
|
resourceType?: string;
|
||||||
|
action?: string;
|
||||||
|
tableName?: string;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogStats {
|
||||||
|
dailyCounts: Array<{ date: string; count: number }>;
|
||||||
|
resourceTypeCounts: Array<{ resource_type: string; count: number }>;
|
||||||
|
actionCounts: Array<{ action: string; count: number }>;
|
||||||
|
topUsers: Array<{ user_id: string; user_name: string; count: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuditLogService {
|
||||||
|
/**
|
||||||
|
* 감사 로그 1건 기록 (fire-and-forget)
|
||||||
|
* 본 작업에 영향을 주지 않도록 에러를 내부에서 처리
|
||||||
|
*/
|
||||||
|
async log(params: AuditLogParams): Promise<void> {
|
||||||
|
try {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO system_audit_log
|
||||||
|
(company_code, user_id, user_name, action, resource_type,
|
||||||
|
resource_id, resource_name, table_name, summary, changes,
|
||||||
|
ip_address, request_path)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
||||||
|
[
|
||||||
|
params.companyCode,
|
||||||
|
params.userId,
|
||||||
|
params.userName || null,
|
||||||
|
params.action,
|
||||||
|
params.resourceType,
|
||||||
|
params.resourceId || null,
|
||||||
|
params.resourceName || null,
|
||||||
|
params.tableName || null,
|
||||||
|
params.summary || null,
|
||||||
|
params.changes ? JSON.stringify(params.changes) : null,
|
||||||
|
params.ipAddress || null,
|
||||||
|
params.requestPath || null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("감사 로그 기록 실패 (무시됨)", { error, params });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 감사 로그 다건 기록 (배치)
|
||||||
|
*/
|
||||||
|
async logBatch(entries: AuditLogParams[]): Promise<void> {
|
||||||
|
if (entries.length === 0) return;
|
||||||
|
try {
|
||||||
|
const values = entries
|
||||||
|
.map(
|
||||||
|
(_, i) =>
|
||||||
|
`($${i * 12 + 1}, $${i * 12 + 2}, $${i * 12 + 3}, $${i * 12 + 4}, $${i * 12 + 5}, $${i * 12 + 6}, $${i * 12 + 7}, $${i * 12 + 8}, $${i * 12 + 9}, $${i * 12 + 10}, $${i * 12 + 11}, $${i * 12 + 12})`
|
||||||
|
)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
const params = entries.flatMap((e) => [
|
||||||
|
e.companyCode,
|
||||||
|
e.userId,
|
||||||
|
e.userName || null,
|
||||||
|
e.action,
|
||||||
|
e.resourceType,
|
||||||
|
e.resourceId || null,
|
||||||
|
e.resourceName || null,
|
||||||
|
e.tableName || null,
|
||||||
|
e.summary || null,
|
||||||
|
e.changes ? JSON.stringify(e.changes) : null,
|
||||||
|
e.ipAddress || null,
|
||||||
|
e.requestPath || null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`INSERT INTO system_audit_log
|
||||||
|
(company_code, user_id, user_name, action, resource_type,
|
||||||
|
resource_id, resource_name, table_name, summary, changes,
|
||||||
|
ip_address, request_path)
|
||||||
|
VALUES ${values}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("감사 로그 배치 기록 실패 (무시됨)", { error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 감사 로그 조회 (페이징, 필터)
|
||||||
|
*/
|
||||||
|
async queryLogs(
|
||||||
|
filters: AuditLogFilters,
|
||||||
|
isSuperAdmin: boolean = false
|
||||||
|
): Promise<{ data: AuditLogEntry[]; total: number }> {
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (!isSuperAdmin && filters.companyCode) {
|
||||||
|
conditions.push(`company_code = $${paramIndex++}`);
|
||||||
|
params.push(filters.companyCode);
|
||||||
|
} else if (isSuperAdmin && filters.companyCode) {
|
||||||
|
conditions.push(`company_code = $${paramIndex++}`);
|
||||||
|
params.push(filters.companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.userId) {
|
||||||
|
conditions.push(`user_id = $${paramIndex++}`);
|
||||||
|
params.push(filters.userId);
|
||||||
|
}
|
||||||
|
if (filters.resourceType) {
|
||||||
|
conditions.push(`resource_type = $${paramIndex++}`);
|
||||||
|
params.push(filters.resourceType);
|
||||||
|
}
|
||||||
|
if (filters.action) {
|
||||||
|
conditions.push(`action = $${paramIndex++}`);
|
||||||
|
params.push(filters.action);
|
||||||
|
}
|
||||||
|
if (filters.tableName) {
|
||||||
|
conditions.push(`table_name = $${paramIndex++}`);
|
||||||
|
params.push(filters.tableName);
|
||||||
|
}
|
||||||
|
if (filters.dateFrom) {
|
||||||
|
conditions.push(`created_at >= $${paramIndex++}::timestamptz`);
|
||||||
|
params.push(filters.dateFrom);
|
||||||
|
}
|
||||||
|
if (filters.dateTo) {
|
||||||
|
conditions.push(`created_at <= $${paramIndex++}::timestamptz`);
|
||||||
|
params.push(filters.dateTo);
|
||||||
|
}
|
||||||
|
if (filters.search) {
|
||||||
|
conditions.push(
|
||||||
|
`(summary ILIKE $${paramIndex} OR resource_name ILIKE $${paramIndex} OR user_name ILIKE $${paramIndex})`
|
||||||
|
);
|
||||||
|
params.push(`%${filters.search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause =
|
||||||
|
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||||
|
|
||||||
|
const page = filters.page || 1;
|
||||||
|
const limit = filters.limit || 50;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const countResult = await query<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM system_audit_log ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const total = parseInt(countResult[0].count, 10);
|
||||||
|
|
||||||
|
const data = await query<AuditLogEntry>(
|
||||||
|
`SELECT * FROM system_audit_log ${whereClause}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
||||||
|
[...params, limit, offset]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { data, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 통계 조회
|
||||||
|
*/
|
||||||
|
async getStats(
|
||||||
|
companyCode?: string,
|
||||||
|
days: number = 30
|
||||||
|
): Promise<AuditLogStats> {
|
||||||
|
const companyFilter = companyCode
|
||||||
|
? "AND company_code = $1"
|
||||||
|
: "";
|
||||||
|
const params = companyCode ? [companyCode] : [];
|
||||||
|
|
||||||
|
const dailyCounts = await query<{ date: string; count: number }>(
|
||||||
|
`SELECT DATE(created_at) as date, COUNT(*)::int as count
|
||||||
|
FROM system_audit_log
|
||||||
|
WHERE created_at >= NOW() - INTERVAL '${days} days' ${companyFilter}
|
||||||
|
GROUP BY DATE(created_at)
|
||||||
|
ORDER BY date DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
const resourceTypeCounts = await query<{
|
||||||
|
resource_type: string;
|
||||||
|
count: number;
|
||||||
|
}>(
|
||||||
|
`SELECT resource_type, COUNT(*)::int as count
|
||||||
|
FROM system_audit_log
|
||||||
|
WHERE created_at >= NOW() - INTERVAL '${days} days' ${companyFilter}
|
||||||
|
GROUP BY resource_type
|
||||||
|
ORDER BY count DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
const actionCounts = await query<{ action: string; count: number }>(
|
||||||
|
`SELECT action, COUNT(*)::int as count
|
||||||
|
FROM system_audit_log
|
||||||
|
WHERE created_at >= NOW() - INTERVAL '${days} days' ${companyFilter}
|
||||||
|
GROUP BY action
|
||||||
|
ORDER BY count DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
const topUsers = await query<{
|
||||||
|
user_id: string;
|
||||||
|
user_name: string;
|
||||||
|
count: number;
|
||||||
|
}>(
|
||||||
|
`SELECT user_id, COALESCE(MAX(user_name), user_id) as user_name, COUNT(*)::int as count
|
||||||
|
FROM system_audit_log
|
||||||
|
WHERE created_at >= NOW() - INTERVAL '${days} days' ${companyFilter}
|
||||||
|
GROUP BY user_id
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT 10`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return { dailyCounts, resourceTypeCounts, actionCounts, topUsers };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const auditLogService = new AuditLogService();
|
||||||
|
|
@ -405,69 +405,169 @@ class CategoryTreeService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 모든 하위 카테고리 값 ID 재귀 수집
|
* 카테고리 값이 실제 데이터 테이블에서 사용 중인지 확인
|
||||||
*/
|
*/
|
||||||
private async collectAllChildValueIds(
|
private async checkCategoryValueInUse(
|
||||||
companyCode: string,
|
companyCode: string,
|
||||||
valueId: number
|
value: CategoryValue
|
||||||
): Promise<number[]> {
|
): Promise<{ inUse: boolean; count: number }> {
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
|
|
||||||
// 재귀 CTE를 사용하여 모든 하위 카테고리 수집
|
try {
|
||||||
const query = `
|
const tableExists = await pool.query(
|
||||||
WITH RECURSIVE category_tree AS (
|
`SELECT EXISTS (
|
||||||
SELECT value_id FROM category_values
|
SELECT 1 FROM information_schema.tables
|
||||||
WHERE parent_value_id = $1 AND (company_code = $2 OR company_code = '*')
|
WHERE table_schema = 'public' AND table_name = $1
|
||||||
UNION ALL
|
) AS exists`,
|
||||||
SELECT cv.value_id
|
[value.tableName]
|
||||||
FROM category_values cv
|
);
|
||||||
INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id
|
|
||||||
WHERE cv.company_code = $2 OR cv.company_code = '*'
|
|
||||||
)
|
|
||||||
SELECT value_id FROM category_tree
|
|
||||||
`;
|
|
||||||
|
|
||||||
const result = await pool.query(query, [valueId, companyCode]);
|
if (!tableExists.rows[0].exists) {
|
||||||
return result.rows.map(row => row.value_id);
|
return { inUse: false, count: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnExists = await pool.query(
|
||||||
|
`SELECT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2
|
||||||
|
) AS exists`,
|
||||||
|
[value.tableName, value.columnName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!columnExists.rows[0].exists) {
|
||||||
|
return { inUse: false, count: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCompanyCode = await pool.query(
|
||||||
|
`SELECT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = $1 AND column_name = 'company_code'
|
||||||
|
) AS exists`,
|
||||||
|
[value.tableName]
|
||||||
|
);
|
||||||
|
|
||||||
|
let countQuery: string;
|
||||||
|
let params: any[];
|
||||||
|
|
||||||
|
if (hasCompanyCode.rows[0].exists && companyCode !== "*") {
|
||||||
|
countQuery = `
|
||||||
|
SELECT COUNT(*) as count FROM "${value.tableName}"
|
||||||
|
WHERE company_code = $1
|
||||||
|
AND ($2 = ANY(string_to_array("${value.columnName}"::text, ','))
|
||||||
|
OR "${value.columnName}"::text = $2)
|
||||||
|
`;
|
||||||
|
params = [companyCode, value.valueCode];
|
||||||
|
} else {
|
||||||
|
countQuery = `
|
||||||
|
SELECT COUNT(*) as count FROM "${value.tableName}"
|
||||||
|
WHERE $1 = ANY(string_to_array("${value.columnName}"::text, ','))
|
||||||
|
OR "${value.columnName}"::text = $1
|
||||||
|
`;
|
||||||
|
params = [value.valueCode];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(countQuery, params);
|
||||||
|
const count = parseInt(result.rows[0].count);
|
||||||
|
|
||||||
|
return { inUse: count > 0, count };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
logger.warn("카테고리 사용 여부 확인 중 오류 (무시하고 삭제 허용)", {
|
||||||
|
error: err.message,
|
||||||
|
tableName: value.tableName,
|
||||||
|
columnName: value.columnName,
|
||||||
|
});
|
||||||
|
return { inUse: false, count: 0 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 카테고리 값 삭제 (하위 항목도 함께 삭제)
|
* 카테고리 값 삭제 가능 여부 사전 확인
|
||||||
|
*/
|
||||||
|
async checkCanDelete(
|
||||||
|
companyCode: string,
|
||||||
|
valueId: number
|
||||||
|
): Promise<{ canDelete: boolean; reason?: string }> {
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
const value = await this.getCategoryValue(companyCode, valueId);
|
||||||
|
if (!value) {
|
||||||
|
return { canDelete: false, reason: "카테고리 값을 찾을 수 없습니다" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const childCheck = await pool.query(
|
||||||
|
`SELECT COUNT(*) as count FROM category_values
|
||||||
|
WHERE parent_value_id = $1 AND (company_code = $2 OR company_code = '*')`,
|
||||||
|
[valueId, companyCode]
|
||||||
|
);
|
||||||
|
const childCount = parseInt(childCheck.rows[0].count);
|
||||||
|
|
||||||
|
if (childCount > 0) {
|
||||||
|
return {
|
||||||
|
canDelete: false,
|
||||||
|
reason: `하위 카테고리가 ${childCount}개 존재합니다. 하위 카테고리를 먼저 삭제해주세요.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const usageCheck = await this.checkCategoryValueInUse(companyCode, value);
|
||||||
|
if (usageCheck.inUse) {
|
||||||
|
return {
|
||||||
|
canDelete: false,
|
||||||
|
reason: `이 카테고리 값(${value.valueLabel})은 ${usageCheck.count}건의 데이터에서 사용 중이므로 삭제할 수 없습니다.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { canDelete: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 값 삭제 (자식 존재 및 사용 중 검증 후 삭제)
|
||||||
*/
|
*/
|
||||||
async deleteCategoryValue(companyCode: string, valueId: number): Promise<boolean> {
|
async deleteCategoryValue(companyCode: string, valueId: number): Promise<boolean> {
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 모든 하위 카테고리 ID 수집
|
const value = await this.getCategoryValue(companyCode, valueId);
|
||||||
const childValueIds = await this.collectAllChildValueIds(companyCode, valueId);
|
if (!value) {
|
||||||
const allValueIds = [valueId, ...childValueIds];
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
logger.info("삭제 대상 카테고리 값 수집 완료", {
|
// 1. 자식 카테고리 존재 여부 확인
|
||||||
valueId,
|
const childCheck = await pool.query(
|
||||||
childCount: childValueIds.length,
|
`SELECT COUNT(*) as count FROM category_values
|
||||||
totalCount: allValueIds.length,
|
WHERE parent_value_id = $1 AND (company_code = $2 OR company_code = '*')`,
|
||||||
});
|
[valueId, companyCode]
|
||||||
|
);
|
||||||
|
const childCount = parseInt(childCheck.rows[0].count);
|
||||||
|
|
||||||
// 2. 하위 카테고리부터 역순으로 삭제 (외래키 제약 회피)
|
if (childCount > 0) {
|
||||||
const reversedIds = [...allValueIds].reverse();
|
throw new Error(
|
||||||
|
`VALIDATION:하위 카테고리가 ${childCount}개 존재합니다. 하위 카테고리를 먼저 삭제해주세요.`
|
||||||
for (const id of reversedIds) {
|
|
||||||
await pool.query(
|
|
||||||
`DELETE FROM category_values WHERE (company_code = $1 OR company_code = '*') AND value_id = $2`,
|
|
||||||
[companyCode, id]
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("카테고리 값 삭제 완료", {
|
// 2. 실제 데이터에서 사용 중인지 확인
|
||||||
valueId,
|
const usageCheck = await this.checkCategoryValueInUse(companyCode, value);
|
||||||
deletedCount: allValueIds.length,
|
if (usageCheck.inUse) {
|
||||||
deletedChildCount: childValueIds.length,
|
throw new Error(
|
||||||
});
|
`VALIDATION:이 카테고리 값(${value.valueLabel})은 ${value.tableName} 테이블에서 ${usageCheck.count}건의 데이터가 사용 중이므로 삭제할 수 없습니다.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 삭제
|
||||||
|
await pool.query(
|
||||||
|
`DELETE FROM category_values WHERE (company_code = $1 OR company_code = '*') AND value_id = $2`,
|
||||||
|
[companyCode, valueId]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("카테고리 값 삭제 완료", { valueId, valueLabel: value.valueLabel });
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
logger.error("카테고리 값 삭제 실패", { error: err.message, valueId });
|
if (!err.message.startsWith("VALIDATION:")) {
|
||||||
|
logger.error("카테고리 값 삭제 실패", { error: err.message, valueId });
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -765,7 +765,7 @@ export class EntityJoinService {
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
try {
|
try {
|
||||||
// 1. 테이블의 기본 컬럼 정보 조회
|
// 1. 테이블의 기본 컬럼 정보 조회 (모든 데이터 타입 포함)
|
||||||
const columns = await query<{
|
const columns = await query<{
|
||||||
column_name: string;
|
column_name: string;
|
||||||
data_type: string;
|
data_type: string;
|
||||||
|
|
@ -775,7 +775,7 @@ export class EntityJoinService {
|
||||||
data_type
|
data_type
|
||||||
FROM information_schema.columns
|
FROM information_schema.columns
|
||||||
WHERE table_name = $1
|
WHERE table_name = $1
|
||||||
AND data_type IN ('character varying', 'varchar', 'text', 'char')
|
AND table_schema = 'public'
|
||||||
ORDER BY ordinal_position`,
|
ORDER BY ordinal_position`,
|
||||||
[tableName]
|
[tableName]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -403,6 +403,38 @@ export class ScreenManagementService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면의 메인 테이블명만 업데이트
|
||||||
|
*/
|
||||||
|
async updateScreenTableName(
|
||||||
|
screenId: number,
|
||||||
|
tableName: string,
|
||||||
|
userCompanyCode: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const existingResult = await query<{ company_code: string | null }>(
|
||||||
|
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
|
||||||
|
[screenId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingResult.length === 0) {
|
||||||
|
throw new Error("화면을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
userCompanyCode !== "*" &&
|
||||||
|
existingResult[0].company_code !== userCompanyCode
|
||||||
|
) {
|
||||||
|
throw new Error("이 화면을 수정할 권한이 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE screen_definitions SET table_name = $1, updated_date = NOW() WHERE screen_id = $2`,
|
||||||
|
[tableName, screenId],
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`화면 테이블명 업데이트 완료: screenId=${screenId}, tableName=${tableName}`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 화면 의존성 체크 - 다른 화면에서 이 화면을 참조하는지 확인
|
* 화면 의존성 체크 - 다른 화면에서 이 화면을 참조하는지 확인
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,948 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Layout,
|
||||||
|
Monitor,
|
||||||
|
GitBranch,
|
||||||
|
User,
|
||||||
|
Database,
|
||||||
|
Shield,
|
||||||
|
Search,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Clock,
|
||||||
|
Filter,
|
||||||
|
Building2,
|
||||||
|
Hash,
|
||||||
|
FileText,
|
||||||
|
RefreshCw,
|
||||||
|
Check,
|
||||||
|
ChevronsUpDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
getAuditLogs,
|
||||||
|
getAuditLogStats,
|
||||||
|
getAuditLogUsers,
|
||||||
|
AuditLogEntry,
|
||||||
|
AuditLogFilters,
|
||||||
|
AuditLogStats,
|
||||||
|
AuditLogUser,
|
||||||
|
} from "@/lib/api/auditLog";
|
||||||
|
import { getCompanyList } from "@/lib/api/company";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { Company } from "@/types/company";
|
||||||
|
|
||||||
|
const RESOURCE_TYPE_CONFIG: Record<
|
||||||
|
string,
|
||||||
|
{ label: string; icon: React.ElementType; color: string }
|
||||||
|
> = {
|
||||||
|
MENU: { label: "메뉴", icon: Layout, color: "bg-blue-100 text-blue-700" },
|
||||||
|
SCREEN: { label: "화면", icon: Monitor, color: "bg-purple-100 text-purple-700" },
|
||||||
|
SCREEN_LAYOUT: { label: "레이아웃", icon: Monitor, color: "bg-purple-100 text-purple-700" },
|
||||||
|
FLOW: { label: "플로우", icon: GitBranch, color: "bg-green-100 text-green-700" },
|
||||||
|
FLOW_STEP: { label: "플로우 스텝", icon: GitBranch, color: "bg-green-100 text-green-700" },
|
||||||
|
USER: { label: "사용자", icon: User, color: "bg-orange-100 text-orange-700" },
|
||||||
|
ROLE: { label: "권한", icon: Shield, color: "bg-red-100 text-red-700" },
|
||||||
|
PERMISSION: { label: "권한", icon: Shield, color: "bg-red-100 text-red-700" },
|
||||||
|
COMPANY: { label: "회사", icon: Building2, color: "bg-indigo-100 text-indigo-700" },
|
||||||
|
CODE_CATEGORY: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
||||||
|
CODE: { label: "코드", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
||||||
|
DATA: { label: "데이터", icon: Database, color: "bg-gray-100 text-gray-700" },
|
||||||
|
TABLE: { label: "테이블", icon: Database, color: "bg-gray-100 text-gray-700" },
|
||||||
|
NUMBERING_RULE: { label: "채번 규칙", icon: FileText, color: "bg-amber-100 text-amber-700" },
|
||||||
|
BATCH: { label: "배치", icon: RefreshCw, color: "bg-teal-100 text-teal-700" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTION_CONFIG: Record<string, { label: string; color: string }> = {
|
||||||
|
CREATE: { label: "생성", color: "bg-emerald-100 text-emerald-700" },
|
||||||
|
UPDATE: { label: "수정", color: "bg-blue-100 text-blue-700" },
|
||||||
|
DELETE: { label: "삭제", color: "bg-red-100 text-red-700" },
|
||||||
|
COPY: { label: "복사", color: "bg-violet-100 text-violet-700" },
|
||||||
|
LOGIN: { label: "로그인", color: "bg-gray-100 text-gray-700" },
|
||||||
|
STATUS_CHANGE: { label: "상태변경", color: "bg-amber-100 text-amber-700" },
|
||||||
|
BATCH_CREATE: { label: "배치생성", color: "bg-emerald-100 text-emerald-700" },
|
||||||
|
BATCH_UPDATE: { label: "배치수정", color: "bg-blue-100 text-blue-700" },
|
||||||
|
BATCH_DELETE: { label: "배치삭제", color: "bg-red-100 text-red-700" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDateTime(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleString("ko-KR", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleTimeString("ko-KR", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_NAME_MAP: Record<string, string> = {
|
||||||
|
status: "상태",
|
||||||
|
menuUrl: "메뉴 URL",
|
||||||
|
menu_url: "메뉴 URL",
|
||||||
|
menuNameKor: "메뉴명",
|
||||||
|
menu_name_kor: "메뉴명",
|
||||||
|
menuNameEng: "메뉴명(영)",
|
||||||
|
menu_name_eng: "메뉴명(영)",
|
||||||
|
screenName: "화면명",
|
||||||
|
screen_name: "화면명",
|
||||||
|
tableName: "테이블명",
|
||||||
|
table_name: "테이블명",
|
||||||
|
description: "설명",
|
||||||
|
isActive: "활성 여부",
|
||||||
|
is_active: "활성 여부",
|
||||||
|
userName: "사용자명",
|
||||||
|
user_name: "사용자명",
|
||||||
|
userId: "사용자 ID",
|
||||||
|
user_id: "사용자 ID",
|
||||||
|
deptName: "부서명",
|
||||||
|
dept_name: "부서명",
|
||||||
|
authName: "권한명",
|
||||||
|
authCode: "권한코드",
|
||||||
|
companyCode: "회사코드",
|
||||||
|
company_code: "회사코드",
|
||||||
|
company_name: "회사명",
|
||||||
|
name: "이름",
|
||||||
|
user_password: "비밀번호",
|
||||||
|
prefix: "접두사",
|
||||||
|
ruleName: "규칙명",
|
||||||
|
stepName: "스텝명",
|
||||||
|
stepOrder: "스텝 순서",
|
||||||
|
sourceScreenId: "원본 화면 ID",
|
||||||
|
targetCompanyCode: "대상 회사코드",
|
||||||
|
mainScreenName: "메인 화면명",
|
||||||
|
screenCode: "화면코드",
|
||||||
|
menuObjid: "메뉴 ID",
|
||||||
|
deleteReason: "삭제 사유",
|
||||||
|
force: "강제 삭제",
|
||||||
|
deletedMenus: "삭제된 메뉴",
|
||||||
|
failedMenuIds: "실패한 메뉴",
|
||||||
|
deletedCount: "삭제 건수",
|
||||||
|
items: "항목 수",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatFieldValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return "(없음)";
|
||||||
|
if (typeof value === "boolean") return value ? "예" : "아니오";
|
||||||
|
if (Array.isArray(value)) return value.length > 0 ? `${value.length}건` : "(없음)";
|
||||||
|
if (typeof value === "object") return JSON.stringify(value);
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChanges(changes: Record<string, unknown>) {
|
||||||
|
const before = (changes.before as Record<string, unknown>) || {};
|
||||||
|
const after = (changes.after as Record<string, unknown>) || {};
|
||||||
|
const fields = (changes.fields as string[]) || [];
|
||||||
|
|
||||||
|
const allKeys = new Set([
|
||||||
|
...Object.keys(before),
|
||||||
|
...Object.keys(after),
|
||||||
|
...fields,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (allKeys.size === 0) return null;
|
||||||
|
|
||||||
|
const rows = Array.from(allKeys)
|
||||||
|
.filter((key) => key !== "deletedMenus" && key !== "failedMenuIds")
|
||||||
|
.map((key) => ({
|
||||||
|
field: FIELD_NAME_MAP[key] || key,
|
||||||
|
beforeVal: key in before ? formatFieldValue(before[key]) : null,
|
||||||
|
afterVal: key in after ? formatFieldValue(after[key]) : null,
|
||||||
|
isSensitive: fields.includes(key) && !(key in before) && !(key in after),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const hasBefore = Object.keys(before).length > 0;
|
||||||
|
const hasAfter = Object.keys(after).length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded border">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-muted/50">
|
||||||
|
<th className="px-3 py-1.5 text-left font-medium">항목</th>
|
||||||
|
{hasBefore && (
|
||||||
|
<th className="px-3 py-1.5 text-left font-medium text-red-600">
|
||||||
|
변경 전
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
{hasAfter && (
|
||||||
|
<th className="px-3 py-1.5 text-left font-medium text-blue-600">
|
||||||
|
변경 후
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<tr key={i} className="border-t">
|
||||||
|
<td className="text-muted-foreground px-3 py-1.5 font-medium">
|
||||||
|
{row.field}
|
||||||
|
</td>
|
||||||
|
{row.isSensitive ? (
|
||||||
|
<td
|
||||||
|
colSpan={
|
||||||
|
(hasBefore ? 1 : 0) + (hasAfter ? 1 : 0)
|
||||||
|
}
|
||||||
|
className="px-3 py-1.5 italic text-amber-600"
|
||||||
|
>
|
||||||
|
(보안 항목 - 값 비공개)
|
||||||
|
</td>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{hasBefore && (
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
{row.beforeVal !== null ? (
|
||||||
|
<span className="rounded bg-red-50 px-1.5 py-0.5 text-red-700">
|
||||||
|
{row.beforeVal}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{hasAfter && (
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
{row.afterVal !== null ? (
|
||||||
|
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-blue-700">
|
||||||
|
{row.afterVal}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateGroup(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
const today = new Date();
|
||||||
|
const yesterday = new Date(today);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
|
||||||
|
if (d.toDateString() === today.toDateString()) return "오늘";
|
||||||
|
if (d.toDateString() === yesterday.toDateString()) return "어제";
|
||||||
|
|
||||||
|
return d.toLocaleDateString("ko-KR", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
weekday: "short",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByDate(entries: AuditLogEntry[]): Map<string, AuditLogEntry[]> {
|
||||||
|
const groups = new Map<string, AuditLogEntry[]>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const dateKey = new Date(entry.created_at).toDateString();
|
||||||
|
if (!groups.has(dateKey)) groups.set(dateKey, []);
|
||||||
|
groups.get(dateKey)!.push(entry);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuditLogPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const isSuperAdmin = user?.companyCode === "*" || user?.company_code === "*";
|
||||||
|
|
||||||
|
const [entries, setEntries] = useState<AuditLogEntry[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [filters, setFilters] = useState<AuditLogFilters>({
|
||||||
|
page: 1,
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
const [stats, setStats] = useState<AuditLogStats | null>(null);
|
||||||
|
const [selectedEntry, setSelectedEntry] = useState<AuditLogEntry | null>(null);
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [userComboOpen, setUserComboOpen] = useState(false);
|
||||||
|
const [companyComboOpen, setCompanyComboOpen] = useState(false);
|
||||||
|
const [companies, setCompanies] = useState<Company[]>([]);
|
||||||
|
const [auditUsers, setAuditUsers] = useState<AuditLogUser[]>([]);
|
||||||
|
|
||||||
|
const fetchCompanies = useCallback(async () => {
|
||||||
|
if (!isSuperAdmin) return;
|
||||||
|
try {
|
||||||
|
const list = await getCompanyList({ status: "Y" });
|
||||||
|
setCompanies(list);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("회사 목록 조회 실패:", error);
|
||||||
|
}
|
||||||
|
}, [isSuperAdmin]);
|
||||||
|
|
||||||
|
const fetchAuditUsers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await getAuditLogUsers(filters.companyCode);
|
||||||
|
if (result.success) {
|
||||||
|
setAuditUsers(result.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("사용자 목록 조회 실패:", error);
|
||||||
|
}
|
||||||
|
}, [filters.companyCode]);
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await getAuditLogs(filters);
|
||||||
|
if (result.success) {
|
||||||
|
setEntries(result.data);
|
||||||
|
setTotal(result.total);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("감사 로그 조회 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
const fetchStats = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await getAuditLogStats(filters.companyCode, 30);
|
||||||
|
if (result.success) {
|
||||||
|
setStats(result.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("통계 조회 실패:", error);
|
||||||
|
}
|
||||||
|
}, [filters.companyCode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCompanies();
|
||||||
|
}, [fetchCompanies]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAuditUsers();
|
||||||
|
}, [fetchAuditUsers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLogs();
|
||||||
|
}, [fetchLogs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats();
|
||||||
|
}, [fetchStats]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / (filters.limit || 50));
|
||||||
|
|
||||||
|
const dateGroups = groupByDate(entries);
|
||||||
|
|
||||||
|
const handleFilterChange = (key: keyof AuditLogFilters, value: string) => {
|
||||||
|
const updates: Partial<AuditLogFilters> = { [key]: value || undefined, page: 1 };
|
||||||
|
if (key === "companyCode") {
|
||||||
|
updates.userId = undefined;
|
||||||
|
}
|
||||||
|
setFilters((prev) => ({ ...prev, ...updates }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
fetchLogs();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDetail = (entry: AuditLogEntry) => {
|
||||||
|
setSelectedEntry(entry);
|
||||||
|
setDetailOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-4 p-4 md:p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">통합 변경 이력</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
시스템 전체 변경 사항을 추적합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
fetchLogs();
|
||||||
|
fetchStats();
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats && (
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-muted-foreground text-xs">최근 30일 총 변경</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{stats.dailyCounts.reduce((s, d) => s + d.count, 0).toLocaleString()}건
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-muted-foreground text-xs">리소스 유형</p>
|
||||||
|
<p className="text-2xl font-bold">{stats.resourceTypeCounts.length}종</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-muted-foreground text-xs">활동 사용자</p>
|
||||||
|
<p className="text-2xl font-bold">{stats.topUsers.length}명</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-muted-foreground text-xs">오늘 변경</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{(
|
||||||
|
stats.dailyCounts.find(
|
||||||
|
(d) =>
|
||||||
|
new Date(d.date).toDateString() ===
|
||||||
|
new Date().toDateString()
|
||||||
|
)?.count || 0
|
||||||
|
).toLocaleString()}건
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSearch}
|
||||||
|
className="flex flex-wrap items-end gap-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-[120px] flex-1">
|
||||||
|
<label className="text-xs font-medium">검색어</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="text-muted-foreground absolute left-2.5 top-2.5 h-4 w-4" />
|
||||||
|
<Input
|
||||||
|
placeholder="이름, 요약, 사용자..."
|
||||||
|
value={filters.search || ""}
|
||||||
|
onChange={(e) => handleFilterChange("search", e.target.value)}
|
||||||
|
className="h-9 pl-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-[130px]">
|
||||||
|
<label className="text-xs font-medium">유형</label>
|
||||||
|
<Select
|
||||||
|
value={filters.resourceType || "all"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
handleFilterChange("resourceType", v === "all" ? "" : v)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
{Object.entries(RESOURCE_TYPE_CONFIG).map(([key, cfg]) => (
|
||||||
|
<SelectItem key={key} value={key}>
|
||||||
|
{cfg.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-[120px]">
|
||||||
|
<label className="text-xs font-medium">동작</label>
|
||||||
|
<Select
|
||||||
|
value={filters.action || "all"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
handleFilterChange("action", v === "all" ? "" : v)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
{Object.entries(ACTION_CONFIG).map(([key, cfg]) => (
|
||||||
|
<SelectItem key={key} value={key}>
|
||||||
|
{cfg.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<div className="w-[160px]">
|
||||||
|
<label className="text-xs font-medium">회사</label>
|
||||||
|
<Popover open={companyComboOpen} onOpenChange={setCompanyComboOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={companyComboOpen}
|
||||||
|
className="h-9 w-full justify-between text-xs"
|
||||||
|
>
|
||||||
|
{filters.companyCode
|
||||||
|
? companies.find((c) => c.company_code === filters.companyCode)
|
||||||
|
?.company_name || filters.companyCode
|
||||||
|
: "전체 회사"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 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" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-3 text-center text-xs">
|
||||||
|
회사를 찾을 수 없습니다
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
value="__all_companies__"
|
||||||
|
onSelect={() => {
|
||||||
|
handleFilterChange("companyCode", "");
|
||||||
|
setCompanyComboOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
!filters.companyCode ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
전체 회사
|
||||||
|
</CommandItem>
|
||||||
|
{companies.map((company) => (
|
||||||
|
<CommandItem
|
||||||
|
key={company.company_code}
|
||||||
|
value={`${company.company_name} ${company.company_code}`}
|
||||||
|
onSelect={() => {
|
||||||
|
handleFilterChange(
|
||||||
|
"companyCode",
|
||||||
|
filters.companyCode === company.company_code
|
||||||
|
? ""
|
||||||
|
: company.company_code
|
||||||
|
);
|
||||||
|
setCompanyComboOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
filters.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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="w-[160px]">
|
||||||
|
<label className="text-xs font-medium">사용자</label>
|
||||||
|
<Popover open={userComboOpen} onOpenChange={setUserComboOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={userComboOpen}
|
||||||
|
className="h-9 w-full justify-between text-xs"
|
||||||
|
>
|
||||||
|
{filters.userId
|
||||||
|
? auditUsers.find((u) => u.user_id === filters.userId)
|
||||||
|
?.user_name || filters.userId
|
||||||
|
: "전체"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 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" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-3 text-center text-xs">
|
||||||
|
사용자를 찾을 수 없습니다
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
value="__all_users__"
|
||||||
|
onSelect={() => {
|
||||||
|
handleFilterChange("userId", "");
|
||||||
|
setUserComboOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
!filters.userId ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
전체
|
||||||
|
</CommandItem>
|
||||||
|
{auditUsers.map((u) => (
|
||||||
|
<CommandItem
|
||||||
|
key={u.user_id}
|
||||||
|
value={`${u.user_name} ${u.user_id}`}
|
||||||
|
onSelect={() => {
|
||||||
|
handleFilterChange(
|
||||||
|
"userId",
|
||||||
|
filters.userId === u.user_id ? "" : u.user_id
|
||||||
|
);
|
||||||
|
setUserComboOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
filters.userId === u.user_id
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{u.user_name}</span>
|
||||||
|
<span className="text-muted-foreground text-[10px]">
|
||||||
|
{u.user_id} ({u.count}건)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-[130px]">
|
||||||
|
<label className="text-xs font-medium">시작일</label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={filters.dateFrom || ""}
|
||||||
|
onChange={(e) => handleFilterChange("dateFrom", e.target.value)}
|
||||||
|
className="h-9 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-[130px]">
|
||||||
|
<label className="text-xs font-medium">종료일</label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={filters.dateTo || ""}
|
||||||
|
onChange={(e) => handleFilterChange("dateTo", e.target.value)}
|
||||||
|
className="h-9 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" size="sm" className="h-9">
|
||||||
|
<Filter className="mr-1 h-4 w-4" />
|
||||||
|
필터 적용
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="flex-1 overflow-hidden">
|
||||||
|
<CardHeader className="border-b px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
변경 이력 ({total.toLocaleString()}건)
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
disabled={filters.page === 1}
|
||||||
|
onClick={() =>
|
||||||
|
setFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
page: (prev.page || 1) - 1,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{filters.page || 1} / {totalPages || 1}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
disabled={(filters.page || 1) >= totalPages}
|
||||||
|
onClick={() =>
|
||||||
|
setFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
page: (prev.page || 1) + 1,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="overflow-auto p-0" style={{ maxHeight: "calc(100vh - 400px)" }}>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<RefreshCw className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<Clock className="text-muted-foreground mb-3 h-10 w-10" />
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
변경 이력이 없습니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{Array.from(dateGroups.entries()).map(([dateKey, items]) => (
|
||||||
|
<div key={dateKey}>
|
||||||
|
<div className="bg-muted/50 sticky top-0 z-10 border-b px-4 py-2">
|
||||||
|
<span className="text-xs font-semibold">
|
||||||
|
{formatDateGroup(items[0].created_at)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground ml-2 text-xs">
|
||||||
|
{items.length}건
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{items.map((entry) => {
|
||||||
|
const rtConfig =
|
||||||
|
RESOURCE_TYPE_CONFIG[entry.resource_type] ||
|
||||||
|
RESOURCE_TYPE_CONFIG.DATA;
|
||||||
|
const actConfig =
|
||||||
|
ACTION_CONFIG[entry.action] || ACTION_CONFIG.UPDATE;
|
||||||
|
const IconComp = rtConfig.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className="hover:bg-muted/30 flex cursor-pointer items-start gap-3 px-4 py-3 transition-colors"
|
||||||
|
onClick={() => openDetail(entry)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${rtConfig.color}`}
|
||||||
|
>
|
||||||
|
<IconComp className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{entry.user_name || entry.user_id}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={`text-[10px] ${rtConfig.color}`}
|
||||||
|
>
|
||||||
|
{rtConfig.label}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={`text-[10px] ${actConfig.color}`}
|
||||||
|
>
|
||||||
|
{actConfig.label}
|
||||||
|
</Badge>
|
||||||
|
{entry.company_code && entry.company_code !== "*" && (
|
||||||
|
<span className="text-muted-foreground text-[10px]">
|
||||||
|
[{entry.company_code}]
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-0.5 truncate text-xs">
|
||||||
|
{entry.summary || entry.resource_name || "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground shrink-0 text-xs">
|
||||||
|
{formatTime(entry.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">
|
||||||
|
변경 상세 정보
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
{selectedEntry &&
|
||||||
|
formatDateTime(selectedEntry.created_at)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{selectedEntry && (
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-muted-foreground text-xs">
|
||||||
|
사용자
|
||||||
|
</label>
|
||||||
|
<p className="font-medium">
|
||||||
|
{selectedEntry.user_name || selectedEntry.user_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-muted-foreground text-xs">
|
||||||
|
회사코드
|
||||||
|
</label>
|
||||||
|
<p className="font-medium">{selectedEntry.company_code}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-muted-foreground text-xs">
|
||||||
|
리소스 유형
|
||||||
|
</label>
|
||||||
|
<p className="font-medium">
|
||||||
|
{RESOURCE_TYPE_CONFIG[selectedEntry.resource_type]?.label ||
|
||||||
|
selectedEntry.resource_type}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-muted-foreground text-xs">동작</label>
|
||||||
|
<p className="font-medium">
|
||||||
|
{ACTION_CONFIG[selectedEntry.action]?.label ||
|
||||||
|
selectedEntry.action}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{selectedEntry.resource_name && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="text-muted-foreground text-xs">
|
||||||
|
리소스명
|
||||||
|
</label>
|
||||||
|
<p className="font-medium">{selectedEntry.resource_name}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedEntry.table_name && (
|
||||||
|
<div>
|
||||||
|
<label className="text-muted-foreground text-xs">
|
||||||
|
테이블명
|
||||||
|
</label>
|
||||||
|
<p className="font-medium">{selectedEntry.table_name}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedEntry.ip_address && (
|
||||||
|
<div>
|
||||||
|
<label className="text-muted-foreground text-xs">
|
||||||
|
IP 주소
|
||||||
|
</label>
|
||||||
|
<p className="font-medium">{selectedEntry.ip_address}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedEntry.summary && (
|
||||||
|
<div>
|
||||||
|
<label className="text-muted-foreground text-xs">요약</label>
|
||||||
|
<p className="bg-muted rounded p-2 text-xs">
|
||||||
|
{selectedEntry.summary}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedEntry.changes && (
|
||||||
|
<div>
|
||||||
|
<label className="text-muted-foreground text-xs">
|
||||||
|
변경 내역
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
{renderChanges(
|
||||||
|
selectedEntry.changes as Record<string, unknown>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedEntry.request_path && (
|
||||||
|
<div>
|
||||||
|
<label className="text-muted-foreground text-xs">
|
||||||
|
API 경로
|
||||||
|
</label>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{selectedEntry.request_path}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -380,8 +380,8 @@ export function CreateTableModal({
|
||||||
<ColumnDefinitionTable columns={columns} onChange={setColumns} disabled={loading} />
|
<ColumnDefinitionTable columns={columns} onChange={setColumns} disabled={loading} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 로그 테이블 생성 옵션 */}
|
{/* 로그 테이블 생성 옵션 - 통합 변경 이력 시스템으로 대체됨 (숨김 처리) */}
|
||||||
<div className="flex items-start space-x-3 rounded-lg border p-4">
|
{/* <div className="flex items-start space-x-3 rounded-lg border p-4">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="useLogTable"
|
id="useLogTable"
|
||||||
checked={useLogTable}
|
checked={useLogTable}
|
||||||
|
|
@ -401,7 +401,7 @@ export function CreateTableModal({
|
||||||
자동으로 생성되어 INSERT/UPDATE/DELETE 변경 이력을 기록합니다.
|
자동으로 생성되어 INSERT/UPDATE/DELETE 변경 이력을 기록합니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> */}
|
||||||
|
|
||||||
{/* 자동 추가 컬럼 안내 */}
|
{/* 자동 추가 컬럼 안내 */}
|
||||||
<Alert>
|
<Alert>
|
||||||
|
|
|
||||||
|
|
@ -2092,24 +2092,25 @@ export default function ScreenDesigner({
|
||||||
// V2/POP API 사용 여부에 따라 분기
|
// V2/POP API 사용 여부에 따라 분기
|
||||||
const v2Layout = convertLegacyToV2(layoutWithResolution);
|
const v2Layout = convertLegacyToV2(layoutWithResolution);
|
||||||
if (USE_POP_API) {
|
if (USE_POP_API) {
|
||||||
// POP 모드: screen_layouts_pop 테이블에 저장
|
|
||||||
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
|
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
|
||||||
} else if (USE_V2_API) {
|
} else if (USE_V2_API) {
|
||||||
// 레이어 기반 저장: 현재 활성 레이어의 layout만 저장
|
|
||||||
const currentLayerId = activeLayerIdRef.current || 1;
|
const currentLayerId = activeLayerIdRef.current || 1;
|
||||||
await screenApi.saveLayoutV2(selectedScreen.screenId, {
|
await screenApi.saveLayoutV2(selectedScreen.screenId, {
|
||||||
...v2Layout,
|
...v2Layout,
|
||||||
layerId: currentLayerId,
|
layerId: currentLayerId,
|
||||||
mainTableName: currentMainTableName, // 화면의 기본 테이블 (DB 업데이트용)
|
mainTableName: currentMainTableName,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log("✅ 저장 성공!");
|
// 테이블이 변경된 경우 전용 API로 명시적으로 업데이트
|
||||||
|
if (currentMainTableName && currentMainTableName !== selectedScreen.tableName) {
|
||||||
|
await screenApi.updateScreenTableName(selectedScreen.screenId, currentMainTableName);
|
||||||
|
}
|
||||||
|
|
||||||
toast.success("화면이 저장되었습니다.");
|
toast.success("화면이 저장되었습니다.");
|
||||||
|
|
||||||
// 저장 성공 후 부모에게 화면 정보 업데이트 알림 (테이블명 즉시 반영)
|
|
||||||
if (onScreenUpdate && currentMainTableName) {
|
if (onScreenUpdate && currentMainTableName) {
|
||||||
onScreenUpdate({ tableName: currentMainTableName });
|
onScreenUpdate({ tableName: currentMainTableName });
|
||||||
}
|
}
|
||||||
|
|
@ -5625,33 +5626,38 @@ export default function ScreenDesigner({
|
||||||
if (layout.components.length > 0 && selectedScreen?.screenId) {
|
if (layout.components.length > 0 && selectedScreen?.screenId) {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
// 해상도 정보를 포함한 레이아웃 데이터 생성
|
const currentMainTableName = tables.length > 0 ? tables[0].tableName : null;
|
||||||
|
|
||||||
const layoutWithResolution = {
|
const layoutWithResolution = {
|
||||||
...layout,
|
...layout,
|
||||||
screenResolution: screenResolution,
|
screenResolution: screenResolution,
|
||||||
|
mainTableName: currentMainTableName,
|
||||||
};
|
};
|
||||||
console.log("⚡ 자동 저장할 레이아웃 데이터:", {
|
|
||||||
componentsCount: layoutWithResolution.components.length,
|
|
||||||
gridSettings: layoutWithResolution.gridSettings,
|
|
||||||
screenResolution: layoutWithResolution.screenResolution,
|
|
||||||
});
|
|
||||||
// V2/POP API 사용 여부에 따라 분기
|
// V2/POP API 사용 여부에 따라 분기
|
||||||
const v2Layout = convertLegacyToV2(layoutWithResolution);
|
const v2Layout = convertLegacyToV2(layoutWithResolution);
|
||||||
if (USE_POP_API) {
|
if (USE_POP_API) {
|
||||||
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
|
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
|
||||||
} else if (USE_V2_API) {
|
} else if (USE_V2_API) {
|
||||||
// 현재 활성 레이어 ID 포함 (레이어별 저장)
|
|
||||||
const currentLayerId = activeLayerIdRef.current || 1;
|
const currentLayerId = activeLayerIdRef.current || 1;
|
||||||
await screenApi.saveLayoutV2(selectedScreen.screenId, {
|
await screenApi.saveLayoutV2(selectedScreen.screenId, {
|
||||||
...v2Layout,
|
...v2Layout,
|
||||||
layerId: currentLayerId,
|
layerId: currentLayerId,
|
||||||
|
mainTableName: currentMainTableName,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentMainTableName && currentMainTableName !== selectedScreen.tableName) {
|
||||||
|
await screenApi.updateScreenTableName(selectedScreen.screenId, currentMainTableName);
|
||||||
|
}
|
||||||
|
|
||||||
toast.success("레이아웃이 저장되었습니다.");
|
toast.success("레이아웃이 저장되었습니다.");
|
||||||
|
|
||||||
|
if (onScreenUpdate && currentMainTableName) {
|
||||||
|
onScreenUpdate({ tableName: currentMainTableName });
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("레이아웃 저장 실패:", error);
|
|
||||||
toast.error("레이아웃 저장에 실패했습니다.");
|
toast.error("레이아웃 저장에 실패했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
|
|
@ -5783,6 +5789,8 @@ export default function ScreenDesigner({
|
||||||
handleGroupDistribute,
|
handleGroupDistribute,
|
||||||
handleMatchSize,
|
handleMatchSize,
|
||||||
handleToggleAllLabels,
|
handleToggleAllLabels,
|
||||||
|
tables,
|
||||||
|
onScreenUpdate,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 플로우 위젯 높이 자동 업데이트 이벤트 리스너
|
// 플로우 위젯 높이 자동 업데이트 이벤트 리스너
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import {
|
||||||
createCategoryValue,
|
createCategoryValue,
|
||||||
updateCategoryValue,
|
updateCategoryValue,
|
||||||
deleteCategoryValue,
|
deleteCategoryValue,
|
||||||
|
checkCanDeleteCategoryValue,
|
||||||
CreateCategoryValueInput,
|
CreateCategoryValueInput,
|
||||||
} from "@/lib/api/categoryTree";
|
} from "@/lib/api/categoryTree";
|
||||||
import {
|
import {
|
||||||
|
|
@ -310,53 +311,6 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||||
return count;
|
return count;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 하위 항목 개수만 계산 (자기 자신 제외)
|
|
||||||
const countAllDescendants = useCallback(
|
|
||||||
(node: CategoryValue): number => {
|
|
||||||
if (!node.children || node.children.length === 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return countAllValues(node.children);
|
|
||||||
},
|
|
||||||
[countAllValues],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 노드와 모든 하위 항목의 ID 수집
|
|
||||||
const collectNodeAndDescendantIds = useCallback((node: CategoryValue): number[] => {
|
|
||||||
const ids: number[] = [node.valueId];
|
|
||||||
if (node.children) {
|
|
||||||
for (const child of node.children) {
|
|
||||||
ids.push(...collectNodeAndDescendantIds(child));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ids;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 트리에서 valueId로 노드 찾기
|
|
||||||
const findNodeById = useCallback((nodes: CategoryValue[], valueId: number): CategoryValue | null => {
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (node.valueId === valueId) {
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
if (node.children) {
|
|
||||||
const found = findNodeById(node.children, valueId);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 체크된 항목들의 총 삭제 대상 수 계산 (하위 포함)
|
|
||||||
const totalDeleteCount = useMemo(() => {
|
|
||||||
const allIds = new Set<number>();
|
|
||||||
checkedIds.forEach((id) => {
|
|
||||||
const node = findNodeById(tree, id);
|
|
||||||
if (node) {
|
|
||||||
collectNodeAndDescendantIds(node).forEach((descendantId) => allIds.add(descendantId));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return allIds.size;
|
|
||||||
}, [checkedIds, tree, findNodeById, collectNodeAndDescendantIds]);
|
|
||||||
|
|
||||||
// 활성 노드만 필터링
|
// 활성 노드만 필터링
|
||||||
const filterActiveNodes = useCallback((nodes: CategoryValue[]): CategoryValue[] => {
|
const filterActiveNodes = useCallback((nodes: CategoryValue[]): CategoryValue[] => {
|
||||||
|
|
@ -504,8 +458,20 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||||
setIsEditModalOpen(true);
|
setIsEditModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 삭제 다이얼로그 열기
|
// 삭제 다이얼로그 열기 (사전 확인 후)
|
||||||
const handleOpenDeleteDialog = (value: CategoryValue) => {
|
const handleOpenDeleteDialog = async (value: CategoryValue) => {
|
||||||
|
try {
|
||||||
|
const response = await checkCanDeleteCategoryValue(value.valueId);
|
||||||
|
if (response.success && response.data) {
|
||||||
|
if (!response.data.canDelete) {
|
||||||
|
toast.error(response.data.reason || "이 카테고리는 삭제할 수 없습니다");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 사전 확인 실패 시에도 다이얼로그는 열어줌 (삭제 시 백엔드에서 재검증)
|
||||||
|
}
|
||||||
|
|
||||||
setDeletingValue(value);
|
setDeletingValue(value);
|
||||||
setIsDeleteDialogOpen(true);
|
setIsDeleteDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
@ -616,8 +582,8 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||||
try {
|
try {
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let failCount = 0;
|
let failCount = 0;
|
||||||
|
const failMessages: string[] = [];
|
||||||
|
|
||||||
// 체크된 항목들을 순차적으로 삭제 (하위는 백엔드에서 자동 삭제)
|
|
||||||
for (const valueId of Array.from(checkedIds)) {
|
for (const valueId of Array.from(checkedIds)) {
|
||||||
try {
|
try {
|
||||||
const response = await deleteCategoryValue(valueId);
|
const response = await deleteCategoryValue(valueId);
|
||||||
|
|
@ -625,6 +591,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||||
successCount++;
|
successCount++;
|
||||||
} else {
|
} else {
|
||||||
failCount++;
|
failCount++;
|
||||||
|
if (response.error) failMessages.push(response.error);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
failCount++;
|
failCount++;
|
||||||
|
|
@ -634,12 +601,14 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||||
setIsBulkDeleteDialogOpen(false);
|
setIsBulkDeleteDialogOpen(false);
|
||||||
setCheckedIds(new Set());
|
setCheckedIds(new Set());
|
||||||
setSelectedValue(null);
|
setSelectedValue(null);
|
||||||
loadTree(true); // 기존 펼침 상태 유지
|
loadTree(true);
|
||||||
|
|
||||||
if (failCount === 0) {
|
if (failCount === 0) {
|
||||||
toast.success(`${successCount}개 카테고리가 삭제되었습니다 (하위 항목 포함)`);
|
toast.success(`${successCount}개 카테고리가 삭제되었습니다`);
|
||||||
|
} else if (successCount === 0) {
|
||||||
|
toast.error(`삭제할 수 없습니다: ${failMessages[0] || "삭제 실패"}`);
|
||||||
} else {
|
} else {
|
||||||
toast.warning(`${successCount}개 삭제 성공, ${failCount}개 삭제 실패`);
|
toast.warning(`${successCount}개 삭제 성공, ${failCount}개 삭제 실패 (사용 중이거나 하위 항목 존재)`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("카테고리 일괄 삭제 오류:", error);
|
console.error("카테고리 일괄 삭제 오류:", error);
|
||||||
|
|
@ -889,14 +858,8 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||||
<AlertDialogTitle>카테고리 삭제</AlertDialogTitle>
|
<AlertDialogTitle>카테고리 삭제</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
<strong>{deletingValue?.valueLabel}</strong>을(를) 삭제하시겠습니까?
|
<strong>{deletingValue?.valueLabel}</strong>을(를) 삭제하시겠습니까?
|
||||||
{deletingValue && countAllDescendants(deletingValue) > 0 && (
|
<br />
|
||||||
<>
|
<span className="text-muted-foreground text-xs">삭제된 카테고리는 복구할 수 없습니다.</span>
|
||||||
<br />
|
|
||||||
<span className="text-destructive">
|
|
||||||
하위 카테고리 {countAllDescendants(deletingValue)}개도 모두 함께 삭제됩니다.
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|
@ -918,12 +881,6 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||||
<AlertDialogTitle>카테고리 일괄 삭제</AlertDialogTitle>
|
<AlertDialogTitle>카테고리 일괄 삭제</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
선택한 <strong>{checkedIds.size}개</strong> 카테고리를 삭제하시겠습니까?
|
선택한 <strong>{checkedIds.size}개</strong> 카테고리를 삭제하시겠습니까?
|
||||||
{totalDeleteCount > checkedIds.size && (
|
|
||||||
<>
|
|
||||||
<br />
|
|
||||||
<span className="text-destructive">하위 카테고리 포함 총 {totalDeleteCount}개가 삭제됩니다.</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<br />
|
<br />
|
||||||
<span className="text-muted-foreground text-xs">삭제된 카테고리는 복구할 수 없습니다.</span>
|
<span className="text-muted-foreground text-xs">삭제된 카테고리는 복구할 수 없습니다.</span>
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
|
|
@ -934,7 +891,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||||
onClick={handleBulkDelete}
|
onClick={handleBulkDelete}
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
>
|
>
|
||||||
{totalDeleteCount}개 삭제
|
{checkedIds.size}개 삭제
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface AuditLogEntry {
|
||||||
|
id: number;
|
||||||
|
company_code: string;
|
||||||
|
user_id: string;
|
||||||
|
user_name: string | null;
|
||||||
|
action: string;
|
||||||
|
resource_type: string;
|
||||||
|
resource_id: string | null;
|
||||||
|
resource_name: string | null;
|
||||||
|
table_name: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
changes: any;
|
||||||
|
ip_address: string | null;
|
||||||
|
request_path: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogFilters {
|
||||||
|
companyCode?: string;
|
||||||
|
userId?: string;
|
||||||
|
resourceType?: string;
|
||||||
|
action?: string;
|
||||||
|
tableName?: string;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogStats {
|
||||||
|
dailyCounts: Array<{ date: string; count: number }>;
|
||||||
|
resourceTypeCounts: Array<{ resource_type: string; count: number }>;
|
||||||
|
actionCounts: Array<{ action: string; count: number }>;
|
||||||
|
topUsers: Array<{ user_id: string; user_name: string; count: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuditLogs(
|
||||||
|
filters: AuditLogFilters
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data: AuditLogEntry[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
}> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.companyCode) params.append("companyCode", filters.companyCode);
|
||||||
|
if (filters.userId) params.append("userId", filters.userId);
|
||||||
|
if (filters.resourceType) params.append("resourceType", filters.resourceType);
|
||||||
|
if (filters.action) params.append("action", filters.action);
|
||||||
|
if (filters.tableName) params.append("tableName", filters.tableName);
|
||||||
|
if (filters.dateFrom) params.append("dateFrom", filters.dateFrom);
|
||||||
|
if (filters.dateTo) params.append("dateTo", filters.dateTo);
|
||||||
|
if (filters.search) params.append("search", filters.search);
|
||||||
|
if (filters.page) params.append("page", String(filters.page));
|
||||||
|
if (filters.limit) params.append("limit", String(filters.limit));
|
||||||
|
|
||||||
|
const response = await apiClient.get(`/audit-log?${params.toString()}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuditLogStats(
|
||||||
|
companyCode?: string,
|
||||||
|
days?: number
|
||||||
|
): Promise<{ success: boolean; data: AuditLogStats }> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (companyCode) params.append("companyCode", companyCode);
|
||||||
|
if (days) params.append("days", String(days));
|
||||||
|
|
||||||
|
const response = await apiClient.get(`/audit-log/stats?${params.toString()}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogUser {
|
||||||
|
user_id: string;
|
||||||
|
user_name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuditLogUsers(
|
||||||
|
companyCode?: string
|
||||||
|
): Promise<{ success: boolean; data: AuditLogUser[] }> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (companyCode) params.append("companyCode", companyCode);
|
||||||
|
|
||||||
|
const response = await apiClient.get(`/audit-log/users?${params.toString()}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
@ -156,6 +156,24 @@ export async function updateCategoryValue(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 값 삭제 가능 여부 사전 확인
|
||||||
|
*/
|
||||||
|
export async function checkCanDeleteCategoryValue(
|
||||||
|
valueId: number
|
||||||
|
): Promise<ApiResponse<{ canDelete: boolean; reason?: string }>> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/category-tree/test/value/${valueId}/can-delete`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as { response?: { data?: { error?: string } }; message?: string };
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err.response?.data?.error || err.message || "삭제 가능 여부 확인 실패",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 카테고리 값 삭제
|
* 카테고리 값 삭제
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,11 @@ export const screenApi = {
|
||||||
await apiClient.put(`/screen-management/screens/${screenId}/info`, data);
|
await apiClient.put(`/screen-management/screens/${screenId}/info`, data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 화면 테이블명 변경
|
||||||
|
updateScreenTableName: async (screenId: number, tableName: string): Promise<void> => {
|
||||||
|
await apiClient.patch(`/screen-management/screens/${screenId}/table-name`, { tableName });
|
||||||
|
},
|
||||||
|
|
||||||
// 화면 의존성 체크
|
// 화면 의존성 체크
|
||||||
checkScreenDependencies: async (
|
checkScreenDependencies: async (
|
||||||
screenId: number,
|
screenId: number,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue