diff --git a/backend/pom.xml b/backend/pom.xml index 808494f..fa16c7c 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -42,17 +42,11 @@ org.postgresqlpostgresql${postgresql.version} - - org.duckdbduckdb_jdbc1.1.3 - io.jsonwebtokenjjwt-api${jjwt.version} io.jsonwebtokenjjwt-impl${jjwt.version}runtime io.jsonwebtokenjjwt-jackson${jjwt.version}runtime - - dev.samstevens.totptotp1.7.1 - org.springdoc diff --git a/backend/src/main/java/com/zioinfo/hrm/admin/AdminController.java b/backend/src/main/java/com/zioinfo/hrm/admin/AdminController.java index 60e4e71..ca94dea 100644 --- a/backend/src/main/java/com/zioinfo/hrm/admin/AdminController.java +++ b/backend/src/main/java/com/zioinfo/hrm/admin/AdminController.java @@ -51,31 +51,6 @@ public class AdminController { return ApiResponse.ok(null); } - /** - * 관리자 OTP 초기화(SUPERADMIN). 대상 사용자의 OTP 시크릿을 폐기하고 등록을 해제한다. - * 사용자는 다음 로그인 시 OTP_SETUP(QR 재등록) 플로우를 탄다. 시크릿은 응답/로그에 노출하지 않는다. - */ - @PostMapping("/users/{id}/otp-reset") - public ApiResponse resetOtp(@PathVariable Long id, Authentication auth) { - HrmUser user = adminMapper.findUserById(id); - if (user == null) { - throw new RuntimeException("ERR-HRM-404: 대상 사용자를 찾을 수 없습니다"); - } - adminMapper.clearOtp(id); - - Map log = new HashMap<>(); - log.put("actor", AuthSupport.actor(auth)); - log.put("action", "USER_OTP_RESET"); - log.put("targetType", "auth.otp"); - log.put("targetId", user.getUsername()); - log.put("detail", "OTP 초기화(다음 로그인 재등록)"); - log.put("ipAddr", null); - adminMapper.insertAuditLog(log); - - user.setPasswordHash(null); // 비밀번호 해시 미노출 - return ApiResponse.ok(user); - } - @GetMapping("/audit") public ApiResponse> audit( @RequestParam(required = false) String actor, diff --git a/backend/src/main/java/com/zioinfo/hrm/admin/AdminMapper.java b/backend/src/main/java/com/zioinfo/hrm/admin/AdminMapper.java index 1c2c54e..e69d8a8 100644 --- a/backend/src/main/java/com/zioinfo/hrm/admin/AdminMapper.java +++ b/backend/src/main/java/com/zioinfo/hrm/admin/AdminMapper.java @@ -14,8 +14,6 @@ public interface AdminMapper { int insertUser(HrmUser user); int updateUser(HrmUser user); int updateUserActive(@Param("id") Long id, @Param("active") boolean active); - /** 관리자 OTP 초기화: 시크릿 폐기 + otp_enabled=false(다음 로그인 시 재등록 유도). 시크릿 미노출. */ - int clearOtp(@Param("id") Long id); List> findAuditLogs(@Param("actor") String actor, @Param("action") String action, @Param("offset") int offset, diff --git a/backend/src/main/java/com/zioinfo/hrm/ai/AiService.java b/backend/src/main/java/com/zioinfo/hrm/ai/AiService.java index 9325dcc..cb2df98 100644 --- a/backend/src/main/java/com/zioinfo/hrm/ai/AiService.java +++ b/backend/src/main/java/com/zioinfo/hrm/ai/AiService.java @@ -1,27 +1,37 @@ package com.zioinfo.hrm.ai; -import com.zioinfo.hrm.ai.service.AiTextRouter; -import com.zioinfo.hrm.common.ai.TextAiClient.GenResult; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import java.time.Duration; import java.util.HashMap; import java.util.List; import java.util.Map; /** - * AI 인사 분석 서비스. 텍스트 생성은 {@link AiTextRouter} 경유(provider 선택: Claude ↔ Ollama). - * 외부 AI API 는 소유자 승인 예외인 Claude(api.anthropic.com) 만 허용, 그 외 온프레미스 Ollama 전용. - * Claude/Ollama 응답 실패 시 Java 폴백 로직으로 대체(무회귀). + * Ollama 온프레미스 AI 인사 분석 서비스. + * 외부 AI API 절대 금지 — localhost:11434 만 사용. + * Ollama 응답 실패 시 Java 폴백 로직으로 대체. */ @Slf4j @Service @RequiredArgsConstructor public class AiService { - /** provider 라우터(Claude→Ollama 폴백 + 추론 로그). 기존 Ollama 직접호출을 대체. */ - private final AiTextRouter aiTextRouter; + @Value("${guardia.ollama-url:http://localhost:11434}") + private String ollamaUrl; + + @Value("${guardia.ollama-text-model:llama3}") + private String textModel; + + private WebClient ollamaClient() { + return WebClient.builder().baseUrl(ollamaUrl) + .codecs(c -> c.defaultCodecs().maxInMemorySize(4 * 1024 * 1024)) + .build(); + } public Map predictTurnover(Long empId) { try { @@ -107,12 +117,16 @@ public class AiService { } private Map callOllama(String prompt) { - // provider 라우터 경유: provider=claude·키설정·활성 → Claude, 실패/그외 → Ollama(선택모델) 폴백. - GenResult gr = aiTextRouter.generate(prompt); - if (gr.degraded() || gr.text() == null || gr.text().isBlank()) { - return fallbackTurnover(); - } - String response = gr.text(); + Map body = Map.of("model", textModel, "prompt", prompt, "stream", false); + @SuppressWarnings("unchecked") + Map resp = ollamaClient().post().uri("/api/generate") + .bodyValue(body) + .retrieve() + .bodyToMono(Map.class) + .timeout(Duration.ofSeconds(30)) + .block(); + if (resp == null) return fallbackTurnover(); + String response = (String) resp.getOrDefault("response", "{}"); // JSON 추출 int start = response.indexOf('{'); int end = response.lastIndexOf('}'); @@ -120,10 +134,10 @@ public class AiService { // 간단 파싱: 실제로는 Jackson 사용 Map r = new HashMap<>(); r.put("raw", response.substring(start, end + 1)); - r.put("source", "ai"); + r.put("source", "ollama"); return r; } - return Map.of("response", response, "source", "ai"); + return Map.of("response", response, "source", "ollama"); } private Map fallbackTurnover() { diff --git a/backend/src/main/java/com/zioinfo/hrm/auth/AuthController.java b/backend/src/main/java/com/zioinfo/hrm/auth/AuthController.java index a32e872..3345745 100644 --- a/backend/src/main/java/com/zioinfo/hrm/auth/AuthController.java +++ b/backend/src/main/java/com/zioinfo/hrm/auth/AuthController.java @@ -1,54 +1,22 @@ package com.zioinfo.hrm.auth; -import com.zioinfo.hrm.auth.dto.ChangePasswordRequest; -import com.zioinfo.hrm.auth.dto.OtpConfirmRequest; -import com.zioinfo.hrm.auth.dto.OtpSetupResponse; -import com.zioinfo.hrm.auth.dto.OtpVerifyRequest; import com.zioinfo.hrm.common.ApiResponse; -import com.zioinfo.hrm.uiws.auth.OtpAuthService; -import com.zioinfo.hrm.uiws.auth.TwoFactorService; -import com.zioinfo.hrm.uiws.common.UiwsApiException; -import com.zioinfo.hrm.uiws.common.UiwsErrorCode; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import java.util.Map; -/** - * HRM 인증 컨트롤러. - * - /login: 2FA off 면 { token, type, twofa:"false" }. - * OTP on 이면 { twofa:"true", verifyToken, verifyMethod:(OTP|OTP_SETUP), secret?, otpAuthUri?, qrImage? }. - * 이메일 2FA on 이면 { twofa:"true", verifyToken, step, maskedEmail }. - * - /verify-otp: (UIMS 방식) verify-token + 6자리 → access/refresh(최초 로그인이면 등록 확정). - * - /verify: (이메일 2FA) verify-token + 인증코드 → access/refresh. - * 기존 클라이언트(2FA off)는 응답에 token 보존 → 회귀 0. - */ @RestController @RequestMapping("/api/hrm/auth") @RequiredArgsConstructor public class AuthController { private final AuthService authService; - private final TwoFactorService twoFactorService; - private final OtpAuthService otpAuthService; - private final JwtUtil jwtUtil; @PostMapping("/login") public ApiResponse> login(@RequestBody LoginRequest req) { - return ApiResponse.ok(authService.login(req.username(), req.password())); - } - - /** 이메일 2FA 이식: 2차 인증 코드 검증 → access/refresh 발급. */ - @PostMapping("/verify") - public ApiResponse> verify(@RequestBody VerifyRequest req) { - return ApiResponse.ok(twoFactorService.verify(req.verifyToken(), req.code())); - } - - /** TOTP 이식: 로그인 2단계 6자리 검증 → access/refresh 발급(최초 로그인이면 등록 확정). */ - @PostMapping("/verify-otp") - public ApiResponse> verifyOtp(@Valid @RequestBody OtpVerifyRequest req) { - return ApiResponse.ok(otpAuthService.verifyOtp(req.verifyToken(), req.code())); + String token = authService.login(req.username(), req.password()); + return ApiResponse.ok(Map.of("token", token, "type", "Bearer")); } @GetMapping("/me") @@ -57,50 +25,5 @@ public class AuthController { return ApiResponse.ok(authService.me(token)); } - // ── 마이페이지: OTP 등록/재설정/해제 + 비밀번호 변경 (본인, access 토큰 필요) ────────── - - /** 마이페이지 OTP 등록/재설정 시작 → { secret, otpAuthUri, qrImage }(이 응답에서만 시크릿/QR 노출). */ - @PostMapping("/otp/setup") - public ApiResponse otpSetup(@RequestHeader("Authorization") String header) { - return ApiResponse.ok(otpAuthService.setup(requireUser(header))); - } - - /** 마이페이지 OTP 확인·활성화(앱 6자리 코드). */ - @PostMapping("/otp/confirm") - public ApiResponse> otpConfirm(@RequestHeader("Authorization") String header, - @Valid @RequestBody OtpConfirmRequest req) { - otpAuthService.confirm(requireUser(header), req.code()); - return ApiResponse.ok(Map.of("result", "ok")); - } - - /** 마이페이지 OTP 해제. */ - @PostMapping("/otp/disable") - public ApiResponse> otpDisable(@RequestHeader("Authorization") String header) { - otpAuthService.disable(requireUser(header)); - return ApiResponse.ok(Map.of("result", "ok")); - } - - /** 마이페이지 비밀번호 변경(현재 비번 검증 + BCrypt). 새 비번은 응답/로그 미포함. */ - @PostMapping("/change-password") - public ApiResponse> changePassword(@RequestHeader("Authorization") String header, - @Valid @RequestBody ChangePasswordRequest req) { - authService.changePassword(requireUser(header), req); - return ApiResponse.ok(Map.of("result", "ok")); - } - - /** - * Authorization 헤더에서 인증 사용자를 해석한다. verify-token(2fa 단계용)은 거부. - * (/api/hrm/auth/** 는 permitAll 이라 필터 인증이 없으므로 여기서 access 토큰을 명시 검증.) - */ - private String requireUser(String header) { - String token = header == null ? "" : header.replace("Bearer ", "").trim(); - if (token.isEmpty() || jwtUtil.isVerifyToken(token) || !jwtUtil.isValid(token)) { - throw new UiwsApiException(UiwsErrorCode.UNAUTHORIZED); - } - return jwtUtil.getUsername(token); - } - record LoginRequest(String username, String password) {} - - record VerifyRequest(String verifyToken, String code) {} } diff --git a/backend/src/main/java/com/zioinfo/hrm/auth/AuthService.java b/backend/src/main/java/com/zioinfo/hrm/auth/AuthService.java index 14043d6..e9a4bd1 100644 --- a/backend/src/main/java/com/zioinfo/hrm/auth/AuthService.java +++ b/backend/src/main/java/com/zioinfo/hrm/auth/AuthService.java @@ -1,35 +1,13 @@ package com.zioinfo.hrm.auth; -import com.zioinfo.hrm.auth.dto.FindIdRequest; -import com.zioinfo.hrm.auth.dto.FindIdResponse; -import com.zioinfo.hrm.auth.dto.ResetPwRequest; -import com.zioinfo.hrm.auth.dto.SignupRequest; -import com.zioinfo.hrm.auth.dto.SignupResponse; -import com.zioinfo.hrm.auth.dto.ChangePasswordRequest; import com.zioinfo.hrm.auth.mapper.UserMapper; -import com.zioinfo.hrm.uiws.auth.OtpAuthService; -import com.zioinfo.hrm.uiws.auth.TwoFactorService; -import com.zioinfo.hrm.uiws.common.UiwsApiException; -import com.zioinfo.hrm.uiws.common.UiwsErrorCode; -import com.zioinfo.hrm.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; -/** - * HRM 인증 서비스. - * - 기존 단일 JWT 로그인 보존(2FA off 또는 미설정 시 회귀 0). - * - UIWS 2FA 레이어(이메일 코드) 추가: 2FA on 이면 1차 통과 후 verify-token + 이메일코드 발급. - * 실패 누적 max-login-fail 회 시 계정 잠금. - * - UIWS 로그인 보조 3종(회원가입·아이디찾기·비밀번호 초기화) 이식. - */ -@Slf4j @Service @RequiredArgsConstructor public class AuthService { @@ -37,66 +15,16 @@ public class AuthService { private final UserMapper userMapper; private final PasswordEncoder passwordEncoder; private final JwtUtil jwtUtil; - private final TwoFactorService twoFactorService; - private final OtpAuthService otpAuthService; - private final MailSender mailSender; - private static final SecureRandom RANDOM = new SecureRandom(); - // 혼동 문자(0/O/1/l/I) 제외 — 임시비번 가독성. - private static final String TMP_PW_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789"; - - /** - * 1차 로그인. 2FA 활성 시 verify-token + 이메일코드 흐름으로 분기, - * 비활성 시 기존처럼 access 토큰 즉시 발급. - * - * @return 2FA off: { token, type, twofa:"false" } - * 2FA on : { verifyToken, step:"EMAIL", maskedEmail, twofa:"true" } - */ - public Map login(String username, String password) { + public String login(String username, String password) { HrmUser user = userMapper.findByUsername(username); - - // 잠금 우선 차단(존재하는 계정에 한해 잠금 메시지 — 존재 여부 누설 최소화) - if (user != null && twoFactorService.isLocked(user)) { - throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED); - } if (user == null || !user.isActive()) { throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정"); } - // 회원가입 승인 게이트(UIWS 로그인 보조 이식): 가입 신청자(approved=false)는 관리자 승인 전 로그인 차단. - // 기존 계정은 approved=true(91_uiws_port.sql 멱등 보정)라 회귀 없음. - if (Boolean.FALSE.equals(user.getApproved())) { - throw new RuntimeException("ERR-AUTH-003: 승인 대기 중인 계정입니다. 관리자 승인 후 로그인하세요."); - } if (!passwordEncoder.matches(password, user.getPasswordHash())) { - // 2FA(OTP 또는 이메일) 활성 시 실패 누적/잠금. 비활성 시 기존 동작(메시지만) 유지. - if (otpAuthService.isEnabled() || twoFactorService.isEnabled()) { - twoFactorService.recordLoginFailure(username); - HrmUser after = userMapper.findByUsername(username); - if (after != null && Boolean.TRUE.equals(after.getLocked())) { - throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED); - } - } throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치"); } - - // 비밀번호 검증 통과 — 2단계 우선순위: OTP > 이메일코드 > 단일 로그인 - if (otpAuthService.isEnabled()) { - // { twofa:true, verifyToken, verifyMethod:(OTP|OTP_SETUP), secret?, otpAuthUri?, qrImage? } - return otpAuthService.beginOtp(user); - } - if (twoFactorService.isEnabled()) { - Map step1 = twoFactorService.beginTwoFactor(user); - return Map.of( - "twofa", "true", - "verifyToken", step1.get("verifyToken"), - "step", step1.get("step"), - "maskedEmail", step1.getOrDefault("maskedEmail", "")); - } - - // 2FA 비활성 — 기존 단일 로그인 흐름(회귀 0) - userMapper.resetLoginFail(username); - String token = jwtUtil.generate(username, user.getRole()); - return Map.of("twofa", "false", "token", token, "type", "Bearer"); + return jwtUtil.generate(username, user.getRole()); } public Map me(String token) { @@ -109,122 +37,4 @@ public class AuthService { m.put("displayName", u != null ? u.getDisplayName() : username); return m; } - - /** - * 마이페이지 비밀번호 변경(현재 비밀번호 검증 + BCrypt 저장). UIMS changePassword 미러. - * 현재 비번 불일치 → PASSWORD_MISMATCH, 기존과 동일 → PASSWORD_SAME_AS_OLD. - * 새 비밀번호 평문은 로그/응답/감사에 절대 기록하지 않는다(보안 불변규칙). - */ - @Transactional - public void changePassword(String username, ChangePasswordRequest req) { - HrmUser user = userMapper.findByUsername(username); - if (user == null) { - throw new UiwsApiException(UiwsErrorCode.USER_NOT_FOUND); - } - if (!passwordEncoder.matches(req.currentPassword(), user.getPasswordHash())) { - throw new UiwsApiException(UiwsErrorCode.PASSWORD_MISMATCH); - } - if (passwordEncoder.matches(req.newPassword(), user.getPasswordHash())) { - throw new UiwsApiException(UiwsErrorCode.PASSWORD_SAME_AS_OLD); - } - userMapper.updatePasswordByUsername(username, passwordEncoder.encode(req.newPassword())); - log.info("[auth] password changed: username={}", username); - } - - // ── 로그인 보조 3종 (UIWS auth 패턴 이식) ──────────────────────────────────── - - /** - * 운영자 회원가입(승인 대기 INSERT). username/email 중복 차단, 비번 BCrypt, approved=false. - * 보안: 응답에 자격증명·임시정보 미포함, 일반 메시지만. - */ - public SignupResponse 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 SignupResponse(false, "아이디·비밀번호(4자 이상)·이메일은 필수입니다."); - } - if (userMapper.countByUsername(req.username()) > 0) { - return new SignupResponse(false, "이미 사용 중인 아이디입니다."); - } - if (userMapper.countByEmail(req.email()) > 0) { - return new SignupResponse(false, "이미 등록된 이메일입니다."); - } - HrmUser u = new HrmUser(); - 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()); - // insertSignup: role='VIEWER', approved=false, locked=false, login_fail_count=0 (XML 고정) - userMapper.insertSignup(u); - log.info("[auth-helper] signup pending approval: username={}", req.username()); - return new SignupResponse(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, ""); - } - HrmUser 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 SignupResponse resetPassword(ResetPwRequest req) { - final String okMsg = "임시 비밀번호를 등록된 이메일로 발송했습니다. 메일을 확인하세요."; - if (req.username() == null || req.username().isBlank() - || req.email() == null || req.email().isBlank()) { - return new SignupResponse(false, "아이디와 이메일을 모두 입력하세요."); - } - HrmUser u = userMapper.findByUsernameAndEmail(req.username(), req.email()); - if (u == null) { - // 존재 여부 누설 방지 — 동일 성공 메시지 반환(실제 발송 없음). - log.info("[auth-helper] reset-password no match (suppressed): username={}", req.username()); - return new SignupResponse(true, okMsg); - } - String tempPw = generateTempPassword(); - // applyTempPassword: BCrypt 저장 + pw_change_yn=true + locked=false + login_fail_count=0 (멱등) - userMapper.applyTempPassword(req.username(), passwordEncoder.encode(tempPw)); - - String subject = "[GUARDiA HRM] 임시 비밀번호 안내"; - 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 SignupResponse(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/hrm/auth/HrmUser.java b/backend/src/main/java/com/zioinfo/hrm/auth/HrmUser.java index ef06201..ad98500 100644 --- a/backend/src/main/java/com/zioinfo/hrm/auth/HrmUser.java +++ b/backend/src/main/java/com/zioinfo/hrm/auth/HrmUser.java @@ -17,20 +17,4 @@ public class HrmUser { private String role; private boolean active; private LocalDateTime createdAt; - - // UIWS 2FA 이식 컬럼 (멱등 ALTER — db/91_uiws_port.sql). - private String emailVerifyCode; - private LocalDateTime emailVerifyExpire; - private Integer loginFailCount; - private Boolean locked; - /** TOTP 시크릿(보류/확정 공용). API 응답·로그에 절대 미노출. (db/91_uiws_port.sql ALTER) */ - private String otpSecret; - /** OTP 등록 확정 여부(기본 false). 최초 로그인 verify 성공 시 true 로 확정(멱등). (db/93_auth_otp.sql ALTER) */ - private Boolean otpEnabled; - - // UIWS 로그인 보조기능 이식 컬럼 (멱등 ALTER — db/91_uiws_port.sql). - // approved: 가입 신청자는 false(관리자 승인 전 로그인 차단), 기존 계정은 true 보정. - // pwChangeYn: 임시비번 발급 시 true(다음 로그인 시 변경 유도). - private Boolean approved; - private Boolean pwChangeYn; } diff --git a/backend/src/main/java/com/zioinfo/hrm/auth/JwtFilter.java b/backend/src/main/java/com/zioinfo/hrm/auth/JwtFilter.java index e60bf2c..6e597ea 100644 --- a/backend/src/main/java/com/zioinfo/hrm/auth/JwtFilter.java +++ b/backend/src/main/java/com/zioinfo/hrm/auth/JwtFilter.java @@ -26,10 +26,7 @@ public class JwtFilter extends OncePerRequestFilter { String header = req.getHeader("Authorization"); if (header != null && header.startsWith("Bearer ")) { String token = header.substring(7); - // 보안(UIWS 2FA): purpose=2fa verify-token 은 access 토큰이 아니다. - // 동일 서명키라 isValid()는 통과하므로 별도 차단하지 않으면 2차 인증 전 보호 API 접근(2FA 우회)이 가능. - // → verify-token 은 인증 컨텍스트를 세우지 않고 무시한다(/api/hrm/auth/verify 에서만 사용). - if (jwtUtil.isValid(token) && !jwtUtil.isVerifyToken(token)) { + if (jwtUtil.isValid(token)) { String username = jwtUtil.getUsername(token); String role = jwtUtil.getRole(token); var auth = new UsernamePasswordAuthenticationToken( diff --git a/backend/src/main/java/com/zioinfo/hrm/auth/JwtUtil.java b/backend/src/main/java/com/zioinfo/hrm/auth/JwtUtil.java index 6df8619..2656647 100644 --- a/backend/src/main/java/com/zioinfo/hrm/auth/JwtUtil.java +++ b/backend/src/main/java/com/zioinfo/hrm/auth/JwtUtil.java @@ -34,47 +34,6 @@ public class JwtUtil { .compact(); } - /** - * UIWS 2FA 이식: 1차 로그인 통과 후 발급하는 단기 verify-token. - * purpose=2fa 클레임으로 access 토큰과 구분(verify-token 으로는 보호 API 접근 불가). - */ - public String generateVerifyToken(String username, long validitySeconds) { - return Jwts.builder() - .subject(username) - .claim("purpose", "2fa") - .issuedAt(new Date()) - .expiration(new Date(System.currentTimeMillis() + validitySeconds * 1000L)) - .signWith(key()) - .compact(); - } - - /** verify-token 검증 후 username 반환. 유효하지 않거나 purpose!=2fa 면 null. */ - public String parseVerifyTokenUsername(String token) { - try { - Claims c = parse(token); - if (!"2fa".equals(c.get("purpose", String.class))) { - return null; - } - return c.getSubject(); - } catch (JwtException | IllegalArgumentException e) { - log.debug("verify-token 검증 실패: {}", e.getMessage()); - return null; - } - } - - /** - * 보안: 토큰이 2FA verify-token(purpose=2fa)인지 판별. - * JwtFilter 가 access 토큰만 인증 컨텍스트로 인정하도록 verify-token 을 걸러내는 데 사용 - * (2차 인증 전 verify-token 으로 보호 API 에 접근하는 2FA 우회 차단). - */ - public boolean isVerifyToken(String token) { - try { - return "2fa".equals(parse(token).get("purpose", String.class)); - } catch (JwtException | IllegalArgumentException e) { - return false; - } - } - public Claims parse(String token) { return Jwts.parser().verifyWith(key()).build() .parseSignedClaims(token).getPayload(); diff --git a/backend/src/main/java/com/zioinfo/hrm/auth/mapper/UserMapper.java b/backend/src/main/java/com/zioinfo/hrm/auth/mapper/UserMapper.java index ecb7c17..28bdf7c 100644 --- a/backend/src/main/java/com/zioinfo/hrm/auth/mapper/UserMapper.java +++ b/backend/src/main/java/com/zioinfo/hrm/auth/mapper/UserMapper.java @@ -3,123 +3,9 @@ package com.zioinfo.hrm.auth.mapper; import com.zioinfo.hrm.auth.HrmUser; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Select; -import org.apache.ibatis.annotations.Update; -import java.time.LocalDateTime; - -/** - * HRM 운영자 계정 매퍼. - * 기존 메서드(findByUsername/insert)에 UIWS 2FA·로그인 보조기능 이식 메서드를 추가. - * 대상 테이블 hrm_users (컬럼 active — pms_user.is_active 와 다름). - */ @Mapper public interface UserMapper { - HrmUser findByUsername(@Param("username") String username); - int insert(HrmUser user); - - /** 회원가입(승인 대기): username·password_hash·display_name·email + role='VIEWER'·approved=false. */ - int insertSignup(HrmUser user); - - // ── UIWS 로그인 보조기능 이식(회원가입·아이디찾기·비번초기화) ─────────────── - - /** 가입 중복 검사: username 존재 여부. */ - @Select("SELECT COUNT(1) FROM hrm_users WHERE username = #{username}") - int countByUsername(@Param("username") String username); - - /** 가입 중복 검사: email 존재 여부(enumeration 최소화 — 내부 사용). */ - @Select("SELECT COUNT(1) FROM hrm_users WHERE email = #{email}") - int countByEmail(@Param("email") String email); - - /** 아이디찾기: 이름(display_name)·이메일 일치 사용자(존재 시 마스킹 반환). */ - @Select(""" - SELECT id, username, password_hash, role, active, created_at, - display_name, email, - email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, - approved, pw_change_yn - FROM hrm_users - WHERE display_name = #{displayName} AND email = #{email} - ORDER BY id LIMIT 1 - """) - HrmUser findByDisplayNameAndEmail(@Param("displayName") String displayName, - @Param("email") String email); - - /** 비번초기화: username·email 동시 일치 검증용. */ - @Select(""" - SELECT id, username, password_hash, role, active, created_at, - display_name, email, - email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, - approved, pw_change_yn - FROM hrm_users - WHERE username = #{username} AND email = #{email} - """) - HrmUser findByUsernameAndEmail(@Param("username") String username, - @Param("email") String email); - - /** 비번초기화: 임시비번 적용 + 변경유도 + 잠금/실패카운트 해제(멱등 UPDATE). */ - @Update(""" - UPDATE hrm_users - SET password_hash = #{passwordHash}, pw_change_yn = true, - locked = false, login_fail_count = 0 - WHERE username = #{username} - """) - int applyTempPassword(@Param("username") String username, - @Param("passwordHash") String passwordHash); - - // ── UIWS 2FA 이식: 실패 카운트/잠금/인증코드 갱신 (멱등 UPDATE) ─────────────── - - /** 로그인 성공 시 실패 카운트 초기화. */ - @Update("UPDATE hrm_users SET login_fail_count = 0 WHERE username = #{username}") - int resetLoginFail(@Param("username") String username); - - /** 로그인 실패 누적(+1) 및 임계 도달 시 잠금. */ - @Update(""" - UPDATE hrm_users - SET login_fail_count = COALESCE(login_fail_count, 0) + 1, - locked = (COALESCE(login_fail_count, 0) + 1 >= #{maxFail}) - WHERE username = #{username} - """) - int incrementLoginFail(@Param("username") String username, @Param("maxFail") int maxFail); - - /** 1차 통과 시 이메일 인증코드/만료 저장(verify 단계에서 검증). */ - @Update(""" - UPDATE hrm_users - SET email_verify_code = #{code}, email_verify_expire = #{expire}, login_fail_count = 0 - WHERE username = #{username} - """) - int saveEmailCode(@Param("username") String username, - @Param("code") String code, - @Param("expire") LocalDateTime expire); - - /** 2차 검증 성공 시 코드 폐기. */ - @Update("UPDATE hrm_users SET email_verify_code = NULL, email_verify_expire = NULL WHERE username = #{username}") - int clearEmailCode(@Param("username") String username); - - /** 관리자 잠금 해제(실패 카운트/잠금 초기화). */ - @Update("UPDATE hrm_users SET locked = false, login_fail_count = 0 WHERE username = #{username}") - int unlock(@Param("username") String username); - - /** - * BCrypt 비밀번호 해시 갱신(username 기준). 평문은 절대 저장하지 않는다. - * 마이페이지 비밀번호 변경 + AdminPasswordSeeder(env 재시드) 공용. - */ - @Update("UPDATE hrm_users SET password_hash = #{passwordHash} WHERE username = #{username}") - int updatePasswordByUsername(@Param("username") String username, - @Param("passwordHash") String passwordHash); - - // ── TOTP(OTP 2차 인증) 이식 (멱등 UPDATE) ──────────────────────────────────── - - /** 보류 시크릿 저장(등록 확정 전). otp_enabled 은 건드리지 않는다(verify/confirm 에서 확정). */ - @Update("UPDATE hrm_users SET otp_secret = #{secret} WHERE username = #{username}") - int updateOtpSecret(@Param("username") String username, @Param("secret") String secret); - - /** 등록 확정: otp_enabled=true (시크릿은 유지). */ - @Update("UPDATE hrm_users SET otp_enabled = true WHERE username = #{username}") - int enableOtp(@Param("username") String username); - - /** 마이페이지 해제: 시크릿 폐기 + otp_enabled=false. */ - @Update("UPDATE hrm_users SET otp_secret = NULL, otp_enabled = false WHERE username = #{username}") - int disableOtp(@Param("username") String username); } diff --git a/backend/src/main/java/com/zioinfo/hrm/config/SecurityConfig.java b/backend/src/main/java/com/zioinfo/hrm/config/SecurityConfig.java index 981f824..73bba0c 100644 --- a/backend/src/main/java/com/zioinfo/hrm/config/SecurityConfig.java +++ b/backend/src/main/java/com/zioinfo/hrm/config/SecurityConfig.java @@ -41,18 +41,10 @@ public class SecurityConfig { .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/hrm/auth/**").permitAll() - // UIWS 로그인 보조 3종(회원가입·아이디찾기·비번초기화) — 로그인 전 무인증 접근. - .requestMatchers("/api/auth/**").permitAll() - // UIWS system: 가입 화면 공개 조회(부서/회사 GET) — permitAll. - .requestMatchers(HttpMethod.GET, "/api/public/**").permitAll() .requestMatchers("/actuator/health").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api/hrm/docs/**", "/api/hrm/swagger/**").permitAll() .requestMatchers("/", "/index.html", "/assets/**", "/favicon.ico").permitAll() - // UIWS system(권한관리): 조회는 MANAGER 이상, 변경은 SUPERADMIN. - .requestMatchers(HttpMethod.GET, "/api/system/**").hasAnyRole("SUPERADMIN", "MANAGER") - .requestMatchers("/api/system/**").hasRole("SUPERADMIN") - .requestMatchers("/api/hrm/admin/users/**").hasRole("SUPERADMIN") .requestMatchers(HttpMethod.GET, "/api/hrm/admin/settings").hasAnyRole("SUPERADMIN", "MANAGER") .requestMatchers("/api/hrm/admin/settings/**").hasRole("SUPERADMIN") diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 4f1a60a..5bd7742 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -15,14 +15,8 @@ spring: minimum-idle: 1 sql: init: - # schema.sql(전부 IF NOT EXISTS + ON CONFLICT DO NOTHING)·UIWS 이식 파일(전부 멱등)만 재적용. - # 91_uiws_port.sql: 업무 모듈 tb_uiws_* + hrm_users 2FA/로그인보조 컬럼 ALTER(멱등). - # 92_uiws_system.sql: UIWS system(권한관리) tb_uiws_* 10테이블(부서/회사/코드/사용자/역할/메뉴/프로그램, 멱등). - mode: ${SQL_INIT_MODE:always} - # 104_seed_ai_config.sql: AI 플랫폼(provider/모델) 설정 시드 hrm_settings ai.* (멱등 ON CONFLICT DO NOTHING). - # 93_auth_otp.sql: hrm_users otp_enabled 멱등 ALTER(TOTP 2차 인증). ops_otp_reset_all.sql 은 미등재(운영 1회 수동). - schema-locations: classpath:db/schema.sql,classpath:db/91_uiws_port.sql,classpath:db/92_uiws_system.sql,classpath:db/93_auth_otp.sql,classpath:db/104_seed_ai_config.sql - continue-on-error: true + mode: ${SQL_INIT_MODE:never} + schema-locations: classpath:db/schema.sql servlet: multipart: max-file-size: 20MB @@ -44,38 +38,15 @@ guardia: erp-url: ${ERP_URL:http://localhost:8003} itsm-url: ${ITSM_URL:http://localhost:9001} groupware-url: ${GROUPWARE_URL:http://localhost:8009} - # 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지(Claude 는 소유자 승인 예외). + # 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지. ollama-url: ${OLLAMA_URL:http://localhost:11434} ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3} - # 중앙 guardia-rag 연동(피드백 전달). base-url 은 서버 내부 루프백 전용 — 외부 URL 금지. - rag: - base-url: ${RAG_URL:http://127.0.0.1:8020} - enabled: ${RAG_ENABLED:true} - # 로컬 임베디드 DuckDB 학습 저장소 파일 경로(솔루션별 격리). - learning: - duckdb-path: ${HRM_LEARNING_DUCKDB:/opt/guardia-hrm/data/hrm_learning.duckdb} crypto: secret: ${HRM_CRYPTO_SECRET:guardia-hrm-aes-256-gcm-master-key-2026-zioinfo} jwt: secret: ${JWT_SECRET:guardia-hrm-jwt-secret-2026-minimum-256bit-key-zioinfo} expiration: 86400000 -# UIWS 이식 모듈 설정 (worklog·schedule·message·stats + 2FA 레이어 + 로그인 보조). -# 2FA 토글: hrm.uiws.auth.twofa-enabled=false 면 기존 단일 JWT 로그인 회귀 0. -hrm: - uiws: - auth: - twofa-enabled: ${UIWS_2FA:true} - # TOTP(OTP 2차 인증, UIMS 방식) on/off. 기본 on. 우선순위: otp → 이메일코드 → 단일. off 시 이메일/단일 경로로 폴백. - otp-enabled: ${UIWS_OTP:true} - verify-token-validity-seconds: 300 # verify-token 5분 - email-code-validity-seconds: 300 # 이메일 코드 5분 - max-login-fail: 5 # 실패 5회 잠금 - mail: - mode: ${UIWS_MAIL_MODE:log} # log 폴백(외부 API 금지). smtp 운영 시 별도 구현 - upload: - upload-dir: ${UIWS_UPLOAD_DIR:./uploads/uiws} - logging: level: com.zioinfo.hrm: DEBUG diff --git a/backend/src/main/resources/mapper/AdminMapper.xml b/backend/src/main/resources/mapper/AdminMapper.xml index 08480f0..1459b2c 100644 --- a/backend/src/main/resources/mapper/AdminMapper.xml +++ b/backend/src/main/resources/mapper/AdminMapper.xml @@ -39,10 +39,6 @@ UPDATE hrm_users SET active=#{active} WHERE id=#{id} - - UPDATE hrm_users SET otp_secret=NULL, otp_enabled=false WHERE id=#{id} - - - SELECT id, username, password_hash, display_name, email, role, active, created_at, - email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled, - approved, pw_change_yn + SELECT id, username, password_hash, display_name, email, role, active, created_at FROM hrm_users WHERE username = #{username} @@ -36,13 +24,4 @@ VALUES (#{username}, #{passwordHash}, #{displayName}, #{email}, #{role}, #{active}) - - - INSERT INTO hrm_users (username, password_hash, role, display_name, email, - active, approved, login_fail_count, locked) - VALUES (#{username}, #{passwordHash}, 'VIEWER', #{displayName}, #{email}, - true, false, 0, false) - - diff --git a/backend/target/classes/application.yml b/backend/target/classes/application.yml new file mode 100644 index 0000000..5bd7742 --- /dev/null +++ b/backend/target/classes/application.yml @@ -0,0 +1,53 @@ +server: + port: 8014 + +spring: + application: + name: guardia-hrm + datasource: + url: ${DB_URL:jdbc:postgresql://localhost:5432/hrm_db} + username: ${DB_USER:hrm_user} + password: ${DB_PASS:hrm_pass2026} + driver-class-name: org.postgresql.Driver + hikari: + # 운영 함정 준수: 다중 솔루션 동시 기동 시 PG max_connections 보호 — 풀 3개 캡. + maximum-pool-size: ${DB_POOL_MAX:3} + minimum-idle: 1 + sql: + init: + mode: ${SQL_INIT_MODE:never} + schema-locations: classpath:db/schema.sql + servlet: + multipart: + max-file-size: 20MB + max-request-size: 20MB + +mybatis: + mapper-locations: classpath:mapper/**/*.xml + configuration: + map-underscore-to-camel-case: true + log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl + +springdoc: + api-docs: + path: /api/hrm/docs + swagger-ui: + path: /api/hrm/swagger + +guardia: + erp-url: ${ERP_URL:http://localhost:8003} + itsm-url: ${ITSM_URL:http://localhost:9001} + groupware-url: ${GROUPWARE_URL:http://localhost:8009} + # 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지. + ollama-url: ${OLLAMA_URL:http://localhost:11434} + ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3} + crypto: + secret: ${HRM_CRYPTO_SECRET:guardia-hrm-aes-256-gcm-master-key-2026-zioinfo} + jwt: + secret: ${JWT_SECRET:guardia-hrm-jwt-secret-2026-minimum-256bit-key-zioinfo} + expiration: 86400000 + +logging: + level: + com.zioinfo.hrm: DEBUG + org.mybatis: WARN diff --git a/backend/target/classes/com/zioinfo/hrm/HrmApplication.class b/backend/target/classes/com/zioinfo/hrm/HrmApplication.class new file mode 100644 index 0000000..bbaf961 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/HrmApplication.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/admin/AdminController.class b/backend/target/classes/com/zioinfo/hrm/admin/AdminController.class new file mode 100644 index 0000000..cec50a7 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/admin/AdminController.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/admin/AdminMapper.class b/backend/target/classes/com/zioinfo/hrm/admin/AdminMapper.class new file mode 100644 index 0000000..ca10db5 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/admin/AdminMapper.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/ai/AiController.class b/backend/target/classes/com/zioinfo/hrm/ai/AiController.class new file mode 100644 index 0000000..4fb4726 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/ai/AiController.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/ai/AiService.class b/backend/target/classes/com/zioinfo/hrm/ai/AiService.class new file mode 100644 index 0000000..c543303 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/ai/AiService.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/attendance/AttendanceController.class b/backend/target/classes/com/zioinfo/hrm/attendance/AttendanceController.class new file mode 100644 index 0000000..5e2de59 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/attendance/AttendanceController.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/attendance/AttendanceMapper.class b/backend/target/classes/com/zioinfo/hrm/attendance/AttendanceMapper.class new file mode 100644 index 0000000..7ca2581 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/attendance/AttendanceMapper.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/attendance/AttendanceService.class b/backend/target/classes/com/zioinfo/hrm/attendance/AttendanceService.class new file mode 100644 index 0000000..97b6031 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/attendance/AttendanceService.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/auth/AuthController$LoginRequest.class b/backend/target/classes/com/zioinfo/hrm/auth/AuthController$LoginRequest.class new file mode 100644 index 0000000..d922dbc Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/auth/AuthController$LoginRequest.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/auth/AuthController.class b/backend/target/classes/com/zioinfo/hrm/auth/AuthController.class new file mode 100644 index 0000000..f6df1f1 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/auth/AuthController.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/auth/AuthService.class b/backend/target/classes/com/zioinfo/hrm/auth/AuthService.class new file mode 100644 index 0000000..39e3400 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/auth/AuthService.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/auth/HrmUser.class b/backend/target/classes/com/zioinfo/hrm/auth/HrmUser.class new file mode 100644 index 0000000..51f52f3 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/auth/HrmUser.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/auth/JwtFilter.class b/backend/target/classes/com/zioinfo/hrm/auth/JwtFilter.class new file mode 100644 index 0000000..86dffb9 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/auth/JwtFilter.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/auth/JwtUtil.class b/backend/target/classes/com/zioinfo/hrm/auth/JwtUtil.class new file mode 100644 index 0000000..34bfbbf Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/auth/JwtUtil.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/auth/mapper/UserMapper.class b/backend/target/classes/com/zioinfo/hrm/auth/mapper/UserMapper.class new file mode 100644 index 0000000..66ba111 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/auth/mapper/UserMapper.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/common/ApiResponse.class b/backend/target/classes/com/zioinfo/hrm/common/ApiResponse.class new file mode 100644 index 0000000..8d81b2a Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/common/ApiResponse.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/common/AuthSupport.class b/backend/target/classes/com/zioinfo/hrm/common/AuthSupport.class new file mode 100644 index 0000000..8d1d375 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/common/AuthSupport.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/common/CryptoUtil.class b/backend/target/classes/com/zioinfo/hrm/common/CryptoUtil.class new file mode 100644 index 0000000..0cd8f3e Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/common/CryptoUtil.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/common/GlobalExceptionHandler.class b/backend/target/classes/com/zioinfo/hrm/common/GlobalExceptionHandler.class new file mode 100644 index 0000000..ebbc054 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/common/GlobalExceptionHandler.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/config/MyBatisConfig.class b/backend/target/classes/com/zioinfo/hrm/config/MyBatisConfig.class new file mode 100644 index 0000000..34617a4 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/config/MyBatisConfig.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/config/SecurityConfig.class b/backend/target/classes/com/zioinfo/hrm/config/SecurityConfig.class new file mode 100644 index 0000000..8b4629e Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/config/SecurityConfig.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/dashboard/DashboardController.class b/backend/target/classes/com/zioinfo/hrm/dashboard/DashboardController.class new file mode 100644 index 0000000..8aee888 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/dashboard/DashboardController.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/dashboard/DashboardMapper.class b/backend/target/classes/com/zioinfo/hrm/dashboard/DashboardMapper.class new file mode 100644 index 0000000..c9b5fcf Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/dashboard/DashboardMapper.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/employee/Employee.class b/backend/target/classes/com/zioinfo/hrm/employee/Employee.class new file mode 100644 index 0000000..22031cd Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/employee/Employee.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/employee/EmployeeController.class b/backend/target/classes/com/zioinfo/hrm/employee/EmployeeController.class new file mode 100644 index 0000000..eb30507 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/employee/EmployeeController.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/employee/EmployeeMapper.class b/backend/target/classes/com/zioinfo/hrm/employee/EmployeeMapper.class new file mode 100644 index 0000000..563784b Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/employee/EmployeeMapper.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/employee/EmployeeService.class b/backend/target/classes/com/zioinfo/hrm/employee/EmployeeService.class new file mode 100644 index 0000000..43d60a6 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/employee/EmployeeService.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/integration/IntegrationController.class b/backend/target/classes/com/zioinfo/hrm/integration/IntegrationController.class new file mode 100644 index 0000000..8556da9 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/integration/IntegrationController.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/integration/IntegrationService.class b/backend/target/classes/com/zioinfo/hrm/integration/IntegrationService.class new file mode 100644 index 0000000..47bc170 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/integration/IntegrationService.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/organization/OrgController.class b/backend/target/classes/com/zioinfo/hrm/organization/OrgController.class new file mode 100644 index 0000000..c1e4738 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/organization/OrgController.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/organization/OrgMapper.class b/backend/target/classes/com/zioinfo/hrm/organization/OrgMapper.class new file mode 100644 index 0000000..02c3fe9 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/organization/OrgMapper.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/organization/OrgService.class b/backend/target/classes/com/zioinfo/hrm/organization/OrgService.class new file mode 100644 index 0000000..bcf5520 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/organization/OrgService.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/payroll/PayrollController.class b/backend/target/classes/com/zioinfo/hrm/payroll/PayrollController.class new file mode 100644 index 0000000..09c5cb3 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/payroll/PayrollController.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/payroll/PayrollMapper.class b/backend/target/classes/com/zioinfo/hrm/payroll/PayrollMapper.class new file mode 100644 index 0000000..e9179e7 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/payroll/PayrollMapper.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/payroll/PayrollService.class b/backend/target/classes/com/zioinfo/hrm/payroll/PayrollService.class new file mode 100644 index 0000000..4d1f3f5 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/payroll/PayrollService.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/performance/PerformanceController.class b/backend/target/classes/com/zioinfo/hrm/performance/PerformanceController.class new file mode 100644 index 0000000..237f2dd Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/performance/PerformanceController.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/performance/PerformanceMapper.class b/backend/target/classes/com/zioinfo/hrm/performance/PerformanceMapper.class new file mode 100644 index 0000000..9fb060c Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/performance/PerformanceMapper.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/performance/PerformanceService.class b/backend/target/classes/com/zioinfo/hrm/performance/PerformanceService.class new file mode 100644 index 0000000..1b7c40a Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/performance/PerformanceService.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/recruitment/RecruitmentController.class b/backend/target/classes/com/zioinfo/hrm/recruitment/RecruitmentController.class new file mode 100644 index 0000000..3a73d5d Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/recruitment/RecruitmentController.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/recruitment/RecruitmentMapper.class b/backend/target/classes/com/zioinfo/hrm/recruitment/RecruitmentMapper.class new file mode 100644 index 0000000..00e9e33 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/recruitment/RecruitmentMapper.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/recruitment/RecruitmentService.class b/backend/target/classes/com/zioinfo/hrm/recruitment/RecruitmentService.class new file mode 100644 index 0000000..585f227 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/recruitment/RecruitmentService.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/training/TrainingController.class b/backend/target/classes/com/zioinfo/hrm/training/TrainingController.class new file mode 100644 index 0000000..28909ce Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/training/TrainingController.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/training/TrainingMapper.class b/backend/target/classes/com/zioinfo/hrm/training/TrainingMapper.class new file mode 100644 index 0000000..97f2c29 Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/training/TrainingMapper.class differ diff --git a/backend/target/classes/com/zioinfo/hrm/training/TrainingService.class b/backend/target/classes/com/zioinfo/hrm/training/TrainingService.class new file mode 100644 index 0000000..2faabdb Binary files /dev/null and b/backend/target/classes/com/zioinfo/hrm/training/TrainingService.class differ diff --git a/backend/target/classes/db/schema.sql b/backend/target/classes/db/schema.sql new file mode 100644 index 0000000..01a1af8 --- /dev/null +++ b/backend/target/classes/db/schema.sql @@ -0,0 +1,515 @@ +-- GUARDiA HRM v1.0 — PostgreSQL 스키마 +-- DROP TABLE 없이 CREATE TABLE IF NOT EXISTS 사용 + +-- 사용자/권한 +CREATE TABLE IF NOT EXISTS hrm_users ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + display_name VARCHAR(100), + email VARCHAR(200), + role VARCHAR(30) NOT NULL DEFAULT 'HR_STAFF', + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- 역할: SUPERADMIN / MANAGER / HR_STAFF / VIEWER + +-- 부서 +CREATE TABLE IF NOT EXISTS hrm_departments ( + id BIGSERIAL PRIMARY KEY, + dept_code VARCHAR(30) NOT NULL UNIQUE, + dept_name VARCHAR(100) NOT NULL, + parent_id BIGINT REFERENCES hrm_departments(id), + manager_emp_id BIGINT, + sort_order INT NOT NULL DEFAULT 0, + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- 직책 +CREATE TABLE IF NOT EXISTS hrm_positions ( + id BIGSERIAL PRIMARY KEY, + position_code VARCHAR(30) NOT NULL UNIQUE, + position_name VARCHAR(100) NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + active BOOLEAN NOT NULL DEFAULT true +); + +-- 직급 +CREATE TABLE IF NOT EXISTS hrm_grades ( + id BIGSERIAL PRIMARY KEY, + grade_code VARCHAR(30) NOT NULL UNIQUE, + grade_name VARCHAR(100) NOT NULL, + grade_level INT NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + active BOOLEAN NOT NULL DEFAULT true +); + +-- 사원 +CREATE TABLE IF NOT EXISTS hrm_employees ( + id BIGSERIAL PRIMARY KEY, + emp_no VARCHAR(30) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + name_en VARCHAR(100), + department_id BIGINT REFERENCES hrm_departments(id), + position_id BIGINT REFERENCES hrm_positions(id), + grade_id BIGINT REFERENCES hrm_grades(id), + employment_type VARCHAR(20) NOT NULL DEFAULT 'REGULAR', -- REGULAR/CONTRACT/PARTTIME + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE/LEAVE/RETIRED + hire_date DATE NOT NULL, + retire_date DATE, + email VARCHAR(200), + phone_enc TEXT, -- AES-256-GCM 암호화 + photo_url VARCHAR(500), + gender VARCHAR(10), + birth_date DATE, + address TEXT, + bank_account TEXT, -- 암호화 저장 + created_by VARCHAR(50), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- 사원 경력 +CREATE TABLE IF NOT EXISTS hrm_emp_careers ( + id BIGSERIAL PRIMARY KEY, + emp_id BIGINT NOT NULL REFERENCES hrm_employees(id), + company_name VARCHAR(200) NOT NULL, + position VARCHAR(100), + start_date DATE NOT NULL, + end_date DATE, + description TEXT +); + +-- 사원 자격증 +CREATE TABLE IF NOT EXISTS hrm_emp_certs ( + id BIGSERIAL PRIMARY KEY, + emp_id BIGINT NOT NULL REFERENCES hrm_employees(id), + cert_name VARCHAR(200) NOT NULL, + cert_no VARCHAR(100), + issue_date DATE NOT NULL, + expire_date DATE, + issuer VARCHAR(200) +); + +-- 급여 지급 배치 +CREATE TABLE IF NOT EXISTS hrm_payroll ( + id BIGSERIAL PRIMARY KEY, + year INT NOT NULL, + month INT NOT NULL, + dept_id BIGINT REFERENCES hrm_departments(id), + total_amount NUMERIC(18,2) DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', -- DRAFT/PROCESSED/APPROVED + processed_by VARCHAR(50), + processed_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE (year, month) +); + +-- 급여항목 마스터 +CREATE TABLE IF NOT EXISTS hrm_salary_items ( + id BIGSERIAL PRIMARY KEY, + item_code VARCHAR(30) NOT NULL UNIQUE, + item_name VARCHAR(100) NOT NULL, + item_type VARCHAR(20) NOT NULL DEFAULT 'ALLOWANCE', -- ALLOWANCE/DEDUCTION + is_taxable BOOLEAN NOT NULL DEFAULT true, + sort_order INT NOT NULL DEFAULT 0, + active BOOLEAN NOT NULL DEFAULT true +); + +-- 사원 급여 기준 +CREATE TABLE IF NOT EXISTS hrm_emp_salaries ( + id BIGSERIAL PRIMARY KEY, + emp_id BIGINT NOT NULL REFERENCES hrm_employees(id), + base_salary NUMERIC(15,2) NOT NULL DEFAULT 0, + allowances NUMERIC(15,2) NOT NULL DEFAULT 0, + bonus NUMERIC(15,2) NOT NULL DEFAULT 0, + effective_date DATE NOT NULL DEFAULT CURRENT_DATE, + UNIQUE (emp_id) +); + +-- 급여 명세서 +CREATE TABLE IF NOT EXISTS hrm_payslips ( + id BIGSERIAL PRIMARY KEY, + payroll_id BIGINT REFERENCES hrm_payroll(id), + emp_id BIGINT NOT NULL REFERENCES hrm_employees(id), + year INT NOT NULL, + month INT NOT NULL, + base_salary NUMERIC(15,2) NOT NULL DEFAULT 0, + total_allowance NUMERIC(15,2) NOT NULL DEFAULT 0, + total_deduction NUMERIC(15,2) NOT NULL DEFAULT 0, + net_salary NUMERIC(15,2) NOT NULL DEFAULT 0, + income_tax NUMERIC(12,2) NOT NULL DEFAULT 0, + health_ins NUMERIC(12,2) NOT NULL DEFAULT 0, + pension NUMERIC(12,2) NOT NULL DEFAULT 0, + work_days INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE (emp_id, year, month) +); + +-- 출퇴근 +CREATE TABLE IF NOT EXISTS hrm_attendance ( + id BIGSERIAL PRIMARY KEY, + emp_id BIGINT NOT NULL REFERENCES hrm_employees(id), + work_date DATE NOT NULL, + check_in_time TIMESTAMP, + check_out_time TIMESTAMP, + work_minutes INT NOT NULL DEFAULT 0, + overtime_minutes INT NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'PRESENT', -- PRESENT/ABSENT/LEAVE/HOLIDAY + created_by VARCHAR(50), + UNIQUE (emp_id, work_date) +); + +-- 휴가 유형 +CREATE TABLE IF NOT EXISTS hrm_leave_types ( + id BIGSERIAL PRIMARY KEY, + type_code VARCHAR(30) NOT NULL UNIQUE, + type_name VARCHAR(100) NOT NULL, + is_paid BOOLEAN NOT NULL DEFAULT true, + max_days INT NOT NULL DEFAULT 0, + sort_order INT NOT NULL DEFAULT 0, + active BOOLEAN NOT NULL DEFAULT true +); + +-- 휴가 신청 +CREATE TABLE IF NOT EXISTS hrm_leaves ( + id BIGSERIAL PRIMARY KEY, + emp_id BIGINT NOT NULL REFERENCES hrm_employees(id), + leave_type_id BIGINT NOT NULL REFERENCES hrm_leave_types(id), + start_date DATE NOT NULL, + end_date DATE NOT NULL, + days NUMERIC(5,1) NOT NULL DEFAULT 1, + reason TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- PENDING/APPROVED/REJECTED/CANCELLED + approved_by VARCHAR(50), + created_by VARCHAR(50), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- 연차 부여 +CREATE TABLE IF NOT EXISTS hrm_annual_leaves ( + id BIGSERIAL PRIMARY KEY, + emp_id BIGINT NOT NULL REFERENCES hrm_employees(id), + year INT NOT NULL, + total_days NUMERIC(5,1) NOT NULL DEFAULT 15, + UNIQUE (emp_id, year) +); + +-- 초과근무 +CREATE TABLE IF NOT EXISTS hrm_overtime ( + id BIGSERIAL PRIMARY KEY, + emp_id BIGINT NOT NULL REFERENCES hrm_employees(id), + ot_date DATE NOT NULL, + start_time TIME NOT NULL, + end_time TIME NOT NULL, + ot_minutes INT NOT NULL DEFAULT 0, + reason TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'APPROVED', + created_by VARCHAR(50), + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- 성과 평가 +CREATE TABLE IF NOT EXISTS hrm_performance_reviews ( + id BIGSERIAL PRIMARY KEY, + emp_id BIGINT NOT NULL REFERENCES hrm_employees(id), + year INT NOT NULL, + period VARCHAR(20) NOT NULL DEFAULT 'ANNUAL', -- ANNUAL/H1/H2/Q1/Q2/Q3/Q4 + final_grade VARCHAR(5), -- S/A/B/C/D + score NUMERIC(5,2), + comments TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', -- DRAFT/SUBMITTED/COMPLETED + created_by VARCHAR(50), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- MBO 목표 +CREATE TABLE IF NOT EXISTS hrm_goals ( + id BIGSERIAL PRIMARY KEY, + emp_id BIGINT NOT NULL REFERENCES hrm_employees(id), + year INT NOT NULL, + goal_title VARCHAR(200) NOT NULL, + goal_desc TEXT, + weight NUMERIC(5,2) NOT NULL DEFAULT 100, + target_value TEXT, + actual_value TEXT, + achievement_rate NUMERIC(5,2), + status VARCHAR(20) NOT NULL DEFAULT 'IN_PROGRESS', + created_by VARCHAR(50), + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- 역량 마스터 +CREATE TABLE IF NOT EXISTS hrm_competencies ( + id BIGSERIAL PRIMARY KEY, + competency_code VARCHAR(30) NOT NULL UNIQUE, + competency_name VARCHAR(100) NOT NULL, + category VARCHAR(50), + description TEXT, + sort_order INT NOT NULL DEFAULT 0 +); + +-- 역량 점수 +CREATE TABLE IF NOT EXISTS hrm_competency_scores ( + id BIGSERIAL PRIMARY KEY, + emp_id BIGINT NOT NULL REFERENCES hrm_employees(id), + competency_id BIGINT NOT NULL REFERENCES hrm_competencies(id), + year INT NOT NULL, + self_score NUMERIC(3,1), + manager_score NUMERIC(3,1), + peer_score NUMERIC(3,1), + final_score NUMERIC(3,1), + evaluator VARCHAR(50), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE (emp_id, competency_id, year) +); + +-- 채용 공고 +CREATE TABLE IF NOT EXISTS hrm_job_postings ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(200) NOT NULL, + department_id BIGINT REFERENCES hrm_departments(id), + employment_type VARCHAR(20) NOT NULL DEFAULT 'REGULAR', + headcount INT NOT NULL DEFAULT 1, + description TEXT, + requirements TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', -- DRAFT/PUBLISHED/CLOSED + start_date DATE, + end_date DATE, + created_by VARCHAR(50), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- 지원자 +CREATE TABLE IF NOT EXISTS hrm_applicants ( + id BIGSERIAL PRIMARY KEY, + posting_id BIGINT NOT NULL REFERENCES hrm_job_postings(id), + applicant_name VARCHAR(100) NOT NULL, + email VARCHAR(200), + phone VARCHAR(30), + resume_url VARCHAR(500), + cover_letter TEXT, + status VARCHAR(30) NOT NULL DEFAULT 'APPLIED', -- APPLIED/REVIEWED/INTERVIEW/OFFER/HIRED/REJECTED + apply_date TIMESTAMP NOT NULL DEFAULT NOW(), + memo TEXT, + created_by VARCHAR(50), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- 면접 +CREATE TABLE IF NOT EXISTS hrm_interviews ( + id BIGSERIAL PRIMARY KEY, + posting_id BIGINT NOT NULL REFERENCES hrm_job_postings(id), + applicant_id BIGINT NOT NULL REFERENCES hrm_applicants(id), + interview_type VARCHAR(50) NOT NULL DEFAULT 'TECHNICAL', -- DOCUMENT/TECHNICAL/HR/FINAL + scheduled_at TIMESTAMP NOT NULL, + location VARCHAR(200), + interviewers TEXT, + result VARCHAR(20), -- PASS/FAIL/PENDING + notes TEXT, + created_by VARCHAR(50), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- 교육 과정 +CREATE TABLE IF NOT EXISTS hrm_training_courses ( + id BIGSERIAL PRIMARY KEY, + course_code VARCHAR(50) NOT NULL UNIQUE, + course_name VARCHAR(200) NOT NULL, + course_type VARCHAR(50) NOT NULL DEFAULT 'INTERNAL', -- INTERNAL/EXTERNAL/ONLINE + instructor VARCHAR(100), + description TEXT, + start_date DATE, + end_date DATE, + duration_hours NUMERIC(5,1) NOT NULL DEFAULT 0, + is_legal BOOLEAN NOT NULL DEFAULT false, + max_attendees INT NOT NULL DEFAULT 50, + status VARCHAR(20) NOT NULL DEFAULT 'SCHEDULED', + created_by VARCHAR(50), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- 교육 수강 +CREATE TABLE IF NOT EXISTS hrm_training_enrollments ( + id BIGSERIAL PRIMARY KEY, + emp_id BIGINT NOT NULL REFERENCES hrm_employees(id), + course_id BIGINT NOT NULL REFERENCES hrm_training_courses(id), + status VARCHAR(20) NOT NULL DEFAULT 'ENROLLED', -- ENROLLED/COMPLETED/CANCELLED + enrolled_at TIMESTAMP NOT NULL DEFAULT NOW(), + completed_at DATE, + created_by VARCHAR(50), + UNIQUE (emp_id, course_id) +); + +-- 감사 로그 +CREATE TABLE IF NOT EXISTS hrm_audit_log ( + id BIGSERIAL PRIMARY KEY, + actor VARCHAR(100) NOT NULL, + action VARCHAR(100) NOT NULL, + target_type VARCHAR(50), + target_id VARCHAR(100), + detail TEXT, + ip_addr VARCHAR(50), + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- 시스템 설정 +CREATE TABLE IF NOT EXISTS hrm_settings ( + key VARCHAR(100) PRIMARY KEY, + value TEXT, + description VARCHAR(500), + updated_by VARCHAR(50), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- 인덱스 +CREATE INDEX IF NOT EXISTS idx_hrm_emp_dept ON hrm_employees(department_id); +CREATE INDEX IF NOT EXISTS idx_hrm_emp_status ON hrm_employees(status); +CREATE INDEX IF NOT EXISTS idx_hrm_attendance_date ON hrm_attendance(work_date); +CREATE INDEX IF NOT EXISTS idx_hrm_leaves_emp ON hrm_leaves(emp_id); +CREATE INDEX IF NOT EXISTS idx_hrm_leaves_status ON hrm_leaves(status); +CREATE INDEX IF NOT EXISTS idx_hrm_payslips_emp ON hrm_payslips(emp_id, year, month); +CREATE INDEX IF NOT EXISTS idx_hrm_perf_emp ON hrm_performance_reviews(emp_id, year); +CREATE INDEX IF NOT EXISTS idx_hrm_goals_emp ON hrm_goals(emp_id, year); +CREATE INDEX IF NOT EXISTS idx_hrm_audit_actor ON hrm_audit_log(actor); +CREATE INDEX IF NOT EXISTS idx_hrm_audit_created ON hrm_audit_log(created_at DESC); + +-- ============================================================ +-- 시드 데이터 +-- ============================================================ + +-- admin 사용자 (password: admin123) +INSERT INTO hrm_users (username, password_hash, display_name, email, role, active) +VALUES ('admin', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', 'HRM 관리자', 'admin@zioinfo.co.kr', 'SUPERADMIN', true) +ON CONFLICT (username) DO NOTHING; + +INSERT INTO hrm_users (username, password_hash, display_name, email, role, active) +VALUES ('hr_manager', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', '인사팀장', 'hrmanager@zioinfo.co.kr', 'MANAGER', true) +ON CONFLICT (username) DO NOTHING; + +-- 기본 부서 +INSERT INTO hrm_departments (dept_code, dept_name, sort_order) VALUES +('MGMT', '경영지원부', 1), +('DEV', '개발부', 2), +('OPS', '운영부', 3), +('SALES', '영업부', 4), +('ADMIN', '관리부', 5) +ON CONFLICT (dept_code) DO NOTHING; + +-- 직책 +INSERT INTO hrm_positions (position_code, position_name, sort_order) VALUES +('CEO', '대표이사', 1), +('DIR', '이사', 2), +('MGR', '부장', 3), +('TEAM_LEAD', '팀장', 4), +('SENIOR', '선임', 5), +('STAFF', '사원', 6) +ON CONFLICT (position_code) DO NOTHING; + +-- 직급 +INSERT INTO hrm_grades (grade_code, grade_name, grade_level, sort_order) VALUES +('G1', '1급', 1, 1), +('G2', '2급', 2, 2), +('G3', '3급', 3, 3), +('G4', '4급', 4, 4), +('G5', '5급', 5, 5), +('G6', '6급', 6, 6) +ON CONFLICT (grade_code) DO NOTHING; + +-- 휴가 유형 +INSERT INTO hrm_leave_types (type_code, type_name, is_paid, max_days, sort_order) VALUES +('ANNUAL', '연차', true, 15, 1), +('HALF', '반차', true, 30, 2), +('SICK', '병가', false, 60, 3), +('MATERNITY', '출산휴가', true, 90, 4), +('PARENTAL', '육아휴직', false, 365, 5), +('SPECIAL', '특별휴가', true, 5, 6) +ON CONFLICT (type_code) DO NOTHING; + +-- 급여 항목 +INSERT INTO hrm_salary_items (item_code, item_name, item_type, is_taxable, sort_order) VALUES +('BASE', '기본급', 'ALLOWANCE', true, 1), +('MEAL', '식대', 'ALLOWANCE', false, 2), +('TRANSPORT', '교통비', 'ALLOWANCE', false, 3), +('OVERTIME', '초과근무수당', 'ALLOWANCE', true, 4), +('INCOME_TAX', '소득세', 'DEDUCTION', false, 10), +('RESIDENT_TAX', '지방소득세', 'DEDUCTION', false, 11), +('HEALTH_INS', '건강보험', 'DEDUCTION', false, 12), +('PENSION', '국민연금', 'DEDUCTION', false, 13), +('EMPLOY_INS', '고용보험', 'DEDUCTION', false, 14) +ON CONFLICT (item_code) DO NOTHING; + +-- 역량 마스터 +INSERT INTO hrm_competencies (competency_code, competency_name, category, sort_order) VALUES +('LEADERSHIP', '리더십', '공통역량', 1), +('COMM', '커뮤니케이션', '공통역량', 2), +('PROBLEM', '문제해결력', '공통역량', 3), +('CUSTOMER', '고객지향성', '공통역량', 4), +('TECH', '전문기술', '직무역량', 5), +('INNOVATION', '혁신능력', '직무역량', 6) +ON CONFLICT (competency_code) DO NOTHING; + +-- 법정 교육 +INSERT INTO hrm_training_courses (course_code, course_name, course_type, duration_hours, is_legal, max_attendees) VALUES +('LEGAL_SEXUAL', '직장 내 성희롱 예방교육', 'ONLINE', 1.0, true, 999), +('LEGAL_DISABLED', '장애인 인식개선 교육', 'ONLINE', 1.0, true, 999), +('LEGAL_SAFETY', '산업안전보건교육', 'INTERNAL', 2.0, true, 999), +('LEGAL_PRIVACY', '개인정보보호 교육', 'ONLINE', 1.0, true, 999) +ON CONFLICT (course_code) DO NOTHING; + +-- 샘플 사원 5명 +INSERT INTO hrm_employees (emp_no, name, department_id, position_id, grade_id, employment_type, status, hire_date, email, created_by) +SELECT 'EMP-2024-0001', '홍길동', + (SELECT id FROM hrm_departments WHERE dept_code='DEV'), + (SELECT id FROM hrm_positions WHERE position_code='MGR'), + (SELECT id FROM hrm_grades WHERE grade_code='G3'), + 'REGULAR', 'ACTIVE', '2020-03-02', 'hong@zioinfo.co.kr', 'admin' +WHERE NOT EXISTS (SELECT 1 FROM hrm_employees WHERE emp_no='EMP-2024-0001'); + +INSERT INTO hrm_employees (emp_no, name, department_id, position_id, grade_id, employment_type, status, hire_date, email, created_by) +SELECT 'EMP-2024-0002', '김철수', + (SELECT id FROM hrm_departments WHERE dept_code='OPS'), + (SELECT id FROM hrm_positions WHERE position_code='SENIOR'), + (SELECT id FROM hrm_grades WHERE grade_code='G4'), + 'REGULAR', 'ACTIVE', '2021-07-01', 'kim@zioinfo.co.kr', 'admin' +WHERE NOT EXISTS (SELECT 1 FROM hrm_employees WHERE emp_no='EMP-2024-0002'); + +INSERT INTO hrm_employees (emp_no, name, department_id, position_id, grade_id, employment_type, status, hire_date, email, created_by) +SELECT 'EMP-2024-0003', '이영희', + (SELECT id FROM hrm_departments WHERE dept_code='SALES'), + (SELECT id FROM hrm_positions WHERE position_code='TEAM_LEAD'), + (SELECT id FROM hrm_grades WHERE grade_code='G3'), + 'REGULAR', 'ACTIVE', '2019-01-15', 'lee@zioinfo.co.kr', 'admin' +WHERE NOT EXISTS (SELECT 1 FROM hrm_employees WHERE emp_no='EMP-2024-0003'); + +INSERT INTO hrm_employees (emp_no, name, department_id, position_id, grade_id, employment_type, status, hire_date, email, created_by) +SELECT 'EMP-2024-0004', '박민수', + (SELECT id FROM hrm_departments WHERE dept_code='DEV'), + (SELECT id FROM hrm_positions WHERE position_code='STAFF'), + (SELECT id FROM hrm_grades WHERE grade_code='G5'), + 'REGULAR', 'ACTIVE', '2023-03-02', 'park@zioinfo.co.kr', 'admin' +WHERE NOT EXISTS (SELECT 1 FROM hrm_employees WHERE emp_no='EMP-2024-0004'); + +INSERT INTO hrm_employees (emp_no, name, department_id, position_id, grade_id, employment_type, status, hire_date, email, created_by) +SELECT 'EMP-2024-0005', '최지현', + (SELECT id FROM hrm_departments WHERE dept_code='ADMIN'), + (SELECT id FROM hrm_positions WHERE position_code='SENIOR'), + (SELECT id FROM hrm_grades WHERE grade_code='G4'), + 'REGULAR', 'ACTIVE', '2022-05-09', 'choi@zioinfo.co.kr', 'admin' +WHERE NOT EXISTS (SELECT 1 FROM hrm_employees WHERE emp_no='EMP-2024-0005'); + +-- 기본 설정 +INSERT INTO hrm_settings (key, value, description) VALUES +('company_name', '지오정보기술(주)', '회사명'), +('fiscal_year_start', '01', '회계연도 시작월'), +('annual_leave_base', '15', '기본 연차 일수'), +('payroll_day', '25', '급여 지급일') +ON CONFLICT (key) DO NOTHING; diff --git a/backend/target/classes/mapper/AdminMapper.xml b/backend/target/classes/mapper/AdminMapper.xml new file mode 100644 index 0000000..1459b2c --- /dev/null +++ b/backend/target/classes/mapper/AdminMapper.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + INSERT INTO hrm_users (username, password_hash, display_name, email, role, active) + VALUES (#{username}, #{passwordHash}, #{displayName}, #{email}, #{role}, #{active}) + + + + UPDATE hrm_users SET + display_name=#{displayName}, email=#{email}, role=#{role} + , password_hash=#{passwordHash} + WHERE id=#{id} + + + + UPDATE hrm_users SET active=#{active} WHERE id=#{id} + + + + + + + + INSERT INTO hrm_audit_log (actor, action, target_type, target_id, detail, ip_addr) + VALUES (#{actor}, #{action}, #{targetType}, #{targetId}, #{detail}, #{ipAddr}) + + + + + + + + INSERT INTO hrm_settings (key, value, updated_by) + VALUES (#{key}, #{value}, #{updatedBy}) + ON CONFLICT (key) DO UPDATE SET value=#{value}, updated_by=#{updatedBy}, updated_at=NOW() + + + diff --git a/backend/target/classes/mapper/AttendanceMapper.xml b/backend/target/classes/mapper/AttendanceMapper.xml new file mode 100644 index 0000000..e3498d7 --- /dev/null +++ b/backend/target/classes/mapper/AttendanceMapper.xml @@ -0,0 +1,99 @@ + + + + + + + + + + INSERT INTO hrm_attendance (emp_id, work_date, check_in_time, status) + VALUES (#{empId}, #{workDate}, #{checkInTime}, 'PRESENT') + ON CONFLICT (emp_id, work_date) DO UPDATE SET check_in_time=#{checkInTime} + + + + UPDATE hrm_attendance SET + check_out_time=#{checkOutTime}, + work_minutes=EXTRACT(EPOCH FROM (#{checkOutTime}::timestamp - check_in_time))/60 + WHERE emp_id=#{empId} AND work_date=#{workDate} + + + + + + INSERT INTO hrm_leaves (emp_id, leave_type_id, start_date, end_date, days, reason, status, created_by) + VALUES (#{empId}, #{leaveTypeId}, #{startDate}, #{endDate}, #{days}, #{reason}, #{status}, #{createdBy}) + + + + UPDATE hrm_leaves SET status=#{status}, approved_by=#{approvedBy}, updated_at=NOW() + WHERE id=#{id} + + + + + + + + INSERT INTO hrm_leave_types (type_code, type_name, is_paid, max_days) + VALUES (#{typeCode}, #{typeName}, #{isPaid}, #{maxDays}) + + + + + + INSERT INTO hrm_overtime (emp_id, ot_date, start_time, end_time, ot_minutes, reason, created_by) + VALUES (#{empId}, #{otDate}, #{startTime}, #{endTime}, #{otMinutes}, #{reason}, #{createdBy}) + + + diff --git a/backend/target/classes/mapper/DashboardMapper.xml b/backend/target/classes/mapper/DashboardMapper.xml new file mode 100644 index 0000000..b478d1e --- /dev/null +++ b/backend/target/classes/mapper/DashboardMapper.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + diff --git a/backend/target/classes/mapper/EmployeeMapper.xml b/backend/target/classes/mapper/EmployeeMapper.xml new file mode 100644 index 0000000..4d8e95f --- /dev/null +++ b/backend/target/classes/mapper/EmployeeMapper.xml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO hrm_employees + (emp_no, name, name_en, department_id, position_id, grade_id, + employment_type, status, hire_date, email, phone_enc, photo_url, gender, birth_date, address, bank_account, created_by) + VALUES + (#{empNo}, #{name}, #{nameEn}, #{departmentId}, #{positionId}, #{gradeId}, + #{employmentType}, #{status}, #{hireDate}, #{email}, #{phoneEnc}, #{photoUrl}, #{gender}, #{birthDate}, #{address}, #{bankAccount}, #{createdBy}) + + + + UPDATE hrm_employees SET + name=#{name}, name_en=#{nameEn}, department_id=#{departmentId}, + position_id=#{positionId}, grade_id=#{gradeId}, + employment_type=#{employmentType}, email=#{email}, + phone_enc=#{phoneEnc}, photo_url=#{photoUrl}, gender=#{gender}, + birth_date=#{birthDate}, address=#{address}, updated_at=NOW() + WHERE id=#{id} + + + + UPDATE hrm_employees SET status=#{status}, retire_date=#{retireDate}, updated_at=NOW() + WHERE id=#{id} + + + + + + INSERT INTO hrm_emp_careers (emp_id, company_name, position, start_date, end_date, description) + VALUES (#{empId}, #{companyName}, #{position}, #{startDate}, #{endDate}, #{description}) + + + DELETE FROM hrm_emp_careers WHERE id=#{id} + + + + + INSERT INTO hrm_emp_certs (emp_id, cert_name, cert_no, issue_date, expire_date, issuer) + VALUES (#{empId}, #{certName}, #{certNo}, #{issueDate}, #{expireDate}, #{issuer}) + + + DELETE FROM hrm_emp_certs WHERE id=#{id} + + diff --git a/backend/target/classes/mapper/OrgMapper.xml b/backend/target/classes/mapper/OrgMapper.xml new file mode 100644 index 0000000..4a813bf --- /dev/null +++ b/backend/target/classes/mapper/OrgMapper.xml @@ -0,0 +1,54 @@ + + + + + + + + INSERT INTO hrm_departments (dept_code, dept_name, parent_id, manager_emp_id, sort_order) + VALUES (#{deptCode}, #{deptName}, #{parentId}, #{managerEmpId}, #{sortOrder}) + + + + UPDATE hrm_departments SET + dept_name=#{deptName}, parent_id=#{parentId}, + manager_emp_id=#{managerEmpId}, sort_order=#{sortOrder} + WHERE id=#{id} + + + + UPDATE hrm_departments SET active=false WHERE id=#{id} + + + + + + INSERT INTO hrm_positions (position_code, position_name, sort_order) + VALUES (#{positionCode}, #{positionName}, #{sortOrder}) + + + + + + INSERT INTO hrm_grades (grade_code, grade_name, grade_level, sort_order) + VALUES (#{gradeCode}, #{gradeName}, #{gradeLevel}, #{sortOrder}) + + + diff --git a/backend/target/classes/mapper/PayrollMapper.xml b/backend/target/classes/mapper/PayrollMapper.xml new file mode 100644 index 0000000..307560e --- /dev/null +++ b/backend/target/classes/mapper/PayrollMapper.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + INSERT INTO hrm_payroll (year, month, status, processed_by, processed_at) + VALUES (#{year}, #{month}, #{status}, #{processedBy}, #{processedAt}) + ON CONFLICT (year, month) DO UPDATE SET status=#{status}, processed_at=NOW() + + + + UPDATE hrm_payroll SET status=#{status} WHERE id=#{id} + + + + + + + + INSERT INTO hrm_emp_salaries (emp_id, base_salary, allowances, bonus, effective_date) + VALUES (#{empId}, #{baseSalary}, #{allowances}, #{bonus}, #{effectiveDate}) + ON CONFLICT (emp_id) DO UPDATE SET + base_salary=#{baseSalary}, allowances=#{allowances}, + bonus=#{bonus}, effective_date=#{effectiveDate} + + + + + diff --git a/backend/target/classes/mapper/PerformanceMapper.xml b/backend/target/classes/mapper/PerformanceMapper.xml new file mode 100644 index 0000000..e16f764 --- /dev/null +++ b/backend/target/classes/mapper/PerformanceMapper.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + INSERT INTO hrm_performance_reviews (emp_id, year, period, status, created_by) + VALUES (#{empId}, #{year}, #{period}, #{status}, #{createdBy}) + + + + UPDATE hrm_performance_reviews SET + final_grade=#{finalGrade}, score=#{score}, comments=#{comments}, updated_at=NOW() + WHERE id=#{id} + + + + UPDATE hrm_performance_reviews SET status=#{status}, updated_at=NOW() WHERE id=#{id} + + + + + + INSERT INTO hrm_goals (emp_id, year, goal_title, goal_desc, weight, target_value, created_by) + VALUES (#{empId}, #{year}, #{goalTitle}, #{goalDesc}, #{weight}, #{targetValue}, #{createdBy}) + + + + UPDATE hrm_goals SET + goal_title=#{goalTitle}, weight=#{weight}, target_value=#{targetValue}, + actual_value=#{actualValue}, achievement_rate=#{achievementRate}, status=#{status} + WHERE id=#{id} + + + + + + INSERT INTO hrm_competency_scores (emp_id, competency_id, year, self_score, manager_score, evaluator) + VALUES (#{empId}, #{competencyId}, #{year}, #{selfScore}, #{managerScore}, #{evaluator}) + ON CONFLICT (emp_id, competency_id, year) DO UPDATE SET + manager_score=#{managerScore}, evaluator=#{evaluator}, updated_at=NOW() + + + diff --git a/backend/target/classes/mapper/RecruitmentMapper.xml b/backend/target/classes/mapper/RecruitmentMapper.xml new file mode 100644 index 0000000..5051652 --- /dev/null +++ b/backend/target/classes/mapper/RecruitmentMapper.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + INSERT INTO hrm_job_postings (title, department_id, employment_type, headcount, description, requirements, status, start_date, end_date, created_by) + VALUES (#{title}, #{departmentId}, #{employmentType}, #{headcount}, #{description}, #{requirements}, #{status}, #{startDate}, #{endDate}, #{createdBy}) + + + + UPDATE hrm_job_postings SET + title=#{title}, department_id=#{departmentId}, employment_type=#{employmentType}, + headcount=#{headcount}, description=#{description}, requirements=#{requirements}, + start_date=#{startDate}, end_date=#{endDate}, updated_at=NOW() + WHERE id=#{id} + + + + UPDATE hrm_job_postings SET status=#{status}, updated_at=NOW() WHERE id=#{id} + + + + + + + + INSERT INTO hrm_applicants (posting_id, applicant_name, email, phone, resume_url, cover_letter, status, apply_date, created_by) + VALUES (#{postingId}, #{applicantName}, #{email}, #{phone}, #{resumeUrl}, #{coverLetter}, #{status}, NOW(), #{createdBy}) + + + + UPDATE hrm_applicants SET status=#{status}, memo=#{memo}, updated_at=NOW() WHERE id=#{id} + + + + + + INSERT INTO hrm_interviews (posting_id, applicant_id, interview_type, scheduled_at, location, interviewers, created_by) + VALUES (#{postingId}, #{applicantId}, #{interviewType}, #{scheduledAt}, #{location}, #{interviewers}, #{createdBy}) + + + + UPDATE hrm_interviews SET + interview_type=#{interviewType}, scheduled_at=#{scheduledAt}, + location=#{location}, result=#{result}, notes=#{notes}, updated_at=NOW() + WHERE id=#{id} + + + diff --git a/backend/target/classes/mapper/TrainingMapper.xml b/backend/target/classes/mapper/TrainingMapper.xml new file mode 100644 index 0000000..693aa13 --- /dev/null +++ b/backend/target/classes/mapper/TrainingMapper.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + INSERT INTO hrm_training_courses (course_code, course_name, course_type, instructor, description, + start_date, end_date, duration_hours, is_legal, max_attendees, status, created_by) + VALUES (#{courseCode}, #{courseName}, #{courseType}, #{instructor}, #{description}, + #{startDate}, #{endDate}, #{durationHours}, #{isLegal}, #{maxAttendees}, 'SCHEDULED', #{createdBy}) + + + + UPDATE hrm_training_courses SET + course_name=#{courseName}, instructor=#{instructor}, + start_date=#{startDate}, end_date=#{endDate}, + duration_hours=#{durationHours}, max_attendees=#{maxAttendees}, updated_at=NOW() + WHERE id=#{id} + + + + + + INSERT INTO hrm_training_enrollments (emp_id, course_id, status, enrolled_at, created_by) + VALUES (#{empId}, #{courseId}, #{status}, NOW(), #{createdBy}) + ON CONFLICT (emp_id, course_id) DO NOTHING + + + + UPDATE hrm_training_enrollments SET status=#{status}, completed_at=#{completedAt} + WHERE id=#{id} + + + + + + + diff --git a/backend/target/classes/mapper/UserMapper.xml b/backend/target/classes/mapper/UserMapper.xml new file mode 100644 index 0000000..88b9afa --- /dev/null +++ b/backend/target/classes/mapper/UserMapper.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + INSERT INTO hrm_users (username, password_hash, display_name, email, role, active) + VALUES (#{username}, #{passwordHash}, #{displayName}, #{email}, #{role}, #{active}) + + + diff --git a/frontend/index.html b/frontend/index.html index 76aa26c..aa320b4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + GUARDiA HRM — AI 인사관리 플랫폼 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9e98f89..ca15a15 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,21 +10,7 @@ import PerformancePage from './pages/PerformancePage' import RecruitmentPage from './pages/RecruitmentPage' import TrainingPage from './pages/TrainingPage' import AiPage from './pages/AiPage' -import WiseAiPage from './pages/WiseAiPage' -import AiPlatformSettings from './pages/AiPlatformSettings' import AdminPage from './pages/AdminPage' -import MyPage from './pages/MyPage' -import MobileApp from './pages/MobileApp' -// UIWS(UIMS) 이식 — 공통 업무협업 화면(업무일지·일정·쪽지·통계) -import WorklogList from './pages/uiws/WorklogList' -import ScheduleCalendar from './pages/uiws/ScheduleCalendar' -import MessageBox from './pages/uiws/MessageBox' -import StatsPivot from './pages/uiws/StatsPivot' -// UIWS system(권한관리) 이식 — 시스템관리(권한) 화면 셸(탭: 권한/역할-메뉴/공통코드/메뉴/부서/거래처) -import SystemPage from './pages/uiws/system/SystemPage' - -// 시스템관리(권한)는 관리자 전용. 백엔드 /api/system/** 조회=SUPERADMIN/MANAGER, 쓰기=SUPERADMIN. -const ADMIN_ROLES = ['SUPERADMIN', 'MANAGER', 'ADMIN'] const MENU = [ { path: '/dashboard', label: '대시보드', icon: '📊' }, @@ -35,25 +21,13 @@ const MENU = [ { path: '/performance', label: '성과평가', icon: '🎯' }, { path: '/recruitment', label: '채용관리', icon: '🔍' }, { path: '/training', label: '교육관리', icon: '📚' }, - // UIWS 이식 — 업무 협업 - { path: '/worklogs', label: '업무일지', icon: '📝' }, - { path: '/schedules', label: '일정관리', icon: '📅' }, - { path: '/messages', label: '쪽지함', icon: '✉️' }, - { path: '/work-stats', label: '업무통계', icon: '📈' }, { path: '/ai', label: 'AI 인사분석', icon: '🤖' }, - { path: '/wise-ai', label: 'WISE AI', icon: '✨' }, - { path: '/ai-platform', label: 'AI 플랫폼 설정', icon: '🧠', adminOnly: true }, { path: '/admin', label: '시스템관리', icon: '⚙️' }, - // UIWS system 이식 — 권한·코드·메뉴·부서·거래처 (관리자 전용) - { path: '/system', label: '시스템관리(권한)', icon: '🔐', adminOnly: true }, - { path: '/mobile-app', label: '모바일 앱', icon: '📱' }, ] function Layout({ children }: { children: React.ReactNode }) { const [collapsed, setCollapsed] = useState(false) const user = JSON.parse(localStorage.getItem('hrm_user') || '{"displayName":"관리자","role":"SUPERADMIN"}') - const isAdmin = ADMIN_ROLES.includes(user.role) - const visibleMenu = MENU.filter(m => !m.adminOnly || isAdmin) return (
@@ -71,7 +45,7 @@ function Layout({ children }: { children: React.ReactNode }) {