feat(uiws): UIWS 업무모듈(업무일지·일정·쪽지·통계)+2FA 이식

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
GUARDiA 2026-06-20 21:07:37 +09:00
parent f5cb69e70e
commit 31d89228a3
75 changed files with 5606 additions and 516 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
target/
backend/target/
node_modules/
frontend/dist/

View File

@ -44,7 +44,7 @@ public class OllamaClient {
.bodyValue(body)
.retrieve()
.bodyToMono(Map.class)
.timeout(Duration.ofSeconds(30))
.timeout(Duration.ofSeconds(120))
.map(m -> (Map<String, Object>) m)
.block();
if (res == null) return "";
@ -70,7 +70,7 @@ public class OllamaClient {
.bodyValue(body)
.retrieve()
.bodyToMono(Map.class)
.timeout(Duration.ofSeconds(45))
.timeout(Duration.ofSeconds(120))
.map(m -> (Map<String, Object>) m)
.block();
if (res == null) return "";

View File

@ -1,22 +1,35 @@
package com.zioinfo.mes.auth;
import com.zioinfo.mes.common.ApiResponse;
import com.zioinfo.mes.uiws.auth.TwoFactorService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* MES 인증 컨트롤러.
* - /login: 2FA off { token, type, twofa:"false" }, 2FA on 이면 { twofa:"true", verifyToken, step, maskedEmail }.
* - /verify: (UIWS 2FA 이식) verify-token + 인증코드 access/refresh 발급.
* 기존 클라이언트(2FA off) 응답 형태 token 보존 회귀 0.
*/
@RestController
@RequestMapping("/api/mes/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/refresh 발급. */
@PostMapping("/verify")
public ApiResponse<Map<String, String>> verify(@RequestBody VerifyRequest req) {
return ApiResponse.ok(twoFactorService.verify(req.verifyToken(), req.code()));
}
@GetMapping("/me")
@ -26,4 +39,6 @@ public class AuthController {
}
record LoginRequest(String username, String password) {}
record VerifyRequest(String verifyToken, String code) {}
}

View File

@ -1,6 +1,9 @@
package com.zioinfo.mes.auth;
import com.zioinfo.mes.auth.mapper.UserMapper;
import com.zioinfo.mes.uiws.auth.TwoFactorService;
import com.zioinfo.mes.uiws.common.UiwsApiException;
import com.zioinfo.mes.uiws.common.UiwsErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@ -8,6 +11,12 @@ import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
/**
* MES 인증 서비스.
* - 기존 단일 JWT 로그인 보존(2FA off 또는 미설정 회귀 0).
* - UIWS 2FA 레이어(이메일 코드) 추가: 2FA on 이면 1차 통과 verify-token + 이메일코드 발급.
* 실패 누적 max-login-fail 계정 잠금.
*/
@Service
@RequiredArgsConstructor
public class AuthService {
@ -15,16 +24,52 @@ public class AuthService {
private final UserMapper 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) {
MesUser 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);
MesUser after = userMapper.findByUsername(username);
if (after != null && Boolean.TRUE.equals(after.getLocked())) {
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
}
}
throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치");
}
return jwtUtil.generate(username, user.getRole());
// 비밀번호 검증 통과
if (twoFactorService.isEnabled()) {
Map<String, String> step1 = twoFactorService.beginTwoFactor(user);
// verifyToken/step/maskedEmail + twofa 플래그
return Map.of(
"twofa", "true",
"verifyToken", step1.get("verifyToken"),
"step", step1.get("step"),
"maskedEmail", step1.getOrDefault("maskedEmail", ""));
}
// 2FA 비활성 기존 단일 로그인 흐름(회귀 0)
userMapper.resetLoginFail(username);
String token = jwtUtil.generate(username, user.getRole());
return Map.of("twofa", "false", "token", token, "type", "Bearer");
}
public Map<String, Object> me(String token) {

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/mes/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

@ -34,6 +34,46 @@ public class JwtUtil {
.compact();
}
/**
* UIWS 2FA 이식: 1차 로그인 통과 발급하는 단기 verify-token.
* purpose=2fa 클레임으로 access 토큰과 구분(verify-token 으로는 API 접근 불가).
*/
public String generateVerifyToken(String username, long validitySeconds) {
return Jwts.builder()
.subject(username)
.claim("purpose", "2fa")
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + validitySeconds * 1000L))
.signWith(key())
.compact();
}
/** verify-token 검증 후 username 반환. 유효하지 않거나 purpose!=2fa 면 null. */
public String parseVerifyTokenUsername(String token) {
try {
Claims c = parse(token);
if (!"2fa".equals(c.get("purpose", String.class))) {
return null;
}
return c.getSubject();
} catch (JwtException | IllegalArgumentException e) {
log.debug("verify-token 검증 실패: {}", e.getMessage());
return null;
}
}
/**
* 보안: 토큰이 2FA verify-token(purpose=2fa)인지 판별.
* JwtFilter access 토큰만 인증 컨텍스트로 인정하도록 verify-token 걸러내는 사용.
*/
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

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

View File

