사용자 변경 이력 조회 구현

This commit is contained in:
dohyeons 2025-08-25 18:30:07 +09:00
parent 3027f2c817
commit b43a88a045
6 changed files with 207 additions and 38 deletions

View File

@ -1832,6 +1832,158 @@ export const checkDuplicateUserId = async (
* / API
* Java AdminController의 saveUserInfo
*/
/**
* GET /api/admin/users/:userId/history
* API
* Java AdminController.getUserHistory()
*/
export const getUserHistory = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { userId } = req.params;
const { page = 1, countPerPage = 10 } = req.query;
logger.info(`사용자 변경이력 조회 요청 - userId: ${userId}`, {
page,
countPerPage,
user: req.user,
});
if (!userId) {
res.status(400).json({
success: false,
message: "사용자 ID가 필요합니다.",
error: {
code: "USER_ID_REQUIRED",
details: "userId parameter is required",
},
});
return;
}
// PostgreSQL 클라이언트 생성
const client = new Client({
connectionString:
process.env.DATABASE_URL ||
"postgresql://postgres:postgres@localhost:5432/ilshin",
});
await client.connect();
try {
// 페이징 계산
const currentPage = Number(page);
const pageSize = Number(countPerPage);
const pageStart = (currentPage - 1) * pageSize + 1;
const pageEnd = currentPage * pageSize;
// 전체 건수 조회 쿼리 (기존 backend와 동일한 로직)
const countQuery = `
SELECT
CEIL(TOTAL_CNT::float / $1)::integer AS MAX_PAGE_SIZE,
TOTAL_CNT
FROM (
SELECT
COUNT(1) AS TOTAL_CNT
FROM user_info_history
WHERE user_id = $2
) A
`;
const countResult = await client.query(countQuery, [pageSize, userId]);
const countData = countResult.rows[0] || {
total_cnt: 0,
max_page_size: 1,
};
// 변경이력 목록 조회 쿼리 (기존 backend와 동일한 로직)
const historyQuery = `
SELECT
A.*
FROM (
SELECT
A.*,
ROW_NUMBER() OVER (ORDER BY RM DESC) AS RNUM
FROM (
SELECT
T.*,
ROW_NUMBER() OVER (ORDER BY regdate) AS RM,
(SELECT user_name FROM user_info UI WHERE T.writer = UI.user_id) AS writer_name,
TO_CHAR(T.regdate, 'YYYY-MM-DD HH24:MI:SS') AS reg_date_title
FROM
user_info_history T
WHERE user_id = $1
) A
WHERE 1=1
) A
WHERE 1=1
AND RNUM::integer <= $2
AND RNUM::integer >= $3
ORDER BY RM DESC
`;
const historyResult = await client.query(historyQuery, [
userId,
pageEnd,
pageStart,
]);
// 응답 데이터 가공
const historyList = historyResult.rows.map((row) => ({
sabun: row.sabun || "",
userId: row.user_id || "",
userName: row.user_name || "",
deptCode: row.dept_code || "",
deptName: row.dept_name || "",
userTypeName: row.user_type_name || "",
historyType: row.history_type || "",
writer: row.writer || "",
writerName: row.writer_name || "",
regDate: row.regdate,
regDateTitle: row.reg_date_title || "",
status: row.status || "",
rowNum: row.rnum,
}));
logger.info(
`사용자 변경이력 조회 완료 - userId: ${userId}, 조회건수: ${historyList.length}, 전체: ${countData.total_cnt}`
);
const response: ApiResponse<any[]> = {
success: true,
data: historyList,
total: Number(countData.total_cnt),
pagination: {
page: currentPage,
limit: pageSize,
total: Number(countData.total_cnt),
totalPages: Number(countData.max_page_size),
},
};
res.status(200).json(response);
} finally {
await client.end();
}
} catch (error) {
logger.error("사용자 변경이력 조회 중 오류 발생", error);
const response: ApiResponse<null> = {
success: false,
message: "사용자 변경이력 조회 중 오류가 발생했습니다.",
error: {
code: "USER_HISTORY_FETCH_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
};
export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
try {
const userData = req.body;

View File

@ -9,6 +9,7 @@ import {
deleteMenusBatch, // 메뉴 일괄 삭제
getUserList,
getUserInfo, // 사용자 상세 조회
getUserHistory, // 사용자 변경이력 조회
getDepartmentList, // 부서 목록 조회
checkDuplicateUserId, // 사용자 ID 중복 체크
saveUser, // 사용자 등록/수정
@ -39,6 +40,7 @@ router.delete("/menus/:menuId", deleteMenu); // 메뉴 삭제
// 사용자 관리 API
router.get("/users", getUserList);
router.get("/users/:userId", getUserInfo); // 사용자 상세 조회
router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회
router.post("/users", saveUser); // 사용자 등록/수정
router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크

View File

@ -92,3 +92,20 @@ export interface AuthStatusInfo {
export interface AuthenticatedRequest extends Request {
user?: PersonBean;
}
// 사용자 변경이력 타입
export interface UserHistory {
sabun: string;
userId: string;
userName: string;
deptCode: string;
deptName: string;
userTypeName: string;
historyType: string;
writer: string;
writerName: string;
regDate: Date;
regDateTitle: string;
status: string;
rowNum: number;
}

View File

@ -33,9 +33,10 @@ export function UserHistoryModal({ isOpen, onClose, userId, userName }: UserHist
const [maxPageSize, setMaxPageSize] = useState(1);
// 페이지네이션 정보 계산
const totalPages = Math.ceil(totalItems / pageSize);
const paginationInfo: PaginationInfo = {
currentPage,
totalPages: maxPageSize,
totalPages: totalPages || 1,
totalItems,
itemsPerPage: pageSize,
startItem: totalItems > 0 ? (currentPage - 1) * pageSize + 1 : 0,
@ -62,14 +63,16 @@ export function UserHistoryModal({ isOpen, onClose, userId, userName }: UserHist
console.log("📊 백엔드 응답:", response);
if (response && response.success && Array.isArray(response.data)) {
// 원본 JSP처럼 No 컬럼을 RM 값으로 설정
const mappedHistoryList = response.data.map((item) => ({
const responseTotal = response.total || 0;
// No 컬럼을 rowNum 값으로 설정 (페이징 고려)
const mappedHistoryList = response.data.map((item, index) => ({
...item,
no: item.RM || 0, // 원본 JSP에서는 RM을 No로 사용
no: item.rowNum || responseTotal - (pageToLoad - 1) * pageSize - index, // rowNum 우선, 없으면 계산
}));
setHistoryList(mappedHistoryList);
setTotalItems(response.total || 0);
setTotalItems(responseTotal);
setMaxPageSize(response.maxPageSize || 1);
} else if (response && response.success && (!response.data || response.data.length === 0)) {
// 데이터가 비어있는 경우
@ -202,23 +205,23 @@ export function UserHistoryModal({ isOpen, onClose, userId, userName }: UserHist
historyList.map((history, index) => (
<TableRow key={index}>
<TableCell className="text-center font-mono text-sm">{history.no}</TableCell>
<TableCell className="text-center text-sm">{history.SABUN || "-"}</TableCell>
<TableCell className="text-center text-sm">{history.USER_ID || "-"}</TableCell>
<TableCell className="text-center text-sm">{history.USER_NAME || "-"}</TableCell>
<TableCell className="text-center text-sm">{history.DEPT_NAME || "-"}</TableCell>
<TableCell className="text-center text-sm">{history.sabun || "-"}</TableCell>
<TableCell className="text-center text-sm">{history.userId || "-"}</TableCell>
<TableCell className="text-center text-sm">{history.userName || "-"}</TableCell>
<TableCell className="text-center text-sm">{history.deptName || "-"}</TableCell>
<TableCell className="text-center">
<Badge variant={getStatusBadgeVariant(history.STATUS || "")}>
{getStatusText(history.STATUS || "")}
<Badge variant={getStatusBadgeVariant(history.status || "")}>
{getStatusText(history.status || "")}
</Badge>
</TableCell>
<TableCell className="text-center">
<Badge variant={getChangeTypeBadgeVariant(history.HISTORY_TYPE || "")}>
{history.HISTORY_TYPE || "-"}
<Badge variant={getChangeTypeBadgeVariant(history.historyType || "")}>
{history.historyType || "-"}
</Badge>
</TableCell>
<TableCell className="text-center text-sm">{history.WRITER_NAME || "-"}</TableCell>
<TableCell className="text-center text-sm">{history.writerName || "-"}</TableCell>
<TableCell className="text-center text-sm">
{history.REG_DATE_TITLE || formatDate(history.REGDATE || "")}
{history.regDateTitle || formatDate(history.regDate || "")}
</TableCell>
</TableRow>
))

View File

@ -81,8 +81,8 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on
const handleOpenHistoryModal = (user: User) => {
setHistoryModal({
isOpen: true,
userId: user.user_id,
userName: user.user_name || user.user_id,
userId: user.userId,
userName: user.userName || user.userId,
});
};
@ -178,7 +178,7 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on
</TableHeader>
<TableBody>
{users.map((user, index) => (
<TableRow key={`${user.user_id}-${index}`} className="hover:bg-muted/50">
<TableRow key={`${user.userId}-${index}`} className="hover:bg-muted/50">
<TableCell className="font-mono text-sm font-medium">{getRowNumber(index)}</TableCell>
<TableCell className="font-mono text-sm">{user.sabun || "-"}</TableCell>
<TableCell className="font-medium">{user.companyCode || "-"}</TableCell>
@ -190,7 +190,7 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on
<TableCell className="max-w-[200px] truncate" title={user.email}>
{user.email || "-"}
</TableCell>
<TableCell>{formatDate(user.regDate)}</TableCell>
<TableCell>{formatDate(user.regDate || "")}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Switch

View File

@ -2,28 +2,23 @@
export interface UserHistory {
no?: number; // 순번 (프론트엔드에서 추가)
rnum?: number; // 행 번호 (쿼리에서 생성)
rm?: number; // 정렬 번호 (쿼리에서 생성)
rowNum?: number; // 행 번호 (쿼리에서 생성)
// USER_INFO_HISTORY 테이블 컬럼들 (원본 구조)
SABUN?: string; // 사번
USER_ID: string; // 사용자 ID
USER_NAME?: string; // 사용자 이름
DEPT_CODE?: string; // 부서 코드
DEPT_NAME?: string; // 부서명
USER_TYPE_NAME?: string; // 사용자 타입명 (회사명)
HISTORY_TYPE?: string; // 이력유형
WRITER?: string; // 작성자 ID
REGDATE?: string; // 등록일시
STATUS?: string; // 상태
// USER_INFO_HISTORY 테이블 컬럼들 (camelCase)
sabun?: string; // 사번
userId: string; // 사용자 ID
userName?: string; // 사용자 이름
deptCode?: string; // 부서 코드
deptName?: string; // 부서명
userTypeName?: string; // 사용자 타입명 (회사명)
historyType?: string; // 이력유형
writer?: string; // 작성자 ID
regDate?: string; // 등록일시
status?: string; // 상태
// 조인된 컬럼들
WRITER_NAME?: string; // 작성자명 (JOIN으로 가져옴)
REG_DATE_TITLE?: string; // 작성일 (YYYY-MM-DD 형식)
// MyBatis ROW_NUMBER() 결과
RNUM?: number; // 페이지네이션용 행번호
RM?: number; // 정렬용 행번호
writerName?: string; // 작성자명 (JOIN으로 가져옴)
regDateTitle?: string; // 작성일 (YYYY-MM-DD 형식)
}
// API 응답 타입