사용자 관리 기능 구현 #3
|
|
@ -3,6 +3,7 @@ import { logger } from "../utils/logger";
|
|||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { ApiResponse } from "../types/common";
|
||||
import { Client } from "pg";
|
||||
import config from "../config/environment";
|
||||
import { AdminService } from "../services/adminService";
|
||||
import { EncryptUtil } from "../utils/encryptUtil";
|
||||
|
||||
|
|
@ -173,13 +174,30 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
|
|||
user: req.user,
|
||||
});
|
||||
|
||||
const { page = 1, countPerPage = 20, search, deptCode, status } = req.query;
|
||||
const {
|
||||
page = 1,
|
||||
countPerPage = 20,
|
||||
// 통합 검색 (전체 필드 대상)
|
||||
search,
|
||||
// 고급 검색 (개별 필드별)
|
||||
searchField,
|
||||
searchValue,
|
||||
search_sabun,
|
||||
search_companyName,
|
||||
search_deptName,
|
||||
search_positionName,
|
||||
search_userId,
|
||||
search_userName,
|
||||
search_tel,
|
||||
search_email,
|
||||
// 기존 필터
|
||||
deptCode,
|
||||
status,
|
||||
} = req.query;
|
||||
|
||||
// PostgreSQL 클라이언트 생성
|
||||
const client = new Client({
|
||||
connectionString:
|
||||
process.env.DATABASE_URL ||
|
||||
"postgresql://postgres:postgres@localhost:5432/ilshin",
|
||||
connectionString: config.databaseUrl,
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
|
|
@ -213,27 +231,109 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
|
|||
|
||||
const queryParams: any[] = [];
|
||||
let paramIndex = 1;
|
||||
let searchType = "none";
|
||||
|
||||
// 검색 조건 처리
|
||||
if (search && typeof search === "string" && search.trim()) {
|
||||
// 통합 검색 (우선순위: 모든 주요 필드에서 검색)
|
||||
searchType = "unified";
|
||||
const searchTerm = search.trim();
|
||||
|
||||
// 검색 조건 추가
|
||||
if (search) {
|
||||
query += ` AND (
|
||||
u.user_name ILIKE $${paramIndex} OR
|
||||
u.user_id ILIKE $${paramIndex} OR
|
||||
u.sabun ILIKE $${paramIndex} OR
|
||||
u.email ILIKE $${paramIndex}
|
||||
UPPER(COALESCE(u.sabun, '')) LIKE UPPER($${paramIndex}) OR
|
||||
UPPER(COALESCE(u.user_type_name, '')) LIKE UPPER($${paramIndex}) OR
|
||||
UPPER(COALESCE(u.dept_name, '')) LIKE UPPER($${paramIndex}) OR
|
||||
UPPER(COALESCE(u.position_name, '')) LIKE UPPER($${paramIndex}) OR
|
||||
UPPER(COALESCE(u.user_id, '')) LIKE UPPER($${paramIndex}) OR
|
||||
UPPER(COALESCE(u.user_name, '')) LIKE UPPER($${paramIndex}) OR
|
||||
UPPER(COALESCE(u.tel, '')) LIKE UPPER($${paramIndex}) OR
|
||||
UPPER(COALESCE(u.cell_phone, '')) LIKE UPPER($${paramIndex}) OR
|
||||
UPPER(COALESCE(u.email, '')) LIKE UPPER($${paramIndex})
|
||||
)`;
|
||||
queryParams.push(`%${search}%`);
|
||||
queryParams.push(`%${searchTerm}%`);
|
||||
paramIndex++;
|
||||
|
||||
logger.info("통합 검색 실행", { searchTerm });
|
||||
} else if (searchField && searchValue) {
|
||||
// 단일 필드 검색
|
||||
searchType = "single";
|
||||
const fieldMap: { [key: string]: string } = {
|
||||
sabun: "u.sabun",
|
||||
companyName: "u.user_type_name",
|
||||
deptName: "u.dept_name",
|
||||
positionName: "u.position_name",
|
||||
userId: "u.user_id",
|
||||
userName: "u.user_name",
|
||||
tel: "u.tel",
|
||||
cellPhone: "u.cell_phone",
|
||||
email: "u.email",
|
||||
};
|
||||
|
||||
if (fieldMap[searchField as string]) {
|
||||
if (searchField === "tel") {
|
||||
// 전화번호는 TEL과 CELL_PHONE 모두 검색
|
||||
query += ` AND (UPPER(u.tel) LIKE UPPER($${paramIndex}) OR UPPER(u.cell_phone) LIKE UPPER($${paramIndex}))`;
|
||||
} else {
|
||||
query += ` AND UPPER(${fieldMap[searchField as string]}) LIKE UPPER($${paramIndex})`;
|
||||
}
|
||||
queryParams.push(`%${searchValue}%`);
|
||||
paramIndex++;
|
||||
|
||||
logger.info("단일 필드 검색 실행", { searchField, searchValue });
|
||||
}
|
||||
} else {
|
||||
// 고급 검색 (개별 필드별 AND 조건)
|
||||
const advancedSearchFields = [
|
||||
{ param: search_sabun, field: "u.sabun" },
|
||||
{ param: search_companyName, field: "u.user_type_name" },
|
||||
{ param: search_deptName, field: "u.dept_name" },
|
||||
{ param: search_positionName, field: "u.position_name" },
|
||||
{ param: search_userId, field: "u.user_id" },
|
||||
{ param: search_userName, field: "u.user_name" },
|
||||
{ param: search_email, field: "u.email" },
|
||||
];
|
||||
|
||||
let hasAdvancedSearch = false;
|
||||
|
||||
for (const { param, field } of advancedSearchFields) {
|
||||
if (param && typeof param === "string" && param.trim()) {
|
||||
query += ` AND UPPER(${field}) LIKE UPPER($${paramIndex})`;
|
||||
queryParams.push(`%${param.trim()}%`);
|
||||
paramIndex++;
|
||||
hasAdvancedSearch = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 전화번호 검색 (TEL 또는 CELL_PHONE)
|
||||
if (search_tel && typeof search_tel === "string" && search_tel.trim()) {
|
||||
query += ` AND (UPPER(u.tel) LIKE UPPER($${paramIndex}) OR UPPER(u.cell_phone) LIKE UPPER($${paramIndex}))`;
|
||||
queryParams.push(`%${search_tel.trim()}%`);
|
||||
paramIndex++;
|
||||
hasAdvancedSearch = true;
|
||||
}
|
||||
|
||||
if (hasAdvancedSearch) {
|
||||
searchType = "advanced";
|
||||
logger.info("고급 검색 실행", {
|
||||
search_sabun,
|
||||
search_companyName,
|
||||
search_deptName,
|
||||
search_positionName,
|
||||
search_userId,
|
||||
search_userName,
|
||||
search_tel,
|
||||
search_email,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 부서 코드 필터
|
||||
// 기존 필터들
|
||||
if (deptCode) {
|
||||
query += ` AND u.dept_code = $${paramIndex}`;
|
||||
queryParams.push(deptCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (status) {
|
||||
query += ` AND u.status = $${paramIndex}`;
|
||||
queryParams.push(status);
|
||||
|
|
@ -243,26 +343,15 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
|
|||
// 정렬
|
||||
query += ` ORDER BY u.regdate DESC, u.user_name`;
|
||||
|
||||
// 총 개수 조회
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM user_info u
|
||||
WHERE 1=1
|
||||
${
|
||||
search
|
||||
? `AND (
|
||||
u.user_name ILIKE $1 OR
|
||||
u.user_id ILIKE $1 OR
|
||||
u.sabun ILIKE $1 OR
|
||||
u.email ILIKE $1
|
||||
)`
|
||||
: ""
|
||||
}
|
||||
${deptCode ? `AND u.dept_code = $${search ? 2 : 1}` : ""}
|
||||
${status ? `AND u.status = $${search ? (deptCode ? 3 : 2) : deptCode ? 2 : 1}` : ""}
|
||||
`;
|
||||
// 페이징 파라미터 제외한 카운트용 파라미터
|
||||
const countParams = [...queryParams];
|
||||
|
||||
// 총 개수 조회를 위해 기존 쿼리를 COUNT로 변환
|
||||
const countQuery = query.replace(
|
||||
/SELECT[\s\S]*?FROM/i,
|
||||
"SELECT COUNT(*) as total FROM"
|
||||
).replace(/ORDER BY.*$/i, "");
|
||||
|
||||
const countParams = queryParams.slice(0, -2); // 페이징 파라미터 제외
|
||||
const countResult = await client.query(countQuery, countParams);
|
||||
const totalCount = parseInt(countResult.rows[0].total);
|
||||
|
||||
|
|
@ -359,14 +448,13 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
|
|||
|
||||
const response = {
|
||||
success: true,
|
||||
data: {
|
||||
users: processedUsers,
|
||||
pagination: {
|
||||
currentPage: Number(page),
|
||||
countPerPage: Number(countPerPage),
|
||||
totalCount: totalCount,
|
||||
totalPages: Math.ceil(totalCount / Number(countPerPage)),
|
||||
},
|
||||
data: processedUsers,
|
||||
total: totalCount,
|
||||
searchType, // 검색 타입 정보 (unified, single, advanced, none)
|
||||
pagination: {
|
||||
page: Number(page),
|
||||
limit: Number(countPerPage),
|
||||
totalPages: Math.ceil(totalCount / Number(countPerPage)),
|
||||
},
|
||||
message: "사용자 목록 조회 성공",
|
||||
};
|
||||
|
|
@ -374,6 +462,7 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
|
|||
logger.info("사용자 목록 조회 성공", {
|
||||
totalCount,
|
||||
returnedCount: processedUsers.length,
|
||||
searchType,
|
||||
currentPage: Number(page),
|
||||
countPerPage: Number(countPerPage),
|
||||
});
|
||||
|
|
@ -1832,6 +1921,292 @@ 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);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/users/:userId/status
|
||||
* 사용자 상태 변경 API (부분 수정)
|
||||
* 기존 Java AdminController.changeUserStatus() 포팅
|
||||
*/
|
||||
export const changeUserStatus = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
const { status } = req.body;
|
||||
|
||||
logger.info("사용자 상태 변경 요청", { userId, status, user: req.user });
|
||||
|
||||
// 필수 파라미터 검증
|
||||
if (!userId || !status) {
|
||||
res.status(400).json({
|
||||
result: false,
|
||||
msg: "사용자 ID와 상태는 필수입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 상태 값 검증
|
||||
if (!["active", "inactive"].includes(status)) {
|
||||
res.status(400).json({
|
||||
result: false,
|
||||
msg: "유효하지 않은 상태값입니다. (active, inactive만 허용)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new Client({
|
||||
connectionString: config.databaseUrl,
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
|
||||
// 1. 사용자 존재 여부 확인
|
||||
const userCheckResult = await client.query(
|
||||
"SELECT user_id, user_name, status FROM user_info WHERE user_id = $1",
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userCheckResult.rows.length === 0) {
|
||||
res.status(404).json({
|
||||
result: false,
|
||||
msg: "사용자를 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUser = userCheckResult.rows[0];
|
||||
|
||||
// 2. 상태 변경 쿼리 실행
|
||||
let updateQuery = `
|
||||
UPDATE user_info
|
||||
SET status = $1
|
||||
`;
|
||||
|
||||
const queryParams = [status];
|
||||
|
||||
// active/inactive에 따른 END_DATE 처리
|
||||
if (status === "inactive") {
|
||||
updateQuery += `, end_date = NOW()`;
|
||||
} else if (status === "active") {
|
||||
updateQuery += `, end_date = NULL`;
|
||||
}
|
||||
|
||||
updateQuery += ` WHERE user_id = $2`;
|
||||
queryParams.push(userId);
|
||||
|
||||
const updateResult = await client.query(updateQuery, queryParams);
|
||||
|
||||
if (updateResult.rowCount && updateResult.rowCount > 0) {
|
||||
// 3. 사용자 이력 저장 (선택적)
|
||||
try {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO user_info_history
|
||||
(user_id, user_name, dept_code, dept_name, user_type_name, history_type, writer, reg_date, status, sabun)
|
||||
VALUES ($1, $2, '', '', '', '사용자 상태 변경', $3, NOW(), $4, '')
|
||||
`,
|
||||
[
|
||||
userId,
|
||||
currentUser.user_name || userId,
|
||||
req.user?.userId || "system",
|
||||
status,
|
||||
]
|
||||
);
|
||||
} catch (historyError) {
|
||||
logger.warn("사용자 이력 저장 실패", {
|
||||
error: historyError,
|
||||
userId,
|
||||
status,
|
||||
});
|
||||
// 이력 저장 실패는 치명적이지 않으므로 계속 진행
|
||||
}
|
||||
|
||||
logger.info("사용자 상태 변경 성공", {
|
||||
userId,
|
||||
oldStatus: currentUser.status,
|
||||
newStatus: status,
|
||||
updatedBy: req.user?.userId,
|
||||
});
|
||||
|
||||
res.json({
|
||||
result: true,
|
||||
msg: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`,
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
result: false,
|
||||
msg: "사용자 상태 변경에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("사용자 상태 변경 중 오류 발생", {
|
||||
error,
|
||||
userId: req.params.userId,
|
||||
status: req.body.status,
|
||||
});
|
||||
res.status(500).json({
|
||||
result: false,
|
||||
msg: "시스템 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const userData = req.body;
|
||||
|
|
@ -2305,3 +2680,158 @@ export const deleteCompany = async (
|
|||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/admin/users/reset-password
|
||||
* 사용자 비밀번호 초기화 API
|
||||
* 기존 Java AdminController.resetUserPassword() 포팅
|
||||
*/
|
||||
export const resetUserPassword = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { userId, newPassword } = req.body;
|
||||
|
||||
logger.info("비밀번호 초기화 요청", { userId, user: req.user });
|
||||
|
||||
// 입력값 검증
|
||||
if (!userId || !userId.trim()) {
|
||||
res.status(400).json({
|
||||
result: false,
|
||||
msg: "사용자 ID가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newPassword || !newPassword.trim()) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "새 비밀번호가 필요합니다.",
|
||||
msg: "새 비밀번호가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 비밀번호 길이 검증 (최소 4자)
|
||||
if (newPassword.length < 4) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "비밀번호는 최소 4자 이상이어야 합니다.",
|
||||
msg: "비밀번호는 최소 4자 이상이어야 합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new Client({ connectionString: config.databaseUrl });
|
||||
try {
|
||||
await client.connect();
|
||||
|
||||
// 1. 사용자 존재 여부 확인
|
||||
const userCheckResult = await client.query(
|
||||
"SELECT user_id, user_name FROM user_info WHERE user_id = $1",
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userCheckResult.rows.length === 0) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "사용자를 찾을 수 없습니다.",
|
||||
msg: "사용자를 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUser = userCheckResult.rows[0];
|
||||
|
||||
// 2. 비밀번호 암호화 (기존 Java 로직과 동일)
|
||||
let encryptedPassword: string;
|
||||
try {
|
||||
// EncryptUtil과 동일한 암호화 사용
|
||||
const crypto = require("crypto");
|
||||
const keyName = "ILJIAESSECRETKEY";
|
||||
const algorithm = "aes-128-ecb";
|
||||
|
||||
// AES-128-ECB 암호화
|
||||
const cipher = crypto.createCipher(algorithm, keyName);
|
||||
let encrypted = cipher.update(newPassword, "utf8", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
encryptedPassword = encrypted.toUpperCase();
|
||||
} catch (encryptError) {
|
||||
logger.error("비밀번호 암호화 중 오류 발생", {
|
||||
error: encryptError,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "비밀번호 암호화 중 오류가 발생했습니다.",
|
||||
msg: "비밀번호 암호화 중 오류가 발생했습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 비밀번호 업데이트 실행
|
||||
const updateResult = await client.query(
|
||||
"UPDATE user_info SET user_password = $1 WHERE user_id = $2",
|
||||
[encryptedPassword, userId]
|
||||
);
|
||||
|
||||
if (updateResult.rowCount && updateResult.rowCount > 0) {
|
||||
// 4. 이력 저장 (선택적)
|
||||
try {
|
||||
const writer = req.user?.userId || "system";
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO user_info_history
|
||||
(sabun, user_id, user_name, dept_code, dept_name, user_type_name, history_type, writer, regdate, status)
|
||||
VALUES ('', $1, $2, '', '', '', '비밀번호 초기화', $3, NOW(), '')
|
||||
`,
|
||||
[userId, currentUser.user_name || userId, writer]
|
||||
);
|
||||
} catch (historyError) {
|
||||
logger.warn("비밀번호 초기화 이력 저장 실패", {
|
||||
error: historyError,
|
||||
userId,
|
||||
});
|
||||
// 이력 저장 실패해도 비밀번호 초기화는 성공으로 처리
|
||||
}
|
||||
|
||||
logger.info("비밀번호 초기화 성공", {
|
||||
userId,
|
||||
updatedBy: req.user?.userId,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: true,
|
||||
message: "비밀번호가 성공적으로 초기화되었습니다.",
|
||||
msg: "비밀번호가 성공적으로 초기화되었습니다.",
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "사용자 정보를 찾을 수 없거나 비밀번호 변경에 실패했습니다.",
|
||||
msg: "사용자 정보를 찾을 수 없거나 비밀번호 변경에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("비밀번호 초기화 중 오류 발생", {
|
||||
error,
|
||||
userId: req.body.userId,
|
||||
});
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "비밀번호 초기화 중 시스템 오류가 발생했습니다.",
|
||||
msg: "비밀번호 초기화 중 시스템 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ import {
|
|||
deleteMenusBatch, // 메뉴 일괄 삭제
|
||||
getUserList,
|
||||
getUserInfo, // 사용자 상세 조회
|
||||
getUserHistory, // 사용자 변경이력 조회
|
||||
changeUserStatus, // 사용자 상태 변경
|
||||
resetUserPassword, // 사용자 비밀번호 초기화
|
||||
getDepartmentList, // 부서 목록 조회
|
||||
checkDuplicateUserId, // 사용자 ID 중복 체크
|
||||
saveUser, // 사용자 등록/수정
|
||||
|
|
@ -39,8 +42,11 @@ router.delete("/menus/:menuId", deleteMenu); // 메뉴 삭제
|
|||
// 사용자 관리 API
|
||||
router.get("/users", getUserList);
|
||||
router.get("/users/:userId", getUserInfo); // 사용자 상세 조회
|
||||
router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회
|
||||
router.patch("/users/:userId/status", changeUserStatus); // 사용자 상태 변경
|
||||
router.post("/users", saveUser); // 사용자 등록/수정
|
||||
router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크
|
||||
router.post("/users/reset-password", resetUserPassword); // 사용자 비밀번호 초기화
|
||||
|
||||
// 부서 관리 API
|
||||
router.get("/departments", getDepartmentList); // 부서 목록 조회
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
version: '3.8'
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
# Next.js 프론트엔드만
|
||||
|
|
@ -22,4 +22,3 @@ services:
|
|||
networks:
|
||||
pms-network:
|
||||
driver: bridge
|
||||
external: true
|
||||
|
|
@ -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>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ export function UserManagement() {
|
|||
|
||||
// 비밀번호 초기화 성공 핸들러
|
||||
const handlePasswordResetSuccess = () => {
|
||||
refreshData(); // 목록 새로고침
|
||||
// refreshData(); // 비밀번호 변경은 목록에 영향을 주지 않으므로 새로고침 불필요
|
||||
handlePasswordResetClose();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
// import { toast } from "react-hot-toast"; // 라이브러리 미설치로 alert 사용
|
||||
import { userAPI } from "@/lib/api/user";
|
||||
import { AlertModal, AlertType } from "@/components/common/AlertModal";
|
||||
|
||||
interface UserPasswordResetModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -24,6 +24,40 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu
|
|||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 알림 모달 상태
|
||||
const [alertState, setAlertState] = useState<{
|
||||
isOpen: boolean;
|
||||
type: AlertType;
|
||||
title: string;
|
||||
message: string;
|
||||
}>({
|
||||
isOpen: false,
|
||||
type: "info",
|
||||
title: "",
|
||||
message: "",
|
||||
});
|
||||
|
||||
// 알림 모달 표시 헬퍼 함수
|
||||
const showAlert = (type: AlertType, title: string, message: string) => {
|
||||
setAlertState({
|
||||
isOpen: true,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
});
|
||||
};
|
||||
|
||||
// 알림 모달 닫기
|
||||
const closeAlert = () => {
|
||||
setAlertState((prev) => ({ ...prev, isOpen: false }));
|
||||
|
||||
// 성공 알림이 닫힐 때 메인 모달도 닫기
|
||||
if (alertState.type === "success") {
|
||||
handleClose();
|
||||
onSuccess?.();
|
||||
}
|
||||
};
|
||||
|
||||
// 비밀번호 유효성 검사 (영문, 숫자, 특수문자만 허용)
|
||||
const validatePassword = (password: string) => {
|
||||
const regex = /^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?`~]*$/;
|
||||
|
|
@ -37,17 +71,17 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu
|
|||
// 초기화 핸들러
|
||||
const handleReset = useCallback(async () => {
|
||||
if (!userId || !newPassword.trim()) {
|
||||
alert("새 비밀번호를 입력해주세요.");
|
||||
showAlert("warning", "입력 필요", "새 비밀번호를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validatePassword(newPassword)) {
|
||||
alert("비밀번호는 영문, 숫자, 특수문자만 사용할 수 있습니다.");
|
||||
showAlert("warning", "형식 오류", "비밀번호는 영문, 숫자, 특수문자만 사용할 수 있습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
alert("비밀번호가 일치하지 않습니다.");
|
||||
showAlert("warning", "비밀번호 불일치", "비밀번호가 일치하지 않습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -60,15 +94,14 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu
|
|||
});
|
||||
|
||||
if (response.success) {
|
||||
alert("비밀번호가 성공적으로 초기화되었습니다.");
|
||||
handleClose();
|
||||
onSuccess?.();
|
||||
showAlert("success", "초기화 완료", "비밀번호가 성공적으로 초기화되었습니다.");
|
||||
// 성공 알림은 사용자가 확인 버튼을 눌러서 닫도록 함
|
||||
} else {
|
||||
alert(response.message || "비밀번호 초기화에 실패했습니다.");
|
||||
showAlert("error", "초기화 실패", response.message || "비밀번호 초기화에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("비밀번호 초기화 오류:", error);
|
||||
alert("비밀번호 초기화 중 오류가 발생했습니다.");
|
||||
showAlert("error", "시스템 오류", "비밀번호 초기화 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
|
@ -183,6 +216,15 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu
|
|||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
{/* 알림 모달 */}
|
||||
<AlertModal
|
||||
isOpen={alertState.isOpen}
|
||||
onClose={closeAlert}
|
||||
type={alertState.type}
|
||||
title={alertState.title}
|
||||
message={alertState.message}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { User } from "@/types/user";
|
||||
import { User, USER_STATUS_LABELS } from "@/types/user";
|
||||
|
||||
interface UserStatusConfirmDialogProps {
|
||||
user: User | null;
|
||||
|
|
@ -29,8 +29,12 @@ export function UserStatusConfirmDialog({
|
|||
}: UserStatusConfirmDialogProps) {
|
||||
if (!user) return null;
|
||||
|
||||
const statusText = newStatus === "active" ? "활성" : "비활성";
|
||||
const statusColor = newStatus === "active" ? "text-blue-600" : "text-gray-600";
|
||||
// 현재 상태와 새로운 상태의 텍스트 및 색상
|
||||
const currentStatusText = USER_STATUS_LABELS[user.status as keyof typeof USER_STATUS_LABELS] || user.status;
|
||||
const newStatusText = USER_STATUS_LABELS[newStatus as keyof typeof USER_STATUS_LABELS] || newStatus;
|
||||
|
||||
const currentStatusColor = user.status === "active" ? "text-blue-600" : "text-gray-600";
|
||||
const newStatusColor = newStatus === "active" ? "text-blue-600" : "text-gray-600";
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
|
||||
|
|
@ -41,16 +45,20 @@ export function UserStatusConfirmDialog({
|
|||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm">사용자:</span>
|
||||
<span className="text-muted-foreground w-16 text-sm">사용자:</span>
|
||||
<span className="font-medium">
|
||||
{user.user_name} ({user.user_id})
|
||||
{user.userName} ({user.userId})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm">변경할 상태:</span>
|
||||
<span className={`font-medium ${statusColor}`}>{statusText}</span>
|
||||
<span className="text-muted-foreground w-16 text-sm">상태 변경:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-medium ${currentStatusColor}`}>{currentStatusText}</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className={`font-medium ${newStatusColor}`}>{newStatusText}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Key, FileText, History } from "lucide-react";
|
||||
import { Key, History } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { User } from "@/types/user";
|
||||
import { USER_TABLE_COLUMNS } from "@/constants/user";
|
||||
|
|
@ -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
|
||||
|
|
@ -198,11 +198,6 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on
|
|||
onCheckedChange={(checked) => handleStatusToggle(user, checked)}
|
||||
aria-label={`${user.userName} 상태 토글`}
|
||||
/>
|
||||
<span
|
||||
className={`text-sm font-medium ${user.status === "active" ? "text-blue-600" : "text-gray-500"}`}
|
||||
>
|
||||
{user.status === "active" ? "활성" : "비활성"}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { Search, Plus } from "lucide-react";
|
||||
import { Search, Plus, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { UserSearchFilter } from "@/types/user";
|
||||
import { SEARCH_OPTIONS } from "@/constants/user";
|
||||
import { useState } from "react";
|
||||
|
||||
interface UserToolbarProps {
|
||||
searchFilter: UserSearchFilter;
|
||||
|
|
@ -15,7 +14,7 @@ interface UserToolbarProps {
|
|||
|
||||
/**
|
||||
* 사용자 관리 툴바 컴포넌트
|
||||
* 검색, 필터링, 액션 버튼들을 포함
|
||||
* 통합 검색 + 고급 검색 옵션 지원
|
||||
*/
|
||||
export function UserToolbar({
|
||||
searchFilter,
|
||||
|
|
@ -24,62 +23,197 @@ export function UserToolbar({
|
|||
onSearchChange,
|
||||
onCreateClick,
|
||||
}: UserToolbarProps) {
|
||||
const [showAdvancedSearch, setShowAdvancedSearch] = useState(false);
|
||||
|
||||
// 통합 검색어 변경
|
||||
const handleUnifiedSearchChange = (value: string) => {
|
||||
onSearchChange({
|
||||
searchValue: value,
|
||||
// 통합 검색 시 고급 검색 필드들 클리어
|
||||
searchType: undefined,
|
||||
search_sabun: undefined,
|
||||
search_companyName: undefined,
|
||||
search_deptName: undefined,
|
||||
search_positionName: undefined,
|
||||
search_userId: undefined,
|
||||
search_userName: undefined,
|
||||
search_tel: undefined,
|
||||
search_email: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
// 고급 검색 필드 변경
|
||||
const handleAdvancedSearchChange = (field: string, value: string) => {
|
||||
onSearchChange({
|
||||
[field]: value,
|
||||
// 고급 검색 시 통합 검색어 클리어
|
||||
searchValue: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
// 고급 검색 모드인지 확인
|
||||
const isAdvancedSearchMode = !!(
|
||||
searchFilter.search_sabun ||
|
||||
searchFilter.search_companyName ||
|
||||
searchFilter.search_deptName ||
|
||||
searchFilter.search_positionName ||
|
||||
searchFilter.search_userId ||
|
||||
searchFilter.search_userName ||
|
||||
searchFilter.search_tel ||
|
||||
searchFilter.search_email
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 검색 필터 영역 */}
|
||||
<div className="bg-muted/30 flex flex-wrap gap-4 rounded-lg p-4">
|
||||
{/* 검색 대상 선택 */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">검색 대상</label>
|
||||
<Select
|
||||
value={searchFilter.searchType || "all"}
|
||||
onValueChange={(value) =>
|
||||
onSearchChange({
|
||||
searchType: value as (typeof SEARCH_OPTIONS)[number]["value"],
|
||||
searchValue: "", // 옵션 변경 시 항상 검색어 초기화
|
||||
})
|
||||
}
|
||||
{/* 메인 검색 영역 */}
|
||||
<div className="bg-muted/30 rounded-lg p-4">
|
||||
{/* 통합 검색 */}
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search
|
||||
className={`absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform ${
|
||||
isSearching ? "animate-pulse text-blue-500" : "text-muted-foreground"
|
||||
}`}
|
||||
/>
|
||||
<Input
|
||||
placeholder="통합 검색..."
|
||||
value={searchFilter.searchValue || ""}
|
||||
onChange={(e) => handleUnifiedSearchChange(e.target.value)}
|
||||
disabled={isAdvancedSearchMode}
|
||||
className={`pl-10 ${isSearching ? "border-blue-300 ring-1 ring-blue-200" : ""} ${
|
||||
isAdvancedSearchMode ? "bg-muted text-muted-foreground cursor-not-allowed" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{isSearching && <p className="mt-1 text-xs text-blue-500">검색 중...</p>}
|
||||
{isAdvancedSearchMode && (
|
||||
<p className="mt-1 text-xs text-amber-600">
|
||||
고급 검색 모드가 활성화되어 있습니다. 통합 검색을 사용하려면 고급 검색 조건을 초기화하세요.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 고급 검색 토글 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvancedSearch(!showAdvancedSearch)}
|
||||
className="gap-2"
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SEARCH_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
🔍 고급 검색
|
||||
{showAdvancedSearch ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 검색어 입력 */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">
|
||||
검색어
|
||||
{isSearching && <span className="ml-1 text-xs text-blue-500">(검색 중...)</span>}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search
|
||||
className={`absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform ${
|
||||
isSearching ? "animate-pulse text-blue-500" : "text-muted-foreground"
|
||||
}`}
|
||||
/>
|
||||
<Input
|
||||
placeholder={
|
||||
(searchFilter.searchType || "all") === "all"
|
||||
? "전체 목록을 조회합니다"
|
||||
: `${SEARCH_OPTIONS.find((opt) => opt.value === (searchFilter.searchType || "all"))?.label || "전체"}을 입력하세요`
|
||||
}
|
||||
value={searchFilter.searchValue || ""}
|
||||
onChange={(e) => onSearchChange({ searchValue: e.target.value })}
|
||||
disabled={(searchFilter.searchType || "all") === "all"}
|
||||
className={`w-[300px] pl-10 ${isSearching ? "border-blue-300 ring-1 ring-blue-200" : ""} ${
|
||||
(searchFilter.searchType || "all") === "all" ? "bg-muted text-muted-foreground cursor-not-allowed" : ""
|
||||
}`}
|
||||
/>
|
||||
{/* 고급 검색 옵션 */}
|
||||
{showAdvancedSearch && (
|
||||
<div className="border-t pt-4">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-medium">고급 검색 옵션</h4>
|
||||
<span className="text-muted-foreground text-xs">(각 필드별로 개별 검색 조건을 설정할 수 있습니다)</span>
|
||||
</div>
|
||||
|
||||
{/* 고급 검색 필드들 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<label className="text-muted-foreground mb-1 block text-xs font-medium">사번</label>
|
||||
<Input
|
||||
placeholder="사번 검색"
|
||||
value={searchFilter.search_sabun || ""}
|
||||
onChange={(e) => handleAdvancedSearchChange("search_sabun", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-muted-foreground mb-1 block text-xs font-medium">회사명</label>
|
||||
<Input
|
||||
placeholder="회사명 검색"
|
||||
value={searchFilter.search_companyName || ""}
|
||||
onChange={(e) => handleAdvancedSearchChange("search_companyName", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-muted-foreground mb-1 block text-xs font-medium">부서명</label>
|
||||
<Input
|
||||
placeholder="부서명 검색"
|
||||
value={searchFilter.search_deptName || ""}
|
||||
onChange={(e) => handleAdvancedSearchChange("search_deptName", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-muted-foreground mb-1 block text-xs font-medium">직책</label>
|
||||
<Input
|
||||
placeholder="직책 검색"
|
||||
value={searchFilter.search_positionName || ""}
|
||||
onChange={(e) => handleAdvancedSearchChange("search_positionName", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-muted-foreground mb-1 block text-xs font-medium">사용자 ID</label>
|
||||
<Input
|
||||
placeholder="사용자 ID 검색"
|
||||
value={searchFilter.search_userId || ""}
|
||||
onChange={(e) => handleAdvancedSearchChange("search_userId", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-muted-foreground mb-1 block text-xs font-medium">사용자명</label>
|
||||
<Input
|
||||
placeholder="사용자명 검색"
|
||||
value={searchFilter.search_userName || ""}
|
||||
onChange={(e) => handleAdvancedSearchChange("search_userName", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-muted-foreground mb-1 block text-xs font-medium">전화번호</label>
|
||||
<Input
|
||||
placeholder="전화번호/휴대폰 검색"
|
||||
value={searchFilter.search_tel || ""}
|
||||
onChange={(e) => handleAdvancedSearchChange("search_tel", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-muted-foreground mb-1 block text-xs font-medium">이메일</label>
|
||||
<Input
|
||||
placeholder="이메일 검색"
|
||||
value={searchFilter.search_email || ""}
|
||||
onChange={(e) => handleAdvancedSearchChange("search_email", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 고급 검색 초기화 버튼 */}
|
||||
{isAdvancedSearchMode && (
|
||||
<div className="mt-4 border-t pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
onSearchChange({
|
||||
search_sabun: undefined,
|
||||
search_companyName: undefined,
|
||||
search_deptName: undefined,
|
||||
search_positionName: undefined,
|
||||
search_userId: undefined,
|
||||
search_userName: undefined,
|
||||
search_tel: undefined,
|
||||
search_email: undefined,
|
||||
})
|
||||
}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
고급 검색 조건 초기화
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 영역 */}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CheckCircle, XCircle, AlertTriangle, Info } from "lucide-react";
|
||||
|
||||
export type AlertType = "success" | "error" | "warning" | "info";
|
||||
|
||||
interface AlertModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
type: AlertType;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
onConfirm?: () => void;
|
||||
}
|
||||
|
||||
const alertConfig = {
|
||||
success: {
|
||||
icon: CheckCircle,
|
||||
iconColor: "text-green-500",
|
||||
titleColor: "text-green-700",
|
||||
},
|
||||
error: {
|
||||
icon: XCircle,
|
||||
iconColor: "text-red-500",
|
||||
titleColor: "text-red-700",
|
||||
},
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
iconColor: "text-yellow-500",
|
||||
titleColor: "text-yellow-700",
|
||||
},
|
||||
info: {
|
||||
icon: Info,
|
||||
iconColor: "text-blue-500",
|
||||
titleColor: "text-blue-700",
|
||||
},
|
||||
};
|
||||
|
||||
export function AlertModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
confirmText = "확인",
|
||||
onConfirm,
|
||||
}: AlertModalProps) {
|
||||
const config = alertConfig[type];
|
||||
const IconComponent = config.icon;
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (onConfirm) {
|
||||
onConfirm();
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<IconComponent className={`h-6 w-6 ${config.iconColor}`} />
|
||||
<DialogTitle className={config.titleColor}>{title}</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription className="text-left">{message}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={handleConfirm} className="w-full">
|
||||
{confirmText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// 편의를 위한 래퍼 함수들
|
||||
export const SuccessModal = (props: Omit<AlertModalProps, "type">) => <AlertModal {...props} type="success" />;
|
||||
|
||||
export const ErrorModal = (props: Omit<AlertModalProps, "type">) => <AlertModal {...props} type="error" />;
|
||||
|
||||
export const WarningModal = (props: Omit<AlertModalProps, "type">) => <AlertModal {...props} type="warning" />;
|
||||
|
||||
export const InfoModal = (props: Omit<AlertModalProps, "type">) => <AlertModal {...props} type="info" />;
|
||||
|
|
@ -44,7 +44,7 @@ const DialogContent = React.forwardRef<
|
|||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none cursor-pointer">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
|
|
|
|||
|
|
@ -15,20 +15,85 @@ export const useUserManagement = () => {
|
|||
// 검색 필터 상태
|
||||
const [searchFilter, setSearchFilter] = useState<UserSearchFilter>({});
|
||||
|
||||
// 검색어만 디바운싱 (500ms 지연) - searchType은 즉시 반영
|
||||
// 통합 검색어 디바운싱 (500ms 지연)
|
||||
const debouncedSearchValue = useDebounce(searchFilter.searchValue || "", 500);
|
||||
|
||||
// 고급 검색 필드들 디바운싱
|
||||
const debouncedSabun = useDebounce(searchFilter.search_sabun || "", 500);
|
||||
const debouncedCompanyName = useDebounce(searchFilter.search_companyName || "", 500);
|
||||
const debouncedDeptName = useDebounce(searchFilter.search_deptName || "", 500);
|
||||
const debouncedPositionName = useDebounce(searchFilter.search_positionName || "", 500);
|
||||
const debouncedUserId = useDebounce(searchFilter.search_userId || "", 500);
|
||||
const debouncedUserName = useDebounce(searchFilter.search_userName || "", 500);
|
||||
const debouncedTel = useDebounce(searchFilter.search_tel || "", 500);
|
||||
const debouncedEmail = useDebounce(searchFilter.search_email || "", 500);
|
||||
|
||||
// 디바운싱된 검색 필터 (useMemo로 최적화)
|
||||
const debouncedSearchFilter = useMemo(
|
||||
() => ({
|
||||
// 통합 검색
|
||||
searchValue: debouncedSearchValue,
|
||||
|
||||
// 고급 검색
|
||||
search_sabun: debouncedSabun,
|
||||
search_companyName: debouncedCompanyName,
|
||||
search_deptName: debouncedDeptName,
|
||||
search_positionName: debouncedPositionName,
|
||||
search_userId: debouncedUserId,
|
||||
search_userName: debouncedUserName,
|
||||
search_tel: debouncedTel,
|
||||
search_email: debouncedEmail,
|
||||
|
||||
// 하위 호환성
|
||||
searchType: searchFilter.searchType || "all",
|
||||
}),
|
||||
[debouncedSearchValue, searchFilter.searchType],
|
||||
[
|
||||
debouncedSearchValue,
|
||||
debouncedSabun,
|
||||
debouncedCompanyName,
|
||||
debouncedDeptName,
|
||||
debouncedPositionName,
|
||||
debouncedUserId,
|
||||
debouncedUserName,
|
||||
debouncedTel,
|
||||
debouncedEmail,
|
||||
searchFilter.searchType,
|
||||
],
|
||||
);
|
||||
|
||||
// 검색 중인지 확인 (검색어만 체크)
|
||||
const isSearching = (searchFilter.searchValue || "") !== debouncedSearchValue;
|
||||
// 검색 중인지 확인 (모든 검색 필드를 고려)
|
||||
const isSearching = useMemo(() => {
|
||||
return (
|
||||
(searchFilter.searchValue || "") !== debouncedSearchValue ||
|
||||
(searchFilter.search_sabun || "") !== debouncedSabun ||
|
||||
(searchFilter.search_companyName || "") !== debouncedCompanyName ||
|
||||
(searchFilter.search_deptName || "") !== debouncedDeptName ||
|
||||
(searchFilter.search_positionName || "") !== debouncedPositionName ||
|
||||
(searchFilter.search_userId || "") !== debouncedUserId ||
|
||||
(searchFilter.search_userName || "") !== debouncedUserName ||
|
||||
(searchFilter.search_tel || "") !== debouncedTel ||
|
||||
(searchFilter.search_email || "") !== debouncedEmail
|
||||
);
|
||||
}, [
|
||||
searchFilter.searchValue,
|
||||
debouncedSearchValue,
|
||||
searchFilter.search_sabun,
|
||||
debouncedSabun,
|
||||
searchFilter.search_companyName,
|
||||
debouncedCompanyName,
|
||||
searchFilter.search_deptName,
|
||||
debouncedDeptName,
|
||||
searchFilter.search_positionName,
|
||||
debouncedPositionName,
|
||||
searchFilter.search_userId,
|
||||
debouncedUserId,
|
||||
searchFilter.search_userName,
|
||||
debouncedUserName,
|
||||
searchFilter.search_tel,
|
||||
debouncedTel,
|
||||
searchFilter.search_email,
|
||||
debouncedEmail,
|
||||
]);
|
||||
|
||||
// 로딩 및 에러 상태
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
|
@ -39,52 +104,55 @@ export const useUserManagement = () => {
|
|||
const [pageSize, setPageSize] = useState(20);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
|
||||
// 사용자 목록 로드 (특정 검색 조건으로 호출)
|
||||
// 사용자 목록 로드 (새로운 통합 검색 방식)
|
||||
const loadUsers = useCallback(
|
||||
async (searchValue?: string, searchType?: string) => {
|
||||
async (filter?: UserSearchFilter) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 백엔드 API 호출
|
||||
// 검색 파라미터 구성 (단순 검색 방식)
|
||||
// 검색 파라미터 구성
|
||||
const searchParams: Record<string, string | number | undefined> = {
|
||||
page: currentPage,
|
||||
countPerPage: pageSize,
|
||||
};
|
||||
|
||||
// 검색어가 있고 searchType이 'all'이 아닐 때만 검색 파라미터 추가
|
||||
if (searchValue && searchValue.trim() && searchType && searchType !== "all") {
|
||||
const trimmedValue = searchValue.trim();
|
||||
// 검색 조건 추가
|
||||
if (filter) {
|
||||
// 통합 검색 (우선순위 최고)
|
||||
if (filter.searchValue && filter.searchValue.trim()) {
|
||||
searchParams.search = filter.searchValue.trim();
|
||||
}
|
||||
|
||||
// 각 검색 타입별로 해당하는 백엔드 파라미터 매핑 (백엔드 MyBatis와 정확히 일치)
|
||||
switch (searchType) {
|
||||
case "sabun":
|
||||
searchParams.search_sabun = trimmedValue;
|
||||
break;
|
||||
case "company_name":
|
||||
searchParams.search_companyName = trimmedValue; // MyBatis: search_companyName
|
||||
break;
|
||||
case "dept_name":
|
||||
searchParams.search_deptName = trimmedValue; // MyBatis: search_deptName
|
||||
break;
|
||||
case "position_name":
|
||||
searchParams.search_positionName = trimmedValue; // MyBatis: search_positionName
|
||||
break;
|
||||
case "user_id":
|
||||
searchParams.search_userId = trimmedValue; // MyBatis: search_userId
|
||||
break;
|
||||
case "user_name":
|
||||
searchParams.search_userName = trimmedValue; // MyBatis: search_userName
|
||||
break;
|
||||
case "tel":
|
||||
searchParams.search_tel = trimmedValue; // MyBatis: search_tel
|
||||
break;
|
||||
case "email":
|
||||
searchParams.search_email = trimmedValue; // MyBatis: search_email
|
||||
break;
|
||||
default:
|
||||
searchParams.search_userName = trimmedValue; // 기본값
|
||||
// 고급 검색 (개별 필드별)
|
||||
if (filter.search_sabun && filter.search_sabun.trim()) {
|
||||
searchParams.search_sabun = filter.search_sabun.trim();
|
||||
}
|
||||
if (filter.search_companyName && filter.search_companyName.trim()) {
|
||||
searchParams.search_companyName = filter.search_companyName.trim();
|
||||
}
|
||||
if (filter.search_deptName && filter.search_deptName.trim()) {
|
||||
searchParams.search_deptName = filter.search_deptName.trim();
|
||||
}
|
||||
if (filter.search_positionName && filter.search_positionName.trim()) {
|
||||
searchParams.search_positionName = filter.search_positionName.trim();
|
||||
}
|
||||
if (filter.search_userId && filter.search_userId.trim()) {
|
||||
searchParams.search_userId = filter.search_userId.trim();
|
||||
}
|
||||
if (filter.search_userName && filter.search_userName.trim()) {
|
||||
searchParams.search_userName = filter.search_userName.trim();
|
||||
}
|
||||
if (filter.search_tel && filter.search_tel.trim()) {
|
||||
searchParams.search_tel = filter.search_tel.trim();
|
||||
}
|
||||
if (filter.search_email && filter.search_email.trim()) {
|
||||
searchParams.search_email = filter.search_email.trim();
|
||||
}
|
||||
|
||||
// 하위 호환성: 기존 searchType/searchValue 방식 지원
|
||||
if (!filter.searchValue && filter.searchType && filter.searchType !== "all" && searchParams.searchValue) {
|
||||
// 기존 방식 변환은 일단 제거 (통합 검색과 고급 검색만 지원)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -125,30 +193,41 @@ export const useUserManagement = () => {
|
|||
loadUsers();
|
||||
}, [loadUsers]);
|
||||
|
||||
// 검색어 변경 시에만 API 호출 (검색어가 있고 'all'이 아닐 때)
|
||||
// 디바운싱된 검색 조건이 변경될 때마다 API 호출
|
||||
useEffect(() => {
|
||||
if (
|
||||
debouncedSearchFilter.searchValue &&
|
||||
debouncedSearchFilter.searchValue.trim() &&
|
||||
debouncedSearchFilter.searchType !== "all"
|
||||
) {
|
||||
loadUsers(debouncedSearchFilter.searchValue, debouncedSearchFilter.searchType);
|
||||
}
|
||||
}, [debouncedSearchFilter.searchValue, loadUsers]);
|
||||
|
||||
// '전체' 선택 시에만 즉시 반영
|
||||
useEffect(() => {
|
||||
if (searchFilter.searchType === "all" && !searchFilter.searchValue) {
|
||||
loadUsers(); // 전체 목록 로드 (검색 조건 없음)
|
||||
}
|
||||
}, [searchFilter.searchType, loadUsers]);
|
||||
loadUsers(debouncedSearchFilter);
|
||||
}, [
|
||||
debouncedSearchFilter.searchValue,
|
||||
debouncedSearchFilter.search_sabun,
|
||||
debouncedSearchFilter.search_companyName,
|
||||
debouncedSearchFilter.search_deptName,
|
||||
debouncedSearchFilter.search_positionName,
|
||||
debouncedSearchFilter.search_userId,
|
||||
debouncedSearchFilter.search_userName,
|
||||
debouncedSearchFilter.search_tel,
|
||||
debouncedSearchFilter.search_email,
|
||||
loadUsers,
|
||||
]);
|
||||
|
||||
// 검색 필터 업데이트
|
||||
const updateSearchFilter = useCallback((newFilter: Partial<UserSearchFilter>) => {
|
||||
setSearchFilter((prev) => ({ ...prev, ...newFilter }));
|
||||
|
||||
// searchType이 변경되거나 searchValue가 변경될 때만 첫 페이지로 이동
|
||||
if (newFilter.searchType !== undefined || newFilter.searchValue !== undefined) {
|
||||
// 검색 조건이 변경될 때마다 첫 페이지로 이동
|
||||
const hasSearchChange = !!(
|
||||
newFilter.searchValue !== undefined ||
|
||||
newFilter.search_sabun !== undefined ||
|
||||
newFilter.search_companyName !== undefined ||
|
||||
newFilter.search_deptName !== undefined ||
|
||||
newFilter.search_positionName !== undefined ||
|
||||
newFilter.search_userId !== undefined ||
|
||||
newFilter.search_userName !== undefined ||
|
||||
newFilter.search_tel !== undefined ||
|
||||
newFilter.search_email !== undefined ||
|
||||
newFilter.searchType !== undefined
|
||||
);
|
||||
|
||||
if (hasSearchChange) {
|
||||
setCurrentPage(1);
|
||||
}
|
||||
}, []);
|
||||
|
|
@ -181,10 +260,10 @@ export const useUserManagement = () => {
|
|||
// 사용자 상태 토글 핸들러
|
||||
const handleStatusToggle = useCallback(async (user: User, newStatus: string) => {
|
||||
try {
|
||||
console.log(`🎛️ 상태 변경: ${user.user_name} (${user.user_id}) → ${newStatus}`);
|
||||
console.log(`🎛️ 상태 변경: ${user.userName} (${user.userId}) → ${newStatus}`);
|
||||
|
||||
// 백엔드 API 호출
|
||||
const response = await userAPI.updateStatus(user.user_id, newStatus);
|
||||
const response = await userAPI.updateStatus(user.userId, newStatus);
|
||||
|
||||
// 백엔드 응답 구조: { result: boolean, msg: string }
|
||||
if (response && typeof response === "object" && "result" in response) {
|
||||
|
|
@ -194,7 +273,7 @@ export const useUserManagement = () => {
|
|||
console.log("✅ 상태 변경 성공:", apiResponse.msg);
|
||||
|
||||
// 전체 목록 새로고침 대신 개별 사용자 상태만 업데이트
|
||||
setUsers((prevUsers) => prevUsers.map((u) => (u.user_id === user.user_id ? { ...u, status: newStatus } : u)));
|
||||
setUsers((prevUsers) => prevUsers.map((u) => (u.userId === user.userId ? { ...u, status: newStatus } : u)));
|
||||
} else {
|
||||
console.error("❌ 상태 변경 실패:", apiResponse.msg);
|
||||
alert(apiResponse.msg || "상태 변경에 실패했습니다.");
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ interface ApiResponse<T> {
|
|||
message?: string;
|
||||
errorCode?: string;
|
||||
total?: number;
|
||||
searchType?: "unified" | "single" | "advanced" | "none"; // 검색 타입 정보
|
||||
pagination?: {
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
// 백엔드 호환성을 위한 추가 필드
|
||||
result?: boolean;
|
||||
msg?: string;
|
||||
|
|
@ -78,10 +84,10 @@ export async function createUser(userData: any) {
|
|||
// 사용자 수정 기능 제거됨
|
||||
|
||||
/**
|
||||
* 사용자 상태 변경
|
||||
* 사용자 상태 변경 (부분 수정)
|
||||
*/
|
||||
export async function updateUserStatus(userId: string, status: string) {
|
||||
const response = await apiClient.put(`/admin/users/${userId}/status`, { status });
|
||||
const response = await apiClient.patch(`/admin/users/${userId}/status`, { status });
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,8 +30,21 @@ export interface User {
|
|||
|
||||
// 사용자 검색 필터
|
||||
export interface UserSearchFilter {
|
||||
searchType?: "all" | "sabun" | "companyCode" | "deptName" | "positionName" | "userId" | "userName" | "tel" | "email"; // 검색 대상
|
||||
searchValue?: string; // 검색어
|
||||
// 통합 검색 (우선순위: 가장 높음)
|
||||
searchValue?: string; // 통합 검색어 (모든 필드 대상)
|
||||
|
||||
// 단일 필드 검색 (중간 우선순위)
|
||||
searchType?: "all" | "sabun" | "companyCode" | "deptName" | "positionName" | "userId" | "userName" | "tel" | "email"; // 검색 대상 (하위 호환성)
|
||||
|
||||
// 고급 검색 (개별 필드별 AND 조건)
|
||||
search_sabun?: string; // 사번 검색
|
||||
search_companyName?: string; // 회사명 검색
|
||||
search_deptName?: string; // 부서명 검색
|
||||
search_positionName?: string; // 직책 검색
|
||||
search_userId?: string; // 사용자 ID 검색
|
||||
search_userName?: string; // 사용자명 검색
|
||||
search_tel?: string; // 전화번호 검색 (TEL + CELL_PHONE)
|
||||
search_email?: string; // 이메일 검색
|
||||
}
|
||||
|
||||
// 사용자 목록 테이블 컬럼 정의
|
||||
|
|
|
|||
|
|
@ -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 응답 타입
|
||||
|
|
|
|||
|
|
@ -8,6 +8,14 @@ echo ""
|
|||
echo "🚀 백엔드(Node.js)와 프론트엔드(Next.js)를 순차적으로 시작합니다..."
|
||||
echo ""
|
||||
|
||||
# 기존 컨테이너 강제 삭제 (이름 충돌 방지)
|
||||
echo "============================================"
|
||||
echo "0. 기존 컨테이너 정리 중..."
|
||||
echo "============================================"
|
||||
docker rm -f pms-backend-mac pms-frontend-mac 2>/dev/null || echo "기존 컨테이너가 없습니다."
|
||||
docker network rm pms-network 2>/dev/null || echo "기존 네트워크가 없습니다."
|
||||
echo ""
|
||||
|
||||
# 백엔드 먼저 시작
|
||||
echo "============================================"
|
||||
echo "1. 백엔드 서비스 시작 중... (Node.js)"
|
||||
|
|
|
|||
Loading…
Reference in New Issue