@ -3,11 +3,49 @@ package com.zioinfo.mes.auth.mapper;
import com.zioinfo.mes.auth.MesUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
@Mapper
public interface UserMapper {
// findByUsername / insert mapper/UserMapper.xml 정의(2FA 컬럼 포함 resultMap).
// 어노테이션 중복 정의 "statement already contains value" 크래시 XML 유지.
MesUser findByUsername(@Param("username") String username);
int insert(MesUser user);
// UIWS 2FA 이식: 실패 카운트/잠금/인증코드 갱신 (멱등 UPDATE)
/** 로그인 성공 시 실패 카운트 초기화. */
@Update("UPDATE mes_user SET login_fail_count = 0 WHERE username = #{username}")
int resetLoginFail(@Param("username") String username);
/** 로그인 실패 누적(+1) 및 임계 도달 시 잠금. */
@Update("""
UPDATE mes_user
SET login_fail_count = COALESCE(login_fail_count, 0) + 1,
locked = (COALESCE(login_fail_count, 0) + 1 >= #{maxFail})
WHERE username = #{username}
""")
int incrementLoginFail(@Param("username") String username, @Param("maxFail") int maxFail);
/** 1차 통과 시 이메일 인증코드/만료 저장(verify 단계에서 검증). */
@Update("""
UPDATE mes_user
SET email_verify_code = #{code}, email_verify_expire = #{expire}, login_fail_count = 0
WHERE username = #{username}
""")
int saveEmailCode(@Param("username") String username,
@Param("code") String code,
@Param("expire") LocalDateTime expire);
/** 2차 검증 성공 시 코드 폐기. */
@Update("UPDATE mes_user SET email_verify_code = NULL, email_verify_expire = NULL WHERE username = #{username}")
int clearEmailCode(@Param("username") String username);
/** 관리자 잠금 해제(실패 카운트/잠금 초기화). */
@Update("UPDATE mes_user SET locked = false, login_fail_count = 0 WHERE username = #{username}")
int unlock(@Param("username") String username);
}

View File

