feat(uiws): UIWS 업무모듈(worklog·schedule·message·stats)+2FA 이식

- tb_uiws_* 9테이블 + esn_user 2FA 컬럼(91_uiws_port.sql)
- com.zioinfo.esn.uiws.* 백엔드 40 java + JwtFilter purpose=2fa 차단
- 프론트 업무화면+2FA+다크/라이트 테마
- 자동배포 인프라 구축(repos/guardia-esn)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
DESKTOP-TKLFCPR\ython 2026-06-20 22:17:46 +09:00
parent 4c3caea1b5
commit f60cf93254
66 changed files with 5093 additions and 45 deletions

View File

@ -1,22 +1,35 @@
package com.zioinfo.esn.auth;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.auth.TwoFactorService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* ESN 인증 컨트롤러.
* - /login: 2FA off { twofa:"false", token, type }, 2FA on 이면 { twofa:"true", verifyToken, step, maskedEmail }.
* - /verify: (UIWS 2FA 이식) verify-token + 인증코드 access 토큰 발급.
* 기존 클라이언트는 응답에 token 필드가 그대로 존재(2FA off ) 회귀 0.
*/
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
private final TwoFactorService twoFactorService;
@PostMapping("/login")
public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) {
String token = authService.login(req.username(), req.password());
return ApiResponse.ok(Map.of("token", token, "type", "Bearer"));
return ApiResponse.ok(authService.login(req.username(), req.password()));
}
/** UIWS 2FA 이식: 2차 인증 코드 검증 → access 토큰 발급. */
@PostMapping("/verify")
public ApiResponse<Map<String, String>> verify(@RequestBody VerifyRequest req) {
return ApiResponse.ok(twoFactorService.verify(req.verifyToken(), req.code()));
}
@GetMapping("/me")
@ -32,4 +45,6 @@ public class AuthController {
}
record LoginRequest(String username, String password) {}
record VerifyRequest(String verifyToken, String code) {}
}

View File

@ -1,12 +1,21 @@
package com.zioinfo.esn.auth;
import com.zioinfo.esn.auth.mapper.UserAuthMapper;
import com.zioinfo.esn.uiws.auth.TwoFactorService;
import com.zioinfo.esn.uiws.common.UiwsApiException;
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Map;
/**
* ESN 인증 서비스.
* - 기존 단일 JWT 로그인 보존(2FA off 또는 미설정 회귀 0).
* - UIWS 2FA 레이어(이메일 코드) 추가: 2FA on 이면 1차 통과 verify-token + 이메일코드 발급.
* 실패 누적 max-login-fail 계정 잠금.
*/
@Service
@RequiredArgsConstructor
public class AuthService {
@ -14,17 +23,52 @@ public class AuthService {
private final UserAuthMapper userMapper;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
private final TwoFactorService twoFactorService;
public String login(String username, String password) {
/**
* 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) {
EsnUser 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: 존재하지 않거나 비활성 계정");
}
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
// 2FA 활성 실패 누적/잠금. 비활성 기존 동작(메시지만) 유지.
if (twoFactorService.isEnabled()) {
twoFactorService.recordLoginFailure(username);
EsnUser after = userMapper.findByUsername(username);
if (after != null && Boolean.TRUE.equals(after.getLocked())) {
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
}
}
throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치");
}
// 비밀번호 검증 통과
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);
userMapper.updateLastLogin(username);
return jwtUtil.generate(username, user.getRole(), user.getTenantCode());
String token = jwtUtil.generate(username, user.getRole(), user.getTenantCode());
return Map.of("twofa", "false", "token", token, "type", "Bearer");
}
public Map<String, String> me(String token) {

View File

@ -18,4 +18,19 @@ public class EsnUser {
private boolean active;
private LocalDateTime lastLoginAt;
private LocalDateTime createdAt;
// UIWS 2FA 이식 컬럼 (esn_user ALTER, db/91_uiws_port.sql)
/** 이메일 인증코드(6자리). 발급 후 verify 단계에서 검증. API 응답에는 절대 미포함. */
@JsonIgnore
private String emailVerifyCode;
/** 인증코드 만료시각. */
@JsonIgnore
private LocalDateTime emailVerifyExpire;
/** 로그인 실패 누적 횟수(기본 0). max-login-fail 도달 시 locked. */
private Integer loginFailCount;
/** 계정 잠금 여부(기본 false). */
private Boolean locked;
/** TOTP 시크릿(UIWS OTP 경로 대비, 현재 이메일 흐름에서는 미사용). */
@JsonIgnore
private String otpSecret;
}

View File

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

View File

@ -35,6 +35,48 @@ public class JwtUtil {
.compact();
}
/**
* UIWS 2FA 이식: 1차 로그인 통과 발급하는 단기 verify-token.
* purpose=2fa 클레임으로 access 토큰과 구분(verify-token 으로는 보호 API 접근 불가 JwtFilter 에서 차단).
*/
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)인지 판별.
* verify-token access 같은 서명키라 isValid() 통과하므로, JwtFilter 별도로
* 걸러내지 않으면 2차 코드 검증 없이 보호 API 접근(2FA 완전 우회) 된다.
* JwtFilter purpose=2fa 토큰을 인증 컨텍스트로 세우지 않는다(/api/auth/verify 에서만 사용).
*/
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();

View File

@ -4,8 +4,29 @@ import com.zioinfo.esn.auth.EsnUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
@Mapper
public interface UserAuthMapper {
EsnUser findByUsername(@Param("username") String username);
int updateLastLogin(@Param("username") String username);
// UIWS 2FA 이식: 실패 카운트/잠금/인증코드 갱신 (멱등 UPDATE)
/** 로그인 성공 시 실패 카운트 초기화. */
int resetLoginFail(@Param("username") String username);
/** 로그인 실패 누적(+1) 및 임계 도달 시 잠금. */
int incrementLoginFail(@Param("username") String username, @Param("maxFail") int maxFail);
/** 1차 통과 시 이메일 인증코드/만료 저장(verify 단계에서 검증). */
int saveEmailCode(@Param("username") String username,
@Param("code") String code,
@Param("expire") LocalDateTime expire);
/** 2차 검증 성공 시 코드 폐기. */
int clearEmailCode(@Param("username") String username);
/** 관리자 잠금 해제(실패 카운트/잠금 초기화). */
int unlock(@Param("username") String username);
}

View File

@ -0,0 +1,64 @@
package com.zioinfo.esn.common;
import com.zioinfo.esn.uiws.common.UiwsApiException;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 전역 예외 처리(보안 불변규칙: 스택트레이스/SQL/자격증명 미노출).
*
* <p>UIWS 이식 모듈 + 2FA 레이어 도입과 함께 추가. 기존 ESN 컨트롤러도 동일 정책으로 보호한다
* (예외 메시지는 사용자 노출용 요약만 내부 원인은 서버 로그에만 남긴다).
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/** UIWS 이식/2FA 도메인 예외 → 400 + 안전한 사용자 메시지. */
@ExceptionHandler(UiwsApiException.class)
public ResponseEntity<ApiResponse<Void>> handleUiws(UiwsApiException e) {
log.debug("[UIWS] {}: {}", e.getErrorCode().getCode(), e.getMessage());
return ResponseEntity.badRequest().body(ApiResponse.fail(e.getMessage()));
}
/** 요청 검증 실패 → 400 + 필드 요약(스택트레이스 없음). */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(fe -> fe.getDefaultMessage())
.orElse("요청 값이 올바르지 않습니다.");
return ResponseEntity.badRequest().body(ApiResponse.fail(msg));
}
/** 권한 거부 → 403 (RBAC 가드). */
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleDenied(AccessDeniedException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.fail("접근 권한이 없습니다."));
}
/**
* 모든 예외 400 + 요약 메시지(기존 ESN RuntimeException("ERR-AUTH-...") 흐름 보존).
* 스택트레이스/원인은 서버 로그에만 기록하고 응답에는 노출하지 않는다(보안 불변규칙).
*/
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ApiResponse<Void>> handleRuntime(RuntimeException e, HttpServletRequest req) {
log.warn("[ERR] {} {} — {}", req.getMethod(), req.getRequestURI(), e.getMessage());
String msg = e.getMessage() != null ? e.getMessage() : "요청 처리 중 오류가 발생했습니다.";
return ResponseEntity.badRequest().body(ApiResponse.fail(msg));
}
/** 그 외 체크 예외/오류 → 500 + 요약(상세 미노출). */
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleOther(Exception e, HttpServletRequest req) {
log.error("[ERR-500] {} {}", req.getMethod(), req.getRequestURI(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.fail("서버 처리 중 오류가 발생했습니다."));
}
}

View File

@ -51,7 +51,7 @@ public class OllamaClient {
.uri(URI.create(baseUrl + "/api/chat"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.timeout(Duration.ofSeconds(30))
.timeout(Duration.ofSeconds(120))
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 200) {

View File

@ -0,0 +1,26 @@
package com.zioinfo.esn.uiws.auth;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Update;
/**
* 2FA 로그인 검증 이력 매퍼 (tb_uiws_login_verify). 발급/검증 감사 기록.
* verify_code 감사 이력에만 남고 API 응답에는 절대 노출하지 않는다.
*/
@Mapper
public interface LoginVerifyMapper {
int insert(UiwsLoginVerify v);
/** 동일 user 의 미검증 EMAIL 이력 중 최신 1건을 검증완료(Y) 처리. */
@Update("""
UPDATE tb_uiws_login_verify
SET verified_yn = 'Y', updated_by = #{userId}, updated_at = now()
WHERE verify_id = (
SELECT verify_id FROM tb_uiws_login_verify
WHERE user_id = #{userId} AND verify_method = 'EMAIL' AND verified_yn = 'N'
ORDER BY verify_id DESC LIMIT 1
)
""")
int markLatestVerified(String userId);
}

View File

@ -0,0 +1,161 @@
package com.zioinfo.esn.uiws.auth;
import com.zioinfo.esn.auth.EsnUser;
import com.zioinfo.esn.auth.JwtUtil;
import com.zioinfo.esn.auth.mapper.UserAuthMapper;
import com.zioinfo.esn.uiws.common.UiwsApiException;
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
import com.zioinfo.esn.uiws.common.mail.MailSender;
import com.zioinfo.esn.uiws.config.UiwsProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Map;
/**
* UIWS 2FA(이메일 코드) 레이어. ESN 기존 단일 로그인을 보존하면서 2단계 인증을 추가한다.
*
* 흐름:
* 1) 1차 로그인 성공 {@link #beginTwoFactor}: verify-token 발급 + 이메일 인증코드 발송(LogMailSender 폴백) + 감사 기록.
* 2) {@code POST /api/auth/verify}(verifyToken + code) {@link #verify}: 코드 검증 access 발급.
* 3) 로그인 실패 누적 max-login-fail 계정 잠금({@link #recordLoginFailure}).
*
* 보안:
* - 인증코드는 메일/감사 채널로만 전달. API 응답·로그 메시지에 코드/비밀번호/자격증명 절대 미노출(불변규칙).
* - 외부 API 호출 없음(MailSender 폴백은 로그). Ollama 외부 통신 금지 준수.
* - access 토큰은 ESN JwtUtil(username/role/tenantCode 3-클레임) 정책을 그대로 재사용한다.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TwoFactorService {
private static final SecureRandom RANDOM = new SecureRandom();
private static final String SYSTEM = "SYSTEM";
private final UserAuthMapper userMapper;
private final LoginVerifyMapper loginVerifyMapper;
private final JwtUtil jwtUtil;
private final MailSender mailSender;
private final UiwsProperties properties;
public boolean isEnabled() {
return properties.getAuth().isTwofaEnabled();
}
/** 잠금 여부(1차 로그인 전 차단용). */
public boolean isLocked(EsnUser user) {
return Boolean.TRUE.equals(user.getLocked());
}
/**
* 1차 로그인 성공 2차 인증 시작: verify-token 발급 + 이메일코드 발송 + 감사 기록 + 실패카운트 초기화.
* @return { verifyToken, step:"EMAIL", maskedEmail }
*/
@Transactional
public Map<String, String> beginTwoFactor(EsnUser user) {
long codeValidity = properties.getAuth().getEmailCodeValiditySeconds();
long tokenValidity = properties.getAuth().getVerifyTokenValiditySeconds();
String code = String.format("%06d", RANDOM.nextInt(1_000_000));
LocalDateTime expire = LocalDateTime.now().plusSeconds(codeValidity);
// user 테이블에 코드/만료 저장(실패카운트 초기화) + 감사 이력 기록
userMapper.saveEmailCode(user.getUsername(), code, expire);
recordVerifyAttempt(user.getUsername(), code, expire);
// 이메일 발송(미설정 환경은 LogMailSender 폴백). 코드는 메일 본문에만.
String subject = "[GUARDiA ESN] 로그인 2차 인증 코드";
String body = String.format(
"안녕하세요 %s 님,\n로그인 2차 인증 코드는 [%s] 입니다.\n유효시간: %d초",
user.getUsername(), code, codeValidity);
if (user.getEmail() != null && !user.getEmail().isBlank()) {
mailSender.send(user.getEmail(), subject, body);
} else {
log.warn("[2FA] no email for user={} — code logged only", user.getUsername());
}
String verifyToken = jwtUtil.generateVerifyToken(user.getUsername(), tokenValidity);
// 응답에는 코드 미포함 verifyToken/step/maskedEmail .
return Map.of(
"verifyToken", verifyToken,
"step", "EMAIL",
"maskedEmail", maskEmail(user.getEmail()));
}
/**
* 2차 검증: verify-token + code 검증 access 발급. 코드 폐기 + 감사 이력 검증완료.
* @return { token, type, username, role, tenant }
*/
@Transactional
public Map<String, String> verify(String verifyToken, String code) {
String username = jwtUtil.parseVerifyTokenUsername(verifyToken);
if (username == null) {
throw new UiwsApiException(UiwsErrorCode.VERIFY_TOKEN_INVALID);
}
EsnUser user = userMapper.findByUsername(username);
if (user == null) {
throw new UiwsApiException(UiwsErrorCode.VERIFY_TOKEN_INVALID);
}
if (Boolean.TRUE.equals(user.getLocked())) {
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
}
boolean codeOk = user.getEmailVerifyCode() != null
&& user.getEmailVerifyCode().equals(code)
&& user.getEmailVerifyExpire() != null
&& user.getEmailVerifyExpire().isAfter(LocalDateTime.now());
if (!codeOk) {
throw new UiwsApiException(UiwsErrorCode.VERIFY_CODE_INVALID);
}
// 코드 폐기 + 감사 검증완료 + 마지막 로그인 갱신
userMapper.clearEmailCode(username);
loginVerifyMapper.markLatestVerified(username);
userMapper.updateLastLogin(username);
String access = jwtUtil.generate(user.getUsername(), user.getRole(), user.getTenantCode());
return Map.of(
"token", access,
"type", "Bearer",
"username", user.getUsername(),
"role", user.getRole() == null ? "" : user.getRole(),
"tenant", user.getTenantCode() == null ? "" : user.getTenantCode());
}
/** 로그인 비밀번호 실패 시 누적/잠금 처리. */
@Transactional
public void recordLoginFailure(String username) {
userMapper.incrementLoginFail(username, properties.getAuth().getMaxLoginFail());
}
private void recordVerifyAttempt(String username, String code, LocalDateTime expire) {
UiwsLoginVerify v = new UiwsLoginVerify();
v.setUserId(username);
v.setVerifyMethod("EMAIL");
v.setVerifyCode(code);
v.setExpireAt(expire);
v.setVerifiedYn("N");
v.setCreatedBy(SYSTEM);
v.setCreatedAt(LocalDateTime.now());
loginVerifyMapper.insert(v);
}
/** 이메일 마스킹(자격증명 보호): ab****@domain. */
private static String maskEmail(String email) {
if (email == null || email.isBlank() || !email.contains("@")) {
return "";
}
int at = email.indexOf('@');
String local = email.substring(0, at);
String domain = email.substring(at);
if (local.length() <= 2) {
return local.charAt(0) + "*" + domain;
}
return local.substring(0, 2) + "*".repeat(Math.max(1, local.length() - 2)) + domain;
}
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.esn.uiws.auth;
import lombok.Data;
import java.time.LocalDateTime;
/** 2FA 로그인 검증 이력 (tb_uiws_login_verify). 원본 com.urp.uiws.domain.LoginVerify 이식. */
@Data
public class UiwsLoginVerify {
private Long verifyId;
private String userId; // ERP username 논리참조
private String verifyMethod; // EMAIL | OTP
private String verifyCode;
private LocalDateTime expireAt;
private String verifiedYn; // Y | N
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,24 @@
package com.zioinfo.esn.uiws.common;
import lombok.Getter;
/**
* UIWS 이식 모듈 업무 예외. RuntimeException 상속하여 ERP 기존 GlobalExceptionHandler
* (RuntimeException 400 + message, 스택트레이스 미노출) 그대로 포착된다.
* 추가 인프라/ 없이 ERP 공통 에러 처리와 정합한다.
*/
@Getter
public class UiwsApiException extends RuntimeException {
private final UiwsErrorCode errorCode;
public UiwsApiException(UiwsErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
public UiwsApiException(UiwsErrorCode errorCode, String detail) {
super(detail);
this.errorCode = errorCode;
}
}

View File

@ -0,0 +1,48 @@
package com.zioinfo.esn.uiws.common;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
/**
* 현재 인증 사용자 식별자(=ERP username) 추출 헬퍼. 감사 컬럼(created_by/updated_by)
* 소유자(writer_id/owner_id/sender_id) 기록에 사용한다.
*
* ERP JwtFilter 인증 principal username(String) 세팅하므로 값을 그대로 사용한다.
* (UIWS 원본의 CurrentUser/UserPrincipal 패턴을 ERP 인증 모델에 맞게 단순화 이식.)
*/
public final class UiwsCurrentUser {
private static final String SYSTEM = "SYSTEM";
private UiwsCurrentUser() {
}
/** 현재 사용자 ID(username). 미인증/시스템 컨텍스트는 "SYSTEM". */
public static String id() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof String username
&& !"anonymousUser".equals(username)) {
return username;
}
return SYSTEM;
}
/** ADMIN 권한 보유 여부(데이터 가시범위 판정용 — ADMIN=전체). */
public static boolean isAdmin() {
return hasRole("ADMIN");
}
/** MANAGER 이상(MANAGER/ADMIN) 여부 — 업무일지 댓글 권한 등에 사용. */
public static boolean isManagerOrAbove() {
return hasRole("ADMIN") || hasRole("MANAGER") || hasRole("CFO");
}
private static boolean hasRole(String role) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null) {
return false;
}
return auth.getAuthorities().stream()
.anyMatch(a -> ("ROLE_" + role).equals(a.getAuthority()));
}
}

View File

@ -0,0 +1,29 @@
package com.zioinfo.esn.uiws.common;
import java.util.List;
/**
* 데이터 가시범위(스코프) UIWS DataScopeService silo 단순화 이식.
* 원본은 부서계층(TB_DEPT) 기반 스코프를 산출하나, ERP 이식본은 코어 dept 테이블을 이식하지 않으므로
* 역할만으로 판정한다:
* - ADMIN 전체(all=true)
* - 본인 데이터만(ownerIds = [본인])
* (부서계층 스코프가 필요해지면 ERP tb_department 연계로 확장 후속 트랙.)
*/
public final class UiwsDataScope {
private UiwsDataScope() {
}
/** all=true 면 전체 조회(ownerIds 무시). ownerIds 는 항상 본인 포함(빈 IN 회피). */
public record Scope(boolean all, List<String> ownerIds) {
}
public static Scope current() {
String me = UiwsCurrentUser.id();
if (UiwsCurrentUser.isAdmin()) {
return new Scope(true, List.of(me));
}
return new Scope(false, List.of(me));
}
}

View File

@ -0,0 +1,48 @@
package com.zioinfo.esn.uiws.common;
import lombok.Getter;
/**
* UIWS 이식 모듈 도메인 오류 코드.
* 원본 com.urp.uiws.common.exception.ErrorCode worklog/schedule/message/auth(2FA) 영역 발췌·이식.
* 메시지는 사용자 노출용 스택트레이스/SQL 민감정보는 절대 포함하지 않는다(보안 불변규칙).
*/
@Getter
public enum UiwsErrorCode {
// 공통
INVALID_REQUEST("ERR-UIWS-400", "요청이 올바르지 않습니다."),
FORBIDDEN("ERR-UIWS-403", "접근 권한이 없습니다."),
NOT_FOUND("ERR-UIWS-404", "대상을 찾을 수 없습니다."),
// worklog
WORKLOG_NOT_FOUND("ERR-UIWS-WL-404", "업무일지를 찾을 수 없습니다."),
WORKLOG_TIME_OVERLAP("ERR-UIWS-WL-409", "동일 일지 내 시간대가 중복됩니다."),
WORKLOG_TIME_INVALID("ERR-UIWS-WL-422", "근무 시작/종료 시간이 올바르지 않습니다."),
// schedule
SCHEDULE_NOT_FOUND("ERR-UIWS-SC-404", "일정을 찾을 수 없습니다."),
SCHEDULE_DT_INVALID("ERR-UIWS-SC-422", "일정 시작/종료 일시가 올바르지 않습니다."),
DIARY_NOT_FOUND("ERR-UIWS-DI-404", "일지를 찾을 수 없습니다."),
ATTACH_NOT_FOUND("ERR-UIWS-AT-404", "첨부파일을 찾을 수 없습니다."),
ATTACH_REF_TYPE_INVALID("ERR-UIWS-AT-422", "첨부 대상 유형은 SCHEDULE 또는 DIARY 여야 합니다."),
FILE_EMPTY("ERR-UIWS-FILE-400", "업로드할 파일이 비어 있습니다."),
FILE_STORAGE_ERROR("ERR-UIWS-FILE-500", "파일 저장 중 오류가 발생했습니다."),
// message
MESSAGE_NOT_FOUND("ERR-UIWS-MSG-404", "쪽지를 찾을 수 없습니다."),
MESSAGE_RCV_TYPE_INVALID("ERR-UIWS-MSG-422", "수신구분은 RECV(수신) 또는 REF(참조) 여야 합니다."),
// auth (2FA)
VERIFY_TOKEN_INVALID("ERR-UIWS-2FA-401", "2차 검증 토큰이 유효하지 않거나 만료되었습니다."),
VERIFY_CODE_INVALID("ERR-UIWS-2FA-401C", "인증 코드가 올바르지 않거나 만료되었습니다."),
ACCOUNT_LOCKED("ERR-UIWS-2FA-LOCK", "로그인 실패 횟수 초과로 계정이 잠겼습니다. 관리자에게 문의하세요.");
private final String code;
private final String message;
UiwsErrorCode(String code, String message) {
this.code = code;
this.message = message;
}
}

View File

@ -0,0 +1,23 @@
package com.zioinfo.esn.uiws.common.mail;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
/**
* 로컬/개발/미설정용 메일 발송 폴백. 실제 전송 없이 로그로만 기록한다(외부 호출 0).
* esn.uiws.mail.mode=log (기본) 활성. SMTP 운영 mode=smtp 별도 구현 활성화.
*
* 보안: 본문에 인증코드/임시비밀번호가 포함되므로 로그 레벨은 운영에서 조정.
* (인증코드는 API 응답으로는 절대 반환하지 않는다 메일/로그 채널로만 전달.)
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "esn.uiws.mail.mode", havingValue = "log", matchIfMissing = true)
public class LogMailSender implements MailSender {
@Override
public void send(String to, String subject, String body) {
log.info("[UIWS-MAIL:LOG] to={} subject={}\n{}", to, subject, body);
}
}

View File

@ -0,0 +1,10 @@
package com.zioinfo.esn.uiws.common.mail;
/**
* 메일 발송 추상화(UIWS 이식). 로컬/미설정 환경은 LogMailSender(로그만), 운영은 SMTP 구현으로 교체.
* 외부 API 호출은 하지 않는다(보안 불변규칙 Ollama 외부 호출 금지).
*/
public interface MailSender {
void send(String to, String subject, String body);
}

View File

@ -0,0 +1,41 @@
package com.zioinfo.esn.uiws.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* UIWS 이식 모듈 설정. application.yml esn.uiws.* 바인딩.
* 계획서 2FA 설정키(verify-token-validity / max-login-fail / email-code-validity) +
* 첨부 업로드 디렉터리. 모두 안전 기본값 보유(미설정이어도 동작).
*/
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "esn.uiws")
public class UiwsProperties {
private final Auth auth = new Auth();
private final Upload upload = new Upload();
@Getter
@Setter
public static class Auth {
/** 2FA 레이어 on/off. 기본 on(켜짐). off 시 기존 단일 JWT 로그인 흐름 유지(회귀 0). */
private boolean twofaEnabled = true;
/** 1차 통과 후 verify-token 유효시간(초). 계획서 300(5분). */
private long verifyTokenValiditySeconds = 300;
/** 이메일 인증코드 유효시간(초). 계획서 300(5분). */
private long emailCodeValiditySeconds = 300;
/** 로그인 실패 누적 N회 시 계정 잠금. 계획서 5. */
private int maxLoginFail = 5;
}
@Getter
@Setter
public static class Upload {
/** 첨부파일 저장 루트. 기본 ./uploads/uiws. */
private String uploadDir = "./uploads/uiws";
}
}

