feat(ux): 날짜 입력 전면 캘린더 전환 — 웹 datetime-local/date/time + 모바일 순수JS CalendarPicker [auto-sync]
This commit is contained in:
parent
e299068832
commit
4e9436edbe
@ -42,17 +42,11 @@
|
||||
<!-- DB Driver -->
|
||||
<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 -->
|
||||
<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-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 -->
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
|
||||
@ -51,31 +51,6 @@ public class AdminController {
|
||||
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")
|
||||
public ApiResponse<Map<String, Object>> audit(
|
||||
@RequestParam(required = false) String actor,
|
||||
|
||||
@ -14,8 +14,6 @@ public interface AdminMapper {
|
||||
int insertUser(HrmUser user);
|
||||
int updateUser(HrmUser user);
|
||||
int updateUserActive(@Param("id") Long id, @Param("active") boolean active);
|
||||
/** 관리자 OTP 초기화: 시크릿 폐기 + otp_enabled=false(다음 로그인 시 재등록 유도). 시크릿 미노출. */
|
||||
int clearOtp(@Param("id") Long id);
|
||||
List<Map<String, Object>> findAuditLogs(@Param("actor") String actor,
|
||||
@Param("action") String action,
|
||||
@Param("offset") int offset,
|
||||
|
||||
@ -1,27 +1,37 @@
|
||||
package com.zioinfo.hrm.ai;
|
||||
|
||||
import com.zioinfo.hrm.ai.service.AiTextRouter;
|
||||
import com.zioinfo.hrm.common.ai.TextAiClient.GenResult;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AI 인사 분석 서비스. 텍스트 생성은 {@link AiTextRouter} 경유(provider 선택: Claude ↔ Ollama).
|
||||
* 외부 AI API 는 소유자 승인 예외인 Claude(api.anthropic.com) 만 허용, 그 외 온프레미스 Ollama 전용.
|
||||
* Claude/Ollama 응답 실패 시 Java 폴백 로직으로 대체(무회귀).
|
||||
* Ollama 온프레미스 AI 인사 분석 서비스.
|
||||
* 외부 AI API 절대 금지 — localhost:11434 만 사용.
|
||||
* Ollama 응답 실패 시 Java 폴백 로직으로 대체.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AiService {
|
||||
|
||||
/** provider 라우터(Claude→Ollama 폴백 + 추론 로그). 기존 Ollama 직접호출을 대체. */
|
||||
private final AiTextRouter aiTextRouter;
|
||||
@Value("${guardia.ollama-url:http://localhost:11434}")
|
||||
private String ollamaUrl;
|
||||
|
||||
@Value("${guardia.ollama-text-model:llama3}")
|
||||
private String textModel;
|
||||
|
||||
private WebClient ollamaClient() {
|
||||
return WebClient.builder().baseUrl(ollamaUrl)
|
||||
.codecs(c -> c.defaultCodecs().maxInMemorySize(4 * 1024 * 1024))
|
||||
.build();
|
||||
}
|
||||
|
||||
public Map<String, Object> predictTurnover(Long empId) {
|
||||
try {
|
||||
@ -107,12 +117,16 @@ public class AiService {
|
||||
}
|
||||
|
||||
private Map<String, Object> callOllama(String prompt) {
|
||||
// provider 라우터 경유: provider=claude·키설정·활성 → Claude, 실패/그외 → Ollama(선택모델) 폴백.
|
||||
GenResult gr = aiTextRouter.generate(prompt);
|
||||
if (gr.degraded() || gr.text() == null || gr.text().isBlank()) {
|
||||
return fallbackTurnover();
|
||||
}
|
||||
String response = gr.text();
|
||||
Map<String, Object> body = Map.of("model", textModel, "prompt", prompt, "stream", false);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> resp = ollamaClient().post().uri("/api/generate")
|
||||
.bodyValue(body)
|
||||
.retrieve()
|
||||
.bodyToMono(Map.class)
|
||||
.timeout(Duration.ofSeconds(30))
|
||||
.block();
|
||||
if (resp == null) return fallbackTurnover();
|
||||
String response = (String) resp.getOrDefault("response", "{}");
|
||||
// JSON 추출
|
||||
int start = response.indexOf('{');
|
||||
int end = response.lastIndexOf('}');
|
||||
@ -120,10 +134,10 @@ public class AiService {
|
||||
// 간단 파싱: 실제로는 Jackson 사용
|
||||
Map<String, Object> r = new HashMap<>();
|
||||
r.put("raw", response.substring(start, end + 1));
|
||||
r.put("source", "ai");
|
||||
r.put("source", "ollama");
|
||||
return r;
|
||||
}
|
||||
return Map.of("response", response, "source", "ai");
|
||||
return Map.of("response", response, "source", "ollama");
|
||||
}
|
||||
|
||||
private Map<String, Object> fallbackTurnover() {
|
||||
|
||||
@ -1,54 +1,22 @@
|
||||
package com.zioinfo.hrm.auth;
|
||||
|
||||
import com.zioinfo.hrm.auth.dto.ChangePasswordRequest;
|
||||
import com.zioinfo.hrm.auth.dto.OtpConfirmRequest;
|
||||
import com.zioinfo.hrm.auth.dto.OtpSetupResponse;
|
||||
import com.zioinfo.hrm.auth.dto.OtpVerifyRequest;
|
||||
import com.zioinfo.hrm.common.ApiResponse;
|
||||
import com.zioinfo.hrm.uiws.auth.OtpAuthService;
|
||||
import com.zioinfo.hrm.uiws.auth.TwoFactorService;
|
||||
import com.zioinfo.hrm.uiws.common.UiwsApiException;
|
||||
import com.zioinfo.hrm.uiws.common.UiwsErrorCode;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* HRM 인증 컨트롤러.
|
||||
* - /login: 2FA off 면 { token, type, twofa:"false" }.
|
||||
* OTP on 이면 { twofa:"true", verifyToken, verifyMethod:(OTP|OTP_SETUP), secret?, otpAuthUri?, qrImage? }.
|
||||
* 이메일 2FA on 이면 { twofa:"true", verifyToken, step, maskedEmail }.
|
||||
* - /verify-otp: (UIMS 방식) verify-token + 6자리 → access/refresh(최초 로그인이면 등록 확정).
|
||||
* - /verify: (이메일 2FA) verify-token + 인증코드 → access/refresh.
|
||||
* 기존 클라이언트(2FA off)는 응답에 token 보존 → 회귀 0.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/hrm/auth")
|
||||
@RequiredArgsConstructor
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final OtpAuthService otpAuthService;
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
@PostMapping("/login")
|
||||
public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) {
|
||||
return ApiResponse.ok(authService.login(req.username(), req.password()));
|
||||
}
|
||||
|
||||
/** 이메일 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()));
|
||||
String token = authService.login(req.username(), req.password());
|
||||
return ApiResponse.ok(Map.of("token", token, "type", "Bearer"));
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
@ -57,50 +25,5 @@ public class AuthController {
|
||||
return ApiResponse.ok(authService.me(token));
|
||||
}
|
||||
|
||||
// ── 마이페이지: OTP 등록/재설정/해제 + 비밀번호 변경 (본인, access 토큰 필요) ──────────
|
||||
|
||||
/** 마이페이지 OTP 등록/재설정 시작 → { secret, otpAuthUri, qrImage }(이 응답에서만 시크릿/QR 노출). */
|
||||
@PostMapping("/otp/setup")
|
||||
public ApiResponse<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 VerifyRequest(String verifyToken, String code) {}
|
||||
}
|
||||
|
||||
@ -1,35 +1,13 @@
|
||||
package com.zioinfo.hrm.auth;
|
||||
|
||||
import com.zioinfo.hrm.auth.dto.FindIdRequest;
|
||||
import com.zioinfo.hrm.auth.dto.FindIdResponse;
|
||||
import com.zioinfo.hrm.auth.dto.ResetPwRequest;
|
||||
import com.zioinfo.hrm.auth.dto.SignupRequest;
|
||||
import com.zioinfo.hrm.auth.dto.SignupResponse;
|
||||
import com.zioinfo.hrm.auth.dto.ChangePasswordRequest;
|
||||
import com.zioinfo.hrm.auth.mapper.UserMapper;
|
||||
import com.zioinfo.hrm.uiws.auth.OtpAuthService;
|
||||
import com.zioinfo.hrm.uiws.auth.TwoFactorService;
|
||||
import com.zioinfo.hrm.uiws.common.UiwsApiException;
|
||||
import com.zioinfo.hrm.uiws.common.UiwsErrorCode;
|
||||
import com.zioinfo.hrm.uiws.common.mail.MailSender;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* HRM 인증 서비스.
|
||||
* - 기존 단일 JWT 로그인 보존(2FA off 또는 미설정 시 회귀 0).
|
||||
* - UIWS 2FA 레이어(이메일 코드) 추가: 2FA on 이면 1차 통과 후 verify-token + 이메일코드 발급.
|
||||
* 실패 누적 max-login-fail 회 시 계정 잠금.
|
||||
* - UIWS 로그인 보조 3종(회원가입·아이디찾기·비밀번호 초기화) 이식.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuthService {
|
||||
@ -37,66 +15,16 @@ public class AuthService {
|
||||
private final UserMapper userMapper;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtUtil jwtUtil;
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final OtpAuthService otpAuthService;
|
||||
private final MailSender mailSender;
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
// 혼동 문자(0/O/1/l/I) 제외 — 임시비번 가독성.
|
||||
private static final String TMP_PW_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
|
||||
|
||||
/**
|
||||
* 1차 로그인. 2FA 활성 시 verify-token + 이메일코드 흐름으로 분기,
|
||||
* 비활성 시 기존처럼 access 토큰 즉시 발급.
|
||||
*
|
||||
* @return 2FA off: { token, type, twofa:"false" }
|
||||
* 2FA on : { verifyToken, step:"EMAIL", maskedEmail, twofa:"true" }
|
||||
*/
|
||||
public Map<String, String> login(String username, String password) {
|
||||
public String login(String username, String password) {
|
||||
HrmUser user = userMapper.findByUsername(username);
|
||||
|
||||
// 잠금 우선 차단(존재하는 계정에 한해 잠금 메시지 — 존재 여부 누설 최소화)
|
||||
if (user != null && twoFactorService.isLocked(user)) {
|
||||
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
|
||||
}
|
||||
if (user == null || !user.isActive()) {
|
||||
throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정");
|
||||
}
|
||||
// 회원가입 승인 게이트(UIWS 로그인 보조 이식): 가입 신청자(approved=false)는 관리자 승인 전 로그인 차단.
|
||||
// 기존 계정은 approved=true(91_uiws_port.sql 멱등 보정)라 회귀 없음.
|
||||
if (Boolean.FALSE.equals(user.getApproved())) {
|
||||
throw new RuntimeException("ERR-AUTH-003: 승인 대기 중인 계정입니다. 관리자 승인 후 로그인하세요.");
|
||||
}
|
||||
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
|
||||
// 2FA(OTP 또는 이메일) 활성 시 실패 누적/잠금. 비활성 시 기존 동작(메시지만) 유지.
|
||||
if (otpAuthService.isEnabled() || twoFactorService.isEnabled()) {
|
||||
twoFactorService.recordLoginFailure(username);
|
||||
HrmUser after = userMapper.findByUsername(username);
|
||||
if (after != null && Boolean.TRUE.equals(after.getLocked())) {
|
||||
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
|
||||
}
|
||||
}
|
||||
throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치");
|
||||
}
|
||||
|
||||
// 비밀번호 검증 통과 — 2단계 우선순위: OTP > 이메일코드 > 단일 로그인
|
||||
if (otpAuthService.isEnabled()) {
|
||||
// { twofa:true, verifyToken, verifyMethod:(OTP|OTP_SETUP), secret?, otpAuthUri?, qrImage? }
|
||||
return otpAuthService.beginOtp(user);
|
||||
}
|
||||
if (twoFactorService.isEnabled()) {
|
||||
Map<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");
|
||||
return jwtUtil.generate(username, user.getRole());
|
||||
}
|
||||
|
||||
public Map<String, Object> me(String token) {
|
||||
@ -109,122 +37,4 @@ public class AuthService {
|
||||
m.put("displayName", u != null ? u.getDisplayName() : username);
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* 마이페이지 비밀번호 변경(현재 비밀번호 검증 + BCrypt 저장). UIMS changePassword 미러.
|
||||
* 현재 비번 불일치 → PASSWORD_MISMATCH, 기존과 동일 → PASSWORD_SAME_AS_OLD.
|
||||
* 새 비밀번호 평문은 로그/응답/감사에 절대 기록하지 않는다(보안 불변규칙).
|
||||
*/
|
||||
@Transactional
|
||||
public void changePassword(String username, ChangePasswordRequest req) {
|
||||
HrmUser user = userMapper.findByUsername(username);
|
||||
if (user == null) {
|
||||
throw new UiwsApiException(UiwsErrorCode.USER_NOT_FOUND);
|
||||
}
|
||||
if (!passwordEncoder.matches(req.currentPassword(), user.getPasswordHash())) {
|
||||
throw new UiwsApiException(UiwsErrorCode.PASSWORD_MISMATCH);
|
||||
}
|
||||
if (passwordEncoder.matches(req.newPassword(), user.getPasswordHash())) {
|
||||
throw new UiwsApiException(UiwsErrorCode.PASSWORD_SAME_AS_OLD);
|
||||
}
|
||||
userMapper.updatePasswordByUsername(username, passwordEncoder.encode(req.newPassword()));
|
||||
log.info("[auth] password changed: username={}", username);
|
||||
}
|
||||
|
||||
// ── 로그인 보조 3종 (UIWS auth 패턴 이식) ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 운영자 회원가입(승인 대기 INSERT). username/email 중복 차단, 비번 BCrypt, approved=false.
|
||||
* 보안: 응답에 자격증명·임시정보 미포함, 일반 메시지만.
|
||||
*/
|
||||
public SignupResponse signup(SignupRequest req) {
|
||||
if (req.username() == null || req.username().isBlank()
|
||||
|| req.password() == null || req.password().length() < 4
|
||||
|| req.email() == null || req.email().isBlank()) {
|
||||
return new SignupResponse(false, "아이디·비밀번호(4자 이상)·이메일은 필수입니다.");
|
||||
}
|
||||
if (userMapper.countByUsername(req.username()) > 0) {
|
||||
return new SignupResponse(false, "이미 사용 중인 아이디입니다.");
|
||||
}
|
||||
if (userMapper.countByEmail(req.email()) > 0) {
|
||||
return new SignupResponse(false, "이미 등록된 이메일입니다.");
|
||||
}
|
||||
HrmUser u = new HrmUser();
|
||||
u.setUsername(req.username());
|
||||
u.setPasswordHash(passwordEncoder.encode(req.password()));
|
||||
u.setDisplayName(req.displayName() != null && !req.displayName().isBlank()
|
||||
? req.displayName() : req.username());
|
||||
u.setEmail(req.email());
|
||||
// insertSignup: role='VIEWER', approved=false, locked=false, login_fail_count=0 (XML 고정)
|
||||
userMapper.insertSignup(u);
|
||||
log.info("[auth-helper] signup pending approval: username={}", req.username());
|
||||
return new SignupResponse(true, "가입 신청이 접수되었습니다. 관리자 승인 후 로그인할 수 있습니다.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 아이디 찾기: 표시명+이메일 동시 일치 계정 1건 조회. username 은 부분 마스킹 후 반환.
|
||||
* 미발견 시 found=false(원문 username 절대 미노출).
|
||||
*/
|
||||
public FindIdResponse findId(FindIdRequest req) {
|
||||
if (req.displayName() == null || req.displayName().isBlank()
|
||||
|| req.email() == null || req.email().isBlank()) {
|
||||
return new FindIdResponse(false, "");
|
||||
}
|
||||
HrmUser u = userMapper.findByDisplayNameAndEmail(req.displayName(), req.email());
|
||||
if (u == null) {
|
||||
return new FindIdResponse(false, "");
|
||||
}
|
||||
return new FindIdResponse(true, maskUsername(u.getUsername()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 초기화: username+email 일치 검증 → 임시비번 생성·BCrypt 저장·잠금/실패카운트 해제·변경유도.
|
||||
* 임시비번은 메일(미설정 시 LogMailSender 로그)로만 전달. API 응답·로그 메시지에 비번 미노출.
|
||||
* 대상 미존재여도 success=true(계정 열거 방지).
|
||||
*/
|
||||
@Transactional
|
||||
public SignupResponse resetPassword(ResetPwRequest req) {
|
||||
final String okMsg = "임시 비밀번호를 등록된 이메일로 발송했습니다. 메일을 확인하세요.";
|
||||
if (req.username() == null || req.username().isBlank()
|
||||
|| req.email() == null || req.email().isBlank()) {
|
||||
return new SignupResponse(false, "아이디와 이메일을 모두 입력하세요.");
|
||||
}
|
||||
HrmUser u = userMapper.findByUsernameAndEmail(req.username(), req.email());
|
||||
if (u == null) {
|
||||
// 존재 여부 누설 방지 — 동일 성공 메시지 반환(실제 발송 없음).
|
||||
log.info("[auth-helper] reset-password no match (suppressed): username={}", req.username());
|
||||
return new SignupResponse(true, okMsg);
|
||||
}
|
||||
String tempPw = generateTempPassword();
|
||||
// applyTempPassword: BCrypt 저장 + pw_change_yn=true + locked=false + login_fail_count=0 (멱등)
|
||||
userMapper.applyTempPassword(req.username(), passwordEncoder.encode(tempPw));
|
||||
|
||||
String subject = "[GUARDiA HRM] 임시 비밀번호 안내";
|
||||
String body = String.format(
|
||||
"안녕하세요 %s 님,\n임시 비밀번호는 [%s] 입니다.\n로그인 후 즉시 비밀번호를 변경하세요.",
|
||||
u.getDisplayName() != null ? u.getDisplayName() : u.getUsername(), tempPw);
|
||||
// 메일 본문에만 임시비번 포함. mailSender 미설정 환경은 LogMailSender 폴백(서버 로그).
|
||||
mailSender.send(u.getEmail(), subject, body);
|
||||
log.info("[auth-helper] reset-password issued temp pw (sent via mail/log): username={}", req.username());
|
||||
return new SignupResponse(true, okMsg);
|
||||
}
|
||||
|
||||
private static String generateTempPassword() {
|
||||
StringBuilder sb = new StringBuilder(10);
|
||||
for (int i = 0; i < 10; i++) {
|
||||
sb.append(TMP_PW_CHARS.charAt(RANDOM.nextInt(TMP_PW_CHARS.length())));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** username 부분 마스킹: 앞 2자만 노출(예: admin → ad***). 2자 이하는 첫 글자만. */
|
||||
private static String maskUsername(String username) {
|
||||
if (username == null || username.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
if (username.length() <= 2) {
|
||||
return username.charAt(0) + "*";
|
||||
}
|
||||
return username.substring(0, 2) + "*".repeat(Math.max(1, username.length() - 2));
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,20 +17,4 @@ public class HrmUser {
|
||||
private String role;
|
||||
private boolean active;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
// UIWS 2FA 이식 컬럼 (멱등 ALTER — db/91_uiws_port.sql).
|
||||
private String emailVerifyCode;
|
||||
private LocalDateTime emailVerifyExpire;
|
||||
private Integer loginFailCount;
|
||||
private Boolean locked;
|
||||
/** TOTP 시크릿(보류/확정 공용). API 응답·로그에 절대 미노출. (db/91_uiws_port.sql ALTER) */
|
||||
private String otpSecret;
|
||||
/** OTP 등록 확정 여부(기본 false). 최초 로그인 verify 성공 시 true 로 확정(멱등). (db/93_auth_otp.sql ALTER) */
|
||||
private Boolean otpEnabled;
|
||||
|
||||
// UIWS 로그인 보조기능 이식 컬럼 (멱등 ALTER — db/91_uiws_port.sql).
|
||||
// approved: 가입 신청자는 false(관리자 승인 전 로그인 차단), 기존 계정은 true 보정.
|
||||
// pwChangeYn: 임시비번 발급 시 true(다음 로그인 시 변경 유도).
|
||||
private Boolean approved;
|
||||
private Boolean pwChangeYn;
|
||||
}
|
||||
|
||||
@ -26,10 +26,7 @@ public class JwtFilter extends OncePerRequestFilter {
|
||||
String header = req.getHeader("Authorization");
|
||||
if (header != null && header.startsWith("Bearer ")) {
|
||||
String token = header.substring(7);
|
||||
// 보안(UIWS 2FA): purpose=2fa verify-token 은 access 토큰이 아니다.
|
||||
// 동일 서명키라 isValid()는 통과하므로 별도 차단하지 않으면 2차 인증 전 보호 API 접근(2FA 우회)이 가능.
|
||||
// → verify-token 은 인증 컨텍스트를 세우지 않고 무시한다(/api/hrm/auth/verify 에서만 사용).
|
||||
if (jwtUtil.isValid(token) && !jwtUtil.isVerifyToken(token)) {
|
||||
if (jwtUtil.isValid(token)) {
|
||||
String username = jwtUtil.getUsername(token);
|
||||
String role = jwtUtil.getRole(token);
|
||||
var auth = new UsernamePasswordAuthenticationToken(
|
||||
|
||||
@ -34,47 +34,6 @@ public class JwtUtil {
|
||||
.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* UIWS 2FA 이식: 1차 로그인 통과 후 발급하는 단기 verify-token.
|
||||
* purpose=2fa 클레임으로 access 토큰과 구분(verify-token 으로는 보호 API 접근 불가).
|
||||
*/
|
||||
public String generateVerifyToken(String username, long validitySeconds) {
|
||||
return Jwts.builder()
|
||||
.subject(username)
|
||||
.claim("purpose", "2fa")
|
||||
.issuedAt(new Date())
|
||||
.expiration(new Date(System.currentTimeMillis() + validitySeconds * 1000L))
|
||||
.signWith(key())
|
||||
.compact();
|
||||
}
|
||||
|
||||
/** verify-token 검증 후 username 반환. 유효하지 않거나 purpose!=2fa 면 null. */
|
||||
public String parseVerifyTokenUsername(String token) {
|
||||
try {
|
||||
Claims c = parse(token);
|
||||
if (!"2fa".equals(c.get("purpose", String.class))) {
|
||||
return null;
|
||||
}
|
||||
return c.getSubject();
|
||||
} catch (JwtException | IllegalArgumentException e) {
|
||||
log.debug("verify-token 검증 실패: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 보안: 토큰이 2FA verify-token(purpose=2fa)인지 판별.
|
||||
* JwtFilter 가 access 토큰만 인증 컨텍스트로 인정하도록 verify-token 을 걸러내는 데 사용
|
||||
* (2차 인증 전 verify-token 으로 보호 API 에 접근하는 2FA 우회 차단).
|
||||
*/
|
||||
public boolean isVerifyToken(String token) {
|
||||
try {
|
||||
return "2fa".equals(parse(token).get("purpose", String.class));
|
||||
} catch (JwtException | IllegalArgumentException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public Claims parse(String token) {
|
||||
return Jwts.parser().verifyWith(key()).build()
|
||||
.parseSignedClaims(token).getPayload();
|
||||
|
||||
@ -3,123 +3,9 @@ package com.zioinfo.hrm.auth.mapper;
|
||||
import com.zioinfo.hrm.auth.HrmUser;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* HRM 운영자 계정 매퍼.
|
||||
* 기존 메서드(findByUsername/insert)에 UIWS 2FA·로그인 보조기능 이식 메서드를 추가.
|
||||
* 대상 테이블 hrm_users (컬럼 active — pms_user.is_active 와 다름).
|
||||
*/
|
||||
@Mapper
|
||||
public interface UserMapper {
|
||||
|
||||
HrmUser findByUsername(@Param("username") String username);
|
||||
|
||||
int insert(HrmUser user);
|
||||
|
||||
/** 회원가입(승인 대기): username·password_hash·display_name·email + role='VIEWER'·approved=false. */
|
||||
int insertSignup(HrmUser user);
|
||||
|
||||
// ── UIWS 로그인 보조기능 이식(회원가입·아이디찾기·비번초기화) ───────────────
|
||||
|
||||
/** 가입 중복 검사: username 존재 여부. */
|
||||
@Select("SELECT COUNT(1) FROM hrm_users WHERE username = #{username}")
|
||||
int countByUsername(@Param("username") String username);
|
||||
|
||||
/** 가입 중복 검사: email 존재 여부(enumeration 최소화 — 내부 사용). */
|
||||
@Select("SELECT COUNT(1) FROM hrm_users WHERE email = #{email}")
|
||||
int countByEmail(@Param("email") String email);
|
||||
|
||||
/** 아이디찾기: 이름(display_name)·이메일 일치 사용자(존재 시 마스킹 반환). */
|
||||
@Select("""
|
||||
SELECT id, username, password_hash, role, active, created_at,
|
||||
display_name, email,
|
||||
email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret,
|
||||
approved, pw_change_yn
|
||||
FROM hrm_users
|
||||
WHERE display_name = #{displayName} AND email = #{email}
|
||||
ORDER BY id LIMIT 1
|
||||
""")
|
||||
HrmUser findByDisplayNameAndEmail(@Param("displayName") String displayName,
|
||||
@Param("email") String email);
|
||||
|
||||
/** 비번초기화: username·email 동시 일치 검증용. */
|
||||
@Select("""
|
||||
SELECT id, username, password_hash, role, active, created_at,
|
||||
display_name, email,
|
||||
email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret,
|
||||
approved, pw_change_yn
|
||||
FROM hrm_users
|
||||
WHERE username = #{username} AND email = #{email}
|
||||
""")
|
||||
HrmUser findByUsernameAndEmail(@Param("username") String username,
|
||||
@Param("email") String email);
|
||||
|
||||
/** 비번초기화: 임시비번 적용 + 변경유도 + 잠금/실패카운트 해제(멱등 UPDATE). */
|
||||
@Update("""
|
||||
UPDATE hrm_users
|
||||
SET password_hash = #{passwordHash}, pw_change_yn = true,
|
||||
locked = false, login_fail_count = 0
|
||||
WHERE username = #{username}
|
||||
""")
|
||||
int applyTempPassword(@Param("username") String username,
|
||||
@Param("passwordHash") String passwordHash);
|
||||
|
||||
// ── UIWS 2FA 이식: 실패 카운트/잠금/인증코드 갱신 (멱등 UPDATE) ───────────────
|
||||
|
||||
/** 로그인 성공 시 실패 카운트 초기화. */
|
||||
@Update("UPDATE hrm_users SET login_fail_count = 0 WHERE username = #{username}")
|
||||
int resetLoginFail(@Param("username") String username);
|
||||
|
||||
/** 로그인 실패 누적(+1) 및 임계 도달 시 잠금. */
|
||||
@Update("""
|
||||
UPDATE hrm_users
|
||||
SET login_fail_count = COALESCE(login_fail_count, 0) + 1,
|
||||
locked = (COALESCE(login_fail_count, 0) + 1 >= #{maxFail})
|
||||
WHERE username = #{username}
|
||||
""")
|
||||
int incrementLoginFail(@Param("username") String username, @Param("maxFail") int maxFail);
|
||||
|
||||
/** 1차 통과 시 이메일 인증코드/만료 저장(verify 단계에서 검증). */
|
||||
@Update("""
|
||||
UPDATE hrm_users
|
||||
SET email_verify_code = #{code}, email_verify_expire = #{expire}, login_fail_count = 0
|
||||
WHERE username = #{username}
|
||||
""")
|
||||
int saveEmailCode(@Param("username") String username,
|
||||
@Param("code") String code,
|
||||
@Param("expire") LocalDateTime expire);
|
||||
|
||||
/** 2차 검증 성공 시 코드 폐기. */
|
||||
@Update("UPDATE hrm_users SET email_verify_code = NULL, email_verify_expire = NULL WHERE username = #{username}")
|
||||
int clearEmailCode(@Param("username") String username);
|
||||
|
||||
/** 관리자 잠금 해제(실패 카운트/잠금 초기화). */
|
||||
@Update("UPDATE hrm_users SET locked = false, login_fail_count = 0 WHERE username = #{username}")
|
||||
int unlock(@Param("username") String username);
|
||||
|
||||
/**
|
||||
* BCrypt 비밀번호 해시 갱신(username 기준). 평문은 절대 저장하지 않는다.
|
||||
* 마이페이지 비밀번호 변경 + AdminPasswordSeeder(env 재시드) 공용.
|
||||
*/
|
||||
@Update("UPDATE hrm_users SET password_hash = #{passwordHash} WHERE username = #{username}")
|
||||
int updatePasswordByUsername(@Param("username") String username,
|
||||
@Param("passwordHash") String passwordHash);
|
||||
|
||||
// ── TOTP(OTP 2차 인증) 이식 (멱등 UPDATE) ────────────────────────────────────
|
||||
|
||||
/** 보류 시크릿 저장(등록 확정 전). otp_enabled 은 건드리지 않는다(verify/confirm 에서 확정). */
|
||||
@Update("UPDATE hrm_users SET otp_secret = #{secret} WHERE username = #{username}")
|
||||
int updateOtpSecret(@Param("username") String username, @Param("secret") String secret);
|
||||
|
||||
/** 등록 확정: otp_enabled=true (시크릿은 유지). */
|
||||
@Update("UPDATE hrm_users SET otp_enabled = true WHERE username = #{username}")
|
||||
int enableOtp(@Param("username") String username);
|
||||
|
||||
/** 마이페이지 해제: 시크릿 폐기 + otp_enabled=false. */
|
||||
@Update("UPDATE hrm_users SET otp_secret = NULL, otp_enabled = false WHERE username = #{username}")
|
||||
int disableOtp(@Param("username") String username);
|
||||
}
|
||||
|
||||
@ -41,18 +41,10 @@ public class SecurityConfig {
|
||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/hrm/auth/**").permitAll()
|
||||
// UIWS 로그인 보조 3종(회원가입·아이디찾기·비번초기화) — 로그인 전 무인증 접근.
|
||||
.requestMatchers("/api/auth/**").permitAll()
|
||||
// UIWS system: 가입 화면 공개 조회(부서/회사 GET) — permitAll.
|
||||
.requestMatchers(HttpMethod.GET, "/api/public/**").permitAll()
|
||||
.requestMatchers("/actuator/health").permitAll()
|
||||
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api/hrm/docs/**", "/api/hrm/swagger/**").permitAll()
|
||||
.requestMatchers("/", "/index.html", "/assets/**", "/favicon.ico").permitAll()
|
||||
|
||||
// UIWS system(권한관리): 조회는 MANAGER 이상, 변경은 SUPERADMIN.
|
||||
.requestMatchers(HttpMethod.GET, "/api/system/**").hasAnyRole("SUPERADMIN", "MANAGER")
|
||||
.requestMatchers("/api/system/**").hasRole("SUPERADMIN")
|
||||
|
||||
.requestMatchers("/api/hrm/admin/users/**").hasRole("SUPERADMIN")
|
||||
.requestMatchers(HttpMethod.GET, "/api/hrm/admin/settings").hasAnyRole("SUPERADMIN", "MANAGER")
|
||||
.requestMatchers("/api/hrm/admin/settings/**").hasRole("SUPERADMIN")
|
||||
|
||||
@ -15,14 +15,8 @@ spring:
|
||||
minimum-idle: 1
|
||||
sql:
|
||||
init:
|
||||
# schema.sql(전부 IF NOT EXISTS + ON CONFLICT DO NOTHING)·UIWS 이식 파일(전부 멱등)만 재적용.
|
||||
# 91_uiws_port.sql: 업무 모듈 tb_uiws_* + hrm_users 2FA/로그인보조 컬럼 ALTER(멱등).
|
||||
# 92_uiws_system.sql: UIWS system(권한관리) tb_uiws_* 10테이블(부서/회사/코드/사용자/역할/메뉴/프로그램, 멱등).
|
||||
mode: ${SQL_INIT_MODE:always}
|
||||
# 104_seed_ai_config.sql: AI 플랫폼(provider/모델) 설정 시드 hrm_settings ai.* (멱등 ON CONFLICT DO NOTHING).
|
||||
# 93_auth_otp.sql: hrm_users otp_enabled 멱등 ALTER(TOTP 2차 인증). ops_otp_reset_all.sql 은 미등재(운영 1회 수동).
|
||||
schema-locations: classpath:db/schema.sql,classpath:db/91_uiws_port.sql,classpath:db/92_uiws_system.sql,classpath:db/93_auth_otp.sql,classpath:db/104_seed_ai_config.sql
|
||||
continue-on-error: true
|
||||
mode: ${SQL_INIT_MODE:never}
|
||||
schema-locations: classpath:db/schema.sql
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 20MB
|
||||
@ -44,38 +38,15 @@ guardia:
|
||||
erp-url: ${ERP_URL:http://localhost:8003}
|
||||
itsm-url: ${ITSM_URL:http://localhost:9001}
|
||||
groupware-url: ${GROUPWARE_URL:http://localhost:8009}
|
||||
# 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지(Claude 는 소유자 승인 예외).
|
||||
# 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지.
|
||||
ollama-url: ${OLLAMA_URL:http://localhost:11434}
|
||||
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3}
|
||||
# 중앙 guardia-rag 연동(피드백 전달). base-url 은 서버 내부 루프백 전용 — 외부 URL 금지.
|
||||
rag:
|
||||
base-url: ${RAG_URL:http://127.0.0.1:8020}
|
||||
enabled: ${RAG_ENABLED:true}
|
||||
# 로컬 임베디드 DuckDB 학습 저장소 파일 경로(솔루션별 격리).
|
||||
learning:
|
||||
duckdb-path: ${HRM_LEARNING_DUCKDB:/opt/guardia-hrm/data/hrm_learning.duckdb}
|
||||
crypto:
|
||||
secret: ${HRM_CRYPTO_SECRET:guardia-hrm-aes-256-gcm-master-key-2026-zioinfo}
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:guardia-hrm-jwt-secret-2026-minimum-256bit-key-zioinfo}
|
||||
expiration: 86400000
|
||||
|
||||
# UIWS 이식 모듈 설정 (worklog·schedule·message·stats + 2FA 레이어 + 로그인 보조).
|
||||
# 2FA 토글: hrm.uiws.auth.twofa-enabled=false 면 기존 단일 JWT 로그인 회귀 0.
|
||||
hrm:
|
||||
uiws:
|
||||
auth:
|
||||
twofa-enabled: ${UIWS_2FA:true}
|
||||
# TOTP(OTP 2차 인증, UIMS 방식) on/off. 기본 on. 우선순위: otp → 이메일코드 → 단일. off 시 이메일/단일 경로로 폴백.
|
||||
otp-enabled: ${UIWS_OTP:true}
|
||||
verify-token-validity-seconds: 300 # verify-token 5분
|
||||
email-code-validity-seconds: 300 # 이메일 코드 5분
|
||||
max-login-fail: 5 # 실패 5회 잠금
|
||||
mail:
|
||||
mode: ${UIWS_MAIL_MODE:log} # log 폴백(외부 API 금지). smtp 운영 시 별도 구현
|
||||
upload:
|
||||
upload-dir: ${UIWS_UPLOAD_DIR:./uploads/uiws}
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.zioinfo.hrm: DEBUG
|
||||
|
||||
@ -39,10 +39,6 @@
|
||||
UPDATE hrm_users SET active=#{active} WHERE id=#{id}
|
||||
</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, actor, action, target_type, target_id, detail, ip_addr, created_at
|
||||
FROM hrm_audit_log
|
||||
|
||||
@ -12,22 +12,10 @@
|
||||
<result property="role" column="role"/>
|
||||
<result property="active" column="active"/>
|
||||
<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>
|
||||
|
||||
<select id="findByUsername" resultMap="UserRM">
|
||||
SELECT id, username, password_hash, display_name, email, role, active, created_at,
|
||||
email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled,
|
||||
approved, pw_change_yn
|
||||
SELECT id, username, password_hash, display_name, email, role, active, created_at
|
||||
FROM hrm_users WHERE username = #{username}
|
||||
</select>
|
||||
|
||||
@ -36,13 +24,4 @@
|
||||
VALUES (#{username}, #{passwordHash}, #{displayName}, #{email}, #{role}, #{active})
|
||||
</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>
|
||||
|
||||
53
backend/target/classes/application.yml
Normal file
53
backend/target/classes/application.yml
Normal 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
|
||||
BIN
backend/target/classes/com/zioinfo/hrm/HrmApplication.class
Normal file
BIN
backend/target/classes/com/zioinfo/hrm/HrmApplication.class
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/target/classes/com/zioinfo/hrm/admin/AdminMapper.class
Normal file
BIN
backend/target/classes/com/zioinfo/hrm/admin/AdminMapper.class
Normal file
Binary file not shown.
BIN
backend/target/classes/com/zioinfo/hrm/ai/AiController.class
Normal file
BIN
backend/target/classes/com/zioinfo/hrm/ai/AiController.class
Normal file
Binary file not shown.
BIN
backend/target/classes/com/zioinfo/hrm/ai/AiService.class
Normal file
BIN
backend/target/classes/com/zioinfo/hrm/ai/AiService.class
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
backend/target/classes/com/zioinfo/hrm/auth/AuthController.class
Normal file
BIN
backend/target/classes/com/zioinfo/hrm/auth/AuthController.class
Normal file
Binary file not shown.
BIN
backend/target/classes/com/zioinfo/hrm/auth/AuthService.class
Normal file
BIN
backend/target/classes/com/zioinfo/hrm/auth/AuthService.class
Normal file
Binary file not shown.
BIN
backend/target/classes/com/zioinfo/hrm/auth/HrmUser.class
Normal file
BIN
backend/target/classes/com/zioinfo/hrm/auth/HrmUser.class
Normal file
Binary file not shown.
BIN
backend/target/classes/com/zioinfo/hrm/auth/JwtFilter.class
Normal file
BIN
backend/target/classes/com/zioinfo/hrm/auth/JwtFilter.class
Normal file
Binary file not shown.
BIN
backend/target/classes/com/zioinfo/hrm/auth/JwtUtil.class
Normal file
BIN
backend/target/classes/com/zioinfo/hrm/auth/JwtUtil.class
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/target/classes/com/zioinfo/hrm/common/ApiResponse.class
Normal file
BIN
backend/target/classes/com/zioinfo/hrm/common/ApiResponse.class
Normal file
Binary file not shown.
BIN
backend/target/classes/com/zioinfo/hrm/common/AuthSupport.class
Normal file
BIN
backend/target/classes/com/zioinfo/hrm/common/AuthSupport.class
Normal file
Binary file not shown.
BIN
backend/target/classes/com/zioinfo/hrm/common/CryptoUtil.class
Normal file
BIN
backend/target/classes/com/zioinfo/hrm/common/CryptoUtil.class
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
backend/target/classes/com/zioinfo/hrm/employee/Employee.class
Normal file
BIN
backend/target/classes/com/zioinfo/hrm/employee/Employee.class
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
515
backend/target/classes/db/schema.sql
Normal file
515
backend/target/classes/db/schema.sql
Normal 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;
|
||||
80
backend/target/classes/mapper/AdminMapper.xml
Normal file
80
backend/target/classes/mapper/AdminMapper.xml
Normal 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>
|
||||
99
backend/target/classes/mapper/AttendanceMapper.xml
Normal file
99
backend/target/classes/mapper/AttendanceMapper.xml
Normal 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>
|
||||
71
backend/target/classes/mapper/DashboardMapper.xml
Normal file
71
backend/target/classes/mapper/DashboardMapper.xml
Normal 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 <= 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>
|
||||
129
backend/target/classes/mapper/EmployeeMapper.xml
Normal file
129
backend/target/classes/mapper/EmployeeMapper.xml
Normal 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>
|
||||
54
backend/target/classes/mapper/OrgMapper.xml
Normal file
54
backend/target/classes/mapper/OrgMapper.xml
Normal 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>
|
||||
85
backend/target/classes/mapper/PayrollMapper.xml
Normal file
85
backend/target/classes/mapper/PayrollMapper.xml
Normal 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>
|
||||
86
backend/target/classes/mapper/PerformanceMapper.xml
Normal file
86
backend/target/classes/mapper/PerformanceMapper.xml
Normal 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>
|
||||
104
backend/target/classes/mapper/RecruitmentMapper.xml
Normal file
104
backend/target/classes/mapper/RecruitmentMapper.xml
Normal 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>
|
||||
88
backend/target/classes/mapper/TrainingMapper.xml
Normal file
88
backend/target/classes/mapper/TrainingMapper.xml
Normal 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>
|
||||
27
backend/target/classes/mapper/UserMapper.xml
Normal file
27
backend/target/classes/mapper/UserMapper.xml
Normal 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>
|
||||
@ -2,7 +2,7 @@
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<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" />
|
||||
<title>GUARDiA HRM — AI 인사관리 플랫폼</title>
|
||||
</head>
|
||||
|
||||
@ -10,21 +10,7 @@ import PerformancePage from './pages/PerformancePage'
|
||||
import RecruitmentPage from './pages/RecruitmentPage'
|
||||
import TrainingPage from './pages/TrainingPage'
|
||||
import AiPage from './pages/AiPage'
|
||||
import WiseAiPage from './pages/WiseAiPage'
|
||||
import AiPlatformSettings from './pages/AiPlatformSettings'
|
||||
import AdminPage from './pages/AdminPage'
|
||||
import MyPage from './pages/MyPage'
|
||||
import MobileApp from './pages/MobileApp'
|
||||
// UIWS(UIMS) 이식 — 공통 업무협업 화면(업무일지·일정·쪽지·통계)
|
||||
import WorklogList from './pages/uiws/WorklogList'
|
||||
import ScheduleCalendar from './pages/uiws/ScheduleCalendar'
|
||||
import MessageBox from './pages/uiws/MessageBox'
|
||||
import StatsPivot from './pages/uiws/StatsPivot'
|
||||
// UIWS system(권한관리) 이식 — 시스템관리(권한) 화면 셸(탭: 권한/역할-메뉴/공통코드/메뉴/부서/거래처)
|
||||
import SystemPage from './pages/uiws/system/SystemPage'
|
||||
|
||||
// 시스템관리(권한)는 관리자 전용. 백엔드 /api/system/** 조회=SUPERADMIN/MANAGER, 쓰기=SUPERADMIN.
|
||||
const ADMIN_ROLES = ['SUPERADMIN', 'MANAGER', 'ADMIN']
|
||||
|
||||
const MENU = [
|
||||
{ path: '/dashboard', label: '대시보드', icon: '📊' },
|
||||
@ -35,25 +21,13 @@ const MENU = [
|
||||
{ path: '/performance', label: '성과평가', icon: '🎯' },
|
||||
{ path: '/recruitment', label: '채용관리', icon: '🔍' },
|
||||
{ path: '/training', label: '교육관리', icon: '📚' },
|
||||
// UIWS 이식 — 업무 협업
|
||||
{ path: '/worklogs', label: '업무일지', icon: '📝' },
|
||||
{ path: '/schedules', label: '일정관리', icon: '📅' },
|
||||
{ path: '/messages', label: '쪽지함', icon: '✉️' },
|
||||
{ path: '/work-stats', label: '업무통계', icon: '📈' },
|
||||
{ path: '/ai', label: 'AI 인사분석', icon: '🤖' },
|
||||
{ path: '/wise-ai', label: 'WISE AI', icon: '✨' },
|
||||
{ path: '/ai-platform', label: 'AI 플랫폼 설정', icon: '🧠', adminOnly: true },
|
||||
{ path: '/admin', label: '시스템관리', icon: '⚙️' },
|
||||
// UIWS system 이식 — 권한·코드·메뉴·부서·거래처 (관리자 전용)
|
||||
{ path: '/system', label: '시스템관리(권한)', icon: '🔐', adminOnly: true },
|
||||
{ path: '/mobile-app', label: '모바일 앱', icon: '📱' },
|
||||
]
|
||||
|
||||
function Layout({ children }: { children: React.ReactNode }) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const user = JSON.parse(localStorage.getItem('hrm_user') || '{"displayName":"관리자","role":"SUPERADMIN"}')
|
||||
const isAdmin = ADMIN_ROLES.includes(user.role)
|
||||
const visibleMenu = MENU.filter(m => !m.adminOnly || isAdmin)
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
@ -71,7 +45,7 @@ function Layout({ children }: { children: React.ReactNode }) {
|
||||
</button>
|
||||
</div>
|
||||
<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}
|
||||
className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`}>
|
||||
<span className="text-base">{m.icon}</span>
|
||||
@ -92,13 +66,10 @@ function Layout({ children }: { children: React.ReactNode }) {
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<NavLink to="/mypage" className="text-xs text-slate-400 hover:text-white">마이페이지</NavLink>
|
||||
<button className="text-xs text-slate-400 hover:text-white"
|
||||
<button className="mt-3 w-full text-xs text-slate-400 hover:text-white"
|
||||
onClick={() => { localStorage.clear(); window.location.href = '/login'; }}>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
@ -116,15 +87,6 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
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() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
@ -139,23 +101,8 @@ export default function App() {
|
||||
<Route path="/performance" element={<PrivateRoute><PerformancePage /></PrivateRoute>} />
|
||||
<Route path="/recruitment" element={<PrivateRoute><RecruitmentPage /></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>} />
|
||||
{/* 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>} />
|
||||
{/* 마이페이지 — 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 />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@ -20,30 +20,3 @@ api.interceptors.response.use(
|
||||
)
|
||||
|
||||
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`)
|
||||
|
||||
@ -40,45 +40,3 @@ body {
|
||||
.table-cell {
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import api, { adminOtpReset } from '../api/client'
|
||||
import api from '../api/client'
|
||||
|
||||
export default function AdminPage() {
|
||||
const [users, setUsers] = useState<any[]>([])
|
||||
@ -28,21 +28,6 @@ export default function AdminPage() {
|
||||
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) => {
|
||||
await api.put(`/admin/settings/${key}`, null, { params: { value } })
|
||||
loadSettings()
|
||||
@ -94,16 +79,10 @@ export default function AdminPage() {
|
||||
</td>
|
||||
<td className="table-cell text-xs">{u.last_login_at?.slice(0,16) || '-'}</td>
|
||||
<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'}`}
|
||||
onClick={() => toggleUser(u.id, u.is_active)}>
|
||||
{u.is_active ? '비활성화' : '활성화'}
|
||||
</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>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@ -1,10 +1,6 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
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() {
|
||||
const nav = useNavigate()
|
||||
@ -12,67 +8,17 @@ export default function LoginPage() {
|
||||
const [error, setError] = useState('')
|
||||
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) => {
|
||||
e.preventDefault()
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
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)
|
||||
await afterAuthenticated()
|
||||
const me = await api.get('/auth/me')
|
||||
localStorage.setItem('hrm_user', JSON.stringify(me.data.data))
|
||||
nav('/dashboard')
|
||||
} catch {
|
||||
setError('인증 코드가 올바르지 않거나 만료되었습니다.')
|
||||
setError('아이디 또는 비밀번호가 올바르지 않습니다.')
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
@ -83,8 +29,6 @@ export default function LoginPage() {
|
||||
<h1 className="text-3xl font-bold text-white">GUARDiA HRM</h1>
|
||||
<p className="text-blue-300 mt-2">AI 기반 통합 인사관리 플랫폼</p>
|
||||
</div>
|
||||
|
||||
{!twofa ? (
|
||||
<form onSubmit={login} className="bg-white rounded-2xl shadow-2xl p-8 space-y-5">
|
||||
<div>
|
||||
<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">
|
||||
{loading ? '로그인 중...' : '로그인'}
|
||||
</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">
|
||||
GUARDiA HRM v1.0 | 온프레미스 AI 인사관리
|
||||
</p>
|
||||
</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>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 }) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [startDt, setStartDt] = useState(`${date}T09:00:00`)
|
||||
const [endDt, setEndDt] = useState(`${date}T18:00:00`)
|
||||
const [startDt, setStartDt] = useState(`${date}T09:00`)
|
||||
const [endDt, setEndDt] = useState(`${date}T18:00`)
|
||||
const [content, setContent] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const save = async () => {
|
||||
setErr('')
|
||||
try {
|
||||
await createSchedule({ scheType: type, title, startDt, endDt, content, attachmentIds: [] })
|
||||
await createSchedule({ scheType: type, title, startDt: toApiDt(startDt), endDt: toApiDt(endDt), content, attachmentIds: [] })
|
||||
onSaved()
|
||||
} 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}
|
||||
footer={<><Button variant="ghost" onClick={onClose}>취소</Button><Button onClick={save}>저장</Button></>}>
|
||||
<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 value={endDt} onChange={setEndDt} style={{ width: '100%' }} /></FormField>
|
||||
<FormField label="시작"><Input type="datetime-local" value={startDt} onChange={setStartDt} 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>
|
||||
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
|
||||
</Modal>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user