diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index dc8cf064..d7aa247d 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -2504,7 +2504,9 @@ export const changeUserStatus = async ( // 필수 파라미터 검증 if (!userId || !status) { res.status(400).json({ + success: false, result: false, + message: "사용자 ID와 상태는 필수입니다.", msg: "사용자 ID와 상태는 필수입니다.", }); return; @@ -2513,7 +2515,9 @@ export const changeUserStatus = async ( // 상태 값 검증 if (!["active", "inactive"].includes(status)) { res.status(400).json({ + success: false, result: false, + message: "유효하지 않은 상태값입니다. (active, inactive만 허용)", msg: "유효하지 않은 상태값입니다. (active, inactive만 허용)", }); return; @@ -2528,7 +2532,9 @@ export const changeUserStatus = async ( if (!currentUser) { res.status(404).json({ + success: false, result: false, + message: "사용자를 찾을 수 없습니다.", msg: "사용자를 찾을 수 없습니다.", }); return; @@ -2549,6 +2555,12 @@ export const changeUserStatus = async ( if (updateResult.length > 0) { // 사용자 이력 저장은 user_info_history 테이블이 @@ignore 상태이므로 생략 + // inactive로 변경 시 기존 JWT 토큰 무효화 + if (status === "inactive") { + const { TokenInvalidationService } = require("../services/tokenInvalidationService"); + await TokenInvalidationService.invalidateUserTokens(userId); + } + logger.info("사용자 상태 변경 성공", { userId, oldStatus: currentUser.status, @@ -2571,12 +2583,16 @@ export const changeUserStatus = async ( }); res.json({ + success: true, result: true, + message: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`, msg: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`, }); } else { res.status(400).json({ + success: false, result: false, + message: "사용자 상태 변경에 실패했습니다.", msg: "사용자 상태 변경에 실패했습니다.", }); } @@ -2587,7 +2603,9 @@ export const changeUserStatus = async ( status: req.body.status, }); res.status(500).json({ + success: false, result: false, + message: "시스템 오류가 발생했습니다.", msg: "시스템 오류가 발생했습니다.", }); } @@ -2627,12 +2645,214 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => { } } + // 추가 유효성 검증 + + // 1. email 형식 검증 (값이 있는 경우만) + if (userData.email && userData.email.trim() !== "") { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(userData.email.trim())) { + res.status(400).json({ + success: false, + message: "이메일 형식이 올바르지 않습니다.", + error: { + code: "INVALID_EMAIL_FORMAT", + details: `Invalid email format: ${userData.email}`, + }, + }); + return; + } + } + + // 2. companyCode 존재 확인 (값이 있는 경우만) + if (userData.companyCode && userData.companyCode.trim() !== "") { + const companyExists = await queryOne<{ company_code: string }>( + `SELECT company_code FROM company_mng WHERE company_code = $1`, + [userData.companyCode.trim()] + ); + if (!companyExists) { + res.status(400).json({ + success: false, + message: `존재하지 않는 회사 코드입니다: ${userData.companyCode}`, + error: { + code: "INVALID_COMPANY_CODE", + details: `Company code not found: ${userData.companyCode}`, + }, + }); + return; + } + } + + // 3. userType 유효값 검증 (값이 있는 경우만) + if (userData.userType && userData.userType.trim() !== "") { + const validUserTypes = ["SUPER_ADMIN", "COMPANY_ADMIN", "USER", "GUEST", "PARTNER"]; + if (!validUserTypes.includes(userData.userType.trim())) { + res.status(400).json({ + success: false, + message: `유효하지 않은 사용자 유형입니다: ${userData.userType}. 허용값: ${validUserTypes.join(", ")}`, + error: { + code: "INVALID_USER_TYPE", + details: `Invalid userType: ${userData.userType}. Allowed: ${validUserTypes.join(", ")}`, + }, + }); + return; + } + } + + // 4. 비밀번호 최소 길이 검증 (신규 등록 시) + if (!isUpdate && userData.userPassword && userData.userPassword.length < 4) { + res.status(400).json({ + success: false, + message: "비밀번호는 최소 4자 이상이어야 합니다.", + error: { + code: "PASSWORD_TOO_SHORT", + details: "Password must be at least 4 characters long", + }, + }); + return; + } + // 비밀번호 암호화 (비밀번호가 제공된 경우에만) let encryptedPassword = null; if (userData.userPassword) { encryptedPassword = await EncryptUtil.encrypt(userData.userPassword); } + // PUT(수정) 요청 시 company_code / dept_code 변경 감지 + if (isUpdate) { + const existingUser = await queryOne<{ company_code: string; dept_code: string }>( + `SELECT company_code, dept_code FROM user_info WHERE user_id = $1`, + [userData.userId] + ); + + // company_code 변경 감지 → 이전 회사 권한 그룹 제거 + if ( + userData.companyCode && + existingUser && + existingUser.company_code && + existingUser.company_code !== userData.companyCode + ) { + const oldCompanyCode = existingUser.company_code; + logger.info("사용자 회사 코드 변경 감지 - 이전 회사 권한 그룹 제거", { + userId: userData.userId, + oldCompanyCode, + newCompanyCode: userData.companyCode, + }); + + // 이전 회사의 권한 그룹에서 해당 사용자 제거 + await query( + `DELETE FROM authority_sub_user + WHERE user_id = $1 + AND master_objid IN ( + SELECT objid FROM authority_master WHERE company_code = $2 + )`, + [userData.userId, oldCompanyCode] + ); + } + + // dept_code 변경 감지 → 결재 템플릿/진행중 결재라인 경고 로그 + const newDeptCode = userData.deptCode || null; + const oldDeptCode = existingUser?.dept_code || null; + if (existingUser && oldDeptCode && newDeptCode && oldDeptCode !== newDeptCode) { + logger.warn("사용자 부서 변경 감지 - 결재라인 영향 확인 시작", { + userId: userData.userId, + userName: userData.userName, + oldDeptCode, + newDeptCode, + }); + + try { + // 1) 결재선 템플릿 스텝에서 해당 사용자가 결재자로 등록된 건 조회 + const templateSteps = await query<{ + template_id: number; + step_order: number; + approver_label: string | null; + approver_dept_code: string | null; + }>( + `SELECT s.template_id, s.step_order, s.approver_label, s.approver_dept_code + FROM approval_line_template_steps s + WHERE s.approver_user_id = $1`, + [userData.userId] + ); + + if (templateSteps && templateSteps.length > 0) { + logger.warn( + `[결재라인 경고] 부서 변경된 사용자(${userData.userId})가 결재선 템플릿 ${templateSteps.length}건에 결재자로 등록되어 있습니다. 수동 확인이 필요합니다.`, + { + userId: userData.userId, + oldDeptCode, + newDeptCode, + affectedTemplates: templateSteps.map((s) => ({ + templateId: s.template_id, + stepOrder: s.step_order, + label: s.approver_label, + currentDeptInStep: s.approver_dept_code, + })), + } + ); + } + + // 2) 진행중인 결재 요청에서 해당 사용자가 대기중 결재자인 건 조회 + const pendingLines = await query<{ + request_id: number; + step_order: number; + approver_dept: string | null; + status: string; + }>( + `SELECT l.request_id, l.step_order, l.approver_dept, l.status + FROM approval_lines l + JOIN approval_requests r ON r.request_id = l.request_id + WHERE l.approver_id = $1 + AND l.status = 'pending' + AND r.status IN ('in_progress', 'pending')`, + [userData.userId] + ); + + if (pendingLines && pendingLines.length > 0) { + logger.warn( + `[결재라인 경고] 부서 변경된 사용자(${userData.userId})에게 대기중인 결재 ${pendingLines.length}건이 있습니다. 수동 확인이 필요합니다.`, + { + userId: userData.userId, + oldDeptCode, + newDeptCode, + pendingApprovals: pendingLines.map((l) => ({ + requestId: l.request_id, + stepOrder: l.step_order, + currentDeptInLine: l.approver_dept, + })), + } + ); + } + + // 감사 로그 기록 + auditLogService.log({ + companyCode: userData.companyCode || req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "DEPT_CHANGE_WARNING", + resourceType: "USER", + resourceId: userData.userId, + resourceName: userData.userName, + summary: `사용자 "${userData.userName}"의 부서 변경 (${oldDeptCode} → ${newDeptCode}). 결재 템플릿 ${templateSteps?.length || 0}건, 대기중 결재 ${pendingLines?.length || 0}건 영향 가능`, + changes: { + before: { deptCode: oldDeptCode }, + after: { + deptCode: newDeptCode, + affectedTemplateCount: templateSteps?.length || 0, + pendingApprovalCount: pendingLines?.length || 0, + }, + }, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + } catch (approvalCheckError) { + // 결재 테이블이 없는 환경에서도 사용자 저장은 계속 진행 + logger.warn("결재라인 영향 확인 중 오류 (사용자 저장은 계속 진행)", { + error: approvalCheckError instanceof Error ? approvalCheckError.message : approvalCheckError, + }); + } + } + } + // Raw Query를 사용한 사용자 저장 (upsert with ON CONFLICT) const updatePasswordClause = encryptedPassword ? "user_password = $4," : ""; @@ -2688,6 +2908,12 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => { savedUser.regdate && new Date(savedUser.regdate).getTime() < Date.now() - 1000; + // 기존 사용자의 비밀번호 변경 시 JWT 토큰 무효화 + if (encryptedPassword && isExistingUser) { + const { TokenInvalidationService } = require("../services/tokenInvalidationService"); + await TokenInvalidationService.invalidateUserTokens(userData.userId); + } + logger.info( isExistingUser ? "사용자 정보 수정 완료" : "새 사용자 등록 완료", { @@ -3534,6 +3760,10 @@ export const resetUserPassword = async ( if (updateResult.length > 0) { // 이력 저장은 user_info_history 테이블이 @@ignore 상태이므로 생략 + // 비밀번호 변경 후 기존 JWT 토큰 무효화 + const { TokenInvalidationService } = require("../services/tokenInvalidationService"); + await TokenInvalidationService.invalidateUserTokens(userId); + logger.info("비밀번호 초기화 성공", { userId, updatedBy: req.user?.userId, @@ -4153,6 +4383,140 @@ export const saveUserWithDept = async ( * GET /api/admin/users/:userId/with-dept * 사원 + 부서 정보 조회 API (수정 모달용) */ +/** + * DELETE /api/admin/users/:userId + * 사용자 삭제 API (soft delete) + * status = 'deleted', end_date = now() 설정 + * authority_sub_user 멤버십 제거, JWT 토큰 무효화 + */ +export const deleteUser = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { userId } = req.params; + + // 1. userId 파라미터 검증 + if (!userId) { + res.status(400).json({ + success: false, + result: false, + message: "사용자 ID는 필수입니다.", + }); + return; + } + + // 2. 자기 자신 삭제 방지 + if (req.user?.userId === userId) { + res.status(400).json({ + success: false, + result: false, + message: "자기 자신은 삭제할 수 없습니다.", + }); + return; + } + + // 3. 사용자 존재 여부 확인 + const currentUser = await queryOne( + `SELECT user_id, user_name, status, company_code FROM user_info WHERE user_id = $1`, + [userId] + ); + + if (!currentUser) { + res.status(404).json({ + success: false, + result: false, + message: "사용자를 찾을 수 없습니다.", + }); + return; + } + + // 이미 삭제된 사용자 체크 + if (currentUser.status === "deleted") { + res.status(400).json({ + success: false, + result: false, + message: "이미 삭제된 사용자입니다.", + }); + return; + } + + // 4. soft delete: status = 'deleted', end_date = now() + const updateResult = await query( + `UPDATE user_info + SET status = 'deleted', end_date = NOW() + WHERE user_id = $1 + RETURNING *`, + [userId] + ); + + if (updateResult.length === 0) { + res.status(500).json({ + success: false, + result: false, + message: "사용자 삭제에 실패했습니다.", + }); + return; + } + + // 5. authority_sub_user에서 해당 사용자 멤버십 제거 + await query( + `DELETE FROM authority_sub_user WHERE user_id = $1`, + [userId] + ); + + // 6. JWT 토큰 무효화 + try { + const { TokenInvalidationService } = require("../services/tokenInvalidationService"); + await TokenInvalidationService.invalidateUserTokens(userId); + } catch (tokenError) { + logger.warn("토큰 무효화 중 오류 (삭제는 정상 처리됨)", { userId, error: tokenError }); + } + + logger.info("사용자 삭제(soft delete) 성공", { + userId, + userName: currentUser.user_name, + deletedBy: req.user?.userId, + }); + + // 7. 감사 로그 기록 + auditLogService.log({ + companyCode: currentUser.company_code || req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "DELETE", + resourceType: "USER", + resourceId: userId, + resourceName: currentUser.user_name, + summary: `사용자 "${currentUser.user_name}" (${userId}) 삭제 처리`, + changes: { + before: { status: currentUser.status }, + after: { status: "deleted" }, + fields: ["status", "end_date"], + }, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + + // 8. 응답 + res.json({ + success: true, + result: true, + message: `사용자 "${currentUser.user_name}" (${userId})이(가) 삭제되었습니다.`, + }); + } catch (error: any) { + logger.error("사용자 삭제 중 오류 발생", { + error: error.message, + userId: req.params.userId, + }); + res.status(500).json({ + success: false, + result: false, + message: "시스템 오류가 발생했습니다.", + }); + } +}; + export const getUserWithDept = async ( req: AuthenticatedRequest, res: Response diff --git a/backend-node/src/controllers/multilangController.ts b/backend-node/src/controllers/multilangController.ts index f14fc3b5..62451708 100644 --- a/backend-node/src/controllers/multilangController.ts +++ b/backend-node/src/controllers/multilangController.ts @@ -191,18 +191,30 @@ export const getLangKeys = async ( ): Promise => { try { const { companyCode, menuCode, keyType, searchText, categoryId } = req.query; + const userCompanyCode = req.user?.companyCode; logger.info("다국어 키 목록 조회 요청", { query: req.query, user: req.user, }); + // company_code 필터링: 비관리자는 자기 회사 + 공통(*) 키만 조회 가능 + let effectiveCompanyCode = companyCode as string; + if (userCompanyCode !== "*") { + // 비관리자가 다른 회사의 데이터를 요청하면 자기 회사로 제한 + if (companyCode && companyCode !== userCompanyCode && companyCode !== "*") { + effectiveCompanyCode = userCompanyCode || ""; + } + } + const multiLangService = new MultiLangService(); const langKeys = await multiLangService.getLangKeys({ - companyCode: companyCode as string, + companyCode: effectiveCompanyCode, menuCode: menuCode as string, keyType: keyType as string, searchText: searchText as string, categoryId: categoryId ? parseInt(categoryId as string, 10) : undefined, + // 비관리자: companyCode 필터가 없으면 자기 회사 + 공통(*) 키만 반환 + userCompanyCode: userCompanyCode, }); const response: ApiResponse = { @@ -235,9 +247,24 @@ export const getLangTexts = async ( ): Promise => { try { const { keyId } = req.params; + const userCompanyCode = req.user?.companyCode; logger.info("다국어 텍스트 조회 요청", { keyId, user: req.user }); const multiLangService = new MultiLangService(); + + // 비관리자: 해당 키가 자기 회사 또는 공통(*) 키인지 검증 + if (userCompanyCode !== "*") { + const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId)); + if (keyOwner && keyOwner !== "*" && keyOwner !== userCompanyCode) { + res.status(403).json({ + success: false, + message: "다른 회사의 다국어 키에 접근할 권한이 없습니다.", + error: { code: "PERMISSION_DENIED" }, + }); + return; + } + } + const langTexts = await multiLangService.getLangTexts(parseInt(keyId)); const response: ApiResponse = { @@ -270,6 +297,7 @@ export const createLangKey = async ( ): Promise => { try { const keyData: CreateLangKeyRequest = req.body; + const userCompanyCode = req.user?.companyCode; logger.info("다국어 키 생성 요청", { keyData, user: req.user }); // 필수 입력값 검증 @@ -285,6 +313,26 @@ export const createLangKey = async ( return; } + // 권한 검사: 공통 키(*)는 최고 관리자만 생성 가능 + if (keyData.companyCode === "*" && userCompanyCode !== "*") { + res.status(403).json({ + success: false, + message: "공통 키는 최고 관리자만 생성할 수 있습니다.", + error: { code: "PERMISSION_DENIED" }, + }); + return; + } + + // 비관리자: 자기 회사 키만 생성 가능 + if (userCompanyCode !== "*" && keyData.companyCode !== userCompanyCode) { + res.status(403).json({ + success: false, + message: "다른 회사의 키를 생성할 권한이 없습니다.", + error: { code: "PERMISSION_DENIED" }, + }); + return; + } + const multiLangService = new MultiLangService(); const keyId = await multiLangService.createLangKey({ ...keyData, @@ -323,10 +371,33 @@ export const updateLangKey = async ( try { const { keyId } = req.params; const keyData: UpdateLangKeyRequest = req.body; + const userCompanyCode = req.user?.companyCode; logger.info("다국어 키 수정 요청", { keyId, keyData, user: req.user }); const multiLangService = new MultiLangService(); + + // 비관리자: 해당 키가 자기 회사 키인지 검증 (공통 키는 수정 불가) + if (userCompanyCode !== "*") { + const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId)); + if (!keyOwner) { + res.status(404).json({ + success: false, + message: "다국어 키를 찾을 수 없습니다.", + error: { code: "KEY_NOT_FOUND" }, + }); + return; + } + if (keyOwner !== userCompanyCode) { + res.status(403).json({ + success: false, + message: "다른 회사의 다국어 키를 수정할 권한이 없습니다.", + error: { code: "PERMISSION_DENIED" }, + }); + return; + } + } + await multiLangService.updateLangKey(parseInt(keyId), { ...keyData, updatedBy: req.user?.userId || "system", @@ -362,9 +433,32 @@ export const deleteLangKey = async ( ): Promise => { try { const { keyId } = req.params; + const userCompanyCode = req.user?.companyCode; logger.info("다국어 키 삭제 요청", { keyId, user: req.user }); const multiLangService = new MultiLangService(); + + // 비관리자: 해당 키가 자기 회사 키인지 검증 (공통 키 삭제 불가) + if (userCompanyCode !== "*") { + const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId)); + if (!keyOwner) { + res.status(404).json({ + success: false, + message: "다국어 키를 찾을 수 없습니다.", + error: { code: "KEY_NOT_FOUND" }, + }); + return; + } + if (keyOwner !== userCompanyCode) { + res.status(403).json({ + success: false, + message: "다른 회사의 다국어 키를 삭제할 권한이 없습니다.", + error: { code: "PERMISSION_DENIED" }, + }); + return; + } + } + await multiLangService.deleteLangKey(parseInt(keyId)); const response: ApiResponse = { @@ -397,9 +491,32 @@ export const toggleLangKey = async ( ): Promise => { try { const { keyId } = req.params; + const userCompanyCode = req.user?.companyCode; logger.info("다국어 키 상태 토글 요청", { keyId, user: req.user }); const multiLangService = new MultiLangService(); + + // 비관리자: 해당 키가 자기 회사 키인지 검증 + if (userCompanyCode !== "*") { + const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId)); + if (!keyOwner) { + res.status(404).json({ + success: false, + message: "다국어 키를 찾을 수 없습니다.", + error: { code: "KEY_NOT_FOUND" }, + }); + return; + } + if (keyOwner !== userCompanyCode) { + res.status(403).json({ + success: false, + message: "다른 회사의 다국어 키를 변경할 권한이 없습니다.", + error: { code: "PERMISSION_DENIED" }, + }); + return; + } + } + const result = await multiLangService.toggleLangKey(parseInt(keyId)); const response: ApiResponse = { @@ -433,6 +550,7 @@ export const saveLangTexts = async ( try { const { keyId } = req.params; const textData: SaveLangTextsRequest = req.body; + const userCompanyCode = req.user?.companyCode; logger.info("다국어 텍스트 저장 요청", { keyId, textData, user: req.user }); @@ -454,6 +572,28 @@ export const saveLangTexts = async ( } const multiLangService = new MultiLangService(); + + // 비관리자: 해당 키가 자기 회사 또는 공통(*) 키인지 검증 + if (userCompanyCode !== "*") { + const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId)); + if (!keyOwner) { + res.status(404).json({ + success: false, + message: "다국어 키를 찾을 수 없습니다.", + error: { code: "KEY_NOT_FOUND" }, + }); + return; + } + if (keyOwner !== userCompanyCode) { + res.status(403).json({ + success: false, + message: "다른 회사의 다국어 텍스트를 수정할 권한이 없습니다.", + error: { code: "PERMISSION_DENIED" }, + }); + return; + } + } + await multiLangService.saveLangTexts(parseInt(keyId), { texts: textData.texts.map((text) => ({ ...text, diff --git a/backend-node/src/controllers/roleController.ts b/backend-node/src/controllers/roleController.ts index 06f72f31..29bc4b0e 100644 --- a/backend-node/src/controllers/roleController.ts +++ b/backend-node/src/controllers/roleController.ts @@ -472,6 +472,10 @@ export const addRoleMembers = async ( req.user?.userId || "SYSTEM" ); + // 권한 변경된 사용자들의 JWT 토큰 무효화 + const { TokenInvalidationService } = require("../services/tokenInvalidationService"); + await TokenInvalidationService.invalidateMultipleUserTokens(userIds); + const response: ApiResponse = { success: true, message: "권한 그룹 멤버 추가 성공", @@ -568,6 +572,13 @@ export const updateRoleMembers = async ( ); } + // 권한 변경된 사용자들의 JWT 토큰 무효화 + const allAffectedUsers = [...new Set([...toAdd, ...toRemove])]; + if (allAffectedUsers.length > 0) { + const { TokenInvalidationService } = require("../services/tokenInvalidationService"); + await TokenInvalidationService.invalidateMultipleUserTokens(allAffectedUsers); + } + logger.info("권한 그룹 멤버 일괄 업데이트 성공", { masterObjid, added: toAdd.length, @@ -646,6 +657,10 @@ export const removeRoleMembers = async ( req.user?.userId || "SYSTEM" ); + // 권한 변경된 사용자들의 JWT 토큰 무효화 + const { TokenInvalidationService } = require("../services/tokenInvalidationService"); + await TokenInvalidationService.invalidateMultipleUserTokens(userIds); + const response: ApiResponse = { success: true, message: "권한 그룹 멤버 제거 성공", @@ -777,6 +792,18 @@ export const setMenuPermissions = async ( req.user?.userId || "SYSTEM" ); + // 해당 권한 그룹의 모든 멤버 JWT 토큰 무효화 + try { + const members = await RoleService.getRoleMembers(authObjid); + const memberIds = members.map((m: any) => m.userId); + if (memberIds.length > 0) { + const { TokenInvalidationService } = require("../services/tokenInvalidationService"); + await TokenInvalidationService.invalidateMultipleUserTokens(memberIds); + } + } catch (invalidateError) { + logger.warn("메뉴 권한 변경 후 토큰 무효화 실패 (권한 설정은 성공)", { invalidateError }); + } + const response: ApiResponse = { success: true, message: "메뉴 권한 설정 성공", diff --git a/backend-node/src/middleware/authMiddleware.ts b/backend-node/src/middleware/authMiddleware.ts index 938988b5..8dfe28b3 100644 --- a/backend-node/src/middleware/authMiddleware.ts +++ b/backend-node/src/middleware/authMiddleware.ts @@ -5,6 +5,7 @@ import { Request, Response, NextFunction } from "express"; import { JwtUtils } from "../utils/jwtUtils"; import { AuthenticatedRequest, PersonBean } from "../types/auth"; import { logger } from "../utils/logger"; +import { TokenInvalidationService } from "../services/tokenInvalidationService"; // AuthenticatedRequest 타입을 다른 모듈에서 사용할 수 있도록 re-export export { AuthenticatedRequest } from "../types/auth"; @@ -22,11 +23,11 @@ declare global { * JWT 토큰 검증 미들웨어 * 기존 세션 방식과 동일한 효과를 제공 */ -export const authenticateToken = ( +export const authenticateToken = async ( req: AuthenticatedRequest, res: Response, next: NextFunction -): void => { +): Promise => { try { // Authorization 헤더에서 토큰 추출 const authHeader = req.get("Authorization"); @@ -46,6 +47,25 @@ export const authenticateToken = ( // JWT 토큰 검증 및 사용자 정보 추출 const userInfo: PersonBean = JwtUtils.verifyToken(token); + // token_version 검증 (JWT payload vs DB) + const decoded = JwtUtils.decodeToken(token); + const tokenVersion = decoded?.tokenVersion; + + // tokenVersion이 undefined면 구버전 토큰이므로 통과 (하위 호환) + if (tokenVersion !== undefined) { + const dbVersion = await TokenInvalidationService.getUserTokenVersion(userInfo.userId); + if (tokenVersion !== dbVersion) { + res.status(401).json({ + success: false, + error: { + code: "TOKEN_INVALIDATED", + details: "보안 정책에 의해 재로그인이 필요합니다.", + }, + }); + return; + } + } + // 요청 객체에 사용자 정보 설정 (기존 PersonBean과 동일) req.user = userInfo; @@ -173,11 +193,11 @@ export const requireUserOrAdmin = (targetUserId: string) => { * 토큰 갱신 미들웨어 * 토큰이 곧 만료될 경우 자동으로 갱신 */ -export const refreshTokenIfNeeded = ( +export const refreshTokenIfNeeded = async ( req: AuthenticatedRequest, res: Response, next: NextFunction -): void => { +): Promise => { try { const authHeader = req.get("Authorization"); const token = authHeader && authHeader.split(" ")[1]; @@ -191,6 +211,16 @@ export const refreshTokenIfNeeded = ( // 1시간(3600초) 이내에 만료되는 경우 갱신 if (timeUntilExpiry > 0 && timeUntilExpiry < 3600) { + // 갱신 전 token_version 검증 + if (decoded.tokenVersion !== undefined) { + const dbVersion = await TokenInvalidationService.getUserTokenVersion(decoded.userId); + if (decoded.tokenVersion !== dbVersion) { + // 무효화된 토큰은 갱신하지 않음 + next(); + return; + } + } + const newToken = JwtUtils.refreshToken(token); // 새로운 토큰을 응답 헤더에 포함 diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index a0779d50..d0ddbd6c 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -21,6 +21,7 @@ import { saveUser, // 사용자 등록/수정 saveUserWithDept, // 사원 + 부서 통합 저장 (NEW!) getUserWithDept, // 사원 + 부서 조회 (NEW!) + deleteUser, // 사용자 삭제 (soft delete) getCompanyList, getCompanyListFromDB, // 실제 DB에서 회사 목록 조회 getCompanyByCode, // 회사 단건 조회 @@ -62,6 +63,7 @@ router.put("/users/:userId", saveUser); // 사용자 수정 (REST API) router.put("/profile", updateProfile); // 프로필 수정 router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크 router.post("/users/reset-password", resetUserPassword); // 사용자 비밀번호 초기화 +router.delete("/users/:userId", deleteUser); // 사용자 삭제 (soft delete) // 부서 관리 API router.get("/departments", getDepartmentList); // 부서 목록 조회 diff --git a/backend-node/src/services/auditLogService.ts b/backend-node/src/services/auditLogService.ts index 82c2566e..d62d1d71 100644 --- a/backend-node/src/services/auditLogService.ts +++ b/backend-node/src/services/auditLogService.ts @@ -24,7 +24,8 @@ export type AuditAction = | "STATUS_CHANGE" | "BATCH_CREATE" | "BATCH_UPDATE" - | "BATCH_DELETE"; + | "BATCH_DELETE" + | "DEPT_CHANGE_WARNING"; export type AuditResourceType = | "MENU" diff --git a/backend-node/src/services/authService.ts b/backend-node/src/services/authService.ts index 5bbf3089..c83c5874 100644 --- a/backend-node/src/services/authService.ts +++ b/backend-node/src/services/authService.ts @@ -134,12 +134,14 @@ export class AuthService { company_code: string | null; locale: string | null; photo: Buffer | null; + token_version: number | null; }>( `SELECT sabun, user_id, user_name, user_name_eng, user_name_cn, dept_code, dept_name, position_code, position_name, email, tel, cell_phone, user_type, user_type_name, - partner_objid, company_code, locale, photo + partner_objid, company_code, locale, photo, + COALESCE(token_version, 0) as token_version FROM user_info WHERE user_id = $1`, [userId] @@ -210,6 +212,7 @@ export class AuthService { ? `data:image/jpeg;base64,${Buffer.from(userInfo.photo).toString("base64")}` : undefined, locale: userInfo.locale || "KR", + tokenVersion: userInfo.token_version ?? 0, // 권한 레벨 정보 추가 (3단계 체계) isSuperAdmin: companyCode === "*" && userType === "SUPER_ADMIN", isCompanyAdmin: userType === "COMPANY_ADMIN" && companyCode !== "*", diff --git a/backend-node/src/services/multilangService.ts b/backend-node/src/services/multilangService.ts index fc765d89..7ca92932 100644 --- a/backend-node/src/services/multilangService.ts +++ b/backend-node/src/services/multilangService.ts @@ -673,6 +673,22 @@ export class MultiLangService { } } + /** + * 키의 소유 회사 코드 조회 (권한 검증용) + */ + async getKeyCompanyCode(keyId: number): Promise { + try { + const result = await queryOne<{ company_code: string }>( + `SELECT company_code FROM multi_lang_key_master WHERE key_id = $1`, + [keyId] + ); + return result?.company_code || null; + } catch (error) { + logger.error("키 소유 회사 코드 조회 실패:", error); + return null; + } + } + /** * 다국어 키 목록 조회 */ @@ -688,6 +704,10 @@ export class MultiLangService { if (params.companyCode) { whereConditions.push(`company_code = $${paramIndex++}`); values.push(params.companyCode); + } else if (params.userCompanyCode && params.userCompanyCode !== "*") { + // 비관리자: companyCode 필터가 없으면 자기 회사 + 공통(*) 키만 반환 + whereConditions.push(`company_code IN ($${paramIndex++}, '*')`); + values.push(params.userCompanyCode); } // 메뉴 코드 필터 diff --git a/backend-node/src/services/roleService.ts b/backend-node/src/services/roleService.ts index abf19f40..2696dfce 100644 --- a/backend-node/src/services/roleService.ts +++ b/backend-node/src/services/roleService.ts @@ -1,4 +1,4 @@ -import { query } from "../database/db"; +import { query, transaction } from "../database/db"; import { logger } from "../utils/logger"; /** @@ -145,10 +145,19 @@ export class RoleService { writer: string; }): Promise { try { + // 동일 회사 내 같은 이름의 권한 그룹 중복 체크 + const dupCheck = await query<{ count: string }>( + `SELECT COUNT(*) AS count FROM authority_master WHERE company_code = $1 AND auth_name = $2`, + [data.companyCode, data.authName] + ); + if (dupCheck.length > 0 && parseInt(dupCheck[0].count, 10) > 0) { + throw new Error(`동일 회사 내에 이미 같은 이름의 권한 그룹이 존재합니다: ${data.authName}`); + } + const sql = ` INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status, writer, regdate) VALUES (nextval('seq_authority_master'), $1, $2, $3, 'active', $4, NOW()) - RETURNING objid, auth_name AS "authName", auth_code AS "authCode", + RETURNING objid, auth_name AS "authName", auth_code AS "authCode", company_code AS "companyCode", status, writer, regdate `; @@ -460,35 +469,37 @@ export class RoleService { writer: string ): Promise { try { - // 기존 권한 삭제 - await query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [ - authObjid, - ]); - - // 새로운 권한 삽입 - if (permissions.length > 0) { - const values = permissions - .map( - (_, index) => - `(nextval('seq_rel_menu_auth'), $${index * 5 + 2}, $1, $${index * 5 + 3}, $${index * 5 + 4}, $${index * 5 + 5}, $${index * 5 + 6}, $${permissions.length * 5 + 2}, NOW())` - ) - .join(", "); - - const params = permissions.flatMap((p) => [ - p.menuObjid, - p.createYn, - p.readYn, - p.updateYn, - p.deleteYn, + await transaction(async (client) => { + // 기존 권한 삭제 + await client.query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [ + authObjid, ]); - const sql = ` - INSERT INTO rel_menu_auth (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer, regdate) - VALUES ${values} - `; + // 새로운 권한 삽입 + if (permissions.length > 0) { + const values = permissions + .map( + (_, index) => + `(nextval('seq_rel_menu_auth'), $${index * 5 + 2}, $1, $${index * 5 + 3}, $${index * 5 + 4}, $${index * 5 + 5}, $${index * 5 + 6}, $${permissions.length * 5 + 2}, NOW())` + ) + .join(", "); - await query(sql, [authObjid, ...params, writer]); - } + const params = permissions.flatMap((p) => [ + p.menuObjid, + p.createYn, + p.readYn, + p.updateYn, + p.deleteYn, + ]); + + const sql = ` + INSERT INTO rel_menu_auth (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer, regdate) + VALUES ${values} + `; + + await client.query(sql, [authObjid, ...params, writer]); + } + }); logger.info("메뉴 권한 설정 성공", { authObjid, diff --git a/backend-node/src/services/tokenInvalidationService.ts b/backend-node/src/services/tokenInvalidationService.ts new file mode 100644 index 00000000..6bcddc13 --- /dev/null +++ b/backend-node/src/services/tokenInvalidationService.ts @@ -0,0 +1,75 @@ +// JWT 토큰 무효화 서비스 +// user_info.token_version 기반으로 기존 JWT 토큰을 무효화 + +import { query } from "../database/db"; +import { cache } from "../utils/cache"; +import { logger } from "../utils/logger"; + +const TOKEN_VERSION_CACHE_TTL = 2 * 60 * 1000; // 2분 캐시 + +export class TokenInvalidationService { + /** + * 캐시 키 생성 + */ + static cacheKey(userId: string): string { + return `token_version:${userId}`; + } + + /** + * 단일 사용자의 토큰 무효화 (token_version +1) + */ + static async invalidateUserTokens(userId: string): Promise { + try { + await query( + `UPDATE user_info SET token_version = COALESCE(token_version, 0) + 1 WHERE user_id = $1`, + [userId] + ); + cache.delete(this.cacheKey(userId)); + logger.info(`토큰 무효화: ${userId}`); + } catch (error) { + logger.error(`토큰 무효화 실패: ${userId}`, { error }); + } + } + + /** + * 여러 사용자의 토큰 일괄 무효화 + */ + static async invalidateMultipleUserTokens(userIds: string[]): Promise { + if (userIds.length === 0) return; + try { + const placeholders = userIds.map((_, i) => `$${i + 1}`).join(", "); + await query( + `UPDATE user_info SET token_version = COALESCE(token_version, 0) + 1 WHERE user_id IN (${placeholders})`, + userIds + ); + userIds.forEach((id) => cache.delete(this.cacheKey(id))); + logger.info(`토큰 일괄 무효화: ${userIds.length}명`); + } catch (error) { + logger.error(`토큰 일괄 무효화 실패`, { error, userIds }); + } + } + + /** + * 현재 token_version 조회 (캐시 사용) + */ + static async getUserTokenVersion(userId: string): Promise { + const cacheKey = this.cacheKey(userId); + const cached = cache.get(cacheKey); + if (cached !== null) { + return cached; + } + + try { + const result = await query<{ token_version: number | null }>( + `SELECT token_version FROM user_info WHERE user_id = $1`, + [userId] + ); + const version = result.length > 0 ? (result[0].token_version ?? 0) : 0; + cache.set(cacheKey, version, TOKEN_VERSION_CACHE_TTL); + return version; + } catch (error) { + logger.error(`token_version 조회 실패: ${userId}`, { error }); + return 0; + } + } +} diff --git a/backend-node/src/types/auth.ts b/backend-node/src/types/auth.ts index 6abd1e39..e360c01a 100644 --- a/backend-node/src/types/auth.ts +++ b/backend-node/src/types/auth.ts @@ -64,6 +64,7 @@ export interface PersonBean { companyName?: string; // 회사명 추가 photo?: string; locale?: string; + tokenVersion?: number; // JWT 토큰 무효화용 버전 // 권한 레벨 정보 (3단계 체계) isSuperAdmin?: boolean; // 최고 관리자 (company_code === '*' && userType === 'SUPER_ADMIN') isCompanyAdmin?: boolean; // 회사 관리자 (userType === 'COMPANY_ADMIN') @@ -98,6 +99,7 @@ export interface JwtPayload { companyName?: string; // 회사명 추가 userType?: string; userTypeName?: string; + tokenVersion?: number; // JWT 토큰 무효화용 버전 iat?: number; exp?: number; aud?: string; diff --git a/backend-node/src/types/multilang.ts b/backend-node/src/types/multilang.ts index c30fdfaa..026810ca 100644 --- a/backend-node/src/types/multilang.ts +++ b/backend-node/src/types/multilang.ts @@ -140,6 +140,7 @@ export interface GetLangKeysParams { includeOverrides?: boolean; page?: number; limit?: number; + userCompanyCode?: string; // 요청 사용자의 회사 코드 (비관리자 필터링용) } export interface GetUserTextParams { diff --git a/backend-node/src/utils/jwtUtils.ts b/backend-node/src/utils/jwtUtils.ts index 44f75cbc..aba3bf68 100644 --- a/backend-node/src/utils/jwtUtils.ts +++ b/backend-node/src/utils/jwtUtils.ts @@ -20,6 +20,7 @@ export class JwtUtils { companyName: userInfo.companyName, // 회사명 추가 userType: userInfo.userType, userTypeName: userInfo.userTypeName, + tokenVersion: userInfo.tokenVersion ?? 0, }; return jwt.sign(payload, config.jwt.secret, { diff --git a/frontend/app/(main)/production/plan-management/page.tsx b/frontend/app/(main)/production/plan-management/page.tsx index 2d5dcd45..51f7af14 100644 --- a/frontend/app/(main)/production/plan-management/page.tsx +++ b/frontend/app/(main)/production/plan-management/page.tsx @@ -582,8 +582,22 @@ export default function ProductionPlanManagementPage() { if (!ok) return; try { - await Promise.all(plannedIds.map((id) => deletePlan(id))); - toast.success(`${plannedIds.length}건의 계획이 삭제되었습니다`); + const results = await Promise.allSettled(plannedIds.map((id) => deletePlan(id))); + const failedIds = plannedIds.filter((_, i) => results[i].status === "rejected"); + const succeededCount = plannedIds.length - failedIds.length; + + if (failedIds.length === plannedIds.length) { + // 전부 삭제 실패 + toast.error(`${failedIds.length}건 모두 삭제에 실패했습니다. 다시 시도해주세요.`); + } else if (failedIds.length > 0) { + // 일부 삭제 실패 + toast.warning( + `${succeededCount}건 삭제 완료, ${failedIds.length}건 삭제 실패. 실패 항목을 다시 시도해주세요.` + ); + } else { + // 전부 성공 + toast.success(`${plannedIds.length}건의 계획이 삭제되었습니다`); + } fetchPlans(); } catch (err: any) { toast.error("삭제 실패: " + (err.message || "")); diff --git a/frontend/app/(main)/sales/claim/page.tsx b/frontend/app/(main)/sales/claim/page.tsx index 333e8fc6..86ba092d 100644 --- a/frontend/app/(main)/sales/claim/page.tsx +++ b/frontend/app/(main)/sales/claim/page.tsx @@ -18,16 +18,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, - DialogDescription, -} from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { @@ -49,11 +40,7 @@ import { CommandList, } from "@/components/ui/command"; import { - Search, Download, - Upload, - Settings, - RotateCcw, Plus, Save, BarChart3, @@ -63,26 +50,42 @@ import { ChevronsUpDown, Loader2, FileSpreadsheet, + Trash2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; +import { useAuth } from "@/hooks/useAuth"; +import { toast } from "sonner"; +import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; +import { exportToExcel } from "@/lib/utils/excelExport"; +import { FullscreenDialog } from "@/components/common/FullscreenDialog"; +import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; + +// --- 상수 --- +const TABLE_NAME = "claim_mng"; -// --- Types --- type ClaimType = "불량" | "교환" | "반품" | "배송지연" | "기타"; type ClaimStatus = "접수" | "처리중" | "완료" | "취소"; -interface Claim { - claimNo: string; - claimDate: string; - claimType: ClaimType; - claimStatus: ClaimStatus; - customerCode: string; - customerName: string; - managerName: string; - orderNo: string; - claimContent: string; - processContent: string; +interface ClaimRow { + id: number; + claim_no: string; + claim_date: string; + claim_type: string; + claim_status: string; + customer_code: string; + customer_name: string; + manager_name: string; + order_no: string; + claim_content: string; + process_content: string; + company_code?: string; + writer?: string; + created_date?: string; + updated_date?: string; + [key: string]: any; } interface CustomerOption { @@ -96,9 +99,7 @@ interface SalesOrderOption { status: string; } -const initialData: Claim[] = []; - -const getClaimTypeStyle = (type: ClaimType) => { +const getClaimTypeStyle = (type: string) => { switch (type) { case "불량": return "bg-rose-100 text-rose-800 border-rose-200"; @@ -115,7 +116,7 @@ const getClaimTypeStyle = (type: ClaimType) => { } }; -const getClaimStatusStyle = (status: ClaimStatus) => { +const getClaimStatusStyle = (status: string) => { switch (status) { case "접수": return "bg-blue-100 text-blue-800 border-blue-200"; @@ -134,16 +135,16 @@ const CLAIM_TYPES: ClaimType[] = ["불량", "교환", "반품", "배송지연", const CLAIM_STATUSES: ClaimStatus[] = ["접수", "처리중", "완료", "취소"]; export default function ClaimManagementPage() { - const [data, setData] = useState(initialData); + const { user } = useAuth(); + const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [totalCount, setTotalCount] = useState(0); const [selectedClaimNo, setSelectedClaimNo] = useState(null); - // 검색 상태 - const [searchDateFrom, setSearchDateFrom] = useState(""); - const [searchDateTo, setSearchDateTo] = useState(""); - const [searchClaimType, setSearchClaimType] = useState("all"); - const [searchStatus, setSearchStatus] = useState("all"); - const [searchCustomer, setSearchCustomer] = useState(""); - const [searchClaimNo, setSearchClaimNo] = useState(""); + // 검색 필터 (DynamicSearchFilter) + const [searchFilters, setSearchFilters] = useState([]); // 엑셀 업로드 const [excelUploadOpen, setExcelUploadOpen] = useState(false); @@ -151,7 +152,8 @@ export default function ClaimManagementPage() { // 모달 상태 const [isModalOpen, setIsModalOpen] = useState(false); const [isEditMode, setIsEditMode] = useState(false); - const [formData, setFormData] = useState>({}); + const [saving, setSaving] = useState(false); + const [formData, setFormData] = useState>({}); // Combobox 상태 const [customerOpen, setCustomerOpen] = useState(false); @@ -163,10 +165,40 @@ export default function ClaimManagementPage() { const [customersLoading, setCustomersLoading] = useState(false); const [ordersLoading, setOrdersLoading] = useState(false); - useEffect(() => { - }, []); + // --- 데이터 조회 (table-management API + autoFilter로 멀티테넌시 자동 적용) --- + const fetchData = useCallback(async () => { + setLoading(true); + try { + const filters: any[] = searchFilters.map((f) => ({ + columnName: f.columnName, + operator: f.operator, + value: f.value, + })); - // 거래처 목록 조회 + const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, { + page: 1, + size: 500, + dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, + autoFilter: true, // company_code 자동 필터링 + sort: { columnName: "claim_date", order: "desc" }, + }); + + const rows: ClaimRow[] = res.data?.data?.data || res.data?.data?.rows || []; + setData(rows); + setTotalCount(res.data?.data?.total || rows.length); + } catch (err) { + console.error("클레임 조회 실패:", err); + toast.error("클레임 목록을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }, [searchFilters]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // 거래처 목록 조회 (autoFilter로 멀티테넌시 적용) const fetchCustomers = useCallback(async (force = false) => { if (!force && customers.length > 0) return; setCustomersLoading(true); @@ -183,8 +215,6 @@ export default function ClaimManagementPage() { customerName: row.customer_name || "", })); setCustomers(list); - } else { - console.warn("거래처 응답 구조 확인:", JSON.stringify(res.data, null, 2)); } } catch (e) { console.error("거래처 목록 조회 실패:", e); @@ -193,7 +223,7 @@ export default function ClaimManagementPage() { } }, [customers.length]); - // 수주 목록 조회 + // 수주 목록 조회 (autoFilter로 멀티테넌시 적용) const fetchSalesOrders = useCallback(async (force = false) => { if (!force && salesOrders.length > 0) return; setOrdersLoading(true); @@ -218,8 +248,6 @@ export default function ClaimManagementPage() { }); } setSalesOrders(list); - } else { - console.warn("수주 응답 구조 확인:", JSON.stringify(res.data, null, 2)); } } catch (e) { console.error("수주 목록 조회 실패:", e); @@ -228,60 +256,26 @@ export default function ClaimManagementPage() { } }, [salesOrders.length]); - const filteredData = useMemo(() => { - return data - .filter((claim) => { - if (searchDateFrom && claim.claimDate < searchDateFrom) return false; - if (searchDateTo && claim.claimDate > searchDateTo) return false; - if (searchClaimType !== "all" && claim.claimType !== searchClaimType) - return false; - if (searchStatus !== "all" && claim.claimStatus !== searchStatus) - return false; - if ( - searchCustomer && - !claim.customerName - .toLowerCase() - .includes(searchCustomer.toLowerCase()) - ) - return false; - if ( - searchClaimNo && - !claim.claimNo.toLowerCase().includes(searchClaimNo.toLowerCase()) - ) - return false; - return true; - }) - .sort((a, b) => b.claimDate.localeCompare(a.claimDate)); - }, [ - data, - searchDateFrom, - searchDateTo, - searchClaimType, - searchStatus, - searchCustomer, - searchClaimNo, - ]); - // 상태별 카운트 const statusCounts = useMemo(() => { const counts = { 접수: 0, 처리중: 0, 완료: 0, 취소: 0 }; data.forEach((claim) => { - if (counts[claim.claimStatus] !== undefined) { - counts[claim.claimStatus]++; + if (counts[claim.claim_status as keyof typeof counts] !== undefined) { + counts[claim.claim_status as keyof typeof counts]++; } }); return counts; }, [data]); + // 클레임번호 자동 생성 const generateClaimNo = useCallback(() => { const year = new Date().getFullYear(); const prefix = `CLM-${year}-`; const existingNumbers = data - .filter((c) => c.claimNo.startsWith(prefix)) - .map((c) => parseInt(c.claimNo.replace(prefix, ""), 10)) + .filter((c) => c.claim_no?.startsWith(prefix)) + .map((c) => parseInt(c.claim_no.replace(prefix, ""), 10)) .filter((n) => !isNaN(n)); - const maxNumber = - existingNumbers.length > 0 ? Math.max(...existingNumbers) : 0; + const maxNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) : 0; return `${prefix}${String(maxNumber + 1).padStart(3, "0")}`; }, [data]); @@ -292,16 +286,16 @@ export default function ClaimManagementPage() { const openRegisterModal = () => { setIsEditMode(false); setFormData({ - claimNo: generateClaimNo(), - claimDate: new Date().toISOString().split("T")[0], - claimType: undefined, - claimStatus: "접수", - customerCode: "", - customerName: "", - managerName: "", - orderNo: "", - claimContent: "", - processContent: "", + claim_no: generateClaimNo(), + claim_date: new Date().toISOString().split("T")[0], + claim_type: undefined, + claim_status: "접수", + customer_code: "", + customer_name: "", + manager_name: "", + order_no: "", + claim_content: "", + process_content: "", }); setIsModalOpen(true); fetchCustomers(true); @@ -309,7 +303,7 @@ export default function ClaimManagementPage() { }; const openEditModal = (claimNo: string) => { - const claim = data.find((c) => c.claimNo === claimNo); + const claim = data.find((c) => c.claim_no === claimNo); if (!claim) return; setIsEditMode(true); setFormData({ ...claim }); @@ -318,55 +312,98 @@ export default function ClaimManagementPage() { fetchSalesOrders(true); }; - const handleFormChange = (field: keyof Claim, value: string) => { + const handleFormChange = (field: string, value: string) => { setFormData((prev) => ({ ...prev, [field]: value })); }; - const handleSave = () => { - if (!formData.claimType || !formData.customerName || !formData.claimContent) { - alert("필수 항목을 모두 입력해주세요."); + // --- 저장 (table-management API, company_code 자동 주입) --- + const handleSave = async () => { + if (!formData.claim_type || !formData.customer_name || !formData.claim_content) { + toast.error("필수 항목을 모두 입력해주세요. (클레임유형, 거래처명, 클레임내용)"); return; } - const claimData: Claim = { - claimNo: formData.claimNo || "", - claimDate: formData.claimDate || new Date().toISOString().split("T")[0], - claimType: formData.claimType as ClaimType, - claimStatus: (formData.claimStatus as ClaimStatus) || "접수", - customerCode: formData.customerCode || "", - customerName: formData.customerName || "", - managerName: formData.managerName || "", - orderNo: formData.orderNo || "", - claimContent: formData.claimContent || "", - processContent: formData.processContent || "", - }; + setSaving(true); + try { + // company_code, writer, created_date 등 시스템 필드는 제외 (백엔드가 자동 주입) + const { id, company_code, writer, created_date, updated_date, created_by, updated_by, ...saveFields } = formData as any; - if (isEditMode) { - setData((prev) => - prev.map((c) => (c.claimNo === claimData.claimNo ? claimData : c)) - ); - } else { - setData((prev) => [claimData, ...prev]); + if (isEditMode && id) { + // 수정 + await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, { + originalData: { id }, + updatedData: saveFields, + }); + toast.success("클레임이 수정되었습니다."); + } else { + // 등록 + await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, saveFields); + toast.success("클레임이 등록되었습니다."); + } + + setIsModalOpen(false); + fetchData(); // 목록 새로고침 + } catch (err: any) { + console.error("클레임 저장 실패:", err); + toast.error(err.response?.data?.message || "저장에 실패했습니다."); + } finally { + setSaving(false); } - - setIsModalOpen(false); - alert("클레임이 저장되었습니다."); }; - const handleResetSearch = () => { - const today = new Date(); - const thirtyDaysAgo = new Date(today); - thirtyDaysAgo.setDate(today.getDate() - 30); - setSearchDateFrom(thirtyDaysAgo.toISOString().split("T")[0]); - setSearchDateTo(today.toISOString().split("T")[0]); - setSearchClaimType("all"); - setSearchStatus("all"); - setSearchCustomer(""); - setSearchClaimNo(""); + // --- 삭제 --- + const handleDelete = async (claimNo: string) => { + const claim = data.find((c) => c.claim_no === claimNo); + if (!claim) return; + + const ok = await confirm(`클레임 ${claimNo}을(를) 삭제하시겠습니까?`, { + variant: "destructive", + confirmText: "삭제", + }); + if (!ok) return; + + try { + await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { + data: [{ id: claim.id }], + }); + toast.success("클레임이 삭제되었습니다."); + if (selectedClaimNo === claimNo) setSelectedClaimNo(null); + fetchData(); + } catch (err: any) { + console.error("클레임 삭제 실패:", err); + toast.error(err.response?.data?.message || "삭제에 실패했습니다."); + } + }; + + // 엑셀 다운로드 + const handleExcelDownload = async () => { + if (data.length === 0) { + toast.error("다운로드할 데이터가 없습니다."); + return; + } + try { + const exportData = data.map((row) => ({ + 클레임번호: row.claim_no, + 접수일자: row.claim_date, + 클레임유형: row.claim_type, + 처리상태: row.claim_status, + 거래처코드: row.customer_code, + 거래처명: row.customer_name, + 담당자: row.manager_name, + 수주번호: row.order_no, + 클레임내용: row.claim_content, + 처리내용: row.process_content, + })); + await exportToExcel(exportData, "클레임관리.xlsx", "클레임"); + toast.success("엑셀 다운로드 완료"); + } catch (err) { + console.error("엑셀 다운로드 실패:", err); + toast.error("엑셀 다운로드에 실패했습니다."); + } }; const selectedClaim = useMemo( - () => data.find((c) => c.claimNo === selectedClaimNo), + () => data.find((c) => c.claim_no === selectedClaimNo), [data, selectedClaimNo] ); @@ -404,105 +441,13 @@ export default function ClaimManagementPage() { return (
- {/* 검색 섹션 */} - - -
- -
- setSearchDateFrom(e.target.value)} - /> - ~ - setSearchDateTo(e.target.value)} - /> -
-
- -
- - -
- -
- - -
- -
- - setSearchCustomer(e.target.value)} - /> -
- -
- - setSearchClaimNo(e.target.value)} - /> -
- -
- -
- - - - -
- - + {/* 검색 섹션 — DynamicSearchFilter 사용 */} + {/* 메인 분할 레이아웃 */}
@@ -515,13 +460,17 @@ export default function ClaimManagementPage() { 클레임 목록 - {filteredData.length}건 + {totalCount}건 + {loading && }
+ @@ -535,12 +484,8 @@ export default function ClaimManagementPage() { No 클레임번호 접수일자 - - 유형 - - - 상태 - + 유형 + 상태 거래처명 담당자 수주번호 @@ -548,7 +493,7 @@ export default function ClaimManagementPage() { - {filteredData.length === 0 ? ( + {data.length === 0 ? (
- 등록된 클레임이 없습니다 + {loading ? "데이터를 불러오는 중..." : "등록된 클레임이 없습니다"}
) : ( - filteredData.map((claim, idx) => ( + data.map((claim, idx) => ( handleRowClick(claim.claimNo)} - onDoubleClick={() => openEditModal(claim.claimNo)} + onClick={() => handleRowClick(claim.claim_no)} + onDoubleClick={() => openEditModal(claim.claim_no)} > {idx + 1} - {claim.claimNo} + {claim.claim_no} - {claim.claimDate} + {claim.claim_date} - {claim.claimType} + {claim.claim_type} - {claim.claimStatus} + {claim.claim_status} - {claim.customerName} - {claim.managerName || "-"} + {claim.customer_name} + {claim.manager_name || "-"} - {claim.orderNo || "-"} + {claim.order_no || "-"} - {claim.claimContent || "-"} + {claim.claim_content || "-"} )) @@ -652,7 +597,7 @@ export default function ClaimManagementPage() {

- 클레임 상세 - {selectedClaim.claimNo} + 클레임 상세 - {selectedClaim.claim_no}

@@ -661,14 +606,14 @@ export default function ClaimManagementPage() { 클레임번호 - {selectedClaim.claimNo} + {selectedClaim.claim_no}
접수일자 - {selectedClaim.claimDate} + {selectedClaim.claim_date}
@@ -677,10 +622,10 @@ export default function ClaimManagementPage() { - {selectedClaim.claimType} + {selectedClaim.claim_type}
@@ -690,30 +635,30 @@ export default function ClaimManagementPage() { - {selectedClaim.claimStatus} + {selectedClaim.claim_status}
거래처명 - {selectedClaim.customerName} + {selectedClaim.customer_name}
담당자 - {selectedClaim.managerName || "-"} + {selectedClaim.manager_name || "-"}
수주번호 - {selectedClaim.orderNo || "-"} + {selectedClaim.order_no || "-"}
@@ -723,7 +668,7 @@ export default function ClaimManagementPage() { 클레임 내용
- {selectedClaim.claimContent || "-"} + {selectedClaim.claim_content || "-"}
@@ -732,18 +677,28 @@ export default function ClaimManagementPage() { 처리 내용
- {selectedClaim.processContent || "-"} + {selectedClaim.process_content || "-"}
- +
+ + +
) : (
@@ -761,311 +716,15 @@ export default function ClaimManagementPage() {
- {/* 클레임 등록/수정 모달 */} - - - - - {isEditMode ? "클레임 수정" : "클레임 등록"} - - - {isEditMode - ? "클레임 정보를 수정합니다." - : "새로운 클레임을 등록합니다."} - - - -
-
- {/* 왼쪽: 기본 정보 */} -
-

- 클레임 기본 정보 -

- -
- - -
- -
- - - handleFormChange("claimDate", e.target.value) - } - className="h-8 text-xs sm:h-10 sm:text-sm" - /> -
- -
- - -
- -
- - -
- -
- - - - - - - - - - {customersLoading ? ( -
- - 로딩 중... -
- ) : ( - <> - - 거래처를 찾을 수 없습니다. - - - {customers.map((cust) => ( - { - setFormData((prev) => ({ - ...prev, - customerCode: cust.customerCode, - customerName: cust.customerName, - })); - setCustomerOpen(false); - }} - className="text-xs sm:text-sm" - > - -
- {cust.customerName} - - {cust.customerCode} - -
-
- ))} -
- - )} -
-
-
-
-
- -
- - - handleFormChange("managerName", e.target.value) - } - className="h-8 text-xs sm:h-10 sm:text-sm" - /> -
- -
- - - - - - - - - - {ordersLoading ? ( -
- - 로딩 중... -
- ) : ( - <> - - 수주번호를 찾을 수 없습니다. - - - {salesOrders.map((order) => ( - { - setFormData((prev) => ({ - ...prev, - orderNo: order.orderNo, - })); - setOrderOpen(false); - }} - className="text-xs sm:text-sm" - > - -
- {order.orderNo} - - {order.status} - {order.partnerName ? ` | ${order.partnerName}` : ""} - -
-
- ))} -
- - )} -
-
-
-
-
-
- - {/* 오른쪽: 상세 내용 */} -
-

- 클레임 상세 내용 -

- -
- -