feat: Phase 4 - 모선 워크플로우 + 관리자 화면 + 권한 라우트 가드

Phase 4-1: 운영자 워크플로우 백엔드 (자체 DB)
- ParentResolution / ParentReviewLog / CandidateExclusion / LabelSession 엔티티
- Repository 4종 + DTO 5종
- ParentInferenceWorkflowService (HYBRID 패턴):
  - review (CONFIRM/REJECT/RESET) - parent-inference-workflow:parent-review (UPDATE)
  - excludeForGroup - parent-inference-workflow:parent-exclusion (CREATE)
  - excludeGlobal - parent-inference-workflow:exclusion-management (CREATE) [admin]
  - releaseExclusion (UPDATE)
  - createLabelSession / cancelLabelSession (CREATE/UPDATE)
- ParentInferenceWorkflowController: @RequirePermission으로 권한 강제
- 모든 액션에 @Auditable AOP → audit_log + review_log 동시 기록

Phase 4-2: PermTreeController + AdminLogController
- GET /api/perm-tree (모든 사용자) - 메뉴/사이드바 구성용
- GET /api/roles (admin:role-management) - 역할+권한 매트릭스
- GET /api/admin/audit-logs / access-logs / login-history

Phase 4-3: iran 백엔드 프록시 (stub)
- IranBackendClient: RestClient 기반, 호출 실패 시 null 반환 (graceful)
- VesselAnalysisProxyController: serviceAvailable=false 응답
- PredictionProxyController: DISCONNECTED 응답
- Phase 5에서 iran 백엔드 실 연결 시 코드 변경 최소

Phase 4-4: 프론트엔드 services
- parentInferenceApi.ts: 모선 워크플로우 22개 함수
- adminApi.ts: 감사로그/접근이력/로그인이력/권한트리/역할 조회

Phase 4-5: 사이드바 권한 필터링 + ProtectedRoute 권한 가드
- AuthContext.PATH_TO_RESOURCE에 신규 경로 매핑 추가
- ProtectedRoute에 resource/operation prop 추가
  → 권한 거부 시 403 페이지 표시
- 모든 라우트에 권한 리소스 명시
- MainLayout 사이드바: parent-inference-workflow + admin 로그 메뉴 추가
- 사이드바 hasAccess 필터링 (이전부터 구현됨, 신규 메뉴에도 자동 적용)

Phase 4-6: 신규 페이지 3종
- ParentReview.tsx: 모선 확정/거부/리셋 + 신규 등록 폼
- ParentExclusion.tsx: GROUP/GLOBAL 제외 등록 + 해제
- LabelSession.tsx: 학습 세션 생성/취소
- AuditLogs.tsx: 감사 로그 조회
- AccessLogs.tsx: 접근 이력 조회
- LoginHistoryView.tsx: 로그인 이력 조회

Phase 4-7: i18n 키 + 라우터 등록
- 한국어/영어 nav.* + group.* 키 추가
- App.tsx에 12개 신규 라우트 등록 + 권한 가드 적용

Phase 4-8: 검증 완료
- 백엔드 컴파일/기동 성공
- 프론트엔드 빌드 성공 (475ms)
- E2E 시나리오:
  - operator 로그인 → CONFIRM 확정 → MANUAL_CONFIRMED 갱신
  - operator GROUP 제외 → 성공
  - operator GLOBAL 제외 → 403 FORBIDDEN (권한 없음)
  - operator 학습 세션 생성 → ACTIVE
  - admin GLOBAL 제외 → 성공
  - 감사 로그 자동 기록: REVIEW_PARENT/EXCLUDE_CANDIDATE_GROUP/
    LABEL_PARENT_CREATE/EXCLUDE_CANDIDATE_GLOBAL 등 14건
  - 권한 트리 RBAC + AOP 정상 동작 확인

설계 핵심:
- 운영자 의사결정만 자체 DB에 저장 (HYBRID)
- iran 백엔드 데이터는 향후 Phase 5에서 합쳐서 표시
- @RequirePermission + @Auditable로 모든 액션 권한 + 감사 자동화
- 데모 계정으로 완전한 워크플로우 시연 가능

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-04-07 09:44:43 +09:00
부모 b0c9a9fffb
커밋 bae2f33b86
33개의 변경된 파일2403개의 추가작업 그리고 31개의 파일을 삭제

파일 보기

@ -0,0 +1,59 @@
package gc.mda.kcg.admin;
import gc.mda.kcg.audit.AccessLog;
import gc.mda.kcg.audit.AccessLogRepository;
import gc.mda.kcg.audit.AuditLog;
import gc.mda.kcg.audit.AuditLogRepository;
import gc.mda.kcg.auth.LoginHistory;
import gc.mda.kcg.auth.LoginHistoryRepository;
import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 관리자 로그 조회 API.
* - 감사 로그 (auth_audit_log)
* - 접근 이력 (auth_access_log)
* - 로그인 이력 (auth_login_hist)
*/
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
public class AdminLogController {
private final AuditLogRepository auditLogRepository;
private final AccessLogRepository accessLogRepository;
private final LoginHistoryRepository loginHistoryRepository;
@GetMapping("/audit-logs")
@RequirePermission(resource = "admin:audit-logs", operation = "READ")
public Page<AuditLog> getAuditLogs(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
return auditLogRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(page, size));
}
@GetMapping("/access-logs")
@RequirePermission(resource = "admin:access-logs", operation = "READ")
public Page<AccessLog> getAccessLogs(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
return accessLogRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(page, size));
}
@GetMapping("/login-history")
@RequirePermission(resource = "admin:login-history", operation = "READ")
public Page<LoginHistory> getLoginHistory(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
return loginHistoryRepository.findAllByOrderByLoginDtmDesc(PageRequest.of(page, size));
}
}

파일 보기

@ -0,0 +1,54 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.config.AppProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.util.Map;
/**
* iran 백엔드 REST 클라이언트.
*
* 현재는 호출 자체는 시도하되, 연결 불가 graceful degradation:
* - 503 또는 응답을 반환하여 프론트에서 UI 처리
*
* 향후 운영 환경에서 iran 백엔드 base-url이 정확히 설정되면 그대로 사용 가능.
*/
@Slf4j
@Component
public class IranBackendClient {
private final RestClient restClient;
private final boolean enabled;
public IranBackendClient(AppProperties appProperties) {
String baseUrl = appProperties.getIranBackend().getBaseUrl();
this.enabled = baseUrl != null && !baseUrl.isBlank();
this.restClient = enabled
? RestClient.builder().baseUrl(baseUrl).build()
: RestClient.create();
log.info("IranBackendClient initialized: enabled={}, baseUrl={}", enabled, baseUrl);
}
public boolean isEnabled() {
return enabled;
}
/**
* GET 호출 (Map 반환). 실패 null 반환.
*/
public Map<String, Object> getJson(String path) {
if (!enabled) return null;
try {
@SuppressWarnings("unchecked")
Map<String, Object> body = restClient.get().uri(path).retrieve().body(Map.class);
return body;
} catch (RestClientException e) {
log.debug("iran 백엔드 호출 실패: {} - {}", path, e.getMessage());
return null;
}
}
}

파일 보기

@ -0,0 +1,51 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* Prediction (Python FastAPI) 서비스 프록시.
* 현재는 stub - Phase 5에서 연결.
*/
@RestController
@RequestMapping("/api/prediction")
@RequiredArgsConstructor
public class PredictionProxyController {
private final IranBackendClient iranClient;
@GetMapping("/health")
public ResponseEntity<?> health() {
Map<String, Object> data = iranClient.getJson("/api/prediction/health");
if (data == null) {
return ResponseEntity.ok(Map.of(
"status", "DISCONNECTED",
"message", "Prediction 서비스 미연결 (Phase 5에서 연결 예정)"
));
}
return ResponseEntity.ok(data);
}
@GetMapping("/status")
@RequirePermission(resource = "monitoring", operation = "READ")
public ResponseEntity<?> status() {
Map<String, Object> data = iranClient.getJson("/api/prediction/status");
if (data == null) {
return ResponseEntity.ok(Map.of("status", "DISCONNECTED"));
}
return ResponseEntity.ok(data);
}
@PostMapping("/trigger")
@RequirePermission(resource = "ai-operations:mlops", operation = "UPDATE")
public ResponseEntity<?> trigger() {
return ResponseEntity.ok(Map.of("ok", false, "message", "Prediction 서비스 미연결"));
}
}

파일 보기

@ -0,0 +1,58 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* iran 백엔드의 분석 데이터를 프록시 제공.
*
* 현재 단계: iran 백엔드 미연결 응답 + serviceAvailable=false
* 향후 단계: 연결 + 자체 DB의 운영자 결정과 조합 (HYBRID)
*/
@RestController
@RequestMapping("/api/vessel-analysis")
@RequiredArgsConstructor
public class VesselAnalysisProxyController {
private final IranBackendClient iranClient;
@GetMapping
@RequirePermission(resource = "detection", operation = "READ")
public ResponseEntity<?> getVesselAnalysis() {
Map<String, Object> data = iranClient.getJson("/api/vessel-analysis");
if (data == null) {
return ResponseEntity.ok(Map.of(
"serviceAvailable", false,
"message", "iran 백엔드 미연결 (Phase 5에서 연결 예정)",
"results", List.of(),
"stats", Map.of()
));
}
return ResponseEntity.ok(data);
}
@GetMapping("/groups")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> getGroups() {
Map<String, Object> data = iranClient.getJson("/api/vessel-analysis/groups");
if (data == null) {
return ResponseEntity.ok(Map.of("serviceAvailable", false, "groups", List.of()));
}
return ResponseEntity.ok(data);
}
@GetMapping("/groups/{groupKey}/detail")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> getGroupDetail(@PathVariable String groupKey) {
Map<String, Object> data = iranClient.getJson("/api/vessel-analysis/groups/" + groupKey + "/detail");
if (data == null) {
return ResponseEntity.ok(Map.of("serviceAvailable", false, "groupKey", groupKey));
}
return ResponseEntity.ok(data);
}
}

파일 보기

