From e67e43cd7d3ece881226dc2448d2841dafa8fba1 Mon Sep 17 00:00:00 2001 From: kmh Date: Wed, 25 Mar 2026 14:36:57 +0900 Subject: [PATCH 1/4] feat: update shipping-plan page and FieldDetailSettingsModal Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/(main)/sales/shipping-plan/page.tsx | 18 +-- .../modals/FieldDetailSettingsModal.tsx | 142 ++++++++++++++++++ 2 files changed, 151 insertions(+), 9 deletions(-) diff --git a/frontend/app/(main)/sales/shipping-plan/page.tsx b/frontend/app/(main)/sales/shipping-plan/page.tsx index f04e6908..e77e7066 100644 --- a/frontend/app/(main)/sales/shipping-plan/page.tsx +++ b/frontend/app/(main)/sales/shipping-plan/page.tsx @@ -315,15 +315,15 @@ export default function ShippingPlanPage() { onCheckedChange={handleCheckAll} /> - 수주번호 - 납기일 - 거래처 - 품목코드 - 품목명 - 수주수량 - 계획수량 - 출하계획일 - 상태 + 수주번호 + 납기일 + 거래처 + 품목코드 + 품목명 + 수주수량 + 계획수량 + 출하계획일 + 상태 diff --git a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx index 9fa2e0a0..13bf2c11 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx @@ -109,6 +109,11 @@ export function FieldDetailSettingsModal({ const [cascadingRelationOpen, setCascadingRelationOpen] = useState(false); const [parentFieldOpen, setParentFieldOpen] = useState(false); + // 기본 선택값용 옵션 목록 상태 + const [defaultValueCategoryValues, setDefaultValueCategoryValues] = useState<{value: string; label: string}[]>([]); + const [defaultValueTableOptions, setDefaultValueTableOptions] = useState<{value: string; label: string}[]>([]); + const [loadingDefaultValueOptions, setLoadingDefaultValueOptions] = useState(false); + // Combobox 열림 상태 const [sourceTableOpen, setSourceTableOpen] = useState(false); const [targetColumnOpenMap, setTargetColumnOpenMap] = useState>({}); @@ -209,6 +214,69 @@ export function FieldDetailSettingsModal({ loadCascadingRelations(); }, [open]); + // 기본 선택값용: code 타입 카테고리 값 로드 + useEffect(() => { + const loadCategoryValues = async () => { + const categoryKey = localField.selectOptions?.categoryKey; + if (!open || localField.selectOptions?.type !== "code" || !categoryKey) { + setDefaultValueCategoryValues([]); + return; + } + setLoadingDefaultValueOptions(true); + try { + const [tableName, columnName] = categoryKey.split("."); + const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`); + if (response.data?.success && response.data?.data) { + setDefaultValueCategoryValues( + response.data.data.map((item: any) => ({ + value: item.valueCode || item.value_code, + label: item.valueLabel || item.value_label, + })) + ); + } else { + setDefaultValueCategoryValues([]); + } + } catch { + setDefaultValueCategoryValues([]); + } finally { + setLoadingDefaultValueOptions(false); + } + }; + loadCategoryValues(); + }, [open, localField.selectOptions?.type, localField.selectOptions?.categoryKey]); + + // 기본 선택값용: table 타입 옵션 로드 + useEffect(() => { + const loadTableOptions = async () => { + const opts = localField.selectOptions; + if (!open || opts?.type !== "table" || !opts?.tableName || !opts?.valueColumn || !opts?.labelColumn) { + setDefaultValueTableOptions([]); + return; + } + setLoadingDefaultValueOptions(true); + try { + const response = await apiClient.post(`/table-management/tables/${opts.tableName}/data`, { + page: 1, + size: 200, + autoFilter: { enabled: true, filterColumn: "company_code" }, + }); + const dataArray = response.data?.data?.data || response.data?.data || []; + setDefaultValueTableOptions( + dataArray.map((row: any) => ({ + value: String(row[opts.valueColumn!] || ""), + label: String(row[opts.labelColumn!] || ""), + })) + ); + } catch { + setDefaultValueTableOptions([]); + } finally { + setLoadingDefaultValueOptions(false); + } + }; + loadTableOptions(); + }, [open, localField.selectOptions?.type, localField.selectOptions?.tableName, + localField.selectOptions?.valueColumn, localField.selectOptions?.labelColumn]); + // 관계 코드 선택 시 상세 설정 자동 채움 const handleRelationCodeSelect = async (relationCode: string) => { if (!relationCode) return; @@ -1181,6 +1249,80 @@ export function FieldDetailSettingsModal({ )} + + {/* 기본 선택값 설정 (cascading 제외) */} + {(() => { + const effectiveType = localField.selectOptions?.type || "static"; + if (effectiveType === "cascading") return null; + return ( +
+
+ 기본 선택값 + {/* static 타입 */} + {effectiveType === "static" && (localField.selectOptions?.staticOptions?.length || 0) > 0 && ( + + )} + {/* code 타입 */} + {effectiveType === "code" && defaultValueCategoryValues.length > 0 && ( + + )} + {/* table 타입 */} + {effectiveType === "table" && defaultValueTableOptions.length > 0 && ( + + )} + {loadingDefaultValueOptions && ( + 로딩 중... + )} +
+ 폼이 열릴 때 자동으로 선택될 기본값을 설정합니다 +
+ ); + })()} )} From 70e040db39d94ad53de2edf4a4c13bee0822b113 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 25 Mar 2026 18:47:50 +0900 Subject: [PATCH 2/4] Enhance user management and token invalidation features - Added comprehensive validation for user data during registration and updates, including email format, company code existence, user type validation, and password length checks. - Implemented JWT token invalidation for users when their status changes or when roles are updated, ensuring security and compliance with the latest policies. - Introduced a new TokenInvalidationService to manage token versioning and invalidation processes efficiently. - Updated the admin controller to provide detailed error messages and success responses for user status changes and validations. - Enhanced the authentication middleware to check token versions against the database, ensuring that invalidated tokens cannot be used. This commit improves the overall security and user management experience within the application. --- .../src/controllers/adminController.ts | 364 ++++++++++++++++++ .../src/controllers/roleController.ts | 27 ++ backend-node/src/middleware/authMiddleware.ts | 38 +- backend-node/src/routes/adminRoutes.ts | 2 + backend-node/src/services/auditLogService.ts | 3 +- backend-node/src/services/authService.ts | 5 +- backend-node/src/services/roleService.ts | 67 ++-- .../src/services/tokenInvalidationService.ts | 75 ++++ backend-node/src/types/auth.ts | 2 + backend-node/src/utils/jwtUtils.ts | 1 + .../production/plan-management/page.tsx | 18 +- frontend/lib/api/client.ts | 7 + 12 files changed, 573 insertions(+), 36 deletions(-) create mode 100644 backend-node/src/services/tokenInvalidationService.ts 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/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/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/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/lib/api/client.ts b/frontend/lib/api/client.ts index bd935b63..427af1bb 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -457,6 +457,13 @@ apiClient.interceptors.response.use( } } + // TOKEN_INVALIDATED → 재로그인 필요 (갱신 시도 없이 즉시) + if (errorCode === "TOKEN_INVALIDATED") { + authLog("REDIRECT_TO_LOGIN", `토큰 무효화 (보안 정책 변경) → 즉시 로그인 리다이렉트 (${url})`); + redirectToLogin(); + return Promise.reject(error); + } + // TOKEN_MISSING, INVALID_TOKEN 등 → 로그인으로 authLog("REDIRECT_TO_LOGIN", `복구 불가능한 인증 에러 (${errorCode || "UNKNOWN"}, ${url}) → 로그인 리다이렉트`); redirectToLogin(); From 1bf91bf043a409d3959fcd632018a234c9cfc4c0 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 26 Mar 2026 09:30:17 +0900 Subject: [PATCH 3/4] Merge branch 'mhkim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node --- .../src/controllers/multilangController.ts | 142 ++- backend-node/src/services/multilangService.ts | 20 + backend-node/src/types/multilang.ts | 1 + frontend/app/(main)/sales/claim/page.tsx | 1116 ++++++++--------- frontend/package-lock.json | 42 +- 5 files changed, 696 insertions(+), 625 deletions(-) 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/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/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/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}` : ""} - -
-
- ))} -
- - )} -
-
-
-
-
-
- - {/* 오른쪽: 상세 내용 */} -
-

- 클레임 상세 내용 -

- -
- -