@ -0,0 +1,26 @@
package com.zioinfo.mes.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.mes.uiws.auth;
import com.zioinfo.mes.auth.JwtUtil;
import com.zioinfo.mes.auth.mapper.UserMapper;
import com.zioinfo.mes.auth.MesUser;
import com.zioinfo.mes.uiws.common.UiwsApiException;
import com.zioinfo.mes.uiws.common.UiwsErrorCode;
import com.zioinfo.mes.uiws.common.mail.MailSender;
import com.zioinfo.mes.uiws.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(이메일 코드) 레이어. ERP 기존 단일 로그인을 보존하면서 2단계 인증을 추가한다.
*
* 흐름:
* 1) 1차 로그인 성공 {@link #beginTwoFactor}: verify-token 발급 + 이메일 인증코드 발송(LogMailSender 폴백) + 감사 기록.
* 2) {@code POST /api/auth/verify}(verifyToken + code) {@link #verify}: 코드 검증 access/refresh 발급.
* 3) 로그인 실패 누적 max-login-fail 계정 잠금({@link #recordLoginFailure}).
*
* 보안:
* - 인증코드는 메일/감사 채널로만 전달. API 응답·로그 메시지에 코드/비밀번호/자격증명 절대 미노출(불변규칙).
* - 외부 API 호출 없음(MailSender 폴백은 로그). Ollama 외부 통신 금지 준수.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TwoFactorService {
private static final SecureRandom RANDOM = new SecureRandom();
private static final String SYSTEM = "SYSTEM";
private final UserMapper 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(MesUser user) {
return Boolean.TRUE.equals(user.getLocked());
}
/**
* 1차 로그인 성공 2차 인증 시작: verify-token 발급 + 이메일코드 발송 + 감사 기록 + 실패카운트 초기화.
* @return { verifyToken, step:"EMAIL", maskedEmail }
*/
@Transactional
public Map<String, String> beginTwoFactor(MesUser 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 MES] 로그인 2차 인증 코드";
String body = String.format(
"안녕하세요 %s 님,\n로그인 2차 인증 코드는 [%s] 입니다.\n유효시간: %d초",
user.getDisplayName() != null ? user.getDisplayName() : 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/refresh 발급. 코드 폐기 + 감사 이력 검증완료.
* @return { token, refreshToken, username, role }
*/
@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);
}
MesUser 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);
String access = jwtUtil.generate(user.getUsername(), user.getRole());
// refresh 토큰: ERP 기존 access 토큰 정책 재사용(별도 refresh 정책 부재 동일 발급).
String refresh = jwtUtil.generate(user.getUsername(), user.getRole());
return Map.of(
"token", access,
"refreshToken", refresh,
"type", "Bearer",
"username", user.getUsername(),
"role", user.getRole() == null ? "" : user.getRole());
}
/** 로그인 비밀번호 실패 시 누적/잠금 처리. */
@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.mes.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.mes.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.mes.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.mes.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.mes.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.mes.uiws.common.mail;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
/**
* 로컬/개발/미설정용 메일 발송 폴백. 실제 전송 없이 로그로만 기록한다(외부 호출 0).
* mes.uiws.mail.mode=log (기본) 활성. SMTP 운영 mode=smtp 별도 구현 활성화.
*
* 보안: 본문에 인증코드/임시비밀번호가 포함되므로 로그 레벨은 운영에서 조정.
* (인증코드는 API 응답으로는 절대 반환하지 않는다 메일/로그 채널로만 전달.)
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "mes.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.mes.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.mes.uiws.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* UIWS 이식 모듈 설정. application.yml mes.uiws.* 바인딩.
* 계획서 2FA 설정키(verify-token-validity / max-login-fail / email-code-validity) +
* 첨부 업로드 디렉터리. 모두 안전 기본값 보유(미설정이어도 동작).
*/
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "mes.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,86 @@
package com.zioinfo.mes.uiws.message.controller;
import com.zioinfo.mes.common.ApiResponse;
import com.zioinfo.mes.uiws.message.dto.MessageDtos.*;
import com.zioinfo.mes.uiws.message.service.MessageService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
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 미사용).
*/
@Tag(name = "UIWS-쪽지", description = "UIWS 이식: 쪽지 송수신/개봉현황")
@RestController
@RequestMapping("/api/messages")
@RequiredArgsConstructor
public class MessageController {
private final MessageService messageService;
@Operation(summary = "쪽지 전송/답장")
@PostMapping
public ApiResponse<MessageSendResponse> send(@Valid @RequestBody MessageSendDto dto) {
return ApiResponse.ok(messageService.send(dto));
}
@Operation(summary = "보낸쪽지함(기본 최근 1주일)")
@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));
}
@Operation(summary = "보낸쪽지 상세(수신자 개봉현황)")
@GetMapping("/sent/{id}")
public ApiResponse<SentMessageDetailDto> sentDetail(@PathVariable("id") Long id) {
return ApiResponse.ok(messageService.sentDetail(id));
}
@Operation(summary = "보낸쪽지 다중삭제")
@DeleteMapping("/sent")
public ApiResponse<Void> deleteSent(@Valid @RequestBody MessageIdsRequest req) {
messageService.deleteSent(req.ids());
return ApiResponse.ok(null);
}
@Operation(summary = "미열람 받은쪽지 수(배지)")
@GetMapping("/unread-count")
public ApiResponse<Long> unreadCount() {
return ApiResponse.ok(messageService.unreadCount());
}
@Operation(summary = "받은쪽지함(기본 최근 1주일)")
@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));
}
@Operation(summary = "받은쪽지 상세(조회 시 개봉처리)")
@GetMapping("/received/{id}")
public ApiResponse<ReceivedMessageDetailDto> receivedDetail(@PathVariable("id") Long id) {
return ApiResponse.ok(messageService.receivedDetail(id));
}
@Operation(summary = "받은쪽지 다중삭제")
@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.mes.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.mes.uiws.message.mapper;
import com.zioinfo.mes.uiws.message.model.UiwsMessage;
import com.zioinfo.mes.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 변환 이식.
* 사용자명은 mes_user(usernamedisplay_name) 조인으로 라벨화(코어 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.mes.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.mes.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.mes.uiws.message.service;
import com.zioinfo.mes.uiws.common.UiwsApiException;
import com.zioinfo.mes.uiws.common.UiwsCurrentUser;
import com.zioinfo.mes.uiws.common.UiwsErrorCode;
import com.zioinfo.mes.uiws.message.dto.MessageDtos.*;
import com.zioinfo.mes.uiws.message.mapper.MessageMapper;
import com.zioinfo.mes.uiws.message.model.UiwsMessage;
import com.zioinfo.mes.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,74 @@
package com.zioinfo.mes.uiws.schedule.controller;
import com.zioinfo.mes.common.ApiResponse;
import com.zioinfo.mes.uiws.schedule.dto.ScheduleDtos.AttachmentDto;
import com.zioinfo.mes.uiws.schedule.service.AttachmentService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
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).
*/
@Tag(name = "UIWS-첨부", description = "UIWS 이식: 일정/일지 첨부파일")
@RestController
@RequestMapping("/api/attachments")
@RequiredArgsConstructor
public class AttachmentController {
private final AttachmentService attachmentService;
@Operation(summary = "첨부파일 업로드(일정/일지)")
@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));
}
@Operation(summary = "첨부 다운로드")
@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);
}
@Operation(summary = "첨부 삭제")
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable("id") Long id) {
attachmentService.delete(id);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,60 @@
package com.zioinfo.mes.uiws.schedule.controller;
import com.zioinfo.mes.common.ApiResponse;
import com.zioinfo.mes.uiws.schedule.dto.ScheduleDtos.*;
import com.zioinfo.mes.uiws.schedule.service.DiaryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
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).
*/
@Tag(name = "UIWS-일지", description = "UIWS 이식: 일지")
@RestController
@RequestMapping("/api/diaries")
@RequiredArgsConstructor
public class DiaryController {
private final DiaryService diaryService;
@Operation(summary = "일지 목록")
@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));
}
@Operation(summary = "일지 등록")
@PostMapping
public ApiResponse<DiaryDetailDto> create(@Valid @RequestBody DiarySaveDto dto) {
return ApiResponse.ok(diaryService.create(dto));
}
@Operation(summary = "일지 상세")
@GetMapping("/{id}")
public ApiResponse<DiaryDetailDto> detail(@PathVariable("id") Long id) {
return ApiResponse.ok(diaryService.detail(id));
}
@Operation(summary = "일지 수정")
@PutMapping("/{id}")
public ApiResponse<DiaryDetailDto> update(@PathVariable("id") Long id, @Valid @RequestBody DiarySaveDto dto) {
return ApiResponse.ok(diaryService.update(id, dto));
}
@Operation(summary = "일지 삭제")
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable("id") Long id) {
diaryService.delete(id);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,78 @@
package com.zioinfo.mes.uiws.schedule.controller;
import com.zioinfo.mes.common.ApiResponse;
import com.zioinfo.mes.uiws.schedule.dto.ScheduleDtos.*;
import com.zioinfo.mes.uiws.schedule.service.ScheduleService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
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). 원본 엔드포인트 보존.
*/
@Tag(name = "UIWS-일정", description = "UIWS 이식: 개인/부서 일정")
@RestController
@RequestMapping("/api/schedules")
@RequiredArgsConstructor
public class ScheduleController {
private final ScheduleService scheduleService;
@Operation(summary = "개인/부서 일정(달력 month/week/day)")
@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));
}
@Operation(summary = "전체일정 목록")
@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));
}
@Operation(summary = "일정검색 팝업")
@GetMapping("/search")
public ApiResponse<List<ScheduleDto>> search(@RequestParam(required = false) String keyword) {
return ApiResponse.ok(scheduleService.search(keyword));
}
@Operation(summary = "일정 등록")
@PostMapping
public ApiResponse<ScheduleDto> create(@Valid @RequestBody ScheduleSaveDto dto) {
return ApiResponse.ok(scheduleService.create(dto));
}
@Operation(summary = "일정 상세")
@GetMapping("/{id}")
public ApiResponse<ScheduleDetailDto> detail(@PathVariable("id") Long id) {
return ApiResponse.ok(scheduleService.detail(id));
}
@Operation(summary = "일정 수정")
@PutMapping("/{id}")
public ApiResponse<ScheduleDetailDto> update(@PathVariable("id") Long id, @Valid @RequestBody ScheduleSaveDto dto) {
return ApiResponse.ok(scheduleService.update(id, dto));
}
@Operation(summary = "일정 삭제")
@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.mes.uiws.schedule.dto;
import com.zioinfo.mes.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.mes.uiws.schedule.mapper;
import com.zioinfo.mes.uiws.schedule.model.UiwsAttach;
import com.zioinfo.mes.uiws.schedule.model.UiwsDiary;
import com.zioinfo.mes.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.mes.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.mes.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.mes.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.mes.uiws.schedule.service;
import com.zioinfo.mes.uiws.common.UiwsApiException;
import com.zioinfo.mes.uiws.common.UiwsCurrentUser;
import com.zioinfo.mes.uiws.common.UiwsErrorCode;
import com.zioinfo.mes.uiws.schedule.dto.ScheduleDtos.AttachmentDto;
import com.zioinfo.mes.uiws.schedule.mapper.ScheduleMapper;
import com.zioinfo.mes.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.mes.uiws.schedule.service;
import com.zioinfo.mes.uiws.common.UiwsApiException;
import com.zioinfo.mes.uiws.common.UiwsCurrentUser;
import com.zioinfo.mes.uiws.common.UiwsErrorCode;
import com.zioinfo.mes.uiws.schedule.dto.ScheduleDtos.*;
import com.zioinfo.mes.uiws.schedule.mapper.ScheduleMapper;
import com.zioinfo.mes.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.mes.uiws.schedule.service;
import com.zioinfo.mes.uiws.common.UiwsApiException;
import com.zioinfo.mes.uiws.common.UiwsErrorCode;
import com.zioinfo.mes.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.mes.uiws.schedule.service;
import com.zioinfo.mes.uiws.common.UiwsApiException;
import com.zioinfo.mes.uiws.common.UiwsCurrentUser;
import com.zioinfo.mes.uiws.common.UiwsDataScope;
import com.zioinfo.mes.uiws.common.UiwsErrorCode;
import com.zioinfo.mes.uiws.schedule.dto.ScheduleDtos.*;
import com.zioinfo.mes.uiws.schedule.mapper.ScheduleMapper;
import com.zioinfo.mes.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,48 @@
package com.zioinfo.mes.uiws.stats.controller;
import com.zioinfo.mes.common.ApiResponse;
import com.zioinfo.mes.uiws.stats.dto.StatsDtos.PivotResponse;
import com.zioinfo.mes.uiws.stats.service.StatsService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
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 기존 라우트와 미충돌(확인). 원본 엔드포인트 보존.
*/
@Tag(name = "UIWS-업무통계", description = "UIWS 이식: 근무현황 동적 피벗")
@RestController
@RequestMapping("/api/stats")
@RequiredArgsConstructor
public class StatsController {
private final StatsService statsService;
@Operation(summary = "개인별 근무현황(행=근무자×상태×유형, 동적열=근무처)")
@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));
}
@Operation(summary = "업체별 근무현황(행=근무처×유형, 동적열=근무자)")
@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.mes.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.mes.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 미이식):
* - 근무자 라벨: mes_user.display_name (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.mes.uiws.stats.service;
import com.zioinfo.mes.uiws.common.UiwsDataScope;
import com.zioinfo.mes.uiws.stats.dto.StatsDtos.PivotColumn;
import com.zioinfo.mes.uiws.stats.dto.StatsDtos.PivotResponse;
import com.zioinfo.mes.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,108 @@
package com.zioinfo.mes.uiws.worklog.controller;
import com.zioinfo.mes.common.ApiResponse;
import com.zioinfo.mes.uiws.worklog.dto.WorklogDtos.*;
import com.zioinfo.mes.uiws.worklog.service.WorklogService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
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 의존성 미보유로 제외(보고서는 후속 트랙 erp_feature_port.md 참조).
*/
@Tag(name = "UIWS-업무일지", description = "UIWS 이식: 업무일지/시간대별 상세/댓글")
@RestController
@RequestMapping("/api/worklogs")
@RequiredArgsConstructor
public class WorklogController {
private final WorklogService worklogService;
@Operation(summary = "업무일지 목록(리스트형, 기본 최근 1개월)")
@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));
}
@Operation(summary = "관리자 대시보드 — 진행상태별 건수")
@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));
}
@Operation(summary = "업무일지 목록(달력형)")
@GetMapping("/calendar")
public ApiResponse<List<WorklogCalendarDto>> calendar(
@RequestParam(required = false) String yearMonth,
@RequestParam(required = false) String writerId) {
return ApiResponse.ok(worklogService.calendar(yearMonth, writerId));
}
@Operation(summary = "업무일지 조회 팝업")
@GetMapping("/search")
public ApiResponse<List<WorklogListDto>> search(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String writerId) {
return ApiResponse.ok(worklogService.search(keyword, writerId));
}
@Operation(summary = "업무일지 등록")
@PostMapping
public ApiResponse<WorklogDetailDto> create(@Valid @RequestBody WorklogSaveDto dto) {
return ApiResponse.ok(worklogService.create(dto));
}
@Operation(summary = "업무일지 상세")
@GetMapping("/{id}")
public ApiResponse<WorklogDetailDto> detail(@PathVariable("id") Long id) {
return ApiResponse.ok(worklogService.detail(id));
}
@Operation(summary = "업무일지 수정(시간대별 일괄, _state)")
@PutMapping("/{id}")
public ApiResponse<WorklogDetailDto> update(@PathVariable("id") Long id, @Valid @RequestBody WorklogSaveDto dto) {
return ApiResponse.ok(worklogService.update(id, dto));
}
@Operation(summary = "업무일지 삭제")
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable("id") Long id) {
worklogService.delete(id);
return ApiResponse.ok(null);
}
@Operation(summary = "댓글 등록(+알림)")
@PostMapping("/{id}/comments")
public ApiResponse<WorklogCommentDto> addComment(@PathVariable("id") Long id,
@Valid @RequestBody CommentRequest req) {
return ApiResponse.ok(worklogService.addComment(id, req));
}
@Operation(summary = "댓글 확인 처리(일지 작성자 전용)")
@PostMapping("/comments/{cmtId}/confirm")
public ApiResponse<Void> confirmComment(@PathVariable("cmtId") Long cmtId) {
worklogService.confirmComment(cmtId);
return ApiResponse.ok(null);
}
@Operation(summary = "내가 단 댓글 중 작성자 미확인 목록")
@GetMapping("/comments/unconfirmed")
public ApiResponse<List<UnconfirmedCommentDto>> unconfirmedComments() {
return ApiResponse.ok(worklogService.unconfirmedByMe());
}
}