@ -0,0 +1,63 @@
package gc.mda.kcg.domain.fleet;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* 모선 후보 제외 (운영자 결정).
* scope_type: GROUP(그룹 한정) / GLOBAL(전역, 모든 그룹에 적용)
*/
@Entity
@Table(name = "gear_parent_candidate_exclusions", schema = "kcg")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class CandidateExclusion {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "scope_type", nullable = false, length = 20)
private String scopeType; // GROUP, GLOBAL
@Column(name = "group_key", length = 255)
private String groupKey;
@Column(name = "sub_cluster_id")
private Integer subClusterId;
@Column(name = "excluded_mmsi", nullable = false, length = 20)
private String excludedMmsi;
@Column(name = "reason", columnDefinition = "text")
private String reason;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "actor")
private UUID actor;
@Column(name = "actor_acnt", length = 50)
private String actorAcnt;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@Column(name = "released_at")
private OffsetDateTime releasedAt;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "released_by")
private UUID releasedBy;
@Column(name = "released_by_acnt", length = 50)
private String releasedByAcnt;
@PrePersist
void prePersist() {
if (createdAt == null) createdAt = OffsetDateTime.now();
}
}

파일 보기

@ -0,0 +1,73 @@
package gc.mda.kcg.domain.fleet;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
/**
* 모선 추론 학습 세션 (운영자가 정답 라벨링).
*/
@Entity
@Table(name = "gear_parent_label_sessions", schema = "kcg")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class LabelSession {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "group_key", nullable = false, length = 255)
private String groupKey;
@Column(name = "sub_cluster_id", nullable = false)
private Integer subClusterId;
@Column(name = "label_parent_mmsi", nullable = false, length = 20)
private String labelParentMmsi;
@Column(name = "status", nullable = false, length = 20)
private String status; // ACTIVE, CANCELLED, COMPLETED
@Column(name = "active_from", nullable = false)
private OffsetDateTime activeFrom;
@Column(name = "active_until")
private OffsetDateTime activeUntil;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "anchor_snapshot", columnDefinition = "jsonb")
private Map<String, Object> anchorSnapshot;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "created_by")
private UUID createdBy;
@Column(name = "created_by_acnt", length = 50)
private String createdByAcnt;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "cancelled_by")
private UUID cancelledBy;
@Column(name = "cancelled_at")
private OffsetDateTime cancelledAt;
@Column(name = "cancel_reason", columnDefinition = "text")
private String cancelReason;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@PrePersist
void prePersist() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) createdAt = now;
if (activeFrom == null) activeFrom = now;
if (status == null) status = "ACTIVE";
}
}

파일 보기

@ -0,0 +1,131 @@
package gc.mda.kcg.domain.fleet;
import gc.mda.kcg.domain.fleet.dto.*;
import gc.mda.kcg.permission.annotation.RequirePermission;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/parent-inference")
@RequiredArgsConstructor
public class ParentInferenceWorkflowController {
private final ParentInferenceWorkflowService service;
// ========================================================================
// 검토 대기 / 결과 조회
// ========================================================================
@GetMapping("/review")
@RequirePermission(resource = "parent-inference-workflow:parent-review", operation = "READ")
public Page<ParentResolution> listReview(
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
return service.listReview(status, PageRequest.of(page, size));
}
// ========================================================================
// 모선 확정/거부/리셋
// ========================================================================
@PostMapping("/groups/{groupKey}/{subClusterId}/review")
@RequirePermission(resource = "parent-inference-workflow:parent-review", operation = "UPDATE")
public ParentResolution review(
@PathVariable String groupKey,
@PathVariable Integer subClusterId,
@Valid @RequestBody ReviewRequest req
) {
return service.review(groupKey, subClusterId, req);
}
// ========================================================================
// 후보 제외 (그룹 / 전역)
// ========================================================================
@PostMapping("/groups/{groupKey}/{subClusterId}/exclusions")
@RequirePermission(resource = "parent-inference-workflow:parent-exclusion", operation = "CREATE")
public CandidateExclusion excludeForGroup(
@PathVariable String groupKey,
@PathVariable Integer subClusterId,
@Valid @RequestBody ExclusionRequest req
) {
return service.excludeForGroup(groupKey, subClusterId, req);
}
@PostMapping("/exclusions/global")
@RequirePermission(resource = "parent-inference-workflow:exclusion-management", operation = "CREATE")
public CandidateExclusion excludeGlobal(@Valid @RequestBody GlobalExclusionRequest req) {
return service.excludeGlobal(req);
}
@PostMapping("/exclusions/{exclusionId}/release")
@RequirePermission(resource = "parent-inference-workflow:parent-exclusion", operation = "UPDATE")
public CandidateExclusion releaseExclusion(
@PathVariable Long exclusionId,
@RequestBody(required = false) CancelRequest req
) {
return service.releaseExclusion(exclusionId, req);
}
@GetMapping("/exclusions")
@RequirePermission(resource = "parent-inference-workflow:parent-exclusion", operation = "READ")
public Page<CandidateExclusion> listExclusions(
@RequestParam(required = false) String scopeType,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
return service.listExclusions(scopeType, PageRequest.of(page, size));
}
// ========================================================================
// 학습 세션
// ========================================================================
@PostMapping("/groups/{groupKey}/{subClusterId}/label-sessions")
@RequirePermission(resource = "parent-inference-workflow:label-session", operation = "CREATE")
public LabelSession createLabelSession(
@PathVariable String groupKey,
@PathVariable Integer subClusterId,
@Valid @RequestBody LabelSessionRequest req
) {
return service.createLabelSession(groupKey, subClusterId, req);
}
@PostMapping("/label-sessions/{sessionId}/cancel")
@RequirePermission(resource = "parent-inference-workflow:label-session", operation = "UPDATE")
public LabelSession cancelLabelSession(
@PathVariable Long sessionId,
@RequestBody(required = false) CancelRequest req
) {
return service.cancelLabelSession(sessionId, req);
}
@GetMapping("/label-sessions")
@RequirePermission(resource = "parent-inference-workflow:label-session", operation = "READ")
public Page<LabelSession> listLabelSessions(
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
return service.listLabelSessions(status, PageRequest.of(page, size));
}
// ========================================================================
// 도메인 로그 (운영자 액션 이력)
// ========================================================================
@GetMapping("/review-logs")
@RequirePermission(resource = "parent-inference-workflow:parent-review", operation = "READ")
public Page<ParentReviewLog> listReviewLogs(
@RequestParam(required = false) String groupKey,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
return service.listReviewLogs(groupKey, PageRequest.of(page, size));
}
}

파일 보기

