feat(uiws): UIWS 업무모듈(worklog·schedule·message·stats)+2FA 이식
- tb_uiws_* 9테이블 + esn_user 2FA 컬럼(91_uiws_port.sql) - com.zioinfo.esn.uiws.* 백엔드 40 java + JwtFilter purpose=2fa 차단 - 프론트 업무화면+2FA+다크/라이트 테마 - 자동배포 인프라 구축(repos/guardia-esn) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
4c3caea1b5
commit
f60cf93254
@ -1,22 +1,35 @@
|
|||||||
package com.zioinfo.esn.auth;
|
package com.zioinfo.esn.auth;
|
||||||
|
|
||||||
import com.zioinfo.esn.common.ApiResponse;
|
import com.zioinfo.esn.common.ApiResponse;
|
||||||
|
import com.zioinfo.esn.uiws.auth.TwoFactorService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ESN 인증 컨트롤러.
|
||||||
|
* - /login: 2FA off 면 { twofa:"false", token, type }, 2FA on 이면 { twofa:"true", verifyToken, step, maskedEmail }.
|
||||||
|
* - /verify: (UIWS 2FA 이식) verify-token + 인증코드 → access 토큰 발급.
|
||||||
|
* 기존 클라이언트는 응답에 token 필드가 그대로 존재(2FA off 시) → 회귀 0.
|
||||||
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/auth")
|
@RequestMapping("/api/auth")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AuthController {
|
public class AuthController {
|
||||||
|
|
||||||
private final AuthService authService;
|
private final AuthService authService;
|
||||||
|
private final TwoFactorService twoFactorService;
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) {
|
public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) {
|
||||||
String token = authService.login(req.username(), req.password());
|
return ApiResponse.ok(authService.login(req.username(), req.password()));
|
||||||
return ApiResponse.ok(Map.of("token", token, "type", "Bearer"));
|
}
|
||||||
|
|
||||||
|
/** UIWS 2FA 이식: 2차 인증 코드 검증 → access 토큰 발급. */
|
||||||
|
@PostMapping("/verify")
|
||||||
|
public ApiResponse<Map<String, String>> verify(@RequestBody VerifyRequest req) {
|
||||||
|
return ApiResponse.ok(twoFactorService.verify(req.verifyToken(), req.code()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/me")
|
@GetMapping("/me")
|
||||||
@ -32,4 +45,6 @@ public class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
record LoginRequest(String username, String password) {}
|
record LoginRequest(String username, String password) {}
|
||||||
|
|
||||||
|
record VerifyRequest(String verifyToken, String code) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,21 @@
|
|||||||
package com.zioinfo.esn.auth;
|
package com.zioinfo.esn.auth;
|
||||||
|
|
||||||
import com.zioinfo.esn.auth.mapper.UserAuthMapper;
|
import com.zioinfo.esn.auth.mapper.UserAuthMapper;
|
||||||
|
import com.zioinfo.esn.uiws.auth.TwoFactorService;
|
||||||
|
import com.zioinfo.esn.uiws.common.UiwsApiException;
|
||||||
|
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ESN 인증 서비스.
|
||||||
|
* - 기존 단일 JWT 로그인 보존(2FA off 또는 미설정 시 회귀 0).
|
||||||
|
* - UIWS 2FA 레이어(이메일 코드) 추가: 2FA on 이면 1차 통과 후 verify-token + 이메일코드 발급.
|
||||||
|
* 실패 누적 max-login-fail 회 시 계정 잠금.
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AuthService {
|
public class AuthService {
|
||||||
@ -14,17 +23,52 @@ public class AuthService {
|
|||||||
private final UserAuthMapper userMapper;
|
private final UserAuthMapper userMapper;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final JwtUtil jwtUtil;
|
private final JwtUtil jwtUtil;
|
||||||
|
private final TwoFactorService twoFactorService;
|
||||||
|
|
||||||
public String login(String username, String password) {
|
/**
|
||||||
|
* 1차 로그인. 2FA 활성 시 verify-token + 이메일코드 흐름으로 분기,
|
||||||
|
* 비활성 시 기존처럼 access 토큰 즉시 발급.
|
||||||
|
*
|
||||||
|
* @return 2FA off: { token, type, twofa:"false" }
|
||||||
|
* 2FA on : { verifyToken, step:"EMAIL", maskedEmail, twofa:"true" }
|
||||||
|
*/
|
||||||
|
public Map<String, String> login(String username, String password) {
|
||||||
EsnUser user = userMapper.findByUsername(username);
|
EsnUser user = userMapper.findByUsername(username);
|
||||||
|
|
||||||
|
// 잠금 우선 차단(존재하는 계정에 한해 잠금 메시지 — 존재 여부 누설 최소화)
|
||||||
|
if (user != null && twoFactorService.isLocked(user)) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
|
||||||
|
}
|
||||||
if (user == null || !user.isActive()) {
|
if (user == null || !user.isActive()) {
|
||||||
throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정");
|
throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정");
|
||||||
}
|
}
|
||||||
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
|
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
|
||||||
|
// 2FA 활성 시 실패 누적/잠금. 비활성 시 기존 동작(메시지만) 유지.
|
||||||
|
if (twoFactorService.isEnabled()) {
|
||||||
|
twoFactorService.recordLoginFailure(username);
|
||||||
|
EsnUser after = userMapper.findByUsername(username);
|
||||||
|
if (after != null && Boolean.TRUE.equals(after.getLocked())) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
|
||||||
|
}
|
||||||
|
}
|
||||||
throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치");
|
throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 비밀번호 검증 통과
|
||||||
|
if (twoFactorService.isEnabled()) {
|
||||||
|
Map<String, String> step1 = twoFactorService.beginTwoFactor(user);
|
||||||
|
return Map.of(
|
||||||
|
"twofa", "true",
|
||||||
|
"verifyToken", step1.get("verifyToken"),
|
||||||
|
"step", step1.get("step"),
|
||||||
|
"maskedEmail", step1.getOrDefault("maskedEmail", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2FA 비활성 — 기존 단일 로그인 흐름(회귀 0)
|
||||||
|
userMapper.resetLoginFail(username);
|
||||||
userMapper.updateLastLogin(username);
|
userMapper.updateLastLogin(username);
|
||||||
return jwtUtil.generate(username, user.getRole(), user.getTenantCode());
|
String token = jwtUtil.generate(username, user.getRole(), user.getTenantCode());
|
||||||
|
return Map.of("twofa", "false", "token", token, "type", "Bearer");
|
||||||
}
|
}
|
||||||
|
|
||||||
public Map<String, String> me(String token) {
|
public Map<String, String> me(String token) {
|
||||||
|
|||||||
@ -18,4 +18,19 @@ public class EsnUser {
|
|||||||
private boolean active;
|
private boolean active;
|
||||||
private LocalDateTime lastLoginAt;
|
private LocalDateTime lastLoginAt;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
// ── UIWS 2FA 이식 컬럼 (esn_user ALTER, db/91_uiws_port.sql) ─────────────────
|
||||||
|
/** 이메일 인증코드(6자리). 발급 후 verify 단계에서 검증. API 응답에는 절대 미포함. */
|
||||||
|
@JsonIgnore
|
||||||
|
private String emailVerifyCode;
|
||||||
|
/** 인증코드 만료시각. */
|
||||||
|
@JsonIgnore
|
||||||
|
private LocalDateTime emailVerifyExpire;
|
||||||
|
/** 로그인 실패 누적 횟수(기본 0). max-login-fail 도달 시 locked. */
|
||||||
|
private Integer loginFailCount;
|
||||||
|
/** 계정 잠금 여부(기본 false). */
|
||||||
|
private Boolean locked;
|
||||||
|
/** TOTP 시크릿(UIWS OTP 경로 대비, 현재 이메일 흐름에서는 미사용). */
|
||||||
|
@JsonIgnore
|
||||||
|
private String otpSecret;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,7 +26,10 @@ public class JwtFilter extends OncePerRequestFilter {
|
|||||||
String header = req.getHeader("Authorization");
|
String header = req.getHeader("Authorization");
|
||||||
if (header != null && header.startsWith("Bearer ")) {
|
if (header != null && header.startsWith("Bearer ")) {
|
||||||
String token = header.substring(7);
|
String token = header.substring(7);
|
||||||
if (jwtUtil.isValid(token)) {
|
// 보안(★치명 — UIWS 2FA): purpose=2fa verify-token 은 access 토큰이 아니다.
|
||||||
|
// 동일 서명키라 isValid()는 통과하므로 별도 차단하지 않으면 2차 인증 전 보호 API 접근(2FA 완전 우회)이 가능.
|
||||||
|
// → verify-token 은 인증 컨텍스트를 세우지 않고 무시한다(/api/auth/verify 에서만 사용).
|
||||||
|
if (jwtUtil.isValid(token) && !jwtUtil.isVerifyToken(token)) {
|
||||||
String username = jwtUtil.getUsername(token);
|
String username = jwtUtil.getUsername(token);
|
||||||
String role = jwtUtil.getRole(token);
|
String role = jwtUtil.getRole(token);
|
||||||
var auth = new UsernamePasswordAuthenticationToken(
|
var auth = new UsernamePasswordAuthenticationToken(
|
||||||
|
|||||||
@ -35,6 +35,48 @@ public class JwtUtil {
|
|||||||
.compact();
|
.compact();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UIWS 2FA 이식: 1차 로그인 통과 후 발급하는 단기 verify-token.
|
||||||
|
* purpose=2fa 클레임으로 access 토큰과 구분(verify-token 으로는 보호 API 접근 불가 — JwtFilter 에서 차단).
|
||||||
|
*/
|
||||||
|
public String generateVerifyToken(String username, long validitySeconds) {
|
||||||
|
return Jwts.builder()
|
||||||
|
.subject(username)
|
||||||
|
.claim("purpose", "2fa")
|
||||||
|
.issuedAt(new Date())
|
||||||
|
.expiration(new Date(System.currentTimeMillis() + validitySeconds * 1000L))
|
||||||
|
.signWith(key())
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** verify-token 검증 후 username 반환. 유효하지 않거나 purpose!=2fa 면 null. */
|
||||||
|
public String parseVerifyTokenUsername(String token) {
|
||||||
|
try {
|
||||||
|
Claims c = parse(token);
|
||||||
|
if (!"2fa".equals(c.get("purpose", String.class))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return c.getSubject();
|
||||||
|
} catch (JwtException | IllegalArgumentException e) {
|
||||||
|
log.debug("verify-token 검증 실패: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 보안(★치명): 토큰이 2FA verify-token(purpose=2fa)인지 판별.
|
||||||
|
* verify-token 은 access 와 같은 서명키라 isValid()는 통과하므로, JwtFilter 가 별도로
|
||||||
|
* 걸러내지 않으면 2차 코드 검증 없이 보호 API 접근(2FA 완전 우회)이 된다.
|
||||||
|
* → JwtFilter 는 purpose=2fa 토큰을 인증 컨텍스트로 세우지 않는다(/api/auth/verify 에서만 사용).
|
||||||
|
*/
|
||||||
|
public boolean isVerifyToken(String token) {
|
||||||
|
try {
|
||||||
|
return "2fa".equals(parse(token).get("purpose", String.class));
|
||||||
|
} catch (JwtException | IllegalArgumentException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public Claims parse(String token) {
|
public Claims parse(String token) {
|
||||||
return Jwts.parser().verifyWith(key()).build()
|
return Jwts.parser().verifyWith(key()).build()
|
||||||
.parseSignedClaims(token).getPayload();
|
.parseSignedClaims(token).getPayload();
|
||||||
|
|||||||
@ -4,8 +4,29 @@ import com.zioinfo.esn.auth.EsnUser;
|
|||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface UserAuthMapper {
|
public interface UserAuthMapper {
|
||||||
EsnUser findByUsername(@Param("username") String username);
|
EsnUser findByUsername(@Param("username") String username);
|
||||||
int updateLastLogin(@Param("username") String username);
|
int updateLastLogin(@Param("username") String username);
|
||||||
|
|
||||||
|
// ── UIWS 2FA 이식: 실패 카운트/잠금/인증코드 갱신 (멱등 UPDATE) ───────────────
|
||||||
|
|
||||||
|
/** 로그인 성공 시 실패 카운트 초기화. */
|
||||||
|
int resetLoginFail(@Param("username") String username);
|
||||||
|
|
||||||
|
/** 로그인 실패 누적(+1) 및 임계 도달 시 잠금. */
|
||||||
|
int incrementLoginFail(@Param("username") String username, @Param("maxFail") int maxFail);
|
||||||
|
|
||||||
|
/** 1차 통과 시 이메일 인증코드/만료 저장(verify 단계에서 검증). */
|
||||||
|
int saveEmailCode(@Param("username") String username,
|
||||||
|
@Param("code") String code,
|
||||||
|
@Param("expire") LocalDateTime expire);
|
||||||
|
|
||||||
|
/** 2차 검증 성공 시 코드 폐기. */
|
||||||
|
int clearEmailCode(@Param("username") String username);
|
||||||
|
|
||||||
|
/** 관리자 잠금 해제(실패 카운트/잠금 초기화). */
|
||||||
|
int unlock(@Param("username") String username);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,64 @@
|
|||||||
|
package com.zioinfo.esn.common;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.uiws.common.UiwsApiException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전역 예외 처리(보안 불변규칙: 스택트레이스/SQL/자격증명 미노출).
|
||||||
|
*
|
||||||
|
* <p>UIWS 이식 모듈 + 2FA 레이어 도입과 함께 추가. 기존 ESN 컨트롤러도 동일 정책으로 보호한다
|
||||||
|
* (예외 메시지는 사용자 노출용 요약만 — 내부 원인은 서버 로그에만 남긴다).
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
/** UIWS 이식/2FA 도메인 예외 → 400 + 안전한 사용자 메시지. */
|
||||||
|
@ExceptionHandler(UiwsApiException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleUiws(UiwsApiException e) {
|
||||||
|
log.debug("[UIWS] {}: {}", e.getErrorCode().getCode(), e.getMessage());
|
||||||
|
return ResponseEntity.badRequest().body(ApiResponse.fail(e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 요청 검증 실패 → 400 + 필드 요약(스택트레이스 없음). */
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException e) {
|
||||||
|
String msg = e.getBindingResult().getFieldErrors().stream()
|
||||||
|
.findFirst()
|
||||||
|
.map(fe -> fe.getDefaultMessage())
|
||||||
|
.orElse("요청 값이 올바르지 않습니다.");
|
||||||
|
return ResponseEntity.badRequest().body(ApiResponse.fail(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 권한 거부 → 403 (RBAC 가드). */
|
||||||
|
@ExceptionHandler(AccessDeniedException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleDenied(AccessDeniedException e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.fail("접근 권한이 없습니다."));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그 외 모든 예외 → 400 + 요약 메시지(기존 ESN 의 RuntimeException("ERR-AUTH-...") 흐름 보존).
|
||||||
|
* 스택트레이스/원인은 서버 로그에만 기록하고 응답에는 노출하지 않는다(보안 불변규칙).
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(RuntimeException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleRuntime(RuntimeException e, HttpServletRequest req) {
|
||||||
|
log.warn("[ERR] {} {} — {}", req.getMethod(), req.getRequestURI(), e.getMessage());
|
||||||
|
String msg = e.getMessage() != null ? e.getMessage() : "요청 처리 중 오류가 발생했습니다.";
|
||||||
|
return ResponseEntity.badRequest().body(ApiResponse.fail(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 그 외 체크 예외/오류 → 500 + 요약(상세 미노출). */
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleOther(Exception e, HttpServletRequest req) {
|
||||||
|
log.error("[ERR-500] {} {}", req.getMethod(), req.getRequestURI(), e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(ApiResponse.fail("서버 처리 중 오류가 발생했습니다."));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -51,7 +51,7 @@ public class OllamaClient {
|
|||||||
.uri(URI.create(baseUrl + "/api/chat"))
|
.uri(URI.create(baseUrl + "/api/chat"))
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(json))
|
.POST(HttpRequest.BodyPublishers.ofString(json))
|
||||||
.timeout(Duration.ofSeconds(30))
|
.timeout(Duration.ofSeconds(120))
|
||||||
.build();
|
.build();
|
||||||
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
|
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
|
||||||
if (resp.statusCode() == 200) {
|
if (resp.statusCode() == 200) {
|
||||||
|
|||||||
@ -0,0 +1,26 @@
|
|||||||
|
package com.zioinfo.esn.uiws.auth;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2FA 로그인 검증 이력 매퍼 (tb_uiws_login_verify). 발급/검증 감사 기록.
|
||||||
|
* verify_code 는 감사 이력에만 남고 API 응답에는 절대 노출하지 않는다.
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface LoginVerifyMapper {
|
||||||
|
|
||||||
|
int insert(UiwsLoginVerify v);
|
||||||
|
|
||||||
|
/** 동일 user 의 미검증 EMAIL 이력 중 최신 1건을 검증완료(Y) 처리. */
|
||||||
|
@Update("""
|
||||||
|
UPDATE tb_uiws_login_verify
|
||||||
|
SET verified_yn = 'Y', updated_by = #{userId}, updated_at = now()
|
||||||
|
WHERE verify_id = (
|
||||||
|
SELECT verify_id FROM tb_uiws_login_verify
|
||||||
|
WHERE user_id = #{userId} AND verify_method = 'EMAIL' AND verified_yn = 'N'
|
||||||
|
ORDER BY verify_id DESC LIMIT 1
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
int markLatestVerified(String userId);
|
||||||
|
}
|
||||||
@ -0,0 +1,161 @@
|
|||||||
|
package com.zioinfo.esn.uiws.auth;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.auth.EsnUser;
|
||||||
|
import com.zioinfo.esn.auth.JwtUtil;
|
||||||
|
import com.zioinfo.esn.auth.mapper.UserAuthMapper;
|
||||||
|
import com.zioinfo.esn.uiws.common.UiwsApiException;
|
||||||
|
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
|
||||||
|
import com.zioinfo.esn.uiws.common.mail.MailSender;
|
||||||
|
import com.zioinfo.esn.uiws.config.UiwsProperties;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UIWS 2FA(이메일 코드) 레이어. ESN 기존 단일 로그인을 보존하면서 2단계 인증을 추가한다.
|
||||||
|
*
|
||||||
|
* 흐름:
|
||||||
|
* 1) 1차 로그인 성공 → {@link #beginTwoFactor}: verify-token 발급 + 이메일 인증코드 발송(LogMailSender 폴백) + 감사 기록.
|
||||||
|
* 2) {@code POST /api/auth/verify}(verifyToken + code) → {@link #verify}: 코드 검증 후 access 발급.
|
||||||
|
* 3) 로그인 실패 누적 max-login-fail 회 → 계정 잠금({@link #recordLoginFailure}).
|
||||||
|
*
|
||||||
|
* 보안:
|
||||||
|
* - 인증코드는 메일/감사 채널로만 전달. API 응답·로그 메시지에 코드/비밀번호/자격증명 절대 미노출(불변규칙).
|
||||||
|
* - 외부 API 호출 없음(MailSender 폴백은 로그). Ollama 외 외부 통신 금지 준수.
|
||||||
|
* - access 토큰은 ESN JwtUtil(username/role/tenantCode 3-클레임) 정책을 그대로 재사용한다.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TwoFactorService {
|
||||||
|
|
||||||
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
private static final String SYSTEM = "SYSTEM";
|
||||||
|
|
||||||
|
private final UserAuthMapper userMapper;
|
||||||
|
private final LoginVerifyMapper loginVerifyMapper;
|
||||||
|
private final JwtUtil jwtUtil;
|
||||||
|
private final MailSender mailSender;
|
||||||
|
private final UiwsProperties properties;
|
||||||
|
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return properties.getAuth().isTwofaEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 잠금 여부(1차 로그인 전 차단용). */
|
||||||
|
public boolean isLocked(EsnUser user) {
|
||||||
|
return Boolean.TRUE.equals(user.getLocked());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1차 로그인 성공 후 2차 인증 시작: verify-token 발급 + 이메일코드 발송 + 감사 기록 + 실패카운트 초기화.
|
||||||
|
* @return { verifyToken, step:"EMAIL", maskedEmail }
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public Map<String, String> beginTwoFactor(EsnUser user) {
|
||||||
|
long codeValidity = properties.getAuth().getEmailCodeValiditySeconds();
|
||||||
|
long tokenValidity = properties.getAuth().getVerifyTokenValiditySeconds();
|
||||||
|
|
||||||
|
String code = String.format("%06d", RANDOM.nextInt(1_000_000));
|
||||||
|
LocalDateTime expire = LocalDateTime.now().plusSeconds(codeValidity);
|
||||||
|
|
||||||
|
// user 테이블에 코드/만료 저장(실패카운트 초기화) + 감사 이력 기록
|
||||||
|
userMapper.saveEmailCode(user.getUsername(), code, expire);
|
||||||
|
recordVerifyAttempt(user.getUsername(), code, expire);
|
||||||
|
|
||||||
|
// 이메일 발송(미설정 환경은 LogMailSender 폴백). 코드는 메일 본문에만.
|
||||||
|
String subject = "[GUARDiA ESN] 로그인 2차 인증 코드";
|
||||||
|
String body = String.format(
|
||||||
|
"안녕하세요 %s 님,\n로그인 2차 인증 코드는 [%s] 입니다.\n유효시간: %d초",
|
||||||
|
user.getUsername(), code, codeValidity);
|
||||||
|
if (user.getEmail() != null && !user.getEmail().isBlank()) {
|
||||||
|
mailSender.send(user.getEmail(), subject, body);
|
||||||
|
} else {
|
||||||
|
log.warn("[2FA] no email for user={} — code logged only", user.getUsername());
|
||||||
|
}
|
||||||
|
|
||||||
|
String verifyToken = jwtUtil.generateVerifyToken(user.getUsername(), tokenValidity);
|
||||||
|
// 응답에는 코드 미포함 — verifyToken/step/maskedEmail 만.
|
||||||
|
return Map.of(
|
||||||
|
"verifyToken", verifyToken,
|
||||||
|
"step", "EMAIL",
|
||||||
|
"maskedEmail", maskEmail(user.getEmail()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2차 검증: verify-token + code 검증 → access 발급. 코드 폐기 + 감사 이력 검증완료.
|
||||||
|
* @return { token, type, username, role, tenant }
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public Map<String, String> verify(String verifyToken, String code) {
|
||||||
|
String username = jwtUtil.parseVerifyTokenUsername(verifyToken);
|
||||||
|
if (username == null) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.VERIFY_TOKEN_INVALID);
|
||||||
|
}
|
||||||
|
EsnUser user = userMapper.findByUsername(username);
|
||||||
|
if (user == null) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.VERIFY_TOKEN_INVALID);
|
||||||
|
}
|
||||||
|
if (Boolean.TRUE.equals(user.getLocked())) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean codeOk = user.getEmailVerifyCode() != null
|
||||||
|
&& user.getEmailVerifyCode().equals(code)
|
||||||
|
&& user.getEmailVerifyExpire() != null
|
||||||
|
&& user.getEmailVerifyExpire().isAfter(LocalDateTime.now());
|
||||||
|
if (!codeOk) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.VERIFY_CODE_INVALID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 코드 폐기 + 감사 검증완료 + 마지막 로그인 갱신
|
||||||
|
userMapper.clearEmailCode(username);
|
||||||
|
loginVerifyMapper.markLatestVerified(username);
|
||||||
|
userMapper.updateLastLogin(username);
|
||||||
|
|
||||||
|
String access = jwtUtil.generate(user.getUsername(), user.getRole(), user.getTenantCode());
|
||||||
|
return Map.of(
|
||||||
|
"token", access,
|
||||||
|
"type", "Bearer",
|
||||||
|
"username", user.getUsername(),
|
||||||
|
"role", user.getRole() == null ? "" : user.getRole(),
|
||||||
|
"tenant", user.getTenantCode() == null ? "" : user.getTenantCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 로그인 비밀번호 실패 시 누적/잠금 처리. */
|
||||||
|
@Transactional
|
||||||
|
public void recordLoginFailure(String username) {
|
||||||
|
userMapper.incrementLoginFail(username, properties.getAuth().getMaxLoginFail());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void recordVerifyAttempt(String username, String code, LocalDateTime expire) {
|
||||||
|
UiwsLoginVerify v = new UiwsLoginVerify();
|
||||||
|
v.setUserId(username);
|
||||||
|
v.setVerifyMethod("EMAIL");
|
||||||
|
v.setVerifyCode(code);
|
||||||
|
v.setExpireAt(expire);
|
||||||
|
v.setVerifiedYn("N");
|
||||||
|
v.setCreatedBy(SYSTEM);
|
||||||
|
v.setCreatedAt(LocalDateTime.now());
|
||||||
|
loginVerifyMapper.insert(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이메일 마스킹(자격증명 보호): ab****@domain. */
|
||||||
|
private static String maskEmail(String email) {
|
||||||
|
if (email == null || email.isBlank() || !email.contains("@")) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
int at = email.indexOf('@');
|
||||||
|
String local = email.substring(0, at);
|
||||||
|
String domain = email.substring(at);
|
||||||
|
if (local.length() <= 2) {
|
||||||
|
return local.charAt(0) + "*" + domain;
|
||||||
|
}
|
||||||
|
return local.substring(0, 2) + "*".repeat(Math.max(1, local.length() - 2)) + domain;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package com.zioinfo.esn.uiws.auth;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 2FA 로그인 검증 이력 (tb_uiws_login_verify). 원본 com.urp.uiws.domain.LoginVerify 이식. */
|
||||||
|
@Data
|
||||||
|
public class UiwsLoginVerify {
|
||||||
|
private Long verifyId;
|
||||||
|
private String userId; // ERP username 논리참조
|
||||||
|
private String verifyMethod; // EMAIL | OTP
|
||||||
|
private String verifyCode;
|
||||||
|
private LocalDateTime expireAt;
|
||||||
|
private String verifiedYn; // Y | N
|
||||||
|
private String createdBy;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private String updatedBy;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package com.zioinfo.esn.uiws.common;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UIWS 이식 모듈 업무 예외. RuntimeException 을 상속하여 ERP 기존 GlobalExceptionHandler
|
||||||
|
* (RuntimeException → 400 + message, 스택트레이스 미노출)에 그대로 포착된다.
|
||||||
|
* 추가 인프라/빈 없이 ERP 공통 에러 처리와 정합한다.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
public class UiwsApiException extends RuntimeException {
|
||||||
|
|
||||||
|
private final UiwsErrorCode errorCode;
|
||||||
|
|
||||||
|
public UiwsApiException(UiwsErrorCode errorCode) {
|
||||||
|
super(errorCode.getMessage());
|
||||||
|
this.errorCode = errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UiwsApiException(UiwsErrorCode errorCode, String detail) {
|
||||||
|
super(detail);
|
||||||
|
this.errorCode = errorCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
package com.zioinfo.esn.uiws.common;
|
||||||
|
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 인증 사용자 식별자(=ERP username) 추출 헬퍼. 감사 컬럼(created_by/updated_by) 및
|
||||||
|
* 소유자(writer_id/owner_id/sender_id) 기록에 사용한다.
|
||||||
|
*
|
||||||
|
* ERP JwtFilter 는 인증 principal 로 username(String)을 세팅하므로 그 값을 그대로 사용한다.
|
||||||
|
* (UIWS 원본의 CurrentUser/UserPrincipal 패턴을 ERP 인증 모델에 맞게 단순화 이식.)
|
||||||
|
*/
|
||||||
|
public final class UiwsCurrentUser {
|
||||||
|
|
||||||
|
private static final String SYSTEM = "SYSTEM";
|
||||||
|
|
||||||
|
private UiwsCurrentUser() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 현재 사용자 ID(username). 미인증/시스템 컨텍스트는 "SYSTEM". */
|
||||||
|
public static String id() {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof String username
|
||||||
|
&& !"anonymousUser".equals(username)) {
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
return SYSTEM;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ADMIN 권한 보유 여부(데이터 가시범위 판정용 — ADMIN=전체). */
|
||||||
|
public static boolean isAdmin() {
|
||||||
|
return hasRole("ADMIN");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MANAGER 이상(MANAGER/ADMIN) 여부 — 업무일지 댓글 권한 등에 사용. */
|
||||||
|
public static boolean isManagerOrAbove() {
|
||||||
|
return hasRole("ADMIN") || hasRole("MANAGER") || hasRole("CFO");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasRole(String role) {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return auth.getAuthorities().stream()
|
||||||
|
.anyMatch(a -> ("ROLE_" + role).equals(a.getAuthority()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
package com.zioinfo.esn.uiws.common;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 가시범위(스코프) — UIWS DataScopeService 의 silo 단순화 이식.
|
||||||
|
* 원본은 부서계층(TB_DEPT) 기반 팀 스코프를 산출하나, ERP 이식본은 코어 dept 테이블을 이식하지 않으므로
|
||||||
|
* 역할만으로 판정한다:
|
||||||
|
* - ADMIN → 전체(all=true)
|
||||||
|
* - 그 외 → 본인 데이터만(ownerIds = [본인])
|
||||||
|
* (부서계층 스코프가 필요해지면 ERP tb_department 연계로 확장 — 후속 트랙.)
|
||||||
|
*/
|
||||||
|
public final class UiwsDataScope {
|
||||||
|
|
||||||
|
private UiwsDataScope() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** all=true 면 전체 조회(ownerIds 무시). ownerIds 는 항상 본인 포함(빈 IN 회피). */
|
||||||
|
public record Scope(boolean all, List<String> ownerIds) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Scope current() {
|
||||||
|
String me = UiwsCurrentUser.id();
|
||||||
|
if (UiwsCurrentUser.isAdmin()) {
|
||||||
|
return new Scope(true, List.of(me));
|
||||||
|
}
|
||||||
|
return new Scope(false, List.of(me));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
package com.zioinfo.esn.uiws.common;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UIWS 이식 모듈 도메인 오류 코드.
|
||||||
|
* 원본 com.urp.uiws.common.exception.ErrorCode 의 worklog/schedule/message/auth(2FA) 영역 발췌·이식.
|
||||||
|
* 메시지는 사용자 노출용 — 스택트레이스/SQL 등 민감정보는 절대 포함하지 않는다(보안 불변규칙).
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
public enum UiwsErrorCode {
|
||||||
|
|
||||||
|
// 공통
|
||||||
|
INVALID_REQUEST("ERR-UIWS-400", "요청이 올바르지 않습니다."),
|
||||||
|
FORBIDDEN("ERR-UIWS-403", "접근 권한이 없습니다."),
|
||||||
|
NOT_FOUND("ERR-UIWS-404", "대상을 찾을 수 없습니다."),
|
||||||
|
|
||||||
|
// worklog
|
||||||
|
WORKLOG_NOT_FOUND("ERR-UIWS-WL-404", "업무일지를 찾을 수 없습니다."),
|
||||||
|
WORKLOG_TIME_OVERLAP("ERR-UIWS-WL-409", "동일 일지 내 시간대가 중복됩니다."),
|
||||||
|
WORKLOG_TIME_INVALID("ERR-UIWS-WL-422", "근무 시작/종료 시간이 올바르지 않습니다."),
|
||||||
|
|
||||||
|
// schedule
|
||||||
|
SCHEDULE_NOT_FOUND("ERR-UIWS-SC-404", "일정을 찾을 수 없습니다."),
|
||||||
|
SCHEDULE_DT_INVALID("ERR-UIWS-SC-422", "일정 시작/종료 일시가 올바르지 않습니다."),
|
||||||
|
DIARY_NOT_FOUND("ERR-UIWS-DI-404", "일지를 찾을 수 없습니다."),
|
||||||
|
ATTACH_NOT_FOUND("ERR-UIWS-AT-404", "첨부파일을 찾을 수 없습니다."),
|
||||||
|
ATTACH_REF_TYPE_INVALID("ERR-UIWS-AT-422", "첨부 대상 유형은 SCHEDULE 또는 DIARY 여야 합니다."),
|
||||||
|
FILE_EMPTY("ERR-UIWS-FILE-400", "업로드할 파일이 비어 있습니다."),
|
||||||
|
FILE_STORAGE_ERROR("ERR-UIWS-FILE-500", "파일 저장 중 오류가 발생했습니다."),
|
||||||
|
|
||||||
|
// message
|
||||||
|
MESSAGE_NOT_FOUND("ERR-UIWS-MSG-404", "쪽지를 찾을 수 없습니다."),
|
||||||
|
MESSAGE_RCV_TYPE_INVALID("ERR-UIWS-MSG-422", "수신구분은 RECV(수신) 또는 REF(참조) 여야 합니다."),
|
||||||
|
|
||||||
|
// auth (2FA)
|
||||||
|
VERIFY_TOKEN_INVALID("ERR-UIWS-2FA-401", "2차 검증 토큰이 유효하지 않거나 만료되었습니다."),
|
||||||
|
VERIFY_CODE_INVALID("ERR-UIWS-2FA-401C", "인증 코드가 올바르지 않거나 만료되었습니다."),
|
||||||
|
ACCOUNT_LOCKED("ERR-UIWS-2FA-LOCK", "로그인 실패 횟수 초과로 계정이 잠겼습니다. 관리자에게 문의하세요.");
|
||||||
|
|
||||||
|
private final String code;
|
||||||
|
private final String message;
|
||||||
|
|
||||||
|
UiwsErrorCode(String code, String message) {
|
||||||
|
this.code = code;
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package com.zioinfo.esn.uiws.common.mail;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로컬/개발/미설정용 메일 발송 폴백. 실제 전송 없이 로그로만 기록한다(외부 호출 0).
|
||||||
|
* esn.uiws.mail.mode=log (기본) 일 때 활성. SMTP 운영 시 mode=smtp 로 별도 구현 빈 활성화.
|
||||||
|
*
|
||||||
|
* 보안: 본문에 인증코드/임시비밀번호가 포함되므로 로그 레벨은 운영에서 조정.
|
||||||
|
* (인증코드는 API 응답으로는 절대 반환하지 않는다 — 메일/로그 채널로만 전달.)
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "esn.uiws.mail.mode", havingValue = "log", matchIfMissing = true)
|
||||||
|
public class LogMailSender implements MailSender {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void send(String to, String subject, String body) {
|
||||||
|
log.info("[UIWS-MAIL:LOG] to={} subject={}\n{}", to, subject, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package com.zioinfo.esn.uiws.common.mail;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메일 발송 추상화(UIWS 이식). 로컬/미설정 환경은 LogMailSender(로그만), 운영은 SMTP 구현으로 교체.
|
||||||
|
* 외부 API 호출은 하지 않는다(보안 불변규칙 — Ollama 외 외부 호출 금지).
|
||||||
|
*/
|
||||||
|
public interface MailSender {
|
||||||
|
|
||||||
|
void send(String to, String subject, String body);
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
package com.zioinfo.esn.uiws.config;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UIWS 이식 모듈 설정. application.yml 의 esn.uiws.* 바인딩.
|
||||||
|
* 계획서 2FA 설정키(verify-token-validity / max-login-fail / email-code-validity) +
|
||||||
|
* 첨부 업로드 디렉터리. 모두 안전 기본값 보유(미설정이어도 동작).
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "esn.uiws")
|
||||||
|
public class UiwsProperties {
|
||||||
|
|
||||||
|
private final Auth auth = new Auth();
|
||||||
|
private final Upload upload = new Upload();
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class Auth {
|
||||||
|
/** 2FA 레이어 on/off. 기본 on(켜짐). off 시 기존 단일 JWT 로그인 흐름 유지(회귀 0). */
|
||||||
|
private boolean twofaEnabled = true;
|
||||||
|
/** 1차 통과 후 verify-token 유효시간(초). 계획서 300(5분). */
|
||||||
|
private long verifyTokenValiditySeconds = 300;
|
||||||
|
/** 이메일 인증코드 유효시간(초). 계획서 300(5분). */
|
||||||
|
private long emailCodeValiditySeconds = 300;
|
||||||
|
/** 로그인 실패 누적 N회 시 계정 잠금. 계획서 5. */
|
||||||
|
private int maxLoginFail = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public static class Upload {
|
||||||
|
/** 첨부파일 저장 루트. 기본 ./uploads/uiws. */
|
||||||
|
private String uploadDir = "./uploads/uiws";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
package com.zioinfo.esn.uiws.message.controller;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.common.ApiResponse;
|
||||||
|
import com.zioinfo.esn.uiws.message.dto.MessageDtos.*;
|
||||||
|
import com.zioinfo.esn.uiws.message.service.MessageService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UIWS 이식 — 쪽지(모듈 03). 인증 필수(/api/messages). 원본 prefix·엔드포인트 보존.
|
||||||
|
* Spring Data Pageable 의존 회피: page/size 단순 파라미터로 이식(ERP는 spring-data-web 미사용).
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/messages")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MessageController {
|
||||||
|
|
||||||
|
private final MessageService messageService;
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ApiResponse<MessageSendResponse> send(@Valid @RequestBody MessageSendDto dto) {
|
||||||
|
return ApiResponse.ok(messageService.send(dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/sent")
|
||||||
|
public ApiResponse<MessageService.SentPage> sent(
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
|
||||||
|
@RequestParam(required = false) String titleKeyword,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
return ApiResponse.ok(messageService.listSent(fromDate, toDate, titleKeyword, page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/sent/{id}")
|
||||||
|
public ApiResponse<SentMessageDetailDto> sentDetail(@PathVariable("id") Long id) {
|
||||||
|
return ApiResponse.ok(messageService.sentDetail(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/sent")
|
||||||
|
public ApiResponse<Void> deleteSent(@Valid @RequestBody MessageIdsRequest req) {
|
||||||
|
messageService.deleteSent(req.ids());
|
||||||
|
return ApiResponse.ok(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/unread-count")
|
||||||
|
public ApiResponse<Long> unreadCount() {
|
||||||
|
return ApiResponse.ok(messageService.unreadCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/received")
|
||||||
|
public ApiResponse<MessageService.ReceivedPage> received(
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
|
||||||
|
@RequestParam(required = false) String titleKeyword,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
return ApiResponse.ok(messageService.listReceived(fromDate, toDate, titleKeyword, page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/received/{id}")
|
||||||
|
public ApiResponse<ReceivedMessageDetailDto> receivedDetail(@PathVariable("id") Long id) {
|
||||||
|
return ApiResponse.ok(messageService.receivedDetail(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/received")
|
||||||
|
public ApiResponse<Void> deleteReceived(@Valid @RequestBody MessageIdsRequest req) {
|
||||||
|
messageService.deleteReceived(req.ids());
|
||||||
|
return ApiResponse.ok(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
package com.zioinfo.esn.uiws.message.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쪽지(모듈 03) 요청/응답 DTO 모음. 원본 com.urp.uiws.message.dto.* 이식(필드·shape 동일).
|
||||||
|
* 프론트 경계면 계약 유지: API 응답 필드명 그대로 보존.
|
||||||
|
*/
|
||||||
|
public final class MessageDtos {
|
||||||
|
|
||||||
|
private MessageDtos() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 전송/답장 요청. */
|
||||||
|
public record MessageSendDto(
|
||||||
|
@NotBlank String title,
|
||||||
|
@NotBlank String content,
|
||||||
|
Long refWorklogId,
|
||||||
|
Long replyToId,
|
||||||
|
@Valid @NotEmpty(message = "수신자는 1명 이상이어야 합니다.") List<MessageReceiverDto> receivers
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 수신자 1건. rcvType: RECV(수신) | REF(참조). */
|
||||||
|
public record MessageReceiverDto(
|
||||||
|
@NotBlank String receiverId,
|
||||||
|
@NotBlank String rcvType
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 전송 응답. */
|
||||||
|
public record MessageSendResponse(Long messageId) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 보낸쪽지함 행. */
|
||||||
|
public record SentMessageDto(
|
||||||
|
Long messageId,
|
||||||
|
String title,
|
||||||
|
String receiverSummary,
|
||||||
|
long openCount,
|
||||||
|
long totalCount,
|
||||||
|
String sentAt
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 보낸쪽지 상세(수신자 개봉현황). */
|
||||||
|
public record SentMessageDetailDto(
|
||||||
|
Long messageId,
|
||||||
|
String title,
|
||||||
|
String content,
|
||||||
|
Long refWorklogId,
|
||||||
|
String sentAt,
|
||||||
|
List<ReceiverStatusDto> receivers,
|
||||||
|
long totalCount,
|
||||||
|
long openCount,
|
||||||
|
long unopenCount
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 수신자별 개봉현황. */
|
||||||
|
public record ReceiverStatusDto(
|
||||||
|
String receiverNm,
|
||||||
|
String rcvType,
|
||||||
|
String readYn,
|
||||||
|
String readAt
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 받은쪽지함 행. */
|
||||||
|
public record ReceivedMessageDto(
|
||||||
|
Long messageId,
|
||||||
|
String title,
|
||||||
|
String senderNm,
|
||||||
|
String sentAt,
|
||||||
|
String readYn
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 받은쪽지 상세(조회 시 개봉처리). */
|
||||||
|
public record ReceivedMessageDetailDto(
|
||||||
|
Long messageId,
|
||||||
|
String title,
|
||||||
|
String content,
|
||||||
|
String senderNm,
|
||||||
|
String sentAt,
|
||||||
|
String receivedAt,
|
||||||
|
Long refWorklogId
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 다중삭제 본문. */
|
||||||
|
public record MessageIdsRequest(
|
||||||
|
@NotEmpty(message = "ids는 필수입니다.") List<Long> ids
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
package com.zioinfo.esn.uiws.message.mapper;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.uiws.message.model.UiwsMessage;
|
||||||
|
import com.zioinfo.esn.uiws.message.model.UiwsMessageRcv;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쪽지 MyBatis 매퍼. 원본 JPA MessageRepository/MessageRcvRepository → MyBatis 변환 이식.
|
||||||
|
* 사용자명은 esn_user(username 라벨) 조인으로 라벨화(코어 user 재사용, 별도 user 테이블 미이식).
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface MessageMapper {
|
||||||
|
|
||||||
|
// ── message 헤더
|
||||||
|
int insertMessage(UiwsMessage m);
|
||||||
|
|
||||||
|
UiwsMessage findMessageById(@Param("messageId") Long messageId);
|
||||||
|
|
||||||
|
boolean existsMessageById(@Param("messageId") Long messageId);
|
||||||
|
|
||||||
|
/** 보낸쪽지 페이징(SENDER_DEL_YN='N' 제외). */
|
||||||
|
List<UiwsMessage> searchSent(@Param("senderId") String senderId,
|
||||||
|
@Param("from") LocalDateTime from,
|
||||||
|
@Param("to") LocalDateTime to,
|
||||||
|
@Param("titleKeyword") String titleKeyword,
|
||||||
|
@Param("limit") int limit,
|
||||||
|
@Param("offset") int offset);
|
||||||
|
|
||||||
|
long countSent(@Param("senderId") String senderId,
|
||||||
|
@Param("from") LocalDateTime from,
|
||||||
|
@Param("to") LocalDateTime to,
|
||||||
|
@Param("titleKeyword") String titleKeyword);
|
||||||
|
|
||||||
|
/** 보낸쪽지 다중 소프트삭제(본인 것만). */
|
||||||
|
int softDeleteSent(@Param("senderId") String senderId,
|
||||||
|
@Param("ids") List<Long> ids,
|
||||||
|
@Param("actor") String actor);
|
||||||
|
|
||||||
|
// ── 수신자
|
||||||
|
int insertRcv(UiwsMessageRcv rcv);
|
||||||
|
|
||||||
|
List<UiwsMessageRcv> findRcvByMessageId(@Param("messageId") Long messageId);
|
||||||
|
|
||||||
|
List<UiwsMessageRcv> findRcvByMessageIds(@Param("ids") List<Long> ids);
|
||||||
|
|
||||||
|
UiwsMessageRcv findRcvByMessageAndReceiver(@Param("messageId") Long messageId,
|
||||||
|
@Param("receiverId") String receiverId);
|
||||||
|
|
||||||
|
long countUnread(@Param("receiverId") String receiverId);
|
||||||
|
|
||||||
|
/** 받은쪽지 개봉처리(READ_YN='Y', READ_AT). */
|
||||||
|
int markRead(@Param("rcvId") Long rcvId, @Param("actor") String actor, @Param("readAt") LocalDateTime readAt);
|
||||||
|
|
||||||
|
int softDeleteReceived(@Param("receiverId") String receiverId,
|
||||||
|
@Param("ids") List<Long> ids,
|
||||||
|
@Param("actor") String actor);
|
||||||
|
|
||||||
|
/** 받은쪽지 목록(조인: message + rcv). 행: messageId,title,senderId,sentAt,readYn. */
|
||||||
|
List<Map<String, Object>> searchReceived(@Param("receiverId") String receiverId,
|
||||||
|
@Param("from") LocalDateTime from,
|
||||||
|
@Param("to") LocalDateTime to,
|
||||||
|
@Param("titleKeyword") String titleKeyword,
|
||||||
|
@Param("limit") int limit,
|
||||||
|
@Param("offset") int offset);
|
||||||
|
|
||||||
|
long countReceived(@Param("receiverId") String receiverId,
|
||||||
|
@Param("from") LocalDateTime from,
|
||||||
|
@Param("to") LocalDateTime to,
|
||||||
|
@Param("titleKeyword") String titleKeyword);
|
||||||
|
|
||||||
|
// ── 사용자명 라벨(코어 user 재사용)
|
||||||
|
List<Map<String, String>> findUserNames(@Param("ids") List<String> ids);
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package com.zioinfo.esn.uiws.message.model;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쪽지 헤더 (tb_uiws_message). 원본 com.urp.uiws.domain.Message 이식.
|
||||||
|
* MyBatis map-underscore-to-camel-case 로 컬럼↔필드 자동 매핑.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class UiwsMessage {
|
||||||
|
private Long messageId;
|
||||||
|
private String senderId;
|
||||||
|
private String title;
|
||||||
|
private String content;
|
||||||
|
private Long refWorklogId;
|
||||||
|
private Long replyToId;
|
||||||
|
private LocalDateTime sentAt;
|
||||||
|
private String senderDelYn;
|
||||||
|
private String createdBy;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private String updatedBy;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package com.zioinfo.esn.uiws.message.model;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쪽지 수신자 (tb_uiws_message_rcv). 원본 com.urp.uiws.domain.MessageRcv 이식.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class UiwsMessageRcv {
|
||||||
|
private Long rcvId;
|
||||||
|
private Long messageId;
|
||||||
|
private String receiverId;
|
||||||
|
private String rcvType; // RECV | REF
|
||||||
|
private String readYn; // Y | N
|
||||||
|
private LocalDateTime readAt;
|
||||||
|
private String receiverDelYn; // Y | N
|
||||||
|
private String createdBy;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private String updatedBy;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,271 @@
|
|||||||
|
package com.zioinfo.esn.uiws.message.service;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.uiws.common.UiwsApiException;
|
||||||
|
import com.zioinfo.esn.uiws.common.UiwsCurrentUser;
|
||||||
|
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
|
||||||
|
import com.zioinfo.esn.uiws.message.dto.MessageDtos.*;
|
||||||
|
import com.zioinfo.esn.uiws.message.mapper.MessageMapper;
|
||||||
|
import com.zioinfo.esn.uiws.message.model.UiwsMessage;
|
||||||
|
import com.zioinfo.esn.uiws.message.model.UiwsMessageRcv;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.LocalTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쪽지(모듈 03) 서비스 — 원본 com.urp.uiws.message.service.MessageService 를 MyBatis 로 변환 이식.
|
||||||
|
* 로직(전송/답장, 보낸함, 개봉현황, 받은함, 개봉처리, 다중삭제, 미열람 배지)을 동등하게 보존한다.
|
||||||
|
* 기본 조회기간 = 최근 1주일.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MessageService {
|
||||||
|
|
||||||
|
private static final Set<String> VALID_RCV_TYPES = Set.of("RECV", "REF");
|
||||||
|
private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
|
||||||
|
|
||||||
|
private final MessageMapper mapper;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 전송/답장
|
||||||
|
@Transactional
|
||||||
|
public MessageSendResponse send(MessageSendDto dto) {
|
||||||
|
String actor = UiwsCurrentUser.id();
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
|
||||||
|
if (dto.replyToId() != null && !mapper.existsMessageById(dto.replyToId())) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.MESSAGE_NOT_FOUND, "답장 원본 쪽지를 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
UiwsMessage m = new UiwsMessage();
|
||||||
|
m.setSenderId(actor);
|
||||||
|
m.setTitle(dto.title());
|
||||||
|
m.setContent(dto.content());
|
||||||
|
m.setRefWorklogId(dto.refWorklogId());
|
||||||
|
m.setReplyToId(dto.replyToId());
|
||||||
|
m.setSentAt(now);
|
||||||
|
m.setSenderDelYn("N");
|
||||||
|
m.setCreatedBy(actor);
|
||||||
|
m.setCreatedAt(now);
|
||||||
|
mapper.insertMessage(m); // useGeneratedKeys → m.messageId
|
||||||
|
|
||||||
|
// 수신자: 동일 수신자 중복 제거(UNIQUE(message_id,receiver_id) 보호). 첫 등장 rcvType 채택.
|
||||||
|
Set<String> seen = new LinkedHashSet<>();
|
||||||
|
for (MessageReceiverDto r : dto.receivers()) {
|
||||||
|
String receiverId = (r.receiverId() == null) ? null : r.receiverId().trim();
|
||||||
|
String rcvType = (r.rcvType() == null) ? null : r.rcvType().trim().toUpperCase();
|
||||||
|
if (receiverId == null || receiverId.isBlank()) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "수신자 ID가 비어 있습니다.");
|
||||||
|
}
|
||||||
|
if (!VALID_RCV_TYPES.contains(rcvType)) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.MESSAGE_RCV_TYPE_INVALID);
|
||||||
|
}
|
||||||
|
if (!seen.add(receiverId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
UiwsMessageRcv rcv = new UiwsMessageRcv();
|
||||||
|
rcv.setMessageId(m.getMessageId());
|
||||||
|
rcv.setReceiverId(receiverId);
|
||||||
|
rcv.setRcvType(rcvType);
|
||||||
|
rcv.setReadYn("N");
|
||||||
|
rcv.setReceiverDelYn("N");
|
||||||
|
rcv.setCreatedBy(actor);
|
||||||
|
rcv.setCreatedAt(now);
|
||||||
|
mapper.insertRcv(rcv);
|
||||||
|
}
|
||||||
|
return new MessageSendResponse(m.getMessageId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 보낸쪽지함
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public SentPage listSent(LocalDate fromDate, LocalDate toDate, String titleKeyword, int page, int size) {
|
||||||
|
LocalDateTime[] range = range(fromDate, toDate);
|
||||||
|
String me = UiwsCurrentUser.id();
|
||||||
|
String kw = blankToNull(titleKeyword);
|
||||||
|
long total = mapper.countSent(me, range[0], range[1], kw);
|
||||||
|
List<UiwsMessage> rows = mapper.searchSent(me, range[0], range[1], kw, size, page * size);
|
||||||
|
|
||||||
|
List<Long> ids = rows.stream().map(UiwsMessage::getMessageId).toList();
|
||||||
|
List<UiwsMessageRcv> rcvs = ids.isEmpty() ? List.of() : mapper.findRcvByMessageIds(ids);
|
||||||
|
Map<Long, List<UiwsMessageRcv>> byMsg = rcvs.stream().collect(Collectors.groupingBy(UiwsMessageRcv::getMessageId));
|
||||||
|
Map<String, String> names = userNames(rcvs.stream().map(UiwsMessageRcv::getReceiverId).toList());
|
||||||
|
|
||||||
|
List<SentMessageDto> content = rows.stream().map(m -> {
|
||||||
|
List<UiwsMessageRcv> list = byMsg.getOrDefault(m.getMessageId(), List.of());
|
||||||
|
long openCount = list.stream().filter(r -> "Y".equals(r.getReadYn())).count();
|
||||||
|
return new SentMessageDto(m.getMessageId(), m.getTitle(),
|
||||||
|
receiverSummary(list, names), openCount, list.size(), fmt(m.getSentAt()));
|
||||||
|
}).toList();
|
||||||
|
return new SentPage(content, total, page, size, totalPages(total, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 보낸쪽지 상세
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public SentMessageDetailDto sentDetail(Long messageId) {
|
||||||
|
String me = UiwsCurrentUser.id();
|
||||||
|
UiwsMessage m = mapper.findMessageById(messageId);
|
||||||
|
if (m == null) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.MESSAGE_NOT_FOUND);
|
||||||
|
}
|
||||||
|
if (!me.equals(m.getSenderId())) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.FORBIDDEN, "본인이 보낸 쪽지만 조회할 수 있습니다.");
|
||||||
|
}
|
||||||
|
List<UiwsMessageRcv> list = mapper.findRcvByMessageId(messageId);
|
||||||
|
Map<String, String> names = userNames(list.stream().map(UiwsMessageRcv::getReceiverId).toList());
|
||||||
|
|
||||||
|
List<ReceiverStatusDto> receivers = list.stream()
|
||||||
|
.map(r -> new ReceiverStatusDto(
|
||||||
|
names.getOrDefault(r.getReceiverId(), r.getReceiverId()),
|
||||||
|
r.getRcvType(), r.getReadYn(), fmt(r.getReadAt())))
|
||||||
|
.toList();
|
||||||
|
long total = receivers.size();
|
||||||
|
long open = list.stream().filter(r -> "Y".equals(r.getReadYn())).count();
|
||||||
|
return new SentMessageDetailDto(m.getMessageId(), m.getTitle(), m.getContent(), m.getRefWorklogId(),
|
||||||
|
fmt(m.getSentAt()), receivers, total, open, total - open);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 보낸쪽지 다중삭제
|
||||||
|
@Transactional
|
||||||
|
public void deleteSent(List<Long> ids) {
|
||||||
|
if (ids == null || ids.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String me = UiwsCurrentUser.id();
|
||||||
|
mapper.softDeleteSent(me, ids, me);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 미열람 수(배지)
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public long unreadCount() {
|
||||||
|
return mapper.countUnread(UiwsCurrentUser.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 받은쪽지함
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public ReceivedPage listReceived(LocalDate fromDate, LocalDate toDate, String titleKeyword, int page, int size) {
|
||||||
|
LocalDateTime[] range = range(fromDate, toDate);
|
||||||
|
String me = UiwsCurrentUser.id();
|
||||||
|
String kw = blankToNull(titleKeyword);
|
||||||
|
long total = mapper.countReceived(me, range[0], range[1], kw);
|
||||||
|
List<Map<String, Object>> rows = mapper.searchReceived(me, range[0], range[1], kw, size, page * size);
|
||||||
|
Map<String, String> names = userNames(rows.stream().map(r -> str(r.get("senderId"))).toList());
|
||||||
|
|
||||||
|
List<ReceivedMessageDto> content = rows.stream()
|
||||||
|
.map(r -> new ReceivedMessageDto(
|
||||||
|
toLongObj(r.get("messageId")),
|
||||||
|
str(r.get("title")),
|
||||||
|
names.getOrDefault(str(r.get("senderId")), str(r.get("senderId"))),
|
||||||
|
fmt(toDt(r.get("sentAt"))),
|
||||||
|
str(r.get("readYn"))))
|
||||||
|
.toList();
|
||||||
|
return new ReceivedPage(content, total, page, size, totalPages(total, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 받은쪽지 상세(개봉처리)
|
||||||
|
@Transactional
|
||||||
|
public ReceivedMessageDetailDto receivedDetail(Long messageId) {
|
||||||
|
String me = UiwsCurrentUser.id();
|
||||||
|
UiwsMessageRcv rcv = mapper.findRcvByMessageAndReceiver(messageId, me);
|
||||||
|
if (rcv == null || "Y".equals(rcv.getReceiverDelYn())) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.MESSAGE_NOT_FOUND, "받은 쪽지를 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
UiwsMessage m = mapper.findMessageById(messageId);
|
||||||
|
if (m == null) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.MESSAGE_NOT_FOUND);
|
||||||
|
}
|
||||||
|
LocalDateTime readAt = rcv.getReadAt();
|
||||||
|
if (!"Y".equals(rcv.getReadYn())) {
|
||||||
|
readAt = LocalDateTime.now();
|
||||||
|
mapper.markRead(rcv.getRcvId(), me, readAt);
|
||||||
|
}
|
||||||
|
String senderNm = userNames(List.of(m.getSenderId())).getOrDefault(m.getSenderId(), m.getSenderId());
|
||||||
|
return new ReceivedMessageDetailDto(m.getMessageId(), m.getTitle(), m.getContent(),
|
||||||
|
senderNm, fmt(m.getSentAt()), fmt(readAt), m.getRefWorklogId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 받은쪽지 다중삭제
|
||||||
|
@Transactional
|
||||||
|
public void deleteReceived(List<Long> ids) {
|
||||||
|
if (ids == null || ids.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String me = UiwsCurrentUser.id();
|
||||||
|
mapper.softDeleteReceived(me, ids, me);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================== helpers
|
||||||
|
|
||||||
|
private LocalDateTime[] range(LocalDate fromDate, LocalDate toDate) {
|
||||||
|
LocalDate to = (toDate != null) ? toDate : LocalDate.now();
|
||||||
|
LocalDate from = (fromDate != null) ? fromDate : to.minusWeeks(1);
|
||||||
|
return new LocalDateTime[]{from.atStartOfDay(), to.atTime(LocalTime.MAX)};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String receiverSummary(List<UiwsMessageRcv> list, Map<String, String> names) {
|
||||||
|
if (list.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String first = names.getOrDefault(list.get(0).getReceiverId(), list.get(0).getReceiverId());
|
||||||
|
return list.size() == 1 ? first : first + " 외 " + (list.size() - 1) + "명";
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> userNames(List<String> userIds) {
|
||||||
|
List<String> ids = userIds.stream().filter(s -> s != null && !s.isBlank()).distinct().toList();
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
return mapper.findUserNames(ids).stream()
|
||||||
|
.collect(Collectors.toMap(r -> r.get("username"), r -> r.get("fullName"), (a, b) -> a));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int totalPages(long total, int size) {
|
||||||
|
return size <= 0 ? 0 : (int) ((total + size - 1) / size);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String blankToNull(String s) {
|
||||||
|
return (s == null || s.isBlank()) ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String str(Object o) {
|
||||||
|
return o == null ? "" : o.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Long toLongObj(Object o) {
|
||||||
|
if (o == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (o instanceof Number n) ? n.longValue() : Long.parseLong(o.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalDateTime toDt(Object o) {
|
||||||
|
if (o == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (o instanceof LocalDateTime dt) {
|
||||||
|
return dt;
|
||||||
|
}
|
||||||
|
if (o instanceof java.sql.Timestamp ts) {
|
||||||
|
return ts.toLocalDateTime();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String fmt(LocalDateTime dt) {
|
||||||
|
return dt != null ? dt.format(DT) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 페이지 응답(ERP ApiResponse 안에 그대로 직렬화 — content/totalElements/page/size/totalPages 보존)
|
||||||
|
public record SentPage(List<SentMessageDto> content, long totalElements, int page, int size, int totalPages) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ReceivedPage(List<ReceivedMessageDto> content, long totalElements, int page, int size, int totalPages) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
package com.zioinfo.esn.uiws.schedule.controller;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.common.ApiResponse;
|
||||||
|
import com.zioinfo.esn.uiws.schedule.dto.ScheduleDtos.AttachmentDto;
|
||||||
|
import com.zioinfo.esn.uiws.schedule.service.AttachmentService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.core.io.FileSystemResource;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.http.ContentDisposition;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UIWS 이식 — 첨부파일(모듈 02, 폴리모픽 SCHEDULE/DIARY). 인증 필수(/api/attachments).
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/attachments")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AttachmentController {
|
||||||
|
|
||||||
|
private final AttachmentService attachmentService;
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ApiResponse<AttachmentDto> upload(
|
||||||
|
@RequestParam("refType") String refType,
|
||||||
|
@RequestParam("refId") Long refId,
|
||||||
|
@RequestParam("file") MultipartFile file) {
|
||||||
|
return ApiResponse.ok(attachmentService.upload(refType, refId, file));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/download")
|
||||||
|
public ResponseEntity<Resource> download(@PathVariable("id") Long id) {
|
||||||
|
AttachmentService.DownloadFile f = attachmentService.download(id);
|
||||||
|
Resource resource = new FileSystemResource(f.path());
|
||||||
|
String contentType;
|
||||||
|
try {
|
||||||
|
contentType = Files.probeContentType(f.path());
|
||||||
|
} catch (IOException e) {
|
||||||
|
contentType = null;
|
||||||
|
}
|
||||||
|
ContentDisposition cd = ContentDisposition.attachment()
|
||||||
|
.filename(f.fileNm(), StandardCharsets.UTF_8)
|
||||||
|
.build();
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentDisposition(cd);
|
||||||
|
long len = f.path().toFile().length();
|
||||||
|
if (len > 0) {
|
||||||
|
headers.setContentLength(len);
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.headers(headers)
|
||||||
|
.contentType(contentType != null ? MediaType.parseMediaType(contentType) : MediaType.APPLICATION_OCTET_STREAM)
|
||||||
|
.body(resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ApiResponse<Void> delete(@PathVariable("id") Long id) {
|
||||||
|
attachmentService.delete(id);
|
||||||
|
return ApiResponse.ok(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
package com.zioinfo.esn.uiws.schedule.controller;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.common.ApiResponse;
|
||||||
|
import com.zioinfo.esn.uiws.schedule.dto.ScheduleDtos.*;
|
||||||
|
import com.zioinfo.esn.uiws.schedule.service.DiaryService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UIWS 이식 — 일지(모듈 02). 인증 필수(/api/diaries).
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/diaries")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DiaryController {
|
||||||
|
|
||||||
|
private final DiaryService diaryService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ApiResponse<DiaryPage> list(
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
return ApiResponse.ok(diaryService.list(fromDate, toDate, page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ApiResponse<DiaryDetailDto> create(@Valid @RequestBody DiarySaveDto dto) {
|
||||||
|
return ApiResponse.ok(diaryService.create(dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ApiResponse<DiaryDetailDto> detail(@PathVariable("id") Long id) {
|
||||||
|
return ApiResponse.ok(diaryService.detail(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ApiResponse<DiaryDetailDto> update(@PathVariable("id") Long id, @Valid @RequestBody DiarySaveDto dto) {
|
||||||
|
return ApiResponse.ok(diaryService.update(id, dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ApiResponse<Void> delete(@PathVariable("id") Long id) {
|
||||||
|
diaryService.delete(id);
|
||||||
|
return ApiResponse.ok(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
package com.zioinfo.esn.uiws.schedule.controller;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.common.ApiResponse;
|
||||||
|
import com.zioinfo.esn.uiws.schedule.dto.ScheduleDtos.*;
|
||||||
|
import com.zioinfo.esn.uiws.schedule.service.ScheduleService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UIWS 이식 — 일정(모듈 02). 인증 필수(/api/schedules). 원본 엔드포인트 보존.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/schedules")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ScheduleController {
|
||||||
|
|
||||||
|
private final ScheduleService scheduleService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ApiResponse<List<ScheduleDto>> calendar(
|
||||||
|
@RequestParam(defaultValue = "PERSONAL") String type,
|
||||||
|
@RequestParam(defaultValue = "month") String view,
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate baseDate,
|
||||||
|
@RequestParam(required = false) String deptId) {
|
||||||
|
return ApiResponse.ok(scheduleService.calendar(type, view, baseDate, deptId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/all")
|
||||||
|
public ApiResponse<SchedulePage> all(
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
|
||||||
|
@RequestParam(required = false) String type,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
return ApiResponse.ok(scheduleService.all(fromDate, toDate, type, page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/search")
|
||||||
|
public ApiResponse<List<ScheduleDto>> search(@RequestParam(required = false) String keyword) {
|
||||||
|
return ApiResponse.ok(scheduleService.search(keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ApiResponse<ScheduleDto> create(@Valid @RequestBody ScheduleSaveDto dto) {
|
||||||
|
return ApiResponse.ok(scheduleService.create(dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ApiResponse<ScheduleDetailDto> detail(@PathVariable("id") Long id) {
|
||||||
|
return ApiResponse.ok(scheduleService.detail(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ApiResponse<ScheduleDetailDto> update(@PathVariable("id") Long id, @Valid @RequestBody ScheduleSaveDto dto) {
|
||||||
|
return ApiResponse.ok(scheduleService.update(id, dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ApiResponse<Void> delete(@PathVariable("id") Long id) {
|
||||||
|
scheduleService.delete(id);
|
||||||
|
return ApiResponse.ok(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,109 @@
|
|||||||
|
package com.zioinfo.esn.uiws.schedule.dto;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.uiws.schedule.model.UiwsAttach;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일정·일지·첨부(모듈 02) DTO. 원본 com.urp.uiws.schedule.dto.* 이식(shape 동일).
|
||||||
|
*/
|
||||||
|
public final class ScheduleDtos {
|
||||||
|
|
||||||
|
private ScheduleDtos() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ScheduleDto(
|
||||||
|
Long scheduleId,
|
||||||
|
String scheType,
|
||||||
|
String title,
|
||||||
|
String scheGubunCd,
|
||||||
|
String importanceCd,
|
||||||
|
String startDt,
|
||||||
|
String endDt,
|
||||||
|
String ownerId,
|
||||||
|
String deptId
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ScheduleDetailDto(
|
||||||
|
Long scheduleId,
|
||||||
|
String scheType,
|
||||||
|
String title,
|
||||||
|
String scheGubunCd,
|
||||||
|
String importanceCd,
|
||||||
|
String startDt,
|
||||||
|
String endDt,
|
||||||
|
String ownerId,
|
||||||
|
String deptId,
|
||||||
|
String content,
|
||||||
|
String chargerId,
|
||||||
|
String chargerNm,
|
||||||
|
List<AttachmentDto> attachments
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ScheduleSaveDto(
|
||||||
|
@NotBlank @Pattern(regexp = "PERSONAL|DEPT") String scheType,
|
||||||
|
@NotBlank String title,
|
||||||
|
String scheGubunCd,
|
||||||
|
String importanceCd,
|
||||||
|
@NotNull String startDt,
|
||||||
|
@NotNull String endDt,
|
||||||
|
String content,
|
||||||
|
String deptId,
|
||||||
|
String chargerId,
|
||||||
|
List<Long> attachmentIds
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record DiaryDto(
|
||||||
|
Long diaryId,
|
||||||
|
String title,
|
||||||
|
String writerNm,
|
||||||
|
String diaryDate,
|
||||||
|
Long scheduleId
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record DiaryDetailDto(
|
||||||
|
Long diaryId,
|
||||||
|
String title,
|
||||||
|
String writerNm,
|
||||||
|
String diaryDate,
|
||||||
|
Long scheduleId,
|
||||||
|
String content,
|
||||||
|
List<AttachmentDto> attachments
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record DiarySaveDto(
|
||||||
|
@NotBlank String title,
|
||||||
|
String content,
|
||||||
|
Long scheduleId,
|
||||||
|
String diaryDate,
|
||||||
|
List<Long> attachmentIds
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AttachmentDto(
|
||||||
|
Long attachId,
|
||||||
|
String refType,
|
||||||
|
Long refId,
|
||||||
|
String fileNm,
|
||||||
|
Long fileSize
|
||||||
|
) {
|
||||||
|
public static AttachmentDto from(UiwsAttach a) {
|
||||||
|
return new AttachmentDto(a.getAttachId(), a.getRefType(), a.getRefId(), a.getFileNm(), a.getFileSize());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 페이지 래퍼(content/totalElements/page/size/totalPages). */
|
||||||
|
public record DiaryPage(List<DiaryDto> content, long totalElements, int page, int size, int totalPages) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SchedulePage(List<ScheduleDto> content, long totalElements, int page, int size, int totalPages) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
package com.zioinfo.esn.uiws.schedule.mapper;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.uiws.schedule.model.UiwsAttach;
|
||||||
|
import com.zioinfo.esn.uiws.schedule.model.UiwsDiary;
|
||||||
|
import com.zioinfo.esn.uiws.schedule.model.UiwsSchedule;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일정·일지·첨부 MyBatis 매퍼. 원본 JPA ScheduleRepository/DiaryRepository/AttachRepository 변환 이식.
|
||||||
|
* 데이터 가시범위: scopeAll(true=ADMIN 전체) OR owner IN (ownerIds). ownerIds 는 항상 본인 포함(빈 IN 회피).
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface ScheduleMapper {
|
||||||
|
|
||||||
|
// ── schedule
|
||||||
|
int insertSchedule(UiwsSchedule s);
|
||||||
|
|
||||||
|
int updateSchedule(UiwsSchedule s);
|
||||||
|
|
||||||
|
UiwsSchedule findScheduleById(@Param("scheduleId") Long scheduleId);
|
||||||
|
|
||||||
|
boolean existsScheduleById(@Param("scheduleId") Long scheduleId);
|
||||||
|
|
||||||
|
int deleteScheduleById(@Param("scheduleId") Long scheduleId);
|
||||||
|
|
||||||
|
/** 달력: 기간 겹침([from,to) 배타 상한) + type + 개인소유/부서필터. */
|
||||||
|
List<UiwsSchedule> findInRange(@Param("from") LocalDateTime from,
|
||||||
|
@Param("to") LocalDateTime to,
|
||||||
|
@Param("scheType") String scheType,
|
||||||
|
@Param("ownerId") String ownerId,
|
||||||
|
@Param("deptId") String deptId);
|
||||||
|
|
||||||
|
List<UiwsSchedule> searchAll(@Param("from") LocalDateTime from,
|
||||||
|
@Param("to") LocalDateTime to,
|
||||||
|
@Param("scheType") String scheType,
|
||||||
|
@Param("scopeAll") boolean scopeAll,
|
||||||
|
@Param("ownerIds") List<String> ownerIds,
|
||||||
|
@Param("limit") int limit,
|
||||||
|
@Param("offset") int offset);
|
||||||
|
|
||||||
|
long countAll(@Param("from") LocalDateTime from,
|
||||||
|
@Param("to") LocalDateTime to,
|
||||||
|
@Param("scheType") String scheType,
|
||||||
|
@Param("scopeAll") boolean scopeAll,
|
||||||
|
@Param("ownerIds") List<String> ownerIds);
|
||||||
|
|
||||||
|
List<UiwsSchedule> searchPopup(@Param("keyword") String keyword,
|
||||||
|
@Param("scopeAll") boolean scopeAll,
|
||||||
|
@Param("ownerIds") List<String> ownerIds);
|
||||||
|
|
||||||
|
// ── diary
|
||||||
|
int insertDiary(UiwsDiary d);
|
||||||
|
|
||||||
|
int updateDiary(UiwsDiary d);
|
||||||
|
|
||||||
|
UiwsDiary findDiaryById(@Param("diaryId") Long diaryId);
|
||||||
|
|
||||||
|
boolean existsDiaryById(@Param("diaryId") Long diaryId);
|
||||||
|
|
||||||
|
int deleteDiaryById(@Param("diaryId") Long diaryId);
|
||||||
|
|
||||||
|
List<UiwsDiary> searchDiary(@Param("from") LocalDate from,
|
||||||
|
@Param("to") LocalDate to,
|
||||||
|
@Param("limit") int limit,
|
||||||
|
@Param("offset") int offset);
|
||||||
|
|
||||||
|
long countDiary(@Param("from") LocalDate from, @Param("to") LocalDate to);
|
||||||
|
|
||||||
|
// ── attach
|
||||||
|
int insertAttach(UiwsAttach a);
|
||||||
|
|
||||||
|
UiwsAttach findAttachById(@Param("attachId") Long attachId);
|
||||||
|
|
||||||
|
int updateAttachRef(@Param("attachId") Long attachId,
|
||||||
|
@Param("refType") String refType,
|
||||||
|
@Param("refId") Long refId,
|
||||||
|
@Param("actor") String actor);
|
||||||
|
|
||||||
|
List<UiwsAttach> findAttachByRef(@Param("refType") String refType, @Param("refId") Long refId);
|
||||||
|
|
||||||
|
int deleteAttachById(@Param("attachId") Long attachId);
|
||||||
|
|
||||||
|
int deleteAttachByRef(@Param("refType") String refType, @Param("refId") Long refId);
|
||||||
|
|
||||||
|
// ── 사용자명 라벨(코어 user 재사용)
|
||||||
|
List<Map<String, String>> findUserNames(@Param("ids") List<String> ids);
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package com.zioinfo.esn.uiws.schedule.model;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 첨부파일 폴리모픽 (tb_uiws_attach, ref_type=SCHEDULE|DIARY). 원본 com.urp.uiws.domain.Attach 이식. */
|
||||||
|
@Data
|
||||||
|
public class UiwsAttach {
|
||||||
|
private Long attachId;
|
||||||
|
private String refType;
|
||||||
|
private Long refId;
|
||||||
|
private String fileNm;
|
||||||
|
private String filePath;
|
||||||
|
private Long fileSize;
|
||||||
|
private String createdBy;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private String updatedBy;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
package com.zioinfo.esn.uiws.schedule.model;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 일지 (tb_uiws_diary). 원본 com.urp.uiws.domain.Diary 이식. */
|
||||||
|
@Data
|
||||||
|
public class UiwsDiary {
|
||||||
|
private Long diaryId;
|
||||||
|
private String title;
|
||||||
|
private String content;
|
||||||
|
private Long scheduleId;
|
||||||
|
private String writerId;
|
||||||
|
private LocalDate diaryDate;
|
||||||
|
private String createdBy;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private String updatedBy;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package com.zioinfo.esn.uiws.schedule.model;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 일정 (tb_uiws_schedule). 원본 com.urp.uiws.domain.Schedule 이식. */
|
||||||
|
@Data
|
||||||
|
public class UiwsSchedule {
|
||||||
|
private Long scheduleId;
|
||||||
|
private String scheType; // PERSONAL | DEPT
|
||||||
|
private String title;
|
||||||
|
private String scheGubunCd;
|
||||||
|
private String importanceCd;
|
||||||
|
private LocalDateTime startDt;
|
||||||
|
private LocalDateTime endDt;
|
||||||
|
private String content;
|
||||||
|
private String ownerId;
|
||||||
|
private String deptId;
|
||||||
|
private String chargerId;
|
||||||
|
private String createdBy;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private String updatedBy;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,121 @@
|
|||||||
|
package com.zioinfo.esn.uiws.schedule.service;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.uiws.common.UiwsApiException;
|
||||||
|
import com.zioinfo.esn.uiws.common.UiwsCurrentUser;
|
||||||
|
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
|
||||||
|
import com.zioinfo.esn.uiws.schedule.dto.ScheduleDtos.AttachmentDto;
|
||||||
|
import com.zioinfo.esn.uiws.schedule.mapper.ScheduleMapper;
|
||||||
|
import com.zioinfo.esn.uiws.schedule.model.UiwsAttach;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 첨부파일(폴리모픽 SCHEDULE/DIARY) 업로드·삭제·연결. 원본 com.urp.uiws.schedule.service.AttachmentService 이식.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AttachmentService {
|
||||||
|
|
||||||
|
public static final String REF_SCHEDULE = "SCHEDULE";
|
||||||
|
public static final String REF_DIARY = "DIARY";
|
||||||
|
private static final Set<String> VALID_REF_TYPES = Set.of(REF_SCHEDULE, REF_DIARY);
|
||||||
|
|
||||||
|
private final ScheduleMapper mapper;
|
||||||
|
private final FileStorageService fileStorageService;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public AttachmentDto upload(String refType, Long refId, MultipartFile file) {
|
||||||
|
String type = normalizeRefType(refType);
|
||||||
|
if (refId == null) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "refId 는 필수입니다.");
|
||||||
|
}
|
||||||
|
FileStorageService.StoredFile stored = fileStorageService.store(file);
|
||||||
|
|
||||||
|
String actor = UiwsCurrentUser.id();
|
||||||
|
UiwsAttach a = new UiwsAttach();
|
||||||
|
a.setRefType(type);
|
||||||
|
a.setRefId(refId);
|
||||||
|
a.setFileNm(stored.originalName());
|
||||||
|
a.setFilePath(stored.relativePath());
|
||||||
|
a.setFileSize(stored.size());
|
||||||
|
a.setCreatedBy(actor);
|
||||||
|
a.setCreatedAt(LocalDateTime.now());
|
||||||
|
mapper.insertAttach(a);
|
||||||
|
return AttachmentDto.from(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record DownloadFile(String fileNm, Path path) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public DownloadFile download(Long attachId) {
|
||||||
|
UiwsAttach a = mapper.findAttachById(attachId);
|
||||||
|
if (a == null) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.ATTACH_NOT_FOUND);
|
||||||
|
}
|
||||||
|
Path path = fileStorageService.resolve(a.getFilePath());
|
||||||
|
if (!Files.exists(path)) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.ATTACH_NOT_FOUND, "파일이 존재하지 않습니다.");
|
||||||
|
}
|
||||||
|
return new DownloadFile(a.getFileNm(), path);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void delete(Long attachId) {
|
||||||
|
UiwsAttach a = mapper.findAttachById(attachId);
|
||||||
|
if (a == null) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.ATTACH_NOT_FOUND);
|
||||||
|
}
|
||||||
|
fileStorageService.delete(a.getFilePath());
|
||||||
|
mapper.deleteAttachById(attachId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 일정/일지 저장 시 attachmentIds 의 첨부들을 해당 대상으로 귀속(확정). */
|
||||||
|
@Transactional
|
||||||
|
public void link(String refType, Long refId, List<Long> attachmentIds) {
|
||||||
|
String type = normalizeRefType(refType);
|
||||||
|
if (attachmentIds == null || attachmentIds.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String actor = UiwsCurrentUser.id();
|
||||||
|
for (Long id : attachmentIds) {
|
||||||
|
if (id == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
mapper.updateAttachRef(id, type, refId, actor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<AttachmentDto> list(String refType, Long refId) {
|
||||||
|
return mapper.findAttachByRef(normalizeRefType(refType), refId)
|
||||||
|
.stream().map(AttachmentDto::from).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 대상 삭제 시 귀속 첨부 일괄 제거(물리파일 포함). */
|
||||||
|
@Transactional
|
||||||
|
public void deleteByRef(String refType, Long refId) {
|
||||||
|
String type = normalizeRefType(refType);
|
||||||
|
List<UiwsAttach> rows = mapper.findAttachByRef(type, refId);
|
||||||
|
for (UiwsAttach a : rows) {
|
||||||
|
fileStorageService.delete(a.getFilePath());
|
||||||
|
}
|
||||||
|
mapper.deleteAttachByRef(type, refId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeRefType(String refType) {
|
||||||
|
String type = refType == null ? "" : refType.trim().toUpperCase();
|
||||||
|
if (!VALID_REF_TYPES.contains(type)) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.ATTACH_REF_TYPE_INVALID);
|
||||||
|
}
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,133 @@
|
|||||||
|
package com.zioinfo.esn.uiws.schedule.service;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.uiws.common.UiwsApiException;
|
||||||
|
import com.zioinfo.esn.uiws.common.UiwsCurrentUser;
|
||||||
|
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
|
||||||
|
import com.zioinfo.esn.uiws.schedule.dto.ScheduleDtos.*;
|
||||||
|
import com.zioinfo.esn.uiws.schedule.mapper.ScheduleMapper;
|
||||||
|
import com.zioinfo.esn.uiws.schedule.model.UiwsDiary;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일지(모듈 02) 서비스 — 원본 com.urp.uiws.schedule.service.DiaryService MyBatis 변환 이식.
|
||||||
|
* 목록(페이징)·CRUD·첨부 연계.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DiaryService {
|
||||||
|
|
||||||
|
private final ScheduleMapper mapper;
|
||||||
|
private final AttachmentService attachmentService;
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public DiaryPage list(LocalDate fromDate, LocalDate toDate, int page, int size) {
|
||||||
|
LocalDate to = (toDate != null) ? toDate : LocalDate.now();
|
||||||
|
LocalDate from = (fromDate != null) ? fromDate : to.minusMonths(1);
|
||||||
|
long total = mapper.countDiary(from, to);
|
||||||
|
List<UiwsDiary> rows = mapper.searchDiary(from, to, size, page * size);
|
||||||
|
Map<String, String> names = userNames(rows.stream().map(UiwsDiary::getWriterId).toList());
|
||||||
|
List<DiaryDto> content = rows.stream()
|
||||||
|
.map(d -> new DiaryDto(d.getDiaryId(), d.getTitle(),
|
||||||
|
names.getOrDefault(d.getWriterId(), d.getWriterId()),
|
||||||
|
d.getDiaryDate() != null ? d.getDiaryDate().toString() : null,
|
||||||
|
d.getScheduleId()))
|
||||||
|
.toList();
|
||||||
|
return new DiaryPage(content, total, page, size, totalPages(total, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public DiaryDetailDto create(DiarySaveDto dto) {
|
||||||
|
String actor = UiwsCurrentUser.id();
|
||||||
|
UiwsDiary d = new UiwsDiary();
|
||||||
|
applySave(d, dto);
|
||||||
|
d.setWriterId(actor);
|
||||||
|
d.setCreatedBy(actor);
|
||||||
|
d.setCreatedAt(LocalDateTime.now());
|
||||||
|
mapper.insertDiary(d);
|
||||||
|
attachmentService.link(AttachmentService.REF_DIARY, d.getDiaryId(), dto.attachmentIds());
|
||||||
|
return toDetail(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public DiaryDetailDto detail(Long id) {
|
||||||
|
return toDetail(find(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public DiaryDetailDto update(Long id, DiarySaveDto dto) {
|
||||||
|
String actor = UiwsCurrentUser.id();
|
||||||
|
UiwsDiary d = find(id);
|
||||||
|
applySave(d, dto);
|
||||||
|
d.setUpdatedBy(actor);
|
||||||
|
d.setUpdatedAt(LocalDateTime.now());
|
||||||
|
mapper.updateDiary(d);
|
||||||
|
attachmentService.link(AttachmentService.REF_DIARY, d.getDiaryId(), dto.attachmentIds());
|
||||||
|
return toDetail(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void delete(Long id) {
|
||||||
|
if (!mapper.existsDiaryById(id)) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.DIARY_NOT_FOUND);
|
||||||
|
}
|
||||||
|
attachmentService.deleteByRef(AttachmentService.REF_DIARY, id);
|
||||||
|
mapper.deleteDiaryById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================== helpers
|
||||||
|
|
||||||
|
private void applySave(UiwsDiary d, DiarySaveDto dto) {
|
||||||
|
d.setTitle(dto.title());
|
||||||
|
d.setContent(dto.content());
|
||||||
|
d.setScheduleId(dto.scheduleId());
|
||||||
|
d.setDiaryDate(parseDateNullable(dto.diaryDate()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private DiaryDetailDto toDetail(UiwsDiary d) {
|
||||||
|
String writerNm = userNames(List.of(d.getWriterId())).getOrDefault(d.getWriterId(), d.getWriterId());
|
||||||
|
List<AttachmentDto> attachments = attachmentService.list(AttachmentService.REF_DIARY, d.getDiaryId());
|
||||||
|
return new DiaryDetailDto(d.getDiaryId(), d.getTitle(), writerNm,
|
||||||
|
d.getDiaryDate() != null ? d.getDiaryDate().toString() : null,
|
||||||
|
d.getScheduleId(), d.getContent(), attachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
private UiwsDiary find(Long id) {
|
||||||
|
UiwsDiary d = mapper.findDiaryById(id);
|
||||||
|
if (d == null) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.DIARY_NOT_FOUND);
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> userNames(List<String> ids) {
|
||||||
|
List<String> clean = ids.stream().filter(s -> s != null && !s.isBlank()).distinct().toList();
|
||||||
|
if (clean.isEmpty()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
return mapper.findUserNames(clean).stream()
|
||||||
|
.collect(Collectors.toMap(r -> r.get("username"), r -> r.get("fullName"), (a, b) -> a));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int totalPages(long total, int size) {
|
||||||
|
return size <= 0 ? 0 : (int) ((total + size - 1) / size);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalDate parseDateNullable(String s) {
|
||||||
|
if (s == null || s.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return LocalDate.parse(s);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "날짜 형식이 올바르지 않습니다(yyyy-MM-dd).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
package com.zioinfo.esn.uiws.schedule.service;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.uiws.common.UiwsApiException;
|
||||||
|
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
|
||||||
|
import com.zioinfo.esn.uiws.config.UiwsProperties;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 첨부파일 로컬 스토리지(UIWS 이식). 경로순회 방지(파일명 정규화 + UUID 저장명), 일자별 디렉터리 분리.
|
||||||
|
* 저장 결과로 상대경로(file_path)를 반환한다.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class FileStorageService {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("yyyy/MM/dd");
|
||||||
|
|
||||||
|
private final UiwsProperties properties;
|
||||||
|
|
||||||
|
public record StoredFile(String originalName, String relativePath, long size) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public StoredFile store(MultipartFile file) {
|
||||||
|
if (file == null || file.isEmpty()) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.FILE_EMPTY);
|
||||||
|
}
|
||||||
|
String original = StringUtils.cleanPath(
|
||||||
|
file.getOriginalFilename() != null ? file.getOriginalFilename() : "file");
|
||||||
|
original = original.replace("\\", "_").replace("/", "_");
|
||||||
|
if (original.contains("..")) {
|
||||||
|
original = original.replace("..", "_");
|
||||||
|
}
|
||||||
|
String ext = "";
|
||||||
|
int dot = original.lastIndexOf('.');
|
||||||
|
if (dot >= 0) {
|
||||||
|
ext = original.substring(dot);
|
||||||
|
}
|
||||||
|
String subDir = LocalDate.now().format(DAY);
|
||||||
|
String storedName = UUID.randomUUID().toString().replace("-", "") + ext;
|
||||||
|
String relativePath = subDir + "/" + storedName;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Path root = Paths.get(properties.getUpload().getUploadDir()).toAbsolutePath().normalize();
|
||||||
|
Path target = root.resolve(relativePath).normalize();
|
||||||
|
if (!target.startsWith(root)) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.FILE_STORAGE_ERROR, "허용되지 않은 저장 경로입니다.");
|
||||||
|
}
|
||||||
|
Files.createDirectories(target.getParent());
|
||||||
|
file.transferTo(target.toFile());
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.FILE_STORAGE_ERROR);
|
||||||
|
}
|
||||||
|
return new StoredFile(original, relativePath, file.getSize());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Path resolve(String relativePath) {
|
||||||
|
if (relativePath == null || relativePath.isBlank()) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.ATTACH_NOT_FOUND);
|
||||||
|
}
|
||||||
|
Path root = Paths.get(properties.getUpload().getUploadDir()).toAbsolutePath().normalize();
|
||||||
|
Path target = root.resolve(relativePath).normalize();
|
||||||
|
if (!target.startsWith(root)) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.FILE_STORAGE_ERROR, "허용되지 않은 경로입니다.");
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void delete(String relativePath) {
|
||||||
|
if (relativePath == null || relativePath.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Path root = Paths.get(properties.getUpload().getUploadDir()).toAbsolutePath().normalize();
|
||||||
|
Path target = root.resolve(relativePath).normalize();
|
||||||
|
if (target.startsWith(root)) {
|
||||||
|
Files.deleteIfExists(target);
|
||||||
|
}
|
||||||
|
} catch (IOException ignore) {
|
||||||
|
// 물리파일 삭제 실패는 무시(메타 일관성 우선)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,216 @@
|
|||||||
|
package com.zioinfo.esn.uiws.schedule.service;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.uiws.common.UiwsApiException;
|
||||||
|
import com.zioinfo.esn.uiws.common.UiwsCurrentUser;
|
||||||
|
import com.zioinfo.esn.uiws.common.UiwsDataScope;
|
||||||
|
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
|
||||||
|
import com.zioinfo.esn.uiws.schedule.dto.ScheduleDtos.*;
|
||||||
|
import com.zioinfo.esn.uiws.schedule.mapper.ScheduleMapper;
|
||||||
|
import com.zioinfo.esn.uiws.schedule.model.UiwsSchedule;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.DayOfWeek;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.time.temporal.TemporalAdjusters;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일정(모듈 02) 서비스 — 원본 com.urp.uiws.schedule.service.ScheduleService 를 MyBatis 변환 이식.
|
||||||
|
* 달력(month/week/day)·전체목록·검색팝업·CRUD. 첨부 연계(attachmentIds).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ScheduleService {
|
||||||
|
|
||||||
|
private static final String TYPE_PERSONAL = "PERSONAL";
|
||||||
|
private static final String TYPE_DEPT = "DEPT";
|
||||||
|
private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
|
||||||
|
|
||||||
|
private final ScheduleMapper mapper;
|
||||||
|
private final AttachmentService attachmentService;
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<ScheduleDto> calendar(String type, String view, LocalDate baseDate, String deptId) {
|
||||||
|
String t = normalizeType(type);
|
||||||
|
LocalDate base = (baseDate != null) ? baseDate : LocalDate.now();
|
||||||
|
LocalDate[] range = computeRange(view, base);
|
||||||
|
LocalDateTime from = range[0].atStartOfDay();
|
||||||
|
LocalDateTime to = range[1].atStartOfDay(); // 배타 상한
|
||||||
|
|
||||||
|
String ownerId = TYPE_PERSONAL.equals(t) ? UiwsCurrentUser.id() : null;
|
||||||
|
String deptFilter = TYPE_DEPT.equals(t) ? blankToNull(deptId) : null;
|
||||||
|
|
||||||
|
return mapper.findInRange(from, to, t, ownerId, deptFilter).stream().map(this::toDto).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public SchedulePage all(LocalDate fromDate, LocalDate toDate, String type, int page, int size) {
|
||||||
|
LocalDate to = (toDate != null) ? toDate : LocalDate.now();
|
||||||
|
LocalDate from = (fromDate != null) ? fromDate : to.minusMonths(1);
|
||||||
|
String t = (type == null || type.isBlank()) ? null : normalizeType(type);
|
||||||
|
UiwsDataScope.Scope scope = UiwsDataScope.current();
|
||||||
|
LocalDateTime f = from.atStartOfDay();
|
||||||
|
LocalDateTime tt = to.plusDays(1).atStartOfDay();
|
||||||
|
long total = mapper.countAll(f, tt, t, scope.all(), scope.ownerIds());
|
||||||
|
List<ScheduleDto> content = mapper.searchAll(f, tt, t, scope.all(), scope.ownerIds(), size, page * size)
|
||||||
|
.stream().map(this::toDto).toList();
|
||||||
|
return new SchedulePage(content, total, page, size, totalPages(total, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<ScheduleDto> search(String keyword) {
|
||||||
|
UiwsDataScope.Scope scope = UiwsDataScope.current();
|
||||||
|
return mapper.searchPopup(blankToNull(keyword), scope.all(), scope.ownerIds())
|
||||||
|
.stream().map(this::toDto).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ScheduleDto create(ScheduleSaveDto dto) {
|
||||||
|
String actor = UiwsCurrentUser.id();
|
||||||
|
UiwsSchedule s = new UiwsSchedule();
|
||||||
|
applySave(s, dto);
|
||||||
|
s.setOwnerId(actor);
|
||||||
|
s.setCreatedBy(actor);
|
||||||
|
s.setCreatedAt(LocalDateTime.now());
|
||||||
|
mapper.insertSchedule(s);
|
||||||
|
attachmentService.link(AttachmentService.REF_SCHEDULE, s.getScheduleId(), dto.attachmentIds());
|
||||||
|
return toDto(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public ScheduleDetailDto detail(Long id) {
|
||||||
|
return toDetail(find(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ScheduleDetailDto update(Long id, ScheduleSaveDto dto) {
|
||||||
|
String actor = UiwsCurrentUser.id();
|
||||||
|
UiwsSchedule s = find(id);
|
||||||
|
applySave(s, dto);
|
||||||
|
s.setUpdatedBy(actor);
|
||||||
|
s.setUpdatedAt(LocalDateTime.now());
|
||||||
|
mapper.updateSchedule(s);
|
||||||
|
attachmentService.link(AttachmentService.REF_SCHEDULE, s.getScheduleId(), dto.attachmentIds());
|
||||||
|
return toDetail(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void delete(Long id) {
|
||||||
|
if (!mapper.existsScheduleById(id)) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.SCHEDULE_NOT_FOUND);
|
||||||
|
}
|
||||||
|
attachmentService.deleteByRef(AttachmentService.REF_SCHEDULE, id);
|
||||||
|
mapper.deleteScheduleById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================== helpers
|
||||||
|
|
||||||
|
private void applySave(UiwsSchedule s, ScheduleSaveDto dto) {
|
||||||
|
LocalDateTime start = parseDateTime(dto.startDt());
|
||||||
|
LocalDateTime end = parseDateTime(dto.endDt());
|
||||||
|
if (end.isBefore(start)) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.SCHEDULE_DT_INVALID, "종료 일시가 시작 일시보다 빠릅니다.");
|
||||||
|
}
|
||||||
|
s.setScheType(normalizeType(dto.scheType()));
|
||||||
|
s.setTitle(dto.title());
|
||||||
|
s.setScheGubunCd(blankToNull(dto.scheGubunCd()));
|
||||||
|
s.setImportanceCd(blankToNull(dto.importanceCd()));
|
||||||
|
s.setStartDt(start);
|
||||||
|
s.setEndDt(end);
|
||||||
|
s.setContent(dto.content());
|
||||||
|
s.setDeptId(blankToNull(dto.deptId()));
|
||||||
|
s.setChargerId(blankToNull(dto.chargerId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ScheduleDto toDto(UiwsSchedule s) {
|
||||||
|
return new ScheduleDto(s.getScheduleId(), s.getScheType(), s.getTitle(),
|
||||||
|
s.getScheGubunCd(), s.getImportanceCd(), fmt(s.getStartDt()), fmt(s.getEndDt()),
|
||||||
|
s.getOwnerId(), s.getDeptId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ScheduleDetailDto toDetail(UiwsSchedule s) {
|
||||||
|
String chargerNm = null;
|
||||||
|
if (s.getChargerId() != null) {
|
||||||
|
chargerNm = userNames(List.of(s.getChargerId())).getOrDefault(s.getChargerId(), s.getChargerId());
|
||||||
|
}
|
||||||
|
List<AttachmentDto> attachments = attachmentService.list(AttachmentService.REF_SCHEDULE, s.getScheduleId());
|
||||||
|
return new ScheduleDetailDto(s.getScheduleId(), s.getScheType(), s.getTitle(),
|
||||||
|
s.getScheGubunCd(), s.getImportanceCd(), fmt(s.getStartDt()), fmt(s.getEndDt()),
|
||||||
|
s.getOwnerId(), s.getDeptId(), s.getContent(), s.getChargerId(), chargerNm, attachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
private UiwsSchedule find(Long id) {
|
||||||
|
UiwsSchedule s = mapper.findScheduleById(id);
|
||||||
|
if (s == null) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.SCHEDULE_NOT_FOUND);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> userNames(List<String> ids) {
|
||||||
|
List<String> clean = ids.stream().filter(s -> s != null && !s.isBlank()).distinct().toList();
|
||||||
|
if (clean.isEmpty()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
return mapper.findUserNames(clean).stream()
|
||||||
|
.collect(Collectors.toMap(r -> r.get("username"), r -> r.get("fullName"), (a, b) -> a));
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocalDate[] computeRange(String view, LocalDate base) {
|
||||||
|
String v = (view == null || view.isBlank()) ? "month" : view.trim().toLowerCase();
|
||||||
|
return switch (v) {
|
||||||
|
case "day" -> new LocalDate[]{base, base.plusDays(1)};
|
||||||
|
case "week" -> {
|
||||||
|
LocalDate start = base.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
|
||||||
|
yield new LocalDate[]{start, start.plusWeeks(1)};
|
||||||
|
}
|
||||||
|
case "month" -> {
|
||||||
|
LocalDate start = base.withDayOfMonth(1);
|
||||||
|
yield new LocalDate[]{start, start.plusMonths(1)};
|
||||||
|
}
|
||||||
|
default -> throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST,
|
||||||
|
"view 는 month/week/day 중 하나여야 합니다.");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeType(String type) {
|
||||||
|
String t = (type == null) ? "" : type.trim().toUpperCase();
|
||||||
|
if (!TYPE_PERSONAL.equals(t) && !TYPE_DEPT.equals(t)) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "type 은 PERSONAL/DEPT 중 하나여야 합니다.");
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int totalPages(long total, int size) {
|
||||||
|
return size <= 0 ? 0 : (int) ((total + size - 1) / size);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String blankToNull(String s) {
|
||||||
|
return (s == null || s.isBlank()) ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String fmt(LocalDateTime dt) {
|
||||||
|
return dt != null ? dt.format(DT) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalDateTime parseDateTime(String s) {
|
||||||
|
if (s == null || s.isBlank()) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "일시 값이 비어 있습니다.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (s.length() <= 10) {
|
||||||
|
return LocalDate.parse(s).atStartOfDay();
|
||||||
|
}
|
||||||
|
return LocalDateTime.parse(s);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST,
|
||||||
|
"일시 형식이 올바르지 않습니다(yyyy-MM-ddTHH:mm:ss).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
package com.zioinfo.esn.uiws.stats.controller;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.common.ApiResponse;
|
||||||
|
import com.zioinfo.esn.uiws.stats.dto.StatsDtos.PivotResponse;
|
||||||
|
import com.zioinfo.esn.uiws.stats.service.StatsService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UIWS 이식 — 업무통계(모듈 04, 동적 컬럼 피벗). 인증 필수(/api/stats).
|
||||||
|
* prefix /api/stats 는 ERP 기존 라우트와 미충돌(확인). 원본 엔드포인트 보존.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/stats")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class StatsController {
|
||||||
|
|
||||||
|
private final StatsService statsService;
|
||||||
|
|
||||||
|
@GetMapping("/personal-work")
|
||||||
|
public ApiResponse<PivotResponse> personalWork(
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
|
||||||
|
@RequestParam(required = false) String deptId,
|
||||||
|
@RequestParam(required = false) String userId,
|
||||||
|
@RequestParam(required = false) Boolean showStatus,
|
||||||
|
@RequestParam(required = false) Boolean showType) {
|
||||||
|
return ApiResponse.ok(statsService.personalWork(fromDate, toDate, deptId, userId, showStatus, showType));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/company-work")
|
||||||
|
public ApiResponse<PivotResponse> companyWork(
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
|
||||||
|
@RequestParam(required = false) String companyId,
|
||||||
|
@RequestParam(required = false) String userId,
|
||||||
|
@RequestParam(required = false) Boolean showType) {
|
||||||
|
return ApiResponse.ok(statsService.companyWork(fromDate, toDate, companyId, userId, showType));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
package com.zioinfo.esn.uiws.stats.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 근무현황 통계(모듈 04) 동적 컬럼 피벗 DTO. 원본 com.urp.uiws.stats.dto.* 이식.
|
||||||
|
*/
|
||||||
|
public final class StatsDtos {
|
||||||
|
|
||||||
|
private StatsDtos() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 피벗 열 메타. 고정열 예 {key:"worker",label:"근무자"}; 동적열 예 {key:"company_본사",label:"본사"}. */
|
||||||
|
public record PivotColumn(String key, String label) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 동적 컬럼 피벗 응답. { fixedColumns, dynamicColumns, rows } */
|
||||||
|
public record PivotResponse(
|
||||||
|
List<PivotColumn> fixedColumns,
|
||||||
|
List<PivotColumn> dynamicColumns,
|
||||||
|
List<Map<String, Object>> rows
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
package com.zioinfo.esn.uiws.stats.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 근무현황 피벗 집계 매퍼. 원본 StatsRepository(JdbcTemplate 네이티브) → MyBatis 변환 이식.
|
||||||
|
*
|
||||||
|
* silo 정합(코어 TB_USER/TB_COMPANY/TB_CODE 미이식):
|
||||||
|
* - 근무자 라벨: esn_user (writer_id=username 라벨). 미존재 시 writer_id 노출(COALESCE).
|
||||||
|
* - 근무처(company) 라벨: company_id 직접 사용(거래처 테이블 미이식).
|
||||||
|
* - 근무상태/근무유형 라벨: 코드값 직접 사용(공통코드 미이식).
|
||||||
|
* 결과는 long-form(그룹키 + cnt). 서비스가 동적 컬럼으로 피벗한다.
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface StatsMapper {
|
||||||
|
|
||||||
|
/** 개인별: 행=근무자×근무상태×근무유형, 동적열 차원=근무처. 컬럼: worker, work_status, work_type, dyn, cnt. */
|
||||||
|
List<Map<String, Object>> personalWork(@Param("from") LocalDate from,
|
||||||
|
@Param("to") LocalDate to,
|
||||||
|
@Param("userId") String userId,
|
||||||
|
@Param("scopeAll") boolean scopeAll,
|
||||||
|
@Param("ownerIds") List<String> ownerIds);
|
||||||
|
|
||||||
|
/** 업체별: 행=근무처×근무유형, 동적열 차원=근무자. 컬럼: company, work_type, dyn, cnt. */
|
||||||
|
List<Map<String, Object>> companyWork(@Param("from") LocalDate from,
|
||||||
|
@Param("to") LocalDate to,
|
||||||
|
@Param("companyId") String companyId,
|
||||||
|
@Param("userId") String userId,
|
||||||
|
@Param("scopeAll") boolean scopeAll,
|
||||||
|
@Param("ownerIds") List<String> ownerIds);
|
||||||
|
}
|
||||||
@ -0,0 +1,165 @@
|
|||||||
|
package com.zioinfo.esn.uiws.stats.service;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.uiws.common.UiwsDataScope;
|
||||||
|
import com.zioinfo.esn.uiws.stats.dto.StatsDtos.PivotColumn;
|
||||||
|
import com.zioinfo.esn.uiws.stats.dto.StatsDtos.PivotResponse;
|
||||||
|
import com.zioinfo.esn.uiws.stats.mapper.StatsMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 근무현황 통계(모듈 04) — 동적 컬럼 피벗 서비스. 원본 com.urp.uiws.stats.service.StatsService 이식.
|
||||||
|
* long-form 집계를 wide-form 으로 피벗. showStatus/showType 토글. 기본 조회기간 = 최근 1주일.
|
||||||
|
* 데이터 스코프(ADMIN=전체/그 외=본인)는 UiwsDataScope 로 silo 판정.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class StatsService {
|
||||||
|
|
||||||
|
/** 행 식별 키 구분자(데이터에 등장하지 않는 제어문자). */
|
||||||
|
private static final char SEP = '';
|
||||||
|
|
||||||
|
private final StatsMapper mapper;
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public PivotResponse personalWork(LocalDate fromDate, LocalDate toDate,
|
||||||
|
String deptId, String userId,
|
||||||
|
Boolean showStatus, Boolean showType) {
|
||||||
|
LocalDate[] range = range(fromDate, toDate);
|
||||||
|
UiwsDataScope.Scope scope = UiwsDataScope.current();
|
||||||
|
// deptId 는 코어 dept 미이식으로 silo 에서 무시(파라미터 호환만 유지).
|
||||||
|
List<Map<String, Object>> agg = mapper.personalWork(
|
||||||
|
range[0], range[1], emptyToNull(userId), scope.all(), scope.ownerIds());
|
||||||
|
|
||||||
|
boolean status = !Boolean.FALSE.equals(showStatus);
|
||||||
|
boolean type = !Boolean.FALSE.equals(showType);
|
||||||
|
|
||||||
|
List<PivotColumn> fixed = new ArrayList<>();
|
||||||
|
fixed.add(new PivotColumn("worker", "근무자"));
|
||||||
|
if (status) {
|
||||||
|
fixed.add(new PivotColumn("workStatus", "근무상태"));
|
||||||
|
}
|
||||||
|
if (type) {
|
||||||
|
fixed.add(new PivotColumn("workType", "근무유형"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return pivot(agg, fixed, "company",
|
||||||
|
row -> rowKey(row, status, type),
|
||||||
|
row -> {
|
||||||
|
Map<String, Object> base = new LinkedHashMap<>();
|
||||||
|
base.put("worker", str(row.get("worker")));
|
||||||
|
if (status) {
|
||||||
|
base.put("workStatus", str(row.get("work_status")));
|
||||||
|
}
|
||||||
|
if (type) {
|
||||||
|
base.put("workType", str(row.get("work_type")));
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public PivotResponse companyWork(LocalDate fromDate, LocalDate toDate,
|
||||||
|
String companyId, String userId,
|
||||||
|
Boolean showType) {
|
||||||
|
LocalDate[] range = range(fromDate, toDate);
|
||||||
|
UiwsDataScope.Scope scope = UiwsDataScope.current();
|
||||||
|
List<Map<String, Object>> agg = mapper.companyWork(
|
||||||
|
range[0], range[1], emptyToNull(companyId), emptyToNull(userId), scope.all(), scope.ownerIds());
|
||||||
|
|
||||||
|
boolean type = !Boolean.FALSE.equals(showType);
|
||||||
|
|
||||||
|
List<PivotColumn> fixed = new ArrayList<>();
|
||||||
|
fixed.add(new PivotColumn("company", "근무처"));
|
||||||
|
if (type) {
|
||||||
|
fixed.add(new PivotColumn("workType", "근무유형"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return pivot(agg, fixed, "worker",
|
||||||
|
row -> str(row.get("company")) + SEP + (type ? str(row.get("work_type")) : ""),
|
||||||
|
row -> {
|
||||||
|
Map<String, Object> base = new LinkedHashMap<>();
|
||||||
|
base.put("company", str(row.get("company")));
|
||||||
|
if (type) {
|
||||||
|
base.put("workType", str(row.get("work_type")));
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================== 피벗 공통
|
||||||
|
|
||||||
|
private interface RowKeyFn {
|
||||||
|
String key(Map<String, Object> row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private interface BaseFn {
|
||||||
|
Map<String, Object> base(Map<String, Object> row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private PivotResponse pivot(List<Map<String, Object>> agg, List<PivotColumn> fixed,
|
||||||
|
String dynPrefix, RowKeyFn keyFn, BaseFn baseFn) {
|
||||||
|
Map<String, PivotColumn> dynCols = new LinkedHashMap<>();
|
||||||
|
Map<String, Map<String, Object>> rowMap = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
for (Map<String, Object> r : agg) {
|
||||||
|
String dynLabel = str(r.get("dyn"));
|
||||||
|
String dynKey = dynPrefix + "_" + dynLabel;
|
||||||
|
dynCols.putIfAbsent(dynKey, new PivotColumn(dynKey, dynLabel));
|
||||||
|
|
||||||
|
String rk = keyFn.key(r);
|
||||||
|
Map<String, Object> row = rowMap.computeIfAbsent(rk, k -> baseFn.base(r));
|
||||||
|
long cnt = toLong(r.get("cnt"));
|
||||||
|
row.merge(dynKey, cnt, (a, b) -> toLong(a) + toLong(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PivotColumn> dynamic = new ArrayList<>(dynCols.values());
|
||||||
|
List<Map<String, Object>> rows = new ArrayList<>();
|
||||||
|
for (Map<String, Object> row : rowMap.values()) {
|
||||||
|
for (PivotColumn dc : dynamic) {
|
||||||
|
row.putIfAbsent(dc.key(), 0L);
|
||||||
|
}
|
||||||
|
rows.add(row);
|
||||||
|
}
|
||||||
|
return new PivotResponse(fixed, dynamic, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String rowKey(Map<String, Object> row, boolean status, boolean type) {
|
||||||
|
StringBuilder sb = new StringBuilder(str(row.get("worker")));
|
||||||
|
if (status) {
|
||||||
|
sb.append(SEP).append(str(row.get("work_status")));
|
||||||
|
}
|
||||||
|
if (type) {
|
||||||
|
sb.append(SEP).append(str(row.get("work_type")));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocalDate[] range(LocalDate fromDate, LocalDate toDate) {
|
||||||
|
LocalDate to = (toDate != null) ? toDate : LocalDate.now();
|
||||||
|
LocalDate from = (fromDate != null) ? fromDate : to.minusWeeks(1);
|
||||||
|
return new LocalDate[]{from, to};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String emptyToNull(String s) {
|
||||||
|
return (s == null || s.isBlank()) ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String str(Object o) {
|
||||||
|
return o == null ? "" : o.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long toLong(Object o) {
|
||||||
|
if (o == null) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
return (o instanceof Number n) ? n.longValue() : Long.parseLong(o.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
package com.zioinfo.esn.uiws.worklog.controller;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.common.ApiResponse;
|
||||||
|
import com.zioinfo.esn.uiws.worklog.dto.WorklogDtos.*;
|
||||||
|
import com.zioinfo.esn.uiws.worklog.service.WorklogService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UIWS 이식 — 업무일지(모듈 06). 인증 필수(/api/worklogs).
|
||||||
|
* 원본 /pdf(JasperReports) 엔드포인트는 ERP 의존성 미보유로 제외(보고서는 후속 트랙 — esn_port.md 참조).
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/worklogs")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WorklogController {
|
||||||
|
|
||||||
|
private final WorklogService worklogService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ApiResponse<WorklogPage> list(
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
|
||||||
|
@RequestParam(required = false) String writerId,
|
||||||
|
@RequestParam(required = false) String progressCd,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
return ApiResponse.ok(worklogService.list(fromDate, toDate, writerId, progressCd, page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/dashboard/progress")
|
||||||
|
public ApiResponse<List<ProgressSummaryDto>> progressSummary(
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate) {
|
||||||
|
return ApiResponse.ok(worklogService.progressSummary(fromDate, toDate));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/calendar")
|
||||||
|
public ApiResponse<List<WorklogCalendarDto>> calendar(
|
||||||
|
@RequestParam(required = false) String yearMonth,
|
||||||
|
@RequestParam(required = false) String writerId) {
|
||||||
|
return ApiResponse.ok(worklogService.calendar(yearMonth, writerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/search")
|
||||||
|
public ApiResponse<List<WorklogListDto>> search(
|
||||||
|
@RequestParam(required = false) String keyword,
|
||||||
|
@RequestParam(required = false) String writerId) {
|
||||||
|
return ApiResponse.ok(worklogService.search(keyword, writerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ApiResponse<WorklogDetailDto> create(@Valid @RequestBody WorklogSaveDto dto) {
|
||||||
|
return ApiResponse.ok(worklogService.create(dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ApiResponse<WorklogDetailDto> detail(@PathVariable("id") Long id) {
|
||||||
|
return ApiResponse.ok(worklogService.detail(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ApiResponse<WorklogDetailDto> update(@PathVariable("id") Long id, @Valid @RequestBody WorklogSaveDto dto) {
|
||||||
|
return ApiResponse.ok(worklogService.update(id, dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ApiResponse<Void> delete(@PathVariable("id") Long id) {
|
||||||
|
worklogService.delete(id);
|
||||||
|
return ApiResponse.ok(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/comments")
|
||||||
|
public ApiResponse<WorklogCommentDto> addComment(@PathVariable("id") Long id,
|
||||||
|
@Valid @RequestBody CommentRequest req) {
|
||||||
|
return ApiResponse.ok(worklogService.addComment(id, req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/comments/{cmtId}/confirm")
|
||||||
|
public ApiResponse<Void> confirmComment(@PathVariable("cmtId") Long cmtId) {
|
||||||
|
worklogService.confirmComment(cmtId);
|
||||||
|
return ApiResponse.ok(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/comments/unconfirmed")
|
||||||
|
public ApiResponse<List<UnconfirmedCommentDto>> unconfirmedComments() {
|
||||||
|
return ApiResponse.ok(worklogService.unconfirmedByMe());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,118 @@
|
|||||||
|
package com.zioinfo.esn.uiws.worklog.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.zioinfo.esn.uiws.worklog.model.UiwsWorklogDtl;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업무일지(모듈 06) DTO. 원본 com.urp.uiws.worklog.dto.* 이식(shape 동일, _state 보존).
|
||||||
|
*/
|
||||||
|
public final class WorklogDtos {
|
||||||
|
|
||||||
|
private WorklogDtos() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record WorklogListDto(
|
||||||
|
Long worklogId,
|
||||||
|
String title,
|
||||||
|
String writerNm,
|
||||||
|
String workDate,
|
||||||
|
String progressCd,
|
||||||
|
long commentCount
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record WorklogCalendarDto(
|
||||||
|
String workDate,
|
||||||
|
Long worklogId,
|
||||||
|
String workStatusNm,
|
||||||
|
boolean isHoliday,
|
||||||
|
String holidayNm
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record WorklogDetailDto(
|
||||||
|
Long worklogId,
|
||||||
|
String title,
|
||||||
|
String writerId,
|
||||||
|
String writerNm,
|
||||||
|
String workDate,
|
||||||
|
String workStatusCd,
|
||||||
|
String progressCd,
|
||||||
|
String repeatYn,
|
||||||
|
String repeatStartDate,
|
||||||
|
String repeatEndDate,
|
||||||
|
List<WorklogDtlDto> details,
|
||||||
|
List<WorklogCommentDto> comments
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record WorklogDtlDto(
|
||||||
|
Long dtlId,
|
||||||
|
Integer startHour,
|
||||||
|
Integer endHour,
|
||||||
|
String workTypeCd,
|
||||||
|
String companyId,
|
||||||
|
String workContent,
|
||||||
|
String issueContent,
|
||||||
|
Integer sortOrd,
|
||||||
|
String createdAt,
|
||||||
|
String updatedAt,
|
||||||
|
@JsonProperty("_state") String state
|
||||||
|
) {
|
||||||
|
public static WorklogDtlDto from(UiwsWorklogDtl d) {
|
||||||
|
return new WorklogDtlDto(
|
||||||
|
d.getDtlId(), d.getStartHour(), d.getEndHour(), d.getWorkTypeCd(), d.getCompanyId(),
|
||||||
|
d.getWorkContent(), d.getIssueContent(), d.getSortOrd(),
|
||||||
|
d.getCreatedAt() != null ? d.getCreatedAt().toString() : null,
|
||||||
|
d.getUpdatedAt() != null ? d.getUpdatedAt().toString() : null,
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record WorklogSaveDto(
|
||||||
|
@NotBlank String title,
|
||||||
|
@NotBlank String writerId,
|
||||||
|
@NotNull String workDate,
|
||||||
|
@NotBlank String workStatusCd,
|
||||||
|
String progressCd,
|
||||||
|
String repeatYn,
|
||||||
|
String repeatStartDate,
|
||||||
|
String repeatEndDate,
|
||||||
|
@Valid List<WorklogDtlDto> details
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record WorklogCommentDto(
|
||||||
|
Long cmtId,
|
||||||
|
String cmtContent,
|
||||||
|
String writerNm,
|
||||||
|
String createdAt,
|
||||||
|
String kakaoSentYn,
|
||||||
|
String confirmYn
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CommentRequest(@NotBlank String cmtContent) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ProgressSummaryDto(String progressCd, String progressNm, long count) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UnconfirmedCommentDto(
|
||||||
|
Long cmtId,
|
||||||
|
Long worklogId,
|
||||||
|
String worklogTitle,
|
||||||
|
String writerNm,
|
||||||
|
String cmtContent,
|
||||||
|
String createdAt
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record WorklogPage(List<WorklogListDto> content, long totalElements, int page, int size, int totalPages) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
package com.zioinfo.esn.uiws.worklog.mapper;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.uiws.worklog.model.UiwsWorklog;
|
||||||
|
import com.zioinfo.esn.uiws.worklog.model.UiwsWorklogCmt;
|
||||||
|
import com.zioinfo.esn.uiws.worklog.model.UiwsWorklogDtl;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업무일지 MyBatis 매퍼. 원본 JPA Worklog/WorklogDtl/WorklogCmt Repository 변환 이식.
|
||||||
|
* 진행상태 집계는 ported tb_uiws_worklog 기반(코어 TB_CODE 미이식 → 코드값 직접 집계).
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface WorklogMapper {
|
||||||
|
|
||||||
|
// ── worklog 헤더
|
||||||
|
int insertWorklog(UiwsWorklog w);
|
||||||
|
|
||||||
|
int updateWorklog(UiwsWorklog w);
|
||||||
|
|
||||||
|
UiwsWorklog findWorklogById(@Param("worklogId") Long worklogId);
|
||||||
|
|
||||||
|
boolean existsWorklogById(@Param("worklogId") Long worklogId);
|
||||||
|
|
||||||
|
int deleteWorklogById(@Param("worklogId") Long worklogId);
|
||||||
|
|
||||||
|
List<UiwsWorklog> searchList(@Param("from") LocalDate from,
|
||||||
|
@Param("to") LocalDate to,
|
||||||
|
@Param("writerId") String writerId,
|
||||||
|
@Param("progressCd") String progressCd,
|
||||||
|
@Param("scopeAll") boolean scopeAll,
|
||||||
|
@Param("ownerIds") List<String> ownerIds,
|
||||||
|
@Param("limit") int limit,
|
||||||
|
@Param("offset") int offset);
|
||||||
|
|
||||||
|
long countList(@Param("from") LocalDate from,
|
||||||
|
@Param("to") LocalDate to,
|
||||||
|
@Param("writerId") String writerId,
|
||||||
|
@Param("progressCd") String progressCd,
|
||||||
|
@Param("scopeAll") boolean scopeAll,
|
||||||
|
@Param("ownerIds") List<String> ownerIds);
|
||||||
|
|
||||||
|
List<UiwsWorklog> findByMonth(@Param("from") LocalDate from,
|
||||||
|
@Param("to") LocalDate to,
|
||||||
|
@Param("writerId") String writerId,
|
||||||
|
@Param("scopeAll") boolean scopeAll,
|
||||||
|
@Param("ownerIds") List<String> ownerIds);
|
||||||
|
|
||||||
|
List<UiwsWorklog> searchPopup(@Param("keyword") String keyword,
|
||||||
|
@Param("writerId") String writerId,
|
||||||
|
@Param("scopeAll") boolean scopeAll,
|
||||||
|
@Param("ownerIds") List<String> ownerIds);
|
||||||
|
|
||||||
|
/** 진행상태별 건수(progress_cd, cnt). */
|
||||||
|
List<Map<String, Object>> progressSummary(@Param("from") LocalDate from,
|
||||||
|
@Param("to") LocalDate to,
|
||||||
|
@Param("scopeAll") boolean scopeAll,
|
||||||
|
@Param("ownerIds") List<String> ownerIds);
|
||||||
|
|
||||||
|
// ── 상세(dtl)
|
||||||
|
int insertDtl(UiwsWorklogDtl d);
|
||||||
|
|
||||||
|
int updateDtl(UiwsWorklogDtl d);
|
||||||
|
|
||||||
|
int deleteDtlById(@Param("dtlId") Long dtlId);
|
||||||
|
|
||||||
|
List<UiwsWorklogDtl> findDtlByWorklogId(@Param("worklogId") Long worklogId);
|
||||||
|
|
||||||
|
// ── 댓글(cmt)
|
||||||
|
int insertCmt(UiwsWorklogCmt c);
|
||||||
|
|
||||||
|
UiwsWorklogCmt findCmtById(@Param("cmtId") Long cmtId);
|
||||||
|
|
||||||
|
int updateCmtKakaoSent(@Param("cmtId") Long cmtId);
|
||||||
|
|
||||||
|
int confirmCmt(@Param("cmtId") Long cmtId, @Param("actor") String actor);
|
||||||
|
|
||||||
|
List<UiwsWorklogCmt> findCmtByWorklogId(@Param("worklogId") Long worklogId);
|
||||||
|
|
||||||
|
List<Map<String, Object>> countCommentsByWorklogIds(@Param("ids") List<Long> ids);
|
||||||
|
|
||||||
|
List<UiwsWorklogCmt> findUnconfirmedByWriter(@Param("writerId") String writerId);
|
||||||
|
|
||||||
|
// ── 사용자명 라벨(코어 user 재사용)
|
||||||
|
List<Map<String, String>> findUserNames(@Param("ids") List<String> ids);
|
||||||
|
|
||||||
|
/** 댓글 알림 메일 발송 대상 이메일(username 기준). */
|
||||||
|
String findUserEmail(@Param("username") String username);
|
||||||
|
|
||||||
|
List<UiwsWorklog> findWorklogsByIds(@Param("ids") List<Long> ids);
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package com.zioinfo.esn.uiws.worklog.model;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 업무일지 헤더 (tb_uiws_worklog). 원본 com.urp.uiws.domain.Worklog 이식. */
|
||||||
|
@Data
|
||||||
|
public class UiwsWorklog {
|
||||||
|
private Long worklogId;
|
||||||
|
private String title;
|
||||||
|
private String writerId;
|
||||||
|
private LocalDate workDate;
|
||||||
|
private String workStatusCd;
|
||||||
|
private String progressCd; // ONGOING | DONE
|
||||||
|
private String repeatYn; // Y | N
|
||||||
|
private LocalDate repeatStartDate;
|
||||||
|
private LocalDate repeatEndDate;
|
||||||
|
private String createdBy;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private String updatedBy;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package com.zioinfo.esn.uiws.worklog.model;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 업무일지 댓글 (tb_uiws_worklog_cmt). 원본 com.urp.uiws.domain.WorklogCmt 이식. */
|
||||||
|
@Data
|
||||||
|
public class UiwsWorklogCmt {
|
||||||
|
private Long cmtId;
|
||||||
|
private Long worklogId;
|
||||||
|
private String cmtContent;
|
||||||
|
private String writerId;
|
||||||
|
private String kakaoSentYn;
|
||||||
|
private String confirmYn;
|
||||||
|
private String createdBy;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private String updatedBy;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package com.zioinfo.esn.uiws.worklog.model;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 업무일지 시간대별 상세 (tb_uiws_worklog_dtl). 원본 com.urp.uiws.domain.WorklogDtl 이식. */
|
||||||
|
@Data
|
||||||
|
public class UiwsWorklogDtl {
|
||||||
|
private Long dtlId;
|
||||||
|
private Long worklogId;
|
||||||
|
private Integer startHour;
|
||||||
|
private Integer endHour;
|
||||||
|
private String workTypeCd;
|
||||||
|
private String companyId;
|
||||||
|
private String workContent;
|
||||||
|
private String issueContent;
|
||||||
|
private Integer sortOrd;
|
||||||
|
private String createdBy;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private String updatedBy;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package com.zioinfo.esn.uiws.worklog.service;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.uiws.common.mail.MailSender;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업무일지 댓글 알림 — 원본 NaverWorksNotifier(외부 메신저) 대신 MailSender 폴백으로 이식.
|
||||||
|
* 보안 불변규칙(외부 API 금지) 준수: 외부 메신저 호출 없이 메일/로그 채널로만 알림한다.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WorklogNotifier {
|
||||||
|
|
||||||
|
private final MailSender mailSender;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일지 작성자에게 댓글 알림. 발송되면 true(=KAKAO_SENT_YN 'Y' 선반영).
|
||||||
|
* email 미보유 시 로그만 남기고 false.
|
||||||
|
*/
|
||||||
|
public boolean notifyComment(String writerId, String email, String subject, String body) {
|
||||||
|
if (email == null || email.isBlank()) {
|
||||||
|
log.info("[UIWS-WL-NOTIFY] no email for writer={} (skip mail, log only)", writerId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
mailSender.send(email, subject, body);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,438 @@
|
|||||||
|
package com.zioinfo.esn.uiws.worklog.service;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.uiws.common.UiwsApiException;
|
||||||
|
import com.zioinfo.esn.uiws.common.UiwsCurrentUser;
|
||||||
|
import com.zioinfo.esn.uiws.common.UiwsDataScope;
|
||||||
|
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
|
||||||
|
import com.zioinfo.esn.uiws.worklog.dto.WorklogDtos.*;
|
||||||
|
import com.zioinfo.esn.uiws.worklog.mapper.WorklogMapper;
|
||||||
|
import com.zioinfo.esn.uiws.worklog.model.UiwsWorklog;
|
||||||
|
import com.zioinfo.esn.uiws.worklog.model.UiwsWorklogCmt;
|
||||||
|
import com.zioinfo.esn.uiws.worklog.model.UiwsWorklogDtl;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.YearMonth;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업무일지(모듈 06) 서비스 — 원본 com.urp.uiws.worklog.service.WorklogService MyBatis 변환 이식.
|
||||||
|
*
|
||||||
|
* silo 단순화(코어 TB_CODE/TB_DEPT 미이식):
|
||||||
|
* - 근무상태/진행 코드 라벨: 코드값 그대로 노출(공통코드 미이식). 진행 라벨은 ONGOING/DONE 한글 매핑만 내장.
|
||||||
|
* - 진행상태 집계: ONGOING/DONE 고정 셋 0건 포함 반환(차트 범례 안정).
|
||||||
|
* - 댓글 권한: 원본 "관할 상무 이상"(부서계층+직급) → ERP RBAC MANAGER/ADMIN/CFO 로 완화 이식.
|
||||||
|
* - 댓글 알림: NaverWorks(외부) → MailSender 폴백(외부 API 금지 준수).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WorklogService {
|
||||||
|
|
||||||
|
private static final String DEFAULT_PROGRESS = "ONGOING";
|
||||||
|
private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
|
||||||
|
/** 진행 코드 → 라벨(공통코드 미이식 silo 보정). */
|
||||||
|
private static final Map<String, String> PROGRESS_LABEL = Map.of("ONGOING", "진행중", "DONE", "종료");
|
||||||
|
|
||||||
|
private final WorklogMapper mapper;
|
||||||
|
private final WorklogNotifier notifier;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 목록(리스트형)
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public WorklogPage list(LocalDate fromDate, LocalDate toDate, String writerId, String progressCd, int page, int size) {
|
||||||
|
LocalDate to = (toDate != null) ? toDate : LocalDate.now();
|
||||||
|
LocalDate from = (fromDate != null) ? fromDate : to.minusMonths(1);
|
||||||
|
UiwsDataScope.Scope scope = UiwsDataScope.current();
|
||||||
|
String wid = blankToNull(writerId);
|
||||||
|
String pc = blankToNull(progressCd);
|
||||||
|
long total = mapper.countList(from, to, wid, pc, scope.all(), scope.ownerIds());
|
||||||
|
List<UiwsWorklog> rows = mapper.searchList(from, to, wid, pc, scope.all(), scope.ownerIds(), size, page * size);
|
||||||
|
return new WorklogPage(toListDtos(rows), total, page, size, totalPages(total, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 목록(달력형)
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<WorklogCalendarDto> calendar(String yearMonth, String writerId) {
|
||||||
|
YearMonth ym = parseYearMonth(yearMonth);
|
||||||
|
LocalDate from = ym.atDay(1);
|
||||||
|
LocalDate to = ym.atEndOfMonth();
|
||||||
|
UiwsDataScope.Scope scope = UiwsDataScope.current();
|
||||||
|
List<UiwsWorklog> rows = mapper.findByMonth(from, to, blankToNull(writerId), scope.all(), scope.ownerIds());
|
||||||
|
// 공휴일 마스터 미연동 → isHoliday=false. 근무상태 라벨은 코드값 노출(공통코드 미이식).
|
||||||
|
return rows.stream()
|
||||||
|
.map(w -> new WorklogCalendarDto(w.getWorkDate().toString(), w.getWorklogId(),
|
||||||
|
w.getWorkStatusCd(), false, null))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 조회 팝업
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<WorklogListDto> search(String keyword, String writerId) {
|
||||||
|
UiwsDataScope.Scope scope = UiwsDataScope.current();
|
||||||
|
List<UiwsWorklog> rows = mapper.searchPopup(blankToNull(keyword), blankToNull(writerId), scope.all(), scope.ownerIds());
|
||||||
|
return toListDtos(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 대시보드: 진행상태별 집계
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<ProgressSummaryDto> progressSummary(LocalDate fromDate, LocalDate toDate) {
|
||||||
|
LocalDate to = (toDate != null) ? toDate : LocalDate.now();
|
||||||
|
LocalDate from = (fromDate != null) ? fromDate : to.minusMonths(1);
|
||||||
|
UiwsDataScope.Scope scope = UiwsDataScope.current();
|
||||||
|
Map<String, Long> counts = new LinkedHashMap<>();
|
||||||
|
for (Map<String, Object> r : mapper.progressSummary(from, to, scope.all(), scope.ownerIds())) {
|
||||||
|
counts.put(str(r.get("progressCd")), toLong(r.get("cnt")));
|
||||||
|
}
|
||||||
|
// 코드 정의 순서(진행중→종료)로 0건 포함 반환.
|
||||||
|
List<ProgressSummaryDto> out = new ArrayList<>();
|
||||||
|
for (String code : List.of("ONGOING", "DONE")) {
|
||||||
|
out.add(new ProgressSummaryDto(code, PROGRESS_LABEL.getOrDefault(code, code), counts.getOrDefault(code, 0L)));
|
||||||
|
}
|
||||||
|
// 정의 외 코드값도 누락 없이 추가
|
||||||
|
counts.forEach((k, v) -> {
|
||||||
|
if (!"ONGOING".equals(k) && !"DONE".equals(k)) {
|
||||||
|
out.add(new ProgressSummaryDto(k, k, v));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 등록
|
||||||
|
@Transactional
|
||||||
|
public WorklogDetailDto create(WorklogSaveDto dto) {
|
||||||
|
String actor = UiwsCurrentUser.id();
|
||||||
|
UiwsWorklog w = new UiwsWorklog();
|
||||||
|
applyHeader(w, dto);
|
||||||
|
w.setWriterId((dto.writerId() != null && !dto.writerId().isBlank()) ? dto.writerId() : actor);
|
||||||
|
w.setCreatedBy(actor);
|
||||||
|
w.setCreatedAt(LocalDateTime.now());
|
||||||
|
mapper.insertWorklog(w);
|
||||||
|
|
||||||
|
List<WorklogDtlDto> toInsert = (dto.details() == null) ? List.of()
|
||||||
|
: dto.details().stream().filter(d -> !"DEL".equalsIgnoreCase(safeState(d.state()))).toList();
|
||||||
|
validateNoOverlap(toInsert);
|
||||||
|
for (WorklogDtlDto d : toInsert) {
|
||||||
|
mapper.insertDtl(newDtl(w.getWorklogId(), d, actor));
|
||||||
|
}
|
||||||
|
return detail(w.getWorklogId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 상세
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public WorklogDetailDto detail(Long worklogId) {
|
||||||
|
return toDetail(findWorklog(worklogId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 수정(시간대별 일괄, _state)
|
||||||
|
@Transactional
|
||||||
|
public WorklogDetailDto update(Long worklogId, WorklogSaveDto dto) {
|
||||||
|
String actor = UiwsCurrentUser.id();
|
||||||
|
UiwsWorklog w = findWorklog(worklogId);
|
||||||
|
applyHeader(w, dto);
|
||||||
|
if (dto.writerId() != null && !dto.writerId().isBlank()) {
|
||||||
|
w.setWriterId(dto.writerId());
|
||||||
|
}
|
||||||
|
w.setUpdatedBy(actor);
|
||||||
|
w.setUpdatedAt(LocalDateTime.now());
|
||||||
|
mapper.updateWorklog(w);
|
||||||
|
|
||||||
|
List<UiwsWorklogDtl> existing = mapper.findDtlByWorklogId(worklogId);
|
||||||
|
Map<Long, UiwsWorklogDtl> existingById = existing.stream()
|
||||||
|
.collect(Collectors.toMap(UiwsWorklogDtl::getDtlId, d -> d));
|
||||||
|
|
||||||
|
List<WorklogDtlDto> details = (dto.details() == null) ? List.of() : dto.details();
|
||||||
|
List<WorklogDtlDto> survivors = new ArrayList<>();
|
||||||
|
for (WorklogDtlDto d : details) {
|
||||||
|
String st = safeState(d.state());
|
||||||
|
if ("DEL".equalsIgnoreCase(st)) {
|
||||||
|
if (d.dtlId() != null && existingById.containsKey(d.dtlId())) {
|
||||||
|
mapper.deleteDtlById(d.dtlId());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ("MOD".equalsIgnoreCase(st) && d.dtlId() != null) {
|
||||||
|
UiwsWorklogDtl tgt = existingById.get(d.dtlId());
|
||||||
|
if (tgt == null) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.WORKLOG_NOT_FOUND, "수정 대상 상세를 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
applyDtl(tgt, d);
|
||||||
|
tgt.setUpdatedBy(actor);
|
||||||
|
tgt.setUpdatedAt(LocalDateTime.now());
|
||||||
|
mapper.updateDtl(tgt);
|
||||||
|
survivors.add(d);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// _state 미표기 + 기존 dtlId 보유 = 미변경 유지행 → INSERT 금지
|
||||||
|
if (d.dtlId() != null && existingById.containsKey(d.dtlId())) {
|
||||||
|
survivors.add(d);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// ADD 또는 진짜 신규 → INSERT
|
||||||
|
mapper.insertDtl(newDtl(worklogId, d, actor));
|
||||||
|
survivors.add(d);
|
||||||
|
}
|
||||||
|
validateNoOverlap(survivors);
|
||||||
|
return detail(worklogId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 삭제
|
||||||
|
@Transactional
|
||||||
|
public void delete(Long worklogId) {
|
||||||
|
if (!mapper.existsWorklogById(worklogId)) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.WORKLOG_NOT_FOUND);
|
||||||
|
}
|
||||||
|
// 상세/댓글은 DB ON DELETE CASCADE. 헤더 삭제로 일괄 제거.
|
||||||
|
mapper.deleteWorklogById(worklogId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 댓글 등록(+알림)
|
||||||
|
@Transactional
|
||||||
|
public WorklogCommentDto addComment(Long worklogId, CommentRequest req) {
|
||||||
|
String actor = UiwsCurrentUser.id();
|
||||||
|
UiwsWorklog w = findWorklog(worklogId);
|
||||||
|
assertCanComment();
|
||||||
|
|
||||||
|
UiwsWorklogCmt c = new UiwsWorklogCmt();
|
||||||
|
c.setWorklogId(worklogId);
|
||||||
|
c.setCmtContent(req.cmtContent());
|
||||||
|
c.setWriterId(actor);
|
||||||
|
c.setKakaoSentYn("N");
|
||||||
|
c.setConfirmYn("N");
|
||||||
|
c.setCreatedBy(actor);
|
||||||
|
c.setCreatedAt(LocalDateTime.now());
|
||||||
|
mapper.insertCmt(c);
|
||||||
|
|
||||||
|
// 알림: 일지 작성자에게 메일 폴백(외부 메신저 미사용).
|
||||||
|
String writerEmail = userEmail(w.getWriterId());
|
||||||
|
String subject = "[UIWS] 업무일지 새 댓글: " + w.getTitle();
|
||||||
|
String body = "업무일지에 새 댓글이 등록되었습니다.\n내용: " + req.cmtContent();
|
||||||
|
boolean sent = notifier.notifyComment(w.getWriterId(), writerEmail, subject, body);
|
||||||
|
if (sent) {
|
||||||
|
mapper.updateCmtKakaoSent(c.getCmtId());
|
||||||
|
c.setKakaoSentYn("Y");
|
||||||
|
}
|
||||||
|
|
||||||
|
String writerNm = userNames(List.of(actor)).getOrDefault(actor, actor);
|
||||||
|
return new WorklogCommentDto(c.getCmtId(), c.getCmtContent(), writerNm,
|
||||||
|
fmt(c.getCreatedAt()), c.getKakaoSentYn(), c.getConfirmYn());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 댓글 확인(작성자 전용)
|
||||||
|
@Transactional
|
||||||
|
public void confirmComment(Long cmtId) {
|
||||||
|
String actor = UiwsCurrentUser.id();
|
||||||
|
UiwsWorklogCmt c = mapper.findCmtById(cmtId);
|
||||||
|
if (c == null) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.WORKLOG_NOT_FOUND, "댓글을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
UiwsWorklog w = findWorklog(c.getWorklogId());
|
||||||
|
if (!w.getWriterId().equals(actor)) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.FORBIDDEN, "본인 업무일지의 댓글만 확인 처리할 수 있습니다.");
|
||||||
|
}
|
||||||
|
mapper.confirmCmt(cmtId, actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 내가 단 댓글 중 작성자 미확인 목록
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<UnconfirmedCommentDto> unconfirmedByMe() {
|
||||||
|
String me = UiwsCurrentUser.id();
|
||||||
|
List<UiwsWorklogCmt> rows = mapper.findUnconfirmedByWriter(me);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<Long> worklogIds = rows.stream().map(UiwsWorklogCmt::getWorklogId).distinct().toList();
|
||||||
|
Map<Long, UiwsWorklog> worklogs = mapper.findWorklogsByIds(worklogIds).stream()
|
||||||
|
.collect(Collectors.toMap(UiwsWorklog::getWorklogId, wl -> wl));
|
||||||
|
Map<String, String> writerNames = userNames(
|
||||||
|
worklogs.values().stream().map(UiwsWorklog::getWriterId).toList());
|
||||||
|
return rows.stream().map(c -> {
|
||||||
|
UiwsWorklog wl = worklogs.get(c.getWorklogId());
|
||||||
|
return new UnconfirmedCommentDto(c.getCmtId(), c.getWorklogId(),
|
||||||
|
wl != null ? wl.getTitle() : null,
|
||||||
|
wl != null ? writerNames.getOrDefault(wl.getWriterId(), wl.getWriterId()) : null,
|
||||||
|
c.getCmtContent(), fmt(c.getCreatedAt()));
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================== helpers
|
||||||
|
|
||||||
|
/** 댓글 권한(silo 완화): ERP RBAC MANAGER/ADMIN/CFO 만 작성 가능. */
|
||||||
|
private void assertCanComment() {
|
||||||
|
if (!UiwsCurrentUser.isManagerOrAbove()) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.FORBIDDEN, "댓글은 관리자/매니저 권한만 작성할 수 있습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<WorklogListDto> toListDtos(List<UiwsWorklog> rows) {
|
||||||
|
Map<Long, Long> cmtCounts = commentCounts(rows.stream().map(UiwsWorklog::getWorklogId).toList());
|
||||||
|
Map<String, String> writerNames = userNames(rows.stream().map(UiwsWorklog::getWriterId).toList());
|
||||||
|
return rows.stream()
|
||||||
|
.map(w -> new WorklogListDto(w.getWorklogId(), w.getTitle(),
|
||||||
|
writerNames.getOrDefault(w.getWriterId(), w.getWriterId()),
|
||||||
|
w.getWorkDate().toString(), w.getProgressCd(),
|
||||||
|
cmtCounts.getOrDefault(w.getWorklogId(), 0L)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private WorklogDetailDto toDetail(UiwsWorklog w) {
|
||||||
|
List<UiwsWorklogDtl> dtls = mapper.findDtlByWorklogId(w.getWorklogId());
|
||||||
|
List<UiwsWorklogCmt> cmts = mapper.findCmtByWorklogId(w.getWorklogId());
|
||||||
|
List<String> nameIds = new ArrayList<>();
|
||||||
|
nameIds.add(w.getWriterId());
|
||||||
|
cmts.forEach(c -> nameIds.add(c.getWriterId()));
|
||||||
|
Map<String, String> writerNames = userNames(nameIds);
|
||||||
|
|
||||||
|
List<WorklogDtlDto> details = dtls.stream().map(WorklogDtlDto::from).toList();
|
||||||
|
List<WorklogCommentDto> comments = cmts.stream()
|
||||||
|
.map(c -> new WorklogCommentDto(c.getCmtId(), c.getCmtContent(),
|
||||||
|
writerNames.getOrDefault(c.getWriterId(), c.getWriterId()),
|
||||||
|
fmt(c.getCreatedAt()), c.getKakaoSentYn(), c.getConfirmYn()))
|
||||||
|
.toList();
|
||||||
|
return new WorklogDetailDto(w.getWorklogId(), w.getTitle(), w.getWriterId(),
|
||||||
|
writerNames.getOrDefault(w.getWriterId(), w.getWriterId()), w.getWorkDate().toString(),
|
||||||
|
w.getWorkStatusCd(), w.getProgressCd(), w.getRepeatYn(),
|
||||||
|
w.getRepeatStartDate() != null ? w.getRepeatStartDate().toString() : null,
|
||||||
|
w.getRepeatEndDate() != null ? w.getRepeatEndDate().toString() : null,
|
||||||
|
details, comments);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyHeader(UiwsWorklog w, WorklogSaveDto dto) {
|
||||||
|
w.setTitle(dto.title());
|
||||||
|
w.setWorkDate(parseDate(dto.workDate()));
|
||||||
|
w.setWorkStatusCd(dto.workStatusCd());
|
||||||
|
w.setProgressCd((dto.progressCd() != null && !dto.progressCd().isBlank()) ? dto.progressCd() : DEFAULT_PROGRESS);
|
||||||
|
w.setRepeatYn((dto.repeatYn() != null && !dto.repeatYn().isBlank()) ? dto.repeatYn() : "N");
|
||||||
|
w.setRepeatStartDate(parseDateNullable(dto.repeatStartDate()));
|
||||||
|
w.setRepeatEndDate(parseDateNullable(dto.repeatEndDate()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private UiwsWorklogDtl newDtl(Long worklogId, WorklogDtlDto d, String actor) {
|
||||||
|
UiwsWorklogDtl e = new UiwsWorklogDtl();
|
||||||
|
e.setWorklogId(worklogId);
|
||||||
|
applyDtl(e, d);
|
||||||
|
e.setCreatedBy(actor);
|
||||||
|
e.setCreatedAt(LocalDateTime.now());
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyDtl(UiwsWorklogDtl e, WorklogDtlDto d) {
|
||||||
|
if (d.startHour() == null || d.endHour() == null
|
||||||
|
|| d.startHour() < 0 || d.endHour() > 24 || d.endHour() < d.startHour()) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.WORKLOG_TIME_INVALID);
|
||||||
|
}
|
||||||
|
e.setStartHour(d.startHour());
|
||||||
|
e.setEndHour(d.endHour());
|
||||||
|
e.setWorkTypeCd(d.workTypeCd());
|
||||||
|
e.setCompanyId(d.companyId());
|
||||||
|
e.setWorkContent(d.workContent());
|
||||||
|
e.setIssueContent(d.issueContent());
|
||||||
|
e.setSortOrd(d.sortOrd() != null ? d.sortOrd() : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateNoOverlap(List<WorklogDtlDto> details) {
|
||||||
|
List<WorklogDtlDto> rows = details.stream()
|
||||||
|
.filter(d -> d.startHour() != null && d.endHour() != null)
|
||||||
|
.sorted((a, b) -> Integer.compare(a.startHour(), b.startHour()))
|
||||||
|
.toList();
|
||||||
|
for (int i = 1; i < rows.size(); i++) {
|
||||||
|
WorklogDtlDto prev = rows.get(i - 1);
|
||||||
|
WorklogDtlDto cur = rows.get(i);
|
||||||
|
if (cur.startHour() < prev.endHour()) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.WORKLOG_TIME_OVERLAP);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private UiwsWorklog findWorklog(Long id) {
|
||||||
|
UiwsWorklog w = mapper.findWorklogById(id);
|
||||||
|
if (w == null) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.WORKLOG_NOT_FOUND);
|
||||||
|
}
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<Long, Long> commentCounts(List<Long> worklogIds) {
|
||||||
|
if (worklogIds == null || worklogIds.isEmpty()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
Map<Long, Long> result = new LinkedHashMap<>();
|
||||||
|
for (Map<String, Object> cc : mapper.countCommentsByWorklogIds(worklogIds)) {
|
||||||
|
result.put(toLong(cc.get("worklogId")), toLong(cc.get("cnt")));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> userNames(List<String> userIds) {
|
||||||
|
List<String> ids = userIds.stream().filter(s -> s != null && !s.isBlank()).distinct().toList();
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
return mapper.findUserNames(ids).stream()
|
||||||
|
.collect(Collectors.toMap(r -> r.get("username"), r -> r.get("fullName"), (a, b) -> a));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** writerId(username) → email (댓글 알림 메일 발송용). 미보유 시 null. */
|
||||||
|
private String userEmail(String writerId) {
|
||||||
|
if (writerId == null || writerId.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return mapper.findUserEmail(writerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int totalPages(long total, int size) {
|
||||||
|
return size <= 0 ? 0 : (int) ((total + size - 1) / size);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String blankToNull(String s) {
|
||||||
|
return (s == null || s.isBlank()) ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safeState(String s) {
|
||||||
|
return s == null ? "" : s.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String str(Object o) {
|
||||||
|
return o == null ? "" : o.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long toLong(Object o) {
|
||||||
|
if (o == null) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
return (o instanceof Number n) ? n.longValue() : Long.parseLong(o.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String fmt(LocalDateTime dt) {
|
||||||
|
return dt != null ? dt.format(DT) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalDate parseDate(String s) {
|
||||||
|
try {
|
||||||
|
return LocalDate.parse(s);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "날짜 형식이 올바르지 않습니다(yyyy-MM-dd).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalDate parseDateNullable(String s) {
|
||||||
|
return (s == null || s.isBlank()) ? null : parseDate(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static YearMonth parseYearMonth(String s) {
|
||||||
|
try {
|
||||||
|
if (s == null || s.isBlank()) {
|
||||||
|
return YearMonth.now();
|
||||||
|
}
|
||||||
|
return YearMonth.parse(s);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "yearMonth 형식이 올바르지 않습니다(yyyy-MM).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,6 +12,16 @@ spring:
|
|||||||
hikari:
|
hikari:
|
||||||
maximum-pool-size: 3
|
maximum-pool-size: 3
|
||||||
connection-timeout: 30000
|
connection-timeout: 30000
|
||||||
|
# UIWS 이식: 부팅 시 schema.sql + 91_uiws_port.sql 멱등 적용
|
||||||
|
# (둘 다 IF NOT EXISTS / ADD COLUMN IF NOT EXISTS / ON CONFLICT DO NOTHING — 재실행 안전).
|
||||||
|
# 기존 deploy 의 psql -f schema.sql 와 중복돼도 멱등. 91_uiws_port 는 deploy 미적용분(uiws 테이블/2FA 컬럼) 보강.
|
||||||
|
sql:
|
||||||
|
init:
|
||||||
|
mode: always
|
||||||
|
continue-on-error: true
|
||||||
|
schema-locations:
|
||||||
|
- classpath:db/schema.sql
|
||||||
|
- classpath:db/91_uiws_port.sql
|
||||||
web:
|
web:
|
||||||
resources:
|
resources:
|
||||||
static-locations: classpath:/static/
|
static-locations: classpath:/static/
|
||||||
@ -19,7 +29,8 @@ spring:
|
|||||||
throw-exception-if-no-handler-found: true
|
throw-exception-if-no-handler-found: true
|
||||||
|
|
||||||
mybatis:
|
mybatis:
|
||||||
mapper-locations: classpath:mapper/*.xml
|
# UIWS 이식: mapper/uiws/*.xml 포함을 위해 재귀 글로브(**)로 확장(기존 mapper/*.xml 포함).
|
||||||
|
mapper-locations: classpath:mapper/**/*.xml
|
||||||
configuration:
|
configuration:
|
||||||
map-underscore-to-camel-case: true
|
map-underscore-to-camel-case: true
|
||||||
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
|
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
|
||||||
|
|||||||
243
backend/src/main/resources/db/91_uiws_port.sql
Normal file
243
backend/src/main/resources/db/91_uiws_port.sql
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- UIWS 업무 테이블 이식 (ESN) — 2026-06-20, uiws-schema-porter
|
||||||
|
-- 계획서: .claude/agents/_workspace/uiws_port_plan.md (schema-porter 트랙 6.1)
|
||||||
|
-- 원본: workspace/uiws/db/{03_worklog,04_schedule,05_message,02_core(2FA)}.sql
|
||||||
|
--
|
||||||
|
-- 네임스페이스 격리: UIWS TB_* → 소문자 tb_uiws_ 프리픽스 (기존 tb_audit_log 등과 충돌 회피).
|
||||||
|
-- 멱등: 전부 CREATE TABLE IF NOT EXISTS / ADD COLUMN IF NOT EXISTS / ON CONFLICT DO NOTHING.
|
||||||
|
-- mode:always 재실행 시 완전 멱등(기존 schema.sql·시드 무영향).
|
||||||
|
-- FK 정책: tb_uiws_* 내부 참조만 물리 FK. 외부 user/code/company/dept FK는 논리참조로 완화
|
||||||
|
-- (UIWS는 VARCHAR USER_ID 키 / ESN esn_user PK는 BIGSERIAL — 키 체계 불일치 → 컬럼만 유지).
|
||||||
|
-- 코드값(WORK_STATUS_CD/WORK_TYPE_CD/RCV_TYPE 등): TB_CODE 미이식 → 일반 컬럼 + CHECK만 유지.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
SET client_encoding = 'UTF8';
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- [worklog] tb_uiws_worklog — 업무일지 헤더 (원본 TB_WORKLOG)
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS tb_uiws_worklog (
|
||||||
|
worklog_id BIGINT GENERATED ALWAYS AS IDENTITY,
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
writer_id VARCHAR(20) NOT NULL, -- 논리참조(UIWS USER_ID)
|
||||||
|
work_date DATE NOT NULL,
|
||||||
|
work_status_cd VARCHAR(30) NOT NULL, -- WORK_STATUS 코드값(논리)
|
||||||
|
progress_cd VARCHAR(30) NOT NULL DEFAULT 'ONGOING',
|
||||||
|
repeat_yn CHAR(1) NOT NULL DEFAULT 'N',
|
||||||
|
repeat_start_date DATE,
|
||||||
|
repeat_end_date DATE,
|
||||||
|
created_by VARCHAR(20) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
updated_by VARCHAR(20),
|
||||||
|
updated_at TIMESTAMP,
|
||||||
|
CONSTRAINT pk_tb_uiws_worklog PRIMARY KEY (worklog_id),
|
||||||
|
CONSTRAINT ck_uiws_worklog_progress CHECK (progress_cd IN ('ONGOING','DONE')),
|
||||||
|
CONSTRAINT ck_uiws_worklog_repeat_yn CHECK (repeat_yn IN ('Y','N'))
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE tb_uiws_worklog IS 'UIWS 이식: 업무일지 헤더 (근무일자/근무상태/반복)';
|
||||||
|
|
||||||
|
-- tb_uiws_worklog_dtl — 시간대별 상세 (통계 집계 원천, 원본 TB_WORKLOG_DTL)
|
||||||
|
CREATE TABLE IF NOT EXISTS tb_uiws_worklog_dtl (
|
||||||
|
dtl_id BIGINT GENERATED ALWAYS AS IDENTITY,
|
||||||
|
worklog_id BIGINT NOT NULL,
|
||||||
|
start_hour INT NOT NULL,
|
||||||
|
end_hour INT NOT NULL,
|
||||||
|
work_type_cd VARCHAR(30) NOT NULL, -- WORK_TYPE 코드값(논리)
|
||||||
|
company_id VARCHAR(20) NOT NULL, -- 논리참조(UIWS COMPANY_ID)
|
||||||
|
work_content TEXT,
|
||||||
|
issue_content TEXT,
|
||||||
|
sort_ord INT DEFAULT 0,
|
||||||
|
created_by VARCHAR(20) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
updated_by VARCHAR(20),
|
||||||
|
updated_at TIMESTAMP,
|
||||||
|
CONSTRAINT pk_tb_uiws_worklog_dtl PRIMARY KEY (dtl_id),
|
||||||
|
CONSTRAINT fk_uiws_dtl_worklog FOREIGN KEY (worklog_id)
|
||||||
|
REFERENCES tb_uiws_worklog (worklog_id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT ck_uiws_dtl_start_hour CHECK (start_hour BETWEEN 0 AND 24),
|
||||||
|
CONSTRAINT ck_uiws_dtl_end_hour CHECK (end_hour BETWEEN 0 AND 24),
|
||||||
|
CONSTRAINT ck_uiws_dtl_hour_order CHECK (end_hour >= start_hour)
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE tb_uiws_worklog_dtl IS 'UIWS 이식: 업무일지 시간대별 상세 (헤더 삭제 시 CASCADE)';
|
||||||
|
|
||||||
|
-- tb_uiws_worklog_cmt — 댓글 (원본 TB_WORKLOG_CMT)
|
||||||
|
CREATE TABLE IF NOT EXISTS tb_uiws_worklog_cmt (
|
||||||
|
cmt_id BIGINT GENERATED ALWAYS AS IDENTITY,
|
||||||
|
worklog_id BIGINT NOT NULL,
|
||||||
|
cmt_content TEXT NOT NULL,
|
||||||
|
writer_id VARCHAR(20) NOT NULL, -- 논리참조
|
||||||
|
kakao_sent_yn CHAR(1) NOT NULL DEFAULT 'N',
|
||||||
|
confirm_yn CHAR(1) NOT NULL DEFAULT 'N',
|
||||||
|
created_by VARCHAR(20) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
updated_by VARCHAR(20),
|
||||||
|
updated_at TIMESTAMP,
|
||||||
|
CONSTRAINT pk_tb_uiws_worklog_cmt PRIMARY KEY (cmt_id),
|
||||||
|
CONSTRAINT fk_uiws_cmt_worklog FOREIGN KEY (worklog_id)
|
||||||
|
REFERENCES tb_uiws_worklog (worklog_id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT ck_uiws_cmt_confirm_yn CHECK (confirm_yn IN ('Y','N')),
|
||||||
|
CONSTRAINT ck_uiws_cmt_kakao_yn CHECK (kakao_sent_yn IN ('Y','N'))
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE tb_uiws_worklog_cmt IS 'UIWS 이식: 업무일지 댓글 (등록 시 카카오 알림톡)';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_uiws_worklog_date_writer ON tb_uiws_worklog (work_date, writer_id, work_status_cd);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_uiws_worklog_writer ON tb_uiws_worklog (writer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_uiws_dtl_type_company ON tb_uiws_worklog_dtl (work_type_cd, company_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_uiws_dtl_worklog ON tb_uiws_worklog_dtl (worklog_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_uiws_dtl_company ON tb_uiws_worklog_dtl (company_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_uiws_cmt_worklog ON tb_uiws_worklog_cmt (worklog_id);
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- [schedule] tb_uiws_schedule — 일정 개인/부서 (원본 TB_SCHEDULE)
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS tb_uiws_schedule (
|
||||||
|
schedule_id BIGINT GENERATED ALWAYS AS IDENTITY,
|
||||||
|
sche_type VARCHAR(10) NOT NULL,
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
sche_gubun_cd VARCHAR(30),
|
||||||
|
importance_cd VARCHAR(30),
|
||||||
|
start_dt TIMESTAMP NOT NULL,
|
||||||
|
end_dt TIMESTAMP NOT NULL,
|
||||||
|
content TEXT,
|
||||||
|
owner_id VARCHAR(20) NOT NULL, -- 논리참조
|
||||||
|
dept_id VARCHAR(20), -- 논리참조
|
||||||
|
charger_id VARCHAR(20), -- 논리참조
|
||||||
|
created_by VARCHAR(20) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
updated_by VARCHAR(20),
|
||||||
|
updated_at TIMESTAMP,
|
||||||
|
CONSTRAINT pk_tb_uiws_schedule PRIMARY KEY (schedule_id),
|
||||||
|
CONSTRAINT ck_uiws_sche_type CHECK (sche_type IN ('PERSONAL','DEPT')),
|
||||||
|
CONSTRAINT ck_uiws_sche_dt_order CHECK (end_dt >= start_dt)
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE tb_uiws_schedule IS 'UIWS 이식: 일정(개인 PERSONAL / 부서 DEPT)';
|
||||||
|
|
||||||
|
-- tb_uiws_diary — 일지(일정 연계 선택, 원본 TB_DIARY)
|
||||||
|
CREATE TABLE IF NOT EXISTS tb_uiws_diary (
|
||||||
|
diary_id BIGINT GENERATED ALWAYS AS IDENTITY,
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
content TEXT,
|
||||||
|
schedule_id BIGINT,
|
||||||
|
writer_id VARCHAR(20) NOT NULL, -- 논리참조
|
||||||
|
diary_date DATE,
|
||||||
|
created_by VARCHAR(20) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
updated_by VARCHAR(20),
|
||||||
|
updated_at TIMESTAMP,
|
||||||
|
CONSTRAINT pk_tb_uiws_diary PRIMARY KEY (diary_id),
|
||||||
|
CONSTRAINT fk_uiws_diary_sche FOREIGN KEY (schedule_id)
|
||||||
|
REFERENCES tb_uiws_schedule (schedule_id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE tb_uiws_diary IS 'UIWS 이식: 일지 (일정 연계 선택, 일정 삭제 시 연계 해제)';
|
||||||
|
|
||||||
|
-- tb_uiws_attach — 첨부파일 폴리모픽 (원본 TB_ATTACH, 물리 FK 미적용)
|
||||||
|
CREATE TABLE IF NOT EXISTS tb_uiws_attach (
|
||||||
|
attach_id BIGINT GENERATED ALWAYS AS IDENTITY,
|
||||||
|
ref_type VARCHAR(20) NOT NULL,
|
||||||
|
ref_id BIGINT NOT NULL,
|
||||||
|
file_nm VARCHAR(255) NOT NULL,
|
||||||
|
file_path VARCHAR(500) NOT NULL,
|
||||||
|
file_size BIGINT,
|
||||||
|
created_by VARCHAR(20) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
updated_by VARCHAR(20),
|
||||||
|
updated_at TIMESTAMP,
|
||||||
|
CONSTRAINT pk_tb_uiws_attach PRIMARY KEY (attach_id),
|
||||||
|
CONSTRAINT ck_uiws_attach_ref_type CHECK (ref_type IN ('SCHEDULE','DIARY')),
|
||||||
|
CONSTRAINT ck_uiws_attach_size CHECK (file_size IS NULL OR file_size >= 0)
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE tb_uiws_attach IS 'UIWS 이식: 첨부파일 (폴리모픽 REF_TYPE=SCHEDULE/DIARY)';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_uiws_sche_dt_range ON tb_uiws_schedule (start_dt, end_dt, sche_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_uiws_sche_owner ON tb_uiws_schedule (owner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_uiws_sche_dept ON tb_uiws_schedule (dept_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_uiws_diary_sche ON tb_uiws_diary (schedule_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_uiws_diary_writer ON tb_uiws_diary (writer_id, diary_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_uiws_attach_ref ON tb_uiws_attach (ref_type, ref_id);
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- [message] tb_uiws_message — 쪽지 헤더 (원본 TB_MESSAGE)
|
||||||
|
-- REF_WORKLOG_ID → tb_uiws_worklog (내부 FK 유지), REPLY_TO_ID → 자기참조.
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS tb_uiws_message (
|
||||||
|
message_id BIGINT GENERATED ALWAYS AS IDENTITY,
|
||||||
|
sender_id VARCHAR(20) NOT NULL, -- 논리참조
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
ref_worklog_id BIGINT,
|
||||||
|
reply_to_id BIGINT,
|
||||||
|
sent_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
sender_del_yn CHAR(1) NOT NULL DEFAULT 'N',
|
||||||
|
created_by VARCHAR(20) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
updated_by VARCHAR(20),
|
||||||
|
updated_at TIMESTAMP,
|
||||||
|
CONSTRAINT pk_tb_uiws_message PRIMARY KEY (message_id),
|
||||||
|
CONSTRAINT fk_uiws_msg_worklog FOREIGN KEY (ref_worklog_id)
|
||||||
|
REFERENCES tb_uiws_worklog (worklog_id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_uiws_msg_reply FOREIGN KEY (reply_to_id)
|
||||||
|
REFERENCES tb_uiws_message (message_id),
|
||||||
|
CONSTRAINT ck_uiws_msg_sender_del_yn CHECK (sender_del_yn IN ('Y','N'))
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE tb_uiws_message IS 'UIWS 이식: 쪽지 헤더 (참조 업무일지/답장 원본 자기참조)';
|
||||||
|
|
||||||
|
-- tb_uiws_message_rcv — 수신자 (원본 TB_MESSAGE_RCV)
|
||||||
|
CREATE TABLE IF NOT EXISTS tb_uiws_message_rcv (
|
||||||
|
rcv_id BIGINT GENERATED ALWAYS AS IDENTITY,
|
||||||
|
message_id BIGINT NOT NULL,
|
||||||
|
receiver_id VARCHAR(20) NOT NULL, -- 논리참조
|
||||||
|
rcv_type VARCHAR(10) NOT NULL, -- MSG_RCV_TYPE 코드값(논리)
|
||||||
|
read_yn CHAR(1) NOT NULL DEFAULT 'N',
|
||||||
|
read_at TIMESTAMP,
|
||||||
|
receiver_del_yn CHAR(1) NOT NULL DEFAULT 'N',
|
||||||
|
created_by VARCHAR(20) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
updated_by VARCHAR(20),
|
||||||
|
updated_at TIMESTAMP,
|
||||||
|
CONSTRAINT pk_tb_uiws_message_rcv PRIMARY KEY (rcv_id),
|
||||||
|
CONSTRAINT fk_uiws_rcv_message FOREIGN KEY (message_id)
|
||||||
|
REFERENCES tb_uiws_message (message_id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT uq_uiws_rcv_msg_receiver UNIQUE (message_id, receiver_id),
|
||||||
|
CONSTRAINT ck_uiws_rcv_type CHECK (rcv_type IN ('RECV','REF')),
|
||||||
|
CONSTRAINT ck_uiws_rcv_read_yn CHECK (read_yn IN ('Y','N')),
|
||||||
|
CONSTRAINT ck_uiws_rcv_del_yn CHECK (receiver_del_yn IN ('Y','N'))
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE tb_uiws_message_rcv IS 'UIWS 이식: 쪽지 수신자(수신 RECV/참조 REF, 개봉여부)';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_uiws_rcv_receiver_read ON tb_uiws_message_rcv (receiver_id, read_yn);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_uiws_rcv_message ON tb_uiws_message_rcv (message_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_uiws_msg_sender ON tb_uiws_message (sender_id, sent_at);
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- [2FA] tb_uiws_login_verify — 로그인 2차 검증 코드 (원본 TB_LOGIN_VERIFY)
|
||||||
|
-- USER_ID 는 논리참조(외부 user FK 미적용 — 키 체계 불일치).
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS tb_uiws_login_verify (
|
||||||
|
verify_id BIGINT GENERATED ALWAYS AS IDENTITY,
|
||||||
|
user_id VARCHAR(50) NOT NULL, -- ESN username 논리참조
|
||||||
|
verify_method VARCHAR(20) NOT NULL DEFAULT 'EMAIL',
|
||||||
|
verify_code VARCHAR(10) NOT NULL,
|
||||||
|
expire_at TIMESTAMP NOT NULL,
|
||||||
|
verified_yn CHAR(1) NOT NULL DEFAULT 'N',
|
||||||
|
created_by VARCHAR(20) NOT NULL DEFAULT 'SYSTEM',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
updated_by VARCHAR(20),
|
||||||
|
updated_at TIMESTAMP,
|
||||||
|
CONSTRAINT pk_tb_uiws_login_verify PRIMARY KEY (verify_id),
|
||||||
|
CONSTRAINT ck_uiws_verify_yn CHECK (verified_yn IN ('Y','N')),
|
||||||
|
CONSTRAINT ck_uiws_verify_method CHECK (verify_method IN ('EMAIL','OTP'))
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE tb_uiws_login_verify IS 'UIWS 이식(2FA): 로그인 2차 검증 코드(이메일/OTP, 만료)';
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_uiws_verify_user ON tb_uiws_login_verify (user_id, verified_yn);
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- [2FA] 기존 user 테이블 컬럼 보강 (DROP/재정의 금지 — ADD COLUMN IF NOT EXISTS 멱등)
|
||||||
|
-- 이메일 인증코드·만료시각, 실패 카운트(기본 0), 잠금(기본 false), OTP 시크릿.
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
ALTER TABLE esn_user ADD COLUMN IF NOT EXISTS email_verify_code VARCHAR(10);
|
||||||
|
ALTER TABLE esn_user ADD COLUMN IF NOT EXISTS email_verify_expire TIMESTAMP;
|
||||||
|
ALTER TABLE esn_user ADD COLUMN IF NOT EXISTS login_fail_count INT DEFAULT 0;
|
||||||
|
ALTER TABLE esn_user ADD COLUMN IF NOT EXISTS locked BOOLEAN DEFAULT false;
|
||||||
|
ALTER TABLE esn_user ADD COLUMN IF NOT EXISTS otp_secret VARCHAR(255);
|
||||||
|
|
||||||
|
-- end 91_uiws_port.sql
|
||||||
@ -14,11 +14,18 @@
|
|||||||
<result property="active" column="is_active"/>
|
<result property="active" column="is_active"/>
|
||||||
<result property="lastLoginAt" column="last_login_at"/>
|
<result property="lastLoginAt" column="last_login_at"/>
|
||||||
<result property="createdAt" column="created_at"/>
|
<result property="createdAt" column="created_at"/>
|
||||||
|
<!-- UIWS 2FA 이식 컬럼 (91_uiws_port.sql ALTER). -->
|
||||||
|
<result property="emailVerifyCode" column="email_verify_code"/>
|
||||||
|
<result property="emailVerifyExpire" column="email_verify_expire"/>
|
||||||
|
<result property="loginFailCount" column="login_fail_count"/>
|
||||||
|
<result property="locked" column="locked"/>
|
||||||
|
<result property="otpSecret" column="otp_secret"/>
|
||||||
</resultMap>
|
</resultMap>
|
||||||
|
|
||||||
<select id="findByUsername" resultMap="userMap">
|
<select id="findByUsername" resultMap="userMap">
|
||||||
SELECT id, tenant_code, username, password_hash, role, email, phone,
|
SELECT id, tenant_code, username, password_hash, role, email, phone,
|
||||||
is_active, last_login_at, created_at
|
is_active, last_login_at, created_at,
|
||||||
|
email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret
|
||||||
FROM esn_user
|
FROM esn_user
|
||||||
WHERE username = #{username}
|
WHERE username = #{username}
|
||||||
</select>
|
</select>
|
||||||
@ -27,4 +34,31 @@
|
|||||||
UPDATE esn_user SET last_login_at = NOW() WHERE username = #{username}
|
UPDATE esn_user SET last_login_at = NOW() WHERE username = #{username}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
|
<!-- ── UIWS 2FA 이식: 실패 카운트/잠금/인증코드 갱신 (멱등 UPDATE) ─────────────── -->
|
||||||
|
|
||||||
|
<update id="resetLoginFail">
|
||||||
|
UPDATE esn_user SET login_fail_count = 0 WHERE username = #{username}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="incrementLoginFail">
|
||||||
|
UPDATE esn_user
|
||||||
|
SET login_fail_count = COALESCE(login_fail_count, 0) + 1,
|
||||||
|
locked = (COALESCE(login_fail_count, 0) + 1 >= #{maxFail})
|
||||||
|
WHERE username = #{username}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="saveEmailCode">
|
||||||
|
UPDATE esn_user
|
||||||
|
SET email_verify_code = #{code}, email_verify_expire = #{expire}, login_fail_count = 0
|
||||||
|
WHERE username = #{username}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="clearEmailCode">
|
||||||
|
UPDATE esn_user SET email_verify_code = NULL, email_verify_expire = NULL WHERE username = #{username}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="unlock">
|
||||||
|
UPDATE esn_user SET locked = false, login_fail_count = 0 WHERE username = #{username}
|
||||||
|
</update>
|
||||||
|
|
||||||
</mapper>
|
</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.esn.uiws.auth.LoginVerifyMapper">
|
||||||
|
|
||||||
|
<insert id="insert" useGeneratedKeys="true" keyProperty="verifyId" keyColumn="verify_id"
|
||||||
|
parameterType="com.zioinfo.esn.uiws.auth.UiwsLoginVerify">
|
||||||
|
INSERT INTO tb_uiws_login_verify
|
||||||
|
(user_id, verify_method, verify_code, expire_at, verified_yn, created_by, created_at)
|
||||||
|
VALUES
|
||||||
|
(#{userId}, #{verifyMethod}, #{verifyCode}, #{expireAt}, #{verifiedYn}, #{createdBy}, #{createdAt})
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
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.esn.uiws.message.mapper.MessageMapper">
|
||||||
|
|
||||||
|
<!-- ============================ message 헤더 ============================ -->
|
||||||
|
<insert id="insertMessage" useGeneratedKeys="true" keyProperty="messageId" keyColumn="message_id"
|
||||||
|
parameterType="com.zioinfo.esn.uiws.message.model.UiwsMessage">
|
||||||
|
INSERT INTO tb_uiws_message
|
||||||
|
(sender_id, title, content, ref_worklog_id, reply_to_id, sent_at, sender_del_yn, created_by, created_at)
|
||||||
|
VALUES
|
||||||
|
(#{senderId}, #{title}, #{content}, #{refWorklogId}, #{replyToId}, #{sentAt}, #{senderDelYn}, #{createdBy}, #{createdAt})
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<select id="findMessageById" resultType="com.zioinfo.esn.uiws.message.model.UiwsMessage">
|
||||||
|
SELECT * FROM tb_uiws_message WHERE message_id = #{messageId}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="existsMessageById" resultType="boolean">
|
||||||
|
SELECT EXISTS(SELECT 1 FROM tb_uiws_message WHERE message_id = #{messageId})
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="searchSent" resultType="com.zioinfo.esn.uiws.message.model.UiwsMessage">
|
||||||
|
SELECT * FROM tb_uiws_message
|
||||||
|
WHERE sender_id = #{senderId}
|
||||||
|
AND sender_del_yn = 'N'
|
||||||
|
AND sent_at BETWEEN #{from} AND #{to}
|
||||||
|
<if test="titleKeyword != null">
|
||||||
|
AND title LIKE '%' || #{titleKeyword} || '%'
|
||||||
|
</if>
|
||||||
|
ORDER BY sent_at DESC, message_id DESC
|
||||||
|
LIMIT #{limit} OFFSET #{offset}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="countSent" resultType="long">
|
||||||
|
SELECT COUNT(*) FROM tb_uiws_message
|
||||||
|
WHERE sender_id = #{senderId}
|
||||||
|
AND sender_del_yn = 'N'
|
||||||
|
AND sent_at BETWEEN #{from} AND #{to}
|
||||||
|
<if test="titleKeyword != null">
|
||||||
|
AND title LIKE '%' || #{titleKeyword} || '%'
|
||||||
|
</if>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<update id="softDeleteSent">
|
||||||
|
UPDATE tb_uiws_message
|
||||||
|
SET sender_del_yn = 'Y', updated_by = #{actor}, updated_at = now()
|
||||||
|
WHERE sender_id = #{senderId}
|
||||||
|
AND message_id IN
|
||||||
|
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!-- ============================ 수신자 ============================ -->
|
||||||
|
<insert id="insertRcv" useGeneratedKeys="true" keyProperty="rcvId" keyColumn="rcv_id"
|
||||||
|
parameterType="com.zioinfo.esn.uiws.message.model.UiwsMessageRcv">
|
||||||
|
INSERT INTO tb_uiws_message_rcv
|
||||||
|
(message_id, receiver_id, rcv_type, read_yn, receiver_del_yn, created_by, created_at)
|
||||||
|
VALUES
|
||||||
|
(#{messageId}, #{receiverId}, #{rcvType}, #{readYn}, #{receiverDelYn}, #{createdBy}, #{createdAt})
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<select id="findRcvByMessageId" resultType="com.zioinfo.esn.uiws.message.model.UiwsMessageRcv">
|
||||||
|
SELECT * FROM tb_uiws_message_rcv WHERE message_id = #{messageId} ORDER BY rcv_id ASC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="findRcvByMessageIds" resultType="com.zioinfo.esn.uiws.message.model.UiwsMessageRcv">
|
||||||
|
SELECT * FROM tb_uiws_message_rcv
|
||||||
|
WHERE message_id IN
|
||||||
|
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||||
|
ORDER BY rcv_id ASC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="findRcvByMessageAndReceiver" resultType="com.zioinfo.esn.uiws.message.model.UiwsMessageRcv">
|
||||||
|
SELECT * FROM tb_uiws_message_rcv
|
||||||
|
WHERE message_id = #{messageId} AND receiver_id = #{receiverId}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="countUnread" resultType="long">
|
||||||
|
SELECT COUNT(*) FROM tb_uiws_message_rcv
|
||||||
|
WHERE receiver_id = #{receiverId} AND read_yn = 'N' AND receiver_del_yn = 'N'
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<update id="markRead">
|
||||||
|
UPDATE tb_uiws_message_rcv
|
||||||
|
SET read_yn = 'Y', read_at = #{readAt}, updated_by = #{actor}, updated_at = now()
|
||||||
|
WHERE rcv_id = #{rcvId}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="softDeleteReceived">
|
||||||
|
UPDATE tb_uiws_message_rcv
|
||||||
|
SET receiver_del_yn = 'Y', updated_by = #{actor}, updated_at = now()
|
||||||
|
WHERE receiver_id = #{receiverId}
|
||||||
|
AND message_id IN
|
||||||
|
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<select id="searchReceived" resultType="map">
|
||||||
|
SELECT m.message_id AS "messageId",
|
||||||
|
m.title AS "title",
|
||||||
|
m.sender_id AS "senderId",
|
||||||
|
m.sent_at AS "sentAt",
|
||||||
|
r.read_yn AS "readYn"
|
||||||
|
FROM tb_uiws_message_rcv r
|
||||||
|
JOIN tb_uiws_message m ON m.message_id = r.message_id
|
||||||
|
WHERE r.receiver_id = #{receiverId}
|
||||||
|
AND r.receiver_del_yn = 'N'
|
||||||
|
AND m.sent_at BETWEEN #{from} AND #{to}
|
||||||
|
<if test="titleKeyword != null">
|
||||||
|
AND m.title LIKE '%' || #{titleKeyword} || '%'
|
||||||
|
</if>
|
||||||
|
ORDER BY m.sent_at DESC, m.message_id DESC
|
||||||
|
LIMIT #{limit} OFFSET #{offset}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="countReceived" resultType="long">
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM tb_uiws_message_rcv r
|
||||||
|
JOIN tb_uiws_message m ON m.message_id = r.message_id
|
||||||
|
WHERE r.receiver_id = #{receiverId}
|
||||||
|
AND r.receiver_del_yn = 'N'
|
||||||
|
AND m.sent_at BETWEEN #{from} AND #{to}
|
||||||
|
<if test="titleKeyword != null">
|
||||||
|
AND m.title LIKE '%' || #{titleKeyword} || '%'
|
||||||
|
</if>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- ============================ 사용자명 라벨(코어 user 재사용) ============================ -->
|
||||||
|
<select id="findUserNames" resultType="map">
|
||||||
|
SELECT username AS "username", username AS "fullName"
|
||||||
|
FROM esn_user
|
||||||
|
WHERE username IN
|
||||||
|
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
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.esn.uiws.schedule.mapper.ScheduleMapper">
|
||||||
|
|
||||||
|
<!-- ===================== schedule ===================== -->
|
||||||
|
<insert id="insertSchedule" useGeneratedKeys="true" keyProperty="scheduleId" keyColumn="schedule_id"
|
||||||
|
parameterType="com.zioinfo.esn.uiws.schedule.model.UiwsSchedule">
|
||||||
|
INSERT INTO tb_uiws_schedule
|
||||||
|
(sche_type, title, sche_gubun_cd, importance_cd, start_dt, end_dt, content,
|
||||||
|
owner_id, dept_id, charger_id, created_by, created_at)
|
||||||
|
VALUES
|
||||||
|
(#{scheType}, #{title}, #{scheGubunCd}, #{importanceCd}, #{startDt}, #{endDt}, #{content},
|
||||||
|
#{ownerId}, #{deptId}, #{chargerId}, #{createdBy}, #{createdAt})
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<update id="updateSchedule" parameterType="com.zioinfo.esn.uiws.schedule.model.UiwsSchedule">
|
||||||
|
UPDATE tb_uiws_schedule SET
|
||||||
|
sche_type = #{scheType}, title = #{title}, sche_gubun_cd = #{scheGubunCd},
|
||||||
|
importance_cd = #{importanceCd}, start_dt = #{startDt}, end_dt = #{endDt},
|
||||||
|
content = #{content}, dept_id = #{deptId}, charger_id = #{chargerId},
|
||||||
|
updated_by = #{updatedBy}, updated_at = #{updatedAt}
|
||||||
|
WHERE schedule_id = #{scheduleId}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<select id="findScheduleById" resultType="com.zioinfo.esn.uiws.schedule.model.UiwsSchedule">
|
||||||
|
SELECT * FROM tb_uiws_schedule WHERE schedule_id = #{scheduleId}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="existsScheduleById" resultType="boolean">
|
||||||
|
SELECT EXISTS(SELECT 1 FROM tb_uiws_schedule WHERE schedule_id = #{scheduleId})
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<delete id="deleteScheduleById">
|
||||||
|
DELETE FROM tb_uiws_schedule WHERE schedule_id = #{scheduleId}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
<!-- 기간 겹침: start_dt < to(배타 상한) AND end_dt >= from -->
|
||||||
|
<select id="findInRange" resultType="com.zioinfo.esn.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.esn.uiws.schedule.model.UiwsSchedule">
|
||||||
|
SELECT * FROM tb_uiws_schedule
|
||||||
|
<include refid="allWhere"/>
|
||||||
|
ORDER BY start_dt DESC, schedule_id DESC
|
||||||
|
LIMIT #{limit} OFFSET #{offset}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="countAll" resultType="long">
|
||||||
|
SELECT COUNT(*) FROM tb_uiws_schedule
|
||||||
|
<include refid="allWhere"/>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="searchPopup" resultType="com.zioinfo.esn.uiws.schedule.model.UiwsSchedule">
|
||||||
|
SELECT * FROM tb_uiws_schedule
|
||||||
|
WHERE 1=1
|
||||||
|
<if test="keyword != null">
|
||||||
|
AND (title LIKE '%' || #{keyword} || '%' OR content LIKE '%' || #{keyword} || '%')
|
||||||
|
</if>
|
||||||
|
<if test="!scopeAll">
|
||||||
|
AND owner_id IN
|
||||||
|
<foreach collection="ownerIds" item="oid" open="(" separator="," close=")">#{oid}</foreach>
|
||||||
|
</if>
|
||||||
|
ORDER BY start_dt DESC, schedule_id DESC
|
||||||
|
LIMIT 50
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- ===================== diary ===================== -->
|
||||||
|
<insert id="insertDiary" useGeneratedKeys="true" keyProperty="diaryId" keyColumn="diary_id"
|
||||||
|
parameterType="com.zioinfo.esn.uiws.schedule.model.UiwsDiary">
|
||||||
|
INSERT INTO tb_uiws_diary
|
||||||
|
(title, content, schedule_id, writer_id, diary_date, created_by, created_at)
|
||||||
|
VALUES
|
||||||
|
(#{title}, #{content}, #{scheduleId}, #{writerId}, #{diaryDate}, #{createdBy}, #{createdAt})
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<update id="updateDiary" parameterType="com.zioinfo.esn.uiws.schedule.model.UiwsDiary">
|
||||||
|
UPDATE tb_uiws_diary SET
|
||||||
|
title = #{title}, content = #{content}, schedule_id = #{scheduleId}, diary_date = #{diaryDate},
|
||||||
|
updated_by = #{updatedBy}, updated_at = #{updatedAt}
|
||||||
|
WHERE diary_id = #{diaryId}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<select id="findDiaryById" resultType="com.zioinfo.esn.uiws.schedule.model.UiwsDiary">
|
||||||
|
SELECT * FROM tb_uiws_diary WHERE diary_id = #{diaryId}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="existsDiaryById" resultType="boolean">
|
||||||
|
SELECT EXISTS(SELECT 1 FROM tb_uiws_diary WHERE diary_id = #{diaryId})
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<delete id="deleteDiaryById">
|
||||||
|
DELETE FROM tb_uiws_diary WHERE diary_id = #{diaryId}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
<select id="searchDiary" resultType="com.zioinfo.esn.uiws.schedule.model.UiwsDiary">
|
||||||
|
SELECT * FROM tb_uiws_diary
|
||||||
|
WHERE (diary_date IS NULL OR diary_date BETWEEN #{from} AND #{to})
|
||||||
|
ORDER BY COALESCE(diary_date, created_at::date) DESC, diary_id DESC
|
||||||
|
LIMIT #{limit} OFFSET #{offset}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="countDiary" resultType="long">
|
||||||
|
SELECT COUNT(*) FROM tb_uiws_diary
|
||||||
|
WHERE (diary_date IS NULL OR diary_date BETWEEN #{from} AND #{to})
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- ===================== attach ===================== -->
|
||||||
|
<insert id="insertAttach" useGeneratedKeys="true" keyProperty="attachId" keyColumn="attach_id"
|
||||||
|
parameterType="com.zioinfo.esn.uiws.schedule.model.UiwsAttach">
|
||||||
|
INSERT INTO tb_uiws_attach
|
||||||
|
(ref_type, ref_id, file_nm, file_path, file_size, created_by, created_at)
|
||||||
|
VALUES
|
||||||
|
(#{refType}, #{refId}, #{fileNm}, #{filePath}, #{fileSize}, #{createdBy}, #{createdAt})
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<select id="findAttachById" resultType="com.zioinfo.esn.uiws.schedule.model.UiwsAttach">
|
||||||
|
SELECT * FROM tb_uiws_attach WHERE attach_id = #{attachId}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<update id="updateAttachRef">
|
||||||
|
UPDATE tb_uiws_attach
|
||||||
|
SET ref_type = #{refType}, ref_id = #{refId}, updated_by = #{actor}, updated_at = now()
|
||||||
|
WHERE attach_id = #{attachId}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<select id="findAttachByRef" resultType="com.zioinfo.esn.uiws.schedule.model.UiwsAttach">
|
||||||
|
SELECT * FROM tb_uiws_attach
|
||||||
|
WHERE ref_type = #{refType} AND ref_id = #{refId}
|
||||||
|
ORDER BY attach_id ASC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<delete id="deleteAttachById">
|
||||||
|
DELETE FROM tb_uiws_attach WHERE attach_id = #{attachId}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
<delete id="deleteAttachByRef">
|
||||||
|
DELETE FROM tb_uiws_attach WHERE ref_type = #{refType} AND ref_id = #{refId}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
<!-- ===================== 사용자명 라벨 ===================== -->
|
||||||
|
<select id="findUserNames" resultType="map">
|
||||||
|
SELECT username AS "username", username AS "fullName"
|
||||||
|
FROM esn_user
|
||||||
|
WHERE username IN
|
||||||
|
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
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.
|
||||||
|
라벨: 근무자=esn_user.username(writer_id=username 조인, 없으면 writer_id),
|
||||||
|
근무처=company_id, 근무상태/유형=코드값(공통코드 미이식). -->
|
||||||
|
<mapper namespace="com.zioinfo.esn.uiws.stats.mapper.StatsMapper">
|
||||||
|
|
||||||
|
<select id="personalWork" resultType="map">
|
||||||
|
SELECT COALESCE(u.username, w.writer_id) AS worker,
|
||||||
|
w.work_status_cd AS work_status,
|
||||||
|
d.work_type_cd AS work_type,
|
||||||
|
d.company_id AS dyn,
|
||||||
|
COUNT(*) AS cnt
|
||||||
|
FROM tb_uiws_worklog_dtl d
|
||||||
|
JOIN tb_uiws_worklog w ON w.worklog_id = d.worklog_id
|
||||||
|
LEFT JOIN esn_user u ON u.username = w.writer_id
|
||||||
|
WHERE w.work_date BETWEEN #{from} AND #{to}
|
||||||
|
<if test="!scopeAll">
|
||||||
|
AND w.writer_id IN
|
||||||
|
<foreach collection="ownerIds" item="oid" open="(" separator="," close=")">#{oid}</foreach>
|
||||||
|
</if>
|
||||||
|
<if test="userId != null">AND w.writer_id = #{userId}</if>
|
||||||
|
GROUP BY COALESCE(u.username, w.writer_id), w.work_status_cd, d.work_type_cd, d.company_id
|
||||||
|
ORDER BY worker, work_status, work_type, dyn
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="companyWork" resultType="map">
|
||||||
|
SELECT d.company_id AS company,
|
||||||
|
d.work_type_cd AS work_type,
|
||||||
|
COALESCE(u.username, w.writer_id) AS dyn,
|
||||||
|
COUNT(*) AS cnt
|
||||||
|
FROM tb_uiws_worklog_dtl d
|
||||||
|
JOIN tb_uiws_worklog w ON w.worklog_id = d.worklog_id
|
||||||
|
LEFT JOIN esn_user u ON u.username = w.writer_id
|
||||||
|
WHERE w.work_date BETWEEN #{from} AND #{to}
|
||||||
|
<if test="!scopeAll">
|
||||||
|
AND w.writer_id IN
|
||||||
|
<foreach collection="ownerIds" item="oid" open="(" separator="," close=")">#{oid}</foreach>
|
||||||
|
</if>
|
||||||
|
<if test="companyId != null">AND d.company_id = #{companyId}</if>
|
||||||
|
<if test="userId != null">AND w.writer_id = #{userId}</if>
|
||||||
|
GROUP BY d.company_id, d.work_type_cd, COALESCE(u.username, w.writer_id)
|
||||||
|
ORDER BY company, work_type, dyn
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
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.esn.uiws.worklog.mapper.WorklogMapper">
|
||||||
|
|
||||||
|
<!-- ===================== worklog 헤더 ===================== -->
|
||||||
|
<insert id="insertWorklog" useGeneratedKeys="true" keyProperty="worklogId" keyColumn="worklog_id"
|
||||||
|
parameterType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklog">
|
||||||
|
INSERT INTO tb_uiws_worklog
|
||||||
|
(title, writer_id, work_date, work_status_cd, progress_cd, repeat_yn,
|
||||||
|
repeat_start_date, repeat_end_date, created_by, created_at)
|
||||||
|
VALUES
|
||||||
|
(#{title}, #{writerId}, #{workDate}, #{workStatusCd}, #{progressCd}, #{repeatYn},
|
||||||
|
#{repeatStartDate}, #{repeatEndDate}, #{createdBy}, #{createdAt})
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<update id="updateWorklog" parameterType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklog">
|
||||||
|
UPDATE tb_uiws_worklog SET
|
||||||
|
title = #{title}, writer_id = #{writerId}, work_date = #{workDate},
|
||||||
|
work_status_cd = #{workStatusCd}, progress_cd = #{progressCd}, repeat_yn = #{repeatYn},
|
||||||
|
repeat_start_date = #{repeatStartDate}, repeat_end_date = #{repeatEndDate},
|
||||||
|
updated_by = #{updatedBy}, updated_at = #{updatedAt}
|
||||||
|
WHERE worklog_id = #{worklogId}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<select id="findWorklogById" resultType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklog">
|
||||||
|
SELECT * FROM tb_uiws_worklog WHERE worklog_id = #{worklogId}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="existsWorklogById" resultType="boolean">
|
||||||
|
SELECT EXISTS(SELECT 1 FROM tb_uiws_worklog WHERE worklog_id = #{worklogId})
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<delete id="deleteWorklogById">
|
||||||
|
DELETE FROM tb_uiws_worklog WHERE worklog_id = #{worklogId}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
<sql id="scope">
|
||||||
|
<if test="!scopeAll">
|
||||||
|
AND writer_id IN
|
||||||
|
<foreach collection="ownerIds" item="oid" open="(" separator="," close=")">#{oid}</foreach>
|
||||||
|
</if>
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<select id="searchList" resultType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklog">
|
||||||
|
SELECT * FROM tb_uiws_worklog
|
||||||
|
WHERE work_date BETWEEN #{from} AND #{to}
|
||||||
|
<if test="writerId != null">AND writer_id = #{writerId}</if>
|
||||||
|
<if test="progressCd != null">AND progress_cd = #{progressCd}</if>
|
||||||
|
<include refid="scope"/>
|
||||||
|
ORDER BY work_date DESC, worklog_id DESC
|
||||||
|
LIMIT #{limit} OFFSET #{offset}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="countList" resultType="long">
|
||||||
|
SELECT COUNT(*) FROM tb_uiws_worklog
|
||||||
|
WHERE work_date BETWEEN #{from} AND #{to}
|
||||||
|
<if test="writerId != null">AND writer_id = #{writerId}</if>
|
||||||
|
<if test="progressCd != null">AND progress_cd = #{progressCd}</if>
|
||||||
|
<include refid="scope"/>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="findByMonth" resultType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklog">
|
||||||
|
SELECT * FROM tb_uiws_worklog
|
||||||
|
WHERE work_date BETWEEN #{from} AND #{to}
|
||||||
|
<if test="writerId != null">AND writer_id = #{writerId}</if>
|
||||||
|
<include refid="scope"/>
|
||||||
|
ORDER BY work_date ASC, worklog_id ASC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="searchPopup" resultType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklog">
|
||||||
|
SELECT * FROM tb_uiws_worklog
|
||||||
|
WHERE 1=1
|
||||||
|
<if test="keyword != null">AND title LIKE '%' || #{keyword} || '%'</if>
|
||||||
|
<if test="writerId != null">AND writer_id = #{writerId}</if>
|
||||||
|
<include refid="scope"/>
|
||||||
|
ORDER BY work_date DESC, worklog_id DESC
|
||||||
|
LIMIT 50
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="progressSummary" resultType="map">
|
||||||
|
SELECT progress_cd AS "progressCd", COUNT(*) AS "cnt"
|
||||||
|
FROM tb_uiws_worklog
|
||||||
|
WHERE work_date BETWEEN #{from} AND #{to}
|
||||||
|
<include refid="scope"/>
|
||||||
|
GROUP BY progress_cd
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- ===================== 상세(dtl) ===================== -->
|
||||||
|
<insert id="insertDtl" useGeneratedKeys="true" keyProperty="dtlId" keyColumn="dtl_id"
|
||||||
|
parameterType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklogDtl">
|
||||||
|
INSERT INTO tb_uiws_worklog_dtl
|
||||||
|
(worklog_id, start_hour, end_hour, work_type_cd, company_id, work_content, issue_content,
|
||||||
|
sort_ord, created_by, created_at)
|
||||||
|
VALUES
|
||||||
|
(#{worklogId}, #{startHour}, #{endHour}, #{workTypeCd}, #{companyId}, #{workContent}, #{issueContent},
|
||||||
|
#{sortOrd}, #{createdBy}, #{createdAt})
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<update id="updateDtl" parameterType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklogDtl">
|
||||||
|
UPDATE tb_uiws_worklog_dtl SET
|
||||||
|
start_hour = #{startHour}, end_hour = #{endHour}, work_type_cd = #{workTypeCd},
|
||||||
|
company_id = #{companyId}, work_content = #{workContent}, issue_content = #{issueContent},
|
||||||
|
sort_ord = #{sortOrd}, updated_by = #{updatedBy}, updated_at = #{updatedAt}
|
||||||
|
WHERE dtl_id = #{dtlId}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<delete id="deleteDtlById">
|
||||||
|
DELETE FROM tb_uiws_worklog_dtl WHERE dtl_id = #{dtlId}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
<select id="findDtlByWorklogId" resultType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklogDtl">
|
||||||
|
SELECT * FROM tb_uiws_worklog_dtl
|
||||||
|
WHERE worklog_id = #{worklogId}
|
||||||
|
ORDER BY sort_ord ASC, start_hour ASC, dtl_id ASC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- ===================== 댓글(cmt) ===================== -->
|
||||||
|
<insert id="insertCmt" useGeneratedKeys="true" keyProperty="cmtId" keyColumn="cmt_id"
|
||||||
|
parameterType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklogCmt">
|
||||||
|
INSERT INTO tb_uiws_worklog_cmt
|
||||||
|
(worklog_id, cmt_content, writer_id, kakao_sent_yn, confirm_yn, created_by, created_at)
|
||||||
|
VALUES
|
||||||
|
(#{worklogId}, #{cmtContent}, #{writerId}, #{kakaoSentYn}, #{confirmYn}, #{createdBy}, #{createdAt})
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<select id="findCmtById" resultType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklogCmt">
|
||||||
|
SELECT * FROM tb_uiws_worklog_cmt WHERE cmt_id = #{cmtId}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<update id="updateCmtKakaoSent">
|
||||||
|
UPDATE tb_uiws_worklog_cmt SET kakao_sent_yn = 'Y' WHERE cmt_id = #{cmtId}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="confirmCmt">
|
||||||
|
UPDATE tb_uiws_worklog_cmt
|
||||||
|
SET confirm_yn = 'Y', updated_by = #{actor}, updated_at = now()
|
||||||
|
WHERE cmt_id = #{cmtId}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<select id="findCmtByWorklogId" resultType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklogCmt">
|
||||||
|
SELECT * FROM tb_uiws_worklog_cmt
|
||||||
|
WHERE worklog_id = #{worklogId}
|
||||||
|
ORDER BY created_at ASC, cmt_id ASC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="countCommentsByWorklogIds" resultType="map">
|
||||||
|
SELECT worklog_id AS "worklogId", COUNT(*) AS "cnt"
|
||||||
|
FROM tb_uiws_worklog_cmt
|
||||||
|
WHERE worklog_id IN
|
||||||
|
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||||
|
GROUP BY worklog_id
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="findUnconfirmedByWriter" resultType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklogCmt">
|
||||||
|
SELECT * FROM tb_uiws_worklog_cmt
|
||||||
|
WHERE writer_id = #{writerId} AND confirm_yn = 'N'
|
||||||
|
ORDER BY created_at DESC, cmt_id DESC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- ===================== 사용자 라벨/이메일(코어 user 재사용) ===================== -->
|
||||||
|
<select id="findUserNames" resultType="map">
|
||||||
|
SELECT username AS "username", username AS "fullName"
|
||||||
|
FROM esn_user
|
||||||
|
WHERE username IN
|
||||||
|
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="findUserEmail" resultType="string">
|
||||||
|
SELECT email FROM esn_user WHERE username = #{username}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="findWorklogsByIds" resultType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklog">
|
||||||
|
SELECT * FROM tb_uiws_worklog
|
||||||
|
WHERE worklog_id IN
|
||||||
|
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
65
frontend/src/api/uiws.ts
Normal file
65
frontend/src/api/uiws.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import api from './client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UIWS 이식 모듈 API 클라이언트. 기존 ESN axios 인스턴스(baseURL='', esn_token JWT 인터셉터) 재사용.
|
||||||
|
* ESN client 는 baseURL 이 비어 있으므로 전체 경로 /api/... 를 사용한다.
|
||||||
|
* 응답 봉투: { success, message, data }. 호출부는 res.data.data 로 페이로드 접근.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── 2FA
|
||||||
|
export const verify2fa = (verifyToken: string, code: string) =>
|
||||||
|
api.post('/api/auth/verify', { verifyToken, code })
|
||||||
|
|
||||||
|
// ── 쪽지(message)
|
||||||
|
export const sendMessage = (body: object) => api.post('/api/messages', body)
|
||||||
|
export const listSent = (params: Record<string, unknown>) => api.get('/api/messages/sent', { params })
|
||||||
|
export const sentDetail = (id: number) => api.get(`/api/messages/sent/${id}`)
|
||||||
|
export const deleteSent = (ids: number[]) => api.delete('/api/messages/sent', { data: { ids } })
|
||||||
|
export const listReceived = (params: Record<string, unknown>) => api.get('/api/messages/received', { params })
|
||||||
|
export const receivedDetail = (id: number) => api.get(`/api/messages/received/${id}`)
|
||||||
|
export const deleteReceived = (ids: number[]) => api.delete('/api/messages/received', { data: { ids } })
|
||||||
|
export const unreadCount = () => api.get('/api/messages/unread-count')
|
||||||
|
|
||||||
|
// ── 일정(schedule)
|
||||||
|
export const scheduleCalendar = (params: Record<string, unknown>) => api.get('/api/schedules', { params })
|
||||||
|
export const scheduleAll = (params: Record<string, unknown>) => api.get('/api/schedules/all', { params })
|
||||||
|
export const scheduleSearch = (keyword: string) => api.get('/api/schedules/search', { params: { keyword } })
|
||||||
|
export const createSchedule = (body: object) => api.post('/api/schedules', body)
|
||||||
|
export const scheduleDetail = (id: number) => api.get(`/api/schedules/${id}`)
|
||||||
|
export const updateSchedule = (id: number, body: object) => api.put(`/api/schedules/${id}`, body)
|
||||||
|
export const deleteSchedule = (id: number) => api.delete(`/api/schedules/${id}`)
|
||||||
|
|
||||||
|
// ── 일지(diary)
|
||||||
|
export const diaryList = (params: Record<string, unknown>) => api.get('/api/diaries', { params })
|
||||||
|
export const createDiary = (body: object) => api.post('/api/diaries', body)
|
||||||
|
export const diaryDetail = (id: number) => api.get(`/api/diaries/${id}`)
|
||||||
|
export const updateDiary = (id: number, body: object) => api.put(`/api/diaries/${id}`, body)
|
||||||
|
export const deleteDiary = (id: number) => api.delete(`/api/diaries/${id}`)
|
||||||
|
|
||||||
|
// ── 첨부(attachment)
|
||||||
|
export const uploadAttachment = (refType: string, refId: number, file: File) => {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('refType', refType)
|
||||||
|
fd.append('refId', String(refId))
|
||||||
|
fd.append('file', file)
|
||||||
|
return api.post('/api/attachments', fd, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
}
|
||||||
|
export const deleteAttachment = (id: number) => api.delete(`/api/attachments/${id}`)
|
||||||
|
export const attachmentDownloadUrl = (id: number) => `/api/attachments/${id}/download`
|
||||||
|
|
||||||
|
// ── 업무일지(worklog)
|
||||||
|
export const worklogList = (params: Record<string, unknown>) => api.get('/api/worklogs', { params })
|
||||||
|
export const worklogCalendar = (params: Record<string, unknown>) => api.get('/api/worklogs/calendar', { params })
|
||||||
|
export const worklogSearch = (params: Record<string, unknown>) => api.get('/api/worklogs/search', { params })
|
||||||
|
export const worklogProgress = (params: Record<string, unknown>) => api.get('/api/worklogs/dashboard/progress', { params })
|
||||||
|
export const createWorklog = (body: object) => api.post('/api/worklogs', body)
|
||||||
|
export const worklogDetail = (id: number) => api.get(`/api/worklogs/${id}`)
|
||||||
|
export const updateWorklog = (id: number, body: object) => api.put(`/api/worklogs/${id}`, body)
|
||||||
|
export const deleteWorklog = (id: number) => api.delete(`/api/worklogs/${id}`)
|
||||||
|
export const addWorklogComment = (id: number, cmtContent: string) =>
|
||||||
|
api.post(`/api/worklogs/${id}/comments`, { cmtContent })
|
||||||
|
export const confirmWorklogComment = (cmtId: number) => api.post(`/api/worklogs/comments/${cmtId}/confirm`)
|
||||||
|
|
||||||
|
// ── 통계(stats)
|
||||||
|
export const personalWorkStats = (params: Record<string, unknown>) => api.get('/api/stats/personal-work', { params })
|
||||||
|
export const companyWorkStats = (params: Record<string, unknown>) => api.get('/api/stats/company-work', { params })
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
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-*)만 사용).
|
||||||
|
* 다크/라이트 양 모드에서 대비/가독성 정상. 기존 ERP 컴포넌트와 충돌 회피 위해 components/uiws/ 네임스페이스.
|
||||||
|
*/
|
||||||
|
import { type ReactNode, type CSSProperties } from 'react'
|
||||||
|
|
||||||
|
const card: CSSProperties = {
|
||||||
|
background: 'var(--uiws-surface)',
|
||||||
|
border: '1px solid var(--uiws-border)',
|
||||||
|
borderRadius: 12,
|
||||||
|
boxShadow: 'var(--uiws-shadow)',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHeader({ title, subtitle, actions }: { title: string; subtitle?: string; actions?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 20 }}>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ fontSize: 22, fontWeight: 700, color: 'var(--uiws-text)' }}>{title}</h1>
|
||||||
|
{subtitle && <div style={{ fontSize: 13, color: 'var(--uiws-text-muted)', marginTop: 4 }}>{subtitle}</div>}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>{actions}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Panel({ children, style }: { children: ReactNode; style?: CSSProperties }) {
|
||||||
|
return <div style={{ ...card, padding: 18, ...style }}>{children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
children, onClick, variant = 'primary', type = 'button', disabled,
|
||||||
|
}: {
|
||||||
|
children: ReactNode; onClick?: () => void; variant?: 'primary' | 'ghost' | 'danger'
|
||||||
|
type?: 'button' | 'submit'; disabled?: boolean
|
||||||
|
}) {
|
||||||
|
const styles: Record<string, CSSProperties> = {
|
||||||
|
primary: { background: 'var(--uiws-primary)', color: 'var(--uiws-primary-contrast)', border: 'none' },
|
||||||
|
ghost: { background: 'transparent', color: 'var(--uiws-text-muted)', border: '1px solid var(--uiws-border)' },
|
||||||
|
danger: { background: 'var(--uiws-danger)', color: '#fff', border: 'none' },
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button type={type} onClick={onClick} disabled={disabled}
|
||||||
|
style={{ padding: '9px 16px', borderRadius: 8, fontSize: 13, fontWeight: 600,
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? 0.5 : 1, ...styles[variant] }}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Input({
|
||||||
|
value, onChange, placeholder, type = 'text', style,
|
||||||
|
}: {
|
||||||
|
value: string; onChange: (v: string) => void; placeholder?: string; type?: string; style?: CSSProperties
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<input type={type} value={value} placeholder={placeholder} onChange={e => onChange(e.target.value)}
|
||||||
|
style={{ padding: '9px 12px', borderRadius: 8, fontSize: 13,
|
||||||
|
background: 'var(--uiws-input-bg)', border: '1px solid var(--uiws-border)', color: 'var(--uiws-text)', ...style }} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormField({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label style={{ display: 'block', marginBottom: 12 }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--uiws-text-muted)', marginBottom: 6 }}>{label}</div>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchBar({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Panel style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap', marginBottom: 16 }}>
|
||||||
|
{children}
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Column<T> {
|
||||||
|
key: string
|
||||||
|
header: string
|
||||||
|
render?: (row: T) => ReactNode
|
||||||
|
width?: number | string
|
||||||
|
align?: 'left' | 'center' | 'right'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataGrid<T extends Record<string, unknown>>({
|
||||||
|
columns, rows, rowKey, onRowClick, empty = '데이터가 없습니다.',
|
||||||
|
}: {
|
||||||
|
columns: Column<T>[]; rows: T[]; rowKey: (row: T) => string | number
|
||||||
|
onRowClick?: (row: T) => void; empty?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{ ...card, overflow: 'hidden' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13, color: 'var(--uiws-text)' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: 'var(--uiws-surface-2)' }}>
|
||||||
|
{columns.map(c => (
|
||||||
|
<th key={c.key} style={{ textAlign: c.align ?? 'left', padding: '11px 14px',
|
||||||
|
color: 'var(--uiws-text-muted)', fontWeight: 600, fontSize: 12,
|
||||||
|
borderBottom: '1px solid var(--uiws-border)', width: c.width }}>{c.header}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<tr><td colSpan={columns.length} style={{ padding: 28, textAlign: 'center', color: 'var(--uiws-text-faint)' }}>{empty}</td></tr>
|
||||||
|
) : rows.map(row => (
|
||||||
|
<tr key={rowKey(row)} onClick={() => onRowClick?.(row)}
|
||||||
|
style={{ cursor: onRowClick ? 'pointer' : 'default', borderBottom: '1px solid var(--uiws-border)' }}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = 'var(--uiws-row-hover)')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}>
|
||||||
|
{columns.map(c => (
|
||||||
|
<td key={c.key} style={{ padding: '11px 14px', textAlign: c.align ?? 'left' }}>
|
||||||
|
{c.render ? c.render(row) : String(row[c.key] ?? '')}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pagination({ page, totalPages, onChange }: { page: number; totalPages: number; onChange: (p: number) => void }) {
|
||||||
|
if (totalPages <= 1) return null
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', marginTop: 16 }}>
|
||||||
|
<Button variant="ghost" disabled={page <= 0} onClick={() => onChange(page - 1)}>이전</Button>
|
||||||
|
<span style={{ alignSelf: 'center', fontSize: 13, color: 'var(--uiws-text-muted)' }}>
|
||||||
|
{page + 1} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" disabled={page >= totalPages - 1} onClick={() => onChange(page + 1)}>다음</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function YnBadge({ yn, yes = '읽음', no = '안읽음' }: { yn: string; yes?: string; no?: string }) {
|
||||||
|
const on = yn === 'Y'
|
||||||
|
return (
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, padding: '2px 9px', borderRadius: 999,
|
||||||
|
color: on ? 'var(--uiws-success)' : 'var(--uiws-text-muted)',
|
||||||
|
background: on ? 'rgba(46,204,113,0.12)' : 'var(--uiws-surface-2)',
|
||||||
|
border: '1px solid var(--uiws-border)' }}>
|
||||||
|
{on ? yes : no}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({ title, onClose, children, footer }: { title: string; onClose: () => void; children: ReactNode; footer?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div onClick={onClose}
|
||||||
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex',
|
||||||
|
justifyContent: 'center', alignItems: 'center', zIndex: 1000 }}>
|
||||||
|
<div onClick={e => e.stopPropagation()}
|
||||||
|
style={{ ...card, width: 'min(560px, 92vw)', maxHeight: '88vh', overflow: 'auto', padding: 22 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||||
|
<h2 style={{ fontSize: 17, fontWeight: 700, color: 'var(--uiws-text)' }}>{title}</h2>
|
||||||
|
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--uiws-text-muted)', fontSize: 20, cursor: 'pointer' }}>×</button>
|
||||||
|
</div>
|
||||||
|
<div>{children}</div>
|
||||||
|
{footer && <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 18 }}>{footer}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Spinner({ label = '불러오는 중...' }: { label?: string }) {
|
||||||
|
return <div style={{ padding: 28, textAlign: 'center', color: 'var(--uiws-text-faint)', fontSize: 13 }}>{label}</div>
|
||||||
|
}
|
||||||
@ -1,26 +1,62 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { login } from '../api/client'
|
import { login } from '../api/client'
|
||||||
|
import { verify2fa } from '../api/uiws'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인 화면. UIWS 2FA 이식 반영:
|
||||||
|
* - 2FA off 응답({ twofa:"false", token }) → 기존처럼 즉시 로그인(회귀 0).
|
||||||
|
* - 2FA on 응답({ twofa:"true", verifyToken, maskedEmail }) → 인증코드 입력 단계로 전환.
|
||||||
|
* 색상은 ESN Tailwind 테마 토큰(bg-card/text-brand/border-edge…)만 사용 — 하드코딩 없음.
|
||||||
|
*/
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [step, setStep] = useState<'login' | 'verify'>('login')
|
||||||
|
const [verifyToken, setVerifyToken] = useState('')
|
||||||
|
const [maskedEmail, setMaskedEmail] = useState('')
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
function finishLogin(token: string) {
|
||||||
|
localStorage.setItem('esn_token', token)
|
||||||
|
localStorage.setItem('esn_user', username)
|
||||||
|
navigate('/dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
const res = await login(username, password)
|
const res = await login(username, password)
|
||||||
const token = res.data?.data?.token
|
const data = res.data?.data
|
||||||
if (!token) throw new Error('토큰 없음')
|
if (data?.twofa === 'true') {
|
||||||
localStorage.setItem('esn_token', token)
|
setVerifyToken(data.verifyToken)
|
||||||
navigate('/dashboard')
|
setMaskedEmail(data.maskedEmail || '')
|
||||||
|
setStep('verify')
|
||||||
|
} else {
|
||||||
|
if (!data?.token) throw new Error('토큰 없음')
|
||||||
|
finishLogin(data.token)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError('로그인 실패: 아이디/비밀번호를 확인하세요.')
|
setError('아이디/비밀번호가 올바르지 않거나 계정이 잠겼습니다.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVerify(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await verify2fa(verifyToken, code)
|
||||||
|
finishLogin(res.data?.data?.token)
|
||||||
|
} catch {
|
||||||
|
setError('인증 코드가 올바르지 않거나 만료되었습니다.')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@ -30,10 +66,14 @@ export default function Login() {
|
|||||||
<div className="min-h-screen bg-ink flex items-center justify-center">
|
<div className="min-h-screen bg-ink flex items-center justify-center">
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-2xl font-bold text-brand">zioinfo-esn</h1>
|
<h1 className="text-2xl font-bold text-brand">GUARDiA ESN</h1>
|
||||||
<p className="text-sm text-gray-400 mt-1">ESL 통합 관리 플랫폼</p>
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
{step === 'login' ? 'ESL 통합 관리 플랫폼' : '2차 인증'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit} className="bg-card border border-edge rounded-lg p-6 space-y-4">
|
|
||||||
|
{step === 'login' ? (
|
||||||
|
<form onSubmit={handleLogin} className="bg-card border border-edge rounded-lg p-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-400 mb-1">아이디</label>
|
<label className="block text-xs text-gray-400 mb-1">아이디</label>
|
||||||
<input
|
<input
|
||||||
@ -62,6 +102,39 @@ export default function Login() {
|
|||||||
{loading ? '로그인 중...' : '로그인'}
|
{loading ? '로그인 중...' : '로그인'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleVerify} className="bg-card border border-edge rounded-lg p-6 space-y-4">
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{maskedEmail ? `${maskedEmail} 로 발송된 인증코드를 입력하세요.` : '발송된 인증코드를 입력하세요.'}
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-400 mb-1">인증코드 6자리</label>
|
||||||
|
<input
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={6}
|
||||||
|
value={code}
|
||||||
|
onChange={e => setCode(e.target.value)}
|
||||||
|
placeholder="000000"
|
||||||
|
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white text-center tracking-[0.4em] placeholder-gray-600 focus:border-brand focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-brand hover:bg-brand2 text-white py-2 rounded text-sm font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? '인증 중...' : '인증하고 로그인'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setStep('login'); setCode(''); setError('') }}
|
||||||
|
className="w-full border border-edge text-gray-400 hover:text-white py-2 rounded text-sm transition-colors"
|
||||||
|
>
|
||||||
|
뒤로
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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('esn_user') || 'admin')
|
||||||
|
const [workDate, setWorkDate] = useState(new Date().toISOString().slice(0, 10))
|
||||||
|
const [workStatusCd, setWorkStatusCd] = useState('NORMAL')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setErr('')
|
||||||
|
try {
|
||||||
|
await createWorklog({ title, writerId, workDate, workStatusCd, progressCd: 'ONGOING', details: [] })
|
||||||
|
onSaved()
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(e?.response?.data?.message || '저장 실패')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Modal title="업무일지 작성" onClose={onClose}
|
||||||
|
footer={<><Button variant="ghost" onClick={onClose}>취소</Button><Button onClick={save}>저장</Button></>}>
|
||||||
|
<FormField label="제목"><Input value={title} onChange={setTitle} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="작성자 ID"><Input value={writerId} onChange={setWriterId} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="근무일자"><Input type="date" value={workDate} onChange={setWorkDate} style={{ width: '100%' }} /></FormField>
|
||||||
|
<FormField label="근무상태 코드"><Input value={workStatusCd} onChange={setWorkStatusCd} style={{ width: '100%' }} /></FormField>
|
||||||
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorklogDetailModal({ detail, onClose, onChanged }: { detail: any; onClose: () => void; onChanged: () => void }) {
|
||||||
|
const [comment, setComment] = useState('')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
await deleteWorklog(detail.worklogId)
|
||||||
|
onChanged()
|
||||||
|
}
|
||||||
|
const addCmt = async () => {
|
||||||
|
setErr('')
|
||||||
|
try {
|
||||||
|
await addWorklogComment(detail.worklogId, comment)
|
||||||
|
setComment('')
|
||||||
|
onChanged()
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(e?.response?.data?.message || '댓글 등록 실패')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Modal title={detail.title} onClose={onClose}
|
||||||
|
footer={<><Button variant="danger" onClick={remove}>삭제</Button><Button variant="ghost" onClick={onClose}>닫기</Button></>}>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--uiws-text-muted)', marginBottom: 12 }}>
|
||||||
|
{detail.writerNm} · {detail.workDate} · {detail.progressCd}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--uiws-text-muted)', marginBottom: 6 }}>시간대별 상세 {detail.details?.length ?? 0}건</div>
|
||||||
|
<div style={{ marginTop: 14 }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--uiws-text-muted)', marginBottom: 8 }}>댓글</div>
|
||||||
|
{(detail.comments ?? []).map((c: any) => (
|
||||||
|
<div key={c.cmtId} style={{ padding: '8px 10px', borderRadius: 8, background: 'var(--uiws-surface-2)', marginBottom: 6, fontSize: 13 }}>
|
||||||
|
<b>{c.writerNm}</b> · <span style={{ color: 'var(--uiws-text-muted)' }}>{c.createdAt}</span><br />{c.cmtContent}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||||
|
<Input value={comment} onChange={setComment} placeholder="댓글 입력(관리자/매니저)" style={{ flex: 1 }} />
|
||||||
|
<Button onClick={addCmt}>등록</Button>
|
||||||
|
</div>
|
||||||
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 12, marginTop: 6 }}>{err}</div>}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
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 = 'esn_theme'
|
||||||
|
|
||||||
|
function applyTheme(t: ThemeMode) {
|
||||||
|
document.documentElement.setAttribute('data-theme', t)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [theme, setThemeState] = useState<ThemeMode>(() => {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY)
|
||||||
|
return saved === 'light' ? 'light' : 'dark'
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyTheme(theme)
|
||||||
|
localStorage.setItem(STORAGE_KEY, theme)
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
const setTheme = (t: ThemeMode) => setThemeState(t)
|
||||||
|
const toggle = () => setThemeState(prev => (prev === 'dark' ? 'light' : 'dark'))
|
||||||
|
|
||||||
|
return <Ctx.Provider value={{ theme, toggle, setTheme }}>{children}</Ctx.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
return useContext(Ctx)
|
||||||
|
}
|
||||||
52
frontend/src/theme/theme.css
Normal file
52
frontend/src/theme/theme.css
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
/*
|
||||||
|
* GUARDiA ESN 테마 토큰 (UIWS 이식 화면 공통).
|
||||||
|
* 다크/라이트 두 모드 지원. UIWS 화면/컴포넌트는 색상 하드코딩 금지 — 아래 CSS 변수만 사용한다.
|
||||||
|
* 기존 ESN 화면(Tailwind 토큰 다크)은 영향 없음(이 변수는 UIWS 스코프에서만 참조).
|
||||||
|
*
|
||||||
|
* 다크 기본값은 기존 ESN 팔레트(ink #0b0f17 / panel #131927 / card #1a2234 / brand #00a0c8)와 정합.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root,
|
||||||
|
:root[data-theme='dark'] {
|
||||||
|
--uiws-bg: #0b0f17;
|
||||||
|
--uiws-surface: #1a2234;
|
||||||
|
--uiws-surface-2: #131927;
|
||||||
|
--uiws-border: #26304a;
|
||||||
|
--uiws-text: #e6edf3;
|
||||||
|
--uiws-text-muted: #8892b0;
|
||||||
|
--uiws-text-faint: #4d5568;
|
||||||
|
--uiws-primary: #00a0c8;
|
||||||
|
--uiws-primary-contrast: #ffffff;
|
||||||
|
--uiws-primary-soft: rgba(0, 160, 200, 0.14);
|
||||||
|
--uiws-danger: #e74c3c;
|
||||||
|
--uiws-success: #3ddc97;
|
||||||
|
--uiws-warning: #f1c40f;
|
||||||
|
--uiws-row-hover: rgba(255, 255, 255, 0.04);
|
||||||
|
--uiws-input-bg: #0b0f17;
|
||||||
|
--uiws-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='light'] {
|
||||||
|
--uiws-bg: #f4f6fb;
|
||||||
|
--uiws-surface: #ffffff;
|
||||||
|
--uiws-surface-2: #eef1f7;
|
||||||
|
--uiws-border: #d8deea;
|
||||||
|
--uiws-text: #14202e;
|
||||||
|
--uiws-text-muted: #5b6478;
|
||||||
|
--uiws-text-faint: #97a0b5;
|
||||||
|
--uiws-primary: #0089ab;
|
||||||
|
--uiws-primary-contrast: #ffffff;
|
||||||
|
--uiws-primary-soft: rgba(0, 137, 171, 0.10);
|
||||||
|
--uiws-danger: #d63b2b;
|
||||||
|
--uiws-success: #1f9e58;
|
||||||
|
--uiws-warning: #c79a08;
|
||||||
|
--uiws-row-hover: rgba(0, 0, 0, 0.035);
|
||||||
|
--uiws-input-bg: #ffffff;
|
||||||
|
--uiws-shadow: 0 4px 18px rgba(20, 30, 60, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* UIWS 화면 컨테이너 — 토큰 기반 기본 타이포/배경 */
|
||||||
|
.uiws-scope {
|
||||||
|
color: var(--uiws-text);
|
||||||
|
}
|
||||||
|
.uiws-scope a { color: var(--uiws-primary); }
|
||||||
Loading…
Reference in New Issue
Block a user