View File

@ -0,0 +1,118 @@
package com.zioinfo.mes.uiws.worklog.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.zioinfo.mes.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.mes.uiws.worklog.mapper;
import com.zioinfo.mes.uiws.worklog.model.UiwsWorklog;
import com.zioinfo.mes.uiws.worklog.model.UiwsWorklogCmt;
import com.zioinfo.mes.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.mes.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.mes.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.mes.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.mes.uiws.worklog.service;
import com.zioinfo.mes.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.mes.uiws.worklog.service;
import com.zioinfo.mes.uiws.common.UiwsApiException;
import com.zioinfo.mes.uiws.common.UiwsCurrentUser;
import com.zioinfo.mes.uiws.common.UiwsDataScope;
import com.zioinfo.mes.uiws.common.UiwsErrorCode;
import com.zioinfo.mes.uiws.worklog.dto.WorklogDtos.*;
import com.zioinfo.mes.uiws.worklog.mapper.WorklogMapper;
import com.zioinfo.mes.uiws.worklog.model.UiwsWorklog;
import com.zioinfo.mes.uiws.worklog.model.UiwsWorklogCmt;
import com.zioinfo.mes.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

@ -15,8 +15,11 @@ spring:
minimum-idle: 1
sql:
init:
mode: ${SQL_INIT_MODE:never}
schema-locations: classpath:db/schema.sql
# UIWS 이식: 91_uiws_port.sql(tb_uiws_* + mes_user 2FA ALTER, 전부 멱등)만 부팅 시 적용.
# 기존 schema.sql 은 deploy_server 가 psql 로 별도 적용(비멱등 시드 충돌 회피).
mode: ${SQL_INIT_MODE:always}
schema-locations: classpath:db/91_uiws_port.sql
continue-on-error: true
servlet:
multipart:
max-file-size: 20MB
@ -34,6 +37,19 @@ springdoc:
swagger-ui:
path: /api/mes/swagger
# ── UIWS 이식 모듈 설정 (worklog/schedule/message/stats + 2FA 레이어) ──────────
mes:
uiws:
auth:
twofa-enabled: ${UIWS_2FA:true} # off=기존 단일로그인 회귀 0
verify-token-validity-seconds: 300 # 1차 통과 후 verify-token 5분
email-code-validity-seconds: 300 # 이메일 인증코드 5분
max-login-fail: 5 # 실패 5회 누적 시 계정 잠금
mail:
mode: ${UIWS_MAIL_MODE:log} # LogMailSender 폴백(외부 API 0). 운영 smtp 시 별도 빈
upload:
upload-dir: ${UIWS_UPLOAD_DIR:./uploads/uiws}
guardia:
erp-url: ${ERP_URL:http://localhost:8003}
itsm-url: ${ITSM_URL:http://localhost:9001}

View File

@ -0,0 +1,251 @@
-- ============================================================================
-- UIWS 업무 테이블 이식 (MES, ERP 파일럿 복제·치환) — 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 키 / ERP 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, -- MES 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 멱등)
-- ★ MES mes_user 는 email 컬럼 미보유(원본: username/password_hash/display_name/role/is_active)
-- → 2FA 발송 대상 확보를 위해 email 포함 6컬럼 ALTER(Portal 사례와 동일, ERP는 email 기보유라 5컬럼).
-- 이메일·인증코드·만료시각, 실패 카운트(기본 0), 잠금(기본 false), OTP 시크릿.
-- ───────────────────────────────────────────────────────────────────────────
ALTER TABLE mes_user ADD COLUMN IF NOT EXISTS email VARCHAR(255);
ALTER TABLE mes_user ADD COLUMN IF NOT EXISTS email_verify_code VARCHAR(10);
ALTER TABLE mes_user ADD COLUMN IF NOT EXISTS email_verify_expire TIMESTAMP;
ALTER TABLE mes_user ADD COLUMN IF NOT EXISTS login_fail_count INT DEFAULT 0;
ALTER TABLE mes_user ADD COLUMN IF NOT EXISTS locked BOOLEAN DEFAULT false;
ALTER TABLE mes_user ADD COLUMN IF NOT EXISTS otp_secret VARCHAR(255);
-- ───────────────────────────────────────────────────────────────────────────
-- [메뉴] MES 는 DB 메뉴/RBAC 테이블 부재(NAV는 프론트 정적 정의) → SQL 메뉴 시드 대상 없음.
-- "업무 (UIWS)" 메뉴는 프론트 Sidebar/Route 에 추가(코드). 여기서는 주석으로만 명시.
-- ───────────────────────────────────────────────────────────────────────────
-- end 91_uiws_port.sql

View File

@ -11,10 +11,18 @@
<result property="role" column="role"/>
<result property="active" column="is_active"/>
<result property="createdAt" column="created_at"/>
<!-- UIWS 2FA 이식 컬럼 (mes_user ALTER, db/91_uiws_port.sql) -->
<result property="email" column="email"/>
<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, username, password_hash, display_name, role, is_active, created_at
SELECT id, username, password_hash, display_name, role, is_active, created_at,
email, email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret
FROM mes_user
WHERE username = #{username}
</select>

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.mes.uiws.auth.LoginVerifyMapper">
<insert id="insert" useGeneratedKeys="true" keyProperty="verifyId" keyColumn="verify_id"
parameterType="com.zioinfo.mes.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.mes.uiws.message.mapper.MessageMapper">
<!-- ============================ message 헤더 ============================ -->
<insert id="insertMessage" useGeneratedKeys="true" keyProperty="messageId" keyColumn="message_id"
parameterType="com.zioinfo.mes.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.mes.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.mes.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.mes.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.mes.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.mes.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.mes.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", display_name AS "fullName"
FROM mes_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.mes.uiws.schedule.mapper.ScheduleMapper">
<!-- ===================== schedule ===================== -->
<insert id="insertSchedule" useGeneratedKeys="true" keyProperty="scheduleId" keyColumn="schedule_id"
parameterType="com.zioinfo.mes.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.mes.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.mes.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.mes.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.mes.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.mes.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.mes.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.mes.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.mes.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.mes.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.mes.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.mes.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.mes.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", display_name AS "fullName"
FROM mes_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.
라벨: 근무자=mes_user.display_name(writer_id=username 조인, 없으면 writer_id),
근무처=company_id, 근무상태/유형=코드값(공통코드 미이식). -->
<mapper namespace="com.zioinfo.mes.uiws.stats.mapper.StatsMapper">
<select id="personalWork" resultType="map">
SELECT COALESCE(u.display_name, 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 mes_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.display_name, 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.display_name, 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 mes_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.display_name, 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.mes.uiws.worklog.mapper.WorklogMapper">
<!-- ===================== worklog 헤더 ===================== -->
<insert id="insertWorklog" useGeneratedKeys="true" keyProperty="worklogId" keyColumn="worklog_id"
parameterType="com.zioinfo.mes.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.mes.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.mes.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.mes.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.mes.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.mes.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.mes.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.mes.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.mes.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.mes.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.mes.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.mes.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.mes.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", display_name AS "fullName"
FROM mes_user
WHERE username IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
</select>
<select id="findUserEmail" resultType="string">
SELECT email FROM mes_user WHERE username = #{username}
</select>
<select id="findWorklogsByIds" resultType="com.zioinfo.mes.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>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GUARDiA MES — AI 제조실행시스템</title>
<script type="module" crossorigin src="/assets/index-BLHiicbF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BwkEm3xg.css">
<script type="module" crossorigin src="/assets/index-DOZiCYTH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-i_K9xe48.css">
</head>
<body>
<div id="root"></div>

View File

@ -38,6 +38,11 @@ import AiTools from './pages/AiTools'
import UserManagement from './pages/UserManagement'
import AuditLog from './pages/AuditLog'
import SystemSettings from './pages/SystemSettings'
// 업무 (UIWS 이식) — 업무일지/일정/쪽지/업무통계
import UiwsWorklog from './pages/uiws/WorklogList'
import UiwsSchedule from './pages/uiws/ScheduleCalendar'
import UiwsMessage from './pages/uiws/MessageBox'
import UiwsStats from './pages/uiws/StatsPivot'
const qc = new QueryClient()
@ -82,6 +87,11 @@ export default function App() {
{/* 공통 */}
<Route path="/analytics" element={<Analytics />} />
<Route path="/ai-tools" element={<AiTools />} />
{/* 업무 (UIWS 이식) — 기존 라우트 보존, 추가만 */}
<Route path="/uiws/worklog" element={<UiwsWorklog />} />
<Route path="/uiws/schedule" element={<UiwsSchedule />} />
<Route path="/uiws/message" element={<UiwsMessage />} />
<Route path="/uiws/stats" element={<UiwsStats />} />
{/* 관리자 */}
<Route path="/admin/users" element={
<ProtectedRoute min="SUPERADMIN"><UserManagement /></ProtectedRoute>

View File

@ -1,6 +1,6 @@
import axios from 'axios'
const api = axios.create({ baseURL: '' }) // same-origin(운영 단일jar) / dev는 vite proxy(/api→8013)
const api = axios.create({ baseURL: 'http://localhost:8013' })
api.interceptors.request.use(config => {
const token = localStorage.getItem('mes_token')

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

@ -0,0 +1,66 @@
import api from './client'
/**
* UIWS API . MES axios (client.ts, JWT ) .
* uiws prefix /api/worklogs · /api/schedules · /api/messages · /api/stats
* (MES /api/mes/* ). 2FA verify /api/mes/auth/verify.
* : { success, message, data }. res.data.data .
*/
// ── 2FA (MES AuthController: /api/mes/auth/verify)
export const verify2fa = (verifyToken: string, code: string) =>
api.post('/api/mes/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

@ -5,9 +5,10 @@ import {
Gauge, Network, CalendarRange, PackagePlus, Truck, Boxes, ClipboardCheck, Barcode,
ScanSearch, Ruler, AlertTriangle, ListChecks, LineChart, FileBadge, Package, ListTree,
Route as RouteIcon, Building2, Warehouse, BarChart3, Sparkles, ShieldCheck, ScrollText,
Settings, Cpu,
Settings, Cpu, NotebookPen, CalendarDays, Mail, PieChart,
} from 'lucide-react'
import { currentRole, hasRole } from './rbac'
import ThemeToggle from './uiws/ThemeToggle'
interface Link { to: string; label: string; icon: any; min?: string }
@ -50,6 +51,13 @@ const commonLinks: Link[] = [
{ to: '/analytics', label: '분석 / KPI', icon: BarChart3 },
{ to: '/ai-tools', label: 'AI 도구', icon: Sparkles },
]
// 업무 (UIWS 이식) — 업무일지/일정/쪽지/업무통계
const uiwsLinks: Link[] = [
{ to: '/uiws/worklog', label: '업무일지', icon: NotebookPen },
{ to: '/uiws/schedule', label: '일정', icon: CalendarDays },
{ to: '/uiws/message', label: '쪽지', icon: Mail },
{ to: '/uiws/stats', label: '업무통계', icon: PieChart },
]
const adminLinks: Link[] = [
{ to: '/admin/users', label: '사용자/권한', icon: ShieldCheck, min: 'SUPERADMIN' },
{ to: '/admin/audit', label: '감사 로그', icon: ScrollText, min: 'MANAGER' },
@ -111,13 +119,17 @@ export default function Sidebar() {
<Section title="QMS 품질관리" links={qmsLinks} role={role} />
<Section title="기준정보" links={masterLinks} role={role} />
<Section title="공통" links={commonLinks} role={role} />
<Section title="업무 (UIWS)" links={uiwsLinks} role={role} />
{visibleAdmin.length > 0 && (
<div className="border-t border-edge mt-3">
<Section title="관리자" links={visibleAdmin} role={role} />
</div>
)}
</nav>
<div className="p-4 text-[11px] text-slate-500 border-t border-edge">
<div className="p-3 border-t border-edge">
<ThemeToggle />
</div>
<div className="px-4 pb-4 text-[11px] text-slate-500">
Ollama · WMS·MES·QMS
</div>
</aside>

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

@ -2,9 +2,13 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
import './theme/theme.css'
import { ThemeProvider } from './theme/ThemeContext'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
</React.StrictMode>
)

View File

@ -2,20 +2,27 @@ import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Cpu } from 'lucide-react'
import { login, getMe } from '../api/client'
import { verify2fa } from '../api/uiws'
/**
* GUARDiA MES UIWS 2FA .
* 1 login twofa="true" (verifyToken )
* /api/mes/auth/verify access . twofa!="true"(off) ( 0).
* / / .
*/
export default function Login() {
const [username, setUsername] = useState('admin')
const [password, setPassword] = useState('')
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const [step, setStep] = useState<'login' | 'verify'>('login')
const [verifyToken, setVerifyToken] = useState('')
const [maskedEmail, setMaskedEmail] = useState('')
const [code, setCode] = useState('')
const nav = useNavigate()
const submit = async (e: React.FormEvent) => {
e.preventDefault()
setErr(''); setBusy(true)
try {
const res = await login(username, password)
const token = res.data?.data?.token
// access 토큰 저장 + getMe 로 role/username 보관(Sidebar RBAC 가드 의존) → 대시보드 이동
const finish = async (token?: string) => {
if (!token) throw new Error('no token')
localStorage.setItem('mes_token', token)
try {
@ -25,6 +32,22 @@ export default function Login() {
if (d.username) localStorage.setItem('mes_user', d.username)
} catch { /* noop */ }
nav('/dashboard')
}
const submit = async (e: React.FormEvent) => {
e.preventDefault()
setErr(''); setBusy(true)
try {
const res = await login(username, password)
const data = res.data?.data || {}
if (data.twofa === 'true') {
// 2단계: verify-token 보관 후 코드 입력 화면으로(코드는 미표시)
setVerifyToken(data.verifyToken)
setMaskedEmail(data.maskedEmail || '')
setStep('verify')
return
}
await finish(data.token)
} catch {
setErr('로그인 실패 — 아이디/비밀번호를 확인하세요.')
} finally {
@ -32,25 +55,63 @@ export default function Login() {
}
}
const submitCode = async (e: React.FormEvent) => {
e.preventDefault()
setErr(''); setBusy(true)
try {
const res = await verify2fa(verifyToken, code.trim())
await finish(res.data?.data?.token)
} catch {
setErr('인증 코드가 올바르지 않거나 만료되었습니다.')
} finally {
setBusy(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-ink">
<form onSubmit={submit} className="w-[360px] bg-panel border border-edge rounded-2xl p-8">
<form onSubmit={step === 'login' ? submit : submitCode}
className="w-[360px] bg-panel border border-edge rounded-2xl p-8">
<div className="flex items-center gap-2 justify-center mb-6">
<Cpu className="text-brand" size={32} />
<span className="text-xl font-bold">GUARDiA MES</span>
</div>
<p className="text-center text-sm text-slate-400 mb-6">AI · WMS · MES · QMS</p>
{step === 'login' ? (
<>
<label className="block text-xs text-slate-400 mb-1"></label>
<input value={username} onChange={e => setUsername(e.target.value)}
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" />
<label className="block text-xs text-slate-400 mb-1"></label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)}
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" />
</>
) : (
<>
<p className="text-xs text-slate-400 mb-3">
2 {maskedEmail ? ` (${maskedEmail})` : ''}.
</p>
<label className="block text-xs text-slate-400 mb-1"> </label>
<input value={code} onChange={e => setCode(e.target.value)}
inputMode="numeric" autoFocus placeholder="6자리 코드"
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none tracking-widest" />
</>
)}
{err && <p className="text-rose-400 text-xs mb-3">{err}</p>}
<button disabled={busy} className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90 disabled:opacity-60">
{busy ? '로그인 중…' : '로그인'}
{busy ? '처리 중…' : (step === 'login' ? '로그인' : '인증 확인')}
</button>
{step === 'verify' && (
<button type="button" onClick={() => { setStep('login'); setCode(''); setErr('') }}
className="w-full mt-2 py-2 rounded-lg text-xs text-slate-400 hover:text-slate-200">
</button>
)}
{step === 'login' && (
<p className="text-center text-[11px] text-slate-500 mt-4">admin / manager / worker · admin123</p>
)}
</form>
</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('mes_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 = 'mes_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 MES 테마 토큰 (UIWS 이식 화면 공통).
* 다크/라이트 모드 지원. UIWS 화면/컴포넌트는 색상 하드코딩 금지 아래 CSS 변수만 사용한다.
* 기존 ERP 화면(인라인 하드코딩 다크) 영향 없음( 변수는 UIWS 스코프에서만 참조).
*
* 다크 기본값은 기존 ERP 팔레트(#0f1117/#1a1f2e/#4f8ef7) 정합.
*/
:root,
:root[data-theme='dark'] {
--uiws-bg: #0f1117;
--uiws-surface: #1a1f2e;
--uiws-surface-2: #11151f;
--uiws-border: #2d3448;
--uiws-text: #e6edf3;
--uiws-text-muted: #8892b0;
--uiws-text-faint: #4d5568;
--uiws-primary: #4f8ef7;
--uiws-primary-contrast: #ffffff;
--uiws-primary-soft: rgba(79, 142, 247, 0.13);
--uiws-danger: #e74c3c;
--uiws-success: #2ecc71;
--uiws-warning: #f1c40f;
--uiws-row-hover: rgba(255, 255, 255, 0.04);
--uiws-input-bg: #0f1117;
--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: #1a1f2e;
--uiws-text-muted: #5b6478;
--uiws-text-faint: #97a0b5;
--uiws-primary: #2f6fe0;
--uiws-primary-contrast: #ffffff;
--uiws-primary-soft: rgba(47, 111, 224, 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); }