@ -0,0 +1,273 @@
package gc.mda.kcg.domain.fleet;
import gc.mda.kcg.audit.annotation.Auditable;
import gc.mda.kcg.auth.AuthPrincipal;
import gc.mda.kcg.domain.fleet.dto.*;
import gc.mda.kcg.domain.fleet.repository.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
import java.util.List;
/**
* 모선 워크플로우 핵심 서비스 (HYBRID).
* - 후보 데이터: iran 백엔드 API 호출 (현재 stub)
* - 운영자 결정: 자체 DB (gear_group_parent_resolution )
*
* 모든 쓰기 액션은 @Auditable로 감사로그 자동 기록.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ParentInferenceWorkflowService {
private final ParentResolutionRepository resolutionRepository;
private final ParentReviewLogRepository reviewLogRepository;
private final CandidateExclusionRepository exclusionRepository;
private final LabelSessionRepository labelSessionRepository;
// ========================================================================
// Resolution (모선 확정/거부/리셋)
// ========================================================================
@Transactional(readOnly = true)
public Page<ParentResolution> listReview(String status, Pageable pageable) {
if (status == null || status.isBlank()) {
return resolutionRepository.findAllByOrderByUpdatedAtDesc(pageable);
}
return resolutionRepository.findByStatusOrderByUpdatedAtDesc(status, pageable);
}
@Auditable(action = "REVIEW_PARENT", resourceType = "GEAR_GROUP")
@Transactional
public ParentResolution review(String groupKey, Integer subClusterId, ReviewRequest req) {
AuthPrincipal principal = currentPrincipal();
ParentResolution res = resolutionRepository
.findByGroupKeyAndSubClusterId(groupKey, subClusterId)
.orElseGet(() -> ParentResolution.builder()
.groupKey(groupKey)
.subClusterId(subClusterId)
.status("UNRESOLVED")
.build());
OffsetDateTime now = OffsetDateTime.now();
switch (req.action().toUpperCase()) {
case "CONFIRM" -> {
res.setStatus("MANUAL_CONFIRMED");
res.setSelectedParentMmsi(req.selectedParentMmsi());
res.setApprovedBy(principal != null ? principal.getUserId() : null);
res.setApprovedAt(now);
res.setManualComment(req.comment());
}
case "REJECT" -> {
res.setStatus("REVIEW_REQUIRED");
res.setRejectedCandidateMmsi(req.selectedParentMmsi());
res.setRejectedAt(now);
res.setManualComment(req.comment());
}
case "RESET" -> {
res.setStatus("UNRESOLVED");
res.setSelectedParentMmsi(null);
res.setRejectedCandidateMmsi(null);
res.setApprovedBy(null);
res.setApprovedAt(null);
res.setRejectedAt(null);
res.setManualComment(req.comment());
}
default -> throw new IllegalArgumentException("UNKNOWN_ACTION: " + req.action());
}
ParentResolution saved = resolutionRepository.save(res);
reviewLogRepository.save(ParentReviewLog.builder()
.groupKey(groupKey)
.subClusterId(subClusterId)
.action(req.action().toUpperCase())
.selectedParentMmsi(req.selectedParentMmsi())
.actor(principal != null ? principal.getUserId() : null)
.actorAcnt(principal != null ? principal.getUserAcnt() : null)
.comment(req.comment())
.build());
return saved;
}
// ========================================================================
// Exclusion (후보 제외)
// ========================================================================
@Auditable(action = "EXCLUDE_CANDIDATE_GROUP", resourceType = "GEAR_GROUP")
@Transactional
public CandidateExclusion excludeForGroup(String groupKey, Integer subClusterId, ExclusionRequest req) {
AuthPrincipal principal = currentPrincipal();
CandidateExclusion exc = CandidateExclusion.builder()
.scopeType("GROUP")
.groupKey(groupKey)
.subClusterId(subClusterId)
.excludedMmsi(req.excludedMmsi())
.reason(req.reason())
.actor(principal != null ? principal.getUserId() : null)
.actorAcnt(principal != null ? principal.getUserAcnt() : null)
.build();
CandidateExclusion saved = exclusionRepository.save(exc);
reviewLogRepository.save(ParentReviewLog.builder()
.groupKey(groupKey)
.subClusterId(subClusterId)
.action("EXCLUDE_GROUP")
.selectedParentMmsi(req.excludedMmsi())
.actor(principal != null ? principal.getUserId() : null)
.actorAcnt(principal != null ? principal.getUserAcnt() : null)
.comment(req.reason())
.build());
return saved;
}
@Auditable(action = "EXCLUDE_CANDIDATE_GLOBAL", resourceType = "GEAR_GROUP")
@Transactional
public CandidateExclusion excludeGlobal(GlobalExclusionRequest req) {
AuthPrincipal principal = currentPrincipal();
CandidateExclusion exc = CandidateExclusion.builder()
.scopeType("GLOBAL")
.excludedMmsi(req.excludedMmsi())
.reason(req.reason())
.actor(principal != null ? principal.getUserId() : null)
.actorAcnt(principal != null ? principal.getUserAcnt() : null)
.build();
CandidateExclusion saved = exclusionRepository.save(exc);
reviewLogRepository.save(ParentReviewLog.builder()
.groupKey("__GLOBAL__")
.action("EXCLUDE_GLOBAL")
.selectedParentMmsi(req.excludedMmsi())
.actor(principal != null ? principal.getUserId() : null)
.actorAcnt(principal != null ? principal.getUserAcnt() : null)
.comment(req.reason())
.build());
return saved;
}
@Auditable(action = "RELEASE_EXCLUSION", resourceType = "GEAR_GROUP")
@Transactional
public CandidateExclusion releaseExclusion(Long exclusionId, CancelRequest req) {
AuthPrincipal principal = currentPrincipal();
CandidateExclusion exc = exclusionRepository.findById(exclusionId)
.orElseThrow(() -> new IllegalArgumentException("EXCLUSION_NOT_FOUND: " + exclusionId));
exc.setReleasedAt(OffsetDateTime.now());
exc.setReleasedBy(principal != null ? principal.getUserId() : null);
exc.setReleasedByAcnt(principal != null ? principal.getUserAcnt() : null);
CandidateExclusion saved = exclusionRepository.save(exc);
reviewLogRepository.save(ParentReviewLog.builder()
.groupKey(exc.getGroupKey() != null ? exc.getGroupKey() : "__GLOBAL__")
.action("RELEASE_EXCLUSION")
.selectedParentMmsi(exc.getExcludedMmsi())
.actor(principal != null ? principal.getUserId() : null)
.actorAcnt(principal != null ? principal.getUserAcnt() : null)
.comment(req != null ? req.reason() : null)
.build());
return saved;
}
@Transactional(readOnly = true)
public Page<CandidateExclusion> listExclusions(String scopeType, Pageable pageable) {
if (scopeType == null || scopeType.isBlank()) {
return exclusionRepository.findActive(pageable);
}
return exclusionRepository.findActiveByScope(scopeType, pageable);
}
// ========================================================================
// Label Session (학습 세션)
// ========================================================================
@Auditable(action = "LABEL_PARENT_CREATE", resourceType = "GEAR_GROUP")
@Transactional
public LabelSession createLabelSession(String groupKey, Integer subClusterId, LabelSessionRequest req) {
AuthPrincipal principal = currentPrincipal();
LabelSession session = LabelSession.builder()
.groupKey(groupKey)
.subClusterId(subClusterId)
.labelParentMmsi(req.labelParentMmsi())
.anchorSnapshot(req.anchorSnapshot())
.createdBy(principal != null ? principal.getUserId() : null)
.createdByAcnt(principal != null ? principal.getUserAcnt() : null)
.build();
LabelSession saved = labelSessionRepository.save(session);
reviewLogRepository.save(ParentReviewLog.builder()
.groupKey(groupKey)
.subClusterId(subClusterId)
.action("LABEL_PARENT")
.selectedParentMmsi(req.labelParentMmsi())
.actor(principal != null ? principal.getUserId() : null)
.actorAcnt(principal != null ? principal.getUserAcnt() : null)
.build());
return saved;
}
@Auditable(action = "LABEL_PARENT_CANCEL", resourceType = "GEAR_GROUP")
@Transactional
public LabelSession cancelLabelSession(Long sessionId, CancelRequest req) {
AuthPrincipal principal = currentPrincipal();
LabelSession session = labelSessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("LABEL_SESSION_NOT_FOUND: " + sessionId));
session.setStatus("CANCELLED");
session.setCancelledAt(OffsetDateTime.now());
session.setCancelledBy(principal != null ? principal.getUserId() : null);
session.setCancelReason(req != null ? req.reason() : null);
LabelSession saved = labelSessionRepository.save(session);
reviewLogRepository.save(ParentReviewLog.builder()
.groupKey(session.getGroupKey())
.subClusterId(session.getSubClusterId())
.action("CANCEL_LABEL")
.selectedParentMmsi(session.getLabelParentMmsi())
.actor(principal != null ? principal.getUserId() : null)
.actorAcnt(principal != null ? principal.getUserAcnt() : null)
.comment(req != null ? req.reason() : null)
.build());
return saved;
}
@Transactional(readOnly = true)
public Page<LabelSession> listLabelSessions(String status, Pageable pageable) {
if (status == null || status.isBlank()) {
return labelSessionRepository.findAllByOrderByCreatedAtDesc(pageable);
}
return labelSessionRepository.findByStatusOrderByCreatedAtDesc(status, pageable);
}
// ========================================================================
// 도메인 로그 조회
// ========================================================================
@Transactional(readOnly = true)
public Page<ParentReviewLog> listReviewLogs(String groupKey, Pageable pageable) {
if (groupKey == null || groupKey.isBlank()) {
return reviewLogRepository.findAllByOrderByCreatedAtDesc(pageable);
}
return reviewLogRepository.findByGroupKeyOrderByCreatedAtDesc(groupKey, pageable);
}
// ========================================================================
// 헬퍼
// ========================================================================
private AuthPrincipal currentPrincipal() {
var auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof AuthPrincipal p) return p;
return null;
}
}

파일 보기

@ -0,0 +1,71 @@
package gc.mda.kcg.domain.fleet;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* 모선 확정 결과 (운영자 의사결정).
* iran 백엔드의 후보 데이터(prediction이 생성) 별도로 운영자 결정만 자체 DB에 저장.
*/
@Entity
@Table(name = "gear_group_parent_resolution", schema = "kcg",
uniqueConstraints = @UniqueConstraint(columnNames = {"group_key", "sub_cluster_id"}))
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class ParentResolution {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "group_key", nullable = false, length = 255)
private String groupKey;
@Column(name = "sub_cluster_id", nullable = false)
private Integer subClusterId;
@Column(name = "status", nullable = false, length = 30)
private String status; // UNRESOLVED, MANUAL_CONFIRMED, REVIEW_REQUIRED
@Column(name = "selected_parent_mmsi", length = 20)
private String selectedParentMmsi;
@Column(name = "rejected_candidate_mmsi", length = 20)
private String rejectedCandidateMmsi;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "approved_by")
private UUID approvedBy;
@Column(name = "approved_at")
private OffsetDateTime approvedAt;
@Column(name = "rejected_at")
private OffsetDateTime rejectedAt;
@Column(name = "manual_comment", columnDefinition = "text")
private String manualComment;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@PrePersist
void prePersist() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) createdAt = now;
if (updatedAt == null) updatedAt = now;
if (status == null) status = "UNRESOLVED";
}
@PreUpdate
void preUpdate() {
updatedAt = OffsetDateTime.now();
}
}

파일 보기

@ -0,0 +1,53 @@
package gc.mda.kcg.domain.fleet;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* 운영자 액션 로그 (도메인 컨텍스트 보존).
* audit_log와 별개로 group_key 도메인 정보를 직접 저장.
*/
@Entity
@Table(name = "gear_group_parent_review_log", schema = "kcg")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class ParentReviewLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "group_key", nullable = false, length = 255)
private String groupKey;
@Column(name = "sub_cluster_id")
private Integer subClusterId;
@Column(name = "action", nullable = false, length = 30)
private String action; // CONFIRM, REJECT, RESET, EXCLUDE_GROUP, EXCLUDE_GLOBAL, LABEL_PARENT, CANCEL_LABEL, RELEASE_EXCLUSION
@Column(name = "selected_parent_mmsi", length = 20)
private String selectedParentMmsi;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "actor")
private UUID actor;
@Column(name = "actor_acnt", length = 50)
private String actorAcnt;
@Column(name = "comment", columnDefinition = "text")
private String comment;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@PrePersist
void prePersist() {
if (createdAt == null) createdAt = OffsetDateTime.now();
}
}

파일 보기

@ -0,0 +1,3 @@
package gc.mda.kcg.domain.fleet.dto;
public record CancelRequest(String reason) {}

파일 보기

@ -0,0 +1,8 @@
package gc.mda.kcg.domain.fleet.dto;
import jakarta.validation.constraints.NotBlank;
public record ExclusionRequest(
@NotBlank String excludedMmsi,
String reason
) {}

파일 보기

@ -0,0 +1,8 @@
package gc.mda.kcg.domain.fleet.dto;
import jakarta.validation.constraints.NotBlank;
public record GlobalExclusionRequest(
@NotBlank String excludedMmsi,
String reason
) {}

파일 보기

@ -0,0 +1,10 @@
package gc.mda.kcg.domain.fleet.dto;
import jakarta.validation.constraints.NotBlank;
import java.util.Map;
public record LabelSessionRequest(
@NotBlank String labelParentMmsi,
Map<String, Object> anchorSnapshot
) {}

파일 보기

@ -0,0 +1,13 @@
package gc.mda.kcg.domain.fleet.dto;
import jakarta.validation.constraints.NotBlank;
/**
* 모선 확정/거부/리셋 요청.
* action: CONFIRM, REJECT, RESET
*/
public record ReviewRequest(
@NotBlank String action,
String selectedParentMmsi,
String comment
) {}

파일 보기

@ -0,0 +1,22 @@
package gc.mda.kcg.domain.fleet.repository;
import gc.mda.kcg.domain.fleet.CandidateExclusion;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface CandidateExclusionRepository extends JpaRepository<CandidateExclusion, Long> {
@Query("SELECT e FROM CandidateExclusion e WHERE e.releasedAt IS NULL ORDER BY e.createdAt DESC")
Page<CandidateExclusion> findActive(Pageable pageable);
@Query("SELECT e FROM CandidateExclusion e WHERE e.scopeType = :scopeType AND e.releasedAt IS NULL ORDER BY e.createdAt DESC")
Page<CandidateExclusion> findActiveByScope(@Param("scopeType") String scopeType, Pageable pageable);
@Query("SELECT e FROM CandidateExclusion e WHERE e.groupKey = :groupKey AND e.releasedAt IS NULL ORDER BY e.createdAt DESC")
List<CandidateExclusion> findActiveByGroupKey(@Param("groupKey") String groupKey);
}

