feat(ux): 날짜 입력 전면 캘린더 전환 — 웹 datetime-local/date/time + 모바일 순수JS CalendarPicker [auto-sync]

This commit is contained in:
GUARDiA AutoDeploy 2026-07-05 08:23:08 +09:00 committed by DESKTOP-TKLFCPR\ython
parent e299068832
commit 4e9436edbe
77 changed files with 1479 additions and 924 deletions

View File

@ -42,17 +42,11 @@
<!-- DB Driver --> <!-- DB Driver -->
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><version>${postgresql.version}</version></dependency> <dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><version>${postgresql.version}</version></dependency>
<!-- DuckDB — 로컬 임베디드 AI 학습 저장소(ai_feedback·ai_infer_log). 파일: /opt/guardia-hrm/data/hrm_learning.duckdb -->
<dependency><groupId>org.duckdb</groupId><artifactId>duckdb_jdbc</artifactId><version>1.1.3</version></dependency>
<!-- JWT --> <!-- JWT -->
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>${jjwt.version}</version></dependency> <dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>${jjwt.version}</version></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency> <dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency> <dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
<!-- TOTP 2FA (RFC 6238 · SHA1 · 30s · 6자리) — UIWS(uiws build.gradle) 동일 좌표. QR 생성 위해 zxing 전이 포함. -->
<dependency><groupId>dev.samstevens.totp</groupId><artifactId>totp</artifactId><version>1.7.1</version></dependency>
<!-- OpenAPI --> <!-- OpenAPI -->
<dependency> <dependency>
<groupId>org.springdoc</groupId> <groupId>org.springdoc</groupId>

View File

