feat(uiws): UIWS 업무모듈(업무일지·일정·쪽지·통계)+2FA 이식
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
4129b94c4b
commit
90d33e32cc
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
|
||||
target/
|
||||
backend/target/
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
@ -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 "";
|
||||
|
||||
@ -1,22 +1,35 @@
|
||||
package com.zioinfo.cms.auth;
|
||||
|
||||
import com.zioinfo.cms.common.ApiResponse;
|
||||
import com.zioinfo.cms.uiws.auth.TwoFactorService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* CMS 인증 컨트롤러.
|
||||
* - /login: 2FA off 면 { twofa:"false", token }, 2FA on 이면 { twofa:"true", verifyToken, step, maskedEmail }.
|
||||
* - /verify: (UIWS 2FA 이식) verify-token + 인증코드 → access/refresh 발급.
|
||||
* 기존 클라이언트(2FA off)는 token 응답 보존 → 회귀 0.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/cms/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) {}
|
||||
}
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
package com.zioinfo.cms.auth;
|
||||
|
||||
import com.zioinfo.cms.auth.mapper.UserMapper;
|
||||
import com.zioinfo.cms.uiws.auth.TwoFactorService;
|
||||
import com.zioinfo.cms.uiws.common.UiwsApiException;
|
||||
import com.zioinfo.cms.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;
|
||||
|
||||
/**
|
||||
* CMS 인증 서비스.
|
||||
* - 기존 단일 JWT 로그인 보존(2FA off 또는 미설정 시 회귀 0).
|
||||
* - UIWS 2FA 레이어(이메일 코드) 추가: 2FA on 이면 1차 통과 후 verify-token + 이메일코드 발급.
|
||||
* 실패 누적 max-login-fail 회 시 계정 잠금.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuthService {
|
||||
@ -15,16 +24,51 @@ 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: { twofa:"false", token, type }
|
||||
* 2FA on : { twofa:"true", verifyToken, step:"EMAIL", maskedEmail }
|
||||
*/
|
||||
public Map<String, String> login(String username, String password) {
|
||||
CmsUser 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);
|
||||
CmsUser 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);
|
||||
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) {
|
||||
|
||||
@ -18,4 +18,18 @@ public class CmsUser {
|
||||
private String role;
|
||||
private boolean active;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
// ── UIWS 2FA 이식 컬럼 (cms_user ALTER, 91_uiws_port.sql) ───────────────────
|
||||
/** 운영자 이메일(2FA 인증코드 발송 대상). 기존 cms_user 미보유 → 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;
|
||||
}
|
||||
|
||||
@ -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/cms/auth/verify 에서만 사용).
|
||||
if (jwtUtil.isValid(token) && !jwtUtil.isVerifyToken(token)) {
|
||||
String username = jwtUtil.getUsername(token);
|
||||
String role = jwtUtil.getRole(token);
|
||||
var auth = new UsernamePasswordAuthenticationToken(
|
||||
|
||||
@ -34,6 +34,47 @@ public class JwtUtil {
|
||||
.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* UIWS 2FA 이식: 1차 로그인 통과 후 발급하는 단기 verify-token.
|
||||
* purpose=2fa 클레임으로 access 토큰과 구분(verify-token 으로는 API 접근 불가).
|
||||
*/
|
||||
public String generateVerifyToken(String username, long validitySeconds) {
|
||||
return Jwts.builder()
|
||||
.subject(username)
|
||||
.claim("purpose", "2fa")
|
||||
.issuedAt(new Date())
|
||||
.expiration(new Date(System.currentTimeMillis() + validitySeconds * 1000L))
|
||||
.signWith(key())
|
||||
.compact();
|
||||
}
|
||||
|
||||
/** verify-token 검증 후 username 반환. 유효하지 않거나 purpose!=2fa 면 null. */
|
||||
public String parseVerifyTokenUsername(String token) {
|
||||
try {
|
||||
Claims c = parse(token);
|
||||
if (!"2fa".equals(c.get("purpose", String.class))) {
|
||||
return null;
|
||||
}
|
||||
return c.getSubject();
|
||||
} catch (JwtException | IllegalArgumentException e) {
|
||||
log.debug("verify-token 검증 실패: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 보안: 토큰이 2FA verify-token(purpose=2fa)인지 판별.
|
||||
* JwtFilter 가 access 토큰만 인증 컨텍스트로 인정하도록 verify-token 을 걸러내는 데 사용.
|
||||
* (2차 코드 검증 전 verify-token 으로 보호 API 접근 = 2FA 완전 우회 → 반드시 차단.)
|
||||
*/
|
||||
public boolean isVerifyToken(String token) {
|
||||
try {
|
||||
return "2fa".equals(parse(token).get("purpose", String.class));
|
||||
} catch (JwtException | IllegalArgumentException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public Claims parse(String token) {
|
||||
return Jwts.parser().verifyWith(key()).build()
|
||||
.parseSignedClaims(token).getPayload();
|
||||
|
||||
@ -4,10 +4,31 @@ import com.zioinfo.cms.auth.CmsUser;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Mapper
|
||||
public interface UserMapper {
|
||||
|
||||
CmsUser findByUsername(@Param("username") String username);
|
||||
|
||||
int insert(CmsUser user);
|
||||
|
||||
// ── UIWS 2FA 이식: 실패 카운트/잠금/인증코드 갱신 (멱등 UPDATE) ───────────────
|
||||
|
||||
/** 로그인 성공 시 실패 카운트 초기화. */
|
||||
int resetLoginFail(@Param("username") String username);
|
||||
|
||||
/** 로그인 실패 누적(+1) 및 임계 도달 시 잠금. */
|
||||
int incrementLoginFail(@Param("username") String username, @Param("maxFail") int maxFail);
|
||||
|
||||
/** 1차 통과 시 이메일 인증코드/만료 저장(verify 단계에서 검증). */
|
||||
int saveEmailCode(@Param("username") String username,
|
||||
@Param("code") String code,
|
||||
@Param("expire") LocalDateTime expire);
|
||||
|
||||
/** 2차 검증 성공 시 코드 폐기. */
|
||||
int clearEmailCode(@Param("username") String username);
|
||||
|
||||
/** 관리자 잠금 해제(실패 카운트/잠금 초기화). */
|
||||
int unlock(@Param("username") String username);
|
||||
}
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
package com.zioinfo.cms.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);
|
||||
}
|
||||
@ -0,0 +1,161 @@
|
||||
package com.zioinfo.cms.uiws.auth;
|
||||
|
||||
import com.zioinfo.cms.auth.CmsUser;
|
||||
import com.zioinfo.cms.auth.JwtUtil;
|
||||
import com.zioinfo.cms.auth.mapper.UserMapper;
|
||||
import com.zioinfo.cms.uiws.common.UiwsApiException;
|
||||
import com.zioinfo.cms.uiws.common.UiwsErrorCode;
|
||||
import com.zioinfo.cms.uiws.common.mail.MailSender;
|
||||
import com.zioinfo.cms.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(이메일 코드) 레이어. CMS 기존 단일 로그인을 보존하면서 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(CmsUser user) {
|
||||
return Boolean.TRUE.equals(user.getLocked());
|
||||
}
|
||||
|
||||
/**
|
||||
* 1차 로그인 성공 후 2차 인증 시작: verify-token 발급 + 이메일코드 발송 + 감사 기록 + 실패카운트 초기화.
|
||||
* @return { verifyToken, step:"EMAIL", maskedEmail }
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, String> beginTwoFactor(CmsUser 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 CMS] 로그인 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);
|
||||
}
|
||||
CmsUser 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 토큰: CMS 기존 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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package com.zioinfo.cms.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;
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package com.zioinfo.cms.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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
package com.zioinfo.cms.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()));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
package com.zioinfo.cms.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));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
package com.zioinfo.cms.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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package com.zioinfo.cms.uiws.common.mail;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 로컬/개발/미설정용 메일 발송 폴백. 실제 전송 없이 로그로만 기록한다(외부 호출 0).
|
||||
* cms.uiws.mail.mode=log (기본) 일 때 활성. SMTP 운영 시 mode=smtp 로 별도 구현 빈 활성화.
|
||||
*
|
||||
* 보안: 본문에 인증코드/임시비밀번호가 포함되므로 로그 레벨은 운영에서 조정.
|
||||
* (인증코드는 API 응답으로는 절대 반환하지 않는다 — 메일/로그 채널로만 전달.)
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "cms.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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package com.zioinfo.cms.uiws.common.mail;
|
||||
|
||||
/**
|
||||
* 메일 발송 추상화(UIWS 이식). 로컬/미설정 환경은 LogMailSender(로그만), 운영은 SMTP 구현으로 교체.
|
||||
* 외부 API 호출은 하지 않는다(보안 불변규칙 — Ollama 외 외부 호출 금지).
|
||||
*/
|
||||
public interface MailSender {
|
||||
|
||||
void send(String to, String subject, String body);
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
package com.zioinfo.cms.uiws.config;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* UIWS 이식 모듈 설정. application.yml 의 cms.uiws.* 바인딩.
|
||||
* 계획서 2FA 설정키(verify-token-validity / max-login-fail / email-code-validity) +
|
||||
* 첨부 업로드 디렉터리. 모두 안전 기본값 보유(미설정이어도 동작).
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "cms.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";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,86 @@
|
||||
package com.zioinfo.cms.uiws.message.controller;
|
||||
|
||||
import com.zioinfo.cms.common.ApiResponse;
|
||||
import com.zioinfo.cms.uiws.message.dto.MessageDtos.*;
|
||||
import com.zioinfo.cms.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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,100 @@
|
||||
package com.zioinfo.cms.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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
package com.zioinfo.cms.uiws.message.mapper;
|
||||
|
||||
import com.zioinfo.cms.uiws.message.model.UiwsMessage;
|
||||
import com.zioinfo.cms.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 변환 이식.
|
||||
* 사용자명은 cms_user(username↔display_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);
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package com.zioinfo.cms.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;
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package com.zioinfo.cms.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;
|
||||
}
|
||||
@ -0,0 +1,271 @@
|
||||
package com.zioinfo.cms.uiws.message.service;
|
||||
|
||||
import com.zioinfo.cms.uiws.common.UiwsApiException;
|
||||
import com.zioinfo.cms.uiws.common.UiwsCurrentUser;
|
||||
import com.zioinfo.cms.uiws.common.UiwsErrorCode;
|
||||
import com.zioinfo.cms.uiws.message.dto.MessageDtos.*;
|
||||
import com.zioinfo.cms.uiws.message.mapper.MessageMapper;
|
||||
import com.zioinfo.cms.uiws.message.model.UiwsMessage;
|
||||
import com.zioinfo.cms.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) {
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
package com.zioinfo.cms.uiws.schedule.controller;
|
||||
|
||||
import com.zioinfo.cms.common.ApiResponse;
|
||||
import com.zioinfo.cms.uiws.schedule.dto.ScheduleDtos.AttachmentDto;
|
||||
import com.zioinfo.cms.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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
package com.zioinfo.cms.uiws.schedule.controller;
|
||||
|
||||
import com.zioinfo.cms.common.ApiResponse;
|
||||
import com.zioinfo.cms.uiws.schedule.dto.ScheduleDtos.*;
|
||||
import com.zioinfo.cms.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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
package com.zioinfo.cms.uiws.schedule.controller;
|
||||
|
||||
import com.zioinfo.cms.common.ApiResponse;
|
||||
import com.zioinfo.cms.uiws.schedule.dto.ScheduleDtos.*;
|
||||
import com.zioinfo.cms.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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
package com.zioinfo.cms.uiws.schedule.dto;
|
||||
|
||||
import com.zioinfo.cms.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) {
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
package com.zioinfo.cms.uiws.schedule.mapper;
|
||||
|
||||
import com.zioinfo.cms.uiws.schedule.model.UiwsAttach;
|
||||
import com.zioinfo.cms.uiws.schedule.model.UiwsDiary;
|
||||
import com.zioinfo.cms.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);
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package com.zioinfo.cms.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;
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
package com.zioinfo.cms.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;
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package com.zioinfo.cms.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;
|
||||
}
|
||||
@ -0,0 +1,121 @@
|
||||
package com.zioinfo.cms.uiws.schedule.service;
|
||||
|
||||
import com.zioinfo.cms.uiws.common.UiwsApiException;
|
||||
import com.zioinfo.cms.uiws.common.UiwsCurrentUser;
|
||||
import com.zioinfo.cms.uiws.common.UiwsErrorCode;
|
||||
import com.zioinfo.cms.uiws.schedule.dto.ScheduleDtos.AttachmentDto;
|
||||
import com.zioinfo.cms.uiws.schedule.mapper.ScheduleMapper;
|
||||
import com.zioinfo.cms.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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,133 @@
|
||||
package com.zioinfo.cms.uiws.schedule.service;
|
||||
|
||||
import com.zioinfo.cms.uiws.common.UiwsApiException;
|
||||
import com.zioinfo.cms.uiws.common.UiwsCurrentUser;
|
||||
import com.zioinfo.cms.uiws.common.UiwsErrorCode;
|
||||
import com.zioinfo.cms.uiws.schedule.dto.ScheduleDtos.*;
|
||||
import com.zioinfo.cms.uiws.schedule.mapper.ScheduleMapper;
|
||||
import com.zioinfo.cms.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).");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
package com.zioinfo.cms.uiws.schedule.service;
|
||||
|
||||
import com.zioinfo.cms.uiws.common.UiwsApiException;
|
||||
import com.zioinfo.cms.uiws.common.UiwsErrorCode;
|
||||
import com.zioinfo.cms.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) {
|
||||
// 물리파일 삭제 실패는 무시(메타 일관성 우선)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,216 @@
|
||||
package com.zioinfo.cms.uiws.schedule.service;
|
||||
|
||||
import com.zioinfo.cms.uiws.common.UiwsApiException;
|
||||
import com.zioinfo.cms.uiws.common.UiwsCurrentUser;
|
||||
import com.zioinfo.cms.uiws.common.UiwsDataScope;
|
||||
import com.zioinfo.cms.uiws.common.UiwsErrorCode;
|
||||
import com.zioinfo.cms.uiws.schedule.dto.ScheduleDtos.*;
|
||||
import com.zioinfo.cms.uiws.schedule.mapper.ScheduleMapper;
|
||||
import com.zioinfo.cms.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).");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
package com.zioinfo.cms.uiws.stats.controller;
|
||||
|
||||
import com.zioinfo.cms.common.ApiResponse;
|
||||
import com.zioinfo.cms.uiws.stats.dto.StatsDtos.PivotResponse;
|
||||
import com.zioinfo.cms.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));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package com.zioinfo.cms.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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
package com.zioinfo.cms.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 미이식):
|
||||
* - 근무자 라벨: cms_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);
|
||||
}
|
||||
@ -0,0 +1,165 @@
|
||||
package com.zioinfo.cms.uiws.stats.service;
|
||||
|
||||
import com.zioinfo.cms.uiws.common.UiwsDataScope;
|
||||
import com.zioinfo.cms.uiws.stats.dto.StatsDtos.PivotColumn;
|
||||
import com.zioinfo.cms.uiws.stats.dto.StatsDtos.PivotResponse;
|
||||
import com.zioinfo.cms.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());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,108 @@
|
||||
package com.zioinfo.cms.uiws.worklog.controller;
|
||||
|
||||
import com.zioinfo.cms.common.ApiResponse;
|
||||
import com.zioinfo.cms.uiws.worklog.dto.WorklogDtos.*;
|
||||
import com.zioinfo.cms.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());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,118 @@
|
||||
package com.zioinfo.cms.uiws.worklog.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.zioinfo.cms.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) {
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,96 @@
|
||||
package com.zioinfo.cms.uiws.worklog.mapper;
|
||||
|
||||
import com.zioinfo.cms.uiws.worklog.model.UiwsWorklog;
|
||||
import com.zioinfo.cms.uiws.worklog.model.UiwsWorklogCmt;
|
||||
import com.zioinfo.cms.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);
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package com.zioinfo.cms.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;
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package com.zioinfo.cms.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;
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package com.zioinfo.cms.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;
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package com.zioinfo.cms.uiws.worklog.service;
|
||||
|
||||
import com.zioinfo.cms.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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,438 @@
|
||||
package com.zioinfo.cms.uiws.worklog.service;
|
||||
|
||||
import com.zioinfo.cms.uiws.common.UiwsApiException;
|
||||
import com.zioinfo.cms.uiws.common.UiwsCurrentUser;
|
||||
import com.zioinfo.cms.uiws.common.UiwsDataScope;
|
||||
import com.zioinfo.cms.uiws.common.UiwsErrorCode;
|
||||
import com.zioinfo.cms.uiws.worklog.dto.WorklogDtos.*;
|
||||
import com.zioinfo.cms.uiws.worklog.mapper.WorklogMapper;
|
||||
import com.zioinfo.cms.uiws.worklog.model.UiwsWorklog;
|
||||
import com.zioinfo.cms.uiws.worklog.model.UiwsWorklogCmt;
|
||||
import com.zioinfo.cms.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).");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -11,8 +11,12 @@ spring:
|
||||
driver-class-name: org.postgresql.Driver
|
||||
sql:
|
||||
init:
|
||||
mode: ${SQL_INIT_MODE:never}
|
||||
schema-locations: classpath:db/schema.sql
|
||||
# UIWS 이식: 91_uiws_port.sql(신규 tb_uiws_* + cms_user 2FA ALTER, 전부 멱등)만 부팅 시 적용.
|
||||
# 기존 schema.sql 은 deploy_server 가 psql 로 별도 적용(비멱등 cms_setting/cms_i18n 시드 충돌 회피).
|
||||
# 91_uiws_port.sql 단독은 완전 멱등 → mode:always 안전. continue-on-error 로 부분 실패 격리.
|
||||
mode: ${SQL_INIT_MODE:always}
|
||||
schema-locations: classpath:db/91_uiws_port.sql
|
||||
continue-on-error: true
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 20MB
|
||||
@ -46,6 +50,21 @@ guardia:
|
||||
secret: ${JWT_SECRET:guardia-cms-jwt-secret-2026-minimum-256bit-key-zioinfo}
|
||||
expiration: 86400000
|
||||
|
||||
# UIWS 이식 모듈 설정 (com.zioinfo.cms.uiws.config.UiwsProperties = prefix cms.uiws)
|
||||
cms:
|
||||
uiws:
|
||||
auth:
|
||||
# 2FA 레이어 on/off. 기본 on. off 면 기존 단일 JWT 로그인 흐름 유지(회귀 0).
|
||||
twofa-enabled: ${CMS_TWOFA_ENABLED:true}
|
||||
verify-token-validity-seconds: 300 # 1차 통과 후 verify-token 5분
|
||||
email-code-validity-seconds: 300 # 이메일 인증코드 5분
|
||||
max-login-fail: 5 # 실패 5회 시 계정 잠금
|
||||
upload:
|
||||
upload-dir: ${CMS_UIWS_UPLOAD_DIR:./uploads/uiws}
|
||||
mail:
|
||||
# 메일 발송 모드. log=LogMailSender 폴백(외부 호출 0, 코드는 로그에만). smtp 운영 시 별도 빈.
|
||||
mode: ${CMS_MAIL_MODE:log}
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.zioinfo.cms: DEBUG
|
||||
|
||||
250
backend/src/main/resources/db/91_uiws_port.sql
Normal file
250
backend/src/main/resources/db/91_uiws_port.sql
Normal file
@ -0,0 +1,250 @@
|
||||
-- ============================================================================
|
||||
-- UIWS 업무 테이블 이식 (CMS 이식) — 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 키 / CMS 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, -- CMS 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] 기존 cms_user 테이블 컬럼 보강 (DROP/재정의 금지 — ADD COLUMN IF NOT EXISTS 멱등)
|
||||
-- email(원 cms_user 미보유 → 추가), 인증코드·만료, 실패 카운트(0), 잠금(false), OTP.
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
ALTER TABLE cms_user ADD COLUMN IF NOT EXISTS email VARCHAR(255);
|
||||
ALTER TABLE cms_user ADD COLUMN IF NOT EXISTS email_verify_code VARCHAR(10);
|
||||
ALTER TABLE cms_user ADD COLUMN IF NOT EXISTS email_verify_expire TIMESTAMP;
|
||||
ALTER TABLE cms_user ADD COLUMN IF NOT EXISTS login_fail_count INT DEFAULT 0;
|
||||
ALTER TABLE cms_user ADD COLUMN IF NOT EXISTS locked BOOLEAN DEFAULT false;
|
||||
ALTER TABLE cms_user ADD COLUMN IF NOT EXISTS otp_secret VARCHAR(255);
|
||||
|
||||
-- 2FA 메일 발송 대상 시드(미설정 admin 계정에 기본 이메일 — 운영 배포 시 변경).
|
||||
-- email 미설정 시 LogMailSender 폴백(코드는 로그에만)으로 동작하므로 흐름은 보장됨.
|
||||
UPDATE cms_user SET email = 'admin@zioinfo.co.kr' WHERE username = 'admin' AND (email IS NULL OR email = '');
|
||||
UPDATE cms_user SET email = 'editor@zioinfo.co.kr' WHERE username = 'editor' AND (email IS NULL OR email = '');
|
||||
UPDATE cms_user SET email = 'author@zioinfo.co.kr' WHERE username = 'author' AND (email IS NULL OR email = '');
|
||||
|
||||
-- end 91_uiws_port.sql
|
||||
@ -11,10 +11,18 @@
|
||||
<result property="role" column="role"/>
|
||||
<result property="active" column="is_active"/>
|
||||
<result property="createdAt" column="created_at"/>
|
||||
<!-- UIWS 2FA 이식 컬럼 (cms_user ALTER, 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 cms_user
|
||||
WHERE username = #{username}
|
||||
</select>
|
||||
@ -25,4 +33,31 @@
|
||||
VALUES (#{username}, #{passwordHash}, #{displayName}, #{role}, #{active})
|
||||
</insert>
|
||||
|
||||
<!-- ── UIWS 2FA 이식: 실패 카운트/잠금/인증코드 갱신 (멱등 UPDATE) ─────────────── -->
|
||||
|
||||
<update id="resetLoginFail">
|
||||
UPDATE cms_user SET login_fail_count = 0 WHERE username = #{username}
|
||||
</update>
|
||||
|
||||
<update id="incrementLoginFail">
|
||||
UPDATE cms_user
|
||||
SET login_fail_count = COALESCE(login_fail_count, 0) + 1,
|
||||
locked = (COALESCE(login_fail_count, 0) + 1 >= #{maxFail})
|
||||
WHERE username = #{username}
|
||||
</update>
|
||||
|
||||
<update id="saveEmailCode">
|
||||
UPDATE cms_user
|
||||
SET email_verify_code = #{code}, email_verify_expire = #{expire}, login_fail_count = 0
|
||||
WHERE username = #{username}
|
||||
</update>
|
||||
|
||||
<update id="clearEmailCode">
|
||||
UPDATE cms_user SET email_verify_code = NULL, email_verify_expire = NULL WHERE username = #{username}
|
||||
</update>
|
||||
|
||||
<update id="unlock">
|
||||
UPDATE cms_user SET locked = false, login_fail_count = 0 WHERE username = #{username}
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
|
||||
16
backend/src/main/resources/mapper/uiws/LoginVerifyMapper.xml
Normal file
16
backend/src/main/resources/mapper/uiws/LoginVerifyMapper.xml
Normal 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.cms.uiws.auth.LoginVerifyMapper">
|
||||
|
||||
<insert id="insert" useGeneratedKeys="true" keyProperty="verifyId" keyColumn="verify_id"
|
||||
parameterType="com.zioinfo.cms.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>
|
||||
137
backend/src/main/resources/mapper/uiws/MessageMapper.xml
Normal file
137
backend/src/main/resources/mapper/uiws/MessageMapper.xml
Normal 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.cms.uiws.message.mapper.MessageMapper">
|
||||
|
||||
<!-- ============================ message 헤더 ============================ -->
|
||||
<insert id="insertMessage" useGeneratedKeys="true" keyProperty="messageId" keyColumn="message_id"
|
||||
parameterType="com.zioinfo.cms.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.cms.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.cms.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.cms.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.cms.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.cms.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.cms.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 cms_user
|
||||
WHERE username IN
|
||||
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
168
backend/src/main/resources/mapper/uiws/ScheduleMapper.xml
Normal file
168
backend/src/main/resources/mapper/uiws/ScheduleMapper.xml
Normal 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.cms.uiws.schedule.mapper.ScheduleMapper">
|
||||
|
||||
<!-- ===================== schedule ===================== -->
|
||||
<insert id="insertSchedule" useGeneratedKeys="true" keyProperty="scheduleId" keyColumn="schedule_id"
|
||||
parameterType="com.zioinfo.cms.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.cms.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.cms.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 < to(배타 상한) AND end_dt >= from -->
|
||||
<select id="findInRange" resultType="com.zioinfo.cms.uiws.schedule.model.UiwsSchedule">
|
||||
SELECT * FROM tb_uiws_schedule
|
||||
WHERE sche_type = #{scheType}
|
||||
AND start_dt < #{to}
|
||||
AND end_dt >= #{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 < #{to}
|
||||
AND end_dt >= #{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.cms.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.cms.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.cms.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.cms.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.cms.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.cms.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.cms.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.cms.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.cms.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 cms_user
|
||||
WHERE username IN
|
||||
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
48
backend/src/main/resources/mapper/uiws/StatsMapper.xml
Normal file
48
backend/src/main/resources/mapper/uiws/StatsMapper.xml
Normal 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.
|
||||
라벨: 근무자=cms_user.display_name(writer_id=username 조인, 없으면 writer_id),
|
||||
근무처=company_id, 근무상태/유형=코드값(공통코드 미이식). -->
|
||||
<mapper namespace="com.zioinfo.cms.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 cms_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 cms_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>
|
||||
181
backend/src/main/resources/mapper/uiws/WorklogMapper.xml
Normal file
181
backend/src/main/resources/mapper/uiws/WorklogMapper.xml
Normal 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.cms.uiws.worklog.mapper.WorklogMapper">
|
||||
|
||||
<!-- ===================== worklog 헤더 ===================== -->
|
||||
<insert id="insertWorklog" useGeneratedKeys="true" keyProperty="worklogId" keyColumn="worklog_id"
|
||||
parameterType="com.zioinfo.cms.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.cms.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.cms.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.cms.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.cms.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.cms.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.cms.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.cms.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.cms.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.cms.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.cms.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.cms.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.cms.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 cms_user
|
||||
WHERE username IN
|
||||
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
</select>
|
||||
|
||||
<select id="findUserEmail" resultType="string">
|
||||
SELECT email FROM cms_user WHERE username = #{username}
|
||||
</select>
|
||||
|
||||
<select id="findWorklogsByIds" resultType="com.zioinfo.cms.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>
|
||||
512
backend/src/main/resources/static/assets/index-BLbMq7Xd.js
Normal file
512
backend/src/main/resources/static/assets/index-BLbMq7Xd.js
Normal file
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
@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>GUARDiA CMS — AI 콘텐츠 관리</title>
|
||||
<script type="module" crossorigin src="/assets/index-qhjCbNFY.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DzayOpa6.css">
|
||||
<script type="module" crossorigin src="/assets/index-BLbMq7Xd.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CaipB8s8.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -22,6 +22,12 @@ import AiTools from './pages/AiTools'
|
||||
import UserManagement from './pages/UserManagement'
|
||||
import AuditLog from './pages/AuditLog'
|
||||
import SystemSettings from './pages/SystemSettings'
|
||||
// UIWS 이식 업무 모듈(업무일지·일정·쪽지·통계) — 기존 라우트 보존, 신규 메뉴만 추가
|
||||
import UiwsLayout from './components/uiws/UiwsLayout'
|
||||
import WorklogList from './pages/uiws/WorklogList'
|
||||
import ScheduleCalendar from './pages/uiws/ScheduleCalendar'
|
||||
import MessageBox from './pages/uiws/MessageBox'
|
||||
import StatsPivot from './pages/uiws/StatsPivot'
|
||||
|
||||
const qc = new QueryClient()
|
||||
|
||||
@ -58,6 +64,12 @@ export default function App() {
|
||||
<Route path="/admin/settings" element={
|
||||
<ProtectedRoute min="EDITOR"><SystemSettings /></ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* 업무(UIWS 이식) — 인증 사용자 전체(Viewer+). UiwsLayout = .uiws-scope 다크/라이트 토큰 */}
|
||||
<Route path="/uiws/worklog" element={<UiwsLayout><WorklogList /></UiwsLayout>} />
|
||||
<Route path="/uiws/schedule" element={<UiwsLayout><ScheduleCalendar /></UiwsLayout>} />
|
||||
<Route path="/uiws/message" element={<UiwsLayout><MessageBox /></UiwsLayout>} />
|
||||
<Route path="/uiws/stats" element={<UiwsLayout><StatsPivot /></UiwsLayout>} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({ baseURL: '' }) // same-origin(운영 단일jar) / dev는 vite proxy(/api→8012)
|
||||
const api = axios.create({ baseURL: 'http://localhost:8012' })
|
||||
|
||||
api.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('cms_token')
|
||||
|
||||
66
frontend/src/api/uiws.ts
Normal file
66
frontend/src/api/uiws.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import api from './client'
|
||||
|
||||
/**
|
||||
* UIWS 이식 모듈 API 클라이언트. 기존 CMS axios 인스턴스(baseURL=http://localhost:8012, JWT 인터셉터) 재사용.
|
||||
* CMS 백엔드는 UIWS 업무 모듈을 /api/worklogs · /api/schedules · /api/messages · /api/stats 로,
|
||||
* 2FA verify 를 /api/cms/auth/verify 로 노출한다(다른 솔루션과 prefix 정합).
|
||||
* 응답 봉투: { success, message, data }. 호출부는 res.data.data 로 페이로드 접근.
|
||||
*/
|
||||
|
||||
// ── 2FA (CMS auth prefix /api/cms/auth)
|
||||
export const verify2fa = (verifyToken: string, code: string) =>
|
||||
api.post('/api/cms/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 })
|
||||
@ -4,8 +4,10 @@ import {
|
||||
LayoutDashboard, FileText, Image, Package, Megaphone, ListTree, Palette,
|
||||
Languages, Search, FileInput, MessageSquareWarning, Share2, UsersRound,
|
||||
Sparkles, BarChart3, ShieldCheck, ScrollText, Settings, FileStack,
|
||||
BookOpen, CalendarDays, Mail,
|
||||
} from 'lucide-react'
|
||||
import { currentRole, hasRole } from './rbac'
|
||||
import ThemeToggle from './uiws/ThemeToggle'
|
||||
|
||||
interface Link { to: string; label: string; icon: any; min?: string }
|
||||
|
||||
@ -35,6 +37,13 @@ const adminLinks: Link[] = [
|
||||
{ to: '/admin/audit', label: '감사 로그', icon: ScrollText, min: 'EDITOR' },
|
||||
{ to: '/admin/settings', label: '시스템 설정', icon: Settings, min: 'EDITOR' },
|
||||
]
|
||||
// 업무(UIWS 이식) — 업무일지·일정·쪽지·통계 (인증 사용자 전체)
|
||||
const uiwsLinks: Link[] = [
|
||||
{ to: '/uiws/worklog', label: '업무일지', icon: BookOpen },
|
||||
{ to: '/uiws/schedule', label: '일정', icon: CalendarDays },
|
||||
{ to: '/uiws/message', label: '쪽지', icon: Mail },
|
||||
{ to: '/uiws/stats', label: '업무통계', icon: BarChart3 },
|
||||
]
|
||||
|
||||
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||
`flex items-center gap-3 px-5 py-2 text-sm transition-colors ${
|
||||
@ -86,13 +95,19 @@ export default function Sidebar() {
|
||||
<Section links={contentLinks} />
|
||||
<Section title="사이트" links={siteLinks} />
|
||||
<Section title="참여 · 분석" links={engageLinks} />
|
||||
<div className="border-t border-edge mt-3">
|
||||
<Section title="업무(UIWS)" links={uiwsLinks} />
|
||||
</div>
|
||||
{visibleAdmin.length > 0 && (
|
||||
<div className="border-t border-edge mt-3">
|
||||
<Section title="관리자" links={visibleAdmin} />
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
<div className="p-4 text-[11px] text-slate-500 border-t border-edge">
|
||||
<div className="px-4 pt-3 border-t border-edge">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div className="p-4 text-[11px] text-slate-500">
|
||||
Ollama 온프레미스 · 헤드리스 CMS
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
16
frontend/src/components/uiws/ThemeToggle.tsx
Normal file
16
frontend/src/components/uiws/ThemeToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
frontend/src/components/uiws/UiwsLayout.tsx
Normal file
30
frontend/src/components/uiws/UiwsLayout.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import ThemeToggle from './ThemeToggle'
|
||||
|
||||
/**
|
||||
* UIWS 이식 화면 래퍼. `.uiws-scope` 컨테이너로 색상 토큰(var(--uiws-*))을 적용하고,
|
||||
* 우상단에 다크/라이트 테마 토글을 배치한다. 색상 하드코딩 없음 — 두 모드 모두 정상.
|
||||
* CMS 전역 다크 셸과 독립(모듈 스코프) — 전역 테마를 덮어쓰지 않는다.
|
||||
*/
|
||||
export default function UiwsLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="uiws-scope"
|
||||
style={{
|
||||
background: 'var(--uiws-bg)',
|
||||
color: 'var(--uiws-text)',
|
||||
minHeight: '100%',
|
||||
margin: -24, // CMS main 패딩(p-6=24px) 상쇄 — 화면 전체 토큰 배경
|
||||
padding: 24,
|
||||
borderRadius: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
|
||||
<div style={{ width: 150 }}>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
171
frontend/src/components/uiws/ui.tsx
Normal file
171
frontend/src/components/uiws/ui.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
/*
|
||||
* UIWS 이식 공통 컴포넌트 키트 — 색상 하드코딩 금지(테마 토큰 var(--uiws-*)만 사용).
|
||||
* 다크/라이트 양 모드에서 대비/가독성 정상. 기존 CMS 컴포넌트와 충돌 회피 위해 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>
|
||||
}
|
||||
@ -2,9 +2,18 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
// UIWS 이식: 모듈 스코프 테마 토큰(다크/라이트). 전역 CMS 다크 셸은 index.css 유지 — 덮어쓰지 않음.
|
||||
import './theme/theme.css'
|
||||
import { ThemeProvider } from './theme/ThemeContext'
|
||||
|
||||
// 초기 테마 적용(FOUC 방지) — ThemeProvider 마운트 전에 data-theme 세팅(uiws-scope 화면에만 영향)
|
||||
const saved = localStorage.getItem('cms_theme')
|
||||
document.documentElement.setAttribute('data-theme', saved === 'light' ? 'light' : 'dark')
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
||||
@ -1,23 +1,29 @@
|
||||
import { useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { login, getMe } from '../api/client'
|
||||
import { verify2fa } from '../api/uiws'
|
||||
|
||||
/**
|
||||
* CMS 로그인 — UIWS 2FA 레이어 대응.
|
||||
* - 1차: /api/cms/auth/login → 2FA off 면 { token } 즉시 발급(회귀 0),
|
||||
* 2FA on 이면 { verifyToken, step, maskedEmail } → 2차 코드 입력 단계로 전환.
|
||||
* - 2차: /api/cms/auth/verify (verifyToken + code) → access 토큰 발급.
|
||||
* 인증코드는 서버 메일/로그 채널로만 전달(응답·화면에 코드 노출 없음).
|
||||
*/
|
||||
export default function Login() {
|
||||
const [step, setStep] = useState<'login' | 'verify'>('login')
|
||||
const [username, setUsername] = useState('admin')
|
||||
const [password, setPassword] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [verifyToken, setVerifyToken] = useState('')
|
||||
const [maskedEmail, setMaskedEmail] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
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
|
||||
if (!token) throw new Error('no token')
|
||||
// 토큰 저장 + 역할 선조회 후 대시보드 진입(공통)
|
||||
const finishLogin = async (token: string) => {
|
||||
localStorage.setItem('cms_token', token)
|
||||
// 역할 선조회 (RBAC 메뉴 즉시 반영)
|
||||
try {
|
||||
const me = await getMe()
|
||||
const d = me.data?.data || {}
|
||||
@ -25,6 +31,25 @@ export default function Login() {
|
||||
if (d.username) localStorage.setItem('cms_user', d.username)
|
||||
} catch { /* noop */ }
|
||||
nav('/dashboard')
|
||||
}
|
||||
|
||||
const submitLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setErr(''); setBusy(true)
|
||||
try {
|
||||
const res = await login(username, password)
|
||||
const d = res.data?.data || {}
|
||||
if (d.twofa === 'true') {
|
||||
// 2FA on — 2차 코드 입력 단계로
|
||||
setVerifyToken(d.verifyToken || '')
|
||||
setMaskedEmail(d.maskedEmail || '')
|
||||
setStep('verify')
|
||||
} else {
|
||||
// 2FA off(또는 미설정) — 기존 단일 로그인 흐름
|
||||
const token = d.token
|
||||
if (!token) throw new Error('no token')
|
||||
await finishLogin(token)
|
||||
}
|
||||
} catch {
|
||||
setErr('로그인 실패 — 아이디/비밀번호를 확인하세요.')
|
||||
} finally {
|
||||
@ -32,14 +57,34 @@ export default function Login() {
|
||||
}
|
||||
}
|
||||
|
||||
const submitVerify = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setErr(''); setBusy(true)
|
||||
try {
|
||||
const res = await verify2fa(verifyToken, code.trim())
|
||||
const token = res.data?.data?.token
|
||||
if (!token) throw new Error('no token')
|
||||
await finishLogin(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' ? submitLogin : submitVerify}
|
||||
className="w-[360px] bg-panel border border-edge rounded-2xl p-8">
|
||||
<div className="flex items-center gap-2 justify-center mb-6">
|
||||
<img src="/login.png" alt="GUARDiA" style={{ height: 36, width: 'auto' }} onError={e => { (e.target as HTMLImageElement).style.display = 'none' }} />
|
||||
<img src="/login.png" alt="GUARDiA" style={{ height: 36, width: 'auto' }}
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }} />
|
||||
<span className="text-xl font-bold">GUARDiA CMS</span>
|
||||
</div>
|
||||
<p className="text-center text-sm text-slate-400 mb-6">AI 콘텐츠 관리 시스템</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" />
|
||||
@ -51,6 +96,26 @@ export default function Login() {
|
||||
{busy ? '로그인 중…' : '로그인'}
|
||||
</button>
|
||||
<p className="text-center text-[11px] text-slate-500 mt-4">admin / editor / author · admin123</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-center text-xs text-slate-400 mb-4">
|
||||
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"
|
||||
maxLength={6} autoFocus placeholder="6자리 코드"
|
||||
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm tracking-[0.3em] text-center focus:border-brand outline-none" />
|
||||
{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 ? '확인 중…' : '인증 확인'}
|
||||
</button>
|
||||
<button type="button" onClick={() => { setStep('login'); setCode(''); setErr('') }}
|
||||
className="w-full py-2 mt-2 rounded-lg text-slate-400 text-xs hover:text-slate-200">
|
||||
← 로그인으로 돌아가기
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
|
||||
118
frontend/src/pages/uiws/MessageBox.tsx
Normal file
118
frontend/src/pages/uiws/MessageBox.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
121
frontend/src/pages/uiws/ScheduleCalendar.tsx
Normal file
121
frontend/src/pages/uiws/ScheduleCalendar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
frontend/src/pages/uiws/StatsPivot.tsx
Normal file
74
frontend/src/pages/uiws/StatsPivot.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
153
frontend/src/pages/uiws/WorklogList.tsx
Normal file
153
frontend/src/pages/uiws/WorklogList.tsx
Normal 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('cms_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>
|
||||
)
|
||||
}
|
||||
38
frontend/src/theme/ThemeContext.tsx
Normal file
38
frontend/src/theme/ThemeContext.tsx
Normal 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 = 'cms_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)
|
||||
}
|
||||
53
frontend/src/theme/theme.css
Normal file
53
frontend/src/theme/theme.css
Normal file
@ -0,0 +1,53 @@
|
||||
/*
|
||||
* GUARDiA CMS 테마 토큰 (UIWS 이식 화면 공통).
|
||||
* 다크/라이트 두 모드 지원. UIWS 화면/컴포넌트는 색상 하드코딩 금지 — 아래 CSS 변수만 사용한다.
|
||||
* 기존 CMS 화면(인라인 하드코딩 다크)은 영향 없음(이 변수는 UIWS 스코프에서만 참조).
|
||||
*
|
||||
* 다크 기본값은 기존 CMS 팔레트(#0b0f17/#131927/#00a0c8)와 정합.
|
||||
*/
|
||||
|
||||
:root,
|
||||
:root[data-theme='dark'] {
|
||||
/* CMS NCloud 다크 팔레트(ink/panel/card/edge/brand)와 정합 — uiws 화면이 CMS 셸과 자연 블렌딩 */
|
||||
--uiws-bg: #0b0f17;
|
||||
--uiws-surface: #131927;
|
||||
--uiws-surface-2: #1a2234;
|
||||
--uiws-border: #26304a;
|
||||
--uiws-text: #e6edf6;
|
||||
--uiws-text-muted: #8892b0;
|
||||
--uiws-text-faint: #4d5568;
|
||||
--uiws-primary: #00a0c8;
|
||||
--uiws-primary-contrast: #06121a;
|
||||
--uiws-primary-soft: rgba(0, 160, 200, 0.14);
|
||||
--uiws-danger: #e74c3c;
|
||||
--uiws-success: #3ddc97;
|
||||
--uiws-warning: #f1c40f;
|
||||
--uiws-row-hover: rgba(255, 255, 255, 0.04);
|
||||
--uiws-input-bg: #0b1220;
|
||||
--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); }
|
||||
Loading…
Reference in New Issue
Block a user