View File

@ -0,0 +1,75 @@
package com.zioinfo.esn.uiws.message.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.message.dto.MessageDtos.*;
import com.zioinfo.esn.uiws.message.service.MessageService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
/**
* UIWS 이식 쪽지(모듈 03). 인증 필수(/api/messages). 원본 prefix·엔드포인트 보존.
* Spring Data Pageable 의존 회피: page/size 단순 파라미터로 이식(ERP는 spring-data-web 미사용).
*/
@RestController
@RequestMapping("/api/messages")
@RequiredArgsConstructor
public class MessageController {
private final MessageService messageService;
@PostMapping
public ApiResponse<MessageSendResponse> send(@Valid @RequestBody MessageSendDto dto) {
return ApiResponse.ok(messageService.send(dto));
}
@GetMapping("/sent")
public ApiResponse<MessageService.SentPage> sent(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
@RequestParam(required = false) String titleKeyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.ok(messageService.listSent(fromDate, toDate, titleKeyword, page, size));
}
@GetMapping("/sent/{id}")
public ApiResponse<SentMessageDetailDto> sentDetail(@PathVariable("id") Long id) {
return ApiResponse.ok(messageService.sentDetail(id));
}
@DeleteMapping("/sent")
public ApiResponse<Void> deleteSent(@Valid @RequestBody MessageIdsRequest req) {
messageService.deleteSent(req.ids());
return ApiResponse.ok(null);
}
@GetMapping("/unread-count")
public ApiResponse<Long> unreadCount() {
return ApiResponse.ok(messageService.unreadCount());
}
@GetMapping("/received")
public ApiResponse<MessageService.ReceivedPage> received(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
@RequestParam(required = false) String titleKeyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.ok(messageService.listReceived(fromDate, toDate, titleKeyword, page, size));
}
@GetMapping("/received/{id}")
public ApiResponse<ReceivedMessageDetailDto> receivedDetail(@PathVariable("id") Long id) {
return ApiResponse.ok(messageService.receivedDetail(id));
}
@DeleteMapping("/received")
public ApiResponse<Void> deleteReceived(@Valid @RequestBody MessageIdsRequest req) {
messageService.deleteReceived(req.ids());
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,100 @@
package com.zioinfo.esn.uiws.message.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
/**
* 쪽지(모듈 03) 요청/응답 DTO 모음. 원본 com.urp.uiws.message.dto.* 이식(필드·shape 동일).
* 프론트 경계면 계약 유지: API 응답 필드명 그대로 보존.
*/
public final class MessageDtos {
private MessageDtos() {
}
/** 전송/답장 요청. */
public record MessageSendDto(
@NotBlank String title,
@NotBlank String content,
Long refWorklogId,
Long replyToId,
@Valid @NotEmpty(message = "수신자는 1명 이상이어야 합니다.") List<MessageReceiverDto> receivers
) {
}
/** 수신자 1건. rcvType: RECV(수신) | REF(참조). */
public record MessageReceiverDto(
@NotBlank String receiverId,
@NotBlank String rcvType
) {
}
/** 전송 응답. */
public record MessageSendResponse(Long messageId) {
}
/** 보낸쪽지함 행. */
public record SentMessageDto(
Long messageId,
String title,
String receiverSummary,
long openCount,
long totalCount,
String sentAt
) {
}
/** 보낸쪽지 상세(수신자 개봉현황). */
public record SentMessageDetailDto(
Long messageId,
String title,
String content,
Long refWorklogId,
String sentAt,
List<ReceiverStatusDto> receivers,
long totalCount,
long openCount,
long unopenCount
) {
}
/** 수신자별 개봉현황. */
public record ReceiverStatusDto(
String receiverNm,
String rcvType,
String readYn,
String readAt
) {
}
/** 받은쪽지함 행. */
public record ReceivedMessageDto(
Long messageId,
String title,
String senderNm,
String sentAt,
String readYn
) {
}
/** 받은쪽지 상세(조회 시 개봉처리). */
public record ReceivedMessageDetailDto(
Long messageId,
String title,
String content,
String senderNm,
String sentAt,
String receivedAt,
Long refWorklogId
) {
}
/** 다중삭제 본문. */
public record MessageIdsRequest(
@NotEmpty(message = "ids는 필수입니다.") List<Long> ids
) {
}
}

View File

@ -0,0 +1,78 @@
package com.zioinfo.esn.uiws.message.mapper;
import com.zioinfo.esn.uiws.message.model.UiwsMessage;
import com.zioinfo.esn.uiws.message.model.UiwsMessageRcv;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 쪽지 MyBatis 매퍼. 원본 JPA MessageRepository/MessageRcvRepository MyBatis 변환 이식.
* 사용자명은 esn_user(username 라벨) 조인으로 라벨화(코어 user 재사용, 별도 user 테이블 미이식).
*/
@Mapper
public interface MessageMapper {
// message 헤더
int insertMessage(UiwsMessage m);
UiwsMessage findMessageById(@Param("messageId") Long messageId);
boolean existsMessageById(@Param("messageId") Long messageId);
/** 보낸쪽지 페이징(SENDER_DEL_YN='N' 제외). */
List<UiwsMessage> searchSent(@Param("senderId") String senderId,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("titleKeyword") String titleKeyword,
@Param("limit") int limit,
@Param("offset") int offset);
long countSent(@Param("senderId") String senderId,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("titleKeyword") String titleKeyword);
/** 보낸쪽지 다중 소프트삭제(본인 것만). */
int softDeleteSent(@Param("senderId") String senderId,
@Param("ids") List<Long> ids,
@Param("actor") String actor);
// 수신자
int insertRcv(UiwsMessageRcv rcv);
List<UiwsMessageRcv> findRcvByMessageId(@Param("messageId") Long messageId);
List<UiwsMessageRcv> findRcvByMessageIds(@Param("ids") List<Long> ids);
UiwsMessageRcv findRcvByMessageAndReceiver(@Param("messageId") Long messageId,
@Param("receiverId") String receiverId);
long countUnread(@Param("receiverId") String receiverId);
/** 받은쪽지 개봉처리(READ_YN='Y', READ_AT). */
int markRead(@Param("rcvId") Long rcvId, @Param("actor") String actor, @Param("readAt") LocalDateTime readAt);
int softDeleteReceived(@Param("receiverId") String receiverId,
@Param("ids") List<Long> ids,
@Param("actor") String actor);
/** 받은쪽지 목록(조인: message + rcv). 행: messageId,title,senderId,sentAt,readYn. */
List<Map<String, Object>> searchReceived(@Param("receiverId") String receiverId,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("titleKeyword") String titleKeyword,
@Param("limit") int limit,
@Param("offset") int offset);
long countReceived(@Param("receiverId") String receiverId,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("titleKeyword") String titleKeyword);
// 사용자명 라벨(코어 user 재사용)
List<Map<String, String>> findUserNames(@Param("ids") List<String> ids);
}

View File

@ -0,0 +1,24 @@
package com.zioinfo.esn.uiws.message.model;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 쪽지 헤더 (tb_uiws_message). 원본 com.urp.uiws.domain.Message 이식.
* MyBatis map-underscore-to-camel-case 컬럼필드 자동 매핑.
*/
@Data
public class UiwsMessage {
private Long messageId;
private String senderId;
private String title;
private String content;
private Long refWorklogId;
private Long replyToId;
private LocalDateTime sentAt;
private String senderDelYn;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,22 @@
package com.zioinfo.esn.uiws.message.model;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 쪽지 수신자 (tb_uiws_message_rcv). 원본 com.urp.uiws.domain.MessageRcv 이식.
*/
@Data
public class UiwsMessageRcv {
private Long rcvId;
private Long messageId;
private String receiverId;
private String rcvType; // RECV | REF
private String readYn; // Y | N
private LocalDateTime readAt;
private String receiverDelYn; // Y | N
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,271 @@
package com.zioinfo.esn.uiws.message.service;
import com.zioinfo.esn.uiws.common.UiwsApiException;
import com.zioinfo.esn.uiws.common.UiwsCurrentUser;
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
import com.zioinfo.esn.uiws.message.dto.MessageDtos.*;
import com.zioinfo.esn.uiws.message.mapper.MessageMapper;
import com.zioinfo.esn.uiws.message.model.UiwsMessage;
import com.zioinfo.esn.uiws.message.model.UiwsMessageRcv;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 쪽지(모듈 03) 서비스 원본 com.urp.uiws.message.service.MessageService MyBatis 변환 이식.
* 로직(전송/답장, 보낸함, 개봉현황, 받은함, 개봉처리, 다중삭제, 미열람 배지) 동등하게 보존한다.
* 기본 조회기간 = 최근 1주일.
*/
@Service
@RequiredArgsConstructor
public class MessageService {
private static final Set<String> VALID_RCV_TYPES = Set.of("RECV", "REF");
private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
private final MessageMapper mapper;
// ------------------------------------------------------------------ 전송/답장
@Transactional
public MessageSendResponse send(MessageSendDto dto) {
String actor = UiwsCurrentUser.id();
LocalDateTime now = LocalDateTime.now();
if (dto.replyToId() != null && !mapper.existsMessageById(dto.replyToId())) {
throw new UiwsApiException(UiwsErrorCode.MESSAGE_NOT_FOUND, "답장 원본 쪽지를 찾을 수 없습니다.");
}
UiwsMessage m = new UiwsMessage();
m.setSenderId(actor);
m.setTitle(dto.title());
m.setContent(dto.content());
m.setRefWorklogId(dto.refWorklogId());
m.setReplyToId(dto.replyToId());
m.setSentAt(now);
m.setSenderDelYn("N");
m.setCreatedBy(actor);
m.setCreatedAt(now);
mapper.insertMessage(m); // useGeneratedKeys m.messageId
// 수신자: 동일 수신자 중복 제거(UNIQUE(message_id,receiver_id) 보호). 등장 rcvType 채택.
Set<String> seen = new LinkedHashSet<>();
for (MessageReceiverDto r : dto.receivers()) {
String receiverId = (r.receiverId() == null) ? null : r.receiverId().trim();
String rcvType = (r.rcvType() == null) ? null : r.rcvType().trim().toUpperCase();
if (receiverId == null || receiverId.isBlank()) {
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "수신자 ID가 비어 있습니다.");
}
if (!VALID_RCV_TYPES.contains(rcvType)) {
throw new UiwsApiException(UiwsErrorCode.MESSAGE_RCV_TYPE_INVALID);
}
if (!seen.add(receiverId)) {
continue;
}
UiwsMessageRcv rcv = new UiwsMessageRcv();
rcv.setMessageId(m.getMessageId());
rcv.setReceiverId(receiverId);
rcv.setRcvType(rcvType);
rcv.setReadYn("N");
rcv.setReceiverDelYn("N");
rcv.setCreatedBy(actor);
rcv.setCreatedAt(now);
mapper.insertRcv(rcv);
}
return new MessageSendResponse(m.getMessageId());
}
// ------------------------------------------------------------------ 보낸쪽지함
@Transactional(readOnly = true)
public SentPage listSent(LocalDate fromDate, LocalDate toDate, String titleKeyword, int page, int size) {
LocalDateTime[] range = range(fromDate, toDate);
String me = UiwsCurrentUser.id();
String kw = blankToNull(titleKeyword);
long total = mapper.countSent(me, range[0], range[1], kw);
List<UiwsMessage> rows = mapper.searchSent(me, range[0], range[1], kw, size, page * size);
List<Long> ids = rows.stream().map(UiwsMessage::getMessageId).toList();
List<UiwsMessageRcv> rcvs = ids.isEmpty() ? List.of() : mapper.findRcvByMessageIds(ids);
Map<Long, List<UiwsMessageRcv>> byMsg = rcvs.stream().collect(Collectors.groupingBy(UiwsMessageRcv::getMessageId));
Map<String, String> names = userNames(rcvs.stream().map(UiwsMessageRcv::getReceiverId).toList());
List<SentMessageDto> content = rows.stream().map(m -> {
List<UiwsMessageRcv> list = byMsg.getOrDefault(m.getMessageId(), List.of());
long openCount = list.stream().filter(r -> "Y".equals(r.getReadYn())).count();
return new SentMessageDto(m.getMessageId(), m.getTitle(),
receiverSummary(list, names), openCount, list.size(), fmt(m.getSentAt()));
}).toList();
return new SentPage(content, total, page, size, totalPages(total, size));
}
// ------------------------------------------------------------------ 보낸쪽지 상세
@Transactional(readOnly = true)
public SentMessageDetailDto sentDetail(Long messageId) {
String me = UiwsCurrentUser.id();
UiwsMessage m = mapper.findMessageById(messageId);
if (m == null) {
throw new UiwsApiException(UiwsErrorCode.MESSAGE_NOT_FOUND);
}
if (!me.equals(m.getSenderId())) {
throw new UiwsApiException(UiwsErrorCode.FORBIDDEN, "본인이 보낸 쪽지만 조회할 수 있습니다.");
}
List<UiwsMessageRcv> list = mapper.findRcvByMessageId(messageId);
Map<String, String> names = userNames(list.stream().map(UiwsMessageRcv::getReceiverId).toList());
List<ReceiverStatusDto> receivers = list.stream()
.map(r -> new ReceiverStatusDto(
names.getOrDefault(r.getReceiverId(), r.getReceiverId()),
r.getRcvType(), r.getReadYn(), fmt(r.getReadAt())))
.toList();
long total = receivers.size();
long open = list.stream().filter(r -> "Y".equals(r.getReadYn())).count();
return new SentMessageDetailDto(m.getMessageId(), m.getTitle(), m.getContent(), m.getRefWorklogId(),
fmt(m.getSentAt()), receivers, total, open, total - open);
}
// ------------------------------------------------------------------ 보낸쪽지 다중삭제
@Transactional
public void deleteSent(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return;
}
String me = UiwsCurrentUser.id();
mapper.softDeleteSent(me, ids, me);
}
// ------------------------------------------------------------------ 미열람 (배지)
@Transactional(readOnly = true)
public long unreadCount() {
return mapper.countUnread(UiwsCurrentUser.id());
}
// ------------------------------------------------------------------ 받은쪽지함
@Transactional(readOnly = true)
public ReceivedPage listReceived(LocalDate fromDate, LocalDate toDate, String titleKeyword, int page, int size) {
LocalDateTime[] range = range(fromDate, toDate);
String me = UiwsCurrentUser.id();
String kw = blankToNull(titleKeyword);
long total = mapper.countReceived(me, range[0], range[1], kw);
List<Map<String, Object>> rows = mapper.searchReceived(me, range[0], range[1], kw, size, page * size);
Map<String, String> names = userNames(rows.stream().map(r -> str(r.get("senderId"))).toList());
List<ReceivedMessageDto> content = rows.stream()
.map(r -> new ReceivedMessageDto(
toLongObj(r.get("messageId")),
str(r.get("title")),
names.getOrDefault(str(r.get("senderId")), str(r.get("senderId"))),
fmt(toDt(r.get("sentAt"))),
str(r.get("readYn"))))
.toList();
return new ReceivedPage(content, total, page, size, totalPages(total, size));
}
// ------------------------------------------------------------------ 받은쪽지 상세(개봉처리)
@Transactional
public ReceivedMessageDetailDto receivedDetail(Long messageId) {
String me = UiwsCurrentUser.id();
UiwsMessageRcv rcv = mapper.findRcvByMessageAndReceiver(messageId, me);
if (rcv == null || "Y".equals(rcv.getReceiverDelYn())) {
throw new UiwsApiException(UiwsErrorCode.MESSAGE_NOT_FOUND, "받은 쪽지를 찾을 수 없습니다.");
}
UiwsMessage m = mapper.findMessageById(messageId);
if (m == null) {
throw new UiwsApiException(UiwsErrorCode.MESSAGE_NOT_FOUND);
}
LocalDateTime readAt = rcv.getReadAt();
if (!"Y".equals(rcv.getReadYn())) {
readAt = LocalDateTime.now();
mapper.markRead(rcv.getRcvId(), me, readAt);
}
String senderNm = userNames(List.of(m.getSenderId())).getOrDefault(m.getSenderId(), m.getSenderId());
return new ReceivedMessageDetailDto(m.getMessageId(), m.getTitle(), m.getContent(),
senderNm, fmt(m.getSentAt()), fmt(readAt), m.getRefWorklogId());
}
// ------------------------------------------------------------------ 받은쪽지 다중삭제
@Transactional
public void deleteReceived(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return;
}
String me = UiwsCurrentUser.id();
mapper.softDeleteReceived(me, ids, me);
}
// ================================================================== helpers
private LocalDateTime[] range(LocalDate fromDate, LocalDate toDate) {
LocalDate to = (toDate != null) ? toDate : LocalDate.now();
LocalDate from = (fromDate != null) ? fromDate : to.minusWeeks(1);
return new LocalDateTime[]{from.atStartOfDay(), to.atTime(LocalTime.MAX)};
}
private String receiverSummary(List<UiwsMessageRcv> list, Map<String, String> names) {
if (list.isEmpty()) {
return "";
}
String first = names.getOrDefault(list.get(0).getReceiverId(), list.get(0).getReceiverId());
return list.size() == 1 ? first : first + "" + (list.size() - 1) + "";
}
private Map<String, String> userNames(List<String> userIds) {
List<String> ids = userIds.stream().filter(s -> s != null && !s.isBlank()).distinct().toList();
if (ids.isEmpty()) {
return Map.of();
}
return mapper.findUserNames(ids).stream()
.collect(Collectors.toMap(r -> r.get("username"), r -> r.get("fullName"), (a, b) -> a));
}
private static int totalPages(long total, int size) {
return size <= 0 ? 0 : (int) ((total + size - 1) / size);
}
private static String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s;
}
private static String str(Object o) {
return o == null ? "" : o.toString();
}
private static Long toLongObj(Object o) {
if (o == null) {
return null;
}
return (o instanceof Number n) ? n.longValue() : Long.parseLong(o.toString());
}
private static LocalDateTime toDt(Object o) {
if (o == null) {
return null;
}
if (o instanceof LocalDateTime dt) {
return dt;
}
if (o instanceof java.sql.Timestamp ts) {
return ts.toLocalDateTime();
}
return null;
}
private static String fmt(LocalDateTime dt) {
return dt != null ? dt.format(DT) : null;
}
// 페이지 응답(ERP ApiResponse 안에 그대로 직렬화 content/totalElements/page/size/totalPages 보존)
public record SentPage(List<SentMessageDto> content, long totalElements, int page, int size, int totalPages) {
}
public record ReceivedPage(List<ReceivedMessageDto> content, long totalElements, int page, int size, int totalPages) {
}
}

View File