@ -51,31 +51,6 @@ public class AdminController {
return ApiResponse.ok(null); return ApiResponse.ok(null);
} }
/**
* 관리자 OTP 초기화(SUPERADMIN). 대상 사용자의 OTP 시크릿을 폐기하고 등록을 해제한다.
* 사용자는 다음 로그인 OTP_SETUP(QR 재등록) 플로우를 탄다. 시크릿은 응답/로그에 노출하지 않는다.
*/
@PostMapping("/users/{id}/otp-reset")
public ApiResponse<HrmUser> resetOtp(@PathVariable Long id, Authentication auth) {
HrmUser user = adminMapper.findUserById(id);
if (user == null) {
throw new RuntimeException("ERR-HRM-404: 대상 사용자를 찾을 수 없습니다");
}
adminMapper.clearOtp(id);
Map<String, Object> 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") @GetMapping("/audit")
public ApiResponse<Map<String, Object>> audit( public ApiResponse<Map<String, Object>> audit(
@RequestParam(required = false) String actor, @RequestParam(required = false) String actor,

View File

@ -14,8 +14,6 @@ public interface AdminMapper {
int insertUser(HrmUser user); int insertUser(HrmUser user);
int updateUser(HrmUser user); int updateUser(HrmUser user);
int updateUserActive(@Param("id") Long id, @Param("active") boolean active); int updateUserActive(@Param("id") Long id, @Param("active") boolean active);
/** 관리자 OTP 초기화: 시크릿 폐기 + otp_enabled=false(다음 로그인 시 재등록 유도). 시크릿 미노출. */
int clearOtp(@Param("id") Long id);
List<Map<String, Object>> findAuditLogs(@Param("actor") String actor, List<Map<String, Object>> findAuditLogs(@Param("actor") String actor,
@Param("action") String action, @Param("action") String action,
@Param("offset") int offset, @Param("offset") int offset,

View File

@ -1,27 +1,37 @@
package com.zioinfo.hrm.ai; 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.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.Duration;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* AI 인사 분석 서비스. 텍스트 생성은 {@link AiTextRouter} 경유(provider 선택: Claude Ollama). * Ollama 온프레미스 AI 인사 분석 서비스.
* 외부 AI API 소유자 승인 예외인 Claude(api.anthropic.com) 허용, 온프레미스 Ollama . * 외부 AI API 절대 금지 localhost:11434 .
* Claude/Ollama 응답 실패 Java 폴백 로직으로 대체(무회귀). * Ollama 응답 실패 Java 폴백 로직으로 대체.
*/ */
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class AiService { public class AiService {
/** provider 라우터(Claude→Ollama 폴백 + 추론 로그). 기존 Ollama 직접호출을 대체. */ @Value("${guardia.ollama-url:http://localhost:11434}")
private final AiTextRouter aiTextRouter; 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<String, Object> predictTurnover(Long empId) { public Map<String, Object> predictTurnover(Long empId) {
try { try {
@ -107,12 +117,16 @@ public class AiService {
} }
private Map<String, Object> callOllama(String prompt) { private Map<String, Object> callOllama(String prompt) {
// provider 라우터 경유: provider=claude·키설정·활성 Claude, 실패/그외 Ollama(선택모델) 폴백. Map<String, Object> body = Map.of("model", textModel, "prompt", prompt, "stream", false);
GenResult gr = aiTextRouter.generate(prompt); @SuppressWarnings("unchecked")
if (gr.degraded() || gr.text() == null || gr.text().isBlank()) { Map<String, Object> resp = ollamaClient().post().uri("/api/generate")
return fallbackTurnover(); .bodyValue(body)
} .retrieve()
String response = gr.text(); .bodyToMono(Map.class)
.timeout(Duration.ofSeconds(30))
.block();
if (resp == null) return fallbackTurnover();
String response = (String) resp.getOrDefault("response", "{}");
// JSON 추출 // JSON 추출
int start = response.indexOf('{'); int start = response.indexOf('{');
int end = response.lastIndexOf('}'); int end = response.lastIndexOf('}');
@ -120,10 +134,10 @@ public class AiService {
// 간단 파싱: 실제로는 Jackson 사용 // 간단 파싱: 실제로는 Jackson 사용
Map<String, Object> r = new HashMap<>(); Map<String, Object> r = new HashMap<>();
r.put("raw", response.substring(start, end + 1)); r.put("raw", response.substring(start, end + 1));
r.put("source", "ai"); r.put("source", "ollama");
return r; return r;
} }
return Map.of("response", response, "source", "ai"); return Map.of("response", response, "source", "ollama");
} }
private Map<String, Object> fallbackTurnover() { private Map<String, Object> fallbackTurnover() {

View File

@ -1,54 +1,22 @@
package com.zioinfo.hrm.auth; 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.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 lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.Map; 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 @RestController
@RequestMapping("/api/hrm/auth") @RequestMapping("/api/hrm/auth")
@RequiredArgsConstructor @RequiredArgsConstructor
public class AuthController { public class AuthController {
private final AuthService authService; private final AuthService authService;
private final TwoFactorService twoFactorService;
private final OtpAuthService otpAuthService;
private final JwtUtil jwtUtil;
@PostMapping("/login") @PostMapping("/login")
public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) { public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) {
return ApiResponse.ok(authService.login(req.username(), req.password())); String token = authService.login(req.username(), req.password());
} return ApiResponse.ok(Map.of("token", token, "type", "Bearer"));
/** 이메일 2FA 이식: 2차 인증 코드 검증 → access/refresh 발급. */
@PostMapping("/verify")
public ApiResponse<Map<String, String>> verify(@RequestBody VerifyRequest req) {
return ApiResponse.ok(twoFactorService.verify(req.verifyToken(), req.code()));
}
/** TOTP 이식: 로그인 2단계 6자리 검증 → access/refresh 발급(최초 로그인이면 등록 확정). */
@PostMapping("/verify-otp")
public ApiResponse<Map<String, String>> verifyOtp(@Valid @RequestBody OtpVerifyRequest req) {
return ApiResponse.ok(otpAuthService.verifyOtp(req.verifyToken(), req.code()));
} }
@GetMapping("/me") @GetMapping("/me")
@ -57,50 +25,5 @@ public class AuthController {
return ApiResponse.ok(authService.me(token)); return ApiResponse.ok(authService.me(token));
} }
// 마이페이지: OTP 등록/재설정/해제 + 비밀번호 변경 (본인, access 토큰 필요)
/** 마이페이지 OTP 등록/재설정 시작 → { secret, otpAuthUri, qrImage }(이 응답에서만 시크릿/QR 노출). */
@PostMapping("/otp/setup")
public ApiResponse<OtpSetupResponse> otpSetup(@RequestHeader("Authorization") String header) {
return ApiResponse.ok(otpAuthService.setup(requireUser(header)));
}
/** 마이페이지 OTP 확인·활성화(앱 6자리 코드). */
@PostMapping("/otp/confirm")
public ApiResponse<Map<String, String>> 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<Map<String, String>> otpDisable(@RequestHeader("Authorization") String header) {
otpAuthService.disable(requireUser(header));
return ApiResponse.ok(Map.of("result", "ok"));
}
/** 마이페이지 비밀번호 변경(현재 비번 검증 + BCrypt). 새 비번은 응답/로그 미포함. */
@PostMapping("/change-password")
public ApiResponse<Map<String, String>> 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 LoginRequest(String username, String password) {}
record VerifyRequest(String verifyToken, String code) {}
} }

View File

@ -1,35 +1,13 @@
package com.zioinfo.hrm.auth; 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.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.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
/**
* HRM 인증 서비스.
* - 기존 단일 JWT 로그인 보존(2FA off 또는 미설정 회귀 0).
* - UIWS 2FA 레이어(이메일 코드) 추가: 2FA on 이면 1차 통과 verify-token + 이메일코드 발급.
* 실패 누적 max-login-fail 계정 잠금.
* - UIWS 로그인 보조 3종(회원가입·아이디찾기·비밀번호 초기화) 이식.
*/
@Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class AuthService { public class AuthService {
@ -37,66 +15,16 @@ public class AuthService {
private final UserMapper userMapper; private final UserMapper userMapper;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil; private final JwtUtil jwtUtil;
private final TwoFactorService twoFactorService;
private final OtpAuthService otpAuthService;
private final MailSender mailSender;
private static final SecureRandom RANDOM = new SecureRandom(); public String login(String username, String password) {
// 혼동 문자(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<String, String> login(String username, String password) {
HrmUser user = userMapper.findByUsername(username); HrmUser user = userMapper.findByUsername(username);
// 잠금 우선 차단(존재하는 계정에 한해 잠금 메시지 존재 여부 누설 최소화)
if (user != null && twoFactorService.isLocked(user)) {
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
}
if (user == null || !user.isActive()) { if (user == null || !user.isActive()) {
throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정"); 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())) { 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: 비밀번호 불일치"); throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치");
} }
return jwtUtil.generate(username, user.getRole());
// 비밀번호 검증 통과 2단계 우선순위: OTP > 이메일코드 > 단일 로그인
if (otpAuthService.isEnabled()) {
// { twofa:true, verifyToken, verifyMethod:(OTP|OTP_SETUP), secret?, otpAuthUri?, qrImage? }
return otpAuthService.beginOtp(user);
}
if (twoFactorService.isEnabled()) {
Map<String, String> 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");
} }
public Map<String, Object> me(String token) { public Map<String, Object> me(String token) {
@ -109,122 +37,4 @@ public class AuthService {
m.put("displayName", u != null ? u.getDisplayName() : username); m.put("displayName", u != null ? u.getDisplayName() : username);
return m; 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));
}
} }

View File

@ -17,20 +17,4 @@ public class HrmUser {
private String role; private String role;
private boolean active; private boolean active;
private LocalDateTime createdAt; 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;
} }

View File

@ -26,10 +26,7 @@ public class JwtFilter extends OncePerRequestFilter {
String header = req.getHeader("Authorization"); String header = req.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) { if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7); String token = header.substring(7);
// 보안(UIWS 2FA): purpose=2fa verify-token access 토큰이 아니다. if (jwtUtil.isValid(token)) {
// 동일 서명키라 isValid() 통과하므로 별도 차단하지 않으면 2차 인증 보호 API 접근(2FA 우회) 가능.
// verify-token 인증 컨텍스트를 세우지 않고 무시한다(/api/hrm/auth/verify 에서만 사용).
if (jwtUtil.isValid(token) && !jwtUtil.isVerifyToken(token)) {
String username = jwtUtil.getUsername(token); String username = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token); String role = jwtUtil.getRole(token);
var auth = new UsernamePasswordAuthenticationToken( var auth = new UsernamePasswordAuthenticationToken(

View File

@ -34,47 +34,6 @@ public class JwtUtil {
.compact(); .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) { public Claims parse(String token) {
return Jwts.parser().verifyWith(key()).build() return Jwts.parser().verifyWith(key()).build()
.parseSignedClaims(token).getPayload(); .parseSignedClaims(token).getPayload();

View File

@ -3,123 +3,9 @@ package com.zioinfo.hrm.auth.mapper;
import com.zioinfo.hrm.auth.HrmUser; import com.zioinfo.hrm.auth.HrmUser;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; 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 @Mapper
public interface UserMapper { public interface UserMapper {
HrmUser findByUsername(@Param("username") String username); HrmUser findByUsername(@Param("username") String username);
int insert(HrmUser user); 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);
} }

View File

@ -41,18 +41,10 @@ public class SecurityConfig {
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/api/hrm/auth/**").permitAll() .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("/actuator/health").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api/hrm/docs/**", "/api/hrm/swagger/**").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api/hrm/docs/**", "/api/hrm/swagger/**").permitAll()
.requestMatchers("/", "/index.html", "/assets/**", "/favicon.ico").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("/api/hrm/admin/users/**").hasRole("SUPERADMIN")
.requestMatchers(HttpMethod.GET, "/api/hrm/admin/settings").hasAnyRole("SUPERADMIN", "MANAGER") .requestMatchers(HttpMethod.GET, "/api/hrm/admin/settings").hasAnyRole("SUPERADMIN", "MANAGER")
.requestMatchers("/api/hrm/admin/settings/**").hasRole("SUPERADMIN") .requestMatchers("/api/hrm/admin/settings/**").hasRole("SUPERADMIN")

View File

@ -15,14 +15,8 @@ spring:
minimum-idle: 1 minimum-idle: 1
sql: sql:
init: init:
# schema.sql(전부 IF NOT EXISTS + ON CONFLICT DO NOTHING)·UIWS 이식 파일(전부 멱등)만 재적용. mode: ${SQL_INIT_MODE:never}
# 91_uiws_port.sql: 업무 모듈 tb_uiws_* + hrm_users 2FA/로그인보조 컬럼 ALTER(멱등). schema-locations: classpath:db/schema.sql
# 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
servlet: servlet:
multipart: multipart:
max-file-size: 20MB max-file-size: 20MB
@ -44,38 +38,15 @@ guardia:
erp-url: ${ERP_URL:http://localhost:8003} erp-url: ${ERP_URL:http://localhost:8003}
itsm-url: ${ITSM_URL:http://localhost:9001} itsm-url: ${ITSM_URL:http://localhost:9001}
groupware-url: ${GROUPWARE_URL:http://localhost:8009} groupware-url: ${GROUPWARE_URL:http://localhost:8009}
# 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지(Claude 는 소유자 승인 예외). # 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지.
ollama-url: ${OLLAMA_URL:http://localhost:11434} ollama-url: ${OLLAMA_URL:http://localhost:11434}
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3} 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: crypto:
secret: ${HRM_CRYPTO_SECRET:guardia-hrm-aes-256-gcm-master-key-2026-zioinfo} secret: ${HRM_CRYPTO_SECRET:guardia-hrm-aes-256-gcm-master-key-2026-zioinfo}
jwt: jwt:
secret: ${JWT_SECRET:guardia-hrm-jwt-secret-2026-minimum-256bit-key-zioinfo} secret: ${JWT_SECRET:guardia-hrm-jwt-secret-2026-minimum-256bit-key-zioinfo}
expiration: 86400000 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: logging:
level: level:
com.zioinfo.hrm: DEBUG com.zioinfo.hrm: DEBUG

View File

@ -39,10 +39,6 @@
UPDATE hrm_users SET active=#{active} WHERE id=#{id} UPDATE hrm_users SET active=#{active} WHERE id=#{id}
</update> </update>
<update id="clearOtp">
UPDATE hrm_users SET otp_secret=NULL, otp_enabled=false WHERE id=#{id}
</update>
<select id="findAuditLogs" resultType="map"> <select id="findAuditLogs" resultType="map">
SELECT id, actor, action, target_type, target_id, detail, ip_addr, created_at SELECT id, actor, action, target_type, target_id, detail, ip_addr, created_at
FROM hrm_audit_log FROM hrm_audit_log

View File

@ -12,22 +12,10 @@
<result property="role" column="role"/> <result property="role" column="role"/>
<result property="active" column="active"/> <result property="active" column="active"/>
<result property="createdAt" column="created_at"/> <result property="createdAt" column="created_at"/>
<!-- UIWS 2FA 이식 컬럼 (db/91_uiws_port.sql 멱등 ALTER 로 추가) -->
<result property="emailVerifyCode" column="email_verify_code"/>
<result property="emailVerifyExpire" column="email_verify_expire"/>
<result property="loginFailCount" column="login_fail_count"/>
<result property="locked" column="locked"/>
<result property="otpSecret" column="otp_secret"/>
<result property="otpEnabled" column="otp_enabled"/>
<!-- UIWS 로그인 보조기능 이식 컬럼 (회원가입 승인·임시비번 변경유도) -->
<result property="approved" column="approved"/>
<result property="pwChangeYn" column="pw_change_yn"/>
</resultMap> </resultMap>
<select id="findByUsername" resultMap="UserRM"> <select id="findByUsername" resultMap="UserRM">
SELECT id, username, password_hash, display_name, email, role, active, created_at, 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
FROM hrm_users WHERE username = #{username} FROM hrm_users WHERE username = #{username}
</select> </select>
@ -36,13 +24,4 @@
VALUES (#{username}, #{passwordHash}, #{displayName}, #{email}, #{role}, #{active}) VALUES (#{username}, #{passwordHash}, #{displayName}, #{email}, #{role}, #{active})
</insert> </insert>
<!-- UIWS 회원가입 이식: 가입자는 항상 VIEWER(최소권한)·승인 대기(approved=false). -->
<insert id="insertSignup" parameterType="com.zioinfo.hrm.auth.HrmUser"
useGeneratedKeys="true" keyProperty="id">
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)
</insert>
</mapper> </mapper>

View File

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

View File

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

View File

@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.hrm.admin.AdminMapper">
<resultMap id="UserRM" type="com.zioinfo.hrm.auth.HrmUser">
<id property="id" column="id"/>
<result property="username" column="username"/>
<result property="displayName" column="display_name"/>
<result property="email" column="email"/>
<result property="role" column="role"/>
<result property="active" column="active"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findAllUsers" resultMap="UserRM">
SELECT id, username, display_name, email, role, active, created_at
FROM hrm_users ORDER BY id
</select>
<select id="findUserById" resultMap="UserRM">
SELECT id, username, display_name, email, role, active, created_at
FROM hrm_users WHERE id=#{id}
</select>
<insert id="insertUser">
INSERT INTO hrm_users (username, password_hash, display_name, email, role, active)
VALUES (#{username}, #{passwordHash}, #{displayName}, #{email}, #{role}, #{active})
</insert>
<update id="updateUser">
UPDATE hrm_users SET
display_name=#{displayName}, email=#{email}, role=#{role}
<if test="passwordHash != null and passwordHash != ''">, password_hash=#{passwordHash}</if>
WHERE id=#{id}
</update>
<update id="updateUserActive">
UPDATE hrm_users SET active=#{active} WHERE id=#{id}
</update>
<select id="findAuditLogs" resultType="map">
SELECT id, actor, action, target_type, target_id, detail, ip_addr, created_at
FROM hrm_audit_log
<where>
<if test="actor != null and actor != ''">AND actor ILIKE '%'||#{actor}||'%'</if>
<if test="action != null and action != ''">AND action=#{action}</if>
</where>
ORDER BY created_at DESC
LIMIT #{limit} OFFSET #{offset}
</select>
<select id="countAuditLogs" resultType="long">
SELECT COUNT(*) FROM hrm_audit_log
<where>
<if test="actor != null and actor != ''">AND actor ILIKE '%'||#{actor}||'%'</if>
<if test="action != null and action != ''">AND action=#{action}</if>
</where>
</select>
<insert id="insertAuditLog">
INSERT INTO hrm_audit_log (actor, action, target_type, target_id, detail, ip_addr)
VALUES (#{actor}, #{action}, #{targetType}, #{targetId}, #{detail}, #{ipAddr})
</insert>
<select id="findSettings" resultType="map">
SELECT key, value, description, updated_by, updated_at FROM hrm_settings ORDER BY key
</select>
<select id="findSetting" resultType="map">
SELECT key, value, description FROM hrm_settings WHERE key=#{key}
</select>
<insert id="upsertSetting">
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()
</insert>
</mapper>

View File

@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.hrm.attendance.AttendanceMapper">
<select id="findByEmpAndMonth" resultType="map">
SELECT a.id, a.emp_id, a.work_date, a.check_in_time, a.check_out_time,
a.work_minutes, a.overtime_minutes, a.status
FROM hrm_attendance a
WHERE a.emp_id=#{empId}
AND EXTRACT(YEAR FROM a.work_date)=#{year}
AND EXTRACT(MONTH FROM a.work_date)=#{month}
ORDER BY a.work_date
</select>
<select id="findAll" resultType="map">
SELECT a.*, e.name AS emp_name, e.emp_no, d.dept_name
FROM hrm_attendance a
JOIN hrm_employees e ON a.emp_id=e.id
LEFT JOIN hrm_departments d ON e.department_id=d.id
<where>
<if test="date != null">AND a.work_date=#{date}</if>
<if test="deptId != null">AND e.department_id=#{deptId}</if>
</where>
ORDER BY e.emp_no
LIMIT #{limit} OFFSET #{offset}
</select>
<insert id="checkIn">
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}
</insert>
<update id="checkOut">
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}
</update>
<select id="findLeaves" resultType="map">
SELECT l.id, l.emp_id, l.leave_type_id, lt.type_name,
l.start_date, l.end_date, l.days, l.reason, l.status,
l.approved_by, l.created_at
FROM hrm_leaves l
JOIN hrm_leave_types lt ON l.leave_type_id=lt.id
WHERE l.emp_id=#{empId}
<if test="status != null and status != ''">AND l.status=#{status}</if>
<if test="year > 0">AND EXTRACT(YEAR FROM l.start_date)=#{year}</if>
ORDER BY l.start_date DESC
</select>
<insert id="insertLeave">
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})
</insert>
<update id="updateLeaveStatus">
UPDATE hrm_leaves SET status=#{status}, approved_by=#{approvedBy}, updated_at=NOW()
WHERE id=#{id}
</update>
<select id="getLeaveBalance" resultType="map">
SELECT
COALESCE(SUM(CASE WHEN lt.type_name='연차' THEN al.total_days ELSE 0 END), 0) AS total_annual,
COALESCE(SUM(CASE WHEN l.status IN ('APPROVED','USED') AND lt.type_name='연차' THEN l.days ELSE 0 END), 0) AS used_annual
FROM hrm_employees e
LEFT JOIN hrm_annual_leaves al ON al.emp_id=e.id AND al.year=#{year}
LEFT JOIN hrm_leaves l ON l.emp_id=e.id AND EXTRACT(YEAR FROM l.start_date)=#{year}
LEFT JOIN hrm_leave_types lt ON l.leave_type_id=lt.id
WHERE e.id=#{empId}
</select>
<select id="findLeaveTypes" resultType="map">
SELECT id, type_code, type_name, is_paid, max_days
FROM hrm_leave_types WHERE active=true ORDER BY sort_order
</select>
<insert id="insertLeaveType">
INSERT INTO hrm_leave_types (type_code, type_name, is_paid, max_days)
VALUES (#{typeCode}, #{typeName}, #{isPaid}, #{maxDays})
</insert>
<select id="findOvertime" resultType="map">
SELECT id, emp_id, ot_date, start_time, end_time, ot_minutes, reason, status
FROM hrm_overtime
WHERE emp_id=#{empId}
AND EXTRACT(YEAR FROM ot_date)=#{year}
AND EXTRACT(MONTH FROM ot_date)=#{month}
ORDER BY ot_date DESC
</select>
<insert id="insertOvertime">
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})
</insert>
</mapper>

View File

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.hrm.dashboard.DashboardMapper">
<select id="getKpiSummary" resultType="map">
SELECT
(SELECT COUNT(*) FROM hrm_employees WHERE status='ACTIVE') AS total_employees,
(SELECT COUNT(*) FROM hrm_employees WHERE status='ACTIVE' AND hire_date >= DATE_TRUNC('year', CURRENT_DATE)) AS new_hires_ytd,
(SELECT COUNT(*) FROM hrm_employees WHERE status='RETIRED' AND retire_date >= DATE_TRUNC('year', CURRENT_DATE)) AS attrition_ytd,
(SELECT COUNT(*) FROM hrm_job_postings WHERE status='PUBLISHED') AS open_positions,
(SELECT COUNT(*) FROM hrm_leaves WHERE status='PENDING') AS pending_leaves,
(SELECT COUNT(*) FROM hrm_performance_reviews WHERE status='SUBMITTED') AS pending_reviews,
(SELECT COALESCE(SUM(te.completed_at IS NOT NULL::int)::float/NULLIF(COUNT(*),0)*100,0)
FROM hrm_training_enrollments te) AS training_completion_rate
</select>
<select id="getHeadcountTrend" resultType="map">
SELECT
TO_CHAR(gs.month, 'YYYY-MM') AS month,
COUNT(e.id) FILTER (WHERE e.hire_date &lt;= gs.month AND (e.retire_date IS NULL OR e.retire_date > gs.month)) AS headcount
FROM generate_series(
DATE_TRUNC('month', CURRENT_DATE - (#{months}-1||' months')::interval),
DATE_TRUNC('month', CURRENT_DATE),
'1 month'::interval
) AS gs(month)
CROSS JOIN hrm_employees e
GROUP BY gs.month ORDER BY gs.month
</select>
<select id="getDeptHeadcount" resultType="map">
SELECT d.dept_name, COUNT(e.id) AS headcount
FROM hrm_departments d
LEFT JOIN hrm_employees e ON e.department_id=d.id AND e.status='ACTIVE'
WHERE d.active=true
GROUP BY d.id, d.dept_name ORDER BY headcount DESC
</select>
<select id="getAttendanceToday" resultType="map">
SELECT
COUNT(*) FILTER (WHERE status='PRESENT') AS present_count,
COUNT(*) FILTER (WHERE status='ABSENT') AS absent_count,
COUNT(*) FILTER (WHERE status='LEAVE') AS leave_count,
(SELECT COUNT(*) FROM hrm_employees WHERE status='ACTIVE') AS total_active,
ROUND(COUNT(*) FILTER (WHERE status='PRESENT')::numeric /
NULLIF((SELECT COUNT(*) FROM hrm_employees WHERE status='ACTIVE'),0)*100,1) AS attendance_rate
FROM hrm_attendance WHERE work_date=CURRENT_DATE
</select>
<select id="getPayrollSummary" resultType="map">
SELECT
COALESCE(SUM(net_salary),0) AS total_net,
COALESCE(SUM(base_salary),0) AS total_base,
COUNT(*) AS payslip_count
FROM hrm_payslips WHERE year=#{year} AND month=#{month}
</select>
<select id="getRecruitmentPipeline" resultType="map">
SELECT status, COUNT(*) AS cnt
FROM hrm_applicants GROUP BY status ORDER BY cnt DESC
</select>
<select id="getPendingApprovals" resultType="map">
SELECT 'leave' AS type, id, reason AS title, created_at FROM hrm_leaves WHERE status='PENDING'
UNION ALL
SELECT 'review' AS type, id, CONCAT(year::text,' 성과평가') AS title, created_at
FROM hrm_performance_reviews WHERE status='SUBMITTED'
ORDER BY created_at DESC LIMIT 10
</select>
</mapper>

View File

@ -0,0 +1,129 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.hrm.employee.EmployeeMapper">
<resultMap id="EmpRM" type="com.zioinfo.hrm.employee.Employee">
<id property="id" column="id"/>
<result property="empNo" column="emp_no"/>
<result property="name" column="name"/>
<result property="nameEn" column="name_en"/>
<result property="departmentId" column="department_id"/>
<result property="departmentName" column="department_name"/>
<result property="positionId" column="position_id"/>
<result property="positionName" column="position_name"/>
<result property="gradeId" column="grade_id"/>
<result property="gradeName" column="grade_name"/>
<result property="employmentType" column="employment_type"/>
<result property="status" column="status"/>
<result property="hireDate" column="hire_date"/>
<result property="retireDate" column="retire_date"/>
<result property="email" column="email"/>
<result property="phoneEnc" column="phone_enc"/>
<result property="photoUrl" column="photo_url"/>
<result property="gender" column="gender"/>
<result property="birthDate" column="birth_date"/>
<result property="createdBy" column="created_by"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
</resultMap>
<select id="findAll" resultMap="EmpRM">
SELECT e.id, e.emp_no, e.name, e.name_en,
e.department_id, d.dept_name AS department_name,
e.position_id, p.position_name,
e.grade_id, g.grade_name,
e.employment_type, e.status, e.hire_date, e.retire_date,
e.email, e.photo_url, e.gender, e.created_by, e.created_at, e.updated_at
FROM hrm_employees e
LEFT JOIN hrm_departments d ON e.department_id = d.id
LEFT JOIN hrm_positions p ON e.position_id = p.id
LEFT JOIN hrm_grades g ON e.grade_id = g.id
<where>
<if test="keyword != null and keyword != ''">
AND (e.name ILIKE '%'||#{keyword}||'%' OR e.emp_no ILIKE '%'||#{keyword}||'%')
</if>
<if test="deptId != null">AND e.department_id = #{deptId}</if>
<if test="status != null and status != ''">AND e.status = #{status}</if>
</where>
ORDER BY e.emp_no
LIMIT #{limit} OFFSET #{offset}
</select>
<select id="countAll" resultType="long">
SELECT COUNT(*) FROM hrm_employees e
<where>
<if test="keyword != null and keyword != ''">
AND (e.name ILIKE '%'||#{keyword}||'%' OR e.emp_no ILIKE '%'||#{keyword}||'%')
</if>
<if test="deptId != null">AND e.department_id = #{deptId}</if>
<if test="status != null and status != ''">AND e.status = #{status}</if>
</where>
</select>
<select id="findById" resultMap="EmpRM">
SELECT e.*, d.dept_name AS department_name, p.position_name, g.grade_name
FROM hrm_employees e
LEFT JOIN hrm_departments d ON e.department_id = d.id
LEFT JOIN hrm_positions p ON e.position_id = p.id
LEFT JOIN hrm_grades g ON e.grade_id = g.id
WHERE e.id = #{id}
</select>
<select id="findByEmpNo" resultMap="EmpRM">
SELECT * FROM hrm_employees WHERE emp_no = #{empNo}
</select>
<select id="getLastEmpNo" resultType="string">
SELECT emp_no FROM hrm_employees ORDER BY id DESC LIMIT 1
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
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})
</insert>
<update id="update">
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>
<update id="updateStatus">
UPDATE hrm_employees SET status=#{status}, retire_date=#{retireDate}, updated_at=NOW()
WHERE id=#{id}
</update>
<select id="getCareerList" resultType="map">
SELECT id, emp_id, company_name, position, start_date, end_date, description
FROM hrm_emp_careers WHERE emp_id=#{empId} ORDER BY start_date DESC
</select>
<insert id="insertCareer">
INSERT INTO hrm_emp_careers (emp_id, company_name, position, start_date, end_date, description)
VALUES (#{empId}, #{companyName}, #{position}, #{startDate}, #{endDate}, #{description})
</insert>
<delete id="deleteCareer">DELETE FROM hrm_emp_careers WHERE id=#{id}</delete>
<select id="getCertList" resultType="map">
SELECT id, emp_id, cert_name, cert_no, issue_date, expire_date, issuer
FROM hrm_emp_certs WHERE emp_id=#{empId} ORDER BY issue_date DESC
</select>
<insert id="insertCert">
INSERT INTO hrm_emp_certs (emp_id, cert_name, cert_no, issue_date, expire_date, issuer)
VALUES (#{empId}, #{certName}, #{certNo}, #{issueDate}, #{expireDate}, #{issuer})
</insert>
<delete id="deleteCert">DELETE FROM hrm_emp_certs WHERE id=#{id}</delete>
</mapper>

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.hrm.organization.OrgMapper">
<select id="findAllDepts" resultType="map">
SELECT d.id, d.dept_code, d.dept_name, d.parent_id,
p.dept_name AS parent_name, d.manager_emp_id,
e.name AS manager_name, d.sort_order, d.active,
(SELECT COUNT(*) FROM hrm_employees emp WHERE emp.department_id = d.id AND emp.status='ACTIVE') AS headcount
FROM hrm_departments d
LEFT JOIN hrm_departments p ON d.parent_id = p.id
LEFT JOIN hrm_employees e ON d.manager_emp_id = e.id
WHERE d.active = true
ORDER BY d.sort_order, d.id
</select>
<insert id="insertDept">
INSERT INTO hrm_departments (dept_code, dept_name, parent_id, manager_emp_id, sort_order)
VALUES (#{deptCode}, #{deptName}, #{parentId}, #{managerEmpId}, #{sortOrder})
</insert>
<update id="updateDept">
UPDATE hrm_departments SET
dept_name=#{deptName}, parent_id=#{parentId},
manager_emp_id=#{managerEmpId}, sort_order=#{sortOrder}
WHERE id=#{id}
</update>
<update id="deleteDept">
UPDATE hrm_departments SET active=false WHERE id=#{id}
</update>
<select id="findAllPositions" resultType="map">
SELECT id, position_code, position_name, sort_order
FROM hrm_positions WHERE active=true ORDER BY sort_order
</select>
<insert id="insertPosition">
INSERT INTO hrm_positions (position_code, position_name, sort_order)
VALUES (#{positionCode}, #{positionName}, #{sortOrder})
</insert>
<select id="findAllGrades" resultType="map">
SELECT id, grade_code, grade_name, grade_level, sort_order
FROM hrm_grades WHERE active=true ORDER BY grade_level
</select>
<insert id="insertGrade">
INSERT INTO hrm_grades (grade_code, grade_name, grade_level, sort_order)
VALUES (#{gradeCode}, #{gradeName}, #{gradeLevel}, #{sortOrder})
</insert>
</mapper>

View File

@ -0,0 +1,85 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.hrm.payroll.PayrollMapper">
<select id="findPayrolls" resultType="map">
SELECT p.id, p.year, p.month, p.status, p.total_amount,
p.processed_by, p.processed_at,
(SELECT COUNT(*) FROM hrm_payslips ps WHERE ps.payroll_id=p.id) AS payslip_count
FROM hrm_payroll p
<where>
<if test="year > 0">AND p.year = #{year}</if>
<if test="month > 0">AND p.month = #{month}</if>
<if test="deptId != null">AND p.dept_id = #{deptId}</if>
</where>
ORDER BY p.year DESC, p.month DESC
LIMIT #{limit} OFFSET #{offset}
</select>
<select id="countPayrolls" resultType="long">
SELECT COUNT(*) FROM hrm_payroll p
<where>
<if test="year > 0">AND p.year = #{year}</if>
<if test="month > 0">AND p.month = #{month}</if>
<if test="deptId != null">AND p.dept_id = #{deptId}</if>
</where>
</select>
<select id="findPayslip" resultType="map">
SELECT ps.*, e.name AS emp_name, e.emp_no, d.dept_name
FROM hrm_payslips ps
JOIN hrm_employees e ON ps.emp_id = e.id
LEFT JOIN hrm_departments d ON e.department_id = d.id
WHERE ps.emp_id=#{empId} AND ps.year=#{year} AND ps.month=#{month}
</select>
<select id="findPayslipsByEmp" resultType="map">
SELECT ps.id, ps.year, ps.month, ps.base_salary, ps.total_deduction, ps.net_salary
FROM hrm_payslips ps
WHERE ps.emp_id=#{empId} AND ps.year=#{year}
ORDER BY ps.month DESC
</select>
<insert id="insertPayroll">
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()
</insert>
<update id="updatePayrollStatus">
UPDATE hrm_payroll SET status=#{status} WHERE id=#{id}
</update>
<select id="findSalaryItems" resultType="map">
SELECT id, item_code, item_name, item_type, is_taxable, sort_order
FROM hrm_salary_items WHERE active=true ORDER BY sort_order
</select>
<select id="findSalaryByEmpId" resultType="map">
SELECT s.*, e.name AS emp_name, e.emp_no
FROM hrm_emp_salaries s
JOIN hrm_employees e ON s.emp_id = e.id
WHERE s.emp_id=#{empId}
ORDER BY s.effective_date DESC LIMIT 1
</select>
<insert id="upsertSalary">
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}
</insert>
<select id="getYearlySummary" resultType="map">
SELECT
SUM(net_salary) AS total_net,
SUM(base_salary) AS total_base,
SUM(total_deduction) AS total_deduction,
COUNT(*) AS month_count
FROM hrm_payslips
WHERE emp_id=#{empId} AND year=#{year}
</select>
</mapper>

View File

@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.hrm.performance.PerformanceMapper">
<select id="findReviews" resultType="map">
SELECT r.id, r.emp_id, e.name AS emp_name, e.emp_no,
r.year, r.period, r.final_grade, r.score, r.status,
r.created_by, r.created_at
FROM hrm_performance_reviews r
JOIN hrm_employees e ON r.emp_id=e.id
<where>
<if test="year > 0">AND r.year=#{year}</if>
<if test="empId != null">AND r.emp_id=#{empId}</if>
<if test="status != null and status != ''">AND r.status=#{status}</if>
</where>
ORDER BY r.year DESC, e.emp_no
LIMIT #{limit} OFFSET #{offset}
</select>
<select id="countReviews" resultType="long">
SELECT COUNT(*) FROM hrm_performance_reviews r
<where>
<if test="year > 0">AND r.year=#{year}</if>
<if test="empId != null">AND r.emp_id=#{empId}</if>
<if test="status != null and status != ''">AND r.status=#{status}</if>
</where>
</select>
<select id="findReviewById" resultType="map">
SELECT r.*, e.name AS emp_name, e.emp_no
FROM hrm_performance_reviews r
JOIN hrm_employees e ON r.emp_id=e.id
WHERE r.id=#{id}
</select>
<insert id="insertReview">
INSERT INTO hrm_performance_reviews (emp_id, year, period, status, created_by)
VALUES (#{empId}, #{year}, #{period}, #{status}, #{createdBy})
</insert>
<update id="updateReview">
UPDATE hrm_performance_reviews SET
final_grade=#{finalGrade}, score=#{score}, comments=#{comments}, updated_at=NOW()
WHERE id=#{id}
</update>
<update id="updateReviewStatus">
UPDATE hrm_performance_reviews SET status=#{status}, updated_at=NOW() WHERE id=#{id}
</update>
<select id="findGoals" resultType="map">
SELECT id, emp_id, year, goal_title, goal_desc, weight, target_value, actual_value, achievement_rate, status
FROM hrm_goals WHERE emp_id=#{empId} AND year=#{year}
ORDER BY weight DESC
</select>
<insert id="insertGoal">
INSERT INTO hrm_goals (emp_id, year, goal_title, goal_desc, weight, target_value, created_by)
VALUES (#{empId}, #{year}, #{goalTitle}, #{goalDesc}, #{weight}, #{targetValue}, #{createdBy})
</insert>
<update id="updateGoal">
UPDATE hrm_goals SET
goal_title=#{goalTitle}, weight=#{weight}, target_value=#{targetValue},
actual_value=#{actualValue}, achievement_rate=#{achievementRate}, status=#{status}
WHERE id=#{id}
</update>
<select id="findCompetencies" resultType="map">
SELECT cs.id, cs.emp_id, cs.year, c.competency_name, c.category,
cs.self_score, cs.manager_score, cs.peer_score, cs.final_score, cs.evaluator
FROM hrm_competency_scores cs
JOIN hrm_competencies c ON cs.competency_id=c.id
WHERE cs.emp_id=#{empId} AND cs.year=#{year}
ORDER BY c.category, c.sort_order
</select>
<insert id="insertCompetencyScore">
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()
</insert>
</mapper>

View File

@ -0,0 +1,104 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.hrm.recruitment.RecruitmentMapper">
<select id="findPostings" resultType="map">
SELECT jp.id, jp.title, jp.department_id, d.dept_name,
jp.employment_type, jp.headcount, jp.status,
jp.start_date, jp.end_date, jp.created_by, jp.created_at,
(SELECT COUNT(*) FROM hrm_applicants a WHERE a.posting_id=jp.id) AS applicant_count
FROM hrm_job_postings jp
LEFT JOIN hrm_departments d ON jp.department_id=d.id
<where>
<if test="status != null and status != ''">AND jp.status=#{status}</if>
<if test="keyword != null and keyword != ''">AND jp.title ILIKE '%'||#{keyword}||'%'</if>
</where>
ORDER BY jp.created_at DESC
LIMIT #{limit} OFFSET #{offset}
</select>
<select id="countPostings" resultType="long">
SELECT COUNT(*) FROM hrm_job_postings jp
<where>
<if test="status != null and status != ''">AND jp.status=#{status}</if>
<if test="keyword != null and keyword != ''">AND jp.title ILIKE '%'||#{keyword}||'%'</if>
</where>
</select>
<select id="findPostingById" resultType="map">
SELECT jp.*, d.dept_name FROM hrm_job_postings jp
LEFT JOIN hrm_departments d ON jp.department_id=d.id
WHERE jp.id=#{id}
</select>
<insert id="insertPosting">
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})
</insert>
<update id="updatePosting">
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>
<update id="updatePostingStatus">
UPDATE hrm_job_postings SET status=#{status}, updated_at=NOW() WHERE id=#{id}
</update>
<select id="findApplicants" resultType="map">
SELECT a.id, a.posting_id, a.applicant_name, a.email, a.status,
a.apply_date, a.memo, a.created_at
FROM hrm_applicants a
<where>
<if test="postingId != null">AND a.posting_id=#{postingId}</if>
<if test="status != null and status != ''">AND a.status=#{status}</if>
</where>
ORDER BY a.apply_date DESC
</select>
<select id="findApplicantById" resultType="map">
SELECT a.*, jp.title AS posting_title
FROM hrm_applicants a
JOIN hrm_job_postings jp ON a.posting_id=jp.id
WHERE a.id=#{id}
</select>
<insert id="insertApplicant">
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})
</insert>
<update id="updateApplicantStatus">
UPDATE hrm_applicants SET status=#{status}, memo=#{memo}, updated_at=NOW() WHERE id=#{id}
</update>
<select id="findInterviews" resultType="map">
SELECT i.id, i.posting_id, i.applicant_id, a.applicant_name,
i.interview_type, i.scheduled_at, i.location, i.interviewers,
i.result, i.notes, i.created_by
FROM hrm_interviews i
JOIN hrm_applicants a ON i.applicant_id=a.id
<where>
<if test="postingId != null">AND i.posting_id=#{postingId}</if>
<if test="applicantId != null">AND i.applicant_id=#{applicantId}</if>
</where>
ORDER BY i.scheduled_at
</select>
<insert id="insertInterview">
INSERT INTO hrm_interviews (posting_id, applicant_id, interview_type, scheduled_at, location, interviewers, created_by)
VALUES (#{postingId}, #{applicantId}, #{interviewType}, #{scheduledAt}, #{location}, #{interviewers}, #{createdBy})
</insert>
<update id="updateInterview">
UPDATE hrm_interviews SET
interview_type=#{interviewType}, scheduled_at=#{scheduledAt},
location=#{location}, result=#{result}, notes=#{notes}, updated_at=NOW()
WHERE id=#{id}
</update>
</mapper>

View File

@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.hrm.training.TrainingMapper">
<select id="findCourses" resultType="map">
SELECT c.id, c.course_code, c.course_name, c.course_type,
c.instructor, c.start_date, c.end_date, c.duration_hours,
c.is_legal, c.max_attendees, c.status,
(SELECT COUNT(*) FROM hrm_training_enrollments e WHERE e.course_id=c.id) AS enrolled_count
FROM hrm_training_courses c
<where>
<if test="keyword != null and keyword != ''">AND c.course_name ILIKE '%'||#{keyword}||'%'</if>
<if test="type != null and type != ''">AND c.course_type=#{type}</if>
</where>
ORDER BY c.start_date DESC
LIMIT #{limit} OFFSET #{offset}
</select>
<select id="countCourses" resultType="long">
SELECT COUNT(*) FROM hrm_training_courses c
<where>
<if test="keyword != null and keyword != ''">AND c.course_name ILIKE '%'||#{keyword}||'%'</if>
<if test="type != null and type != ''">AND c.course_type=#{type}</if>
</where>
</select>
<select id="findCourseById" resultType="map">
SELECT * FROM hrm_training_courses WHERE id=#{id}
</select>
<insert id="insertCourse">
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})
</insert>
<update id="updateCourse">
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}
</update>
<select id="findEnrollments" resultType="map">
SELECT te.id, te.emp_id, e.name AS emp_name, e.emp_no,
te.course_id, c.course_name, te.status, te.enrolled_at, te.completed_at
FROM hrm_training_enrollments te
JOIN hrm_employees e ON te.emp_id=e.id
JOIN hrm_training_courses c ON te.course_id=c.id
<where>
<if test="empId != null">AND te.emp_id=#{empId}</if>
<if test="courseId != null">AND te.course_id=#{courseId}</if>
<if test="year > 0">AND EXTRACT(YEAR FROM te.enrolled_at)=#{year}</if>
</where>
ORDER BY te.enrolled_at DESC
</select>
<insert id="insertEnrollment">
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
</insert>
<update id="updateEnrollmentStatus">
UPDATE hrm_training_enrollments SET status=#{status}, completed_at=#{completedAt}
WHERE id=#{id}
</update>
<select id="getLegalTrainingStatus" resultType="map">
SELECT
COUNT(DISTINCT c.id) AS total_legal,
COUNT(DISTINCT CASE WHEN te.status='COMPLETED' THEN te.course_id END) AS completed_legal,
ROUND(COUNT(DISTINCT CASE WHEN te.status='COMPLETED' THEN te.course_id END)::numeric /
NULLIF(COUNT(DISTINCT c.id),0) * 100, 1) AS completion_rate
FROM hrm_training_courses c
LEFT JOIN hrm_training_enrollments te ON te.course_id=c.id AND te.emp_id=#{empId}
WHERE c.is_legal=true AND EXTRACT(YEAR FROM c.start_date)=#{year}
</select>
<select id="findLegalCourses" resultType="map">
SELECT id, course_name, course_type, duration_hours, start_date, end_date
FROM hrm_training_courses WHERE is_legal=true ORDER BY start_date
</select>
</mapper>

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.hrm.auth.mapper.UserMapper">
<resultMap id="UserRM" type="com.zioinfo.hrm.auth.HrmUser">
<id property="id" column="id"/>
<result property="username" column="username"/>
<result property="passwordHash" column="password_hash"/>
<result property="displayName" column="display_name"/>
<result property="email" column="email"/>
<result property="role" column="role"/>
<result property="active" column="active"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findByUsername" resultMap="UserRM">
SELECT id, username, password_hash, display_name, email, role, active, created_at
FROM hrm_users WHERE username = #{username}
</select>
<insert id="insert">
INSERT INTO hrm_users (username, password_hash, display_name, email, role, active)
VALUES (#{username}, #{passwordHash}, #{displayName}, #{email}, #{role}, #{active})
</insert>
</mapper>

View File

@ -2,7 +2,7 @@
<html lang="ko"> <html lang="ko">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GUARDiA HRM — AI 인사관리 플랫폼</title> <title>GUARDiA HRM — AI 인사관리 플랫폼</title>
</head> </head>

View File

@ -10,21 +10,7 @@ import PerformancePage from './pages/PerformancePage'
import RecruitmentPage from './pages/RecruitmentPage' import RecruitmentPage from './pages/RecruitmentPage'
import TrainingPage from './pages/TrainingPage' import TrainingPage from './pages/TrainingPage'
import AiPage from './pages/AiPage' import AiPage from './pages/AiPage'
import WiseAiPage from './pages/WiseAiPage'
import AiPlatformSettings from './pages/AiPlatformSettings'
import AdminPage from './pages/AdminPage' 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 = [ const MENU = [
{ path: '/dashboard', label: '대시보드', icon: '📊' }, { path: '/dashboard', label: '대시보드', icon: '📊' },
@ -35,25 +21,13 @@ const MENU = [
{ path: '/performance', label: '성과평가', icon: '🎯' }, { path: '/performance', label: '성과평가', icon: '🎯' },
{ path: '/recruitment', label: '채용관리', icon: '🔍' }, { path: '/recruitment', label: '채용관리', icon: '🔍' },
{ path: '/training', 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: '/ai', label: 'AI 인사분석', icon: '🤖' },
{ path: '/wise-ai', label: 'WISE AI', icon: '✨' },
{ path: '/ai-platform', label: 'AI 플랫폼 설정', icon: '🧠', adminOnly: true },
{ path: '/admin', label: '시스템관리', icon: '⚙️' }, { path: '/admin', label: '시스템관리', icon: '⚙️' },
// UIWS system 이식 — 권한·코드·메뉴·부서·거래처 (관리자 전용)
{ path: '/system', label: '시스템관리(권한)', icon: '🔐', adminOnly: true },
{ path: '/mobile-app', label: '모바일 앱', icon: '📱' },
] ]
function Layout({ children }: { children: React.ReactNode }) { function Layout({ children }: { children: React.ReactNode }) {
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false)
const user = JSON.parse(localStorage.getItem('hrm_user') || '{"displayName":"관리자","role":"SUPERADMIN"}') 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 ( return (
<div className="flex h-screen overflow-hidden"> <div className="flex h-screen overflow-hidden">
@ -71,7 +45,7 @@ function Layout({ children }: { children: React.ReactNode }) {
</button> </button>
</div> </div>
<nav className="flex-1 overflow-y-auto py-3 px-2 space-y-1"> <nav className="flex-1 overflow-y-auto py-3 px-2 space-y-1">
{visibleMenu.map(m => ( {MENU.map(m => (
<NavLink key={m.path} to={m.path} <NavLink key={m.path} to={m.path}
className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`}> className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`}>
<span className="text-base">{m.icon}</span> <span className="text-base">{m.icon}</span>
@ -92,13 +66,10 @@ function Layout({ children }: { children: React.ReactNode }) {
)} )}
</div> </div>
{!collapsed && ( {!collapsed && (
<div className="mt-3 flex items-center justify-between"> <button className="mt-3 w-full text-xs text-slate-400 hover:text-white"
<NavLink to="/mypage" className="text-xs text-slate-400 hover:text-white"></NavLink>
<button className="text-xs text-slate-400 hover:text-white"
onClick={() => { localStorage.clear(); window.location.href = '/login'; }}> onClick={() => { localStorage.clear(); window.location.href = '/login'; }}>
</button> </button>
</div>
)} )}
</div> </div>
</aside> </aside>
@ -116,15 +87,6 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
return <Layout>{children}</Layout> return <Layout>{children}</Layout>
} }
// 관리자 전용 라우트 가드(UI 차단). 실제 인가는 백엔드 /api/system/** RBAC가 강제(403).
function AdminRoute({ children }: { children: React.ReactNode }) {
const token = localStorage.getItem('hrm_token')
if (!token) return <Navigate to="/login" replace />
const user = JSON.parse(localStorage.getItem('hrm_user') || '{"role":"SUPERADMIN"}')
if (!ADMIN_ROLES.includes(user.role)) return <Navigate to="/dashboard" replace />
return <Layout>{children}</Layout>
}
export default function App() { export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
@ -139,23 +101,8 @@ export default function App() {
<Route path="/performance" element={<PrivateRoute><PerformancePage /></PrivateRoute>} /> <Route path="/performance" element={<PrivateRoute><PerformancePage /></PrivateRoute>} />
<Route path="/recruitment" element={<PrivateRoute><RecruitmentPage /></PrivateRoute>} /> <Route path="/recruitment" element={<PrivateRoute><RecruitmentPage /></PrivateRoute>} />
<Route path="/training" element={<PrivateRoute><TrainingPage /></PrivateRoute>} /> <Route path="/training" element={<PrivateRoute><TrainingPage /></PrivateRoute>} />
{/* UIWS(UIMS) 이식 — 업무 협업 화면 */}
<Route path="/worklogs" element={<PrivateRoute><WorklogList /></PrivateRoute>} />
<Route path="/schedules" element={<PrivateRoute><ScheduleCalendar /></PrivateRoute>} />
<Route path="/messages" element={<PrivateRoute><MessageBox /></PrivateRoute>} />
<Route path="/work-stats" element={<PrivateRoute><StatsPivot /></PrivateRoute>} />
<Route path="/ai" element={<PrivateRoute><AiPage /></PrivateRoute>} /> <Route path="/ai" element={<PrivateRoute><AiPage /></PrivateRoute>} />
{/* WISE AI — 중앙 guardia-rag 근거·인용 질의응답(인증 사용자) */}
<Route path="/wise-ai" element={<PrivateRoute><WiseAiPage /></PrivateRoute>} />
{/* AI 플랫폼 설정(provider/모델·연결테스트·피드백 학습) — 관리자 전용. 백엔드 /api/hrm/admin/** RBAC 강제 */}
<Route path="/ai-platform" element={<AdminRoute><AiPlatformSettings /></AdminRoute>} />
<Route path="/admin" element={<PrivateRoute><AdminPage /></PrivateRoute>} /> <Route path="/admin" element={<PrivateRoute><AdminPage /></PrivateRoute>} />
{/* 마이페이지 — OTP 2차 인증 등록/재설정/해제 + 비밀번호 변경 */}
<Route path="/mypage" element={<PrivateRoute><MyPage /></PrivateRoute>} />
{/* UIWS system 이식 — 시스템관리(권한). 관리자 전용(SUPERADMIN/MANAGER/ADMIN), 그 외 대시보드로 */}
<Route path="/system" element={<AdminRoute><SystemPage /></AdminRoute>} />
{/* 통합 메신저 앱 다운로드 QR (ITSM 중앙 APK 저장소 공개 엔드포인트 재사용·읽기전용) */}
<Route path="/mobile-app" element={<PrivateRoute><MobileApp /></PrivateRoute>} />
<Route path="*" element={<Navigate to="/dashboard" replace />} /> <Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View File

@ -20,30 +20,3 @@ api.interceptors.response.use(
) )
export default api export default api
// ── WISE AI (중앙 guardia-rag 프록시) ──────────────────────────────────
// 별도 baseURL(/api/wise) — 공용 api(/api/hrm)와 경로가 다르므로 전용 인스턴스.
// 인증 사용자 전용(로그인 JWT). 응답은 ApiResponse 봉투(r.data.data 에 rag 결과).
const wiseApi = axios.create({ baseURL: '/api/wise' })
wiseApi.interceptors.request.use(cfg => {
const token = localStorage.getItem('hrm_token')
if (token) cfg.headers.Authorization = `Bearer ${token}`
return cfg
})
export const wiseAsk = (query: string) => wiseApi.post('/ask', { query })
// ── Auth / Me ─────────────────────────────────────────────────────────
// client baseURL='/api/hrm' → 경로는 상대(/auth/...·/admin/...).
export const getMe = () => api.get('/auth/me')
// ── 2FA / OTP / 계정 보안 (마이페이지, access 토큰 필요) ────────────────
// 보안 불변: setup 응답(secret/qrImage)은 화면 표시용만 — 로그/저장 금지.
export const otpSetup = () => api.post('/auth/otp/setup')
export const otpConfirm = (code: string) => api.post('/auth/otp/confirm', { code })
export const otpDisable = () => api.post('/auth/otp/disable')
export const changePassword = (currentPassword: string, newPassword: string) =>
api.post('/auth/change-password', { currentPassword, newPassword })
// ── Admin: OTP 초기화 (SUPERADMIN — /api/hrm/admin/users/** 게이트) ─────
// 대상 사용자 OTP 해제(다음 로그인 시 OTP_SETUP 재등록). 시크릿 미조회.
export const adminOtpReset = (id: number) => api.post(`/admin/users/${id}/otp-reset`)

View File

@ -40,45 +40,3 @@ body {
.table-cell { .table-cell {
@apply px-4 py-3 text-sm text-slate-700; @apply px-4 py-3 text-sm text-slate-700;
} }
/*
* UIWS 이식 컴포넌트(components/uiws/ui.tsx) 디자인 토큰.
* 색상 하드코딩 금지 컴포넌트는 var(--uiws-*) 사용한다.
* 기본은 HRM 라이트 테마와 정합. OS 다크 모드 자동 전환(대비/가독성 보존).
* 전역 HRM 테마를 덮어쓰지 않도록 토큰만 정의(모듈 스코프).
*/
:root {
--uiws-surface: #ffffff;
--uiws-surface-2: #f1f5f9;
--uiws-border: #e2e8f0;
--uiws-text: #1e293b;
--uiws-text-muted: #64748b;
--uiws-text-faint: #94a3b8;
--uiws-primary: #2563eb;
--uiws-primary-contrast: #ffffff;
--uiws-primary-soft: #eff6ff;
--uiws-input-bg: #ffffff;
--uiws-row-hover: #f8fafc;
--uiws-danger: #dc2626;
--uiws-success: #16a34a;
--uiws-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
}
@media (prefers-color-scheme: dark) {
:root {
--uiws-surface: #1e293b;
--uiws-surface-2: #0f172a;
--uiws-border: #334155;
--uiws-text: #e2e8f0;
--uiws-text-muted: #94a3b8;
--uiws-text-faint: #64748b;
--uiws-primary: #3b82f6;
--uiws-primary-contrast: #ffffff;
--uiws-primary-soft: #1e3a5f;
--uiws-input-bg: #0f172a;
--uiws-row-hover: #273449;
--uiws-danger: #f87171;
--uiws-success: #4ade80;
--uiws-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
}

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import api, { adminOtpReset } from '../api/client' import api from '../api/client'
export default function AdminPage() { export default function AdminPage() {
const [users, setUsers] = useState<any[]>([]) const [users, setUsers] = useState<any[]>([])
@ -28,21 +28,6 @@ export default function AdminPage() {
loadUsers() loadUsers()
} }
// 관리자 OTP 초기화(SUPERADMIN). 대상 사용자는 다음 로그인 시 OTP_SETUP(QR 재등록)을 탄다.
const resetOtp = async (id: number, username: string) => {
if (!window.confirm(
`'${username}' 사용자의 OTP를 초기화하시겠습니까?\n초기화하면 다음 로그인 시 재등록해야 합니다.`
)) return
try {
await adminOtpReset(id)
alert('OTP가 초기화되었습니다.')
} catch (e: any) {
alert(e?.response?.status === 403
? '권한이 없습니다 (SUPERADMIN 전용).'
: (e?.response?.data?.message || 'OTP 초기화에 실패했습니다.'))
}
}
const saveSetting = async (key: string, value: string) => { const saveSetting = async (key: string, value: string) => {
await api.put(`/admin/settings/${key}`, null, { params: { value } }) await api.put(`/admin/settings/${key}`, null, { params: { value } })
loadSettings() loadSettings()
@ -94,16 +79,10 @@ export default function AdminPage() {
</td> </td>
<td className="table-cell text-xs">{u.last_login_at?.slice(0,16) || '-'}</td> <td className="table-cell text-xs">{u.last_login_at?.slice(0,16) || '-'}</td>
<td className="table-cell"> <td className="table-cell">
<div className="flex items-center gap-3">
<button className={`text-xs hover:underline ${u.is_active?'text-red-600':'text-blue-600'}`} <button className={`text-xs hover:underline ${u.is_active?'text-red-600':'text-blue-600'}`}
onClick={() => toggleUser(u.id, u.is_active)}> onClick={() => toggleUser(u.id, u.is_active)}>
{u.is_active ? '비활성화' : '활성화'} {u.is_active ? '비활성화' : '활성화'}
</button> </button>
<button className="text-xs text-slate-500 hover:underline hover:text-blue-600"
onClick={() => resetOtp(u.id, u.username)} title="OTP 초기화(다음 로그인 재등록)">
OTP
</button>
</div>
</td> </td>
</tr> </tr>
))} ))}

View File

@ -1,10 +1,6 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import api from '../api/client' import api from '../api/client'
import { verify2fa, verifyOtp, signup, findId, resetPasswordHelper } from '../api/uiws'
type Helper = null | 'signup' | 'findId' | 'resetPw'
type VerifyMethod = 'OTP' | 'OTP_SETUP' | 'EMAIL'
export default function LoginPage() { export default function LoginPage() {
const nav = useNavigate() const nav = useNavigate()
@ -12,67 +8,17 @@ export default function LoginPage() {
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
// UIWS 2FA — 1차 통과 후 코드 입력 단계.
// · EMAIL : 이메일 인증코드(maskedEmail) → /verify (하위호환)
// · OTP : Authenticator 6자리 → /verify-otp
// · OTP_SETUP : 최초 로그인 — QR(qrImage)+수동키(secret) 등록 후 6자리 → /verify-otp
const [twofa, setTwofa] = useState<{
verifyToken: string; verifyMethod: VerifyMethod
maskedEmail: string; qrImage: string; secret: string
} | null>(null)
const [code, setCode] = useState('')
const isOtp = twofa?.verifyMethod === 'OTP' || twofa?.verifyMethod === 'OTP_SETUP'
const isSetup = twofa?.verifyMethod === 'OTP_SETUP'
// 로그인 보조 3종 모달
const [helper, setHelper] = useState<Helper>(null)
const afterAuthenticated = async () => {
const me = await api.get('/auth/me')
localStorage.setItem('hrm_user', JSON.stringify(me.data.data))
nav('/dashboard')
}
const login = async (e: React.FormEvent) => { const login = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setLoading(true); setError('') setLoading(true); setError('')
try { try {
const { data } = await api.post('/auth/login', form) const { data } = await api.post('/auth/login', form)
const payload = data.data || {}
if (payload.twofa === 'true') {
// 2FA 활성 — verifyMethod 로 분기(access 토큰 미발급). 시크릿/QR은 화면 표시용만.
setTwofa({
verifyToken: payload.verifyToken,
verifyMethod: (payload.verifyMethod as VerifyMethod) || 'EMAIL',
maskedEmail: payload.maskedEmail || '',
qrImage: payload.qrImage || '',
secret: payload.secret || '',
})
setCode('')
return
}
localStorage.setItem('hrm_token', payload.token)
await afterAuthenticated()
} catch (err: any) {
const msg = err?.response?.data?.message || ''
setError(msg.includes('잠겼') || msg.includes('승인')
? msg : '아이디 또는 비밀번호가 올바르지 않습니다.')
} finally { setLoading(false) }
}
const verify = async (e: React.FormEvent) => {
e.preventDefault()
if (!twofa) return
setLoading(true); setError('')
try {
// OTP·OTP_SETUP 은 /verify-otp, 이메일은 /verify(하위호환)
const { data } = isOtp
? await verifyOtp(twofa.verifyToken, code)
: await verify2fa(twofa.verifyToken, code)
localStorage.setItem('hrm_token', data.data.token) localStorage.setItem('hrm_token', data.data.token)
await afterAuthenticated() const me = await api.get('/auth/me')
localStorage.setItem('hrm_user', JSON.stringify(me.data.data))
nav('/dashboard')
} catch { } catch {
setError('인증 코드가 올바르지 않거나 만료되었습니다.') setError('아이디 또는 비밀번호가 올바르지 않습니다.')
} finally { setLoading(false) } } finally { setLoading(false) }
} }
@ -83,8 +29,6 @@ export default function LoginPage() {
<h1 className="text-3xl font-bold text-white">GUARDiA HRM</h1> <h1 className="text-3xl font-bold text-white">GUARDiA HRM</h1>
<p className="text-blue-300 mt-2">AI </p> <p className="text-blue-300 mt-2">AI </p>
</div> </div>
{!twofa ? (
<form onSubmit={login} className="bg-white rounded-2xl shadow-2xl p-8 space-y-5"> <form onSubmit={login} className="bg-white rounded-2xl shadow-2xl p-8 space-y-5">
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1"></label> <label className="block text-sm font-medium text-slate-700 mb-1"></label>
@ -105,132 +49,11 @@ export default function LoginPage() {
className="w-full py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"> className="w-full py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
{loading ? '로그인 중...' : '로그인'} {loading ? '로그인 중...' : '로그인'}
</button> </button>
{/* UIWS 로그인 보조 3종 */}
<div className="flex items-center justify-center gap-3 text-xs text-slate-500">
<button type="button" className="hover:text-blue-600" onClick={() => { setHelper('signup'); setError('') }}></button>
<span className="text-slate-300">|</span>
<button type="button" className="hover:text-blue-600" onClick={() => { setHelper('findId'); setError('') }}> </button>
<span className="text-slate-300">|</span>
<button type="button" className="hover:text-blue-600" onClick={() => { setHelper('resetPw'); setError('') }}> </button>
</div>
<p className="text-center text-xs text-slate-400"> <p className="text-center text-xs text-slate-400">
GUARDiA HRM v1.0 | AI GUARDiA HRM v1.0 | AI
</p> </p>
</form> </form>
) : (
<form onSubmit={verify} className="bg-white rounded-2xl shadow-2xl p-8 space-y-5">
<div className="text-center">
<p className="text-sm font-semibold text-slate-700">2 </p>
{isSetup ? (
<p className="text-xs text-slate-500 mt-1">
. QR을 Authenticator (Google·Microsoft)
6 .
</p>
) : isOtp ? (
<p className="text-xs text-slate-500 mt-1">Authenticator 6 .</p>
) : (
<p className="text-xs text-slate-500 mt-1">
{twofa.maskedEmail ? `${twofa.maskedEmail} 으로 발송된 인증 코드를 입력하세요.` : '이메일로 발송된 인증 코드를 입력하세요.'}
</p>
)}
</div> </div>
{isSetup && twofa.qrImage && (
<div className="flex justify-center">
<img src={twofa.qrImage} alt="OTP QR" width={180} height={180}
style={{ background: '#fff', padding: 8, borderRadius: 8, border: '1px solid #e2e8f0' }} />
</div>
)}
{isSetup && twofa.secret && (
<div>
<p className="text-[11px] text-slate-400 mb-1">QR </p>
<code className="block text-xs text-blue-700 bg-slate-50 border border-slate-200 rounded-md px-2 py-1.5 break-all select-all">
{twofa.secret}
</code>
</div>
)}
<input type="text" inputMode="numeric" value={code} autoFocus
autoComplete="one-time-code"
onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
className="w-full border border-slate-200 rounded-lg px-4 py-2.5 text-center tracking-widest text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="6자리 코드" maxLength={6} required />
{error && <p className="text-red-500 text-sm text-center">{error}</p>}
<button type="submit" disabled={loading || code.length !== 6}
className="w-full py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
{loading ? '확인 중...' : isSetup ? '등록하고 로그인' : '인증 확인'}
</button>
<button type="button" onClick={() => { setTwofa(null); setCode(''); setError('') }}
className="w-full text-xs text-slate-400 hover:text-slate-600"> </button>
</form>
)}
</div>
{helper && <HelperModal kind={helper} onClose={() => setHelper(null)} />}
</div>
)
}
/** 로그인 보조 3종 모달 — 회원가입 / 아이디찾기 / 비밀번호 초기화. */
function HelperModal({ kind, onClose }: { kind: Exclude<Helper, null>; onClose: () => void }) {
const [f, setF] = useState({ username: '', password: '', displayName: '', email: '' })
const [msg, setMsg] = useState('')
const [busy, setBusy] = useState(false)
const set = (k: string, v: string) => setF({ ...f, [k]: v })
const TITLE: Record<string, string> = { signup: '회원가입', findId: '아이디 찾기', resetPw: '비밀번호 초기화' }
const submit = async (e: React.FormEvent) => {
e.preventDefault()
setBusy(true); setMsg('')
try {
if (kind === 'signup') {
const { data } = await signup({ username: f.username, password: f.password, displayName: f.displayName, email: f.email })
setMsg(data.data?.message || '처리되었습니다.')
} else if (kind === 'findId') {
const { data } = await findId({ displayName: f.displayName, email: f.email })
setMsg(data.data?.found ? `회원님의 아이디: ${data.data.maskedUsername}` : '일치하는 계정을 찾을 수 없습니다.')
} else {
const { data } = await resetPasswordHelper({ username: f.username, email: f.email })
setMsg(data.data?.message || '처리되었습니다.')
}
} catch {
setMsg('요청 처리 중 오류가 발생했습니다.')
} finally { setBusy(false) }
}
const input = (k: string, ph: string, type = 'text') => (
<input type={type} value={(f as any)[k]} onChange={e => set(k, e.target.value)}
className="w-full border border-slate-200 rounded-lg px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={ph} required />
)
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50" onClick={onClose}>
<form onClick={e => e.stopPropagation()} onSubmit={submit}
className="bg-white rounded-2xl shadow-2xl p-7 w-full max-w-sm space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-slate-800">{TITLE[kind]}</h2>
<button type="button" onClick={onClose} className="text-slate-400 hover:text-slate-600"></button>
</div>
{kind === 'signup' && <>
{input('username', '아이디')}
{input('password', '비밀번호(4자 이상)', 'password')}
{input('displayName', '이름')}
{input('email', '이메일', 'email')}
</>}
{kind === 'findId' && <>
{input('displayName', '이름')}
{input('email', '이메일', 'email')}
</>}
{kind === 'resetPw' && <>
{input('username', '아이디')}
{input('email', '이메일', 'email')}
</>}
{msg && <p className="text-sm text-blue-700 bg-blue-50 rounded-lg px-3 py-2">{msg}</p>}
<button type="submit" disabled={busy}
className="w-full py-2.5 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
{busy ? '처리 중...' : '확인'}
</button>
</form>
</div> </div>
) )
} }

View File

@ -84,16 +84,22 @@ export default function ScheduleCalendar() {
) )
} }
// datetime-local(yyyy-MM-ddTHH:mm) → API 계약(yyyy-MM-ddTHH:mm:ss) 보정: 초 없으면 :00 부가
function toApiDt(v: string): string {
if (!v) return v
return v.length === 16 ? `${v}:00` : v
}
function ScheduleForm({ date, type, onClose, onSaved }: { date: string; type: string; onClose: () => void; onSaved: () => void }) { function ScheduleForm({ date, type, onClose, onSaved }: { date: string; type: string; onClose: () => void; onSaved: () => void }) {
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [startDt, setStartDt] = useState(`${date}T09:00:00`) const [startDt, setStartDt] = useState(`${date}T09:00`)
const [endDt, setEndDt] = useState(`${date}T18:00:00`) const [endDt, setEndDt] = useState(`${date}T18:00`)
const [content, setContent] = useState('') const [content, setContent] = useState('')
const [err, setErr] = useState('') const [err, setErr] = useState('')
const save = async () => { const save = async () => {
setErr('') setErr('')
try { try {
await createSchedule({ scheType: type, title, startDt, endDt, content, attachmentIds: [] }) await createSchedule({ scheType: type, title, startDt: toApiDt(startDt), endDt: toApiDt(endDt), content, attachmentIds: [] })
onSaved() onSaved()
} catch (e: any) { setErr(e?.response?.data?.message || '저장 실패') } } catch (e: any) { setErr(e?.response?.data?.message || '저장 실패') }
} }
@ -101,8 +107,8 @@ function ScheduleForm({ date, type, onClose, onSaved }: { date: string; type: st
<Modal title="일정 등록" onClose={onClose} <Modal title="일정 등록" onClose={onClose}
footer={<><Button variant="ghost" onClick={onClose}></Button><Button onClick={save}></Button></>}> footer={<><Button variant="ghost" onClick={onClose}></Button><Button onClick={save}></Button></>}>
<FormField label="제목"><Input value={title} onChange={setTitle} style={{ width: '100%' }} /></FormField> <FormField label="제목"><Input value={title} onChange={setTitle} style={{ width: '100%' }} /></FormField>
<FormField label="시작(yyyy-MM-ddTHH:mm:ss)"><Input value={startDt} onChange={setStartDt} style={{ width: '100%' }} /></FormField> <FormField label="시작"><Input type="datetime-local" value={startDt} onChange={setStartDt} style={{ width: '100%' }} /></FormField>
<FormField label="종료"><Input value={endDt} onChange={setEndDt} style={{ width: '100%' }} /></FormField> <FormField label="종료"><Input type="datetime-local" value={endDt} onChange={setEndDt} style={{ width: '100%' }} /></FormField>
<FormField label="내용"><Input value={content} onChange={setContent} style={{ width: '100%' }} /></FormField> <FormField label="내용"><Input value={content} onChange={setContent} style={{ width: '100%' }} /></FormField>
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>} {err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
</Modal> </Modal>