diff --git a/backend/src/main/java/com/zioinfo/mes/auth/AuthHelperController.java b/backend/src/main/java/com/zioinfo/mes/auth/AuthHelperController.java
new file mode 100644
index 0000000..94f70fc
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/auth/AuthHelperController.java
@@ -0,0 +1,51 @@
+package com.zioinfo.mes.auth;
+
+import com.zioinfo.mes.auth.dto.AuthHelperResult;
+import com.zioinfo.mes.auth.dto.FindIdRequest;
+import com.zioinfo.mes.auth.dto.FindIdResponse;
+import com.zioinfo.mes.auth.dto.ResetPasswordRequest;
+import com.zioinfo.mes.auth.dto.SignupRequest;
+import com.zioinfo.mes.common.ApiResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 로그인 보조 기능 3종(UIWS auth 패턴 이식) — 회원가입 / 아이디찾기 / 비밀번호 초기화.
+ *
+ *
base path {@code /api/mes/auth} (MES 기존 auth 네임스페이스 따름, SecurityConfig permitAll —
+ * 로그인 전 무인증 접근). 대상은 MES 운영자 계정(mes_user).
+ *
+ * - 회원가입: 승인 대기(approved=false) 상태로 등록 → SUPERADMIN 승인 전 로그인 차단
+ * - 아이디찾기: displayName+email 매칭, username 부분 마스킹 반환
+ * - 비번초기화: username+email 검증 → 임시비번 BCrypt 저장 + 메일/로그 발송(응답에 비번 미포함)
+ *
+ * 보안 불변규칙: 임시비번·자격증명·계정 존재여부를 응답/로그 메시지에 노출하지 않는다.
+ */
+@RestController
+@RequestMapping("/api/mes/auth")
+@RequiredArgsConstructor
+public class AuthHelperController {
+
+ private final AuthService authService;
+
+ /** 운영자 회원가입(승인 대기 INSERT). */
+ @PostMapping("/signup")
+ public ApiResponse signup(@RequestBody SignupRequest req) {
+ return ApiResponse.ok(authService.signup(req));
+ }
+
+ /** 아이디 찾기(이메일+이름 매칭, 마스킹 반환). */
+ @PostMapping("/find-id")
+ public ApiResponse findId(@RequestBody FindIdRequest req) {
+ return ApiResponse.ok(authService.findId(req));
+ }
+
+ /** 비밀번호 초기화(검증 → 임시비번 BCrypt + 메일/로그). */
+ @PostMapping("/reset-password")
+ public ApiResponse resetPassword(@RequestBody ResetPasswordRequest req) {
+ return ApiResponse.ok(authService.resetPassword(req));
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/auth/AuthService.java b/backend/src/main/java/com/zioinfo/mes/auth/AuthService.java
index 380469a..cec649e 100644
--- a/backend/src/main/java/com/zioinfo/mes/auth/AuthService.java
+++ b/backend/src/main/java/com/zioinfo/mes/auth/AuthService.java
@@ -1,13 +1,22 @@
package com.zioinfo.mes.auth;
+import com.zioinfo.mes.auth.dto.AuthHelperResult;
+import com.zioinfo.mes.auth.dto.FindIdRequest;
+import com.zioinfo.mes.auth.dto.FindIdResponse;
+import com.zioinfo.mes.auth.dto.ResetPasswordRequest;
+import com.zioinfo.mes.auth.dto.SignupRequest;
import com.zioinfo.mes.auth.mapper.UserMapper;
import com.zioinfo.mes.uiws.auth.TwoFactorService;
import com.zioinfo.mes.uiws.common.UiwsApiException;
import com.zioinfo.mes.uiws.common.UiwsErrorCode;
+import com.zioinfo.mes.uiws.common.mail.MailSender;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import java.security.SecureRandom;
import java.util.HashMap;
import java.util.Map;
@@ -17,14 +26,20 @@ import java.util.Map;
* - UIWS 2FA 레이어(이메일 코드) 추가: 2FA on 이면 1차 통과 후 verify-token + 이메일코드 발급.
* 실패 누적 max-login-fail 회 시 계정 잠금.
*/
+@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
+ private static final SecureRandom RANDOM = new SecureRandom();
+ /** 임시 비밀번호 문자셋(혼동 문자 0/O/1/l/I 제외). */
+ private static final String TMP_PW_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789@#$%";
+
private final UserMapper userMapper;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
private final TwoFactorService twoFactorService;
+ private final MailSender mailSender;
/**
* 1차 로그인. 2FA 활성 시 verify-token + 이메일코드 흐름으로 분기,
@@ -43,6 +58,10 @@ public class AuthService {
if (user == null || !user.isActive()) {
throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정");
}
+ // 회원가입 승인 게이트(로그인 보조 이식): 미승인 계정은 비번 일치 전에 차단.
+ if (Boolean.FALSE.equals(user.getApproved())) {
+ throw new RuntimeException("ERR-AUTH-003: 승인 대기 중인 계정입니다. 관리자 승인 후 이용 가능합니다.");
+ }
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
// 2FA 활성 시 실패 누적/잠금. 비활성 시 기존 동작(메시지만) 유지.
if (twoFactorService.isEnabled()) {
@@ -82,4 +101,100 @@ public class AuthService {
m.put("displayName", u != null ? u.getDisplayName() : username);
return m;
}
+
+ // ── 로그인 보조 이식: 회원가입 / 아이디찾기 / 비밀번호 초기화 ───────────────────
+
+ /**
+ * 운영자 회원가입(승인 대기). username/email 중복 검사 후 approved=false·role=VIEWER 로 INSERT.
+ * 비밀번호는 BCrypt 저장. 활성/승인 전까지 로그인 차단(AuthService.login 의 승인 게이트).
+ */
+ @Transactional
+ public AuthHelperResult signup(SignupRequest req) {
+ if (req.username() == null || req.username().isBlank()
+ || req.password() == null || req.password().length() < 4
+ || req.email() == null || req.email().isBlank()) {
+ return new AuthHelperResult(false, "아이디·비밀번호(4자 이상)·이메일은 필수입니다.");
+ }
+ if (userMapper.countByUsername(req.username()) > 0) {
+ return new AuthHelperResult(false, "이미 사용 중인 아이디입니다.");
+ }
+ if (userMapper.countByEmail(req.email()) > 0) {
+ return new AuthHelperResult(false, "이미 등록된 이메일입니다.");
+ }
+ MesUser u = new MesUser();
+ u.setUsername(req.username());
+ u.setPasswordHash(passwordEncoder.encode(req.password()));
+ u.setDisplayName(req.displayName() != null && !req.displayName().isBlank()
+ ? req.displayName() : req.username());
+ u.setEmail(req.email());
+ userMapper.signup(u);
+ log.info("[auth-helper] signup pending approval: username={}", req.username());
+ return new AuthHelperResult(true, "가입 신청이 접수되었습니다. 관리자 승인 후 로그인할 수 있습니다.");
+ }
+
+ /**
+ * 아이디 찾기: 표시명+이메일 동시 일치 운영자 1건 조회. username 은 부분 마스킹 후 반환.
+ * 미발견 시 found=false(원문 username 절대 미노출).
+ */
+ public FindIdResponse findId(FindIdRequest req) {
+ if (req.displayName() == null || req.displayName().isBlank()
+ || req.email() == null || req.email().isBlank()) {
+ return new FindIdResponse(false, "");
+ }
+ MesUser u = userMapper.findByDisplayNameAndEmail(req.displayName(), req.email());
+ if (u == null) {
+ return new FindIdResponse(false, "");
+ }
+ return new FindIdResponse(true, maskUsername(u.getUsername()));
+ }
+
+ /**
+ * 비밀번호 초기화: username+email 일치 검증 → 임시비번 생성·BCrypt 저장·잠금/실패카운트 해제.
+ * 임시비번은 메일(미설정 시 LogMailSender 로그)로만 전달. API 응답·로그 메시지에 비번 미노출.
+ * 대상 미존재여도 success=true(계정 열거 방지).
+ */
+ @Transactional
+ public AuthHelperResult resetPassword(ResetPasswordRequest req) {
+ final String okMsg = "임시 비밀번호를 등록된 이메일로 발송했습니다. 메일을 확인하세요.";
+ if (req.username() == null || req.username().isBlank()
+ || req.email() == null || req.email().isBlank()) {
+ return new AuthHelperResult(false, "아이디와 이메일을 모두 입력하세요.");
+ }
+ MesUser u = userMapper.findByUsernameAndEmail(req.username(), req.email());
+ if (u == null) {
+ // 존재 여부 누설 방지 — 동일 성공 메시지 반환(실제 발송 없음).
+ log.info("[auth-helper] reset-password no match (suppressed): username={}", req.username());
+ return new AuthHelperResult(true, okMsg);
+ }
+ String tempPw = generateTempPassword();
+ userMapper.updatePasswordHash(req.username(), passwordEncoder.encode(tempPw));
+
+ String subject = "[GUARDiA MES] 임시 비밀번호 안내";
+ String body = String.format(
+ "안녕하세요 %s 님,\n임시 비밀번호는 [%s] 입니다.\n로그인 후 즉시 비밀번호를 변경하세요.",
+ u.getDisplayName() != null ? u.getDisplayName() : u.getUsername(), tempPw);
+ // 메일 본문에만 임시비번 포함. mailSender 미설정 환경은 LogMailSender 폴백(서버 로그).
+ mailSender.send(u.getEmail(), subject, body);
+ log.info("[auth-helper] reset-password issued temp pw (sent via mail/log): username={}", req.username());
+ return new AuthHelperResult(true, okMsg);
+ }
+
+ private static String generateTempPassword() {
+ StringBuilder sb = new StringBuilder(10);
+ for (int i = 0; i < 10; i++) {
+ sb.append(TMP_PW_CHARS.charAt(RANDOM.nextInt(TMP_PW_CHARS.length())));
+ }
+ return sb.toString();
+ }
+
+ /** username 부분 마스킹: 앞 2자만 노출(예: admin → ad***). 2자 이하는 첫 글자만. */
+ private static String maskUsername(String username) {
+ if (username == null || username.isBlank()) {
+ return "";
+ }
+ if (username.length() <= 2) {
+ return username.charAt(0) + "*";
+ }
+ return username.substring(0, 2) + "*".repeat(Math.max(1, username.length() - 2));
+ }
}
diff --git a/backend/src/main/java/com/zioinfo/mes/auth/MesUser.java b/backend/src/main/java/com/zioinfo/mes/auth/MesUser.java
index 58282b4..ca58e63 100644
--- a/backend/src/main/java/com/zioinfo/mes/auth/MesUser.java
+++ b/backend/src/main/java/com/zioinfo/mes/auth/MesUser.java
@@ -31,4 +31,8 @@ public class MesUser {
private Boolean locked;
/** TOTP 시크릿(UIWS OTP 경로 대비, 현재 이메일 흐름에서는 미사용). */
private String otpSecret;
+
+ // ── 로그인 보조 이식(회원가입 승인 게이트) mes_user.approved ───────────────
+ /** 회원가입 승인 여부(기본 true). 신규 가입자는 false → SUPERADMIN 승인 전 로그인 차단. */
+ private Boolean approved;
}
diff --git a/backend/src/main/java/com/zioinfo/mes/auth/dto/AuthHelperResult.java b/backend/src/main/java/com/zioinfo/mes/auth/dto/AuthHelperResult.java
new file mode 100644
index 0000000..f501949
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/auth/dto/AuthHelperResult.java
@@ -0,0 +1,11 @@
+package com.zioinfo.mes.auth.dto;
+
+/**
+ * 로그인 보조 이식 — 회원가입/비밀번호초기화 공통 결과 DTO.
+ * 항상 일반 메시지만 반환(임시비번·존재여부 등 민감정보 미포함, 자격증명 보호 불변규칙).
+ * 보안상 비밀번호 초기화는 대상 미존재 시에도 success=true(열거 공격 방지).
+ */
+public record AuthHelperResult(
+ boolean success,
+ String message) {
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/auth/dto/FindIdRequest.java b/backend/src/main/java/com/zioinfo/mes/auth/dto/FindIdRequest.java
new file mode 100644
index 0000000..94d89eb
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/auth/dto/FindIdRequest.java
@@ -0,0 +1,11 @@
+package com.zioinfo.mes.auth.dto;
+
+/**
+ * 로그인 보조 이식 — 아이디 찾기 요청 DTO.
+ * 표시명(displayName) + 이메일(email) 동시 일치하는 운영자 계정을 조회.
+ * 응답의 username 은 마스킹하여 반환(자격증명 보호).
+ */
+public record FindIdRequest(
+ String displayName,
+ String email) {
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/auth/dto/FindIdResponse.java b/backend/src/main/java/com/zioinfo/mes/auth/dto/FindIdResponse.java
new file mode 100644
index 0000000..577ea3c
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/auth/dto/FindIdResponse.java
@@ -0,0 +1,11 @@
+package com.zioinfo.mes.auth.dto;
+
+/**
+ * 로그인 보조 이식 — 아이디 찾기 응답 DTO.
+ * found=true 이면 maskedUsername(예: ad***) 동봉. 미발견이어도 동일 shape(존재 여부 누설 최소화).
+ * 원본 username 전체는 절대 노출하지 않는다(부분 마스킹만).
+ */
+public record FindIdResponse(
+ boolean found,
+ String maskedUsername) {
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/auth/dto/ResetPasswordRequest.java b/backend/src/main/java/com/zioinfo/mes/auth/dto/ResetPasswordRequest.java
new file mode 100644
index 0000000..2f6c662
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/auth/dto/ResetPasswordRequest.java
@@ -0,0 +1,11 @@
+package com.zioinfo.mes.auth.dto;
+
+/**
+ * 로그인 보조 이식 — 비밀번호 초기화 요청 DTO.
+ * username + email 동시 일치 검증 후 임시 비밀번호를 BCrypt 로 저장.
+ * 임시 비밀번호는 메일(미설정 시 LogMailSender 로그)로만 전달 — API 응답에 절대 미포함.
+ */
+public record ResetPasswordRequest(
+ String username,
+ String email) {
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/auth/dto/SignupRequest.java b/backend/src/main/java/com/zioinfo/mes/auth/dto/SignupRequest.java
new file mode 100644
index 0000000..a4eebb1
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/auth/dto/SignupRequest.java
@@ -0,0 +1,13 @@
+package com.zioinfo.mes.auth.dto;
+
+/**
+ * 로그인 보조 이식 — 운영자 회원가입 요청 DTO.
+ * 대상: MES 관리자/운영자 계정(mes_user).
+ * 가입 결과는 승인 대기(approved=false) 상태로 INSERT → SUPERADMIN 승인 전 로그인 차단.
+ */
+public record SignupRequest(
+ String username,
+ String password,
+ String displayName,
+ String email) {
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/auth/mapper/UserMapper.java b/backend/src/main/java/com/zioinfo/mes/auth/mapper/UserMapper.java
index 3c1af9e..fa6061b 100644
--- a/backend/src/main/java/com/zioinfo/mes/auth/mapper/UserMapper.java
+++ b/backend/src/main/java/com/zioinfo/mes/auth/mapper/UserMapper.java
@@ -48,4 +48,30 @@ public interface UserMapper {
/** 관리자 잠금 해제(실패 카운트/잠금 초기화). */
@Update("UPDATE mes_user SET locked = false, login_fail_count = 0 WHERE username = #{username}")
int unlock(@Param("username") String username);
+
+ // ── 로그인 보조 이식: 회원가입 / 아이디찾기 / 비밀번호 초기화 (XML 정의) ───────────────
+
+ /** username 존재 여부(회원가입 중복 검사). */
+ int countByUsername(@Param("username") String username);
+
+ /** email 존재 여부(회원가입 중복 검사 — 운영자 계정 한정). */
+ int countByEmail(@Param("email") String email);
+
+ /**
+ * 회원가입(승인 대기). approved=false·is_active=true·role=VIEWER 고정.
+ * 관리자 화면에서 활성/승인 전까지 로그인 차단.
+ */
+ int signup(MesUser user);
+
+ /** 아이디찾기: 표시명(display_name)+이메일 일치 운영자 1건. */
+ MesUser findByDisplayNameAndEmail(@Param("displayName") String displayName,
+ @Param("email") String email);
+
+ /** 비밀번호 초기화 대상 검증: username+email 동시 일치 운영자 1건. */
+ MesUser findByUsernameAndEmail(@Param("username") String username,
+ @Param("email") String email);
+
+ /** 임시 비밀번호 적용 + 잠금/실패카운트 해제(초기화 시). */
+ int updatePasswordHash(@Param("username") String username,
+ @Param("passwordHash") String passwordHash);
}
diff --git a/backend/src/main/java/com/zioinfo/mes/config/SecurityConfig.java b/backend/src/main/java/com/zioinfo/mes/config/SecurityConfig.java
index 359ad49..d9b6175 100644
--- a/backend/src/main/java/com/zioinfo/mes/config/SecurityConfig.java
+++ b/backend/src/main/java/com/zioinfo/mes/config/SecurityConfig.java
@@ -43,9 +43,12 @@ public class SecurityConfig {
.cors(cors -> {})
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
+ // 로그인 + 로그인 보조 3종(signup/find-id/reset-password) + 2FA verify 모두 /api/mes/auth/** 하위 → permitAll
.requestMatchers("/api/mes/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api/mes/docs/**", "/api/mes/swagger/**").permitAll()
+ // UIWS system 이식: 무인증 공개 조회(회사/부서 룩업) — 회원가입 화면 등에서 사용
+ .requestMatchers(HttpMethod.GET, "/api/public/**").permitAll()
// 정적 프론트 번들
.requestMatchers("/", "/index.html", "/assets/**", "/favicon.ico").permitAll()
@@ -68,6 +71,9 @@ public class SecurityConfig {
// 조회 — 인증 사용자 전체(Viewer+)
.requestMatchers(HttpMethod.GET, "/api/mes/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER", "VIEWER")
+ // UIWS system(권한관리) 이식 — 사용자/역할/메뉴/부서/거래처/코드 관리는 SUPERADMIN 전용(RBAC 게이트)
+ .requestMatchers("/api/system/**").hasRole("SUPERADMIN")
+
.anyRequest().authenticated()
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/common/UiwsErrorCode.java b/backend/src/main/java/com/zioinfo/mes/uiws/common/UiwsErrorCode.java
index 96eb780..a3e9947 100644
--- a/backend/src/main/java/com/zioinfo/mes/uiws/common/UiwsErrorCode.java
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/common/UiwsErrorCode.java
@@ -36,7 +36,19 @@ public enum UiwsErrorCode {
// auth (2FA)
VERIFY_TOKEN_INVALID("ERR-UIWS-2FA-401", "2차 검증 토큰이 유효하지 않거나 만료되었습니다."),
VERIFY_CODE_INVALID("ERR-UIWS-2FA-401C", "인증 코드가 올바르지 않거나 만료되었습니다."),
- ACCOUNT_LOCKED("ERR-UIWS-2FA-LOCK", "로그인 실패 횟수 초과로 계정이 잠겼습니다. 관리자에게 문의하세요.");
+ ACCOUNT_LOCKED("ERR-UIWS-2FA-LOCK", "로그인 실패 횟수 초과로 계정이 잠겼습니다. 관리자에게 문의하세요."),
+
+ // system (권한관리 이식 — CMS 형제 솔루션 차용)
+ DUPLICATE_KEY("ERR-UIWS-SYS-409D", "이미 존재하는 항목입니다."),
+ RESOURCE_IN_USE("ERR-UIWS-SYS-409U", "참조 중인 항목이 있어 처리할 수 없습니다."),
+ ROLE_NOT_FOUND("ERR-UIWS-SYS-RL404", "권한을 찾을 수 없습니다."),
+ USER_NOT_FOUND("ERR-UIWS-SYS-US404", "사용자를 찾을 수 없습니다."),
+ USER_ID_DUPLICATED("ERR-UIWS-SYS-US409", "이미 사용 중인 사용자 ID입니다."),
+ DEPT_NOT_FOUND("ERR-UIWS-SYS-DP404", "부서를 찾을 수 없습니다."),
+ COMPANY_NOT_FOUND("ERR-UIWS-SYS-CO404", "거래처를 찾을 수 없습니다."),
+ MENU_NOT_FOUND("ERR-UIWS-SYS-MN404", "메뉴를 찾을 수 없습니다."),
+ PROGRAM_NOT_FOUND("ERR-UIWS-SYS-PG404", "프로그램을 찾을 수 없습니다."),
+ CODE_GRP_NOT_FOUND("ERR-UIWS-SYS-CG404", "코드그룹을 찾을 수 없습니다.");
private final String code;
private final String message;
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/CodeController.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/CodeController.java
new file mode 100644
index 0000000..cfa975a
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/CodeController.java
@@ -0,0 +1,58 @@
+package com.zioinfo.mes.uiws.system.controller;
+
+import com.zioinfo.mes.common.ApiResponse;
+import com.zioinfo.mes.uiws.system.dto.CodeGrpDetailDto;
+import com.zioinfo.mes.uiws.system.dto.CodeGrpDto;
+import com.zioinfo.mes.uiws.system.dto.CodeGrpSaveDto;
+import com.zioinfo.mes.uiws.system.dto.CodeValueDto;
+import com.zioinfo.mes.uiws.system.dto.PageResponse;
+import com.zioinfo.mes.uiws.system.service.CodeService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/** 2.7 코드(codes) — 그룹목록/상세/CRUD/드롭다운값. base: /api/system/codes */
+@RestController
+@RequestMapping("/api/system/codes")
+@RequiredArgsConstructor
+public class CodeController {
+
+ private final CodeService codeService;
+
+ @GetMapping
+ public ApiResponse> listGroups(
+ @RequestParam(required = false) String keyword,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "20") int size) {
+ return ApiResponse.ok(codeService.listGroups(keyword, page, size));
+ }
+
+ /** 드롭다운 공용 코드값 조회(정적 경로 우선). */
+ @GetMapping("/group/{grpCd}/values")
+ public ApiResponse> getValues(@PathVariable("grpCd") String grpCd) {
+ return ApiResponse.ok(codeService.getValues(grpCd));
+ }
+
+ @GetMapping("/{grpCd}")
+ public ApiResponse getGroup(@PathVariable("grpCd") String grpCd) {
+ return ApiResponse.ok(codeService.getGroup(grpCd));
+ }
+
+ @PostMapping
+ public ApiResponse create(@Valid @RequestBody CodeGrpSaveDto dto) {
+ return ApiResponse.ok(codeService.create(dto));
+ }
+
+ @PutMapping("/{grpCd}")
+ public ApiResponse update(@PathVariable("grpCd") String grpCd, @Valid @RequestBody CodeGrpSaveDto dto) {
+ return ApiResponse.ok(codeService.update(grpCd, dto));
+ }
+
+ @DeleteMapping("/{grpCd}")
+ public ApiResponse delete(@PathVariable("grpCd") String grpCd) {
+ codeService.delete(grpCd);
+ return ApiResponse.ok(null);
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/CompanyController.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/CompanyController.java
new file mode 100644
index 0000000..78a8b27
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/CompanyController.java
@@ -0,0 +1,56 @@
+package com.zioinfo.mes.uiws.system.controller;
+
+import com.zioinfo.mes.common.ApiResponse;
+import com.zioinfo.mes.uiws.system.dto.CompanyDto;
+import com.zioinfo.mes.uiws.system.dto.CompanySaveDto;
+import com.zioinfo.mes.uiws.system.dto.IdsRequest;
+import com.zioinfo.mes.uiws.system.dto.PageResponse;
+import com.zioinfo.mes.uiws.system.service.CompanyService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/** 2.4 거래처(companies) — 목록/검색팝업/CRUD/다중삭제. base: /api/system/companies */
+@RestController
+@RequestMapping("/api/system/companies")
+@RequiredArgsConstructor
+public class CompanyController {
+
+ private final CompanyService companyService;
+
+ @GetMapping
+ public ApiResponse> list(
+ @RequestParam(required = false) String keyword,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "20") int size) {
+ return ApiResponse.ok(companyService.list(keyword, page, size));
+ }
+
+ @GetMapping("/search")
+ public ApiResponse> search(@RequestParam(required = false) String keyword) {
+ return ApiResponse.ok(companyService.searchPopup(keyword));
+ }
+
+ @PostMapping
+ public ApiResponse create(@Valid @RequestBody CompanySaveDto dto) {
+ return ApiResponse.ok(companyService.create(dto));
+ }
+
+ @GetMapping("/{id}")
+ public ApiResponse get(@PathVariable("id") String id) {
+ return ApiResponse.ok(companyService.get(id));
+ }
+
+ @PutMapping("/{id}")
+ public ApiResponse update(@PathVariable("id") String id, @Valid @RequestBody CompanySaveDto dto) {
+ return ApiResponse.ok(companyService.update(id, dto));
+ }
+
+ @DeleteMapping
+ public ApiResponse delete(@Valid @RequestBody IdsRequest req) {
+ companyService.delete(req.ids());
+ return ApiResponse.ok(null);
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/DeptController.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/DeptController.java
new file mode 100644
index 0000000..3d6f235
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/DeptController.java
@@ -0,0 +1,61 @@
+package com.zioinfo.mes.uiws.system.controller;
+
+import com.zioinfo.mes.common.ApiResponse;
+import com.zioinfo.mes.uiws.system.dto.DeptDto;
+import com.zioinfo.mes.uiws.system.dto.DeptSaveDto;
+import com.zioinfo.mes.uiws.system.dto.DeptTreeDto;
+import com.zioinfo.mes.uiws.system.dto.PageResponse;
+import com.zioinfo.mes.uiws.system.service.DeptService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/** 2.3 부서(depts) — 목록/검색팝업/트리/CRUD. base: /api/system/depts */
+@RestController
+@RequestMapping("/api/system/depts")
+@RequiredArgsConstructor
+public class DeptController {
+
+ private final DeptService deptService;
+
+ @GetMapping
+ public ApiResponse> list(
+ @RequestParam(required = false) String keyword,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "20") int size) {
+ return ApiResponse.ok(deptService.list(keyword, page, size));
+ }
+
+ @GetMapping("/search")
+ public ApiResponse> search(@RequestParam(required = false) String keyword) {
+ return ApiResponse.ok(deptService.searchPopup(keyword));
+ }
+
+ @GetMapping("/tree")
+ public ApiResponse> tree() {
+ return ApiResponse.ok(deptService.tree());
+ }
+
+ @PostMapping
+ public ApiResponse create(@Valid @RequestBody DeptSaveDto dto) {
+ return ApiResponse.ok(deptService.create(dto));
+ }
+
+ @GetMapping("/{id}")
+ public ApiResponse get(@PathVariable("id") String id) {
+ return ApiResponse.ok(deptService.get(id));
+ }
+
+ @PutMapping("/{id}")
+ public ApiResponse update(@PathVariable("id") String id, @Valid @RequestBody DeptSaveDto dto) {
+ return ApiResponse.ok(deptService.update(id, dto));
+ }
+
+ @DeleteMapping("/{id}")
+ public ApiResponse delete(@PathVariable("id") String id) {
+ deptService.delete(id);
+ return ApiResponse.ok(null);
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/DeptRoleController.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/DeptRoleController.java
new file mode 100644
index 0000000..f8b24a3
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/DeptRoleController.java
@@ -0,0 +1,39 @@
+package com.zioinfo.mes.uiws.system.controller;
+
+import com.zioinfo.mes.common.ApiResponse;
+import com.zioinfo.mes.uiws.system.dto.DeptUserRoleDto;
+import com.zioinfo.mes.uiws.system.dto.PageResponse;
+import com.zioinfo.mes.uiws.system.dto.RoleIdsRequest;
+import com.zioinfo.mes.uiws.system.service.DeptRoleService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+/** 2.2 부서권한(dept-role) — 부서 사용자+권한 조회 / 부여 / 삭제. base: /api/system/depts/{deptId}/roles */
+@RestController
+@RequestMapping("/api/system/depts/{deptId}/roles")
+@RequiredArgsConstructor
+public class DeptRoleController {
+
+ private final DeptRoleService deptRoleService;
+
+ @GetMapping
+ public ApiResponse> list(
+ @PathVariable("deptId") String deptId,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "20") int size) {
+ return ApiResponse.ok(deptRoleService.listDeptUsers(deptId, page, size));
+ }
+
+ @PostMapping
+ public ApiResponse grant(@PathVariable("deptId") String deptId, @Valid @RequestBody RoleIdsRequest req) {
+ deptRoleService.grant(deptId, req.roleIds());
+ return ApiResponse.ok(null);
+ }
+
+ @DeleteMapping
+ public ApiResponse revoke(@PathVariable("deptId") String deptId, @Valid @RequestBody RoleIdsRequest req) {
+ deptRoleService.revoke(deptId, req.roleIds());
+ return ApiResponse.ok(null);
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/MenuController.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/MenuController.java
new file mode 100644
index 0000000..111ca66
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/MenuController.java
@@ -0,0 +1,55 @@
+package com.zioinfo.mes.uiws.system.controller;
+
+import com.zioinfo.mes.common.ApiResponse;
+import com.zioinfo.mes.uiws.system.dto.IdsRequest;
+import com.zioinfo.mes.uiws.system.dto.MenuDto;
+import com.zioinfo.mes.uiws.system.dto.MenuSaveDto;
+import com.zioinfo.mes.uiws.system.dto.PageResponse;
+import com.zioinfo.mes.uiws.system.service.MenuService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/** 2.9 메뉴(menus) — 트리목록/검색/CRUD/다중삭제. base: /api/system/menus */
+@RestController
+@RequestMapping("/api/system/menus")
+@RequiredArgsConstructor
+public class MenuController {
+
+ private final MenuService menuService;
+
+ @GetMapping
+ public ApiResponse> list(
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "100") int size) {
+ return ApiResponse.ok(menuService.list(page, size));
+ }
+
+ @GetMapping("/search")
+ public ApiResponse> search(@RequestParam(required = false) String keyword) {
+ return ApiResponse.ok(menuService.searchPopup(keyword));
+ }
+
+ @PostMapping
+ public ApiResponse create(@Valid @RequestBody MenuSaveDto dto) {
+ return ApiResponse.ok(menuService.create(dto));
+ }
+
+ @GetMapping("/{id}")
+ public ApiResponse get(@PathVariable("id") String id) {
+ return ApiResponse.ok(menuService.get(id));
+ }
+
+ @PutMapping("/{id}")
+ public ApiResponse update(@PathVariable("id") String id, @Valid @RequestBody MenuSaveDto dto) {
+ return ApiResponse.ok(menuService.update(id, dto));
+ }
+
+ @DeleteMapping
+ public ApiResponse delete(@Valid @RequestBody IdsRequest req) {
+ menuService.delete(req.ids());
+ return ApiResponse.ok(null);
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/ProgramController.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/ProgramController.java
new file mode 100644
index 0000000..f01fe15
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/ProgramController.java
@@ -0,0 +1,57 @@
+package com.zioinfo.mes.uiws.system.controller;
+
+import com.zioinfo.mes.common.ApiResponse;
+import com.zioinfo.mes.uiws.system.dto.IdsRequest;
+import com.zioinfo.mes.uiws.system.dto.PageResponse;
+import com.zioinfo.mes.uiws.system.dto.ProgramDto;
+import com.zioinfo.mes.uiws.system.dto.ProgramSaveDto;
+import com.zioinfo.mes.uiws.system.service.ProgramService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/** 2.8 프로그램(programs) — 목록/검색/CRUD/다중삭제. base: /api/system/programs */
+@RestController
+@RequestMapping("/api/system/programs")
+@RequiredArgsConstructor
+public class ProgramController {
+
+ private final ProgramService programService;
+
+ @GetMapping
+ public ApiResponse> list(
+ @RequestParam(required = false) String keyword,
+ @RequestParam(required = false) String programType,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "20") int size) {
+ return ApiResponse.ok(programService.list(keyword, programType, page, size));
+ }
+
+ @GetMapping("/search")
+ public ApiResponse> search(@RequestParam(required = false) String keyword) {
+ return ApiResponse.ok(programService.searchPopup(keyword));
+ }
+
+ @PostMapping
+ public ApiResponse create(@Valid @RequestBody ProgramSaveDto dto) {
+ return ApiResponse.ok(programService.create(dto));
+ }
+
+ @GetMapping("/{id}")
+ public ApiResponse get(@PathVariable("id") String id) {
+ return ApiResponse.ok(programService.get(id));
+ }
+
+ @PutMapping("/{id}")
+ public ApiResponse update(@PathVariable("id") String id, @Valid @RequestBody ProgramSaveDto dto) {
+ return ApiResponse.ok(programService.update(id, dto));
+ }
+
+ @DeleteMapping
+ public ApiResponse delete(@Valid @RequestBody IdsRequest req) {
+ programService.delete(req.ids());
+ return ApiResponse.ok(null);
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/PublicLookupController.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/PublicLookupController.java
new file mode 100644
index 0000000..f391f92
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/PublicLookupController.java
@@ -0,0 +1,29 @@
+package com.zioinfo.mes.uiws.system.controller;
+
+import com.zioinfo.mes.common.ApiResponse;
+import com.zioinfo.mes.uiws.system.dto.PublicCompanyDto;
+import com.zioinfo.mes.uiws.system.dto.PublicDeptDto;
+import com.zioinfo.mes.uiws.system.service.PublicLookupService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/** 가입 화면(비인증) 공개 조회. base: /api/public/{depts,companies} */
+@RestController
+@RequestMapping("/api/public")
+@RequiredArgsConstructor
+public class PublicLookupController {
+
+ private final PublicLookupService publicLookupService;
+
+ @GetMapping("/depts")
+ public ApiResponse> depts() {
+ return ApiResponse.ok(publicLookupService.depts());
+ }
+
+ @GetMapping("/companies")
+ public ApiResponse> companies() {
+ return ApiResponse.ok(publicLookupService.companies());
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/RoleController.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/RoleController.java
new file mode 100644
index 0000000..311ffae
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/RoleController.java
@@ -0,0 +1,49 @@
+package com.zioinfo.mes.uiws.system.controller;
+
+import com.zioinfo.mes.common.ApiResponse;
+import com.zioinfo.mes.uiws.system.dto.IdsRequest;
+import com.zioinfo.mes.uiws.system.dto.PageResponse;
+import com.zioinfo.mes.uiws.system.dto.RoleDto;
+import com.zioinfo.mes.uiws.system.dto.RoleSaveDto;
+import com.zioinfo.mes.uiws.system.service.RoleService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+/** 2.1 권한(roles) — 목록/등록/상세/수정/다중삭제. base: /api/system/roles */
+@RestController
+@RequestMapping("/api/system/roles")
+@RequiredArgsConstructor
+public class RoleController {
+
+ private final RoleService roleService;
+
+ @GetMapping
+ public ApiResponse> list(
+ @RequestParam(required = false) String keyword,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "20") int size) {
+ return ApiResponse.ok(roleService.list(keyword, page, size));
+ }
+
+ @PostMapping
+ public ApiResponse create(@Valid @RequestBody RoleSaveDto dto) {
+ return ApiResponse.ok(roleService.create(dto));
+ }
+
+ @GetMapping("/{id}")
+ public ApiResponse get(@PathVariable("id") String id) {
+ return ApiResponse.ok(roleService.get(id));
+ }
+
+ @PutMapping("/{id}")
+ public ApiResponse update(@PathVariable("id") String id, @Valid @RequestBody RoleSaveDto dto) {
+ return ApiResponse.ok(roleService.update(id, dto));
+ }
+
+ @DeleteMapping
+ public ApiResponse delete(@Valid @RequestBody IdsRequest req) {
+ roleService.delete(req.ids());
+ return ApiResponse.ok(null);
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/RoleMenuController.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/RoleMenuController.java
new file mode 100644
index 0000000..e13a7dc
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/RoleMenuController.java
@@ -0,0 +1,39 @@
+package com.zioinfo.mes.uiws.system.controller;
+
+import com.zioinfo.mes.common.ApiResponse;
+import com.zioinfo.mes.uiws.system.dto.PageResponse;
+import com.zioinfo.mes.uiws.system.dto.RoleDto;
+import com.zioinfo.mes.uiws.system.dto.RoleMenuDto;
+import com.zioinfo.mes.uiws.system.dto.RoleMenuSaveRequest;
+import com.zioinfo.mes.uiws.system.service.RoleMenuService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/** 2.5 메뉴생성(role-menus) — 권한목록 / 권한별 메뉴매핑 조회·저장. */
+@RestController
+@RequiredArgsConstructor
+public class RoleMenuController {
+
+ private final RoleMenuService roleMenuService;
+
+ @GetMapping("/api/system/role-menus")
+ public ApiResponse> listRoles(
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "20") int size) {
+ return ApiResponse.ok(roleMenuService.listRoles(page, size));
+ }
+
+ @GetMapping("/api/system/roles/{roleId}/menus")
+ public ApiResponse> getRoleMenus(@PathVariable("roleId") String roleId) {
+ return ApiResponse.ok(roleMenuService.getRoleMenus(roleId));
+ }
+
+ @PutMapping("/api/system/roles/{roleId}/menus")
+ public ApiResponse saveRoleMenus(@PathVariable("roleId") String roleId, @Valid @RequestBody RoleMenuSaveRequest req) {
+ roleMenuService.saveRoleMenus(roleId, req.menus());
+ return ApiResponse.ok(null);
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/UserController.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/UserController.java
new file mode 100644
index 0000000..ebc83d8
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/controller/UserController.java
@@ -0,0 +1,89 @@
+package com.zioinfo.mes.uiws.system.controller;
+
+import com.zioinfo.mes.common.ApiResponse;
+import com.zioinfo.mes.uiws.system.dto.CheckIdResponse;
+import com.zioinfo.mes.uiws.system.dto.IdsRequest;
+import com.zioinfo.mes.uiws.system.dto.PageResponse;
+import com.zioinfo.mes.uiws.system.dto.UserDto;
+import com.zioinfo.mes.uiws.system.dto.UserSaveDto;
+import com.zioinfo.mes.uiws.system.service.UserService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/** 2.6 사용자(users) — 목록/검색/중복ID확인/CRUD/다중삭제/비번초기화/잠금해제/승인. base: /api/system/users */
+@RestController
+@RequestMapping("/api/system/users")
+@RequiredArgsConstructor
+public class UserController {
+
+ private final UserService userService;
+
+ @GetMapping
+ public ApiResponse> list(
+ @RequestParam(required = false) String keyword,
+ @RequestParam(required = false) String deptId,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "20") int size) {
+ return ApiResponse.ok(userService.list(keyword, deptId, page, size));
+ }
+
+ @GetMapping("/search")
+ public ApiResponse> search(
+ @RequestParam(required = false) String keyword,
+ @RequestParam(required = false) String deptId) {
+ return ApiResponse.ok(userService.searchPopup(keyword, deptId));
+ }
+
+ @GetMapping("/check-id")
+ public ApiResponse checkId(@RequestParam String userId) {
+ return ApiResponse.ok(userService.checkId(userId));
+ }
+
+ @PostMapping
+ public ApiResponse create(@Valid @RequestBody UserSaveDto dto) {
+ return ApiResponse.ok(userService.create(dto));
+ }
+
+ @GetMapping("/{id}")
+ public ApiResponse get(@PathVariable("id") String id) {
+ return ApiResponse.ok(userService.get(id));
+ }
+
+ @PutMapping("/{id}")
+ public ApiResponse update(@PathVariable("id") String id, @Valid @RequestBody UserSaveDto dto) {
+ return ApiResponse.ok(userService.update(id, dto));
+ }
+
+ @DeleteMapping
+ public ApiResponse delete(@Valid @RequestBody IdsRequest req) {
+ userService.delete(req.ids());
+ return ApiResponse.ok(null);
+ }
+
+ @PostMapping("/{id}/reset-pw")
+ public ApiResponse resetPassword(@PathVariable("id") String id) {
+ userService.resetPassword(id);
+ return ApiResponse.ok(null);
+ }
+
+ @PostMapping("/{id}/unlock")
+ public ApiResponse unlock(@PathVariable("id") String id) {
+ userService.unlock(id);
+ return ApiResponse.ok(null);
+ }
+
+ @PostMapping("/{id}/approve")
+ public ApiResponse approve(@PathVariable("id") String id) {
+ userService.approve(id);
+ return ApiResponse.ok(null);
+ }
+
+ @PostMapping("/{id}/revoke-approval")
+ public ApiResponse revokeApproval(@PathVariable("id") String id) {
+ userService.revokeApproval(id);
+ return ApiResponse.ok(null);
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/CheckIdResponse.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/CheckIdResponse.java
new file mode 100644
index 0000000..8ef7a21
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/CheckIdResponse.java
@@ -0,0 +1,4 @@
+package com.zioinfo.mes.uiws.system.dto;
+
+/** { available: boolean } */
+public record CheckIdResponse(boolean available) {}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/CodeGrpDetailDto.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/CodeGrpDetailDto.java
new file mode 100644
index 0000000..adc781f
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/CodeGrpDetailDto.java
@@ -0,0 +1,6 @@
+package com.zioinfo.mes.uiws.system.dto;
+
+import java.util.List;
+
+/** { grpCd, grpNm, useYn, values: CodeValueDto[] } */
+public record CodeGrpDetailDto(String grpCd, String grpNm, String useYn, List values) {}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/CodeGrpDto.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/CodeGrpDto.java
new file mode 100644
index 0000000..acc4b40
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/CodeGrpDto.java
@@ -0,0 +1,4 @@
+package com.zioinfo.mes.uiws.system.dto;
+
+/** { grpCd, grpNm, useYn } */
+public record CodeGrpDto(String grpCd, String grpNm, String useYn) {}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/CodeGrpSaveDto.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/CodeGrpSaveDto.java
new file mode 100644
index 0000000..e9883a6
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/CodeGrpSaveDto.java
@@ -0,0 +1,11 @@
+package com.zioinfo.mes.uiws.system.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import java.util.List;
+
+/** { grpCd, grpNm, useYn, values: CodeValueDto[] } */
+public record CodeGrpSaveDto(
+ @NotBlank(message = "grpCd는 필수입니다.") String grpCd,
+ @NotBlank(message = "grpNm은 필수입니다.") String grpNm,
+ @NotBlank(message = "useYn은 필수입니다.") String useYn,
+ List values) {}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/CodeValueDto.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/CodeValueDto.java
new file mode 100644
index 0000000..0e35898
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/CodeValueDto.java
@@ -0,0 +1,4 @@
+package com.zioinfo.mes.uiws.system.dto;
+
+/** { grpCd, codeVal, codeNm, sortOrd, useYn } */
+public record CodeValueDto(String grpCd, String codeVal, String codeNm, Integer sortOrd, String useYn) {}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/CompanyDto.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/CompanyDto.java
new file mode 100644
index 0000000..cd5fadc
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/CompanyDto.java
@@ -0,0 +1,4 @@
+package com.zioinfo.mes.uiws.system.dto;
+
+/** { companyId, companyNm, bizNo, useYn } */
+public record CompanyDto(String companyId, String companyNm, String bizNo, String useYn) {}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/CompanySaveDto.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/CompanySaveDto.java
new file mode 100644
index 0000000..6c9aff3
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/CompanySaveDto.java
@@ -0,0 +1,10 @@
+package com.zioinfo.mes.uiws.system.dto;
+
+import jakarta.validation.constraints.NotBlank;
+
+/** { companyId, companyNm, bizNo, useYn } */
+public record CompanySaveDto(
+ String companyId,
+ @NotBlank(message = "companyNm은 필수입니다.") String companyNm,
+ String bizNo,
+ @NotBlank(message = "useYn은 필수입니다.") String useYn) {}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/DeptDto.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/DeptDto.java
new file mode 100644
index 0000000..8ee5771
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/DeptDto.java
@@ -0,0 +1,4 @@
+package com.zioinfo.mes.uiws.system.dto;
+
+/** { deptId, deptNm, parentDeptId, sortOrd, useYn } */
+public record DeptDto(String deptId, String deptNm, String parentDeptId, Integer sortOrd, String useYn) {}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/DeptSaveDto.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/DeptSaveDto.java
new file mode 100644
index 0000000..f1a4cd4
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/DeptSaveDto.java
@@ -0,0 +1,11 @@
+package com.zioinfo.mes.uiws.system.dto;
+
+import jakarta.validation.constraints.NotBlank;
+
+/** { deptId, deptNm, parentDeptId, sortOrd, useYn } */
+public record DeptSaveDto(
+ String deptId,
+ @NotBlank(message = "deptNm은 필수입니다.") String deptNm,
+ String parentDeptId,
+ Integer sortOrd,
+ @NotBlank(message = "useYn은 필수입니다.") String useYn) {}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/DeptTreeDto.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/DeptTreeDto.java
new file mode 100644
index 0000000..e9d9a06
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/DeptTreeDto.java
@@ -0,0 +1,8 @@
+package com.zioinfo.mes.uiws.system.dto;
+
+import java.util.List;
+
+/** 부서 계층 트리 노드. children 은 sortOrd→deptId 순. */
+public record DeptTreeDto(
+ String deptId, String deptNm, String parentDeptId,
+ Integer sortOrd, String useYn, List children) {}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/DeptUserRoleDto.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/DeptUserRoleDto.java
new file mode 100644
index 0000000..83a2311
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/DeptUserRoleDto.java
@@ -0,0 +1,6 @@
+package com.zioinfo.mes.uiws.system.dto;
+
+import java.util.List;
+
+/** { userId, userNm, roleIds } — 부서 사용자별 부여 권한. */
+public record DeptUserRoleDto(String userId, String userNm, List roleIds) {}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/IdsRequest.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/IdsRequest.java
new file mode 100644
index 0000000..895a524
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/IdsRequest.java
@@ -0,0 +1,7 @@
+package com.zioinfo.mes.uiws.system.dto;
+
+import jakarta.validation.constraints.NotEmpty;
+import java.util.List;
+
+/** 다중삭제 공통 본문: { ids: string[] } */
+public record IdsRequest(@NotEmpty(message = "ids는 필수입니다.") List ids) {}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/MenuDto.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/MenuDto.java
new file mode 100644
index 0000000..f191b23
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/MenuDto.java
@@ -0,0 +1,6 @@
+package com.zioinfo.mes.uiws.system.dto;
+
+/** { menuId, menuNm, parentMenuId, programId, menuUrl, sortOrd, useYn } */
+public record MenuDto(
+ String menuId, String menuNm, String parentMenuId, String programId,
+ String menuUrl, Integer sortOrd, String useYn) {}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/MenuSaveDto.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/MenuSaveDto.java
new file mode 100644
index 0000000..cccc913
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/MenuSaveDto.java
@@ -0,0 +1,10 @@
+package com.zioinfo.mes.uiws.system.dto;
+
+import jakarta.validation.constraints.NotBlank;
+
+/** { menuId, menuNm, parentMenuId, programId, menuUrl, sortOrd, useYn } */
+public record MenuSaveDto(
+ String menuId,
+ @NotBlank(message = "menuNm은 필수입니다.") String menuNm,
+ String parentMenuId, String programId, String menuUrl, Integer sortOrd,
+ @NotBlank(message = "useYn은 필수입니다.") String useYn) {}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/PageResponse.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/PageResponse.java
new file mode 100644
index 0000000..1a6e5c0
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/PageResponse.java
@@ -0,0 +1,18 @@
+package com.zioinfo.mes.uiws.system.dto;
+
+import java.util.List;
+
+/**
+ * UIWS system 이식용 페이지 응답 봉투. 원본 com.urp.uiws.common.response.PageResponse 대체.
+ * MyBatis 기반(Spring Data Page 부재)이므로 content + total + page + size 를 직접 담는다.
+ */
+public record PageResponse(
+ List content,
+ long total,
+ int page,
+ int size
+) {
+ public static PageResponse of(List content, long total, int page, int size) {
+ return new PageResponse<>(content, total, page, size);
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/ProgramDto.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/ProgramDto.java
new file mode 100644
index 0000000..88d486d
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/ProgramDto.java
@@ -0,0 +1,6 @@
+package com.zioinfo.mes.uiws.system.dto;
+
+/** { programId, programNm, programType, programUrl, category, useYn } */
+public record ProgramDto(
+ String programId, String programNm, String programType,
+ String programUrl, String category, String useYn) {}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/ProgramSaveDto.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/ProgramSaveDto.java
new file mode 100644
index 0000000..78e19cc
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/ProgramSaveDto.java
@@ -0,0 +1,11 @@
+package com.zioinfo.mes.uiws.system.dto;
+
+import jakarta.validation.constraints.NotBlank;
+
+/** { programId, programNm, programType, programUrl, category, useYn } */
+public record ProgramSaveDto(
+ @NotBlank(message = "programId는 필수입니다.") String programId,
+ @NotBlank(message = "programNm은 필수입니다.") String programNm,
+ @NotBlank(message = "programType은 필수입니다.") String programType,
+ String programUrl, String category,
+ @NotBlank(message = "useYn은 필수입니다.") String useYn) {}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/PublicCompanyDto.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/PublicCompanyDto.java
new file mode 100644
index 0000000..6a30657
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/PublicCompanyDto.java
@@ -0,0 +1,4 @@
+package com.zioinfo.mes.uiws.system.dto;
+
+/** 가입 화면 공개 조회용 거래처: { companyId, companyNm } */
+public record PublicCompanyDto(String companyId, String companyNm) {}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/PublicDeptDto.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/PublicDeptDto.java
new file mode 100644
index 0000000..9f0aaa7
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/PublicDeptDto.java
@@ -0,0 +1,4 @@
+package com.zioinfo.mes.uiws.system.dto;
+
+/** 가입 화면 공개 조회용 부서: { deptId, deptNm } */
+public record PublicDeptDto(String deptId, String deptNm) {}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/RoleDto.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/RoleDto.java
new file mode 100644
index 0000000..a4e47fc
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/RoleDto.java
@@ -0,0 +1,4 @@
+package com.zioinfo.mes.uiws.system.dto;
+
+/** { roleId, roleNm, roleDesc, useYn } */
+public record RoleDto(String roleId, String roleNm, String roleDesc, String useYn) {}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/RoleIdsRequest.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/RoleIdsRequest.java
new file mode 100644
index 0000000..56e281b
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/RoleIdsRequest.java
@@ -0,0 +1,7 @@
+package com.zioinfo.mes.uiws.system.dto;
+
+import jakarta.validation.constraints.NotEmpty;
+import java.util.List;
+
+/** 부서권한 부여/삭제 본문: { roleIds: string[] } */
+public record RoleIdsRequest(@NotEmpty(message = "roleIds는 필수입니다.") List roleIds) {}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/RoleMenuDto.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/RoleMenuDto.java
new file mode 100644
index 0000000..7ba79ec
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/RoleMenuDto.java
@@ -0,0 +1,4 @@
+package com.zioinfo.mes.uiws.system.dto;
+
+/** { menuId, menuNm, readYn, writeYn } */
+public record RoleMenuDto(String menuId, String menuNm, String readYn, String writeYn) {}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/RoleMenuSaveRequest.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/RoleMenuSaveRequest.java
new file mode 100644
index 0000000..92a65e5
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/RoleMenuSaveRequest.java
@@ -0,0 +1,6 @@
+package com.zioinfo.mes.uiws.system.dto;
+
+import java.util.List;
+
+/** 권한별 메뉴 매핑 저장 본문: { menus: RoleMenuDto[] } */
+public record RoleMenuSaveRequest(List menus) {}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/RoleSaveDto.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/RoleSaveDto.java
new file mode 100644
index 0000000..f30a2b5
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/RoleSaveDto.java
@@ -0,0 +1,10 @@
+package com.zioinfo.mes.uiws.system.dto;
+
+import jakarta.validation.constraints.NotBlank;
+
+/** { roleId, roleNm, roleDesc, useYn } */
+public record RoleSaveDto(
+ String roleId,
+ @NotBlank(message = "roleNm은 필수입니다.") String roleNm,
+ String roleDesc,
+ @NotBlank(message = "useYn은 필수입니다.") String useYn) {}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/UserDto.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/UserDto.java
new file mode 100644
index 0000000..0974397
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/UserDto.java
@@ -0,0 +1,7 @@
+package com.zioinfo.mes.uiws.system.dto;
+
+/** 사용자 조회 DTO(비밀번호 제외 — 절대 노출 금지). */
+public record UserDto(
+ String userId, String userNm, String email, String gradeCd,
+ String deptId, String deptNm, String companyId, String companyNm,
+ String roleCd, String naverworksId, String lockYn, String useYn, String approvalYn) {}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/UserSaveDto.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/UserSaveDto.java
new file mode 100644
index 0000000..0dd3e63
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/dto/UserSaveDto.java
@@ -0,0 +1,13 @@
+package com.zioinfo.mes.uiws.system.dto;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+
+/** roleCd = USER/MANAGER/ADMIN (null → USER). 시스템관리(ADMIN 전용)에서만 설정. */
+public record UserSaveDto(
+ @NotBlank(message = "userId는 필수입니다.") String userId,
+ @NotBlank(message = "userNm은 필수입니다.") String userNm,
+ String password,
+ @NotBlank(message = "email은 필수입니다.") @Email(message = "email 형식이 올바르지 않습니다.") String email,
+ String gradeCd, String deptId, String companyId, String roleCd, String naverworksId,
+ @NotBlank(message = "useYn은 필수입니다.") String useYn) {}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/mapper/CodeMapper.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/mapper/CodeMapper.java
new file mode 100644
index 0000000..e165749
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/mapper/CodeMapper.java
@@ -0,0 +1,23 @@
+package com.zioinfo.mes.uiws.system.mapper;
+
+import com.zioinfo.mes.uiws.system.model.*;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import java.util.List;
+
+/** 공통코드 그룹/값(tb_uiws_code_grp, tb_uiws_code) 매퍼. 원본 CodeGrpRepository/CodeRepository 변환. */
+@Mapper
+public interface CodeMapper {
+ List searchGroups(@Param("keyword") String keyword,
+ @Param("offset") int offset, @Param("size") int size);
+ long countGroups(@Param("keyword") String keyword);
+ SysCodeGrp findGroupById(@Param("grpCd") String grpCd);
+ boolean groupExists(@Param("grpCd") String grpCd);
+ int insertGroup(SysCodeGrp grp);
+ int updateGroup(SysCodeGrp grp);
+ int deleteGroup(@Param("grpCd") String grpCd);
+ List findValuesByGrp(@Param("grpCd") String grpCd);
+ List findActiveValuesByGrp(@Param("grpCd") String grpCd);
+ int insertValue(SysCode code);
+ int deleteValuesByGrp(@Param("grpCd") String grpCd);
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/mapper/CompanyMapper.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/mapper/CompanyMapper.java
new file mode 100644
index 0000000..3ca43fa
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/mapper/CompanyMapper.java
@@ -0,0 +1,22 @@
+package com.zioinfo.mes.uiws.system.mapper;
+
+import com.zioinfo.mes.uiws.system.model.*;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import java.util.List;
+
+/** 거래처(tb_uiws_company) 매퍼. 원본 CompanyRepository 변환. */
+@Mapper
+public interface CompanyMapper {
+ List search(@Param("keyword") String keyword,
+ @Param("offset") int offset, @Param("size") int size);
+ long countSearch(@Param("keyword") String keyword);
+ List searchActive(@Param("keyword") String keyword);
+ List findActiveOrdered();
+ List findAll();
+ SysCompany findById(@Param("companyId") String companyId);
+ boolean existsById(@Param("companyId") String companyId);
+ int insert(SysCompany company);
+ int update(SysCompany company);
+ int deleteById(@Param("companyId") String companyId);
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/mapper/DeptMapper.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/mapper/DeptMapper.java
new file mode 100644
index 0000000..9198e24
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/mapper/DeptMapper.java
@@ -0,0 +1,23 @@
+package com.zioinfo.mes.uiws.system.mapper;
+
+import com.zioinfo.mes.uiws.system.model.*;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import java.util.List;
+
+/** 부서(tb_uiws_dept) 매퍼. 원본 DeptRepository 변환. */
+@Mapper
+public interface DeptMapper {
+ List search(@Param("keyword") String keyword,
+ @Param("offset") int offset, @Param("size") int size);
+ long countSearch(@Param("keyword") String keyword);
+ List searchActive(@Param("keyword") String keyword);
+ List findActiveOrdered();
+ List findAll();
+ SysDept findById(@Param("deptId") String deptId);
+ boolean existsById(@Param("deptId") String deptId);
+ boolean existsByParentDeptId(@Param("parentDeptId") String parentDeptId);
+ int insert(SysDept dept);
+ int update(SysDept dept);
+ int deleteById(@Param("deptId") String deptId);
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/mapper/DeptRoleMapper.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/mapper/DeptRoleMapper.java
new file mode 100644
index 0000000..7b28d79
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/mapper/DeptRoleMapper.java
@@ -0,0 +1,16 @@
+package com.zioinfo.mes.uiws.system.mapper;
+
+import com.zioinfo.mes.uiws.system.model.*;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import java.util.List;
+
+/** 부서-권한 매핑(tb_uiws_dept_role) 매퍼. 원본 DeptRoleRepository 변환. */
+@Mapper
+public interface DeptRoleMapper {
+ List findRoleIdsByDeptId(@Param("deptId") String deptId);
+ boolean exists(@Param("deptId") String deptId, @Param("roleId") String roleId);
+ boolean existsByRoleId(@Param("roleId") String roleId);
+ int insert(@Param("deptId") String deptId, @Param("roleId") String roleId, @Param("actor") String actor);
+ int delete(@Param("deptId") String deptId, @Param("roleId") String roleId);
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/mapper/MenuMapper.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/mapper/MenuMapper.java
new file mode 100644
index 0000000..3839657
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/mapper/MenuMapper.java
@@ -0,0 +1,22 @@
+package com.zioinfo.mes.uiws.system.mapper;
+
+import com.zioinfo.mes.uiws.system.model.*;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import java.util.List;
+
+/** 메뉴(tb_uiws_menu) 매퍼. 원본 SysMenuRepository 변환. */
+@Mapper
+public interface MenuMapper {
+ List searchAll(@Param("offset") int offset, @Param("size") int size);
+ long countAll();
+ List search(@Param("keyword") String keyword);
+ List findAllOrdered();
+ SysMenu findById(@Param("menuId") String menuId);
+ boolean existsById(@Param("menuId") String menuId);
+ boolean existsByParentMenuId(@Param("parentMenuId") String parentMenuId);
+ boolean existsByProgramId(@Param("programId") String programId);
+ int insert(SysMenu menu);
+ int update(SysMenu menu);
+ int deleteById(@Param("menuId") String menuId);
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/mapper/ProgramMapper.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/mapper/ProgramMapper.java
new file mode 100644
index 0000000..de75822
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/mapper/ProgramMapper.java
@@ -0,0 +1,20 @@
+package com.zioinfo.mes.uiws.system.mapper;
+
+import com.zioinfo.mes.uiws.system.model.*;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import java.util.List;
+
+/** 프로그램(tb_uiws_program) 매퍼. 원본 ProgramRepository 변환. */
+@Mapper
+public interface ProgramMapper {
+ List search(@Param("keyword") String keyword, @Param("programType") String programType,
+ @Param("offset") int offset, @Param("size") int size);
+ long countSearch(@Param("keyword") String keyword, @Param("programType") String programType);
+ List searchActive(@Param("keyword") String keyword);
+ SysProgram findById(@Param("programId") String programId);
+ boolean existsById(@Param("programId") String programId);
+ int insert(SysProgram program);
+ int update(SysProgram program);
+ int deleteById(@Param("programId") String programId);
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/mapper/RoleMapper.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/mapper/RoleMapper.java
new file mode 100644
index 0000000..297ec62
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/mapper/RoleMapper.java
@@ -0,0 +1,19 @@
+package com.zioinfo.mes.uiws.system.mapper;
+
+import com.zioinfo.mes.uiws.system.model.*;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import java.util.List;
+
+/** 권한(tb_uiws_role) 매퍼. 원본 RoleRepository 변환. */
+@Mapper
+public interface RoleMapper {
+ List search(@Param("keyword") String keyword,
+ @Param("offset") int offset, @Param("size") int size);
+ long countSearch(@Param("keyword") String keyword);
+ SysRole findById(@Param("roleId") String roleId);
+ boolean existsById(@Param("roleId") String roleId);
+ int insert(SysRole role);
+ int update(SysRole role);
+ int deleteById(@Param("roleId") String roleId);
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/mapper/RoleMenuMapper.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/mapper/RoleMenuMapper.java
new file mode 100644
index 0000000..65210ad
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/mapper/RoleMenuMapper.java
@@ -0,0 +1,15 @@
+package com.zioinfo.mes.uiws.system.mapper;
+
+import com.zioinfo.mes.uiws.system.model.*;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import java.util.List;
+
+/** 권한-메뉴 매핑(tb_uiws_role_menu) 매퍼. 원본 RoleMenuRepository 변환. */
+@Mapper
+public interface RoleMenuMapper {
+ List findByRoleId(@Param("roleId") String roleId);
+ boolean existsByMenuId(@Param("menuId") String menuId);
+ int insert(SysRoleMenu rm);
+ int deleteByRoleId(@Param("roleId") String roleId);
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/mapper/SysUserMapper.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/mapper/SysUserMapper.java
new file mode 100644
index 0000000..05aeaf2
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/mapper/SysUserMapper.java
@@ -0,0 +1,20 @@
+package com.zioinfo.mes.uiws.system.mapper;
+
+import com.zioinfo.mes.uiws.system.model.*;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import java.util.List;
+
+/** 업무 사용자(tb_uiws_sys_user) 매퍼. 원본 SysUserRepository 변환. */
+@Mapper
+public interface SysUserMapper {
+ List search(@Param("keyword") String keyword, @Param("deptId") String deptId,
+ @Param("offset") int offset, @Param("size") int size);
+ long countSearch(@Param("keyword") String keyword, @Param("deptId") String deptId);
+ List searchActive(@Param("keyword") String keyword, @Param("deptId") String deptId);
+ List findByDeptId(@Param("deptId") String deptId);
+ SysUser findById(@Param("userId") String userId);
+ boolean existsById(@Param("userId") String userId);
+ int insert(SysUser user);
+ int update(SysUser user);
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/model/SysCode.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/model/SysCode.java
new file mode 100644
index 0000000..eeaf3fc
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/model/SysCode.java
@@ -0,0 +1,18 @@
+package com.zioinfo.mes.uiws.system.model;
+
+import lombok.Data;
+import java.time.LocalDateTime;
+
+/** 공통코드 값 (tb_uiws_code, 복합 PK grp_cd+code_val). 원본 com.urp.uiws.domain.Code 이식. */
+@Data
+public class SysCode {
+ private String grpCd;
+ private String codeVal;
+ private String codeNm;
+ private Integer sortOrd;
+ private String useYn;
+ private String createdBy;
+ private LocalDateTime createdAt;
+ private String updatedBy;
+ private LocalDateTime updatedAt;
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/model/SysCodeGrp.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/model/SysCodeGrp.java
new file mode 100644
index 0000000..5a0dfe0
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/model/SysCodeGrp.java
@@ -0,0 +1,16 @@
+package com.zioinfo.mes.uiws.system.model;
+
+import lombok.Data;
+import java.time.LocalDateTime;
+
+/** 공통코드 그룹 (tb_uiws_code_grp). 원본 com.urp.uiws.domain.CodeGrp 이식. */
+@Data
+public class SysCodeGrp {
+ private String grpCd;
+ private String grpNm;
+ private String useYn;
+ private String createdBy;
+ private LocalDateTime createdAt;
+ private String updatedBy;
+ private LocalDateTime updatedAt;
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/model/SysCompany.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/model/SysCompany.java
new file mode 100644
index 0000000..c7c50b2
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/model/SysCompany.java
@@ -0,0 +1,17 @@
+package com.zioinfo.mes.uiws.system.model;
+
+import lombok.Data;
+import java.time.LocalDateTime;
+
+/** 거래처(=근무처) (tb_uiws_company). 원본 com.urp.uiws.domain.Company 이식. */
+@Data
+public class SysCompany {
+ private String companyId;
+ private String companyNm;
+ private String bizNo;
+ private String useYn;
+ private String createdBy;
+ private LocalDateTime createdAt;
+ private String updatedBy;
+ private LocalDateTime updatedAt;
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/model/SysDept.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/model/SysDept.java
new file mode 100644
index 0000000..cc0eddd
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/model/SysDept.java
@@ -0,0 +1,18 @@
+package com.zioinfo.mes.uiws.system.model;
+
+import lombok.Data;
+import java.time.LocalDateTime;
+
+/** 부서 (tb_uiws_dept). 원본 com.urp.uiws.domain.Dept 이식. */
+@Data
+public class SysDept {
+ private String deptId;
+ private String deptNm;
+ private String parentDeptId;
+ private Integer sortOrd;
+ private String useYn;
+ private String createdBy;
+ private LocalDateTime createdAt;
+ private String updatedBy;
+ private LocalDateTime updatedAt;
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/model/SysMenu.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/model/SysMenu.java
new file mode 100644
index 0000000..df7284f
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/model/SysMenu.java
@@ -0,0 +1,20 @@
+package com.zioinfo.mes.uiws.system.model;
+
+import lombok.Data;
+import java.time.LocalDateTime;
+
+/** 메뉴 (tb_uiws_menu, 2-depth 자기참조). 원본 com.urp.uiws.domain.Menu 이식. */
+@Data
+public class SysMenu {
+ private String menuId;
+ private String menuNm;
+ private String parentMenuId;
+ private String programId;
+ private String menuUrl;
+ private Integer sortOrd;
+ private String useYn;
+ private String createdBy;
+ private LocalDateTime createdAt;
+ private String updatedBy;
+ private LocalDateTime updatedAt;
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/model/SysProgram.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/model/SysProgram.java
new file mode 100644
index 0000000..9df59fb
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/model/SysProgram.java
@@ -0,0 +1,19 @@
+package com.zioinfo.mes.uiws.system.model;
+
+import lombok.Data;
+import java.time.LocalDateTime;
+
+/** 프로그램(화면) (tb_uiws_program). 원본 com.urp.uiws.domain.Program 이식. */
+@Data
+public class SysProgram {
+ private String programId;
+ private String programNm;
+ private String programType; // FORM | POPUP
+ private String programUrl;
+ private String category;
+ private String useYn;
+ private String createdBy;
+ private LocalDateTime createdAt;
+ private String updatedBy;
+ private LocalDateTime updatedAt;
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/model/SysRole.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/model/SysRole.java
new file mode 100644
index 0000000..0ccd4b0
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/model/SysRole.java
@@ -0,0 +1,17 @@
+package com.zioinfo.mes.uiws.system.model;
+
+import lombok.Data;
+import java.time.LocalDateTime;
+
+/** 권한(역할) (tb_uiws_role). 원본 com.urp.uiws.domain.Role 이식. */
+@Data
+public class SysRole {
+ private String roleId;
+ private String roleNm;
+ private String roleDesc;
+ private String useYn;
+ private String createdBy;
+ private LocalDateTime createdAt;
+ private String updatedBy;
+ private LocalDateTime updatedAt;
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/model/SysRoleMenu.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/model/SysRoleMenu.java
new file mode 100644
index 0000000..ff23952
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/model/SysRoleMenu.java
@@ -0,0 +1,17 @@
+package com.zioinfo.mes.uiws.system.model;
+
+import lombok.Data;
+import java.time.LocalDateTime;
+
+/** 권한-메뉴 매핑 (tb_uiws_role_menu, 복합 PK). 원본 com.urp.uiws.domain.RoleMenu 이식. */
+@Data
+public class SysRoleMenu {
+ private String roleId;
+ private String menuId;
+ private String readYn;
+ private String writeYn;
+ private String createdBy;
+ private LocalDateTime createdAt;
+ private String updatedBy;
+ private LocalDateTime updatedAt;
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/model/SysUser.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/model/SysUser.java
new file mode 100644
index 0000000..a51aef9
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/model/SysUser.java
@@ -0,0 +1,29 @@
+package com.zioinfo.mes.uiws.system.model;
+
+import lombok.Data;
+import java.time.LocalDateTime;
+
+/** 업무 사용자 (tb_uiws_sys_user). 원본 com.urp.uiws.domain.User 이식. password 는 BCrypt. */
+@Data
+public class SysUser {
+ private String userId;
+ private String userNm;
+ private String password;
+ private String email;
+ private String gradeCd;
+ private String deptId;
+ private String companyId;
+ private String roleCd; // USER | MANAGER | ADMIN
+ private String naverworksId;
+ private Integer loginFailCnt;
+ private String lockYn;
+ private String useYn;
+ private String approvalYn;
+ private String verifyMethod;
+ private String otpSecret;
+ private String pwChangeYn;
+ private String createdBy;
+ private LocalDateTime createdAt;
+ private String updatedBy;
+ private LocalDateTime updatedAt;
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/service/CodeService.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/service/CodeService.java
new file mode 100644
index 0000000..5b86c6e
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/service/CodeService.java
@@ -0,0 +1,119 @@
+package com.zioinfo.mes.uiws.system.service;
+
+import com.zioinfo.mes.uiws.common.UiwsApiException;
+import com.zioinfo.mes.uiws.common.UiwsErrorCode;
+import com.zioinfo.mes.uiws.system.dto.CodeGrpDetailDto;
+import com.zioinfo.mes.uiws.system.dto.CodeGrpDto;
+import com.zioinfo.mes.uiws.system.dto.CodeGrpSaveDto;
+import com.zioinfo.mes.uiws.system.dto.CodeValueDto;
+import com.zioinfo.mes.uiws.system.dto.PageResponse;
+import com.zioinfo.mes.uiws.system.mapper.CodeMapper;
+import com.zioinfo.mes.uiws.system.model.SysCode;
+import com.zioinfo.mes.uiws.system.model.SysCodeGrp;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * 2.7 코드(codes). 그룹 + 값 복합 관리. 그룹 수정/등록 시 값 목록 전체 치환.
+ * 사용중(use_yn='Y') 코드값이 있으면 그룹 삭제 차단.
+ */
+@Service
+@RequiredArgsConstructor
+public class CodeService {
+
+ private final CodeMapper codeMapper;
+
+ @Transactional(readOnly = true)
+ public PageResponse listGroups(String keyword, int page, int size) {
+ List content = codeMapper.searchGroups(keyword, page * size, size).stream()
+ .map(g -> new CodeGrpDto(g.getGrpCd(), g.getGrpNm(), g.getUseYn())).toList();
+ return PageResponse.of(content, codeMapper.countGroups(keyword), page, size);
+ }
+
+ @Transactional(readOnly = true)
+ public CodeGrpDetailDto getGroup(String grpCd) {
+ return toDetail(findGrp(grpCd));
+ }
+
+ @Transactional(readOnly = true)
+ public List getValues(String grpCd) {
+ return codeMapper.findActiveValuesByGrp(grpCd).stream().map(this::toValue).toList();
+ }
+
+ @Transactional
+ public CodeGrpDetailDto create(CodeGrpSaveDto dto) {
+ if (codeMapper.groupExists(dto.grpCd())) {
+ throw new UiwsApiException(UiwsErrorCode.DUPLICATE_KEY, "이미 존재하는 코드그룹입니다.");
+ }
+ SysCodeGrp grp = new SysCodeGrp();
+ grp.setGrpCd(dto.grpCd());
+ grp.setGrpNm(dto.grpNm());
+ grp.setUseYn(dto.useYn());
+ grp.setCreatedBy(SysActor.id());
+ codeMapper.insertGroup(grp);
+ replaceValues(dto.grpCd(), dto.values());
+ return toDetail(findGrp(dto.grpCd()));
+ }
+
+ @Transactional
+ public CodeGrpDetailDto update(String grpCd, CodeGrpSaveDto dto) {
+ SysCodeGrp grp = findGrp(grpCd);
+ grp.setGrpNm(dto.grpNm());
+ grp.setUseYn(dto.useYn());
+ grp.setUpdatedBy(SysActor.id());
+ codeMapper.updateGroup(grp);
+ codeMapper.deleteValuesByGrp(grpCd);
+ replaceValues(grpCd, dto.values());
+ return toDetail(findGrp(grpCd));
+ }
+
+ @Transactional
+ public void delete(String grpCd) {
+ findGrp(grpCd);
+ boolean inUse = codeMapper.findValuesByGrp(grpCd).stream().anyMatch(c -> "Y".equals(c.getUseYn()));
+ if (inUse) {
+ throw new UiwsApiException(UiwsErrorCode.RESOURCE_IN_USE,
+ "사용중인 코드값이 존재하여 코드그룹을 삭제할 수 없습니다.");
+ }
+ codeMapper.deleteValuesByGrp(grpCd);
+ codeMapper.deleteGroup(grpCd);
+ }
+
+ private void replaceValues(String grpCd, List values) {
+ if (values == null) {
+ return;
+ }
+ String actor = SysActor.id();
+ for (CodeValueDto v : values) {
+ SysCode code = new SysCode();
+ code.setGrpCd(grpCd);
+ code.setCodeVal(v.codeVal());
+ code.setCodeNm(v.codeNm());
+ code.setSortOrd(v.sortOrd() == null ? 0 : v.sortOrd());
+ code.setUseYn(v.useYn() == null ? "Y" : v.useYn());
+ code.setCreatedBy(actor);
+ codeMapper.insertValue(code);
+ }
+ }
+
+ private CodeGrpDetailDto toDetail(SysCodeGrp grp) {
+ List values = codeMapper.findValuesByGrp(grp.getGrpCd()).stream().map(this::toValue).toList();
+ return new CodeGrpDetailDto(grp.getGrpCd(), grp.getGrpNm(), grp.getUseYn(), values);
+ }
+
+ private CodeValueDto toValue(SysCode c) {
+ return new CodeValueDto(c.getGrpCd(), c.getCodeVal(), c.getCodeNm(),
+ c.getSortOrd() == null ? 0 : c.getSortOrd(), c.getUseYn());
+ }
+
+ private SysCodeGrp findGrp(String grpCd) {
+ SysCodeGrp g = codeMapper.findGroupById(grpCd);
+ if (g == null) {
+ throw new UiwsApiException(UiwsErrorCode.CODE_GRP_NOT_FOUND);
+ }
+ return g;
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/service/CompanyService.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/service/CompanyService.java
new file mode 100644
index 0000000..9dfe122
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/service/CompanyService.java
@@ -0,0 +1,89 @@
+package com.zioinfo.mes.uiws.system.service;
+
+import com.zioinfo.mes.uiws.common.UiwsApiException;
+import com.zioinfo.mes.uiws.common.UiwsErrorCode;
+import com.zioinfo.mes.uiws.system.dto.CompanyDto;
+import com.zioinfo.mes.uiws.system.dto.CompanySaveDto;
+import com.zioinfo.mes.uiws.system.dto.PageResponse;
+import com.zioinfo.mes.uiws.system.mapper.CompanyMapper;
+import com.zioinfo.mes.uiws.system.model.SysCompany;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/** 2.4 거래처(companies) CRUD + 검색팝업 + 다중삭제. */
+@Service
+@RequiredArgsConstructor
+public class CompanyService {
+
+ private final CompanyMapper companyMapper;
+
+ @Transactional(readOnly = true)
+ public PageResponse list(String keyword, int page, int size) {
+ List content = companyMapper.search(keyword, page * size, size).stream().map(this::toDto).toList();
+ return PageResponse.of(content, companyMapper.countSearch(keyword), page, size);
+ }
+
+ @Transactional(readOnly = true)
+ public List searchPopup(String keyword) {
+ return companyMapper.searchActive(keyword).stream().map(this::toDto).toList();
+ }
+
+ @Transactional(readOnly = true)
+ public CompanyDto get(String companyId) {
+ return toDto(find(companyId));
+ }
+
+ @Transactional
+ public CompanyDto create(CompanySaveDto dto) {
+ if (dto.companyId() == null || dto.companyId().isBlank()) {
+ throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "companyId는 등록 시 필수입니다.");
+ }
+ if (companyMapper.existsById(dto.companyId())) {
+ throw new UiwsApiException(UiwsErrorCode.DUPLICATE_KEY, "이미 존재하는 거래처 ID입니다.");
+ }
+ SysCompany c = new SysCompany();
+ c.setCompanyId(dto.companyId());
+ c.setCompanyNm(dto.companyNm());
+ c.setBizNo(dto.bizNo());
+ c.setUseYn(dto.useYn());
+ c.setCreatedBy(SysActor.id());
+ companyMapper.insert(c);
+ return toDto(find(dto.companyId()));
+ }
+
+ @Transactional
+ public CompanyDto update(String companyId, CompanySaveDto dto) {
+ SysCompany c = find(companyId);
+ c.setCompanyNm(dto.companyNm());
+ c.setBizNo(dto.bizNo());
+ c.setUseYn(dto.useYn());
+ c.setUpdatedBy(SysActor.id());
+ companyMapper.update(c);
+ return toDto(find(companyId));
+ }
+
+ @Transactional
+ public void delete(List ids) {
+ for (String id : ids) {
+ if (!companyMapper.existsById(id)) {
+ throw new UiwsApiException(UiwsErrorCode.COMPANY_NOT_FOUND);
+ }
+ companyMapper.deleteById(id);
+ }
+ }
+
+ private CompanyDto toDto(SysCompany c) {
+ return new CompanyDto(c.getCompanyId(), c.getCompanyNm(), c.getBizNo(), c.getUseYn());
+ }
+
+ private SysCompany find(String companyId) {
+ SysCompany c = companyMapper.findById(companyId);
+ if (c == null) {
+ throw new UiwsApiException(UiwsErrorCode.COMPANY_NOT_FOUND);
+ }
+ return c;
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/service/DeptRoleService.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/service/DeptRoleService.java
new file mode 100644
index 0000000..d5d14cd
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/service/DeptRoleService.java
@@ -0,0 +1,61 @@
+package com.zioinfo.mes.uiws.system.service;
+
+import com.zioinfo.mes.uiws.common.UiwsApiException;
+import com.zioinfo.mes.uiws.common.UiwsErrorCode;
+import com.zioinfo.mes.uiws.system.dto.DeptUserRoleDto;
+import com.zioinfo.mes.uiws.system.dto.PageResponse;
+import com.zioinfo.mes.uiws.system.mapper.DeptMapper;
+import com.zioinfo.mes.uiws.system.mapper.DeptRoleMapper;
+import com.zioinfo.mes.uiws.system.mapper.SysUserMapper;
+import com.zioinfo.mes.uiws.system.model.SysUser;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * 2.2 부서권한(dept-role). 부서 단위 권한 부여/삭제.
+ * 조회는 부서 소속 사용자 목록에 부서-권한(roleIds)을 동일하게 부여(권한이 부서로 결정되는 모델).
+ */
+@Service
+@RequiredArgsConstructor
+public class DeptRoleService {
+
+ private final DeptRoleMapper deptRoleMapper;
+ private final DeptMapper deptMapper;
+ private final SysUserMapper userMapper;
+
+ @Transactional(readOnly = true)
+ public PageResponse listDeptUsers(String deptId, int page, int size) {
+ ensureDept(deptId);
+ List roleIds = deptRoleMapper.findRoleIdsByDeptId(deptId);
+ List users = userMapper.search(null, deptId, page * size, size);
+ List content = users.stream()
+ .map(u -> new DeptUserRoleDto(u.getUserId(), u.getUserNm(), roleIds)).toList();
+ return PageResponse.of(content, userMapper.countSearch(null, deptId), page, size);
+ }
+
+ @Transactional
+ public void grant(String deptId, List roleIds) {
+ ensureDept(deptId);
+ String actor = SysActor.id();
+ for (String roleId : roleIds) {
+ deptRoleMapper.insert(deptId, roleId, actor);
+ }
+ }
+
+ @Transactional
+ public void revoke(String deptId, List roleIds) {
+ ensureDept(deptId);
+ for (String roleId : roleIds) {
+ deptRoleMapper.delete(deptId, roleId);
+ }
+ }
+
+ private void ensureDept(String deptId) {
+ if (!deptMapper.existsById(deptId)) {
+ throw new UiwsApiException(UiwsErrorCode.DEPT_NOT_FOUND);
+ }
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/service/DeptService.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/service/DeptService.java
new file mode 100644
index 0000000..a76c088
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/service/DeptService.java
@@ -0,0 +1,181 @@
+package com.zioinfo.mes.uiws.system.service;
+
+import com.zioinfo.mes.uiws.common.UiwsApiException;
+import com.zioinfo.mes.uiws.common.UiwsErrorCode;
+import com.zioinfo.mes.uiws.system.dto.DeptDto;
+import com.zioinfo.mes.uiws.system.dto.DeptSaveDto;
+import com.zioinfo.mes.uiws.system.dto.DeptTreeDto;
+import com.zioinfo.mes.uiws.system.dto.PageResponse;
+import com.zioinfo.mes.uiws.system.mapper.DeptMapper;
+import com.zioinfo.mes.uiws.system.mapper.SysUserMapper;
+import com.zioinfo.mes.uiws.system.model.SysDept;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/** 2.3 부서(depts) CRUD + 검색팝업 + 계층 트리. */
+@Service
+@RequiredArgsConstructor
+public class DeptService {
+
+ private final DeptMapper deptMapper;
+ private final SysUserMapper userMapper;
+
+ @Transactional(readOnly = true)
+ public PageResponse list(String keyword, int page, int size) {
+ List content = deptMapper.search(keyword, page * size, size).stream().map(this::toDto).toList();
+ return PageResponse.of(content, deptMapper.countSearch(keyword), page, size);
+ }
+
+ @Transactional(readOnly = true)
+ public List searchPopup(String keyword) {
+ return deptMapper.searchActive(keyword).stream().map(this::toDto).toList();
+ }
+
+ /** 부서 계층 트리(루트부터 중첩). sortOrd→deptId 순. */
+ @Transactional(readOnly = true)
+ public List tree() {
+ List all = deptMapper.findAll();
+ Set ids = all.stream().map(SysDept::getDeptId).collect(Collectors.toSet());
+
+ Map> childrenOf = new HashMap<>();
+ for (SysDept d : all) {
+ if (d.getParentDeptId() != null && ids.contains(d.getParentDeptId())) {
+ childrenOf.computeIfAbsent(d.getParentDeptId(), k -> new ArrayList<>()).add(d);
+ }
+ }
+ Comparator order = Comparator
+ .comparing((SysDept d) -> d.getSortOrd() == null ? 0 : d.getSortOrd())
+ .thenComparing(SysDept::getDeptId);
+
+ List roots = all.stream()
+ .filter(d -> d.getParentDeptId() == null || !ids.contains(d.getParentDeptId()))
+ .sorted(order)
+ .toList();
+ return roots.stream().map(r -> toNode(r, childrenOf, order, new HashSet<>())).toList();
+ }
+
+ private DeptTreeDto toNode(SysDept d, Map> childrenOf,
+ Comparator order, Set visited) {
+ if (!visited.add(d.getDeptId())) {
+ return new DeptTreeDto(d.getDeptId(), d.getDeptNm(), d.getParentDeptId(),
+ d.getSortOrd() == null ? 0 : d.getSortOrd(), d.getUseYn(), List.of());
+ }
+ List children = childrenOf.getOrDefault(d.getDeptId(), List.of()).stream()
+ .sorted(order)
+ .map(c -> toNode(c, childrenOf, order, visited))
+ .toList();
+ return new DeptTreeDto(d.getDeptId(), d.getDeptNm(), d.getParentDeptId(),
+ d.getSortOrd() == null ? 0 : d.getSortOrd(), d.getUseYn(), children);
+ }
+
+ @Transactional(readOnly = true)
+ public DeptDto get(String deptId) {
+ return toDto(find(deptId));
+ }
+
+ @Transactional
+ public DeptDto create(DeptSaveDto dto) {
+ if (dto.deptId() == null || dto.deptId().isBlank()) {
+ throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "deptId는 등록 시 필수입니다.");
+ }
+ if (deptMapper.existsById(dto.deptId())) {
+ throw new UiwsApiException(UiwsErrorCode.DUPLICATE_KEY, "이미 존재하는 부서 ID입니다.");
+ }
+ validateParent(dto.deptId(), dto.parentDeptId());
+ SysDept d = new SysDept();
+ d.setDeptId(dto.deptId());
+ d.setDeptNm(dto.deptNm());
+ d.setParentDeptId(dto.parentDeptId());
+ d.setSortOrd(dto.sortOrd() == null ? 0 : dto.sortOrd());
+ d.setUseYn(dto.useYn());
+ d.setCreatedBy(SysActor.id());
+ deptMapper.insert(d);
+ return toDto(find(dto.deptId()));
+ }
+
+ @Transactional
+ public DeptDto update(String deptId, DeptSaveDto dto) {
+ SysDept d = find(deptId);
+ validateParent(deptId, dto.parentDeptId());
+ d.setDeptNm(dto.deptNm());
+ d.setParentDeptId(dto.parentDeptId());
+ d.setSortOrd(dto.sortOrd() == null ? 0 : dto.sortOrd());
+ d.setUseYn(dto.useYn());
+ d.setUpdatedBy(SysActor.id());
+ deptMapper.update(d);
+ return toDto(find(deptId));
+ }
+
+ @Transactional
+ public void delete(String deptId) {
+ find(deptId);
+ if (deptMapper.existsByParentDeptId(deptId)) {
+ throw new UiwsApiException(UiwsErrorCode.RESOURCE_IN_USE, "하위 부서가 존재하여 삭제할 수 없습니다.");
+ }
+ if (!userMapper.findByDeptId(deptId).isEmpty()) {
+ throw new UiwsApiException(UiwsErrorCode.RESOURCE_IN_USE, "소속 사용자가 존재하여 삭제할 수 없습니다.");
+ }
+ deptMapper.deleteById(deptId);
+ }
+
+ /** 상위부서 무결성: 자기참조 금지·존재 확인·순환(자기 하위부서를 상위로) 금지. */
+ private void validateParent(String deptId, String parentId) {
+ if (parentId == null || parentId.isBlank()) {
+ return;
+ }
+ if (parentId.equals(deptId)) {
+ throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "자기 자신을 상위부서로 지정할 수 없습니다.");
+ }
+ if (!deptMapper.existsById(parentId)) {
+ throw new UiwsApiException(UiwsErrorCode.DEPT_NOT_FOUND, "상위부서가 존재하지 않습니다: " + parentId);
+ }
+ if (deptId != null && selfAndDescendants(deptId).contains(parentId)) {
+ throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "하위부서를 상위부서로 지정할 수 없습니다(순환 구조).");
+ }
+ }
+
+ /** deptId 자신 + 모든 하위부서 ID 집합(순환 방지용). */
+ private Set selfAndDescendants(String deptId) {
+ List all = deptMapper.findAll();
+ Map> childrenOf = new HashMap<>();
+ for (SysDept d : all) {
+ if (d.getParentDeptId() != null) {
+ childrenOf.computeIfAbsent(d.getParentDeptId(), k -> new ArrayList<>()).add(d.getDeptId());
+ }
+ }
+ Set result = new HashSet<>();
+ ArrayList stack = new ArrayList<>();
+ stack.add(deptId);
+ while (!stack.isEmpty()) {
+ String cur = stack.remove(stack.size() - 1);
+ if (!result.add(cur)) {
+ continue;
+ }
+ stack.addAll(childrenOf.getOrDefault(cur, List.of()));
+ }
+ return result;
+ }
+
+ private DeptDto toDto(SysDept d) {
+ return new DeptDto(d.getDeptId(), d.getDeptNm(), d.getParentDeptId(),
+ d.getSortOrd() == null ? 0 : d.getSortOrd(), d.getUseYn());
+ }
+
+ private SysDept find(String deptId) {
+ SysDept d = deptMapper.findById(deptId);
+ if (d == null) {
+ throw new UiwsApiException(UiwsErrorCode.DEPT_NOT_FOUND);
+ }
+ return d;
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/service/MenuService.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/service/MenuService.java
new file mode 100644
index 0000000..db61735
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/service/MenuService.java
@@ -0,0 +1,103 @@
+package com.zioinfo.mes.uiws.system.service;
+
+import com.zioinfo.mes.uiws.common.UiwsApiException;
+import com.zioinfo.mes.uiws.common.UiwsErrorCode;
+import com.zioinfo.mes.uiws.system.dto.MenuDto;
+import com.zioinfo.mes.uiws.system.dto.MenuSaveDto;
+import com.zioinfo.mes.uiws.system.dto.PageResponse;
+import com.zioinfo.mes.uiws.system.mapper.MenuMapper;
+import com.zioinfo.mes.uiws.system.mapper.RoleMenuMapper;
+import com.zioinfo.mes.uiws.system.model.SysMenu;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/** 2.9 메뉴(menus) CRUD + 검색팝업 + 다중삭제. 프론트가 parentMenuId 로 트리 구성. */
+@Service
+@RequiredArgsConstructor
+public class MenuService {
+
+ private final MenuMapper menuMapper;
+ private final RoleMenuMapper roleMenuMapper;
+
+ @Transactional(readOnly = true)
+ public PageResponse list(int page, int size) {
+ List content = menuMapper.searchAll(page * size, size).stream().map(this::toDto).toList();
+ return PageResponse.of(content, menuMapper.countAll(), page, size);
+ }
+
+ @Transactional(readOnly = true)
+ public List searchPopup(String keyword) {
+ return menuMapper.search(keyword).stream().map(this::toDto).toList();
+ }
+
+ @Transactional(readOnly = true)
+ public MenuDto get(String menuId) {
+ return toDto(find(menuId));
+ }
+
+ @Transactional
+ public MenuDto create(MenuSaveDto dto) {
+ if (dto.menuId() == null || dto.menuId().isBlank()) {
+ throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "menuId는 등록 시 필수입니다.");
+ }
+ if (menuMapper.existsById(dto.menuId())) {
+ throw new UiwsApiException(UiwsErrorCode.DUPLICATE_KEY, "이미 존재하는 메뉴 ID입니다.");
+ }
+ SysMenu m = new SysMenu();
+ m.setMenuId(dto.menuId());
+ apply(m, dto);
+ m.setCreatedBy(SysActor.id());
+ menuMapper.insert(m);
+ return toDto(find(dto.menuId()));
+ }
+
+ @Transactional
+ public MenuDto update(String menuId, MenuSaveDto dto) {
+ SysMenu m = find(menuId);
+ apply(m, dto);
+ m.setUpdatedBy(SysActor.id());
+ menuMapper.update(m);
+ return toDto(find(menuId));
+ }
+
+ @Transactional
+ public void delete(List ids) {
+ for (String id : ids) {
+ if (!menuMapper.existsById(id)) {
+ throw new UiwsApiException(UiwsErrorCode.MENU_NOT_FOUND);
+ }
+ if (menuMapper.existsByParentMenuId(id)) {
+ throw new UiwsApiException(UiwsErrorCode.RESOURCE_IN_USE, "하위 메뉴가 존재하여 삭제할 수 없습니다: " + id);
+ }
+ if (roleMenuMapper.existsByMenuId(id)) {
+ throw new UiwsApiException(UiwsErrorCode.RESOURCE_IN_USE, "권한에 매핑된 메뉴는 삭제할 수 없습니다: " + id);
+ }
+ menuMapper.deleteById(id);
+ }
+ }
+
+ private void apply(SysMenu m, MenuSaveDto dto) {
+ m.setMenuNm(dto.menuNm());
+ m.setParentMenuId(dto.parentMenuId());
+ m.setProgramId(dto.programId());
+ m.setMenuUrl(dto.menuUrl());
+ m.setSortOrd(dto.sortOrd() == null ? 0 : dto.sortOrd());
+ m.setUseYn(dto.useYn());
+ }
+
+ private MenuDto toDto(SysMenu m) {
+ return new MenuDto(m.getMenuId(), m.getMenuNm(), m.getParentMenuId(), m.getProgramId(),
+ m.getMenuUrl(), m.getSortOrd() == null ? 0 : m.getSortOrd(), m.getUseYn());
+ }
+
+ private SysMenu find(String menuId) {
+ SysMenu m = menuMapper.findById(menuId);
+ if (m == null) {
+ throw new UiwsApiException(UiwsErrorCode.MENU_NOT_FOUND);
+ }
+ return m;
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/service/ProgramService.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/service/ProgramService.java
new file mode 100644
index 0000000..0508921
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/service/ProgramService.java
@@ -0,0 +1,98 @@
+package com.zioinfo.mes.uiws.system.service;
+
+import com.zioinfo.mes.uiws.common.UiwsApiException;
+import com.zioinfo.mes.uiws.common.UiwsErrorCode;
+import com.zioinfo.mes.uiws.system.dto.PageResponse;
+import com.zioinfo.mes.uiws.system.dto.ProgramDto;
+import com.zioinfo.mes.uiws.system.dto.ProgramSaveDto;
+import com.zioinfo.mes.uiws.system.mapper.MenuMapper;
+import com.zioinfo.mes.uiws.system.mapper.ProgramMapper;
+import com.zioinfo.mes.uiws.system.model.SysProgram;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/** 2.8 프로그램(programs) CRUD + 검색팝업 + 다중삭제. */
+@Service
+@RequiredArgsConstructor
+public class ProgramService {
+
+ private final ProgramMapper programMapper;
+ private final MenuMapper menuMapper;
+
+ @Transactional(readOnly = true)
+ public PageResponse list(String keyword, String programType, int page, int size) {
+ List content = programMapper.search(keyword, programType, page * size, size)
+ .stream().map(this::toDto).toList();
+ return PageResponse.of(content, programMapper.countSearch(keyword, programType), page, size);
+ }
+
+ @Transactional(readOnly = true)
+ public List searchPopup(String keyword) {
+ return programMapper.searchActive(keyword).stream().map(this::toDto).toList();
+ }
+
+ @Transactional(readOnly = true)
+ public ProgramDto get(String programId) {
+ return toDto(find(programId));
+ }
+
+ @Transactional
+ public ProgramDto create(ProgramSaveDto dto) {
+ if (programMapper.existsById(dto.programId())) {
+ throw new UiwsApiException(UiwsErrorCode.DUPLICATE_KEY, "이미 존재하는 프로그램 ID입니다.");
+ }
+ SysProgram p = new SysProgram();
+ p.setProgramId(dto.programId());
+ apply(p, dto);
+ p.setCreatedBy(SysActor.id());
+ programMapper.insert(p);
+ return toDto(find(dto.programId()));
+ }
+
+ @Transactional
+ public ProgramDto update(String programId, ProgramSaveDto dto) {
+ SysProgram p = find(programId);
+ apply(p, dto);
+ p.setUpdatedBy(SysActor.id());
+ programMapper.update(p);
+ return toDto(find(programId));
+ }
+
+ @Transactional
+ public void delete(List ids) {
+ for (String id : ids) {
+ if (!programMapper.existsById(id)) {
+ throw new UiwsApiException(UiwsErrorCode.PROGRAM_NOT_FOUND);
+ }
+ if (menuMapper.existsByProgramId(id)) {
+ throw new UiwsApiException(UiwsErrorCode.RESOURCE_IN_USE,
+ "메뉴에서 참조 중인 프로그램은 삭제할 수 없습니다: " + id);
+ }
+ programMapper.deleteById(id);
+ }
+ }
+
+ private void apply(SysProgram p, ProgramSaveDto dto) {
+ p.setProgramNm(dto.programNm());
+ p.setProgramType(dto.programType());
+ p.setProgramUrl(dto.programUrl());
+ p.setCategory(dto.category());
+ p.setUseYn(dto.useYn());
+ }
+
+ private ProgramDto toDto(SysProgram p) {
+ return new ProgramDto(p.getProgramId(), p.getProgramNm(), p.getProgramType(),
+ p.getProgramUrl(), p.getCategory(), p.getUseYn());
+ }
+
+ private SysProgram find(String programId) {
+ SysProgram p = programMapper.findById(programId);
+ if (p == null) {
+ throw new UiwsApiException(UiwsErrorCode.PROGRAM_NOT_FOUND);
+ }
+ return p;
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/service/PublicLookupService.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/service/PublicLookupService.java
new file mode 100644
index 0000000..207dd46
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/service/PublicLookupService.java
@@ -0,0 +1,35 @@
+package com.zioinfo.mes.uiws.system.service;
+
+import com.zioinfo.mes.uiws.system.dto.PublicCompanyDto;
+import com.zioinfo.mes.uiws.system.dto.PublicDeptDto;
+import com.zioinfo.mes.uiws.system.mapper.CompanyMapper;
+import com.zioinfo.mes.uiws.system.mapper.DeptMapper;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * 가입 화면(비인증) 공개 조회. use_yn='Y' 부서/거래처만 노출.
+ * (가입 시 존재하지 않는 FK 입력 → 오류 방지)
+ */
+@Service
+@RequiredArgsConstructor
+public class PublicLookupService {
+
+ private final DeptMapper deptMapper;
+ private final CompanyMapper companyMapper;
+
+ @Transactional(readOnly = true)
+ public List depts() {
+ return deptMapper.findActiveOrdered().stream()
+ .map(d -> new PublicDeptDto(d.getDeptId(), d.getDeptNm())).toList();
+ }
+
+ @Transactional(readOnly = true)
+ public List companies() {
+ return companyMapper.findActiveOrdered().stream()
+ .map(c -> new PublicCompanyDto(c.getCompanyId(), c.getCompanyNm())).toList();
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/service/RoleMenuService.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/service/RoleMenuService.java
new file mode 100644
index 0000000..072aa6d
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/service/RoleMenuService.java
@@ -0,0 +1,91 @@
+package com.zioinfo.mes.uiws.system.service;
+
+import com.zioinfo.mes.uiws.common.UiwsApiException;
+import com.zioinfo.mes.uiws.common.UiwsErrorCode;
+import com.zioinfo.mes.uiws.system.dto.PageResponse;
+import com.zioinfo.mes.uiws.system.dto.RoleDto;
+import com.zioinfo.mes.uiws.system.dto.RoleMenuDto;
+import com.zioinfo.mes.uiws.system.mapper.MenuMapper;
+import com.zioinfo.mes.uiws.system.mapper.RoleMapper;
+import com.zioinfo.mes.uiws.system.mapper.RoleMenuMapper;
+import com.zioinfo.mes.uiws.system.model.SysMenu;
+import com.zioinfo.mes.uiws.system.model.SysRole;
+import com.zioinfo.mes.uiws.system.model.SysRoleMenu;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 2.5 메뉴생성(role-menus). 권한 목록 + 권한별 메뉴 매핑 조회/저장.
+ * 조회는 전체 사용중 메뉴 기준으로 매핑 여부(read/write)를 합쳐 반환.
+ */
+@Service
+@RequiredArgsConstructor
+public class RoleMenuService {
+
+ private final RoleMapper roleMapper;
+ private final RoleMenuMapper roleMenuMapper;
+ private final MenuMapper menuMapper;
+
+ @Transactional(readOnly = true)
+ public PageResponse listRoles(int page, int size) {
+ List content = roleMapper.search(null, page * size, size).stream()
+ .map(this::toRoleDto).toList();
+ return PageResponse.of(content, roleMapper.countSearch(null), page, size);
+ }
+
+ @Transactional(readOnly = true)
+ public List getRoleMenus(String roleId) {
+ ensureRole(roleId);
+ Map mapped = new LinkedHashMap<>();
+ for (SysRoleMenu rm : roleMenuMapper.findByRoleId(roleId)) {
+ mapped.put(rm.getMenuId(), rm);
+ }
+ List allMenus = menuMapper.findAllOrdered();
+ return allMenus.stream().map(m -> {
+ SysRoleMenu rm = mapped.get(m.getMenuId());
+ String readYn = rm != null ? rm.getReadYn() : "N";
+ String writeYn = rm != null ? rm.getWriteYn() : "N";
+ return new RoleMenuDto(m.getMenuId(), m.getMenuNm(), readYn, writeYn);
+ }).toList();
+ }
+
+ /** 전체 치환 저장: read/write 중 하나라도 'Y'면 매핑 보존, 둘 다 'N'이면 제거. */
+ @Transactional
+ public void saveRoleMenus(String roleId, List menus) {
+ ensureRole(roleId);
+ String actor = SysActor.id();
+ roleMenuMapper.deleteByRoleId(roleId);
+ if (menus == null) {
+ return;
+ }
+ for (RoleMenuDto dto : menus) {
+ boolean read = "Y".equals(dto.readYn());
+ boolean write = "Y".equals(dto.writeYn());
+ if (!read && !write) {
+ continue;
+ }
+ SysRoleMenu rm = new SysRoleMenu();
+ rm.setRoleId(roleId);
+ rm.setMenuId(dto.menuId());
+ rm.setReadYn(read ? "Y" : "N");
+ rm.setWriteYn(write ? "Y" : "N");
+ rm.setCreatedBy(actor);
+ roleMenuMapper.insert(rm);
+ }
+ }
+
+ private RoleDto toRoleDto(SysRole r) {
+ return new RoleDto(r.getRoleId(), r.getRoleNm(), r.getRoleDesc(), r.getUseYn());
+ }
+
+ private void ensureRole(String roleId) {
+ if (!roleMapper.existsById(roleId)) {
+ throw new UiwsApiException(UiwsErrorCode.ROLE_NOT_FOUND);
+ }
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/service/RoleService.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/service/RoleService.java
new file mode 100644
index 0000000..cc689b5
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/service/RoleService.java
@@ -0,0 +1,89 @@
+package com.zioinfo.mes.uiws.system.service;
+
+import com.zioinfo.mes.uiws.common.UiwsApiException;
+import com.zioinfo.mes.uiws.common.UiwsErrorCode;
+import com.zioinfo.mes.uiws.system.dto.PageResponse;
+import com.zioinfo.mes.uiws.system.dto.RoleDto;
+import com.zioinfo.mes.uiws.system.dto.RoleSaveDto;
+import com.zioinfo.mes.uiws.system.mapper.DeptRoleMapper;
+import com.zioinfo.mes.uiws.system.mapper.RoleMapper;
+import com.zioinfo.mes.uiws.system.mapper.RoleMenuMapper;
+import com.zioinfo.mes.uiws.system.model.SysRole;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/** 2.1 권한(roles) CRUD. */
+@Service
+@RequiredArgsConstructor
+public class RoleService {
+
+ private final RoleMapper roleMapper;
+ private final RoleMenuMapper roleMenuMapper;
+ private final DeptRoleMapper deptRoleMapper;
+
+ @Transactional(readOnly = true)
+ public PageResponse list(String keyword, int page, int size) {
+ List content = roleMapper.search(keyword, page * size, size).stream().map(this::toDto).toList();
+ return PageResponse.of(content, roleMapper.countSearch(keyword), page, size);
+ }
+
+ @Transactional(readOnly = true)
+ public RoleDto get(String roleId) {
+ return toDto(find(roleId));
+ }
+
+ @Transactional
+ public RoleDto create(RoleSaveDto dto) {
+ if (dto.roleId() == null || dto.roleId().isBlank()) {
+ throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "roleId는 등록 시 필수입니다.");
+ }
+ if (roleMapper.existsById(dto.roleId())) {
+ throw new UiwsApiException(UiwsErrorCode.DUPLICATE_KEY, "이미 존재하는 권한 ID입니다.");
+ }
+ SysRole role = new SysRole();
+ role.setRoleId(dto.roleId());
+ role.setRoleNm(dto.roleNm());
+ role.setRoleDesc(dto.roleDesc());
+ role.setUseYn(dto.useYn());
+ role.setCreatedBy(SysActor.id());
+ roleMapper.insert(role);
+ return toDto(find(dto.roleId()));
+ }
+
+ @Transactional
+ public RoleDto update(String roleId, RoleSaveDto dto) {
+ SysRole role = find(roleId);
+ role.setRoleNm(dto.roleNm());
+ role.setRoleDesc(dto.roleDesc());
+ role.setUseYn(dto.useYn());
+ role.setUpdatedBy(SysActor.id());
+ roleMapper.update(role);
+ return toDto(find(roleId));
+ }
+
+ @Transactional
+ public void delete(List ids) {
+ for (String id : ids) {
+ if (deptRoleMapper.existsByRoleId(id)) {
+ throw new UiwsApiException(UiwsErrorCode.RESOURCE_IN_USE, "부서에 부여된 권한은 삭제할 수 없습니다: " + id);
+ }
+ roleMenuMapper.deleteByRoleId(id);
+ roleMapper.deleteById(id);
+ }
+ }
+
+ private RoleDto toDto(SysRole r) {
+ return new RoleDto(r.getRoleId(), r.getRoleNm(), r.getRoleDesc(), r.getUseYn());
+ }
+
+ private SysRole find(String roleId) {
+ SysRole r = roleMapper.findById(roleId);
+ if (r == null) {
+ throw new UiwsApiException(UiwsErrorCode.ROLE_NOT_FOUND);
+ }
+ return r;
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/service/SysActor.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/service/SysActor.java
new file mode 100644
index 0000000..252afda
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/service/SysActor.java
@@ -0,0 +1,24 @@
+package com.zioinfo.mes.uiws.system.service;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+/**
+ * 현재 사용자 ID 추출(감사 컬럼 created_by/updated_by 세팅용). 원본 CurrentUser 대체.
+ * CMS JwtFilter 가 username(String) 을 principal 로 설정 → getName() 으로 추출.
+ */
+public final class SysActor {
+
+ private static final String SYSTEM = "SYSTEM";
+
+ private SysActor() {
+ }
+
+ public static String id() {
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ if (auth != null && auth.getName() != null && !auth.getName().isBlank()) {
+ return auth.getName();
+ }
+ return SYSTEM;
+ }
+}
diff --git a/backend/src/main/java/com/zioinfo/mes/uiws/system/service/UserService.java b/backend/src/main/java/com/zioinfo/mes/uiws/system/service/UserService.java
new file mode 100644
index 0000000..cf19488
--- /dev/null
+++ b/backend/src/main/java/com/zioinfo/mes/uiws/system/service/UserService.java
@@ -0,0 +1,234 @@
+package com.zioinfo.mes.uiws.system.service;
+
+import com.zioinfo.mes.uiws.common.UiwsApiException;
+import com.zioinfo.mes.uiws.common.UiwsErrorCode;
+import com.zioinfo.mes.uiws.common.mail.MailSender;
+import com.zioinfo.mes.uiws.system.dto.CheckIdResponse;
+import com.zioinfo.mes.uiws.system.dto.PageResponse;
+import com.zioinfo.mes.uiws.system.dto.UserDto;
+import com.zioinfo.mes.uiws.system.dto.UserSaveDto;
+import com.zioinfo.mes.uiws.system.mapper.CompanyMapper;
+import com.zioinfo.mes.uiws.system.mapper.DeptMapper;
+import com.zioinfo.mes.uiws.system.mapper.SysUserMapper;
+import com.zioinfo.mes.uiws.system.model.SysCompany;
+import com.zioinfo.mes.uiws.system.model.SysDept;
+import com.zioinfo.mes.uiws.system.model.SysUser;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.security.SecureRandom;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 2.6 사용자(users). 관리자 사용자 CRUD/검색/중복확인/다중삭제/비번초기화/잠금해제/승인.
+ * 관리자 등록 사용자는 approval_yn='Y'. 비밀번호는 BCrypt — 응답·로그(코드 외)에 절대 노출 금지.
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class UserService {
+
+ private static final SecureRandom RANDOM = new SecureRandom();
+
+ private final SysUserMapper userMapper;
+ private final DeptMapper deptMapper;
+ private final CompanyMapper companyMapper;
+ private final PasswordEncoder passwordEncoder;
+ private final MailSender mailSender;
+
+ @Transactional(readOnly = true)
+ public PageResponse list(String keyword, String deptId, int page, int size) {
+ Map deptNames = deptNameMap();
+ Map companyNames = companyNameMap();
+ List content = userMapper.search(keyword, deptId, page * size, size).stream()
+ .map(u -> toDto(u, deptNames, companyNames)).toList();
+ return PageResponse.of(content, userMapper.countSearch(keyword, deptId), page, size);
+ }
+
+ @Transactional(readOnly = true)
+ public List searchPopup(String keyword, String deptId) {
+ Map deptNames = deptNameMap();
+ Map companyNames = companyNameMap();
+ return userMapper.searchActive(keyword, deptId).stream()
+ .map(u -> toDto(u, deptNames, companyNames)).toList();
+ }
+
+ @Transactional(readOnly = true)
+ public CheckIdResponse checkId(String userId) {
+ boolean available = userId != null && !userId.isBlank() && !userMapper.existsById(userId);
+ return new CheckIdResponse(available);
+ }
+
+ @Transactional(readOnly = true)
+ public UserDto get(String userId) {
+ return toDto(find(userId), deptNameMap(), companyNameMap());
+ }
+
+ @Transactional
+ public UserDto create(UserSaveDto dto) {
+ if (userMapper.existsById(dto.userId())) {
+ throw new UiwsApiException(UiwsErrorCode.USER_ID_DUPLICATED);
+ }
+ String rawPw = (dto.password() == null || dto.password().isBlank())
+ ? generateTempPassword() : dto.password();
+
+ SysUser u = new SysUser();
+ u.setUserId(dto.userId());
+ u.setUserNm(dto.userNm());
+ u.setPassword(passwordEncoder.encode(rawPw));
+ u.setEmail(dto.email());
+ u.setGradeCd(dto.gradeCd());
+ u.setDeptId(dto.deptId());
+ u.setCompanyId(dto.companyId());
+ u.setRoleCd(normalizeRole(dto.roleCd()));
+ u.setNaverworksId(blankToNull(dto.naverworksId()));
+ u.setLoginFailCnt(0);
+ u.setLockYn("N");
+ u.setUseYn(dto.useYn() == null ? "Y" : dto.useYn());
+ u.setApprovalYn("Y"); // 관리자 생성 → 즉시 승인
+ u.setVerifyMethod("EMAIL");
+ u.setPwChangeYn("Y"); // 최초 로그인 시 비번 변경 유도
+ u.setCreatedBy(SysActor.id());
+ userMapper.insert(u);
+ return toDto(find(dto.userId()), deptNameMap(), companyNameMap());
+ }
+
+ @Transactional
+ public UserDto update(String userId, UserSaveDto dto) {
+ SysUser u = find(userId);
+ u.setUserNm(dto.userNm());
+ u.setEmail(dto.email());
+ u.setGradeCd(dto.gradeCd());
+ u.setDeptId(dto.deptId());
+ u.setCompanyId(dto.companyId());
+ if (dto.roleCd() != null && !dto.roleCd().isBlank()) {
+ u.setRoleCd(normalizeRole(dto.roleCd()));
+ }
+ u.setNaverworksId(blankToNull(dto.naverworksId()));
+ if (dto.useYn() != null && !dto.useYn().isBlank()) {
+ u.setUseYn(dto.useYn());
+ }
+ u.setPassword((dto.password() != null && !dto.password().isBlank())
+ ? passwordEncoder.encode(dto.password()) : null); // null → XML 에서 비밀번호 미변경
+ u.setUpdatedBy(SysActor.id());
+ userMapper.update(u);
+ return toDto(find(userId), deptNameMap(), companyNameMap());
+ }
+
+ /** 사용자 삭제 = 소프트삭제(use_yn='N' + 승인 회수). 이력 보존 위해 하드삭제 금지. */
+ @Transactional
+ public void delete(List ids) {
+ for (String id : ids) {
+ SysUser u = find(id);
+ u.setUseYn("N");
+ u.setApprovalYn("N");
+ u.setUpdatedBy(SysActor.id());
+ userMapper.update(u);
+ }
+ }
+
+ /** 비번 초기화: 임시비번 생성 → BCrypt 저장 → 메일/로그(코드 외)로만 전달, 잠금 해제. */
+ @Transactional
+ public void resetPassword(String userId) {
+ SysUser u = find(userId);
+ String temp = generateTempPassword();
+ u.setPassword(passwordEncoder.encode(temp));
+ u.setLockYn("N");
+ u.setLoginFailCnt(0);
+ u.setPwChangeYn("Y");
+ u.setUpdatedBy(SysActor.id());
+ userMapper.update(u);
+ // 임시비번은 메일/로그 채널로만 — API 응답에는 절대 미포함(보안 불변규칙).
+ mailSender.send(u.getEmail(), "[GUARDiA CMS] 비밀번호 초기화",
+ String.format("임시 비밀번호: %s\n로그인 후 즉시 변경하세요.", temp));
+ }
+
+ /** 잠금 해제. */
+ @Transactional
+ public void unlock(String userId) {
+ SysUser u = find(userId);
+ u.setLockYn("N");
+ u.setLoginFailCnt(0);
+ u.setUpdatedBy(SysActor.id());
+ userMapper.update(u);
+ }
+
+ /** 가입 승인 — approval_yn='Y'. */
+ @Transactional
+ public void approve(String userId) {
+ SysUser u = find(userId);
+ u.setApprovalYn("Y");
+ u.setUpdatedBy(SysActor.id());
+ userMapper.update(u);
+ }
+
+ /** 승인 취소 — approval_yn='N'. */
+ @Transactional
+ public void revokeApproval(String userId) {
+ SysUser u = find(userId);
+ u.setApprovalYn("N");
+ u.setUpdatedBy(SysActor.id());
+ userMapper.update(u);
+ }
+
+ // ------------------------------------------------------------------
+ private UserDto toDto(SysUser u, Map deptNames, Map companyNames) {
+ String deptNm = u.getDeptId() == null ? null : deptNames.get(u.getDeptId());
+ String companyNm = u.getCompanyId() == null ? null : companyNames.get(u.getCompanyId());
+ return new UserDto(u.getUserId(), u.getUserNm(), u.getEmail(), u.getGradeCd(),
+ u.getDeptId(), deptNm, u.getCompanyId(), companyNm,
+ u.getRoleCd(), u.getNaverworksId(), u.getLockYn(), u.getUseYn(), u.getApprovalYn());
+ }
+
+ private static String blankToNull(String s) {
+ return (s == null || s.isBlank()) ? null : s;
+ }
+
+ private String normalizeRole(String roleCd) {
+ if ("MANAGER".equals(roleCd) || "ADMIN".equals(roleCd)) {
+ return roleCd;
+ }
+ return "USER";
+ }
+
+ private Map deptNameMap() {
+ return deptMapper.findAll().stream()
+ .collect(Collectors.toMap(SysDept::getDeptId, SysDept::getDeptNm, (a, b) -> a, HashMap::new));
+ }
+
+ private Map companyNameMap() {
+ return companyMapper.findAll().stream()
+ .collect(Collectors.toMap(SysCompany::getCompanyId, SysCompany::getCompanyNm, (a, b) -> a, HashMap::new));
+ }
+
+ private SysUser find(String userId) {
+ SysUser u = userMapper.findById(userId);
+ if (u == null) {
+ throw new UiwsApiException(UiwsErrorCode.USER_NOT_FOUND);
+ }
+ return u;
+ }
+
+ private String generateTempPassword() {
+ String upper = "ABCDEFGHJKLMNPQRSTUVWXYZ";
+ String lower = "abcdefghijkmnpqrstuvwxyz";
+ String digit = "23456789";
+ String special = "!@#$%";
+ String all = upper + lower + digit + special;
+ StringBuilder sb = new StringBuilder();
+ sb.append(upper.charAt(RANDOM.nextInt(upper.length())));
+ sb.append(lower.charAt(RANDOM.nextInt(lower.length())));
+ sb.append(digit.charAt(RANDOM.nextInt(digit.length())));
+ sb.append(special.charAt(RANDOM.nextInt(special.length())));
+ for (int i = 0; i < 6; i++) {
+ sb.append(all.charAt(RANDOM.nextInt(all.length())));
+ }
+ return sb.toString();
+ }
+}
diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml
index 8862638..5568360 100644
--- a/backend/src/main/resources/application.yml
+++ b/backend/src/main/resources/application.yml
@@ -18,7 +18,8 @@ spring:
# UIWS 이식: 91_uiws_port.sql(tb_uiws_* + mes_user 2FA ALTER, 전부 멱등)만 부팅 시 적용.
# 기존 schema.sql 은 deploy_server 가 psql 로 별도 적용(비멱등 시드 충돌 회피).
mode: ${SQL_INIT_MODE:always}
- schema-locations: classpath:db/91_uiws_port.sql
+ # 91=업무/2FA(tb_uiws_* + mes_user ALTER), 92=권한관리 system(tb_uiws_sys_user/role/menu...). 전부 멱등.
+ schema-locations: classpath:db/91_uiws_port.sql,classpath:db/92_uiws_system.sql
continue-on-error: true
servlet:
multipart:
diff --git a/backend/src/main/resources/db/91_uiws_port.sql b/backend/src/main/resources/db/91_uiws_port.sql
index 5077e6f..3c57968 100644
--- a/backend/src/main/resources/db/91_uiws_port.sql
+++ b/backend/src/main/resources/db/91_uiws_port.sql
@@ -243,6 +243,10 @@ ALTER TABLE mes_user ADD COLUMN IF NOT EXISTS login_fail_count INT DEFAULT 0;
ALTER TABLE mes_user ADD COLUMN IF NOT EXISTS locked BOOLEAN DEFAULT false;
ALTER TABLE mes_user ADD COLUMN IF NOT EXISTS otp_secret VARCHAR(255);
+-- [로그인 보조 이식] 회원가입 승인 게이트 컬럼. 기존 계정은 기본 true(승인됨) → 로그인 회귀 0.
+-- 신규 가입자만 signup INSERT 시 approved=false 로 등록되어 SUPERADMIN 승인 전 차단.
+ALTER TABLE mes_user ADD COLUMN IF NOT EXISTS approved BOOLEAN DEFAULT true;
+
-- ───────────────────────────────────────────────────────────────────────────
-- [메뉴] MES 는 DB 메뉴/RBAC 테이블 부재(NAV는 프론트 정적 정의) → SQL 메뉴 시드 대상 없음.
-- "업무 (UIWS)" 메뉴는 프론트 Sidebar/Route 에 추가(코드). 여기서는 주석으로만 명시.
diff --git a/backend/src/main/resources/db/92_uiws_system.sql b/backend/src/main/resources/db/92_uiws_system.sql
new file mode 100644
index 0000000..077cd7a
--- /dev/null
+++ b/backend/src/main/resources/db/92_uiws_system.sql
@@ -0,0 +1,229 @@
+-- ============================================================================
+-- UIWS system(시스템관리·권한관리) 이식 (MES) — com.zioinfo.mes.uiws.system
+-- 원본: workspace/uiws/db/02_schema_core.sql (TB_DEPT/COMPANY/CODE_GRP/CODE/USER/ROLE/
+-- DEPT_ROLE/PROGRAM/MENU/ROLE_MENU). 멀티테넌트 키 체계(VARCHAR ID)를 그대로 유지.
+--
+-- 네임스페이스 격리: 원본 TB_* → 소문자 tb_uiws_ 프리픽스. MES mes_user(BIGSERIAL) 와
+-- 별개 계정 모델(tb_uiws_sys_user: VARCHAR user_id) — 절대 병합하지 않는다.
+-- 멱등: 전부 CREATE TABLE IF NOT EXISTS / ON CONFLICT DO NOTHING. mode:always 재실행 완전 멱등.
+-- FK 정책: tb_uiws_* 내부 참조만 물리 FK(원본과 동일). 비밀번호는 BCrypt 저장.
+-- 보안: 비밀번호/임시비번/자격증명은 응답·로그(코드 외)로 노출하지 않는다(불변규칙).
+-- ============================================================================
+
+SET client_encoding = 'UTF8';
+
+-- ───────────────────────────────────────────────────────────────────────────
+-- 부서 (자기참조 계층)
+-- ───────────────────────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS tb_uiws_dept (
+ dept_id VARCHAR(20) NOT NULL,
+ dept_nm VARCHAR(100) NOT NULL,
+ parent_dept_id VARCHAR(20),
+ sort_ord INT DEFAULT 0,
+ use_yn CHAR(1) NOT NULL DEFAULT 'Y',
+ created_by VARCHAR(20) NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT now(),
+ updated_by VARCHAR(20),
+ updated_at TIMESTAMP,
+ CONSTRAINT pk_tb_uiws_dept PRIMARY KEY (dept_id),
+ CONSTRAINT fk_uiws_dept_parent FOREIGN KEY (parent_dept_id) REFERENCES tb_uiws_dept (dept_id),
+ CONSTRAINT ck_uiws_dept_use_yn CHECK (use_yn IN ('Y','N'))
+);
+COMMENT ON TABLE tb_uiws_dept IS 'UIWS 이식: 부서 (2-depth 계층, 자기참조)';
+
+-- ───────────────────────────────────────────────────────────────────────────
+-- 거래처(=근무처) 마스터
+-- ───────────────────────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS tb_uiws_company (
+ company_id VARCHAR(20) NOT NULL,
+ company_nm VARCHAR(100) NOT NULL,
+ biz_no VARCHAR(20),
+ use_yn CHAR(1) NOT NULL DEFAULT 'Y',
+ created_by VARCHAR(20) NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT now(),
+ updated_by VARCHAR(20),
+ updated_at TIMESTAMP,
+ CONSTRAINT pk_tb_uiws_company PRIMARY KEY (company_id),
+ CONSTRAINT ck_uiws_company_use_yn CHECK (use_yn IN ('Y','N'))
+);
+COMMENT ON TABLE tb_uiws_company IS 'UIWS 이식: 거래처=근무처 마스터';
+
+-- ───────────────────────────────────────────────────────────────────────────
+-- 공통코드 그룹 / 값 (복합 PK)
+-- ───────────────────────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS tb_uiws_code_grp (
+ grp_cd VARCHAR(30) NOT NULL,
+ grp_nm VARCHAR(100) NOT NULL,
+ use_yn CHAR(1) NOT NULL DEFAULT 'Y',
+ created_by VARCHAR(20) NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT now(),
+ updated_by VARCHAR(20),
+ updated_at TIMESTAMP,
+ CONSTRAINT pk_tb_uiws_code_grp PRIMARY KEY (grp_cd),
+ CONSTRAINT ck_uiws_code_grp_use_yn CHECK (use_yn IN ('Y','N'))
+);
+COMMENT ON TABLE tb_uiws_code_grp IS 'UIWS 이식: 공통코드 그룹';
+
+CREATE TABLE IF NOT EXISTS tb_uiws_code (
+ grp_cd VARCHAR(30) NOT NULL,
+ code_val VARCHAR(30) NOT NULL,
+ code_nm VARCHAR(100) NOT NULL,
+ sort_ord INT DEFAULT 0,
+ use_yn CHAR(1) NOT NULL DEFAULT 'Y',
+ created_by VARCHAR(20) NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT now(),
+ updated_by VARCHAR(20),
+ updated_at TIMESTAMP,
+ CONSTRAINT pk_tb_uiws_code PRIMARY KEY (grp_cd, code_val),
+ CONSTRAINT fk_uiws_code_grp FOREIGN KEY (grp_cd) REFERENCES tb_uiws_code_grp (grp_cd),
+ CONSTRAINT ck_uiws_code_use_yn CHECK (use_yn IN ('Y','N'))
+);
+COMMENT ON TABLE tb_uiws_code IS 'UIWS 이식: 공통코드 값 (복합 PK: grp_cd + code_val)';
+
+-- ───────────────────────────────────────────────────────────────────────────
+-- 사용자(계정) — MES mes_user 와 별개(VARCHAR user_id, 멀티테넌트 업무 사용자)
+-- ───────────────────────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS tb_uiws_sys_user (
+ user_id VARCHAR(20) NOT NULL,
+ user_nm VARCHAR(50) NOT NULL,
+ password VARCHAR(100) NOT NULL, -- BCrypt
+ email VARCHAR(100) NOT NULL,
+ grade_cd VARCHAR(20),
+ dept_id VARCHAR(20),
+ company_id VARCHAR(20),
+ role_cd VARCHAR(20) NOT NULL DEFAULT 'USER', -- USER/MANAGER/ADMIN
+ naverworks_id VARCHAR(100),
+ login_fail_cnt INT NOT NULL DEFAULT 0,
+ lock_yn CHAR(1) NOT NULL DEFAULT 'N',
+ use_yn CHAR(1) NOT NULL DEFAULT 'Y',
+ approval_yn CHAR(1) NOT NULL DEFAULT 'N',
+ verify_method VARCHAR(20) NOT NULL DEFAULT 'EMAIL',
+ otp_secret VARCHAR(100),
+ pw_change_yn CHAR(1) NOT NULL DEFAULT 'N',
+ created_by VARCHAR(20) NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT now(),
+ updated_by VARCHAR(20),
+ updated_at TIMESTAMP,
+ CONSTRAINT pk_tb_uiws_sys_user PRIMARY KEY (user_id),
+ CONSTRAINT fk_uiws_sysuser_dept FOREIGN KEY (dept_id) REFERENCES tb_uiws_dept (dept_id),
+ CONSTRAINT fk_uiws_sysuser_company FOREIGN KEY (company_id) REFERENCES tb_uiws_company (company_id),
+ CONSTRAINT uq_uiws_sysuser_email UNIQUE (email),
+ CONSTRAINT ck_uiws_sysuser_lock_yn CHECK (lock_yn IN ('Y','N')),
+ CONSTRAINT ck_uiws_sysuser_use_yn CHECK (use_yn IN ('Y','N')),
+ CONSTRAINT ck_uiws_sysuser_approval_yn CHECK (approval_yn IN ('Y','N')),
+ CONSTRAINT ck_uiws_sysuser_verify CHECK (verify_method IN ('EMAIL','OTP')),
+ CONSTRAINT ck_uiws_sysuser_pwchg_yn CHECK (pw_change_yn IN ('Y','N')),
+ CONSTRAINT ck_uiws_sysuser_role_cd CHECK (role_cd IN ('USER','MANAGER','ADMIN')),
+ CONSTRAINT ck_uiws_sysuser_fail_cnt CHECK (login_fail_cnt >= 0)
+);
+COMMENT ON TABLE tb_uiws_sys_user IS 'UIWS 이식: 업무 사용자 계정(BCrypt, 잠금/실패횟수, 가입승인) — MES mes_user 와 별개';
+
+-- ───────────────────────────────────────────────────────────────────────────
+-- 권한(역할) / 부서-권한 매핑
+-- ───────────────────────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS tb_uiws_role (
+ role_id VARCHAR(20) NOT NULL,
+ role_nm VARCHAR(100) NOT NULL,
+ role_desc VARCHAR(255),
+ use_yn CHAR(1) NOT NULL DEFAULT 'Y',
+ created_by VARCHAR(20) NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT now(),
+ updated_by VARCHAR(20),
+ updated_at TIMESTAMP,
+ CONSTRAINT pk_tb_uiws_role PRIMARY KEY (role_id),
+ CONSTRAINT ck_uiws_role_use_yn CHECK (use_yn IN ('Y','N'))
+);
+COMMENT ON TABLE tb_uiws_role IS 'UIWS 이식: 권한(역할)';
+
+CREATE TABLE IF NOT EXISTS tb_uiws_dept_role (
+ dept_id VARCHAR(20) NOT NULL,
+ role_id VARCHAR(20) NOT NULL,
+ created_by VARCHAR(20) NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT now(),
+ updated_by VARCHAR(20),
+ updated_at TIMESTAMP,
+ CONSTRAINT pk_tb_uiws_dept_role PRIMARY KEY (dept_id, role_id),
+ CONSTRAINT fk_uiws_deptrole_dept FOREIGN KEY (dept_id) REFERENCES tb_uiws_dept (dept_id),
+ CONSTRAINT fk_uiws_deptrole_role FOREIGN KEY (role_id) REFERENCES tb_uiws_role (role_id)
+);
+COMMENT ON TABLE tb_uiws_dept_role IS 'UIWS 이식: 부서-권한 매핑 (복합 PK)';
+
+-- ───────────────────────────────────────────────────────────────────────────
+-- 프로그램(화면) / 메뉴 / 권한-메뉴 매핑
+-- ───────────────────────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS tb_uiws_program (
+ program_id VARCHAR(20) NOT NULL,
+ program_nm VARCHAR(100) NOT NULL,
+ program_type VARCHAR(10) NOT NULL,
+ program_url VARCHAR(200),
+ category VARCHAR(50),
+ use_yn CHAR(1) NOT NULL DEFAULT 'Y',
+ created_by VARCHAR(20) NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT now(),
+ updated_by VARCHAR(20),
+ updated_at TIMESTAMP,
+ CONSTRAINT pk_tb_uiws_program PRIMARY KEY (program_id),
+ CONSTRAINT ck_uiws_program_type CHECK (program_type IN ('FORM','POPUP')),
+ CONSTRAINT ck_uiws_program_use_yn CHECK (use_yn IN ('Y','N'))
+);
+COMMENT ON TABLE tb_uiws_program IS 'UIWS 이식: 프로그램(화면 FORM/POPUP)';
+
+CREATE TABLE IF NOT EXISTS tb_uiws_menu (
+ menu_id VARCHAR(20) NOT NULL,
+ menu_nm VARCHAR(100) NOT NULL,
+ parent_menu_id VARCHAR(20),
+ program_id VARCHAR(20),
+ menu_url VARCHAR(200),
+ sort_ord INT DEFAULT 0,
+ use_yn CHAR(1) NOT NULL DEFAULT 'Y',
+ created_by VARCHAR(20) NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT now(),
+ updated_by VARCHAR(20),
+ updated_at TIMESTAMP,
+ CONSTRAINT pk_tb_uiws_menu PRIMARY KEY (menu_id),
+ CONSTRAINT fk_uiws_menu_parent FOREIGN KEY (parent_menu_id) REFERENCES tb_uiws_menu (menu_id),
+ CONSTRAINT fk_uiws_menu_program FOREIGN KEY (program_id) REFERENCES tb_uiws_program (program_id),
+ CONSTRAINT ck_uiws_menu_use_yn CHECK (use_yn IN ('Y','N'))
+);
+COMMENT ON TABLE tb_uiws_menu IS 'UIWS 이식: 메뉴 (2-depth 자기참조, 프로그램 연결)';
+
+CREATE TABLE IF NOT EXISTS tb_uiws_role_menu (
+ role_id VARCHAR(20) NOT NULL,
+ menu_id VARCHAR(20) NOT NULL,
+ read_yn CHAR(1) NOT NULL DEFAULT 'Y',
+ write_yn CHAR(1) NOT NULL DEFAULT 'N',
+ created_by VARCHAR(20) NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT now(),
+ updated_by VARCHAR(20),
+ updated_at TIMESTAMP,
+ CONSTRAINT pk_tb_uiws_role_menu PRIMARY KEY (role_id, menu_id),
+ CONSTRAINT fk_uiws_rolemenu_role FOREIGN KEY (role_id) REFERENCES tb_uiws_role (role_id),
+ CONSTRAINT fk_uiws_rolemenu_menu FOREIGN KEY (menu_id) REFERENCES tb_uiws_menu (menu_id),
+ CONSTRAINT ck_uiws_rolemenu_read_yn CHECK (read_yn IN ('Y','N')),
+ CONSTRAINT ck_uiws_rolemenu_write_yn CHECK (write_yn IN ('Y','N'))
+);
+COMMENT ON TABLE tb_uiws_role_menu IS 'UIWS 이식: 권한-메뉴 매핑 (복합 PK, 조회/등록 권한)';
+
+-- 조회 보조 인덱스
+CREATE INDEX IF NOT EXISTS ix_uiws_sysuser_dept ON tb_uiws_sys_user (dept_id);
+CREATE INDEX IF NOT EXISTS ix_uiws_sysuser_company ON tb_uiws_sys_user (company_id);
+CREATE INDEX IF NOT EXISTS ix_uiws_menu_parent ON tb_uiws_menu (parent_menu_id, sort_ord);
+
+-- ───────────────────────────────────────────────────────────────────────────
+-- 최소 시드(멱등): 기본 권한 + 거래처 + 부서. ON CONFLICT DO NOTHING.
+-- ───────────────────────────────────────────────────────────────────────────
+INSERT INTO tb_uiws_role (role_id, role_nm, role_desc, use_yn, created_by)
+VALUES ('ADMIN', '관리자', '시스템 관리자', 'Y', 'SYSTEM'),
+ ('MANAGER', '매니저', '팀 관리자', 'Y', 'SYSTEM'),
+ ('USER', '일반사용자', '일반 사용자', 'Y', 'SYSTEM')
+ON CONFLICT (role_id) DO NOTHING;
+
+INSERT INTO tb_uiws_company (company_id, company_nm, use_yn, created_by)
+VALUES ('ZIOINFO', '지오정보기술', 'Y', 'SYSTEM')
+ON CONFLICT (company_id) DO NOTHING;
+
+INSERT INTO tb_uiws_dept (dept_id, dept_nm, parent_dept_id, sort_ord, use_yn, created_by)
+VALUES ('ROOT', '본사', NULL, 0, 'Y', 'SYSTEM')
+ON CONFLICT (dept_id) DO NOTHING;
+
+-- end 92_uiws_system.sql
diff --git a/backend/src/main/resources/mapper/UserMapper.xml b/backend/src/main/resources/mapper/UserMapper.xml
index 913401c..11209f2 100644
--- a/backend/src/main/resources/mapper/UserMapper.xml
+++ b/backend/src/main/resources/mapper/UserMapper.xml
@@ -18,11 +18,13 @@
+
+
@@ -33,4 +35,48 @@
VALUES (#{username}, #{passwordHash}, #{displayName}, #{role}, #{active})
+
+
+
+
+
+
+
+
+
+
+ INSERT INTO mes_user (username, password_hash, display_name, role, email,
+ is_active, approved, login_fail_count, locked)
+ VALUES (#{username}, #{passwordHash}, #{displayName}, 'VIEWER', #{email},
+ true, false, 0, false)
+
+
+
+
+
+
+
+
+ UPDATE mes_user
+ SET password_hash = #{passwordHash}, locked = false, login_fail_count = 0
+ WHERE username = #{username}
+
+
diff --git a/backend/src/main/resources/mapper/uiws/system/CodeMapper.xml b/backend/src/main/resources/mapper/uiws/system/CodeMapper.xml
new file mode 100644
index 0000000..72f7a40
--- /dev/null
+++ b/backend/src/main/resources/mapper/uiws/system/CodeMapper.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+ AND (grp_cd ILIKE '%' || #{keyword} || '%' OR grp_nm ILIKE '%' || #{keyword} || '%')
+
+
+
+
+
+
+
+
+
+
+
+
+ INSERT INTO tb_uiws_code_grp (grp_cd, grp_nm, use_yn, created_by, created_at)
+ VALUES (#{grpCd}, #{grpNm}, #{useYn}, #{createdBy}, now())
+
+
+
+ UPDATE tb_uiws_code_grp SET
+ grp_nm = #{grpNm}, use_yn = #{useYn}, updated_by = #{updatedBy}, updated_at = now()
+ WHERE grp_cd = #{grpCd}
+
+
+
+ DELETE FROM tb_uiws_code_grp WHERE grp_cd = #{grpCd}
+
+
+
+
+
+
+
+ INSERT INTO tb_uiws_code (grp_cd, code_val, code_nm, sort_ord, use_yn, created_by, created_at)
+ VALUES (#{grpCd}, #{codeVal}, #{codeNm}, #{sortOrd}, #{useYn}, #{createdBy}, now())
+
+
+
+ DELETE FROM tb_uiws_code WHERE grp_cd = #{grpCd}
+
+
diff --git a/backend/src/main/resources/mapper/uiws/system/CompanyMapper.xml b/backend/src/main/resources/mapper/uiws/system/CompanyMapper.xml
new file mode 100644
index 0000000..e156de0
--- /dev/null
+++ b/backend/src/main/resources/mapper/uiws/system/CompanyMapper.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+ AND (company_id ILIKE '%' || #{keyword} || '%' OR company_nm ILIKE '%' || #{keyword} || '%')
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ INSERT INTO tb_uiws_company (company_id, company_nm, biz_no, use_yn, created_by, created_at)
+ VALUES (#{companyId}, #{companyNm}, #{bizNo}, #{useYn}, #{createdBy}, now())
+
+
+
+ UPDATE tb_uiws_company SET
+ company_nm = #{companyNm}, biz_no = #{bizNo}, use_yn = #{useYn},
+ updated_by = #{updatedBy}, updated_at = now()
+ WHERE company_id = #{companyId}
+
+
+
+ DELETE FROM tb_uiws_company WHERE company_id = #{companyId}
+
+
diff --git a/backend/src/main/resources/mapper/uiws/system/DeptMapper.xml b/backend/src/main/resources/mapper/uiws/system/DeptMapper.xml
new file mode 100644
index 0000000..31766ec
--- /dev/null
+++ b/backend/src/main/resources/mapper/uiws/system/DeptMapper.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+ AND (dept_id ILIKE '%' || #{keyword} || '%' OR dept_nm ILIKE '%' || #{keyword} || '%')
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ INSERT INTO tb_uiws_dept (dept_id, dept_nm, parent_dept_id, sort_ord, use_yn, created_by, created_at)
+ VALUES (#{deptId}, #{deptNm}, #{parentDeptId}, #{sortOrd}, #{useYn}, #{createdBy}, now())
+
+
+
+ UPDATE tb_uiws_dept SET
+ dept_nm = #{deptNm}, parent_dept_id = #{parentDeptId}, sort_ord = #{sortOrd},
+ use_yn = #{useYn}, updated_by = #{updatedBy}, updated_at = now()
+ WHERE dept_id = #{deptId}
+
+
+
+ DELETE FROM tb_uiws_dept WHERE dept_id = #{deptId}
+
+
diff --git a/backend/src/main/resources/mapper/uiws/system/DeptRoleMapper.xml b/backend/src/main/resources/mapper/uiws/system/DeptRoleMapper.xml
new file mode 100644
index 0000000..303475d
--- /dev/null
+++ b/backend/src/main/resources/mapper/uiws/system/DeptRoleMapper.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ INSERT INTO tb_uiws_dept_role (dept_id, role_id, created_by, created_at)
+ VALUES (#{deptId}, #{roleId}, #{actor}, now())
+ ON CONFLICT (dept_id, role_id) DO NOTHING
+
+
+
+ DELETE FROM tb_uiws_dept_role WHERE dept_id = #{deptId} AND role_id = #{roleId}
+
+
diff --git a/backend/src/main/resources/mapper/uiws/system/MenuMapper.xml b/backend/src/main/resources/mapper/uiws/system/MenuMapper.xml
new file mode 100644
index 0000000..e13cba4
--- /dev/null
+++ b/backend/src/main/resources/mapper/uiws/system/MenuMapper.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ INSERT INTO tb_uiws_menu
+ (menu_id, menu_nm, parent_menu_id, program_id, menu_url, sort_ord, use_yn, created_by, created_at)
+ VALUES
+ (#{menuId}, #{menuNm}, #{parentMenuId}, #{programId}, #{menuUrl}, #{sortOrd}, #{useYn}, #{createdBy}, now())
+
+
+
+ UPDATE tb_uiws_menu SET
+ menu_nm = #{menuNm}, parent_menu_id = #{parentMenuId}, program_id = #{programId},
+ menu_url = #{menuUrl}, sort_ord = #{sortOrd}, use_yn = #{useYn},
+ updated_by = #{updatedBy}, updated_at = now()
+ WHERE menu_id = #{menuId}
+
+
+
+ DELETE FROM tb_uiws_menu WHERE menu_id = #{menuId}
+
+
diff --git a/backend/src/main/resources/mapper/uiws/system/ProgramMapper.xml b/backend/src/main/resources/mapper/uiws/system/ProgramMapper.xml
new file mode 100644
index 0000000..9155ec3
--- /dev/null
+++ b/backend/src/main/resources/mapper/uiws/system/ProgramMapper.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+ WHERE 1=1
+
+ AND (program_id ILIKE '%' || #{keyword} || '%' OR program_nm ILIKE '%' || #{keyword} || '%')
+
+ AND program_type = #{programType}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ INSERT INTO tb_uiws_program
+ (program_id, program_nm, program_type, program_url, category, use_yn, created_by, created_at)
+ VALUES
+ (#{programId}, #{programNm}, #{programType}, #{programUrl}, #{category}, #{useYn}, #{createdBy}, now())
+
+
+
+ UPDATE tb_uiws_program SET
+ program_nm = #{programNm}, program_type = #{programType}, program_url = #{programUrl},
+ category = #{category}, use_yn = #{useYn}, updated_by = #{updatedBy}, updated_at = now()
+ WHERE program_id = #{programId}
+
+
+
+ DELETE FROM tb_uiws_program WHERE program_id = #{programId}
+
+
diff --git a/backend/src/main/resources/mapper/uiws/system/RoleMapper.xml b/backend/src/main/resources/mapper/uiws/system/RoleMapper.xml
new file mode 100644
index 0000000..eb379b8
--- /dev/null
+++ b/backend/src/main/resources/mapper/uiws/system/RoleMapper.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+ AND (role_id ILIKE '%' || #{keyword} || '%' OR role_nm ILIKE '%' || #{keyword} || '%')
+
+
+
+
+
+
+
+
+
+
+
+
+ INSERT INTO tb_uiws_role (role_id, role_nm, role_desc, use_yn, created_by, created_at)
+ VALUES (#{roleId}, #{roleNm}, #{roleDesc}, #{useYn}, #{createdBy}, now())
+
+
+
+ UPDATE tb_uiws_role SET
+ role_nm = #{roleNm}, role_desc = #{roleDesc}, use_yn = #{useYn},
+ updated_by = #{updatedBy}, updated_at = now()
+ WHERE role_id = #{roleId}
+
+
+
+ DELETE FROM tb_uiws_role WHERE role_id = #{roleId}
+
+
diff --git a/backend/src/main/resources/mapper/uiws/system/RoleMenuMapper.xml b/backend/src/main/resources/mapper/uiws/system/RoleMenuMapper.xml
new file mode 100644
index 0000000..538dace
--- /dev/null
+++ b/backend/src/main/resources/mapper/uiws/system/RoleMenuMapper.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+ INSERT INTO tb_uiws_role_menu (role_id, menu_id, read_yn, write_yn, created_by, created_at)
+ VALUES (#{roleId}, #{menuId}, #{readYn}, #{writeYn}, #{createdBy}, now())
+ ON CONFLICT (role_id, menu_id) DO UPDATE SET
+ read_yn = EXCLUDED.read_yn, write_yn = EXCLUDED.write_yn
+
+
+
+ DELETE FROM tb_uiws_role_menu WHERE role_id = #{roleId}
+
+
diff --git a/backend/src/main/resources/mapper/uiws/system/SysUserMapper.xml b/backend/src/main/resources/mapper/uiws/system/SysUserMapper.xml
new file mode 100644
index 0000000..6f4dd06
--- /dev/null
+++ b/backend/src/main/resources/mapper/uiws/system/SysUserMapper.xml
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+ WHERE 1=1
+
+ AND (user_id ILIKE '%' || #{keyword} || '%'
+ OR user_nm ILIKE '%' || #{keyword} || '%'
+ OR email ILIKE '%' || #{keyword} || '%')
+
+ AND dept_id = #{deptId}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ INSERT INTO tb_uiws_sys_user
+ (user_id, user_nm, password, email, grade_cd, dept_id, company_id, role_cd,
+ naverworks_id, login_fail_cnt, lock_yn, use_yn, approval_yn, verify_method,
+ pw_change_yn, created_by, created_at)
+ VALUES
+ (#{userId}, #{userNm}, #{password}, #{email}, #{gradeCd}, #{deptId}, #{companyId}, #{roleCd},
+ #{naverworksId}, #{loginFailCnt}, #{lockYn}, #{useYn}, #{approvalYn}, #{verifyMethod},
+ #{pwChangeYn}, #{createdBy}, now())
+
+
+
+ UPDATE tb_uiws_sys_user SET
+ user_nm = #{userNm}, email = #{email}, grade_cd = #{gradeCd},
+ dept_id = #{deptId}, company_id = #{companyId}, role_cd = #{roleCd},
+ naverworks_id = #{naverworksId}, lock_yn = #{lockYn}, use_yn = #{useYn},
+ approval_yn = #{approvalYn},
+ password = #{password},
+ updated_by = #{updatedBy}, updated_at = now()
+ WHERE user_id = #{userId}
+
+
diff --git a/frontend/src/api/uiws.ts b/frontend/src/api/uiws.ts
index 360a602..9ae1b6e 100644
--- a/frontend/src/api/uiws.ts
+++ b/frontend/src/api/uiws.ts
@@ -11,6 +11,15 @@ import api from './client'
export const verify2fa = (verifyToken: string, code: string) =>
api.post('/api/mes/auth/verify', { verifyToken, code })
+// ── 로그인 보조 3종 (MES AuthHelperController: /api/mes/auth/{signup,find-id,reset-password})
+// 무인증 접근(permitAll). 응답 래퍼 {success,message,data} 그대로 반환 — 호출부에서 data 추출.
+export const signup = (body: { username: string; password: string; displayName?: string; email: string }) =>
+ api.post('/api/mes/auth/signup', body)
+export const findId = (body: { displayName: string; email: string }) =>
+ api.post('/api/mes/auth/find-id', body)
+export const resetPassword = (body: { username: string; email: string }) =>
+ api.post('/api/mes/auth/reset-password', body)
+
// ── 쪽지(message)
export const sendMessage = (body: object) => api.post('/api/messages', body)
export const listSent = (params: Record) => api.get('/api/messages/sent', { params })
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx
index fbe30cb..807864d 100644
--- a/frontend/src/pages/Login.tsx
+++ b/frontend/src/pages/Login.tsx
@@ -2,7 +2,9 @@ import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Cpu } from 'lucide-react'
import { login, getMe } from '../api/client'
-import { verify2fa } from '../api/uiws'
+import { verify2fa, signup, findId, resetPassword } from '../api/uiws'
+
+type HelperMode = 'signup' | 'find-id' | 'reset-password'
/**
* GUARDiA MES 로그인 — UIWS 2FA 레이어 대응.
@@ -21,6 +23,50 @@ export default function Login() {
const [code, setCode] = useState('')
const nav = useNavigate()
+ // ── 로그인 보조 3종(회원가입/아이디찾기/비밀번호초기화) 모달 상태 ──────────────
+ const [helper, setHelper] = useState(null)
+ const [hForm, setHForm] = useState({ username: '', password: '', displayName: '', email: '' })
+ const [hBusy, setHBusy] = useState(false)
+ const [hMsg, setHMsg] = useState('') // 결과 안내(성공/실패 공통)
+ const [hErr, setHErr] = useState(false) // 메시지 톤(에러 여부)
+
+ const openHelper = (mode: HelperMode) => {
+ setHelper(mode); setHForm({ username: '', password: '', displayName: '', email: '' })
+ setHMsg(''); setHErr(false)
+ }
+ const closeHelper = () => { setHelper(null); setHMsg(''); setHErr(false) }
+
+ // 보조 기능 제출 — 응답 봉투 {success,message,data}. 임시비번/존재여부는 서버가 메시지로만 안내.
+ const submitHelper = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setHBusy(true); setHMsg(''); setHErr(false)
+ try {
+ let res
+ if (helper === 'signup') {
+ res = await signup({ username: hForm.username.trim(), password: hForm.password,
+ displayName: hForm.displayName.trim() || undefined, email: hForm.email.trim() })
+ const d = res.data?.data
+ setHErr(!d?.success); setHMsg(d?.message || '처리되었습니다.')
+ } else if (helper === 'find-id') {
+ res = await findId({ displayName: hForm.displayName.trim(), email: hForm.email.trim() })
+ const d = res.data?.data
+ if (d?.found) { setHErr(false); setHMsg(`회원님의 아이디는 [${d.maskedUsername}] 입니다.`) }
+ else { setHErr(true); setHMsg('일치하는 계정을 찾을 수 없습니다. 이름과 이메일을 확인하세요.') }
+ } else if (helper === 'reset-password') {
+ res = await resetPassword({ username: hForm.username.trim(), email: hForm.email.trim() })
+ const d = res.data?.data
+ setHErr(!d?.success); setHMsg(d?.message || '요청이 처리되었습니다.')
+ }
+ } catch {
+ setHErr(true); setHMsg('요청 처리 중 오류가 발생했습니다. 잠시 후 다시 시도하세요.')
+ } finally {
+ setHBusy(false)
+ }
+ }
+
+ const helperTitle = helper === 'signup' ? '회원가입(승인 대기)'
+ : helper === 'find-id' ? '아이디 찾기' : '비밀번호 초기화'
+
// access 토큰 저장 + getMe 로 role/username 보관(Sidebar RBAC 가드 의존) → 대시보드 이동
const finish = async (token?: string) => {
if (!token) throw new Error('no token')
@@ -110,9 +156,71 @@ export default function Login() {
)}
{step === 'login' && (
- admin / manager / worker · admin123
+ <>
+ {/* 로그인 보조 3종 링크 (UIWS auth 패턴 이식) */}
+
+
+ |
+
+ |
+
+
+ admin / manager / worker · admin123
+ >
)}
+
+ {/* 로그인 보조 모달 (MES 다크 테마 토큰 재사용 — 하드코딩 색상 없음) */}
+ {helper && (
+
+ )}
)
}