파일 보기

@ -0,0 +1,14 @@
package gc.mda.kcg.domain.fleet.repository;
import gc.mda.kcg.domain.fleet.LabelSession;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface LabelSessionRepository extends JpaRepository<LabelSession, Long> {
Page<LabelSession> findByStatusOrderByCreatedAtDesc(String status, Pageable pageable);
Page<LabelSession> findAllByOrderByCreatedAtDesc(Pageable pageable);
List<LabelSession> findByGroupKeyAndStatus(String groupKey, String status);
}

파일 보기

@ -0,0 +1,16 @@
package gc.mda.kcg.domain.fleet.repository;
import gc.mda.kcg.domain.fleet.ParentResolution;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface ParentResolutionRepository extends JpaRepository<ParentResolution, Long> {
Optional<ParentResolution> findByGroupKeyAndSubClusterId(String groupKey, Integer subClusterId);
List<ParentResolution> findByGroupKey(String groupKey);
Page<ParentResolution> findByStatusOrderByUpdatedAtDesc(String status, Pageable pageable);
Page<ParentResolution> findAllByOrderByUpdatedAtDesc(Pageable pageable);
}

파일 보기

@ -0,0 +1,11 @@
package gc.mda.kcg.domain.fleet.repository;
import gc.mda.kcg.domain.fleet.ParentReviewLog;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ParentReviewLogRepository extends JpaRepository<ParentReviewLog, Long> {
Page<ParentReviewLog> findByGroupKeyOrderByCreatedAtDesc(String groupKey, Pageable pageable);
Page<ParentReviewLog> findAllByOrderByCreatedAtDesc(Pageable pageable);
}

파일 보기

@ -0,0 +1,47 @@
package gc.mda.kcg.permission;
import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
/**
* 권한 트리 + 역할 조회 API.
* - GET /api/perm-tree: 모든 사용자 (메뉴/사이드바 구성용)
* - GET /api/roles: admin:permission-management 권한 필요
*/
@RestController
@RequiredArgsConstructor
public class PermTreeController {
private final PermTreeRepository permTreeRepository;
private final RoleRepository roleRepository;
private final PermRepository permRepository;
@GetMapping("/api/perm-tree")
public List<PermTree> getPermTree() {
return permTreeRepository.findAllByOrderByRsrcLevelAscSortOrdAsc();
}
@GetMapping("/api/roles")
@RequirePermission(resource = "admin:role-management", operation = "READ")
public List<Map<String, Object>> getRolesWithPermissions() {
List<Role> roles = roleRepository.findAllByOrderByRoleSnAsc();
return roles.stream().<Map<String, Object>>map(r -> {
List<Perm> perms = permRepository.findByRoleSn(r.getRoleSn());
return Map.of(
"roleSn", r.getRoleSn(),
"roleCd", r.getRoleCd(),
"roleNm", r.getRoleNm(),
"roleDc", r.getRoleDc() == null ? "" : r.getRoleDc(),
"dfltYn", r.getDfltYn(),
"builtinYn", r.getBuiltinYn(),
"permissions", perms
);
}).toList();
}
}

파일 보기

@ -28,10 +28,43 @@ import { VesselDetail } from '@features/vessel';
import { ChinaFishing } from '@features/detection'; import { ChinaFishing } from '@features/detection';
import { ReportManagement } from '@features/statistics'; import { ReportManagement } from '@features/statistics';
import { AdminPanel } from '@features/admin'; import { AdminPanel } from '@features/admin';
// Phase 4: 모선 워크플로우
import { ParentReview } from '@features/parent-inference/ParentReview';
import { ParentExclusion } from '@features/parent-inference/ParentExclusion';
import { LabelSession } from '@features/parent-inference/LabelSession';
// Phase 4: 관리자 로그
import { AuditLogs } from '@features/admin/AuditLogs';
import { AccessLogs } from '@features/admin/AccessLogs';
import { LoginHistoryView } from '@features/admin/LoginHistoryView';
function ProtectedRoute({ children }: { children: React.ReactNode }) { /**
const { user } = useAuth(); * .
* - user /login으로
* - resource hasPermission 403
*/
function ProtectedRoute({
children,
resource,
operation = 'READ',
}: {
children: React.ReactNode;
resource?: string;
operation?: string;
}) {
const { user, loading, hasPermission } = useAuth();
if (loading) return null;
if (!user) return <Navigate to="/login" replace />; if (!user) return <Navigate to="/login" replace />;
if (resource && !hasPermission(resource, operation)) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center px-6">
<div className="text-4xl mb-4">🚫</div>
<h1 className="text-xl font-bold text-heading mb-2"> </h1>
<p className="text-sm text-hint">
<code className="bg-surface-overlay px-1.5 py-0.5 rounded">{resource}</code>::{operation} .
</p>
</div>
);
}
return <>{children}</>; return <>{children}</>;
} }
@ -44,46 +77,54 @@ export default function App() {
<Route path="/" element={<ProtectedRoute><MainLayout /></ProtectedRoute>}> <Route path="/" element={<ProtectedRoute><MainLayout /></ProtectedRoute>}>
<Route index element={<Navigate to="/dashboard" replace />} /> <Route index element={<Navigate to="/dashboard" replace />} />
{/* SFR-12 대시보드 */} {/* SFR-12 대시보드 */}
<Route path="dashboard" element={<Dashboard />} /> <Route path="dashboard" element={<ProtectedRoute resource="dashboard"><Dashboard /></ProtectedRoute>} />
<Route path="monitoring" element={<MonitoringDashboard />} /> <Route path="monitoring" element={<ProtectedRoute resource="monitoring"><MonitoringDashboard /></ProtectedRoute>} />
{/* SFR-05~06 위험도·단속계획 */} {/* SFR-05~06 위험도·단속계획 */}
<Route path="risk-map" element={<RiskMap />} /> <Route path="risk-map" element={<ProtectedRoute resource="risk-assessment:risk-map"><RiskMap /></ProtectedRoute>} />
<Route path="enforcement-plan" element={<EnforcementPlan />} /> <Route path="enforcement-plan" element={<ProtectedRoute resource="risk-assessment:enforcement-plan"><EnforcementPlan /></ProtectedRoute>} />
{/* SFR-09~10 탐지 */} {/* SFR-09~10 탐지 */}
<Route path="dark-vessel" element={<DarkVesselDetection />} /> <Route path="dark-vessel" element={<ProtectedRoute resource="detection:dark-vessel"><DarkVesselDetection /></ProtectedRoute>} />
<Route path="gear-detection" element={<GearDetection />} /> <Route path="gear-detection" element={<ProtectedRoute resource="detection:gear-detection"><GearDetection /></ProtectedRoute>} />
<Route path="china-fishing" element={<ChinaFishing />} /> <Route path="china-fishing" element={<ProtectedRoute resource="detection:china-fishing"><ChinaFishing /></ProtectedRoute>} />
{/* SFR-07~08 순찰경로 */} {/* SFR-07~08 순찰경로 */}
<Route path="patrol-route" element={<PatrolRoute />} /> <Route path="patrol-route" element={<ProtectedRoute resource="patrol:patrol-route"><PatrolRoute /></ProtectedRoute>} />
<Route path="fleet-optimization" element={<FleetOptimization />} /> <Route path="fleet-optimization" element={<ProtectedRoute resource="patrol:fleet-optimization"><FleetOptimization /></ProtectedRoute>} />
{/* SFR-11 이력 */} {/* SFR-11 이력 */}
<Route path="enforcement-history" element={<EnforcementHistory />} /> <Route path="enforcement-history" element={<ProtectedRoute resource="enforcement:enforcement-history"><EnforcementHistory /></ProtectedRoute>} />
<Route path="event-list" element={<EventList />} /> <Route path="event-list" element={<ProtectedRoute resource="enforcement:event-list"><EventList /></ProtectedRoute>} />
{/* SFR-15~17 현장 대응 */} {/* SFR-15~17 현장 대응 */}
<Route path="mobile-service" element={<MobileService />} /> <Route path="mobile-service" element={<ProtectedRoute resource="field-ops:mobile-service"><MobileService /></ProtectedRoute>} />
<Route path="ship-agent" element={<ShipAgent />} /> <Route path="ship-agent" element={<ProtectedRoute resource="field-ops:ship-agent"><ShipAgent /></ProtectedRoute>} />
<Route path="ai-alert" element={<AIAlert />} /> <Route path="ai-alert" element={<ProtectedRoute resource="field-ops:ai-alert"><AIAlert /></ProtectedRoute>} />
{/* SFR-13~14 통계·외부연계 */} {/* SFR-13~14 통계·외부연계 */}
<Route path="statistics" element={<Statistics />} /> <Route path="statistics" element={<ProtectedRoute resource="statistics:statistics"><Statistics /></ProtectedRoute>} />
<Route path="external-service" element={<ExternalService />} /> <Route path="external-service" element={<ProtectedRoute resource="statistics:external-service"><ExternalService /></ProtectedRoute>} />
<Route path="reports" element={<ReportManagement />} /> <Route path="reports" element={<ProtectedRoute resource="statistics:statistics"><ReportManagement /></ProtectedRoute>} />
{/* SFR-04 AI 모델 */} {/* SFR-04 AI 모델 */}
<Route path="ai-model" element={<AIModelManagement />} /> <Route path="ai-model" element={<ProtectedRoute resource="ai-operations:ai-model"><AIModelManagement /></ProtectedRoute>} />
{/* SFR-18~20 AI 운영 */} {/* SFR-18~20 AI 운영 */}
<Route path="mlops" element={<MLOpsPage />} /> <Route path="mlops" element={<ProtectedRoute resource="ai-operations:mlops"><MLOpsPage /></ProtectedRoute>} />
<Route path="ai-assistant" element={<AIAssistant />} /> <Route path="ai-assistant" element={<ProtectedRoute resource="ai-operations:ai-assistant"><AIAssistant /></ProtectedRoute>} />
{/* SFR-03 데이터허브 */} {/* SFR-03 데이터허브 */}
<Route path="data-hub" element={<DataHub />} /> <Route path="data-hub" element={<ProtectedRoute resource="admin:system-config"><DataHub /></ProtectedRoute>} />
{/* SFR-02 환경설정 */} {/* SFR-02 환경설정 */}
<Route path="system-config" element={<SystemConfig />} /> <Route path="system-config" element={<ProtectedRoute resource="admin:system-config"><SystemConfig /></ProtectedRoute>} />
<Route path="notices" element={<NoticeManagement />} /> <Route path="notices" element={<ProtectedRoute resource="admin"><NoticeManagement /></ProtectedRoute>} />
{/* SFR-01 권한·시스템 */} {/* SFR-01 권한·시스템 */}
<Route path="access-control" element={<AccessControl />} /> <Route path="access-control" element={<ProtectedRoute resource="admin:permission-management"><AccessControl /></ProtectedRoute>} />
<Route path="admin" element={<AdminPanel />} /> <Route path="admin" element={<ProtectedRoute resource="admin"><AdminPanel /></ProtectedRoute>} />
{/* Phase 4: 관리자 로그 */}
<Route path="admin/audit-logs" element={<ProtectedRoute resource="admin:audit-logs"><AuditLogs /></ProtectedRoute>} />
<Route path="admin/access-logs" element={<ProtectedRoute resource="admin:access-logs"><AccessLogs /></ProtectedRoute>} />
<Route path="admin/login-history" element={<ProtectedRoute resource="admin:login-history"><LoginHistoryView /></ProtectedRoute>} />
{/* Phase 4: 모선 워크플로우 */}
<Route path="parent-inference/review" element={<ProtectedRoute resource="parent-inference-workflow:parent-review"><ParentReview /></ProtectedRoute>} />
<Route path="parent-inference/exclusion" element={<ProtectedRoute resource="parent-inference-workflow:parent-exclusion"><ParentExclusion /></ProtectedRoute>} />
<Route path="parent-inference/label-session" element={<ProtectedRoute resource="parent-inference-workflow:label-session"><LabelSession /></ProtectedRoute>} />
{/* 기존 유지 */} {/* 기존 유지 */}
<Route path="events" element={<LiveMapView />} /> <Route path="events" element={<ProtectedRoute resource="surveillance:live-map"><LiveMapView /></ProtectedRoute>} />
<Route path="map-control" element={<MapControl />} /> <Route path="map-control" element={<ProtectedRoute resource="surveillance:map-control"><MapControl /></ProtectedRoute>} />
<Route path="vessel/:id" element={<VesselDetail />} /> <Route path="vessel/:id" element={<ProtectedRoute resource="vessel:vessel-detail"><VesselDetail /></ProtectedRoute>} />
</Route> </Route>
</Routes> </Routes>
</AuthProvider> </AuthProvider>

파일 보기

@ -57,12 +57,20 @@ const PATH_TO_RESOURCE: Record<string, string> = {
'/mlops': 'ai-operations:mlops', '/mlops': 'ai-operations:mlops',
'/statistics': 'statistics:statistics', '/statistics': 'statistics:statistics',
'/external-service': 'statistics:external-service', '/external-service': 'statistics:external-service',
'/admin/audit-logs': 'admin:audit-logs',
'/admin/access-logs': 'admin:access-logs',
'/admin/login-history': 'admin:login-history',
'/admin': 'admin', '/admin': 'admin',
'/access-control': 'admin:permission-management', '/access-control': 'admin:permission-management',
'/system-config': 'admin:system-config', '/system-config': 'admin:system-config',
'/notices': 'admin', '/notices': 'admin',
'/reports': 'statistics:statistics', '/reports': 'statistics:statistics',
'/data-hub': 'admin:system-config', '/data-hub': 'admin:system-config',
// 모선 워크플로우
'/parent-inference/review': 'parent-inference-workflow:parent-review',
'/parent-inference/exclusion': 'parent-inference-workflow:parent-exclusion',
'/parent-inference/label-session': 'parent-inference-workflow:label-session',
'/parent-inference': 'parent-inference-workflow',
}; };
interface AuthContextType { interface AuthContextType {

파일 보기

@ -9,6 +9,7 @@ import {
ChevronsLeft, ChevronsRight, ChevronsLeft, ChevronsRight,
Navigation, Users, EyeOff, BarChart3, Globe, Navigation, Users, EyeOff, BarChart3, Globe,
Smartphone, Monitor, Send, Cpu, MessageSquare, Smartphone, Monitor, Send, Cpu, MessageSquare,
GitBranch, CheckSquare, Ban, Tag, ScrollText, History, KeyRound,
} from 'lucide-react'; } from 'lucide-react';
import { useAuth, type UserRole } from '@/app/auth/AuthContext'; import { useAuth, type UserRole } from '@/app/auth/AuthContext';
import { NotificationBanner, NotificationPopup, type SystemNotice } from '@shared/components/common/NotificationBanner'; import { NotificationBanner, NotificationPopup, type SystemNotice } from '@shared/components/common/NotificationBanner';
@ -75,6 +76,15 @@ const NAV_ENTRIES: NavEntry[] = [
{ to: '/ship-agent', icon: Monitor, labelKey: 'nav.shipAgent' }, { to: '/ship-agent', icon: Monitor, labelKey: 'nav.shipAgent' },
], ],
}, },
// ── 모선 워크플로우 (운영자 의사결정, 그룹) ──
{
groupKey: 'group.parentInference', icon: GitBranch,
items: [
{ to: '/parent-inference/review', icon: CheckSquare, labelKey: 'nav.parentReview' },
{ to: '/parent-inference/exclusion', icon: Ban, labelKey: 'nav.parentExclusion' },
{ to: '/parent-inference/label-session', icon: Tag, labelKey: 'nav.labelSession' },
],
},
// ── 관리자 (그룹) ── // ── 관리자 (그룹) ──
{ {
groupKey: 'group.admin', icon: Settings, groupKey: 'group.admin', icon: Settings,
@ -88,6 +98,9 @@ const NAV_ENTRIES: NavEntry[] = [
{ to: '/notices', icon: Megaphone, labelKey: 'nav.notices' }, { to: '/notices', icon: Megaphone, labelKey: 'nav.notices' },
{ to: '/admin', icon: Settings, labelKey: 'nav.admin' }, { to: '/admin', icon: Settings, labelKey: 'nav.admin' },
{ to: '/access-control', icon: Fingerprint, labelKey: 'nav.accessControl' }, { to: '/access-control', icon: Fingerprint, labelKey: 'nav.accessControl' },
{ to: '/admin/audit-logs', icon: ScrollText, labelKey: 'nav.auditLogs' },
{ to: '/admin/access-logs', icon: History, labelKey: 'nav.accessLogs' },
{ to: '/admin/login-history', icon: KeyRound, labelKey: 'nav.loginHistory' },
], ],
}, },
]; ];