@ -0,0 +1,68 @@
package com.zioinfo.esn.uiws.schedule.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.schedule.dto.ScheduleDtos.AttachmentDto;
import com.zioinfo.esn.uiws.schedule.service.AttachmentService;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
/**
* UIWS 이식 첨부파일(모듈 02, 폴리모픽 SCHEDULE/DIARY). 인증 필수(/api/attachments).
*/
@RestController
@RequestMapping("/api/attachments")
@RequiredArgsConstructor
public class AttachmentController {
private final AttachmentService attachmentService;
@PostMapping
public ApiResponse<AttachmentDto> upload(
@RequestParam("refType") String refType,
@RequestParam("refId") Long refId,
@RequestParam("file") MultipartFile file) {
return ApiResponse.ok(attachmentService.upload(refType, refId, file));
}
@GetMapping("/{id}/download")
public ResponseEntity<Resource> download(@PathVariable("id") Long id) {
AttachmentService.DownloadFile f = attachmentService.download(id);
Resource resource = new FileSystemResource(f.path());
String contentType;
try {
contentType = Files.probeContentType(f.path());
} catch (IOException e) {
contentType = null;
}
ContentDisposition cd = ContentDisposition.attachment()
.filename(f.fileNm(), StandardCharsets.UTF_8)
.build();
HttpHeaders headers = new HttpHeaders();
headers.setContentDisposition(cd);
long len = f.path().toFile().length();
if (len > 0) {
headers.setContentLength(len);
}
return ResponseEntity.ok()
.headers(headers)
.contentType(contentType != null ? MediaType.parseMediaType(contentType) : MediaType.APPLICATION_OCTET_STREAM)
.body(resource);
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable("id") Long id) {
attachmentService.delete(id);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,52 @@
package com.zioinfo.esn.uiws.schedule.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.schedule.dto.ScheduleDtos.*;
import com.zioinfo.esn.uiws.schedule.service.DiaryService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
/**
* UIWS 이식 일지(모듈 02). 인증 필수(/api/diaries).
*/
@RestController
@RequestMapping("/api/diaries")
@RequiredArgsConstructor
public class DiaryController {
private final DiaryService diaryService;
@GetMapping
public ApiResponse<DiaryPage> list(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.ok(diaryService.list(fromDate, toDate, page, size));
}
@PostMapping
public ApiResponse<DiaryDetailDto> create(@Valid @RequestBody DiarySaveDto dto) {
return ApiResponse.ok(diaryService.create(dto));
}
@GetMapping("/{id}")
public ApiResponse<DiaryDetailDto> detail(@PathVariable("id") Long id) {
return ApiResponse.ok(diaryService.detail(id));
}
@PutMapping("/{id}")
public ApiResponse<DiaryDetailDto> update(@PathVariable("id") Long id, @Valid @RequestBody DiarySaveDto dto) {
return ApiResponse.ok(diaryService.update(id, dto));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable("id") Long id) {
diaryService.delete(id);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,68 @@
package com.zioinfo.esn.uiws.schedule.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.schedule.dto.ScheduleDtos.*;
import com.zioinfo.esn.uiws.schedule.service.ScheduleService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.List;
/**
* UIWS 이식 일정(모듈 02). 인증 필수(/api/schedules). 원본 엔드포인트 보존.
*/
@RestController
@RequestMapping("/api/schedules")
@RequiredArgsConstructor
public class ScheduleController {
private final ScheduleService scheduleService;
@GetMapping
public ApiResponse<List<ScheduleDto>> calendar(
@RequestParam(defaultValue = "PERSONAL") String type,
@RequestParam(defaultValue = "month") String view,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate baseDate,
@RequestParam(required = false) String deptId) {
return ApiResponse.ok(scheduleService.calendar(type, view, baseDate, deptId));
}
@GetMapping("/all")
public ApiResponse<SchedulePage> all(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
@RequestParam(required = false) String type,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.ok(scheduleService.all(fromDate, toDate, type, page, size));
}
@GetMapping("/search")
public ApiResponse<List<ScheduleDto>> search(@RequestParam(required = false) String keyword) {
return ApiResponse.ok(scheduleService.search(keyword));
}
@PostMapping
public ApiResponse<ScheduleDto> create(@Valid @RequestBody ScheduleSaveDto dto) {
return ApiResponse.ok(scheduleService.create(dto));
}
@GetMapping("/{id}")
public ApiResponse<ScheduleDetailDto> detail(@PathVariable("id") Long id) {
return ApiResponse.ok(scheduleService.detail(id));
}
@PutMapping("/{id}")
public ApiResponse<ScheduleDetailDto> update(@PathVariable("id") Long id, @Valid @RequestBody ScheduleSaveDto dto) {
return ApiResponse.ok(scheduleService.update(id, dto));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable("id") Long id) {
scheduleService.delete(id);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,109 @@
package com.zioinfo.esn.uiws.schedule.dto;
import com.zioinfo.esn.uiws.schedule.model.UiwsAttach;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import java.util.List;
/**
* 일정·일지·첨부(모듈 02) DTO. 원본 com.urp.uiws.schedule.dto.* 이식(shape 동일).
*/
public final class ScheduleDtos {
private ScheduleDtos() {
}
public record ScheduleDto(
Long scheduleId,
String scheType,
String title,
String scheGubunCd,
String importanceCd,
String startDt,
String endDt,
String ownerId,
String deptId
) {
}
public record ScheduleDetailDto(
Long scheduleId,
String scheType,
String title,
String scheGubunCd,
String importanceCd,
String startDt,
String endDt,
String ownerId,
String deptId,
String content,
String chargerId,
String chargerNm,
List<AttachmentDto> attachments
) {
}
public record ScheduleSaveDto(
@NotBlank @Pattern(regexp = "PERSONAL|DEPT") String scheType,
@NotBlank String title,
String scheGubunCd,
String importanceCd,
@NotNull String startDt,
@NotNull String endDt,
String content,
String deptId,
String chargerId,
List<Long> attachmentIds
) {
}
public record DiaryDto(
Long diaryId,
String title,
String writerNm,
String diaryDate,
Long scheduleId
) {
}
public record DiaryDetailDto(
Long diaryId,
String title,
String writerNm,
String diaryDate,
Long scheduleId,
String content,
List<AttachmentDto> attachments
) {
}
public record DiarySaveDto(
@NotBlank String title,
String content,
Long scheduleId,
String diaryDate,
List<Long> attachmentIds
) {
}
public record AttachmentDto(
Long attachId,
String refType,
Long refId,
String fileNm,
Long fileSize
) {
public static AttachmentDto from(UiwsAttach a) {
return new AttachmentDto(a.getAttachId(), a.getRefType(), a.getRefId(), a.getFileNm(), a.getFileSize());
}
}
/** 페이지 래퍼(content/totalElements/page/size/totalPages). */
public record DiaryPage(List<DiaryDto> content, long totalElements, int page, int size, int totalPages) {
}
public record SchedulePage(List<ScheduleDto> content, long totalElements, int page, int size, int totalPages) {
}
}

View File

@ -0,0 +1,93 @@
package com.zioinfo.esn.uiws.schedule.mapper;
import com.zioinfo.esn.uiws.schedule.model.UiwsAttach;
import com.zioinfo.esn.uiws.schedule.model.UiwsDiary;
import com.zioinfo.esn.uiws.schedule.model.UiwsSchedule;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 일정·일지·첨부 MyBatis 매퍼. 원본 JPA ScheduleRepository/DiaryRepository/AttachRepository 변환 이식.
* 데이터 가시범위: scopeAll(true=ADMIN 전체) OR owner IN (ownerIds). ownerIds 항상 본인 포함( IN 회피).
*/
@Mapper
public interface ScheduleMapper {
// schedule
int insertSchedule(UiwsSchedule s);
int updateSchedule(UiwsSchedule s);
UiwsSchedule findScheduleById(@Param("scheduleId") Long scheduleId);
boolean existsScheduleById(@Param("scheduleId") Long scheduleId);
int deleteScheduleById(@Param("scheduleId") Long scheduleId);
/** 달력: 기간 겹침([from,to) 배타 상한) + type + 개인소유/부서필터. */
List<UiwsSchedule> findInRange(@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("scheType") String scheType,
@Param("ownerId") String ownerId,
@Param("deptId") String deptId);
List<UiwsSchedule> searchAll(@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("scheType") String scheType,
@Param("scopeAll") boolean scopeAll,
@Param("ownerIds") List<String> ownerIds,
@Param("limit") int limit,
@Param("offset") int offset);
long countAll(@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("scheType") String scheType,
@Param("scopeAll") boolean scopeAll,
@Param("ownerIds") List<String> ownerIds);
List<UiwsSchedule> searchPopup(@Param("keyword") String keyword,
@Param("scopeAll") boolean scopeAll,
@Param("ownerIds") List<String> ownerIds);
// diary
int insertDiary(UiwsDiary d);
int updateDiary(UiwsDiary d);
UiwsDiary findDiaryById(@Param("diaryId") Long diaryId);
boolean existsDiaryById(@Param("diaryId") Long diaryId);
int deleteDiaryById(@Param("diaryId") Long diaryId);
List<UiwsDiary> searchDiary(@Param("from") LocalDate from,
@Param("to") LocalDate to,
@Param("limit") int limit,
@Param("offset") int offset);
long countDiary(@Param("from") LocalDate from, @Param("to") LocalDate to);
// attach
int insertAttach(UiwsAttach a);
UiwsAttach findAttachById(@Param("attachId") Long attachId);
int updateAttachRef(@Param("attachId") Long attachId,
@Param("refType") String refType,
@Param("refId") Long refId,
@Param("actor") String actor);
List<UiwsAttach> findAttachByRef(@Param("refType") String refType, @Param("refId") Long refId);
int deleteAttachById(@Param("attachId") Long attachId);
int deleteAttachByRef(@Param("refType") String refType, @Param("refId") Long refId);
// 사용자명 라벨(코어 user 재사용)
List<Map<String, String>> findUserNames(@Param("ids") List<String> ids);
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.esn.uiws.schedule.model;
import lombok.Data;
import java.time.LocalDateTime;
/** 첨부파일 폴리모픽 (tb_uiws_attach, ref_type=SCHEDULE|DIARY). 원본 com.urp.uiws.domain.Attach 이식. */
@Data
public class UiwsAttach {
private Long attachId;
private String refType;
private Long refId;
private String fileNm;
private String filePath;
private Long fileSize;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,20 @@
package com.zioinfo.esn.uiws.schedule.model;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** 일지 (tb_uiws_diary). 원본 com.urp.uiws.domain.Diary 이식. */
@Data
public class UiwsDiary {
private Long diaryId;
private String title;
private String content;
private Long scheduleId;
private String writerId;
private LocalDate diaryDate;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,24 @@
package com.zioinfo.esn.uiws.schedule.model;
import lombok.Data;
import java.time.LocalDateTime;
/** 일정 (tb_uiws_schedule). 원본 com.urp.uiws.domain.Schedule 이식. */
@Data
public class UiwsSchedule {
private Long scheduleId;
private String scheType; // PERSONAL | DEPT
private String title;
private String scheGubunCd;
private String importanceCd;
private LocalDateTime startDt;
private LocalDateTime endDt;
private String content;
private String ownerId;
private String deptId;
private String chargerId;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,121 @@
package com.zioinfo.esn.uiws.schedule.service;
import com.zioinfo.esn.uiws.common.UiwsApiException;
import com.zioinfo.esn.uiws.common.UiwsCurrentUser;
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
import com.zioinfo.esn.uiws.schedule.dto.ScheduleDtos.AttachmentDto;
import com.zioinfo.esn.uiws.schedule.mapper.ScheduleMapper;
import com.zioinfo.esn.uiws.schedule.model.UiwsAttach;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
/**
* 첨부파일(폴리모픽 SCHEDULE/DIARY) 업로드·삭제·연결. 원본 com.urp.uiws.schedule.service.AttachmentService 이식.
*/
@Service
@RequiredArgsConstructor
public class AttachmentService {
public static final String REF_SCHEDULE = "SCHEDULE";
public static final String REF_DIARY = "DIARY";
private static final Set<String> VALID_REF_TYPES = Set.of(REF_SCHEDULE, REF_DIARY);
private final ScheduleMapper mapper;
private final FileStorageService fileStorageService;
@Transactional
public AttachmentDto upload(String refType, Long refId, MultipartFile file) {
String type = normalizeRefType(refType);
if (refId == null) {
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "refId 는 필수입니다.");
}
FileStorageService.StoredFile stored = fileStorageService.store(file);
String actor = UiwsCurrentUser.id();
UiwsAttach a = new UiwsAttach();
a.setRefType(type);
a.setRefId(refId);
a.setFileNm(stored.originalName());
a.setFilePath(stored.relativePath());
a.setFileSize(stored.size());
a.setCreatedBy(actor);
a.setCreatedAt(LocalDateTime.now());
mapper.insertAttach(a);
return AttachmentDto.from(a);
}
public record DownloadFile(String fileNm, Path path) {
}
@Transactional(readOnly = true)
public DownloadFile download(Long attachId) {
UiwsAttach a = mapper.findAttachById(attachId);
if (a == null) {
throw new UiwsApiException(UiwsErrorCode.ATTACH_NOT_FOUND);
}
Path path = fileStorageService.resolve(a.getFilePath());
if (!Files.exists(path)) {
throw new UiwsApiException(UiwsErrorCode.ATTACH_NOT_FOUND, "파일이 존재하지 않습니다.");
}
return new DownloadFile(a.getFileNm(), path);
}
@Transactional
public void delete(Long attachId) {
UiwsAttach a = mapper.findAttachById(attachId);
if (a == null) {
throw new UiwsApiException(UiwsErrorCode.ATTACH_NOT_FOUND);
}
fileStorageService.delete(a.getFilePath());
mapper.deleteAttachById(attachId);
}
/** 일정/일지 저장 시 attachmentIds 의 첨부들을 해당 대상으로 귀속(확정). */
@Transactional
public void link(String refType, Long refId, List<Long> attachmentIds) {
String type = normalizeRefType(refType);
if (attachmentIds == null || attachmentIds.isEmpty()) {
return;
}
String actor = UiwsCurrentUser.id();
for (Long id : attachmentIds) {
if (id == null) {
continue;
}
mapper.updateAttachRef(id, type, refId, actor);
}
}
@Transactional(readOnly = true)
public List<AttachmentDto> list(String refType, Long refId) {
return mapper.findAttachByRef(normalizeRefType(refType), refId)
.stream().map(AttachmentDto::from).toList();
}
/** 대상 삭제 시 귀속 첨부 일괄 제거(물리파일 포함). */
@Transactional
public void deleteByRef(String refType, Long refId) {
String type = normalizeRefType(refType);
List<UiwsAttach> rows = mapper.findAttachByRef(type, refId);
for (UiwsAttach a : rows) {
fileStorageService.delete(a.getFilePath());
}
mapper.deleteAttachByRef(type, refId);
}
private String normalizeRefType(String refType) {
String type = refType == null ? "" : refType.trim().toUpperCase();
if (!VALID_REF_TYPES.contains(type)) {
throw new UiwsApiException(UiwsErrorCode.ATTACH_REF_TYPE_INVALID);
}
return type;
}
}

View File

@ -0,0 +1,133 @@
package com.zioinfo.esn.uiws.schedule.service;
import com.zioinfo.esn.uiws.common.UiwsApiException;
import com.zioinfo.esn.uiws.common.UiwsCurrentUser;
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
import com.zioinfo.esn.uiws.schedule.dto.ScheduleDtos.*;
import com.zioinfo.esn.uiws.schedule.mapper.ScheduleMapper;
import com.zioinfo.esn.uiws.schedule.model.UiwsDiary;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 일지(모듈 02) 서비스 원본 com.urp.uiws.schedule.service.DiaryService MyBatis 변환 이식.
* 목록(페이징)·CRUD·첨부 연계.
*/
@Service
@RequiredArgsConstructor
public class DiaryService {
private final ScheduleMapper mapper;
private final AttachmentService attachmentService;
@Transactional(readOnly = true)
public DiaryPage list(LocalDate fromDate, LocalDate toDate, int page, int size) {
LocalDate to = (toDate != null) ? toDate : LocalDate.now();
LocalDate from = (fromDate != null) ? fromDate : to.minusMonths(1);
long total = mapper.countDiary(from, to);
List<UiwsDiary> rows = mapper.searchDiary(from, to, size, page * size);
Map<String, String> names = userNames(rows.stream().map(UiwsDiary::getWriterId).toList());
List<DiaryDto> content = rows.stream()
.map(d -> new DiaryDto(d.getDiaryId(), d.getTitle(),
names.getOrDefault(d.getWriterId(), d.getWriterId()),
d.getDiaryDate() != null ? d.getDiaryDate().toString() : null,
d.getScheduleId()))
.toList();
return new DiaryPage(content, total, page, size, totalPages(total, size));
}
@Transactional
public DiaryDetailDto create(DiarySaveDto dto) {
String actor = UiwsCurrentUser.id();
UiwsDiary d = new UiwsDiary();
applySave(d, dto);
d.setWriterId(actor);
d.setCreatedBy(actor);
d.setCreatedAt(LocalDateTime.now());
mapper.insertDiary(d);
attachmentService.link(AttachmentService.REF_DIARY, d.getDiaryId(), dto.attachmentIds());
return toDetail(d);
}
@Transactional(readOnly = true)
public DiaryDetailDto detail(Long id) {
return toDetail(find(id));
}
@Transactional
public DiaryDetailDto update(Long id, DiarySaveDto dto) {
String actor = UiwsCurrentUser.id();
UiwsDiary d = find(id);
applySave(d, dto);
d.setUpdatedBy(actor);
d.setUpdatedAt(LocalDateTime.now());
mapper.updateDiary(d);
attachmentService.link(AttachmentService.REF_DIARY, d.getDiaryId(), dto.attachmentIds());
return toDetail(d);
}
@Transactional
public void delete(Long id) {
if (!mapper.existsDiaryById(id)) {
throw new UiwsApiException(UiwsErrorCode.DIARY_NOT_FOUND);
}
attachmentService.deleteByRef(AttachmentService.REF_DIARY, id);
mapper.deleteDiaryById(id);
}
// ================================================================== helpers
private void applySave(UiwsDiary d, DiarySaveDto dto) {
d.setTitle(dto.title());
d.setContent(dto.content());
d.setScheduleId(dto.scheduleId());
d.setDiaryDate(parseDateNullable(dto.diaryDate()));
}
private DiaryDetailDto toDetail(UiwsDiary d) {
String writerNm = userNames(List.of(d.getWriterId())).getOrDefault(d.getWriterId(), d.getWriterId());
List<AttachmentDto> attachments = attachmentService.list(AttachmentService.REF_DIARY, d.getDiaryId());
return new DiaryDetailDto(d.getDiaryId(), d.getTitle(), writerNm,
d.getDiaryDate() != null ? d.getDiaryDate().toString() : null,
d.getScheduleId(), d.getContent(), attachments);
}
private UiwsDiary find(Long id) {
UiwsDiary d = mapper.findDiaryById(id);
if (d == null) {
throw new UiwsApiException(UiwsErrorCode.DIARY_NOT_FOUND);
}
return d;
}
private Map<String, String> userNames(List<String> ids) {
List<String> clean = ids.stream().filter(s -> s != null && !s.isBlank()).distinct().toList();
if (clean.isEmpty()) {
return Map.of();
}
return mapper.findUserNames(clean).stream()
.collect(Collectors.toMap(r -> r.get("username"), r -> r.get("fullName"), (a, b) -> a));
}
private static int totalPages(long total, int size) {
return size <= 0 ? 0 : (int) ((total + size - 1) / size);
}
private static LocalDate parseDateNullable(String s) {
if (s == null || s.isBlank()) {
return null;
}
try {
return LocalDate.parse(s);
} catch (Exception e) {
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "날짜 형식이 올바르지 않습니다(yyyy-MM-dd).");
}
}
}

View File

@ -0,0 +1,93 @@
package com.zioinfo.esn.uiws.schedule.service;
import com.zioinfo.esn.uiws.common.UiwsApiException;
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
import com.zioinfo.esn.uiws.config.UiwsProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
/**
* 첨부파일 로컬 스토리지(UIWS 이식). 경로순회 방지(파일명 정규화 + UUID 저장명), 일자별 디렉터리 분리.
* 저장 결과로 상대경로(file_path) 반환한다.
*/
@Service
@RequiredArgsConstructor
public class FileStorageService {
private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("yyyy/MM/dd");
private final UiwsProperties properties;
public record StoredFile(String originalName, String relativePath, long size) {
}
public StoredFile store(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new UiwsApiException(UiwsErrorCode.FILE_EMPTY);
}
String original = StringUtils.cleanPath(
file.getOriginalFilename() != null ? file.getOriginalFilename() : "file");
original = original.replace("\\", "_").replace("/", "_");
if (original.contains("..")) {
original = original.replace("..", "_");
}
String ext = "";
int dot = original.lastIndexOf('.');
if (dot >= 0) {
ext = original.substring(dot);
}
String subDir = LocalDate.now().format(DAY);
String storedName = UUID.randomUUID().toString().replace("-", "") + ext;
String relativePath = subDir + "/" + storedName;
try {
Path root = Paths.get(properties.getUpload().getUploadDir()).toAbsolutePath().normalize();
Path target = root.resolve(relativePath).normalize();
if (!target.startsWith(root)) {
throw new UiwsApiException(UiwsErrorCode.FILE_STORAGE_ERROR, "허용되지 않은 저장 경로입니다.");
}
Files.createDirectories(target.getParent());
file.transferTo(target.toFile());
} catch (IOException e) {
throw new UiwsApiException(UiwsErrorCode.FILE_STORAGE_ERROR);
}
return new StoredFile(original, relativePath, file.getSize());
}
public Path resolve(String relativePath) {
if (relativePath == null || relativePath.isBlank()) {
throw new UiwsApiException(UiwsErrorCode.ATTACH_NOT_FOUND);
}
Path root = Paths.get(properties.getUpload().getUploadDir()).toAbsolutePath().normalize();
Path target = root.resolve(relativePath).normalize();
if (!target.startsWith(root)) {
throw new UiwsApiException(UiwsErrorCode.FILE_STORAGE_ERROR, "허용되지 않은 경로입니다.");
}
return target;
}
public void delete(String relativePath) {
if (relativePath == null || relativePath.isBlank()) {
return;
}
try {
Path root = Paths.get(properties.getUpload().getUploadDir()).toAbsolutePath().normalize();
Path target = root.resolve(relativePath).normalize();
if (target.startsWith(root)) {
Files.deleteIfExists(target);
}
} catch (IOException ignore) {
// 물리파일 삭제 실패는 무시(메타 일관성 우선)
}
}
}

View File

@ -0,0 +1,216 @@
package com.zioinfo.esn.uiws.schedule.service;
import com.zioinfo.esn.uiws.common.UiwsApiException;
import com.zioinfo.esn.uiws.common.UiwsCurrentUser;
import com.zioinfo.esn.uiws.common.UiwsDataScope;
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
import com.zioinfo.esn.uiws.schedule.dto.ScheduleDtos.*;
import com.zioinfo.esn.uiws.schedule.mapper.ScheduleMapper;
import com.zioinfo.esn.uiws.schedule.model.UiwsSchedule;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAdjusters;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 일정(모듈 02) 서비스 원본 com.urp.uiws.schedule.service.ScheduleService MyBatis 변환 이식.
* 달력(month/week/day)·전체목록·검색팝업·CRUD. 첨부 연계(attachmentIds).
*/
@Service
@RequiredArgsConstructor
public class ScheduleService {
private static final String TYPE_PERSONAL = "PERSONAL";
private static final String TYPE_DEPT = "DEPT";
private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
private final ScheduleMapper mapper;
private final AttachmentService attachmentService;
@Transactional(readOnly = true)
public List<ScheduleDto> calendar(String type, String view, LocalDate baseDate, String deptId) {
String t = normalizeType(type);
LocalDate base = (baseDate != null) ? baseDate : LocalDate.now();
LocalDate[] range = computeRange(view, base);
LocalDateTime from = range[0].atStartOfDay();
LocalDateTime to = range[1].atStartOfDay(); // 배타 상한
String ownerId = TYPE_PERSONAL.equals(t) ? UiwsCurrentUser.id() : null;
String deptFilter = TYPE_DEPT.equals(t) ? blankToNull(deptId) : null;
return mapper.findInRange(from, to, t, ownerId, deptFilter).stream().map(this::toDto).toList();
}
@Transactional(readOnly = true)
public SchedulePage all(LocalDate fromDate, LocalDate toDate, String type, int page, int size) {
LocalDate to = (toDate != null) ? toDate : LocalDate.now();
LocalDate from = (fromDate != null) ? fromDate : to.minusMonths(1);
String t = (type == null || type.isBlank()) ? null : normalizeType(type);
UiwsDataScope.Scope scope = UiwsDataScope.current();
LocalDateTime f = from.atStartOfDay();
LocalDateTime tt = to.plusDays(1).atStartOfDay();
long total = mapper.countAll(f, tt, t, scope.all(), scope.ownerIds());
List<ScheduleDto> content = mapper.searchAll(f, tt, t, scope.all(), scope.ownerIds(), size, page * size)
.stream().map(this::toDto).toList();
return new SchedulePage(content, total, page, size, totalPages(total, size));
}
@Transactional(readOnly = true)
public List<ScheduleDto> search(String keyword) {
UiwsDataScope.Scope scope = UiwsDataScope.current();
return mapper.searchPopup(blankToNull(keyword), scope.all(), scope.ownerIds())
.stream().map(this::toDto).toList();
}
@Transactional
public ScheduleDto create(ScheduleSaveDto dto) {
String actor = UiwsCurrentUser.id();
UiwsSchedule s = new UiwsSchedule();
applySave(s, dto);
s.setOwnerId(actor);
s.setCreatedBy(actor);
s.setCreatedAt(LocalDateTime.now());
mapper.insertSchedule(s);
attachmentService.link(AttachmentService.REF_SCHEDULE, s.getScheduleId(), dto.attachmentIds());
return toDto(s);
}
@Transactional(readOnly = true)
public ScheduleDetailDto detail(Long id) {
return toDetail(find(id));
}
@Transactional
public ScheduleDetailDto update(Long id, ScheduleSaveDto dto) {
String actor = UiwsCurrentUser.id();
UiwsSchedule s = find(id);
applySave(s, dto);
s.setUpdatedBy(actor);
s.setUpdatedAt(LocalDateTime.now());
mapper.updateSchedule(s);
attachmentService.link(AttachmentService.REF_SCHEDULE, s.getScheduleId(), dto.attachmentIds());
return toDetail(s);
}
@Transactional
public void delete(Long id) {
if (!mapper.existsScheduleById(id)) {
throw new UiwsApiException(UiwsErrorCode.SCHEDULE_NOT_FOUND);
}
attachmentService.deleteByRef(AttachmentService.REF_SCHEDULE, id);
mapper.deleteScheduleById(id);
}
// ================================================================== helpers
private void applySave(UiwsSchedule s, ScheduleSaveDto dto) {
LocalDateTime start = parseDateTime(dto.startDt());
LocalDateTime end = parseDateTime(dto.endDt());
if (end.isBefore(start)) {
throw new UiwsApiException(UiwsErrorCode.SCHEDULE_DT_INVALID, "종료 일시가 시작 일시보다 빠릅니다.");
}
s.setScheType(normalizeType(dto.scheType()));
s.setTitle(dto.title());
s.setScheGubunCd(blankToNull(dto.scheGubunCd()));
s.setImportanceCd(blankToNull(dto.importanceCd()));
s.setStartDt(start);
s.setEndDt(end);
s.setContent(dto.content());
s.setDeptId(blankToNull(dto.deptId()));
s.setChargerId(blankToNull(dto.chargerId()));
}
private ScheduleDto toDto(UiwsSchedule s) {
return new ScheduleDto(s.getScheduleId(), s.getScheType(), s.getTitle(),
s.getScheGubunCd(), s.getImportanceCd(), fmt(s.getStartDt()), fmt(s.getEndDt()),
s.getOwnerId(), s.getDeptId());
}
private ScheduleDetailDto toDetail(UiwsSchedule s) {
String chargerNm = null;
if (s.getChargerId() != null) {
chargerNm = userNames(List.of(s.getChargerId())).getOrDefault(s.getChargerId(), s.getChargerId());
}
List<AttachmentDto> attachments = attachmentService.list(AttachmentService.REF_SCHEDULE, s.getScheduleId());
return new ScheduleDetailDto(s.getScheduleId(), s.getScheType(), s.getTitle(),
s.getScheGubunCd(), s.getImportanceCd(), fmt(s.getStartDt()), fmt(s.getEndDt()),
s.getOwnerId(), s.getDeptId(), s.getContent(), s.getChargerId(), chargerNm, attachments);
}
private UiwsSchedule find(Long id) {
UiwsSchedule s = mapper.findScheduleById(id);
if (s == null) {
throw new UiwsApiException(UiwsErrorCode.SCHEDULE_NOT_FOUND);
}
return s;
}
private Map<String, String> userNames(List<String> ids) {
List<String> clean = ids.stream().filter(s -> s != null && !s.isBlank()).distinct().toList();
if (clean.isEmpty()) {
return Map.of();
}
return mapper.findUserNames(clean).stream()
.collect(Collectors.toMap(r -> r.get("username"), r -> r.get("fullName"), (a, b) -> a));
}
private LocalDate[] computeRange(String view, LocalDate base) {
String v = (view == null || view.isBlank()) ? "month" : view.trim().toLowerCase();
return switch (v) {
case "day" -> new LocalDate[]{base, base.plusDays(1)};
case "week" -> {
LocalDate start = base.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
yield new LocalDate[]{start, start.plusWeeks(1)};
}
case "month" -> {
LocalDate start = base.withDayOfMonth(1);
yield new LocalDate[]{start, start.plusMonths(1)};
}
default -> throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST,
"view 는 month/week/day 중 하나여야 합니다.");
};
}
private String normalizeType(String type) {
String t = (type == null) ? "" : type.trim().toUpperCase();
if (!TYPE_PERSONAL.equals(t) && !TYPE_DEPT.equals(t)) {
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "type 은 PERSONAL/DEPT 중 하나여야 합니다.");
}
return t;
}
private static int totalPages(long total, int size) {
return size <= 0 ? 0 : (int) ((total + size - 1) / size);
}
private static String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s;
}
private static String fmt(LocalDateTime dt) {
return dt != null ? dt.format(DT) : null;
}
private static LocalDateTime parseDateTime(String s) {
if (s == null || s.isBlank()) {
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "일시 값이 비어 있습니다.");
}
try {
if (s.length() <= 10) {
return LocalDate.parse(s).atStartOfDay();
}
return LocalDateTime.parse(s);
} catch (Exception e) {
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST,
"일시 형식이 올바르지 않습니다(yyyy-MM-ddTHH:mm:ss).");
}
}
}

View File

@ -0,0 +1,43 @@
package com.zioinfo.esn.uiws.stats.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.stats.dto.StatsDtos.PivotResponse;
import com.zioinfo.esn.uiws.stats.service.StatsService;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
/**
* UIWS 이식 업무통계(모듈 04, 동적 컬럼 피벗). 인증 필수(/api/stats).
* prefix /api/stats ERP 기존 라우트와 미충돌(확인). 원본 엔드포인트 보존.
*/
@RestController
@RequestMapping("/api/stats")
@RequiredArgsConstructor
public class StatsController {
private final StatsService statsService;
@GetMapping("/personal-work")
public ApiResponse<PivotResponse> personalWork(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
@RequestParam(required = false) String deptId,
@RequestParam(required = false) String userId,
@RequestParam(required = false) Boolean showStatus,
@RequestParam(required = false) Boolean showType) {
return ApiResponse.ok(statsService.personalWork(fromDate, toDate, deptId, userId, showStatus, showType));
}
@GetMapping("/company-work")
public ApiResponse<PivotResponse> companyWork(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
@RequestParam(required = false) String companyId,
@RequestParam(required = false) String userId,
@RequestParam(required = false) Boolean showType) {
return ApiResponse.ok(statsService.companyWork(fromDate, toDate, companyId, userId, showType));
}
}

View File

@ -0,0 +1,25 @@
package com.zioinfo.esn.uiws.stats.dto;
import java.util.List;
import java.util.Map;
/**
* 근무현황 통계(모듈 04) 동적 컬럼 피벗 DTO. 원본 com.urp.uiws.stats.dto.* 이식.
*/
public final class StatsDtos {
private StatsDtos() {
}
/** 피벗 열 메타. 고정열 예 {key:"worker",label:"근무자"}; 동적열 예 {key:"company_본사",label:"본사"}. */
public record PivotColumn(String key, String label) {
}
/** 동적 컬럼 피벗 응답. { fixedColumns, dynamicColumns, rows } */
public record PivotResponse(
List<PivotColumn> fixedColumns,
List<PivotColumn> dynamicColumns,
List<Map<String, Object>> rows
) {
}
}

View File

@ -0,0 +1,36 @@
package com.zioinfo.esn.uiws.stats.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
/**
* 근무현황 피벗 집계 매퍼. 원본 StatsRepository(JdbcTemplate 네이티브) MyBatis 변환 이식.
*
* silo 정합(코어 TB_USER/TB_COMPANY/TB_CODE 미이식):
* - 근무자 라벨: esn_user (writer_id=username 라벨). 미존재 writer_id 노출(COALESCE).
* - 근무처(company) 라벨: company_id 직접 사용(거래처 테이블 미이식).
* - 근무상태/근무유형 라벨: 코드값 직접 사용(공통코드 미이식).
* 결과는 long-form(그룹키 + cnt). 서비스가 동적 컬럼으로 피벗한다.
*/
@Mapper
public interface StatsMapper {
/** 개인별: 행=근무자×근무상태×근무유형, 동적열 차원=근무처. 컬럼: worker, work_status, work_type, dyn, cnt. */
List<Map<String, Object>> personalWork(@Param("from") LocalDate from,
@Param("to") LocalDate to,
@Param("userId") String userId,
@Param("scopeAll") boolean scopeAll,
@Param("ownerIds") List<String> ownerIds);
/** 업체별: 행=근무처×근무유형, 동적열 차원=근무자. 컬럼: company, work_type, dyn, cnt. */
List<Map<String, Object>> companyWork(@Param("from") LocalDate from,
@Param("to") LocalDate to,
@Param("companyId") String companyId,
@Param("userId") String userId,
@Param("scopeAll") boolean scopeAll,
@Param("ownerIds") List<String> ownerIds);
}

View File

@ -0,0 +1,165 @@
package com.zioinfo.esn.uiws.stats.service;
import com.zioinfo.esn.uiws.common.UiwsDataScope;
import com.zioinfo.esn.uiws.stats.dto.StatsDtos.PivotColumn;
import com.zioinfo.esn.uiws.stats.dto.StatsDtos.PivotResponse;
import com.zioinfo.esn.uiws.stats.mapper.StatsMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 근무현황 통계(모듈 04) 동적 컬럼 피벗 서비스. 원본 com.urp.uiws.stats.service.StatsService 이식.
* long-form 집계를 wide-form 으로 피벗. showStatus/showType 토글. 기본 조회기간 = 최근 1주일.
* 데이터 스코프(ADMIN=전체/ =본인) UiwsDataScope silo 판정.
*/
@Service
@RequiredArgsConstructor
public class StatsService {
/** 행 식별 키 구분자(데이터에 등장하지 않는 제어문자). */
private static final char SEP = '';
private final StatsMapper mapper;
@Transactional(readOnly = true)
public PivotResponse personalWork(LocalDate fromDate, LocalDate toDate,
String deptId, String userId,
Boolean showStatus, Boolean showType) {
LocalDate[] range = range(fromDate, toDate);
UiwsDataScope.Scope scope = UiwsDataScope.current();
// deptId 코어 dept 미이식으로 silo 에서 무시(파라미터 호환만 유지).
List<Map<String, Object>> agg = mapper.personalWork(
range[0], range[1], emptyToNull(userId), scope.all(), scope.ownerIds());
boolean status = !Boolean.FALSE.equals(showStatus);
boolean type = !Boolean.FALSE.equals(showType);
List<PivotColumn> fixed = new ArrayList<>();
fixed.add(new PivotColumn("worker", "근무자"));
if (status) {
fixed.add(new PivotColumn("workStatus", "근무상태"));
}
if (type) {
fixed.add(new PivotColumn("workType", "근무유형"));
}
return pivot(agg, fixed, "company",
row -> rowKey(row, status, type),
row -> {
Map<String, Object> base = new LinkedHashMap<>();
base.put("worker", str(row.get("worker")));
if (status) {
base.put("workStatus", str(row.get("work_status")));
}
if (type) {
base.put("workType", str(row.get("work_type")));
}
return base;
});
}
@Transactional(readOnly = true)
public PivotResponse companyWork(LocalDate fromDate, LocalDate toDate,
String companyId, String userId,
Boolean showType) {
LocalDate[] range = range(fromDate, toDate);
UiwsDataScope.Scope scope = UiwsDataScope.current();
List<Map<String, Object>> agg = mapper.companyWork(
range[0], range[1], emptyToNull(companyId), emptyToNull(userId), scope.all(), scope.ownerIds());
boolean type = !Boolean.FALSE.equals(showType);
List<PivotColumn> fixed = new ArrayList<>();
fixed.add(new PivotColumn("company", "근무처"));
if (type) {
fixed.add(new PivotColumn("workType", "근무유형"));
}
return pivot(agg, fixed, "worker",
row -> str(row.get("company")) + SEP + (type ? str(row.get("work_type")) : ""),
row -> {
Map<String, Object> base = new LinkedHashMap<>();
base.put("company", str(row.get("company")));
if (type) {
base.put("workType", str(row.get("work_type")));
}
return base;
});
}
// ================================================================== 피벗 공통
private interface RowKeyFn {
String key(Map<String, Object> row);
}
private interface BaseFn {
Map<String, Object> base(Map<String, Object> row);
}
private PivotResponse pivot(List<Map<String, Object>> agg, List<PivotColumn> fixed,
String dynPrefix, RowKeyFn keyFn, BaseFn baseFn) {
Map<String, PivotColumn> dynCols = new LinkedHashMap<>();
Map<String, Map<String, Object>> rowMap = new LinkedHashMap<>();
for (Map<String, Object> r : agg) {
String dynLabel = str(r.get("dyn"));
String dynKey = dynPrefix + "_" + dynLabel;
dynCols.putIfAbsent(dynKey, new PivotColumn(dynKey, dynLabel));
String rk = keyFn.key(r);
Map<String, Object> row = rowMap.computeIfAbsent(rk, k -> baseFn.base(r));
long cnt = toLong(r.get("cnt"));
row.merge(dynKey, cnt, (a, b) -> toLong(a) + toLong(b));
}
List<PivotColumn> dynamic = new ArrayList<>(dynCols.values());
List<Map<String, Object>> rows = new ArrayList<>();
for (Map<String, Object> row : rowMap.values()) {
for (PivotColumn dc : dynamic) {
row.putIfAbsent(dc.key(), 0L);
}
rows.add(row);
}
return new PivotResponse(fixed, dynamic, rows);
}
private String rowKey(Map<String, Object> row, boolean status, boolean type) {
StringBuilder sb = new StringBuilder(str(row.get("worker")));
if (status) {
sb.append(SEP).append(str(row.get("work_status")));
}
if (type) {
sb.append(SEP).append(str(row.get("work_type")));
}
return sb.toString();
}
private LocalDate[] range(LocalDate fromDate, LocalDate toDate) {
LocalDate to = (toDate != null) ? toDate : LocalDate.now();
LocalDate from = (fromDate != null) ? fromDate : to.minusWeeks(1);
return new LocalDate[]{from, to};
}
private static String emptyToNull(String s) {
return (s == null || s.isBlank()) ? null : s;
}
private static String str(Object o) {
return o == null ? "" : o.toString();
}
private static long toLong(Object o) {
if (o == null) {
return 0L;
}
return (o instanceof Number n) ? n.longValue() : Long.parseLong(o.toString());
}
}

View File

@ -0,0 +1,94 @@
package com.zioinfo.esn.uiws.worklog.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.worklog.dto.WorklogDtos.*;
import com.zioinfo.esn.uiws.worklog.service.WorklogService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.List;
/**
* UIWS 이식 업무일지(모듈 06). 인증 필수(/api/worklogs).
* 원본 /pdf(JasperReports) 엔드포인트는 ERP 의존성 미보유로 제외(보고서는 후속 트랙 esn_port.md 참조).
*/
@RestController
@RequestMapping("/api/worklogs")
@RequiredArgsConstructor
public class WorklogController {
private final WorklogService worklogService;
@GetMapping
public ApiResponse<WorklogPage> list(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
@RequestParam(required = false) String writerId,
@RequestParam(required = false) String progressCd,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.ok(worklogService.list(fromDate, toDate, writerId, progressCd, page, size));
}
@GetMapping("/dashboard/progress")
public ApiResponse<List<ProgressSummaryDto>> progressSummary(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate) {
return ApiResponse.ok(worklogService.progressSummary(fromDate, toDate));
}
@GetMapping("/calendar")
public ApiResponse<List<WorklogCalendarDto>> calendar(
@RequestParam(required = false) String yearMonth,
@RequestParam(required = false) String writerId) {
return ApiResponse.ok(worklogService.calendar(yearMonth, writerId));
}
@GetMapping("/search")
public ApiResponse<List<WorklogListDto>> search(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String writerId) {
return ApiResponse.ok(worklogService.search(keyword, writerId));
}
@PostMapping
public ApiResponse<WorklogDetailDto> create(@Valid @RequestBody WorklogSaveDto dto) {
return ApiResponse.ok(worklogService.create(dto));
}
@GetMapping("/{id}")
public ApiResponse<WorklogDetailDto> detail(@PathVariable("id") Long id) {
return ApiResponse.ok(worklogService.detail(id));
}
@PutMapping("/{id}")
public ApiResponse<WorklogDetailDto> update(@PathVariable("id") Long id, @Valid @RequestBody WorklogSaveDto dto) {
return ApiResponse.ok(worklogService.update(id, dto));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable("id") Long id) {
worklogService.delete(id);
return ApiResponse.ok(null);
}
@PostMapping("/{id}/comments")
public ApiResponse<WorklogCommentDto> addComment(@PathVariable("id") Long id,
@Valid @RequestBody CommentRequest req) {
return ApiResponse.ok(worklogService.addComment(id, req));
}
@PostMapping("/comments/{cmtId}/confirm")
public ApiResponse<Void> confirmComment(@PathVariable("cmtId") Long cmtId) {
worklogService.confirmComment(cmtId);
return ApiResponse.ok(null);
}
@GetMapping("/comments/unconfirmed")
public ApiResponse<List<UnconfirmedCommentDto>> unconfirmedComments() {
return ApiResponse.ok(worklogService.unconfirmedByMe());
}
}

View File

@ -0,0 +1,118 @@
package com.zioinfo.esn.uiws.worklog.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.zioinfo.esn.uiws.worklog.model.UiwsWorklogDtl;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.List;
/**
* 업무일지(모듈 06) DTO. 원본 com.urp.uiws.worklog.dto.* 이식(shape 동일, _state 보존).
*/
public final class WorklogDtos {
private WorklogDtos() {
}
public record WorklogListDto(
Long worklogId,
String title,
String writerNm,
String workDate,
String progressCd,
long commentCount
) {
}
public record WorklogCalendarDto(
String workDate,
Long worklogId,
String workStatusNm,
boolean isHoliday,
String holidayNm
) {
}
public record WorklogDetailDto(
Long worklogId,
String title,
String writerId,
String writerNm,
String workDate,
String workStatusCd,
String progressCd,
String repeatYn,
String repeatStartDate,
String repeatEndDate,
List<WorklogDtlDto> details,
List<WorklogCommentDto> comments
) {
}
public record WorklogDtlDto(
Long dtlId,
Integer startHour,
Integer endHour,
String workTypeCd,
String companyId,
String workContent,
String issueContent,
Integer sortOrd,
String createdAt,
String updatedAt,
@JsonProperty("_state") String state
) {
public static WorklogDtlDto from(UiwsWorklogDtl d) {
return new WorklogDtlDto(
d.getDtlId(), d.getStartHour(), d.getEndHour(), d.getWorkTypeCd(), d.getCompanyId(),
d.getWorkContent(), d.getIssueContent(), d.getSortOrd(),
d.getCreatedAt() != null ? d.getCreatedAt().toString() : null,
d.getUpdatedAt() != null ? d.getUpdatedAt().toString() : null,
null);
}
}
public record WorklogSaveDto(
@NotBlank String title,
@NotBlank String writerId,
@NotNull String workDate,
@NotBlank String workStatusCd,
String progressCd,
String repeatYn,
String repeatStartDate,
String repeatEndDate,
@Valid List<WorklogDtlDto> details
) {
}
public record WorklogCommentDto(
Long cmtId,
String cmtContent,
String writerNm,
String createdAt,
String kakaoSentYn,
String confirmYn
) {
}
public record CommentRequest(@NotBlank String cmtContent) {
}
public record ProgressSummaryDto(String progressCd, String progressNm, long count) {
}
public record UnconfirmedCommentDto(
Long cmtId,
Long worklogId,
String worklogTitle,
String writerNm,
String cmtContent,
String createdAt
) {
}
public record WorklogPage(List<WorklogListDto> content, long totalElements, int page, int size, int totalPages) {
}
}

View File

@ -0,0 +1,96 @@
package com.zioinfo.esn.uiws.worklog.mapper;
import com.zioinfo.esn.uiws.worklog.model.UiwsWorklog;
import com.zioinfo.esn.uiws.worklog.model.UiwsWorklogCmt;
import com.zioinfo.esn.uiws.worklog.model.UiwsWorklogDtl;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 업무일지 MyBatis 매퍼. 원본 JPA Worklog/WorklogDtl/WorklogCmt Repository 변환 이식.
* 진행상태 집계는 ported tb_uiws_worklog 기반(코어 TB_CODE 미이식 코드값 직접 집계).
*/
@Mapper
public interface WorklogMapper {
// worklog 헤더
int insertWorklog(UiwsWorklog w);
int updateWorklog(UiwsWorklog w);
UiwsWorklog findWorklogById(@Param("worklogId") Long worklogId);
boolean existsWorklogById(@Param("worklogId") Long worklogId);
int deleteWorklogById(@Param("worklogId") Long worklogId);
List<UiwsWorklog> searchList(@Param("from") LocalDate from,
@Param("to") LocalDate to,
@Param("writerId") String writerId,
@Param("progressCd") String progressCd,
@Param("scopeAll") boolean scopeAll,
@Param("ownerIds") List<String> ownerIds,
@Param("limit") int limit,
@Param("offset") int offset);
long countList(@Param("from") LocalDate from,
@Param("to") LocalDate to,
@Param("writerId") String writerId,
@Param("progressCd") String progressCd,
@Param("scopeAll") boolean scopeAll,
@Param("ownerIds") List<String> ownerIds);
List<UiwsWorklog> findByMonth(@Param("from") LocalDate from,
@Param("to") LocalDate to,
@Param("writerId") String writerId,
@Param("scopeAll") boolean scopeAll,
@Param("ownerIds") List<String> ownerIds);
List<UiwsWorklog> searchPopup(@Param("keyword") String keyword,
@Param("writerId") String writerId,
@Param("scopeAll") boolean scopeAll,
@Param("ownerIds") List<String> ownerIds);
/** 진행상태별 건수(progress_cd, cnt). */
List<Map<String, Object>> progressSummary(@Param("from") LocalDate from,
@Param("to") LocalDate to,
@Param("scopeAll") boolean scopeAll,
@Param("ownerIds") List<String> ownerIds);
// 상세(dtl)
int insertDtl(UiwsWorklogDtl d);
int updateDtl(UiwsWorklogDtl d);
int deleteDtlById(@Param("dtlId") Long dtlId);
List<UiwsWorklogDtl> findDtlByWorklogId(@Param("worklogId") Long worklogId);
// 댓글(cmt)
int insertCmt(UiwsWorklogCmt c);
UiwsWorklogCmt findCmtById(@Param("cmtId") Long cmtId);
int updateCmtKakaoSent(@Param("cmtId") Long cmtId);
int confirmCmt(@Param("cmtId") Long cmtId, @Param("actor") String actor);
List<UiwsWorklogCmt> findCmtByWorklogId(@Param("worklogId") Long worklogId);
List<Map<String, Object>> countCommentsByWorklogIds(@Param("ids") List<Long> ids);
List<UiwsWorklogCmt> findUnconfirmedByWriter(@Param("writerId") String writerId);
// 사용자명 라벨(코어 user 재사용)
List<Map<String, String>> findUserNames(@Param("ids") List<String> ids);
/** 댓글 알림 메일 발송 대상 이메일(username 기준). */
String findUserEmail(@Param("username") String username);
List<UiwsWorklog> findWorklogsByIds(@Param("ids") List<Long> ids);
}

View File

@ -0,0 +1,23 @@
package com.zioinfo.esn.uiws.worklog.model;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** 업무일지 헤더 (tb_uiws_worklog). 원본 com.urp.uiws.domain.Worklog 이식. */
@Data
public class UiwsWorklog {
private Long worklogId;
private String title;
private String writerId;
private LocalDate workDate;
private String workStatusCd;
private String progressCd; // ONGOING | DONE
private String repeatYn; // Y | N
private LocalDate repeatStartDate;
private LocalDate repeatEndDate;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.esn.uiws.worklog.model;
import lombok.Data;
import java.time.LocalDateTime;
/** 업무일지 댓글 (tb_uiws_worklog_cmt). 원본 com.urp.uiws.domain.WorklogCmt 이식. */
@Data
public class UiwsWorklogCmt {
private Long cmtId;
private Long worklogId;
private String cmtContent;
private String writerId;
private String kakaoSentYn;
private String confirmYn;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,22 @@
package com.zioinfo.esn.uiws.worklog.model;
import lombok.Data;
import java.time.LocalDateTime;
/** 업무일지 시간대별 상세 (tb_uiws_worklog_dtl). 원본 com.urp.uiws.domain.WorklogDtl 이식. */
@Data
public class UiwsWorklogDtl {
private Long dtlId;
private Long worklogId;
private Integer startHour;
private Integer endHour;
private String workTypeCd;
private String companyId;
private String workContent;
private String issueContent;
private Integer sortOrd;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,31 @@
package com.zioinfo.esn.uiws.worklog.service;
import com.zioinfo.esn.uiws.common.mail.MailSender;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 업무일지 댓글 알림 원본 NaverWorksNotifier(외부 메신저) 대신 MailSender 폴백으로 이식.
* 보안 불변규칙(외부 API 금지) 준수: 외부 메신저 호출 없이 메일/로그 채널로만 알림한다.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class WorklogNotifier {
private final MailSender mailSender;
/**
* 일지 작성자에게 댓글 알림. 발송되면 true(=KAKAO_SENT_YN 'Y' 선반영).
* email 미보유 로그만 남기고 false.
*/
public boolean notifyComment(String writerId, String email, String subject, String body) {
if (email == null || email.isBlank()) {
log.info("[UIWS-WL-NOTIFY] no email for writer={} (skip mail, log only)", writerId);
return false;
}
mailSender.send(email, subject, body);
return true;
}
}

View File

@ -0,0 +1,438 @@
package com.zioinfo.esn.uiws.worklog.service;
import com.zioinfo.esn.uiws.common.UiwsApiException;
import com.zioinfo.esn.uiws.common.UiwsCurrentUser;
import com.zioinfo.esn.uiws.common.UiwsDataScope;
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
import com.zioinfo.esn.uiws.worklog.dto.WorklogDtos.*;
import com.zioinfo.esn.uiws.worklog.mapper.WorklogMapper;
import com.zioinfo.esn.uiws.worklog.model.UiwsWorklog;
import com.zioinfo.esn.uiws.worklog.model.UiwsWorklogCmt;
import com.zioinfo.esn.uiws.worklog.model.UiwsWorklogDtl;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 업무일지(모듈 06) 서비스 원본 com.urp.uiws.worklog.service.WorklogService MyBatis 변환 이식.
*
* silo 단순화(코어 TB_CODE/TB_DEPT 미이식):
* - 근무상태/진행 코드 라벨: 코드값 그대로 노출(공통코드 미이식). 진행 라벨은 ONGOING/DONE 한글 매핑만 내장.
* - 진행상태 집계: ONGOING/DONE 고정 0건 포함 반환(차트 범례 안정).
* - 댓글 권한: 원본 "관할 상무 이상"(부서계층+직급) ERP RBAC MANAGER/ADMIN/CFO 완화 이식.
* - 댓글 알림: NaverWorks(외부) MailSender 폴백(외부 API 금지 준수).
*/
@Service
@RequiredArgsConstructor
public class WorklogService {
private static final String DEFAULT_PROGRESS = "ONGOING";
private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
/** 진행 코드 → 라벨(공통코드 미이식 silo 보정). */
private static final Map<String, String> PROGRESS_LABEL = Map.of("ONGOING", "진행중", "DONE", "종료");
private final WorklogMapper mapper;
private final WorklogNotifier notifier;
// ------------------------------------------------------------------ 목록(리스트형)
@Transactional(readOnly = true)
public WorklogPage list(LocalDate fromDate, LocalDate toDate, String writerId, String progressCd, int page, int size) {
LocalDate to = (toDate != null) ? toDate : LocalDate.now();
LocalDate from = (fromDate != null) ? fromDate : to.minusMonths(1);
UiwsDataScope.Scope scope = UiwsDataScope.current();
String wid = blankToNull(writerId);
String pc = blankToNull(progressCd);
long total = mapper.countList(from, to, wid, pc, scope.all(), scope.ownerIds());
List<UiwsWorklog> rows = mapper.searchList(from, to, wid, pc, scope.all(), scope.ownerIds(), size, page * size);
return new WorklogPage(toListDtos(rows), total, page, size, totalPages(total, size));
}
// ------------------------------------------------------------------ 목록(달력형)
@Transactional(readOnly = true)
public List<WorklogCalendarDto> calendar(String yearMonth, String writerId) {
YearMonth ym = parseYearMonth(yearMonth);
LocalDate from = ym.atDay(1);
LocalDate to = ym.atEndOfMonth();
UiwsDataScope.Scope scope = UiwsDataScope.current();
List<UiwsWorklog> rows = mapper.findByMonth(from, to, blankToNull(writerId), scope.all(), scope.ownerIds());
// 공휴일 마스터 미연동 isHoliday=false. 근무상태 라벨은 코드값 노출(공통코드 미이식).
return rows.stream()
.map(w -> new WorklogCalendarDto(w.getWorkDate().toString(), w.getWorklogId(),
w.getWorkStatusCd(), false, null))
.toList();
}
// ------------------------------------------------------------------ 조회 팝업
@Transactional(readOnly = true)
public List<WorklogListDto> search(String keyword, String writerId) {
UiwsDataScope.Scope scope = UiwsDataScope.current();
List<UiwsWorklog> rows = mapper.searchPopup(blankToNull(keyword), blankToNull(writerId), scope.all(), scope.ownerIds());
return toListDtos(rows);
}
// ------------------------------------------------------------------ 대시보드: 진행상태별 집계
@Transactional(readOnly = true)
public List<ProgressSummaryDto> progressSummary(LocalDate fromDate, LocalDate toDate) {
LocalDate to = (toDate != null) ? toDate : LocalDate.now();
LocalDate from = (fromDate != null) ? fromDate : to.minusMonths(1);
UiwsDataScope.Scope scope = UiwsDataScope.current();
Map<String, Long> counts = new LinkedHashMap<>();
for (Map<String, Object> r : mapper.progressSummary(from, to, scope.all(), scope.ownerIds())) {
counts.put(str(r.get("progressCd")), toLong(r.get("cnt")));
}
// 코드 정의 순서(진행중종료) 0건 포함 반환.
List<ProgressSummaryDto> out = new ArrayList<>();
for (String code : List.of("ONGOING", "DONE")) {
out.add(new ProgressSummaryDto(code, PROGRESS_LABEL.getOrDefault(code, code), counts.getOrDefault(code, 0L)));
}
// 정의 코드값도 누락 없이 추가
counts.forEach((k, v) -> {
if (!"ONGOING".equals(k) && !"DONE".equals(k)) {
out.add(new ProgressSummaryDto(k, k, v));
}
});
return out;
}
// ------------------------------------------------------------------ 등록
@Transactional
public WorklogDetailDto create(WorklogSaveDto dto) {
String actor = UiwsCurrentUser.id();
UiwsWorklog w = new UiwsWorklog();
applyHeader(w, dto);
w.setWriterId((dto.writerId() != null && !dto.writerId().isBlank()) ? dto.writerId() : actor);
w.setCreatedBy(actor);
w.setCreatedAt(LocalDateTime.now());
mapper.insertWorklog(w);
List<WorklogDtlDto> toInsert = (dto.details() == null) ? List.of()
: dto.details().stream().filter(d -> !"DEL".equalsIgnoreCase(safeState(d.state()))).toList();
validateNoOverlap(toInsert);
for (WorklogDtlDto d : toInsert) {
mapper.insertDtl(newDtl(w.getWorklogId(), d, actor));
}
return detail(w.getWorklogId());
}
// ------------------------------------------------------------------ 상세
@Transactional(readOnly = true)
public WorklogDetailDto detail(Long worklogId) {
return toDetail(findWorklog(worklogId));
}
// ------------------------------------------------------------------ 수정(시간대별 일괄, _state)
@Transactional
public WorklogDetailDto update(Long worklogId, WorklogSaveDto dto) {
String actor = UiwsCurrentUser.id();
UiwsWorklog w = findWorklog(worklogId);
applyHeader(w, dto);
if (dto.writerId() != null && !dto.writerId().isBlank()) {
w.setWriterId(dto.writerId());
}
w.setUpdatedBy(actor);
w.setUpdatedAt(LocalDateTime.now());
mapper.updateWorklog(w);
List<UiwsWorklogDtl> existing = mapper.findDtlByWorklogId(worklogId);
Map<Long, UiwsWorklogDtl> existingById = existing.stream()
.collect(Collectors.toMap(UiwsWorklogDtl::getDtlId, d -> d));
List<WorklogDtlDto> details = (dto.details() == null) ? List.of() : dto.details();
List<WorklogDtlDto> survivors = new ArrayList<>();
for (WorklogDtlDto d : details) {
String st = safeState(d.state());
if ("DEL".equalsIgnoreCase(st)) {
if (d.dtlId() != null && existingById.containsKey(d.dtlId())) {
mapper.deleteDtlById(d.dtlId());
}
continue;
}
if ("MOD".equalsIgnoreCase(st) && d.dtlId() != null) {
UiwsWorklogDtl tgt = existingById.get(d.dtlId());
if (tgt == null) {
throw new UiwsApiException(UiwsErrorCode.WORKLOG_NOT_FOUND, "수정 대상 상세를 찾을 수 없습니다.");
}
applyDtl(tgt, d);
tgt.setUpdatedBy(actor);
tgt.setUpdatedAt(LocalDateTime.now());
mapper.updateDtl(tgt);
survivors.add(d);
continue;
}
// _state 미표기 + 기존 dtlId 보유 = 미변경 유지행 INSERT 금지
if (d.dtlId() != null && existingById.containsKey(d.dtlId())) {
survivors.add(d);
continue;
}
// ADD 또는 진짜 신규 INSERT
mapper.insertDtl(newDtl(worklogId, d, actor));
survivors.add(d);
}
validateNoOverlap(survivors);
return detail(worklogId);
}
// ------------------------------------------------------------------ 삭제
@Transactional
public void delete(Long worklogId) {
if (!mapper.existsWorklogById(worklogId)) {
throw new UiwsApiException(UiwsErrorCode.WORKLOG_NOT_FOUND);
}
// 상세/댓글은 DB ON DELETE CASCADE. 헤더 삭제로 일괄 제거.
mapper.deleteWorklogById(worklogId);
}
// ------------------------------------------------------------------ 댓글 등록(+알림)
@Transactional
public WorklogCommentDto addComment(Long worklogId, CommentRequest req) {
String actor = UiwsCurrentUser.id();
UiwsWorklog w = findWorklog(worklogId);
assertCanComment();
UiwsWorklogCmt c = new UiwsWorklogCmt();
c.setWorklogId(worklogId);
c.setCmtContent(req.cmtContent());
c.setWriterId(actor);
c.setKakaoSentYn("N");
c.setConfirmYn("N");
c.setCreatedBy(actor);
c.setCreatedAt(LocalDateTime.now());
mapper.insertCmt(c);
// 알림: 일지 작성자에게 메일 폴백(외부 메신저 미사용).
String writerEmail = userEmail(w.getWriterId());
String subject = "[UIWS] 업무일지 새 댓글: " + w.getTitle();
String body = "업무일지에 새 댓글이 등록되었습니다.\n내용: " + req.cmtContent();
boolean sent = notifier.notifyComment(w.getWriterId(), writerEmail, subject, body);
if (sent) {
mapper.updateCmtKakaoSent(c.getCmtId());
c.setKakaoSentYn("Y");
}
String writerNm = userNames(List.of(actor)).getOrDefault(actor, actor);
return new WorklogCommentDto(c.getCmtId(), c.getCmtContent(), writerNm,
fmt(c.getCreatedAt()), c.getKakaoSentYn(), c.getConfirmYn());
}
// ------------------------------------------------------------------ 댓글 확인(작성자 전용)
@Transactional
public void confirmComment(Long cmtId) {
String actor = UiwsCurrentUser.id();
UiwsWorklogCmt c = mapper.findCmtById(cmtId);
if (c == null) {
throw new UiwsApiException(UiwsErrorCode.WORKLOG_NOT_FOUND, "댓글을 찾을 수 없습니다.");
}
UiwsWorklog w = findWorklog(c.getWorklogId());
if (!w.getWriterId().equals(actor)) {
throw new UiwsApiException(UiwsErrorCode.FORBIDDEN, "본인 업무일지의 댓글만 확인 처리할 수 있습니다.");
}
mapper.confirmCmt(cmtId, actor);
}
// ------------------------------------------------------------------ 내가 댓글 작성자 미확인 목록
@Transactional(readOnly = true)
public List<UnconfirmedCommentDto> unconfirmedByMe() {
String me = UiwsCurrentUser.id();
List<UiwsWorklogCmt> rows = mapper.findUnconfirmedByWriter(me);
if (rows.isEmpty()) {
return List.of();
}
List<Long> worklogIds = rows.stream().map(UiwsWorklogCmt::getWorklogId).distinct().toList();
Map<Long, UiwsWorklog> worklogs = mapper.findWorklogsByIds(worklogIds).stream()
.collect(Collectors.toMap(UiwsWorklog::getWorklogId, wl -> wl));
Map<String, String> writerNames = userNames(
worklogs.values().stream().map(UiwsWorklog::getWriterId).toList());
return rows.stream().map(c -> {
UiwsWorklog wl = worklogs.get(c.getWorklogId());
return new UnconfirmedCommentDto(c.getCmtId(), c.getWorklogId(),
wl != null ? wl.getTitle() : null,
wl != null ? writerNames.getOrDefault(wl.getWriterId(), wl.getWriterId()) : null,
c.getCmtContent(), fmt(c.getCreatedAt()));
}).toList();
}
// ================================================================== helpers
/** 댓글 권한(silo 완화): ERP RBAC MANAGER/ADMIN/CFO 만 작성 가능. */
private void assertCanComment() {
if (!UiwsCurrentUser.isManagerOrAbove()) {
throw new UiwsApiException(UiwsErrorCode.FORBIDDEN, "댓글은 관리자/매니저 권한만 작성할 수 있습니다.");
}
}
private List<WorklogListDto> toListDtos(List<UiwsWorklog> rows) {
Map<Long, Long> cmtCounts = commentCounts(rows.stream().map(UiwsWorklog::getWorklogId).toList());
Map<String, String> writerNames = userNames(rows.stream().map(UiwsWorklog::getWriterId).toList());
return rows.stream()
.map(w -> new WorklogListDto(w.getWorklogId(), w.getTitle(),
writerNames.getOrDefault(w.getWriterId(), w.getWriterId()),
w.getWorkDate().toString(), w.getProgressCd(),
cmtCounts.getOrDefault(w.getWorklogId(), 0L)))
.toList();
}
private WorklogDetailDto toDetail(UiwsWorklog w) {
List<UiwsWorklogDtl> dtls = mapper.findDtlByWorklogId(w.getWorklogId());
List<UiwsWorklogCmt> cmts = mapper.findCmtByWorklogId(w.getWorklogId());
List<String> nameIds = new ArrayList<>();
nameIds.add(w.getWriterId());
cmts.forEach(c -> nameIds.add(c.getWriterId()));
Map<String, String> writerNames = userNames(nameIds);
List<WorklogDtlDto> details = dtls.stream().map(WorklogDtlDto::from).toList();
List<WorklogCommentDto> comments = cmts.stream()
.map(c -> new WorklogCommentDto(c.getCmtId(), c.getCmtContent(),
writerNames.getOrDefault(c.getWriterId(), c.getWriterId()),
fmt(c.getCreatedAt()), c.getKakaoSentYn(), c.getConfirmYn()))
.toList();
return new WorklogDetailDto(w.getWorklogId(), w.getTitle(), w.getWriterId(),
writerNames.getOrDefault(w.getWriterId(), w.getWriterId()), w.getWorkDate().toString(),
w.getWorkStatusCd(), w.getProgressCd(), w.getRepeatYn(),
w.getRepeatStartDate() != null ? w.getRepeatStartDate().toString() : null,
w.getRepeatEndDate() != null ? w.getRepeatEndDate().toString() : null,
details, comments);
}
private void applyHeader(UiwsWorklog w, WorklogSaveDto dto) {
w.setTitle(dto.title());
w.setWorkDate(parseDate(dto.workDate()));
w.setWorkStatusCd(dto.workStatusCd());
w.setProgressCd((dto.progressCd() != null && !dto.progressCd().isBlank()) ? dto.progressCd() : DEFAULT_PROGRESS);
w.setRepeatYn((dto.repeatYn() != null && !dto.repeatYn().isBlank()) ? dto.repeatYn() : "N");
w.setRepeatStartDate(parseDateNullable(dto.repeatStartDate()));
w.setRepeatEndDate(parseDateNullable(dto.repeatEndDate()));
}
private UiwsWorklogDtl newDtl(Long worklogId, WorklogDtlDto d, String actor) {
UiwsWorklogDtl e = new UiwsWorklogDtl();
e.setWorklogId(worklogId);
applyDtl(e, d);
e.setCreatedBy(actor);
e.setCreatedAt(LocalDateTime.now());
return e;
}
private void applyDtl(UiwsWorklogDtl e, WorklogDtlDto d) {
if (d.startHour() == null || d.endHour() == null
|| d.startHour() < 0 || d.endHour() > 24 || d.endHour() < d.startHour()) {
throw new UiwsApiException(UiwsErrorCode.WORKLOG_TIME_INVALID);
}
e.setStartHour(d.startHour());
e.setEndHour(d.endHour());
e.setWorkTypeCd(d.workTypeCd());
e.setCompanyId(d.companyId());
e.setWorkContent(d.workContent());
e.setIssueContent(d.issueContent());
e.setSortOrd(d.sortOrd() != null ? d.sortOrd() : 0);
}
private void validateNoOverlap(List<WorklogDtlDto> details) {
List<WorklogDtlDto> rows = details.stream()
.filter(d -> d.startHour() != null && d.endHour() != null)
.sorted((a, b) -> Integer.compare(a.startHour(), b.startHour()))
.toList();
for (int i = 1; i < rows.size(); i++) {
WorklogDtlDto prev = rows.get(i - 1);
WorklogDtlDto cur = rows.get(i);
if (cur.startHour() < prev.endHour()) {
throw new UiwsApiException(UiwsErrorCode.WORKLOG_TIME_OVERLAP);
}
}
}
private UiwsWorklog findWorklog(Long id) {
UiwsWorklog w = mapper.findWorklogById(id);
if (w == null) {
throw new UiwsApiException(UiwsErrorCode.WORKLOG_NOT_FOUND);
}
return w;
}
private Map<Long, Long> commentCounts(List<Long> worklogIds) {
if (worklogIds == null || worklogIds.isEmpty()) {
return Map.of();
}
Map<Long, Long> result = new LinkedHashMap<>();
for (Map<String, Object> cc : mapper.countCommentsByWorklogIds(worklogIds)) {
result.put(toLong(cc.get("worklogId")), toLong(cc.get("cnt")));
}
return result;
}
private Map<String, String> userNames(List<String> userIds) {
List<String> ids = userIds.stream().filter(s -> s != null && !s.isBlank()).distinct().toList();
if (ids.isEmpty()) {
return Map.of();
}
return mapper.findUserNames(ids).stream()
.collect(Collectors.toMap(r -> r.get("username"), r -> r.get("fullName"), (a, b) -> a));
}
/** writerId(username) → email (댓글 알림 메일 발송용). 미보유 시 null. */
private String userEmail(String writerId) {
if (writerId == null || writerId.isBlank()) {
return null;
}
return mapper.findUserEmail(writerId);
}
private static int totalPages(long total, int size) {
return size <= 0 ? 0 : (int) ((total + size - 1) / size);
}
private static String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s;
}
private static String safeState(String s) {
return s == null ? "" : s.trim();
}
private static String str(Object o) {
return o == null ? "" : o.toString();
}
private static long toLong(Object o) {
if (o == null) {
return 0L;
}
return (o instanceof Number n) ? n.longValue() : Long.parseLong(o.toString());
}
private static String fmt(LocalDateTime dt) {
return dt != null ? dt.format(DT) : null;
}
private static LocalDate parseDate(String s) {
try {
return LocalDate.parse(s);
} catch (Exception e) {
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "날짜 형식이 올바르지 않습니다(yyyy-MM-dd).");
}
}
private static LocalDate parseDateNullable(String s) {
return (s == null || s.isBlank()) ? null : parseDate(s);
}
private static YearMonth parseYearMonth(String s) {
try {
if (s == null || s.isBlank()) {
return YearMonth.now();
}
return YearMonth.parse(s);
} catch (Exception e) {
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "yearMonth 형식이 올바르지 않습니다(yyyy-MM).");
}
}
}

View File

@ -12,6 +12,16 @@ spring:
hikari:
maximum-pool-size: 3
connection-timeout: 30000
# UIWS 이식: 부팅 시 schema.sql + 91_uiws_port.sql 멱등 적용
# (둘 다 IF NOT EXISTS / ADD COLUMN IF NOT EXISTS / ON CONFLICT DO NOTHING — 재실행 안전).
# 기존 deploy 의 psql -f schema.sql 와 중복돼도 멱등. 91_uiws_port 는 deploy 미적용분(uiws 테이블/2FA 컬럼) 보강.
sql:
init:
mode: always
continue-on-error: true
schema-locations:
- classpath:db/schema.sql
- classpath:db/91_uiws_port.sql
web:
resources:
static-locations: classpath:/static/
@ -19,7 +29,8 @@ spring:
throw-exception-if-no-handler-found: true
mybatis:
mapper-locations: classpath:mapper/*.xml
# UIWS 이식: mapper/uiws/*.xml 포함을 위해 재귀 글로브(**)로 확장(기존 mapper/*.xml 포함).
mapper-locations: classpath:mapper/**/*.xml
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl

View File

@ -0,0 +1,243 @@
-- ============================================================================
-- UIWS 업무 테이블 이식 (ESN) — 2026-06-20, uiws-schema-porter
-- 계획서: .claude/agents/_workspace/uiws_port_plan.md (schema-porter 트랙 6.1)
-- 원본: workspace/uiws/db/{03_worklog,04_schedule,05_message,02_core(2FA)}.sql
--
-- 네임스페이스 격리: UIWS TB_* → 소문자 tb_uiws_ 프리픽스 (기존 tb_audit_log 등과 충돌 회피).
-- 멱등: 전부 CREATE TABLE IF NOT EXISTS / ADD COLUMN IF NOT EXISTS / ON CONFLICT DO NOTHING.
-- mode:always 재실행 시 완전 멱등(기존 schema.sql·시드 무영향).
-- FK 정책: tb_uiws_* 내부 참조만 물리 FK. 외부 user/code/company/dept FK는 논리참조로 완화
-- (UIWS는 VARCHAR USER_ID 키 / ESN esn_user PK는 BIGSERIAL — 키 체계 불일치 → 컬럼만 유지).
-- 코드값(WORK_STATUS_CD/WORK_TYPE_CD/RCV_TYPE 등): TB_CODE 미이식 → 일반 컬럼 + CHECK만 유지.
-- ============================================================================
SET client_encoding = 'UTF8';
-- ───────────────────────────────────────────────────────────────────────────
-- [worklog] tb_uiws_worklog — 업무일지 헤더 (원본 TB_WORKLOG)
-- ───────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS tb_uiws_worklog (
worklog_id BIGINT GENERATED ALWAYS AS IDENTITY,
title VARCHAR(200) NOT NULL,
writer_id VARCHAR(20) NOT NULL, -- 논리참조(UIWS USER_ID)
work_date DATE NOT NULL,
work_status_cd VARCHAR(30) NOT NULL, -- WORK_STATUS 코드값(논리)
progress_cd VARCHAR(30) NOT NULL DEFAULT 'ONGOING',
repeat_yn CHAR(1) NOT NULL DEFAULT 'N',
repeat_start_date DATE,
repeat_end_date DATE,
created_by VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_by VARCHAR(20),
updated_at TIMESTAMP,
CONSTRAINT pk_tb_uiws_worklog PRIMARY KEY (worklog_id),
CONSTRAINT ck_uiws_worklog_progress CHECK (progress_cd IN ('ONGOING','DONE')),
CONSTRAINT ck_uiws_worklog_repeat_yn CHECK (repeat_yn IN ('Y','N'))
);
COMMENT ON TABLE tb_uiws_worklog IS 'UIWS 이식: 업무일지 헤더 (근무일자/근무상태/반복)';
-- tb_uiws_worklog_dtl — 시간대별 상세 (통계 집계 원천, 원본 TB_WORKLOG_DTL)
CREATE TABLE IF NOT EXISTS tb_uiws_worklog_dtl (
dtl_id BIGINT GENERATED ALWAYS AS IDENTITY,
worklog_id BIGINT NOT NULL,
start_hour INT NOT NULL,
end_hour INT NOT NULL,
work_type_cd VARCHAR(30) NOT NULL, -- WORK_TYPE 코드값(논리)
company_id VARCHAR(20) NOT NULL, -- 논리참조(UIWS COMPANY_ID)
work_content TEXT,
issue_content TEXT,
sort_ord INT DEFAULT 0,
created_by VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_by VARCHAR(20),
updated_at TIMESTAMP,
CONSTRAINT pk_tb_uiws_worklog_dtl PRIMARY KEY (dtl_id),
CONSTRAINT fk_uiws_dtl_worklog FOREIGN KEY (worklog_id)
REFERENCES tb_uiws_worklog (worklog_id) ON DELETE CASCADE,
CONSTRAINT ck_uiws_dtl_start_hour CHECK (start_hour BETWEEN 0 AND 24),
CONSTRAINT ck_uiws_dtl_end_hour CHECK (end_hour BETWEEN 0 AND 24),
CONSTRAINT ck_uiws_dtl_hour_order CHECK (end_hour >= start_hour)
);
COMMENT ON TABLE tb_uiws_worklog_dtl IS 'UIWS 이식: 업무일지 시간대별 상세 (헤더 삭제 시 CASCADE)';
-- tb_uiws_worklog_cmt — 댓글 (원본 TB_WORKLOG_CMT)
CREATE TABLE IF NOT EXISTS tb_uiws_worklog_cmt (
cmt_id BIGINT GENERATED ALWAYS AS IDENTITY,
worklog_id BIGINT NOT NULL,
cmt_content TEXT NOT NULL,
writer_id VARCHAR(20) NOT NULL, -- 논리참조
kakao_sent_yn CHAR(1) NOT NULL DEFAULT 'N',
confirm_yn CHAR(1) NOT NULL DEFAULT 'N',
created_by VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_by VARCHAR(20),
updated_at TIMESTAMP,
CONSTRAINT pk_tb_uiws_worklog_cmt PRIMARY KEY (cmt_id),
CONSTRAINT fk_uiws_cmt_worklog FOREIGN KEY (worklog_id)
REFERENCES tb_uiws_worklog (worklog_id) ON DELETE CASCADE,
CONSTRAINT ck_uiws_cmt_confirm_yn CHECK (confirm_yn IN ('Y','N')),
CONSTRAINT ck_uiws_cmt_kakao_yn CHECK (kakao_sent_yn IN ('Y','N'))
);
COMMENT ON TABLE tb_uiws_worklog_cmt IS 'UIWS 이식: 업무일지 댓글 (등록 시 카카오 알림톡)';
CREATE INDEX IF NOT EXISTS ix_uiws_worklog_date_writer ON tb_uiws_worklog (work_date, writer_id, work_status_cd);
CREATE INDEX IF NOT EXISTS ix_uiws_worklog_writer ON tb_uiws_worklog (writer_id);
CREATE INDEX IF NOT EXISTS ix_uiws_dtl_type_company ON tb_uiws_worklog_dtl (work_type_cd, company_id);
CREATE INDEX IF NOT EXISTS ix_uiws_dtl_worklog ON tb_uiws_worklog_dtl (worklog_id);
CREATE INDEX IF NOT EXISTS ix_uiws_dtl_company ON tb_uiws_worklog_dtl (company_id);
CREATE INDEX IF NOT EXISTS ix_uiws_cmt_worklog ON tb_uiws_worklog_cmt (worklog_id);
-- ───────────────────────────────────────────────────────────────────────────
-- [schedule] tb_uiws_schedule — 일정 개인/부서 (원본 TB_SCHEDULE)
-- ───────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS tb_uiws_schedule (
schedule_id BIGINT GENERATED ALWAYS AS IDENTITY,
sche_type VARCHAR(10) NOT NULL,
title VARCHAR(200) NOT NULL,
sche_gubun_cd VARCHAR(30),
importance_cd VARCHAR(30),
start_dt TIMESTAMP NOT NULL,
end_dt TIMESTAMP NOT NULL,
content TEXT,
owner_id VARCHAR(20) NOT NULL, -- 논리참조
dept_id VARCHAR(20), -- 논리참조
charger_id VARCHAR(20), -- 논리참조
created_by VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_by VARCHAR(20),
updated_at TIMESTAMP,
CONSTRAINT pk_tb_uiws_schedule PRIMARY KEY (schedule_id),
CONSTRAINT ck_uiws_sche_type CHECK (sche_type IN ('PERSONAL','DEPT')),
CONSTRAINT ck_uiws_sche_dt_order CHECK (end_dt >= start_dt)
);
COMMENT ON TABLE tb_uiws_schedule IS 'UIWS 이식: 일정(개인 PERSONAL / 부서 DEPT)';
-- tb_uiws_diary — 일지(일정 연계 선택, 원본 TB_DIARY)
CREATE TABLE IF NOT EXISTS tb_uiws_diary (
diary_id BIGINT GENERATED ALWAYS AS IDENTITY,
title VARCHAR(200) NOT NULL,
content TEXT,
schedule_id BIGINT,
writer_id VARCHAR(20) NOT NULL, -- 논리참조
diary_date DATE,
created_by VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_by VARCHAR(20),
updated_at TIMESTAMP,
CONSTRAINT pk_tb_uiws_diary PRIMARY KEY (diary_id),
CONSTRAINT fk_uiws_diary_sche FOREIGN KEY (schedule_id)
REFERENCES tb_uiws_schedule (schedule_id) ON DELETE SET NULL
);
COMMENT ON TABLE tb_uiws_diary IS 'UIWS 이식: 일지 (일정 연계 선택, 일정 삭제 시 연계 해제)';
-- tb_uiws_attach — 첨부파일 폴리모픽 (원본 TB_ATTACH, 물리 FK 미적용)
CREATE TABLE IF NOT EXISTS tb_uiws_attach (
attach_id BIGINT GENERATED ALWAYS AS IDENTITY,
ref_type VARCHAR(20) NOT NULL,
ref_id BIGINT NOT NULL,
file_nm VARCHAR(255) NOT NULL,
file_path VARCHAR(500) NOT NULL,
file_size BIGINT,
created_by VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_by VARCHAR(20),
updated_at TIMESTAMP,
CONSTRAINT pk_tb_uiws_attach PRIMARY KEY (attach_id),
CONSTRAINT ck_uiws_attach_ref_type CHECK (ref_type IN ('SCHEDULE','DIARY')),
CONSTRAINT ck_uiws_attach_size CHECK (file_size IS NULL OR file_size >= 0)
);
COMMENT ON TABLE tb_uiws_attach IS 'UIWS 이식: 첨부파일 (폴리모픽 REF_TYPE=SCHEDULE/DIARY)';
CREATE INDEX IF NOT EXISTS ix_uiws_sche_dt_range ON tb_uiws_schedule (start_dt, end_dt, sche_type);
CREATE INDEX IF NOT EXISTS ix_uiws_sche_owner ON tb_uiws_schedule (owner_id);
CREATE INDEX IF NOT EXISTS ix_uiws_sche_dept ON tb_uiws_schedule (dept_id);
CREATE INDEX IF NOT EXISTS ix_uiws_diary_sche ON tb_uiws_diary (schedule_id);
CREATE INDEX IF NOT EXISTS ix_uiws_diary_writer ON tb_uiws_diary (writer_id, diary_date);
CREATE INDEX IF NOT EXISTS ix_uiws_attach_ref ON tb_uiws_attach (ref_type, ref_id);
-- ───────────────────────────────────────────────────────────────────────────
-- [message] tb_uiws_message — 쪽지 헤더 (원본 TB_MESSAGE)
-- REF_WORKLOG_ID → tb_uiws_worklog (내부 FK 유지), REPLY_TO_ID → 자기참조.
-- ───────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS tb_uiws_message (
message_id BIGINT GENERATED ALWAYS AS IDENTITY,
sender_id VARCHAR(20) NOT NULL, -- 논리참조
title VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
ref_worklog_id BIGINT,
reply_to_id BIGINT,
sent_at TIMESTAMP NOT NULL DEFAULT now(),
sender_del_yn CHAR(1) NOT NULL DEFAULT 'N',
created_by VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_by VARCHAR(20),
updated_at TIMESTAMP,
CONSTRAINT pk_tb_uiws_message PRIMARY KEY (message_id),
CONSTRAINT fk_uiws_msg_worklog FOREIGN KEY (ref_worklog_id)
REFERENCES tb_uiws_worklog (worklog_id) ON DELETE SET NULL,
CONSTRAINT fk_uiws_msg_reply FOREIGN KEY (reply_to_id)
REFERENCES tb_uiws_message (message_id),
CONSTRAINT ck_uiws_msg_sender_del_yn CHECK (sender_del_yn IN ('Y','N'))
);
COMMENT ON TABLE tb_uiws_message IS 'UIWS 이식: 쪽지 헤더 (참조 업무일지/답장 원본 자기참조)';
-- tb_uiws_message_rcv — 수신자 (원본 TB_MESSAGE_RCV)
CREATE TABLE IF NOT EXISTS tb_uiws_message_rcv (
rcv_id BIGINT GENERATED ALWAYS AS IDENTITY,
message_id BIGINT NOT NULL,
receiver_id VARCHAR(20) NOT NULL, -- 논리참조
rcv_type VARCHAR(10) NOT NULL, -- MSG_RCV_TYPE 코드값(논리)
read_yn CHAR(1) NOT NULL DEFAULT 'N',
read_at TIMESTAMP,
receiver_del_yn CHAR(1) NOT NULL DEFAULT 'N',
created_by VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_by VARCHAR(20),
updated_at TIMESTAMP,
CONSTRAINT pk_tb_uiws_message_rcv PRIMARY KEY (rcv_id),
CONSTRAINT fk_uiws_rcv_message FOREIGN KEY (message_id)
REFERENCES tb_uiws_message (message_id) ON DELETE CASCADE,
CONSTRAINT uq_uiws_rcv_msg_receiver UNIQUE (message_id, receiver_id),
CONSTRAINT ck_uiws_rcv_type CHECK (rcv_type IN ('RECV','REF')),
CONSTRAINT ck_uiws_rcv_read_yn CHECK (read_yn IN ('Y','N')),
CONSTRAINT ck_uiws_rcv_del_yn CHECK (receiver_del_yn IN ('Y','N'))
);
COMMENT ON TABLE tb_uiws_message_rcv IS 'UIWS 이식: 쪽지 수신자(수신 RECV/참조 REF, 개봉여부)';
CREATE INDEX IF NOT EXISTS ix_uiws_rcv_receiver_read ON tb_uiws_message_rcv (receiver_id, read_yn);
CREATE INDEX IF NOT EXISTS ix_uiws_rcv_message ON tb_uiws_message_rcv (message_id);
CREATE INDEX IF NOT EXISTS ix_uiws_msg_sender ON tb_uiws_message (sender_id, sent_at);
-- ───────────────────────────────────────────────────────────────────────────
-- [2FA] tb_uiws_login_verify — 로그인 2차 검증 코드 (원본 TB_LOGIN_VERIFY)
-- USER_ID 는 논리참조(외부 user FK 미적용 — 키 체계 불일치).
-- ───────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS tb_uiws_login_verify (
verify_id BIGINT GENERATED ALWAYS AS IDENTITY,
user_id VARCHAR(50) NOT NULL, -- ESN username 논리참조
verify_method VARCHAR(20) NOT NULL DEFAULT 'EMAIL',
verify_code VARCHAR(10) NOT NULL,
expire_at TIMESTAMP NOT NULL,
verified_yn CHAR(1) NOT NULL DEFAULT 'N',
created_by VARCHAR(20) NOT NULL DEFAULT 'SYSTEM',
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_by VARCHAR(20),
updated_at TIMESTAMP,
CONSTRAINT pk_tb_uiws_login_verify PRIMARY KEY (verify_id),
CONSTRAINT ck_uiws_verify_yn CHECK (verified_yn IN ('Y','N')),
CONSTRAINT ck_uiws_verify_method CHECK (verify_method IN ('EMAIL','OTP'))
);
COMMENT ON TABLE tb_uiws_login_verify IS 'UIWS 이식(2FA): 로그인 2차 검증 코드(이메일/OTP, 만료)';
CREATE INDEX IF NOT EXISTS ix_uiws_verify_user ON tb_uiws_login_verify (user_id, verified_yn);
-- ───────────────────────────────────────────────────────────────────────────
-- [2FA] 기존 user 테이블 컬럼 보강 (DROP/재정의 금지 — ADD COLUMN IF NOT EXISTS 멱등)
-- 이메일 인증코드·만료시각, 실패 카운트(기본 0), 잠금(기본 false), OTP 시크릿.
-- ───────────────────────────────────────────────────────────────────────────
ALTER TABLE esn_user ADD COLUMN IF NOT EXISTS email_verify_code VARCHAR(10);
ALTER TABLE esn_user ADD COLUMN IF NOT EXISTS email_verify_expire TIMESTAMP;
ALTER TABLE esn_user ADD COLUMN IF NOT EXISTS login_fail_count INT DEFAULT 0;
ALTER TABLE esn_user ADD COLUMN IF NOT EXISTS locked BOOLEAN DEFAULT false;
ALTER TABLE esn_user ADD COLUMN IF NOT EXISTS otp_secret VARCHAR(255);
-- end 91_uiws_port.sql

View File

@ -14,11 +14,18 @@
<result property="active" column="is_active"/>
<result property="lastLoginAt" column="last_login_at"/>
<result property="createdAt" column="created_at"/>
<!-- UIWS 2FA 이식 컬럼 (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"/>
</resultMap>
<select id="findByUsername" resultMap="userMap">
SELECT id, tenant_code, username, password_hash, role, email, phone,
is_active, last_login_at, created_at
is_active, last_login_at, created_at,
email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret
FROM esn_user
WHERE username = #{username}
</select>
@ -27,4 +34,31 @@
UPDATE esn_user SET last_login_at = NOW() WHERE username = #{username}
</update>
<!-- ── UIWS 2FA 이식: 실패 카운트/잠금/인증코드 갱신 (멱등 UPDATE) ─────────────── -->
<update id="resetLoginFail">
UPDATE esn_user SET login_fail_count = 0 WHERE username = #{username}
</update>
<update id="incrementLoginFail">
UPDATE esn_user
SET login_fail_count = COALESCE(login_fail_count, 0) + 1,
locked = (COALESCE(login_fail_count, 0) + 1 >= #{maxFail})
WHERE username = #{username}
</update>
<update id="saveEmailCode">
UPDATE esn_user
SET email_verify_code = #{code}, email_verify_expire = #{expire}, login_fail_count = 0
WHERE username = #{username}
</update>
<update id="clearEmailCode">
UPDATE esn_user SET email_verify_code = NULL, email_verify_expire = NULL WHERE username = #{username}
</update>
<update id="unlock">
UPDATE esn_user SET locked = false, login_fail_count = 0 WHERE username = #{username}
</update>
</mapper>

View File

@ -0,0 +1,16 @@
<?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">
<!-- UIWS 이식(2FA): 로그인 2차 검증 이력 (tb_uiws_login_verify). -->
<mapper namespace="com.zioinfo.esn.uiws.auth.LoginVerifyMapper">
<insert id="insert" useGeneratedKeys="true" keyProperty="verifyId" keyColumn="verify_id"
parameterType="com.zioinfo.esn.uiws.auth.UiwsLoginVerify">
INSERT INTO tb_uiws_login_verify
(user_id, verify_method, verify_code, expire_at, verified_yn, created_by, created_at)
VALUES
(#{userId}, #{verifyMethod}, #{verifyCode}, #{expireAt}, #{verifiedYn}, #{createdBy}, #{createdAt})
</insert>
</mapper>

View File

@ -0,0 +1,137 @@
<?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">
<!-- UIWS 이식: 쪽지(tb_uiws_message / tb_uiws_message_rcv). -->
<mapper namespace="com.zioinfo.esn.uiws.message.mapper.MessageMapper">
<!-- ============================ message 헤더 ============================ -->
<insert id="insertMessage" useGeneratedKeys="true" keyProperty="messageId" keyColumn="message_id"
parameterType="com.zioinfo.esn.uiws.message.model.UiwsMessage">
INSERT INTO tb_uiws_message
(sender_id, title, content, ref_worklog_id, reply_to_id, sent_at, sender_del_yn, created_by, created_at)
VALUES
(#{senderId}, #{title}, #{content}, #{refWorklogId}, #{replyToId}, #{sentAt}, #{senderDelYn}, #{createdBy}, #{createdAt})
</insert>
<select id="findMessageById" resultType="com.zioinfo.esn.uiws.message.model.UiwsMessage">
SELECT * FROM tb_uiws_message WHERE message_id = #{messageId}
</select>
<select id="existsMessageById" resultType="boolean">
SELECT EXISTS(SELECT 1 FROM tb_uiws_message WHERE message_id = #{messageId})
</select>
<select id="searchSent" resultType="com.zioinfo.esn.uiws.message.model.UiwsMessage">
SELECT * FROM tb_uiws_message
WHERE sender_id = #{senderId}
AND sender_del_yn = 'N'
AND sent_at BETWEEN #{from} AND #{to}
<if test="titleKeyword != null">
AND title LIKE '%' || #{titleKeyword} || '%'
</if>
ORDER BY sent_at DESC, message_id DESC
LIMIT #{limit} OFFSET #{offset}
</select>
<select id="countSent" resultType="long">
SELECT COUNT(*) FROM tb_uiws_message
WHERE sender_id = #{senderId}
AND sender_del_yn = 'N'
AND sent_at BETWEEN #{from} AND #{to}
<if test="titleKeyword != null">
AND title LIKE '%' || #{titleKeyword} || '%'
</if>
</select>
<update id="softDeleteSent">
UPDATE tb_uiws_message
SET sender_del_yn = 'Y', updated_by = #{actor}, updated_at = now()
WHERE sender_id = #{senderId}
AND message_id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
</update>
<!-- ============================ 수신자 ============================ -->
<insert id="insertRcv" useGeneratedKeys="true" keyProperty="rcvId" keyColumn="rcv_id"
parameterType="com.zioinfo.esn.uiws.message.model.UiwsMessageRcv">
INSERT INTO tb_uiws_message_rcv
(message_id, receiver_id, rcv_type, read_yn, receiver_del_yn, created_by, created_at)
VALUES
(#{messageId}, #{receiverId}, #{rcvType}, #{readYn}, #{receiverDelYn}, #{createdBy}, #{createdAt})
</insert>
<select id="findRcvByMessageId" resultType="com.zioinfo.esn.uiws.message.model.UiwsMessageRcv">
SELECT * FROM tb_uiws_message_rcv WHERE message_id = #{messageId} ORDER BY rcv_id ASC
</select>
<select id="findRcvByMessageIds" resultType="com.zioinfo.esn.uiws.message.model.UiwsMessageRcv">
SELECT * FROM tb_uiws_message_rcv
WHERE message_id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
ORDER BY rcv_id ASC
</select>
<select id="findRcvByMessageAndReceiver" resultType="com.zioinfo.esn.uiws.message.model.UiwsMessageRcv">
SELECT * FROM tb_uiws_message_rcv
WHERE message_id = #{messageId} AND receiver_id = #{receiverId}
</select>
<select id="countUnread" resultType="long">
SELECT COUNT(*) FROM tb_uiws_message_rcv
WHERE receiver_id = #{receiverId} AND read_yn = 'N' AND receiver_del_yn = 'N'
</select>
<update id="markRead">
UPDATE tb_uiws_message_rcv
SET read_yn = 'Y', read_at = #{readAt}, updated_by = #{actor}, updated_at = now()
WHERE rcv_id = #{rcvId}
</update>
<update id="softDeleteReceived">
UPDATE tb_uiws_message_rcv
SET receiver_del_yn = 'Y', updated_by = #{actor}, updated_at = now()
WHERE receiver_id = #{receiverId}
AND message_id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
</update>
<select id="searchReceived" resultType="map">
SELECT m.message_id AS "messageId",
m.title AS "title",
m.sender_id AS "senderId",
m.sent_at AS "sentAt",
r.read_yn AS "readYn"
FROM tb_uiws_message_rcv r
JOIN tb_uiws_message m ON m.message_id = r.message_id
WHERE r.receiver_id = #{receiverId}
AND r.receiver_del_yn = 'N'
AND m.sent_at BETWEEN #{from} AND #{to}
<if test="titleKeyword != null">
AND m.title LIKE '%' || #{titleKeyword} || '%'
</if>
ORDER BY m.sent_at DESC, m.message_id DESC
LIMIT #{limit} OFFSET #{offset}
</select>
<select id="countReceived" resultType="long">
SELECT COUNT(*)
FROM tb_uiws_message_rcv r
JOIN tb_uiws_message m ON m.message_id = r.message_id
WHERE r.receiver_id = #{receiverId}
AND r.receiver_del_yn = 'N'
AND m.sent_at BETWEEN #{from} AND #{to}
<if test="titleKeyword != null">
AND m.title LIKE '%' || #{titleKeyword} || '%'
</if>
</select>
<!-- ============================ 사용자명 라벨(코어 user 재사용) ============================ -->
<select id="findUserNames" resultType="map">
SELECT username AS "username", username AS "fullName"
FROM esn_user
WHERE username IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
</select>
</mapper>

View File

@ -0,0 +1,168 @@
<?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">
<!-- UIWS 이식: 일정/일지/첨부 (tb_uiws_schedule / tb_uiws_diary / tb_uiws_attach). -->
<mapper namespace="com.zioinfo.esn.uiws.schedule.mapper.ScheduleMapper">
<!-- ===================== schedule ===================== -->
<insert id="insertSchedule" useGeneratedKeys="true" keyProperty="scheduleId" keyColumn="schedule_id"
parameterType="com.zioinfo.esn.uiws.schedule.model.UiwsSchedule">
INSERT INTO tb_uiws_schedule
(sche_type, title, sche_gubun_cd, importance_cd, start_dt, end_dt, content,
owner_id, dept_id, charger_id, created_by, created_at)
VALUES
(#{scheType}, #{title}, #{scheGubunCd}, #{importanceCd}, #{startDt}, #{endDt}, #{content},
#{ownerId}, #{deptId}, #{chargerId}, #{createdBy}, #{createdAt})
</insert>
<update id="updateSchedule" parameterType="com.zioinfo.esn.uiws.schedule.model.UiwsSchedule">
UPDATE tb_uiws_schedule SET
sche_type = #{scheType}, title = #{title}, sche_gubun_cd = #{scheGubunCd},
importance_cd = #{importanceCd}, start_dt = #{startDt}, end_dt = #{endDt},
content = #{content}, dept_id = #{deptId}, charger_id = #{chargerId},
updated_by = #{updatedBy}, updated_at = #{updatedAt}
WHERE schedule_id = #{scheduleId}
</update>
<select id="findScheduleById" resultType="com.zioinfo.esn.uiws.schedule.model.UiwsSchedule">
SELECT * FROM tb_uiws_schedule WHERE schedule_id = #{scheduleId}
</select>
<select id="existsScheduleById" resultType="boolean">
SELECT EXISTS(SELECT 1 FROM tb_uiws_schedule WHERE schedule_id = #{scheduleId})
</select>
<delete id="deleteScheduleById">
DELETE FROM tb_uiws_schedule WHERE schedule_id = #{scheduleId}
</delete>
<!-- 기간 겹침: start_dt &lt; to(배타 상한) AND end_dt &gt;= from -->
<select id="findInRange" resultType="com.zioinfo.esn.uiws.schedule.model.UiwsSchedule">
SELECT * FROM tb_uiws_schedule
WHERE sche_type = #{scheType}
AND start_dt &lt; #{to}
AND end_dt &gt;= #{from}
<if test="ownerId != null">AND owner_id = #{ownerId}</if>
<if test="deptId != null">AND dept_id = #{deptId}</if>
ORDER BY start_dt ASC, schedule_id ASC
</select>
<sql id="allWhere">
WHERE start_dt &lt; #{to}
AND end_dt &gt;= #{from}
<if test="scheType != null">AND sche_type = #{scheType}</if>
<if test="!scopeAll">
AND owner_id IN
<foreach collection="ownerIds" item="oid" open="(" separator="," close=")">#{oid}</foreach>
</if>
</sql>
<select id="searchAll" resultType="com.zioinfo.esn.uiws.schedule.model.UiwsSchedule">
SELECT * FROM tb_uiws_schedule
<include refid="allWhere"/>
ORDER BY start_dt DESC, schedule_id DESC
LIMIT #{limit} OFFSET #{offset}
</select>
<select id="countAll" resultType="long">
SELECT COUNT(*) FROM tb_uiws_schedule
<include refid="allWhere"/>
</select>
<select id="searchPopup" resultType="com.zioinfo.esn.uiws.schedule.model.UiwsSchedule">
SELECT * FROM tb_uiws_schedule
WHERE 1=1
<if test="keyword != null">
AND (title LIKE '%' || #{keyword} || '%' OR content LIKE '%' || #{keyword} || '%')
</if>
<if test="!scopeAll">
AND owner_id IN
<foreach collection="ownerIds" item="oid" open="(" separator="," close=")">#{oid}</foreach>
</if>
ORDER BY start_dt DESC, schedule_id DESC
LIMIT 50
</select>
<!-- ===================== diary ===================== -->
<insert id="insertDiary" useGeneratedKeys="true" keyProperty="diaryId" keyColumn="diary_id"
parameterType="com.zioinfo.esn.uiws.schedule.model.UiwsDiary">
INSERT INTO tb_uiws_diary
(title, content, schedule_id, writer_id, diary_date, created_by, created_at)
VALUES
(#{title}, #{content}, #{scheduleId}, #{writerId}, #{diaryDate}, #{createdBy}, #{createdAt})
</insert>
<update id="updateDiary" parameterType="com.zioinfo.esn.uiws.schedule.model.UiwsDiary">
UPDATE tb_uiws_diary SET
title = #{title}, content = #{content}, schedule_id = #{scheduleId}, diary_date = #{diaryDate},
updated_by = #{updatedBy}, updated_at = #{updatedAt}
WHERE diary_id = #{diaryId}
</update>
<select id="findDiaryById" resultType="com.zioinfo.esn.uiws.schedule.model.UiwsDiary">
SELECT * FROM tb_uiws_diary WHERE diary_id = #{diaryId}
</select>
<select id="existsDiaryById" resultType="boolean">
SELECT EXISTS(SELECT 1 FROM tb_uiws_diary WHERE diary_id = #{diaryId})
</select>
<delete id="deleteDiaryById">
DELETE FROM tb_uiws_diary WHERE diary_id = #{diaryId}
</delete>
<select id="searchDiary" resultType="com.zioinfo.esn.uiws.schedule.model.UiwsDiary">
SELECT * FROM tb_uiws_diary
WHERE (diary_date IS NULL OR diary_date BETWEEN #{from} AND #{to})
ORDER BY COALESCE(diary_date, created_at::date) DESC, diary_id DESC
LIMIT #{limit} OFFSET #{offset}
</select>
<select id="countDiary" resultType="long">
SELECT COUNT(*) FROM tb_uiws_diary
WHERE (diary_date IS NULL OR diary_date BETWEEN #{from} AND #{to})
</select>
<!-- ===================== attach ===================== -->
<insert id="insertAttach" useGeneratedKeys="true" keyProperty="attachId" keyColumn="attach_id"
parameterType="com.zioinfo.esn.uiws.schedule.model.UiwsAttach">
INSERT INTO tb_uiws_attach
(ref_type, ref_id, file_nm, file_path, file_size, created_by, created_at)
VALUES
(#{refType}, #{refId}, #{fileNm}, #{filePath}, #{fileSize}, #{createdBy}, #{createdAt})
</insert>
<select id="findAttachById" resultType="com.zioinfo.esn.uiws.schedule.model.UiwsAttach">
SELECT * FROM tb_uiws_attach WHERE attach_id = #{attachId}
</select>
<update id="updateAttachRef">
UPDATE tb_uiws_attach
SET ref_type = #{refType}, ref_id = #{refId}, updated_by = #{actor}, updated_at = now()
WHERE attach_id = #{attachId}
</update>
<select id="findAttachByRef" resultType="com.zioinfo.esn.uiws.schedule.model.UiwsAttach">
SELECT * FROM tb_uiws_attach
WHERE ref_type = #{refType} AND ref_id = #{refId}
ORDER BY attach_id ASC
</select>
<delete id="deleteAttachById">
DELETE FROM tb_uiws_attach WHERE attach_id = #{attachId}
</delete>
<delete id="deleteAttachByRef">
DELETE FROM tb_uiws_attach WHERE ref_type = #{refType} AND ref_id = #{refId}
</delete>
<!-- ===================== 사용자명 라벨 ===================== -->
<select id="findUserNames" resultType="map">
SELECT username AS "username", username AS "fullName"
FROM esn_user
WHERE username IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
</select>
</mapper>

View File

@ -0,0 +1,48 @@
<?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">
<!-- UIWS 이식: 근무현황 피벗 집계. 원천 = tb_uiws_worklog_dtl + tb_uiws_worklog.
라벨: 근무자=esn_user.username(writer_id=username 조인, 없으면 writer_id),
근무처=company_id, 근무상태/유형=코드값(공통코드 미이식). -->
<mapper namespace="com.zioinfo.esn.uiws.stats.mapper.StatsMapper">
<select id="personalWork" resultType="map">
SELECT COALESCE(u.username, w.writer_id) AS worker,
w.work_status_cd AS work_status,
d.work_type_cd AS work_type,
d.company_id AS dyn,
COUNT(*) AS cnt
FROM tb_uiws_worklog_dtl d
JOIN tb_uiws_worklog w ON w.worklog_id = d.worklog_id
LEFT JOIN esn_user u ON u.username = w.writer_id
WHERE w.work_date BETWEEN #{from} AND #{to}
<if test="!scopeAll">
AND w.writer_id IN
<foreach collection="ownerIds" item="oid" open="(" separator="," close=")">#{oid}</foreach>
</if>
<if test="userId != null">AND w.writer_id = #{userId}</if>
GROUP BY COALESCE(u.username, w.writer_id), w.work_status_cd, d.work_type_cd, d.company_id
ORDER BY worker, work_status, work_type, dyn
</select>
<select id="companyWork" resultType="map">
SELECT d.company_id AS company,
d.work_type_cd AS work_type,
COALESCE(u.username, w.writer_id) AS dyn,
COUNT(*) AS cnt
FROM tb_uiws_worklog_dtl d
JOIN tb_uiws_worklog w ON w.worklog_id = d.worklog_id
LEFT JOIN esn_user u ON u.username = w.writer_id
WHERE w.work_date BETWEEN #{from} AND #{to}
<if test="!scopeAll">
AND w.writer_id IN
<foreach collection="ownerIds" item="oid" open="(" separator="," close=")">#{oid}</foreach>
</if>
<if test="companyId != null">AND d.company_id = #{companyId}</if>
<if test="userId != null">AND w.writer_id = #{userId}</if>
GROUP BY d.company_id, d.work_type_cd, COALESCE(u.username, w.writer_id)
ORDER BY company, work_type, dyn
</select>
</mapper>

View File

@ -0,0 +1,181 @@
<?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">
<!-- UIWS 이식: 업무일지 (tb_uiws_worklog / _dtl / _cmt). -->
<mapper namespace="com.zioinfo.esn.uiws.worklog.mapper.WorklogMapper">
<!-- ===================== worklog 헤더 ===================== -->
<insert id="insertWorklog" useGeneratedKeys="true" keyProperty="worklogId" keyColumn="worklog_id"
parameterType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklog">
INSERT INTO tb_uiws_worklog
(title, writer_id, work_date, work_status_cd, progress_cd, repeat_yn,
repeat_start_date, repeat_end_date, created_by, created_at)
VALUES
(#{title}, #{writerId}, #{workDate}, #{workStatusCd}, #{progressCd}, #{repeatYn},
#{repeatStartDate}, #{repeatEndDate}, #{createdBy}, #{createdAt})
</insert>
<update id="updateWorklog" parameterType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklog">
UPDATE tb_uiws_worklog SET
title = #{title}, writer_id = #{writerId}, work_date = #{workDate},
work_status_cd = #{workStatusCd}, progress_cd = #{progressCd}, repeat_yn = #{repeatYn},
repeat_start_date = #{repeatStartDate}, repeat_end_date = #{repeatEndDate},
updated_by = #{updatedBy}, updated_at = #{updatedAt}
WHERE worklog_id = #{worklogId}
</update>
<select id="findWorklogById" resultType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklog">
SELECT * FROM tb_uiws_worklog WHERE worklog_id = #{worklogId}
</select>
<select id="existsWorklogById" resultType="boolean">
SELECT EXISTS(SELECT 1 FROM tb_uiws_worklog WHERE worklog_id = #{worklogId})
</select>
<delete id="deleteWorklogById">
DELETE FROM tb_uiws_worklog WHERE worklog_id = #{worklogId}
</delete>
<sql id="scope">
<if test="!scopeAll">
AND writer_id IN
<foreach collection="ownerIds" item="oid" open="(" separator="," close=")">#{oid}</foreach>
</if>
</sql>
<select id="searchList" resultType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklog">
SELECT * FROM tb_uiws_worklog
WHERE work_date BETWEEN #{from} AND #{to}
<if test="writerId != null">AND writer_id = #{writerId}</if>
<if test="progressCd != null">AND progress_cd = #{progressCd}</if>
<include refid="scope"/>
ORDER BY work_date DESC, worklog_id DESC
LIMIT #{limit} OFFSET #{offset}
</select>
<select id="countList" resultType="long">
SELECT COUNT(*) FROM tb_uiws_worklog
WHERE work_date BETWEEN #{from} AND #{to}
<if test="writerId != null">AND writer_id = #{writerId}</if>
<if test="progressCd != null">AND progress_cd = #{progressCd}</if>
<include refid="scope"/>
</select>
<select id="findByMonth" resultType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklog">
SELECT * FROM tb_uiws_worklog
WHERE work_date BETWEEN #{from} AND #{to}
<if test="writerId != null">AND writer_id = #{writerId}</if>
<include refid="scope"/>
ORDER BY work_date ASC, worklog_id ASC
</select>
<select id="searchPopup" resultType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklog">
SELECT * FROM tb_uiws_worklog
WHERE 1=1
<if test="keyword != null">AND title LIKE '%' || #{keyword} || '%'</if>
<if test="writerId != null">AND writer_id = #{writerId}</if>
<include refid="scope"/>
ORDER BY work_date DESC, worklog_id DESC
LIMIT 50
</select>
<select id="progressSummary" resultType="map">
SELECT progress_cd AS "progressCd", COUNT(*) AS "cnt"
FROM tb_uiws_worklog
WHERE work_date BETWEEN #{from} AND #{to}
<include refid="scope"/>
GROUP BY progress_cd
</select>
<!-- ===================== 상세(dtl) ===================== -->
<insert id="insertDtl" useGeneratedKeys="true" keyProperty="dtlId" keyColumn="dtl_id"
parameterType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklogDtl">
INSERT INTO tb_uiws_worklog_dtl
(worklog_id, start_hour, end_hour, work_type_cd, company_id, work_content, issue_content,
sort_ord, created_by, created_at)
VALUES
(#{worklogId}, #{startHour}, #{endHour}, #{workTypeCd}, #{companyId}, #{workContent}, #{issueContent},
#{sortOrd}, #{createdBy}, #{createdAt})
</insert>
<update id="updateDtl" parameterType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklogDtl">
UPDATE tb_uiws_worklog_dtl SET
start_hour = #{startHour}, end_hour = #{endHour}, work_type_cd = #{workTypeCd},
company_id = #{companyId}, work_content = #{workContent}, issue_content = #{issueContent},
sort_ord = #{sortOrd}, updated_by = #{updatedBy}, updated_at = #{updatedAt}
WHERE dtl_id = #{dtlId}
</update>
<delete id="deleteDtlById">
DELETE FROM tb_uiws_worklog_dtl WHERE dtl_id = #{dtlId}
</delete>
<select id="findDtlByWorklogId" resultType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklogDtl">
SELECT * FROM tb_uiws_worklog_dtl
WHERE worklog_id = #{worklogId}
ORDER BY sort_ord ASC, start_hour ASC, dtl_id ASC
</select>
<!-- ===================== 댓글(cmt) ===================== -->
<insert id="insertCmt" useGeneratedKeys="true" keyProperty="cmtId" keyColumn="cmt_id"
parameterType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklogCmt">
INSERT INTO tb_uiws_worklog_cmt
(worklog_id, cmt_content, writer_id, kakao_sent_yn, confirm_yn, created_by, created_at)
VALUES
(#{worklogId}, #{cmtContent}, #{writerId}, #{kakaoSentYn}, #{confirmYn}, #{createdBy}, #{createdAt})
</insert>
<select id="findCmtById" resultType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklogCmt">
SELECT * FROM tb_uiws_worklog_cmt WHERE cmt_id = #{cmtId}
</select>
<update id="updateCmtKakaoSent">
UPDATE tb_uiws_worklog_cmt SET kakao_sent_yn = 'Y' WHERE cmt_id = #{cmtId}
</update>
<update id="confirmCmt">
UPDATE tb_uiws_worklog_cmt
SET confirm_yn = 'Y', updated_by = #{actor}, updated_at = now()
WHERE cmt_id = #{cmtId}
</update>
<select id="findCmtByWorklogId" resultType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklogCmt">
SELECT * FROM tb_uiws_worklog_cmt
WHERE worklog_id = #{worklogId}
ORDER BY created_at ASC, cmt_id ASC
</select>
<select id="countCommentsByWorklogIds" resultType="map">
SELECT worklog_id AS "worklogId", COUNT(*) AS "cnt"
FROM tb_uiws_worklog_cmt
WHERE worklog_id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
GROUP BY worklog_id
</select>
<select id="findUnconfirmedByWriter" resultType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklogCmt">
SELECT * FROM tb_uiws_worklog_cmt
WHERE writer_id = #{writerId} AND confirm_yn = 'N'
ORDER BY created_at DESC, cmt_id DESC
</select>
<!-- ===================== 사용자 라벨/이메일(코어 user 재사용) ===================== -->
<select id="findUserNames" resultType="map">
SELECT username AS "username", username AS "fullName"
FROM esn_user
WHERE username IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
</select>
<select id="findUserEmail" resultType="string">
SELECT email FROM esn_user WHERE username = #{username}
</select>
<select id="findWorklogsByIds" resultType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklog">
SELECT * FROM tb_uiws_worklog
WHERE worklog_id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
</select>
</mapper>

65
frontend/src/api/uiws.ts Normal file
View File

@ -0,0 +1,65 @@
import api from './client'
/**
* UIWS API . ESN axios (baseURL='', esn_token JWT ) .
* ESN client baseURL /api/... .
* : { success, message, data }. res.data.data .
*/
// ── 2FA
export const verify2fa = (verifyToken: string, code: string) =>
api.post('/api/auth/verify', { verifyToken, code })
// ── 쪽지(message)
export const sendMessage = (body: object) => api.post('/api/messages', body)
export const listSent = (params: Record<string, unknown>) => api.get('/api/messages/sent', { params })
export const sentDetail = (id: number) => api.get(`/api/messages/sent/${id}`)
export const deleteSent = (ids: number[]) => api.delete('/api/messages/sent', { data: { ids } })
export const listReceived = (params: Record<string, unknown>) => api.get('/api/messages/received', { params })
export const receivedDetail = (id: number) => api.get(`/api/messages/received/${id}`)
export const deleteReceived = (ids: number[]) => api.delete('/api/messages/received', { data: { ids } })
export const unreadCount = () => api.get('/api/messages/unread-count')
// ── 일정(schedule)
export const scheduleCalendar = (params: Record<string, unknown>) => api.get('/api/schedules', { params })
export const scheduleAll = (params: Record<string, unknown>) => api.get('/api/schedules/all', { params })
export const scheduleSearch = (keyword: string) => api.get('/api/schedules/search', { params: { keyword } })
export const createSchedule = (body: object) => api.post('/api/schedules', body)
export const scheduleDetail = (id: number) => api.get(`/api/schedules/${id}`)
export const updateSchedule = (id: number, body: object) => api.put(`/api/schedules/${id}`, body)
export const deleteSchedule = (id: number) => api.delete(`/api/schedules/${id}`)
// ── 일지(diary)
export const diaryList = (params: Record<string, unknown>) => api.get('/api/diaries', { params })
export const createDiary = (body: object) => api.post('/api/diaries', body)
export const diaryDetail = (id: number) => api.get(`/api/diaries/${id}`)
export const updateDiary = (id: number, body: object) => api.put(`/api/diaries/${id}`, body)
export const deleteDiary = (id: number) => api.delete(`/api/diaries/${id}`)
// ── 첨부(attachment)
export const uploadAttachment = (refType: string, refId: number, file: File) => {
const fd = new FormData()
fd.append('refType', refType)
fd.append('refId', String(refId))
fd.append('file', file)
return api.post('/api/attachments', fd, { headers: { 'Content-Type': 'multipart/form-data' } })
}
export const deleteAttachment = (id: number) => api.delete(`/api/attachments/${id}`)
export const attachmentDownloadUrl = (id: number) => `/api/attachments/${id}/download`
// ── 업무일지(worklog)
export const worklogList = (params: Record<string, unknown>) => api.get('/api/worklogs', { params })
export const worklogCalendar = (params: Record<string, unknown>) => api.get('/api/worklogs/calendar', { params })
export const worklogSearch = (params: Record<string, unknown>) => api.get('/api/worklogs/search', { params })
export const worklogProgress = (params: Record<string, unknown>) => api.get('/api/worklogs/dashboard/progress', { params })
export const createWorklog = (body: object) => api.post('/api/worklogs', body)
export const worklogDetail = (id: number) => api.get(`/api/worklogs/${id}`)
export const updateWorklog = (id: number, body: object) => api.put(`/api/worklogs/${id}`, body)
export const deleteWorklog = (id: number) => api.delete(`/api/worklogs/${id}`)
export const addWorklogComment = (id: number, cmtContent: string) =>
api.post(`/api/worklogs/${id}/comments`, { cmtContent })
export const confirmWorklogComment = (cmtId: number) => api.post(`/api/worklogs/comments/${cmtId}/confirm`)
// ── 통계(stats)
export const personalWorkStats = (params: Record<string, unknown>) => api.get('/api/stats/personal-work', { params })
export const companyWorkStats = (params: Record<string, unknown>) => api.get('/api/stats/company-work', { params })

View File

@ -0,0 +1,16 @@
import { Moon, Sun } from 'lucide-react'
import { useTheme } from '../../theme/ThemeContext'
/** 다크/라이트 테마 토글 버튼. 사이드바 하단 등에 배치. */
export default function ThemeToggle() {
const { theme, toggle } = useTheme()
return (
<button onClick={toggle} title="테마 전환"
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px',
background: 'none', border: '1px solid var(--uiws-border, #2d3448)', borderRadius: 8,
color: 'var(--uiws-text-muted, #8892b0)', cursor: 'pointer', fontSize: 13, width: '100%' }}>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
{theme === 'dark' ? '라이트 모드' : '다크 모드'}
</button>
)
}

View File

@ -0,0 +1,171 @@
/*
* UIWS ( var(--uiws-*) ).
* / / . ERP components/uiws/ .
*/
import { type ReactNode, type CSSProperties } from 'react'
const card: CSSProperties = {
background: 'var(--uiws-surface)',
border: '1px solid var(--uiws-border)',
borderRadius: 12,
boxShadow: 'var(--uiws-shadow)',
}
export function PageHeader({ title, subtitle, actions }: { title: string; subtitle?: string; actions?: ReactNode }) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 20 }}>
<div>
<h1 style={{ fontSize: 22, fontWeight: 700, color: 'var(--uiws-text)' }}>{title}</h1>
{subtitle && <div style={{ fontSize: 13, color: 'var(--uiws-text-muted)', marginTop: 4 }}>{subtitle}</div>}
</div>
<div style={{ display: 'flex', gap: 8 }}>{actions}</div>
</div>
)
}
export function Panel({ children, style }: { children: ReactNode; style?: CSSProperties }) {
return <div style={{ ...card, padding: 18, ...style }}>{children}</div>
}
export function Button({
children, onClick, variant = 'primary', type = 'button', disabled,
}: {
children: ReactNode; onClick?: () => void; variant?: 'primary' | 'ghost' | 'danger'
type?: 'button' | 'submit'; disabled?: boolean
}) {
const styles: Record<string, CSSProperties> = {
primary: { background: 'var(--uiws-primary)', color: 'var(--uiws-primary-contrast)', border: 'none' },
ghost: { background: 'transparent', color: 'var(--uiws-text-muted)', border: '1px solid var(--uiws-border)' },
danger: { background: 'var(--uiws-danger)', color: '#fff', border: 'none' },
}
return (
<button type={type} onClick={onClick} disabled={disabled}
style={{ padding: '9px 16px', borderRadius: 8, fontSize: 13, fontWeight: 600,
cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? 0.5 : 1, ...styles[variant] }}>
{children}
</button>
)
}
export function Input({
value, onChange, placeholder, type = 'text', style,
}: {
value: string; onChange: (v: string) => void; placeholder?: string; type?: string; style?: CSSProperties
}) {
return (
<input type={type} value={value} placeholder={placeholder} onChange={e => onChange(e.target.value)}
style={{ padding: '9px 12px', borderRadius: 8, fontSize: 13,
background: 'var(--uiws-input-bg)', border: '1px solid var(--uiws-border)', color: 'var(--uiws-text)', ...style }} />
)
}
export function FormField({ label, children }: { label: string; children: ReactNode }) {
return (
<label style={{ display: 'block', marginBottom: 12 }}>
<div style={{ fontSize: 12, color: 'var(--uiws-text-muted)', marginBottom: 6 }}>{label}</div>
{children}
</label>
)
}
export function SearchBar({ children }: { children: ReactNode }) {
return (
<Panel style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap', marginBottom: 16 }}>
{children}
</Panel>
)
}
export interface Column<T> {
key: string
header: string
render?: (row: T) => ReactNode
width?: number | string
align?: 'left' | 'center' | 'right'
}
export function DataGrid<T extends Record<string, unknown>>({
columns, rows, rowKey, onRowClick, empty = '데이터가 없습니다.',
}: {
columns: Column<T>[]; rows: T[]; rowKey: (row: T) => string | number
onRowClick?: (row: T) => void; empty?: string
}) {
return (
<div style={{ ...card, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13, color: 'var(--uiws-text)' }}>
<thead>
<tr style={{ background: 'var(--uiws-surface-2)' }}>
{columns.map(c => (
<th key={c.key} style={{ textAlign: c.align ?? 'left', padding: '11px 14px',
color: 'var(--uiws-text-muted)', fontWeight: 600, fontSize: 12,
borderBottom: '1px solid var(--uiws-border)', width: c.width }}>{c.header}</th>
))}
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={columns.length} style={{ padding: 28, textAlign: 'center', color: 'var(--uiws-text-faint)' }}>{empty}</td></tr>
) : rows.map(row => (
<tr key={rowKey(row)} onClick={() => onRowClick?.(row)}
style={{ cursor: onRowClick ? 'pointer' : 'default', borderBottom: '1px solid var(--uiws-border)' }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--uiws-row-hover)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}>
{columns.map(c => (
<td key={c.key} style={{ padding: '11px 14px', textAlign: c.align ?? 'left' }}>
{c.render ? c.render(row) : String(row[c.key] ?? '')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
export function Pagination({ page, totalPages, onChange }: { page: number; totalPages: number; onChange: (p: number) => void }) {
if (totalPages <= 1) return null
return (
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', marginTop: 16 }}>
<Button variant="ghost" disabled={page <= 0} onClick={() => onChange(page - 1)}></Button>
<span style={{ alignSelf: 'center', fontSize: 13, color: 'var(--uiws-text-muted)' }}>
{page + 1} / {totalPages}
</span>
<Button variant="ghost" disabled={page >= totalPages - 1} onClick={() => onChange(page + 1)}></Button>
</div>
)
}
export function YnBadge({ yn, yes = '읽음', no = '안읽음' }: { yn: string; yes?: string; no?: string }) {
const on = yn === 'Y'
return (
<span style={{ fontSize: 11, fontWeight: 600, padding: '2px 9px', borderRadius: 999,
color: on ? 'var(--uiws-success)' : 'var(--uiws-text-muted)',
background: on ? 'rgba(46,204,113,0.12)' : 'var(--uiws-surface-2)',
border: '1px solid var(--uiws-border)' }}>
{on ? yes : no}
</span>
)
}
export function Modal({ title, onClose, children, footer }: { title: string; onClose: () => void; children: ReactNode; footer?: ReactNode }) {
return (
<div onClick={onClose}
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex',
justifyContent: 'center', alignItems: 'center', zIndex: 1000 }}>
<div onClick={e => e.stopPropagation()}
style={{ ...card, width: 'min(560px, 92vw)', maxHeight: '88vh', overflow: 'auto', padding: 22 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2 style={{ fontSize: 17, fontWeight: 700, color: 'var(--uiws-text)' }}>{title}</h2>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--uiws-text-muted)', fontSize: 20, cursor: 'pointer' }}>×</button>
</div>
<div>{children}</div>
{footer && <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 18 }}>{footer}</div>}
</div>
</div>
)
}
export function Spinner({ label = '불러오는 중...' }: { label?: string }) {
return <div style={{ padding: 28, textAlign: 'center', color: 'var(--uiws-text-faint)', fontSize: 13 }}>{label}</div>
}

View File

@ -1,26 +1,62 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { login } from '../api/client'
import { verify2fa } from '../api/uiws'
/**
* . UIWS 2FA :
* - 2FA off ({ twofa:"false", token }) ( 0).
* - 2FA on ({ twofa:"true", verifyToken, maskedEmail }) .
* ESN Tailwind (bg-card/text-brand/border-edge) .
*/
export default function Login() {
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [step, setStep] = useState<'login' | 'verify'>('login')
const [verifyToken, setVerifyToken] = useState('')
const [maskedEmail, setMaskedEmail] = useState('')
const [code, setCode] = useState('')
async function handleSubmit(e: React.FormEvent) {
function finishLogin(token: string) {
localStorage.setItem('esn_token', token)
localStorage.setItem('esn_user', username)
navigate('/dashboard')
}
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
try {
const res = await login(username, password)
const token = res.data?.data?.token
if (!token) throw new Error('토큰 없음')
localStorage.setItem('esn_token', token)
navigate('/dashboard')
const data = res.data?.data
if (data?.twofa === 'true') {
setVerifyToken(data.verifyToken)
setMaskedEmail(data.maskedEmail || '')
setStep('verify')
} else {
if (!data?.token) throw new Error('토큰 없음')
finishLogin(data.token)
}
} catch {
setError('로그인 실패: 아이디/비밀번호를 확인하세요.')
setError('아이디/비밀번호가 올바르지 않거나 계정이 잠겼습니다.')
} finally {
setLoading(false)
}
}
async function handleVerify(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
try {
const res = await verify2fa(verifyToken, code)
finishLogin(res.data?.data?.token)
} catch {
setError('인증 코드가 올바르지 않거나 만료되었습니다.')
} finally {
setLoading(false)
}
@ -30,10 +66,14 @@ export default function Login() {
<div className="min-h-screen bg-ink flex items-center justify-center">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-brand">zioinfo-esn</h1>
<p className="text-sm text-gray-400 mt-1">ESL </p>
<h1 className="text-2xl font-bold text-brand">GUARDiA ESN</h1>
<p className="text-sm text-gray-400 mt-1">
{step === 'login' ? 'ESL 통합 관리 플랫폼' : '2차 인증'}
</p>
</div>
<form onSubmit={handleSubmit} className="bg-card border border-edge rounded-lg p-6 space-y-4">
{step === 'login' ? (
<form onSubmit={handleLogin} className="bg-card border border-edge rounded-lg p-6 space-y-4">
<div>
<label className="block text-xs text-gray-400 mb-1"></label>
<input
@ -62,6 +102,39 @@ export default function Login() {
{loading ? '로그인 중...' : '로그인'}
</button>
</form>
) : (
<form onSubmit={handleVerify} className="bg-card border border-edge rounded-lg p-6 space-y-4">
<p className="text-xs text-gray-400">
{maskedEmail ? `${maskedEmail} 로 발송된 인증코드를 입력하세요.` : '발송된 인증코드를 입력하세요.'}
</p>
<div>
<label className="block text-xs text-gray-400 mb-1"> 6</label>
<input
inputMode="numeric"
maxLength={6}
value={code}
onChange={e => setCode(e.target.value)}
placeholder="000000"
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white text-center tracking-[0.4em] placeholder-gray-600 focus:border-brand focus:outline-none"
/>
</div>
{error && <p className="text-red-400 text-xs">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full bg-brand hover:bg-brand2 text-white py-2 rounded text-sm font-medium transition-colors disabled:opacity-50"
>
{loading ? '인증 중...' : '인증하고 로그인'}
</button>
<button
type="button"
onClick={() => { setStep('login'); setCode(''); setError('') }}
className="w-full border border-edge text-gray-400 hover:text-white py-2 rounded text-sm transition-colors"
>
</button>
</form>
)}
</div>
</div>
)

View File

@ -0,0 +1,118 @@
import { useEffect, useState } from 'react'
import { listReceived, listSent, receivedDetail, sentDetail, sendMessage } from '../../api/uiws'
import { PageHeader, SearchBar, Input, Button, DataGrid, Pagination, Modal, FormField, YnBadge, Spinner, type Column } from '../../components/uiws/ui'
type Tab = 'received' | 'sent'
export default function MessageBox() {
const [tab, setTab] = useState<Tab>('received')
const [rows, setRows] = useState<any[]>([])
const [page, setPage] = useState(0)
const [totalPages, setTotalPages] = useState(0)
const [keyword, setKeyword] = useState('')
const [loading, setLoading] = useState(false)
const [compose, setCompose] = useState(false)
const [detail, setDetail] = useState<any>(null)
const load = async () => {
setLoading(true)
try {
const fn = tab === 'received' ? listReceived : listSent
const res = await fn({ page, size: 20, titleKeyword: keyword || undefined })
const data = res.data.data
setRows(data.content ?? [])
setTotalPages(data.totalPages ?? 0)
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [tab, page])
const receivedCols: Column<any>[] = [
{ key: 'title', header: '제목' },
{ key: 'senderNm', header: '보낸사람', width: 130 },
{ key: 'sentAt', header: '받은시각', width: 170 },
{ key: 'readYn', header: '상태', width: 90, render: r => <YnBadge yn={r.readYn} /> },
]
const sentCols: Column<any>[] = [
{ key: 'title', header: '제목' },
{ key: 'receiverSummary', header: '받는사람', width: 160 },
{ key: 'open', header: '개봉', width: 100, align: 'center', render: r => `${r.openCount}/${r.totalCount}` },
{ key: 'sentAt', header: '보낸시각', width: 170 },
]
const open = async (r: any) => {
const res = tab === 'received' ? await receivedDetail(r.messageId) : await sentDetail(r.messageId)
setDetail({ ...res.data.data, _tab: tab })
if (tab === 'received') load() // 개봉처리 반영
}
return (
<div className="uiws-scope">
<PageHeader title="쪽지" subtitle="UIWS 이식 · 사내 쪽지 송수신"
actions={<Button onClick={() => setCompose(true)}>+ </Button>} />
<SearchBar>
<Button variant={tab === 'received' ? 'primary' : 'ghost'} onClick={() => { setTab('received'); setPage(0) }}></Button>
<Button variant={tab === 'sent' ? 'primary' : 'ghost'} onClick={() => { setTab('sent'); setPage(0) }}></Button>
<div style={{ flex: 1 }} />
<Input value={keyword} onChange={setKeyword} placeholder="제목 검색" style={{ minWidth: 200 }} />
<Button variant="ghost" onClick={() => { setPage(0); load() }}></Button>
</SearchBar>
{loading ? <Spinner /> : (
<>
<DataGrid columns={tab === 'received' ? receivedCols : sentCols} rows={rows} rowKey={r => r.messageId} onRowClick={open} empty="쪽지가 없습니다." />
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
</>
)}
{compose && <Compose onClose={() => setCompose(false)} onSent={() => { setCompose(false); load() }} />}
{detail && <DetailModal detail={detail} onClose={() => setDetail(null)} />}
</div>
)
}
function Compose({ onClose, onSent }: { onClose: () => void; onSent: () => void }) {
const [receiverId, setReceiverId] = useState('')
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [err, setErr] = useState('')
const send = async () => {
setErr('')
try {
await sendMessage({ title, content, receivers: [{ receiverId, rcvType: 'RECV' }] })
onSent()
} catch (e: any) { setErr(e?.response?.data?.message || '전송 실패') }
}
return (
<Modal title="쪽지 보내기" onClose={onClose}
footer={<><Button variant="ghost" onClick={onClose}></Button><Button onClick={send}></Button></>}>
<FormField label="받는사람 ID"><Input value={receiverId} onChange={setReceiverId} style={{ width: '100%' }} /></FormField>
<FormField label="제목"><Input value={title} onChange={setTitle} 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>
)
}
function DetailModal({ detail, onClose }: { detail: any; onClose: () => void }) {
return (
<Modal title={detail.title} onClose={onClose} footer={<Button variant="ghost" onClick={onClose}></Button>}>
<div style={{ fontSize: 13, color: 'var(--uiws-text-muted)', marginBottom: 10 }}>
{detail._tab === 'received' ? `보낸사람: ${detail.senderNm}` : `개봉 ${detail.openCount}/${detail.totalCount}`} · {detail.sentAt}
</div>
<div style={{ fontSize: 14, color: 'var(--uiws-text)', whiteSpace: 'pre-wrap' }}>{detail.content}</div>
{detail._tab === 'sent' && detail.receivers && (
<div style={{ marginTop: 14 }}>
<div style={{ fontSize: 12, color: 'var(--uiws-text-muted)', marginBottom: 6 }}> </div>
{detail.receivers.map((r: any, i: number) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, padding: '4px 0' }}>
<span>{r.receiverNm} ({r.rcvType})</span><YnBadge yn={r.readYn} />
</div>
))}
</div>
)}
</Modal>
)
}

View File

@ -0,0 +1,121 @@
import { useEffect, useState } from 'react'
import { scheduleCalendar, createSchedule, scheduleDetail, deleteSchedule } from '../../api/uiws'
import { PageHeader, Panel, Button, Modal, FormField, Input, Spinner } from '../../components/uiws/ui'
interface Sched { scheduleId: number; title: string; startDt: string; endDt: string; scheType: string; importanceCd?: string }
const WEEK = ['일', '월', '화', '수', '목', '금', '토']
export default function ScheduleCalendar() {
const [base, setBase] = useState(new Date())
const [type, setType] = useState<'PERSONAL' | 'DEPT'>('PERSONAL')
const [items, setItems] = useState<Sched[]>([])
const [loading, setLoading] = useState(false)
const [showForm, setShowForm] = useState<string | null>(null) // date string
const [detail, setDetail] = useState<any>(null)
const ym = `${base.getFullYear()}-${String(base.getMonth() + 1).padStart(2, '0')}`
const load = async () => {
setLoading(true)
try {
const res = await scheduleCalendar({ type, view: 'month', baseDate: `${ym}-01` })
setItems(res.data.data ?? [])
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [ym, type])
const first = new Date(base.getFullYear(), base.getMonth(), 1)
const startPad = first.getDay()
const daysInMonth = new Date(base.getFullYear(), base.getMonth() + 1, 0).getDate()
const cells: (number | null)[] = [...Array(startPad).fill(null), ...Array.from({ length: daysInMonth }, (_, i) => i + 1)]
const byDay = (d: number) => {
const ds = `${ym}-${String(d).padStart(2, '0')}`
return items.filter(s => (s.startDt ?? '').slice(0, 10) <= ds && ds <= (s.endDt ?? '').slice(0, 10))
}
const move = (delta: number) => setBase(new Date(base.getFullYear(), base.getMonth() + delta, 1))
return (
<div className="uiws-scope">
<PageHeader title="일정" subtitle="UIWS 이식 · 개인/부서 일정 달력"
actions={<Button onClick={() => setShowForm(`${ym}-01`)}>+ </Button>} />
<Panel style={{ display: 'flex', gap: 10, alignItems: 'center', marginBottom: 16 }}>
<Button variant="ghost" onClick={() => move(-1)}> </Button>
<div style={{ fontWeight: 700, minWidth: 130, textAlign: 'center', color: 'var(--uiws-text)' }}>{ym}</div>
<Button variant="ghost" onClick={() => move(1)}> </Button>
<div style={{ flex: 1 }} />
<Button variant={type === 'PERSONAL' ? 'primary' : 'ghost'} onClick={() => setType('PERSONAL')}></Button>
<Button variant={type === 'DEPT' ? 'primary' : 'ghost'} onClick={() => setType('DEPT')}></Button>
</Panel>
{loading ? <Spinner /> : (
<div style={{ background: 'var(--uiws-surface)', border: '1px solid var(--uiws-border)', borderRadius: 12, overflow: 'hidden' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)' }}>
{WEEK.map((w, i) => (
<div key={w} style={{ padding: '10px 0', textAlign: 'center', fontSize: 12, fontWeight: 600,
color: i === 0 ? 'var(--uiws-danger)' : 'var(--uiws-text-muted)', borderBottom: '1px solid var(--uiws-border)' }}>{w}</div>
))}
{cells.map((d, i) => (
<div key={i} onClick={() => d && setShowForm(`${ym}-${String(d).padStart(2, '0')}`)}
style={{ minHeight: 96, padding: 6, borderRight: '1px solid var(--uiws-border)', borderBottom: '1px solid var(--uiws-border)',
cursor: d ? 'pointer' : 'default' }}>
{d && <div style={{ fontSize: 12, color: 'var(--uiws-text-muted)', marginBottom: 4 }}>{d}</div>}
{d && byDay(d).slice(0, 3).map(s => (
<div key={s.scheduleId} onClick={e => { e.stopPropagation(); scheduleDetail(s.scheduleId).then(r => setDetail(r.data.data)) }}
style={{ fontSize: 11, padding: '2px 6px', borderRadius: 6, marginBottom: 3,
background: 'var(--uiws-primary-soft)', color: 'var(--uiws-primary)', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' }}>
{s.title}
</div>
))}
</div>
))}
</div>
</div>
)}
{showForm && <ScheduleForm date={showForm} type={type} onClose={() => setShowForm(null)} onSaved={() => { setShowForm(null); load() }} />}
{detail && <ScheduleDetailModal detail={detail} onClose={() => setDetail(null)} onDeleted={() => { setDetail(null); load() }} />}
</div>
)
}
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 [content, setContent] = useState('')
const [err, setErr] = useState('')
const save = async () => {
setErr('')
try {
await createSchedule({ scheType: type, title, startDt, endDt, content, attachmentIds: [] })
onSaved()
} catch (e: any) { setErr(e?.response?.data?.message || '저장 실패') }
}
return (
<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 value={content} onChange={setContent} style={{ width: '100%' }} /></FormField>
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
</Modal>
)
}
function ScheduleDetailModal({ detail, onClose, onDeleted }: { detail: any; onClose: () => void; onDeleted: () => void }) {
const remove = async () => { await deleteSchedule(detail.scheduleId); onDeleted() }
return (
<Modal title={detail.title} onClose={onClose}
footer={<><Button variant="danger" onClick={remove}></Button><Button variant="ghost" onClick={onClose}></Button></>}>
<div style={{ fontSize: 13, color: 'var(--uiws-text-muted)', marginBottom: 8 }}>{detail.scheType} · {detail.startDt} ~ {detail.endDt}</div>
<div style={{ fontSize: 14, color: 'var(--uiws-text)' }}>{detail.content || '내용 없음'}</div>
</Modal>
)
}

View File

@ -0,0 +1,74 @@
import { useEffect, useState } from 'react'
import { personalWorkStats, companyWorkStats } from '../../api/uiws'
import { PageHeader, SearchBar, Input, Button, Spinner } from '../../components/uiws/ui'
interface PivotColumn { key: string; label: string }
interface PivotResponse { fixedColumns: PivotColumn[]; dynamicColumns: PivotColumn[]; rows: Record<string, unknown>[] }
type Mode = 'personal' | 'company'
export default function StatsPivot() {
const [mode, setMode] = useState<Mode>('personal')
const [data, setData] = useState<PivotResponse | null>(null)
const [loading, setLoading] = useState(false)
const today = new Date().toISOString().slice(0, 10)
const weekAgo = new Date(Date.now() - 6 * 864e5).toISOString().slice(0, 10)
const [fromDate, setFromDate] = useState(weekAgo)
const [toDate, setToDate] = useState(today)
const load = async () => {
setLoading(true)
try {
const fn = mode === 'personal' ? personalWorkStats : companyWorkStats
const res = await fn({ fromDate, toDate })
setData(res.data.data)
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [mode])
const cols = data ? [...data.fixedColumns, ...data.dynamicColumns] : []
return (
<div className="uiws-scope">
<PageHeader title="업무통계" subtitle="UIWS 이식 · 근무현황 동적 피벗" />
<SearchBar>
<Button variant={mode === 'personal' ? 'primary' : 'ghost'} onClick={() => setMode('personal')}> </Button>
<Button variant={mode === 'company' ? 'primary' : 'ghost'} onClick={() => setMode('company')}> </Button>
<div style={{ flex: 1 }} />
<Input type="date" value={fromDate} onChange={setFromDate} />
<span style={{ color: 'var(--uiws-text-muted)' }}>~</span>
<Input type="date" value={toDate} onChange={setToDate} />
<Button variant="ghost" onClick={load}></Button>
</SearchBar>
{loading ? <Spinner /> : !data || data.rows.length === 0 ? (
<div style={{ padding: 28, textAlign: 'center', color: 'var(--uiws-text-faint)' }}> .</div>
) : (
<div style={{ background: 'var(--uiws-surface)', border: '1px solid var(--uiws-border)', borderRadius: 12, overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13, color: 'var(--uiws-text)' }}>
<thead>
<tr style={{ background: 'var(--uiws-surface-2)' }}>
{cols.map(c => (
<th key={c.key} style={{ padding: '11px 14px', textAlign: 'left', fontSize: 12, fontWeight: 600,
color: 'var(--uiws-text-muted)', borderBottom: '1px solid var(--uiws-border)', whiteSpace: 'nowrap' }}>{c.label}</th>
))}
</tr>
</thead>
<tbody>
{data.rows.map((row, i) => (
<tr key={i} style={{ borderBottom: '1px solid var(--uiws-border)' }}>
{cols.map(c => (
<td key={c.key} style={{ padding: '10px 14px' }}>{String(row[c.key] ?? '')}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,153 @@
import { useEffect, useState } from 'react'
import { worklogList, worklogProgress, createWorklog, worklogDetail, deleteWorklog, addWorklogComment } from '../../api/uiws'
import { PageHeader, SearchBar, Input, Button, DataGrid, Pagination, Modal, FormField, Spinner, type Column } from '../../components/uiws/ui'
interface WorklogRow extends Record<string, unknown> {
worklogId: number; title: string; writerNm: string; workDate: string; progressCd: string; commentCount: number
}
const PROGRESS_LABEL: Record<string, string> = { ONGOING: '진행중', DONE: '종료' }
export default function WorklogList() {
const [rows, setRows] = useState<WorklogRow[]>([])
const [page, setPage] = useState(0)
const [totalPages, setTotalPages] = useState(0)
const [progress, setProgress] = useState<{ progressCd: string; progressNm: string; count: number }[]>([])
const [filterProgress, setFilterProgress] = useState('')
const [keyword, setKeyword] = useState('')
const [loading, setLoading] = useState(false)
const [showForm, setShowForm] = useState(false)
const [detail, setDetail] = useState<any>(null)
const load = async () => {
setLoading(true)
try {
const res = await worklogList({ page, size: 20, progressCd: filterProgress || undefined })
const data = res.data.data
setRows(data.content ?? [])
setTotalPages(data.totalPages ?? 0)
const pr = await worklogProgress({})
setProgress(pr.data.data ?? [])
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [page, filterProgress])
const columns: Column<WorklogRow>[] = [
{ key: 'workDate', header: '근무일자', width: 120 },
{ key: 'title', header: '제목' },
{ key: 'writerNm', header: '작성자', width: 120 },
{ key: 'progressCd', header: '진행', width: 90, render: r => PROGRESS_LABEL[r.progressCd] ?? r.progressCd },
{ key: 'commentCount', header: '댓글', width: 70, align: 'center' },
]
const openDetail = async (r: WorklogRow) => {
const res = await worklogDetail(r.worklogId)
setDetail(res.data.data)
}
return (
<div className="uiws-scope">
<PageHeader title="업무일지" subtitle="UIWS 이식 · 업무일지 작성/조회/진행관리"
actions={<Button onClick={() => setShowForm(true)}>+ </Button>} />
<div style={{ display: 'flex', gap: 10, marginBottom: 16, flexWrap: 'wrap' }}>
{progress.map(p => (
<div key={p.progressCd} onClick={() => { setFilterProgress(filterProgress === p.progressCd ? '' : p.progressCd); setPage(0) }}
style={{ cursor: 'pointer', padding: '12px 18px', borderRadius: 10, minWidth: 130,
background: 'var(--uiws-surface)', border: `1px solid ${filterProgress === p.progressCd ? 'var(--uiws-primary)' : 'var(--uiws-border)'}` }}>
<div style={{ fontSize: 12, color: 'var(--uiws-text-muted)' }}>{p.progressNm}</div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--uiws-primary)' }}>{p.count}</div>
</div>
))}
</div>
<SearchBar>
<Input value={keyword} onChange={setKeyword} placeholder="제목 검색" style={{ minWidth: 220 }} />
<Button variant="ghost" onClick={() => { setPage(0); load() }}></Button>
{filterProgress && <Button variant="ghost" onClick={() => setFilterProgress('')}> : {PROGRESS_LABEL[filterProgress] ?? filterProgress}</Button>}
</SearchBar>
{loading ? <Spinner /> : (
<>
<DataGrid columns={columns} rows={rows} rowKey={r => r.worklogId} onRowClick={openDetail} empty="업무일지가 없습니다." />
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
</>
)}
{showForm && <WorklogForm onClose={() => setShowForm(false)} onSaved={() => { setShowForm(false); load() }} />}
{detail && <WorklogDetailModal detail={detail} onClose={() => setDetail(null)} onChanged={() => { setDetail(null); load() }} />}
</div>
)
}
function WorklogForm({ onClose, onSaved }: { onClose: () => void; onSaved: () => void }) {
const [title, setTitle] = useState('')
const [writerId, setWriterId] = useState(localStorage.getItem('esn_user') || 'admin')
const [workDate, setWorkDate] = useState(new Date().toISOString().slice(0, 10))
const [workStatusCd, setWorkStatusCd] = useState('NORMAL')
const [err, setErr] = useState('')
const save = async () => {
setErr('')
try {
await createWorklog({ title, writerId, workDate, workStatusCd, progressCd: 'ONGOING', details: [] })
onSaved()
} catch (e: any) {
setErr(e?.response?.data?.message || '저장 실패')
}
}
return (
<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="작성자 ID"><Input value={writerId} onChange={setWriterId} style={{ width: '100%' }} /></FormField>
<FormField label="근무일자"><Input type="date" value={workDate} onChange={setWorkDate} style={{ width: '100%' }} /></FormField>
<FormField label="근무상태 코드"><Input value={workStatusCd} onChange={setWorkStatusCd} style={{ width: '100%' }} /></FormField>
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
</Modal>
)
}
function WorklogDetailModal({ detail, onClose, onChanged }: { detail: any; onClose: () => void; onChanged: () => void }) {
const [comment, setComment] = useState('')
const [err, setErr] = useState('')
const remove = async () => {
await deleteWorklog(detail.worklogId)
onChanged()
}
const addCmt = async () => {
setErr('')
try {
await addWorklogComment(detail.worklogId, comment)
setComment('')
onChanged()
} catch (e: any) {
setErr(e?.response?.data?.message || '댓글 등록 실패')
}
}
return (
<Modal title={detail.title} onClose={onClose}
footer={<><Button variant="danger" onClick={remove}></Button><Button variant="ghost" onClick={onClose}></Button></>}>
<div style={{ fontSize: 13, color: 'var(--uiws-text-muted)', marginBottom: 12 }}>
{detail.writerNm} · {detail.workDate} · {detail.progressCd}
</div>
<div style={{ fontSize: 12, color: 'var(--uiws-text-muted)', marginBottom: 6 }}> {detail.details?.length ?? 0}</div>
<div style={{ marginTop: 14 }}>
<div style={{ fontSize: 12, color: 'var(--uiws-text-muted)', marginBottom: 8 }}></div>
{(detail.comments ?? []).map((c: any) => (
<div key={c.cmtId} style={{ padding: '8px 10px', borderRadius: 8, background: 'var(--uiws-surface-2)', marginBottom: 6, fontSize: 13 }}>
<b>{c.writerNm}</b> · <span style={{ color: 'var(--uiws-text-muted)' }}>{c.createdAt}</span><br />{c.cmtContent}
</div>
))}
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Input value={comment} onChange={setComment} placeholder="댓글 입력(관리자/매니저)" style={{ flex: 1 }} />
<Button onClick={addCmt}></Button>
</div>
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 12, marginTop: 6 }}>{err}</div>}
</div>
</Modal>
)
}

View File

@ -0,0 +1,38 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
export type ThemeMode = 'dark' | 'light'
interface ThemeCtx {
theme: ThemeMode
toggle: () => void
setTheme: (t: ThemeMode) => void
}
const Ctx = createContext<ThemeCtx>({ theme: 'dark', toggle: () => {}, setTheme: () => {} })
const STORAGE_KEY = 'esn_theme'
function applyTheme(t: ThemeMode) {
document.documentElement.setAttribute('data-theme', t)
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<ThemeMode>(() => {
const saved = localStorage.getItem(STORAGE_KEY)
return saved === 'light' ? 'light' : 'dark'
})
useEffect(() => {
applyTheme(theme)
localStorage.setItem(STORAGE_KEY, theme)
}, [theme])
const setTheme = (t: ThemeMode) => setThemeState(t)
const toggle = () => setThemeState(prev => (prev === 'dark' ? 'light' : 'dark'))
return <Ctx.Provider value={{ theme, toggle, setTheme }}>{children}</Ctx.Provider>
}
export function useTheme() {
return useContext(Ctx)
}

View File

@ -0,0 +1,52 @@
/*
* GUARDiA ESN 테마 토큰 (UIWS 이식 화면 공통).
* 다크/라이트 모드 지원. UIWS 화면/컴포넌트는 색상 하드코딩 금지 아래 CSS 변수만 사용한다.
* 기존 ESN 화면(Tailwind 토큰 다크) 영향 없음( 변수는 UIWS 스코프에서만 참조).
*
* 다크 기본값은 기존 ESN 팔레트(ink #0b0f17 / panel #131927 / card #1a2234 / brand #00a0c8) 정합.
*/
:root,
:root[data-theme='dark'] {
--uiws-bg: #0b0f17;
--uiws-surface: #1a2234;
--uiws-surface-2: #131927;
--uiws-border: #26304a;
--uiws-text: #e6edf3;
--uiws-text-muted: #8892b0;
--uiws-text-faint: #4d5568;
--uiws-primary: #00a0c8;
--uiws-primary-contrast: #ffffff;
--uiws-primary-soft: rgba(0, 160, 200, 0.14);
--uiws-danger: #e74c3c;
--uiws-success: #3ddc97;
--uiws-warning: #f1c40f;
--uiws-row-hover: rgba(255, 255, 255, 0.04);
--uiws-input-bg: #0b0f17;
--uiws-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
}
:root[data-theme='light'] {
--uiws-bg: #f4f6fb;
--uiws-surface: #ffffff;
--uiws-surface-2: #eef1f7;
--uiws-border: #d8deea;
--uiws-text: #14202e;
--uiws-text-muted: #5b6478;
--uiws-text-faint: #97a0b5;
--uiws-primary: #0089ab;
--uiws-primary-contrast: #ffffff;
--uiws-primary-soft: rgba(0, 137, 171, 0.10);
--uiws-danger: #d63b2b;
--uiws-success: #1f9e58;
--uiws-warning: #c79a08;
--uiws-row-hover: rgba(0, 0, 0, 0.035);
--uiws-input-bg: #ffffff;
--uiws-shadow: 0 4px 18px rgba(20, 30, 60, 0.10);
}
/* UIWS 화면 컨테이너 — 토큰 기반 기본 타이포/배경 */
.uiws-scope {
color: var(--uiws-text);
}
.uiws-scope a { color: var(--uiws-primary); }