사용자 관리 기능 구현 #3

Merged
hyeonsu merged 8 commits from userMng into dev 2025-08-26 14:34:20 +09:00
17 changed files with 1155 additions and 223 deletions

View File

@ -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: "비밀번호 초기화 중 시스템 오류가 발생했습니다.",
});
}
};

View File

@ -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); // 부서 목록 조회

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

@ -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

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

@ -84,7 +84,7 @@ export function UserManagement() {
// 비밀번호 초기화 성공 핸들러
const handlePasswordResetSuccess = () => {
refreshData(); // 목록 새로고침
// refreshData(); // 비밀번호 변경은 목록에 영향을 주지 않으므로 새로고침 불필요
handlePasswordResetClose();
};

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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>

View File

@ -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>
{/* 액션 버튼 영역 */}

View File

@ -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" />;

View File

@ -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>

View File

@ -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 || "상태 변경에 실패했습니다.");

View File

@ -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;
}

View File

@ -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; // 이메일 검색
}
// 사용자 목록 테이블 컬럼 정의

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 응답 타입

View File

@ -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)"