파일 보기

@ -0,0 +1,89 @@
import { useEffect, useState, useCallback } from 'react';
import { Loader2, RefreshCw } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { fetchAccessLogs, type AccessLog } from '@/services/adminApi';
/**
* ( HTTP ).
* 권한: admin:access-logs (READ)
*
* AccessLogFilter가 .
*/
export function AccessLogs() {
const [items, setItems] = useState<AccessLog[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const res = await fetchAccessLogs(0, 100);
setItems(res.content);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'unknown');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const statusColor = (s: number) => s >= 500 ? 'bg-red-500/20 text-red-400' : s >= 400 ? 'bg-orange-500/20 text-orange-400' : 'bg-green-500/20 text-green-400';
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-heading"> </h1>
<p className="text-xs text-hint mt-1"> HTTP (AccessLogFilter )</p>
</div>
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded flex items-center gap-1">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
{error && <div className="text-xs text-red-400">: {error}</div>}
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
{!loading && (
<Card>
<CardContent className="p-0 overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-surface-overlay text-hint">
<tr>
<th className="px-3 py-2 text-left">SN</th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-center"></th>
<th className="px-3 py-2 text-right">(ms)</th>
<th className="px-3 py-2 text-left">IP</th>
</tr>
</thead>
<tbody>
{items.length === 0 && <tr><td colSpan={8} className="px-3 py-8 text-center text-hint"> .</td></tr>}
{items.map((it) => (
<tr key={it.accessSn} className="border-t border-border hover:bg-surface-overlay/50">
<td className="px-3 py-2 text-hint font-mono">{it.accessSn}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{new Date(it.createdAt).toLocaleString('ko-KR')}</td>
<td className="px-3 py-2 text-cyan-400">{it.userAcnt || '-'}</td>
<td className="px-3 py-2 text-purple-400 font-mono">{it.httpMethod}</td>
<td className="px-3 py-2 text-heading font-mono text-[10px] max-w-md truncate">{it.requestPath}</td>
<td className="px-3 py-2 text-center">
<Badge className={`border-0 text-[9px] ${statusColor(it.statusCode)}`}>{it.statusCode}</Badge>
</td>
<td className="px-3 py-2 text-right text-muted-foreground">{it.durationMs}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{it.ipAddress || '-'}</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)}
</div>
);
}

파일 보기

@ -0,0 +1,94 @@
import { useEffect, useState, useCallback } from 'react';
import { Loader2, RefreshCw } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { fetchAuditLogs, type AuditLog } from '@/services/adminApi';
/**
* .
* 권한: admin:audit-logs (READ)
*
* (CONFIRM/REJECT/EXCLUDE/LABEL/LOGIN/...)
* AuditAspect를 .
*/
export function AuditLogs() {
const [items, setItems] = useState<AuditLog[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const res = await fetchAuditLogs(0, 100);
setItems(res.content);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'unknown');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-heading"> </h1>
<p className="text-xs text-hint mt-1"> (LOGIN/REVIEW_PARENT/EXCLUDE/LABEL...)</p>
</div>
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded flex items-center gap-1">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
{error && <div className="text-xs text-red-400">: {error}</div>}
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
{!loading && (
<Card>
<CardContent className="p-0 overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-surface-overlay text-hint">
<tr>
<th className="px-3 py-2 text-left">SN</th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"> </th>
<th className="px-3 py-2 text-left">IP</th>
<th className="px-3 py-2 text-left"></th>
</tr>
</thead>
<tbody>
{items.length === 0 && <tr><td colSpan={9} className="px-3 py-8 text-center text-hint"> .</td></tr>}
{items.map((it) => (
<tr key={it.auditSn} className="border-t border-border hover:bg-surface-overlay/50">
<td className="px-3 py-2 text-hint font-mono">{it.auditSn}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{new Date(it.createdAt).toLocaleString('ko-KR')}</td>
<td className="px-3 py-2 text-cyan-400">{it.userAcnt || '-'}</td>
<td className="px-3 py-2 text-heading font-medium">{it.actionCd}</td>
<td className="px-3 py-2 text-muted-foreground">{it.resourceType ?? '-'} {it.resourceId ? `(${it.resourceId})` : ''}</td>
<td className="px-3 py-2">
<Badge className={`border-0 text-[9px] ${it.result === 'SUCCESS' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}`}>
{it.result || '-'}
</Badge>
</td>
<td className="px-3 py-2 text-red-400 text-[10px]">{it.failReason || '-'}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{it.ipAddress || '-'}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px] max-w-xs truncate">
{it.detail ? JSON.stringify(it.detail) : '-'}
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)}
</div>
);
}

파일 보기

@ -0,0 +1,89 @@
import { useEffect, useState, useCallback } from 'react';
import { Loader2, RefreshCw } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { fetchLoginHistory, type LoginHistory } from '@/services/adminApi';
/**
* .
* 권한: admin:login-history (READ)
*/
export function LoginHistoryView() {
const [items, setItems] = useState<LoginHistory[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const res = await fetchLoginHistory(0, 100);
setItems(res.content);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'unknown');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const resultColor = (r: string) => {
if (r === 'SUCCESS') return 'bg-green-500/20 text-green-400';
if (r === 'LOCKED') return 'bg-red-500/20 text-red-400';
return 'bg-orange-500/20 text-orange-400';
};
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-heading"> </h1>
<p className="text-xs text-hint mt-1">/ (5 )</p>
</div>
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded flex items-center gap-1">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
{error && <div className="text-xs text-red-400">: {error}</div>}
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
{!loading && (
<Card>
<CardContent className="p-0 overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-surface-overlay text-hint">
<tr>
<th className="px-3 py-2 text-left">SN</th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"> </th>
<th className="px-3 py-2 text-left"> </th>
<th className="px-3 py-2 text-left">IP</th>
</tr>
</thead>
<tbody>
{items.length === 0 && <tr><td colSpan={7} className="px-3 py-8 text-center text-hint"> .</td></tr>}
{items.map((it) => (
<tr key={it.histSn} className="border-t border-border hover:bg-surface-overlay/50">
<td className="px-3 py-2 text-hint font-mono">{it.histSn}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{new Date(it.loginDtm).toLocaleString('ko-KR')}</td>
<td className="px-3 py-2 text-cyan-400">{it.userAcnt}</td>
<td className="px-3 py-2">
<Badge className={`border-0 text-[9px] ${resultColor(it.result)}`}>{it.result}</Badge>
</td>
<td className="px-3 py-2 text-red-400 text-[10px]">{it.failReason || '-'}</td>
<td className="px-3 py-2 text-muted-foreground">{it.authProvider || '-'}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{it.loginIp || '-'}</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)}
</div>
);
}

파일 보기

@ -0,0 +1,185 @@
import { useEffect, useState, useCallback } from 'react';
import { Tag, X, Loader2 } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { useAuth } from '@/app/auth/AuthContext';
import {
fetchLabelSessions,
createLabelSession,
cancelLabelSession,
type LabelSession as LabelSessionType,
} from '@/services/parentInferenceApi';
/**
* .
* prediction .
*
* 권한: parent-inference-workflow:label-session (READ + CREATE + UPDATE)
*/
const STATUS_COLORS: Record<string, string> = {
ACTIVE: 'bg-green-500/20 text-green-400',
CANCELLED: 'bg-gray-500/20 text-gray-400',
COMPLETED: 'bg-blue-500/20 text-blue-400',
};
export function LabelSession() {
const { hasPermission } = useAuth();
const canCreate = hasPermission('parent-inference-workflow:label-session', 'CREATE');
const canUpdate = hasPermission('parent-inference-workflow:label-session', 'UPDATE');
const [items, setItems] = useState<LabelSessionType[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [filter, setFilter] = useState<string>('');
const [busy, setBusy] = useState<number | null>(null);
// 신규 세션
const [groupKey, setGroupKey] = useState('');
const [subCluster, setSubCluster] = useState('1');
const [labelMmsi, setLabelMmsi] = useState('');
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const res = await fetchLabelSessions(filter || undefined, 0, 50);
setItems(res.content);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'unknown');
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => { load(); }, [load]);
const handleCreate = async () => {
if (!canCreate || !groupKey || !labelMmsi) return;
setBusy(-1);
try {
await createLabelSession(groupKey, parseInt(subCluster, 10), {
labelParentMmsi: labelMmsi,
anchorSnapshot: { source: 'manual', timestamp: new Date().toISOString() },
});
setGroupKey(''); setLabelMmsi('');
await load();
} catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
} finally {
setBusy(null);
}
};
const handleCancel = async (id: number) => {
if (!canUpdate) return;
if (!confirm('세션을 취소하시겠습니까?')) return;
setBusy(id);
try {
await cancelLabelSession(id, '운영자 취소');
await load();
} catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
} finally {
setBusy(null);
}
};
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-heading"> </h1>
<p className="text-xs text-hint mt-1"> prediction </p>
</div>
<div className="flex items-center gap-2">
<select value={filter} onChange={(e) => setFilter(e.target.value)}
className="bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs text-heading">
<option value=""> </option>
<option value="ACTIVE">ACTIVE</option>
<option value="CANCELLED">CANCELLED</option>
<option value="COMPLETED">COMPLETED</option>
</select>
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded"></button>
</div>
</div>
<Card>
<CardContent className="p-4">
<div className="text-xs font-medium text-heading mb-2 flex items-center gap-2">
<Tag className="w-3.5 h-3.5" />
{!canCreate && <span className="text-yellow-400 text-[10px]"> </span>}
</div>
<div className="flex items-center gap-2">
<input value={groupKey} onChange={(e) => setGroupKey(e.target.value)} placeholder="group_key"
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} />
<input type="number" value={subCluster} onChange={(e) => setSubCluster(e.target.value)} placeholder="sub"
className="w-24 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} />
<input value={labelMmsi} onChange={(e) => setLabelMmsi(e.target.value)} placeholder="정답 parent MMSI"
className="w-48 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} />
<button type="button" onClick={handleCreate}
disabled={!canCreate || !groupKey || !labelMmsi || busy === -1}
className="px-3 py-1.5 bg-purple-600 hover:bg-purple-500 disabled:bg-purple-600/40 text-white text-xs rounded flex items-center gap-1">
{busy === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Tag className="w-3.5 h-3.5" />}
</button>
</div>
</CardContent>
</Card>
{error && <div className="text-xs text-red-400">: {error}</div>}
{loading && (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
</div>
)}
{!loading && (
<Card>
<CardContent className="p-0 overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-surface-overlay text-hint">
<tr>
<th className="px-3 py-2 text-left">ID</th>
<th className="px-3 py-2 text-left">Group Key</th>
<th className="px-3 py-2 text-center">Sub</th>
<th className="px-3 py-2 text-left"> MMSI</th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-center"></th>
</tr>
</thead>
<tbody>
{items.length === 0 && (
<tr><td colSpan={8} className="px-3 py-8 text-center text-hint"> .</td></tr>
)}
{items.map((it) => (
<tr key={it.id} className="border-t border-border hover:bg-surface-overlay/50">
<td className="px-3 py-2 text-hint font-mono">{it.id}</td>
<td className="px-3 py-2 text-heading font-medium">{it.groupKey}</td>
<td className="px-3 py-2 text-center text-muted-foreground">{it.subClusterId}</td>
<td className="px-3 py-2 text-cyan-400 font-mono">{it.labelParentMmsi}</td>
<td className="px-3 py-2">
<Badge className={`border-0 text-[9px] ${STATUS_COLORS[it.status] || ''}`}>{it.status}</Badge>
</td>
<td className="px-3 py-2 text-muted-foreground">{it.createdByAcnt || '-'}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{new Date(it.activeFrom).toLocaleString('ko-KR')}</td>
<td className="px-3 py-2 text-center">
{it.status === 'ACTIVE' && (
<button type="button" disabled={!canUpdate || busy === it.id} onClick={() => handleCancel(it.id)}
className="p-1 rounded hover:bg-red-500/20 disabled:opacity-30 text-red-400" title="취소">
<X className="w-3.5 h-3.5" />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)}
</div>
);
}

파일 보기

@ -0,0 +1,230 @@
import { useEffect, useState, useCallback } from 'react';
import { Ban, RotateCcw, Loader2, Globe, Layers } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { useAuth } from '@/app/auth/AuthContext';
import {
fetchExclusions,
excludeForGroup,
excludeGlobal,
releaseExclusion,
type CandidateExclusion,
} from '@/services/parentInferenceApi';
/**
* .
* - GROUP scope: 특정 group_key + sub_cluster에서
* - GLOBAL scope: 모든 (admin: exclusion-management )
*
* :
* - parent-inference-workflow:parent-exclusion (CREATE/UPDATE/READ): GROUP scope
* - parent-inference-workflow:exclusion-management (CREATE): GLOBAL scope
*/
export function ParentExclusion() {
const { hasPermission } = useAuth();
const canCreateGroup = hasPermission('parent-inference-workflow:parent-exclusion', 'CREATE');
const canRelease = hasPermission('parent-inference-workflow:parent-exclusion', 'UPDATE');
const canCreateGlobal = hasPermission('parent-inference-workflow:exclusion-management', 'CREATE');
const [items, setItems] = useState<CandidateExclusion[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [filter, setFilter] = useState<'' | 'GROUP' | 'GLOBAL'>('');
const [busy, setBusy] = useState<number | null>(null);
// 신규 GROUP 제외 폼
const [grpKey, setGrpKey] = useState('');
const [grpSub, setGrpSub] = useState('1');
const [grpMmsi, setGrpMmsi] = useState('');
const [grpReason, setGrpReason] = useState('');
// 신규 GLOBAL 제외 폼
const [glbMmsi, setGlbMmsi] = useState('');
const [glbReason, setGlbReason] = useState('');
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const res = await fetchExclusions(filter || undefined, 0, 50);
setItems(res.content);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'unknown';
setError(msg);
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => { load(); }, [load]);
const handleAddGroup = async () => {
if (!canCreateGroup || !grpKey || !grpMmsi) return;
setBusy(-1);
try {
await excludeForGroup(grpKey, parseInt(grpSub, 10), { excludedMmsi: grpMmsi, reason: grpReason });
setGrpKey(''); setGrpMmsi(''); setGrpReason('');
await load();
} catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
} finally {
setBusy(null);
}
};
const handleAddGlobal = async () => {
if (!canCreateGlobal || !glbMmsi) return;
setBusy(-2);
try {
await excludeGlobal({ excludedMmsi: glbMmsi, reason: glbReason });
setGlbMmsi(''); setGlbReason('');
await load();
} catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
} finally {
setBusy(null);
}
};
const handleRelease = async (id: number) => {
if (!canRelease) return;
setBusy(id);
try {
await releaseExclusion(id, '운영자 해제');
await load();
} catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
} finally {
setBusy(null);
}
};
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-heading"> </h1>
<p className="text-xs text-hint mt-1">GROUP/GLOBAL .</p>
</div>
<div className="flex items-center gap-2">
<select
value={filter}
onChange={(e) => setFilter(e.target.value as '' | 'GROUP' | 'GLOBAL')}
className="bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs text-heading"
>
<option value=""> </option>
<option value="GROUP">GROUP</option>
<option value="GLOBAL">GLOBAL</option>
</select>
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded"></button>
</div>
</div>
{/* 신규 등록: GROUP */}
<Card>
<CardContent className="p-4 space-y-2">
<div className="text-xs font-medium text-heading flex items-center gap-2">
<Layers className="w-3.5 h-3.5" /> GROUP ( )
{!canCreateGroup && <span className="text-yellow-400 text-[10px]"> </span>}
</div>
<div className="flex items-center gap-2">
<input value={grpKey} onChange={(e) => setGrpKey(e.target.value)} placeholder="group_key"
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
<input type="number" value={grpSub} onChange={(e) => setGrpSub(e.target.value)} placeholder="sub"
className="w-24 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
<input value={grpMmsi} onChange={(e) => setGrpMmsi(e.target.value)} placeholder="excluded MMSI"
className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
<input value={grpReason} onChange={(e) => setGrpReason(e.target.value)} placeholder="사유"
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
<button type="button" onClick={handleAddGroup}
disabled={!canCreateGroup || !grpKey || !grpMmsi || busy === -1}
className="px-3 py-1.5 bg-orange-600 hover:bg-orange-500 disabled:bg-orange-600/40 text-white text-xs rounded flex items-center gap-1">
{busy === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Ban className="w-3.5 h-3.5" />}
</button>
</div>
</CardContent>
</Card>
{/* 신규 등록: GLOBAL */}
<Card>
<CardContent className="p-4 space-y-2">
<div className="text-xs font-medium text-heading flex items-center gap-2">
<Globe className="w-3.5 h-3.5" /> GLOBAL ( , )
{!canCreateGlobal && <span className="text-yellow-400 text-[10px]"> </span>}
</div>
<div className="flex items-center gap-2">
<input value={glbMmsi} onChange={(e) => setGlbMmsi(e.target.value)} placeholder="excluded MMSI"
className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGlobal} />
<input value={glbReason} onChange={(e) => setGlbReason(e.target.value)} placeholder="사유"
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGlobal} />
<button type="button" onClick={handleAddGlobal}
disabled={!canCreateGlobal || !glbMmsi || busy === -2}
className="px-3 py-1.5 bg-red-600 hover:bg-red-500 disabled:bg-red-600/40 text-white text-xs rounded flex items-center gap-1">
{busy === -2 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Globe className="w-3.5 h-3.5" />}
</button>
</div>
</CardContent>
</Card>
{error && <div className="text-xs text-red-400">: {error}</div>}
{loading && (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
</div>
)}
{!loading && (
<Card>
<CardContent className="p-0 overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-surface-overlay text-hint">
<tr>
<th className="px-3 py-2 text-left">ID</th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left">Group Key</th>
<th className="px-3 py-2 text-center">Sub</th>
<th className="px-3 py-2 text-left"> MMSI</th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-center"></th>
</tr>
</thead>
<tbody>
{items.length === 0 && (
<tr><td colSpan={9} className="px-3 py-8 text-center text-hint"> .</td></tr>
)}
{items.map((it) => (
<tr key={it.id} className="border-t border-border hover:bg-surface-overlay/50">
<td className="px-3 py-2 text-hint font-mono">{it.id}</td>
<td className="px-3 py-2">
<Badge className={`border-0 text-[9px] ${it.scopeType === 'GLOBAL' ? 'bg-red-500/20 text-red-400' : 'bg-orange-500/20 text-orange-400'}`}>
{it.scopeType}
</Badge>
</td>
<td className="px-3 py-2 text-heading font-medium">{it.groupKey || '-'}</td>
<td className="px-3 py-2 text-center text-muted-foreground">{it.subClusterId ?? '-'}</td>
<td className="px-3 py-2 text-cyan-400 font-mono">{it.excludedMmsi}</td>
<td className="px-3 py-2 text-muted-foreground">{it.reason || '-'}</td>
<td className="px-3 py-2 text-muted-foreground">{it.actorAcnt || '-'}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{new Date(it.createdAt).toLocaleString('ko-KR')}</td>
<td className="px-3 py-2 text-center">
<button type="button" disabled={!canRelease || busy === it.id} onClick={() => handleRelease(it.id)}
className="p-1 rounded hover:bg-blue-500/20 disabled:opacity-30 text-blue-400" title="해제">
<RotateCcw className="w-3.5 h-3.5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)}
</div>
);
}

파일 보기

@ -0,0 +1,278 @@
import { useEffect, useState, useCallback } from 'react';
import { CheckCircle, XCircle, RotateCcw, Loader2 } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { useAuth } from '@/app/auth/AuthContext';
import {
fetchReviewList,
reviewParent,
type ParentResolution,
} from '@/services/parentInferenceApi';
/**
* // .
* - prediction이 /.
* - 권한: parent-inference-workflow:parent-review (READ + UPDATE)
* - audit_log + review_log에
*/
const STATUS_COLORS: Record<string, string> = {
UNRESOLVED: 'bg-yellow-500/20 text-yellow-400',
MANUAL_CONFIRMED: 'bg-green-500/20 text-green-400',
REVIEW_REQUIRED: 'bg-red-500/20 text-red-400',
};
const STATUS_LABELS: Record<string, string> = {
UNRESOLVED: '미해결',
MANUAL_CONFIRMED: '확정됨',
REVIEW_REQUIRED: '검토필요',
};
export function ParentReview() {
const { hasPermission } = useAuth();
const canUpdate = hasPermission('parent-inference-workflow:parent-review', 'UPDATE');
const [items, setItems] = useState<ParentResolution[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [actionLoading, setActionLoading] = useState<number | null>(null);
const [filter, setFilter] = useState<string>('');
// 새 그룹 입력 폼 (테스트용)
const [newGroupKey, setNewGroupKey] = useState('');
const [newSubCluster, setNewSubCluster] = useState('1');
const [newMmsi, setNewMmsi] = useState('');
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const res = await fetchReviewList(filter || undefined, 0, 50);
setItems(res.content);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'unknown';
setError(msg);
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => {
load();
}, [load]);
const handleAction = async (
item: ParentResolution,
action: 'CONFIRM' | 'REJECT' | 'RESET',
selectedMmsi?: string,
) => {
if (!canUpdate) return;
setActionLoading(item.id);
try {
await reviewParent(item.groupKey, item.subClusterId, {
action,
selectedParentMmsi: selectedMmsi || item.selectedParentMmsi || undefined,
comment: `${action} via UI`,
});
await load();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'unknown';
alert('처리 실패: ' + msg);
} finally {
setActionLoading(null);
}
};
const handleCreate = async () => {
if (!canUpdate || !newGroupKey || !newMmsi) return;
setActionLoading(-1);
try {
await reviewParent(newGroupKey, parseInt(newSubCluster, 10), {
action: 'CONFIRM',
selectedParentMmsi: newMmsi,
comment: '운영자 직접 등록',
});
setNewGroupKey('');
setNewMmsi('');
await load();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'unknown';
alert('등록 실패: ' + msg);
} finally {
setActionLoading(null);
}
};
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-heading"> /</h1>
<p className="text-xs text-hint mt-1">
/. 권한: parent-inference-workflow:parent-review (UPDATE)
</p>
</div>
<div className="flex items-center gap-2">
<select
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs text-heading"
>
<option value=""> </option>
<option value="UNRESOLVED"></option>
<option value="MANUAL_CONFIRMED"></option>
<option value="REVIEW_REQUIRED"></option>
</select>
<button
type="button"
onClick={load}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded"
>
</button>
</div>
</div>
{/* 신규 등록 폼 (테스트용) */}
{canUpdate && (
<Card>
<CardContent className="p-4">
<div className="text-xs text-muted-foreground mb-2"> ()</div>
<div className="flex items-center gap-2">
<input
type="text"
value={newGroupKey}
onChange={(e) => setNewGroupKey(e.target.value)}
placeholder="group_key (예: 渔船A)"
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs"
/>
<input
type="number"
value={newSubCluster}
onChange={(e) => setNewSubCluster(e.target.value)}
placeholder="sub_cluster_id"
className="w-32 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs"
/>
<input
type="text"
value={newMmsi}
onChange={(e) => setNewMmsi(e.target.value)}
placeholder="parent MMSI"
className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs"
/>
<button
type="button"
onClick={handleCreate}
disabled={!newGroupKey || !newMmsi || actionLoading === -1}
className="px-3 py-1.5 bg-green-600 hover:bg-green-500 disabled:bg-green-600/40 text-white text-xs rounded flex items-center gap-1"
>
{actionLoading === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <CheckCircle className="w-3.5 h-3.5" />}
</button>
</div>
</CardContent>
</Card>
)}
{!canUpdate && (
<Card>
<CardContent className="p-4">
<div className="text-xs text-yellow-400">
(UPDATE ). // .
</div>
</CardContent>
</Card>
)}
{error && (
<Card>
<CardContent className="p-4">
<div className="text-xs text-red-400">: {error}</div>
</CardContent>
</Card>
)}
{loading && (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
</div>
)}
{!loading && items.length === 0 && (
<Card>
<CardContent className="p-8 text-center text-hint text-sm">
. , prediction .
</CardContent>
</Card>
)}
{!loading && items.length > 0 && (
<Card>
<CardContent className="p-0 overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-surface-overlay text-hint">
<tr>
<th className="px-3 py-2 text-left">ID</th>
<th className="px-3 py-2 text-left">Group Key</th>
<th className="px-3 py-2 text-center">Sub</th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"> MMSI</th>
<th className="px-3 py-2 text-left"> </th>
<th className="px-3 py-2 text-center"></th>
</tr>
</thead>
<tbody>
{items.map((it) => (
<tr key={it.id} className="border-t border-border hover:bg-surface-overlay/50">
<td className="px-3 py-2 text-hint font-mono">{it.id}</td>
<td className="px-3 py-2 text-heading font-medium">{it.groupKey}</td>
<td className="px-3 py-2 text-center text-muted-foreground">{it.subClusterId}</td>
<td className="px-3 py-2">
<Badge className={`border-0 text-[9px] ${STATUS_COLORS[it.status] || ''}`}>
{STATUS_LABELS[it.status] || it.status}
</Badge>
</td>
<td className="px-3 py-2 text-cyan-400 font-mono">{it.selectedParentMmsi || '-'}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">
{new Date(it.updatedAt).toLocaleString('ko-KR')}
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-center gap-1">
<button
type="button"
disabled={!canUpdate || actionLoading === it.id}
onClick={() => handleAction(it, 'CONFIRM')}
className="p-1 rounded hover:bg-green-500/20 disabled:opacity-30 text-green-400"
title="확정"
>
<CheckCircle className="w-3.5 h-3.5" />
</button>
<button
type="button"
disabled={!canUpdate || actionLoading === it.id}
onClick={() => handleAction(it, 'REJECT')}
className="p-1 rounded hover:bg-red-500/20 disabled:opacity-30 text-red-400"
title="거부"
>
<XCircle className="w-3.5 h-3.5" />
</button>
<button
type="button"
disabled={!canUpdate || actionLoading === it.id}
onClick={() => handleAction(it, 'RESET')}
className="p-1 rounded hover:bg-blue-500/20 disabled:opacity-30 text-blue-400"
title="리셋"
>
<RotateCcw className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)}
</div>
);
}

파일 보기

@ -24,6 +24,17 @@
"systemConfig": "Settings", "systemConfig": "Settings",
"notices": "Notices", "notices": "Notices",
"accessControl": "Access", "accessControl": "Access",
"admin": "Admin",
"parentReview": "Parent Review",
"parentExclusion": "Exclusion",
"labelSession": "Label Session",
"auditLogs": "Audit Logs",
"accessLogs": "Access Logs",
"loginHistory": "Login History"
},
"group": {
"fieldOps": "Field Ops",
"parentInference": "Parent Workflow",
"admin": "Admin" "admin": "Admin"
}, },
"status": { "status": {

파일 보기

@ -24,7 +24,18 @@
"systemConfig": "환경설정", "systemConfig": "환경설정",
"notices": "공지사항", "notices": "공지사항",
"accessControl": "권한 관리", "accessControl": "권한 관리",
"admin": "시스템 관리" "admin": "시스템 관리",
"parentReview": "모선 확정/거부",
"parentExclusion": "후보 제외",
"labelSession": "학습 세션",
"auditLogs": "감사 로그",
"accessLogs": "접근 이력",
"loginHistory": "로그인 이력"
},
"group": {
"fieldOps": "함정·현장",
"parentInference": "모선 워크플로우",
"admin": "관리자"
}, },
"status": { "status": {
"active": "활성", "active": "활성",

파일 보기

@ -0,0 +1,100 @@
/**
* API ( , , , , ).
*/
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
export interface PageResponse<T> {
content: T[];
totalElements: number;
totalPages: number;
number: number;
size: number;
}
export interface AuditLog {
auditSn: number;
userId: string | null;
userAcnt: string | null;
actionCd: string;
resourceType: string | null;
resourceId: string | null;
detail: Record<string, unknown> | null;
ipAddress: string | null;
result: string | null;
failReason: string | null;
createdAt: string;
}
export interface AccessLog {
accessSn: number;
userId: string | null;
userAcnt: string | null;
httpMethod: string;
requestPath: string;
queryString: string | null;
statusCode: number;
durationMs: number;
ipAddress: string | null;
userAgent: string | null;
createdAt: string;
}
export interface LoginHistory {
histSn: number;
userId: string | null;
userAcnt: string;
loginDtm: string;
loginIp: string | null;
userAgent: string | null;
result: string;
failReason: string | null;
authProvider: string | null;
}
export interface PermTreeNode {
rsrcCd: string;
parentCd: string | null;
rsrcNm: string;
rsrcDesc: string | null;
icon: string | null;
rsrcLevel: number;
sortOrd: number;
useYn: string;
}
export interface RoleWithPermissions {
roleSn: number;
roleCd: string;
roleNm: string;
roleDc: string;
dfltYn: string;
builtinYn: string;
permissions: { permSn: number; roleSn: number; rsrcCd: string; operCd: string; grantYn: string }[];
}
async function apiGet<T>(path: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, { credentials: 'include' });
if (!res.ok) throw new Error(`API ${res.status}: ${path}`);
return res.json();
}
export function fetchAuditLogs(page = 0, size = 50) {
return apiGet<PageResponse<AuditLog>>(`/admin/audit-logs?page=${page}&size=${size}`);
}
export function fetchAccessLogs(page = 0, size = 50) {
return apiGet<PageResponse<AccessLog>>(`/admin/access-logs?page=${page}&size=${size}`);
}
export function fetchLoginHistory(page = 0, size = 50) {
return apiGet<PageResponse<LoginHistory>>(`/admin/login-history?page=${page}&size=${size}`);
}
export function fetchPermTree() {
return apiGet<PermTreeNode[]>('/perm-tree');
}
export function fetchRoles() {
return apiGet<RoleWithPermissions[]>('/roles');
}

파일 보기

@ -0,0 +1,185 @@
/**
* API .
* - /리뷰: 자체 ( DB의 )
* - 향후: iran (HYBRID)
*/
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
export interface ParentResolution {
id: number;
groupKey: string;
subClusterId: number;
status: 'UNRESOLVED' | 'MANUAL_CONFIRMED' | 'REVIEW_REQUIRED';
selectedParentMmsi: string | null;
rejectedCandidateMmsi: string | null;
approvedBy: string | null;
approvedAt: string | null;
rejectedAt: string | null;
manualComment: string | null;
createdAt: string;
updatedAt: string;
}
export interface CandidateExclusion {
id: number;
scopeType: 'GROUP' | 'GLOBAL';
groupKey: string | null;
subClusterId: number | null;
excludedMmsi: string;
reason: string | null;
actor: string | null;
actorAcnt: string | null;
createdAt: string;
releasedAt: string | null;
releasedByAcnt: string | null;
}
export interface LabelSession {
id: number;
groupKey: string;
subClusterId: number;
labelParentMmsi: string;
status: 'ACTIVE' | 'CANCELLED' | 'COMPLETED';
activeFrom: string;
activeUntil: string | null;
createdByAcnt: string | null;
cancelledAt: string | null;
cancelReason: string | null;
createdAt: string;
}
export interface ReviewLog {
id: number;
groupKey: string;
subClusterId: number | null;
action: string;
selectedParentMmsi: string | null;
actorAcnt: string | null;
comment: string | null;
createdAt: string;
}
export interface PageResponse<T> {
content: T[];
totalElements: number;
totalPages: number;
number: number;
size: number;
}
async function apiRequest<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
...init,
});
if (!res.ok) {
let errBody = '';
try { errBody = await res.text(); } catch { /* ignore */ }
throw new Error(`API ${res.status}: ${path} ${errBody}`);
}
return res.json();
}
// ============================================================================
// Resolution
// ============================================================================
export function fetchReviewList(status?: string, page = 0, size = 20) {
const qs = new URLSearchParams();
if (status) qs.set('status', status);
qs.set('page', String(page));
qs.set('size', String(size));
return apiRequest<PageResponse<ParentResolution>>(`/parent-inference/review?${qs}`);
}
export function reviewParent(
groupKey: string,
subClusterId: number,
payload: { action: 'CONFIRM' | 'REJECT' | 'RESET'; selectedParentMmsi?: string; comment?: string },
) {
return apiRequest<ParentResolution>(
`/parent-inference/groups/${encodeURIComponent(groupKey)}/${subClusterId}/review`,
{ method: 'POST', body: JSON.stringify(payload) },
);
}
// ============================================================================
// Exclusions
// ============================================================================
export function fetchExclusions(scopeType?: 'GROUP' | 'GLOBAL', page = 0, size = 20) {
const qs = new URLSearchParams();
if (scopeType) qs.set('scopeType', scopeType);
qs.set('page', String(page));
qs.set('size', String(size));
return apiRequest<PageResponse<CandidateExclusion>>(`/parent-inference/exclusions?${qs}`);
}
export function excludeForGroup(
groupKey: string,
subClusterId: number,
payload: { excludedMmsi: string; reason?: string },
) {
return apiRequest<CandidateExclusion>(
`/parent-inference/groups/${encodeURIComponent(groupKey)}/${subClusterId}/exclusions`,
{ method: 'POST', body: JSON.stringify(payload) },
);
}
export function excludeGlobal(payload: { excludedMmsi: string; reason?: string }) {
return apiRequest<CandidateExclusion>(`/parent-inference/exclusions/global`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function releaseExclusion(exclusionId: number, reason?: string) {
return apiRequest<CandidateExclusion>(`/parent-inference/exclusions/${exclusionId}/release`, {
method: 'POST',
body: JSON.stringify({ reason }),
});
}
// ============================================================================
// Label Sessions
// ============================================================================
export function fetchLabelSessions(status?: string, page = 0, size = 20) {
const qs = new URLSearchParams();
if (status) qs.set('status', status);
qs.set('page', String(page));
qs.set('size', String(size));
return apiRequest<PageResponse<LabelSession>>(`/parent-inference/label-sessions?${qs}`);
}
export function createLabelSession(
groupKey: string,
subClusterId: number,
payload: { labelParentMmsi: string; anchorSnapshot?: Record<string, unknown> },
) {
return apiRequest<LabelSession>(
`/parent-inference/groups/${encodeURIComponent(groupKey)}/${subClusterId}/label-sessions`,
{ method: 'POST', body: JSON.stringify(payload) },
);
}
export function cancelLabelSession(sessionId: number, reason?: string) {
return apiRequest<LabelSession>(`/parent-inference/label-sessions/${sessionId}/cancel`, {
method: 'POST',
body: JSON.stringify({ reason }),
});
}
// ============================================================================
// Review Logs (도메인 액션 이력)
// ============================================================================
export function fetchReviewLogs(groupKey?: string, page = 0, size = 50) {
const qs = new URLSearchParams();
if (groupKey) qs.set('groupKey', groupKey);
qs.set('page', String(page));
qs.set('size', String(size));
return apiRequest<PageResponse<ReviewLog>>(`/parent-inference/review-logs?${qs}`);
}