Compare commits

...

12 커밋

작성자 SHA1 메시지 날짜
62d14fc519 Merge pull request 'release: 2026-04-17 (11건 커밋)' (#72) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 16s
2026-04-17 05:39:15 +09:00
760bceed32 Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-17)' (#71) from release/2026-04-17 into develop 2026-04-17 05:38:19 +09:00
fe43f6b022 docs: 릴리즈 노트 정리 (2026-04-17) 2026-04-17 05:37:39 +09:00
38c97686fc Merge pull request 'refactor(design-system): 하드코딩 색상 라이트/다크 대응 + raw HTML → 공통 컴포넌트 치환' (#70) from refactor/design-system-ssot into develop 2026-04-16 17:10:10 +09:00
5731fa30a1 docs: 릴리즈 노트 업데이트 (PR #C 디자인시스템 정비) 2026-04-16 17:09:32 +09:00
c1cc36b134 refactor(design-system): 하드코딩 색상 라이트/다크 대응 + raw button/input 공통 컴포넌트 치환
30개 파일 전 영역에 동일한 패턴으로 SSOT 준수:

**StatBox 재설계 (2파일)**:
- RealGearGroups, RealVesselAnalysis 의 `color: string` prop 제거
- `intent: BadgeIntent` prop + `INTENT_TEXT_CLASS` 매핑 도입

**raw `<button>` → Button 컴포넌트 (다수)**:
- `bg-blue-600 hover:bg-blue-500 text-on-vivid ...` → `<Button variant="primary">`
- `bg-orange-600 ...` / `bg-green-600 ...` → `<Button variant="primary">`
- `bg-red-600 ...` → `<Button variant="destructive">`
- 아이콘 전용 → `<Button variant="ghost" aria-label=".." icon={...} />`
- detection/enforcement/admin/parent-inference/statistics/ai-operations/auth 전영역

**raw `<input>` → Input 컴포넌트**:
- parent-inference (ParentReview, ParentExclusion, LabelSession)
- admin (PermissionsPanel, UserRoleAssignDialog)
- ai-operations (AIAssistant)
- auth (LoginPage)

**raw `<select>` → Select 컴포넌트**:
- detection (RealGearGroups, RealVesselAnalysis, ChinaFishing)

**커스텀 탭 → TabBar/TabButton (segmented/underline)**:
- ChinaFishing: 모드 탭 + 선박 탭 + 통계 탭

**raw `<input type="checkbox">` → Checkbox**:
- GearDetection FilterCheckGroup

**하드코딩 Tailwind 색상 라이트/다크 쌍 변환 (전영역)**:
- `text-red-400` → `text-red-600 dark:text-red-400`
- `text-green-400` → `text-green-600 dark:text-green-400`
- blue/cyan/orange/yellow/purple/amber 동일 패턴
- `text-*-500` 아이콘도 `text-*-600 dark:text-*-500` 로 라이트 모드 대응
- 상태 dot (bg-red-500 animate-pulse 등)은 의도적 시각 구분이므로 유지

**에러 메시지 한글 → t('error.errorPrefix') 통일**:
- detection/parent-inference/admin 에서 `에러: {error}` 패턴 → `t('error.errorPrefix', { msg: error })`

**결과**: tsc 0 errors / eslint 0 errors (84 warnings 기존)
2026-04-16 17:09:14 +09:00
2c23049c8e Merge pull request 'refactor(i18n): alert/confirm/aria-label 하드코딩 한글 제거' (#69) from refactor/i18n-alert-aria-confirm into develop 2026-04-16 16:33:22 +09:00
03f2ea08db docs: 릴리즈 노트 업데이트 (PR #B i18n 정비) 2026-04-16 16:32:53 +09:00
8af693a2df refactor(i18n): alert/confirm/aria-label 하드코딩 한글 제거
공통 번역 리소스 확장:
- common.json 에 aria / error / dialog / success / message 네임스페이스 추가
- ko/en 양쪽 동일 구조 유지 (aria 36 키 + error 7 키 + dialog 4 키 + message 5 키)

alert/confirm 11건 → t() 치환:
- parent-inference: ParentReview / LabelSession / ParentExclusion
- admin: PermissionsPanel / UserRoleAssignDialog / AccessControl

aria-label 한글 40+건 → t() 치환:
- parent-inference (group_key/sub_cluster/정답 parent MMSI/스코프 필터 등)
- admin (역할 코드/이름, 알림 제목/내용, 시작일/종료일, 코드 검색, 대분류 필터, 수신 현황 기준일)
- detection (그룹 유형/해역 필터, 관심영역, 필터 설정/초기화, 멤버 수, 미니맵/재생 닫기)
- enforcement (확인/선박 상세/단속 등록/오탐 처리)
- vessel/statistics/ai-operations (조회 시작/종료 시각, 업로드 패널 닫기, 전송, 예시 URL 복사)
- 공통 컴포넌트 (SearchInput, NotificationBanner)

MainLayout 언어 토글:
- title 삼항분기 → t('message.switchToEnglish'/'switchToKorean')
- aria-label="페이지 내 검색" → t('aria.searchInPage')
- 토글 버튼 자체에 aria-label={t('aria.languageToggle')} 추가
2026-04-16 16:32:37 +09:00
5a57959bd5 Merge pull request 'refactor: 프로젝트 뼈대 정리 — iran 잔재 제거 + 백엔드 계층 + 카탈로그' (#68) from refactor/cleanup-iran-backend-catalog into develop 2026-04-16 16:20:05 +09:00
bb40958858 docs: 릴리즈 노트 업데이트 (PR #A 구조 정비) 2026-04-16 16:19:02 +09:00
9251d7593c refactor: 프로젝트 뼈대 정리 — iran 잔재 제거 + 백엔드 계층 분리 + 카탈로그 등록
iran 백엔드 프록시 잔재 제거:
- IranBackendClient dead class 삭제, AppProperties/application.yml iran-backend 블록 제거
- Frontend UI 라벨/주석/system-flow manifest deprecated 마킹
- CLAUDE.md 시스템 구성 다이어그램 최신화

백엔드 계층 분리:
- AlertController/MasterDataController/AdminStatsController 에서 repository/JdbcTemplate 직접 주입 제거
- AlertService/MasterDataService/AdminStatsService 신규 계층 도입 + @Transactional(readOnly=true)
- Proxy controller 의 @PostConstruct RestClient 생성 → RestClientConfig @Bean 으로 통합

감사 로그 보강:
- EnforcementService createRecord/updateRecord/createPlan 에 @Auditable 추가
- VesselAnalysisGroupService.resolveParent 에 PARENT_RESOLVE 액션 기록

카탈로그 정합성:
- performanceStatus 를 catalogRegistry 에 등록 (쇼케이스 자동 노출)
- alertLevels 확장: isValidAlertLevel / isHighSeverity / getAlertLevelOrder
- LiveMapView/DarkVesselDetection 시각 매핑(opacity/radius/tier score) 상수로 추출
- GearIdentification/vesselAnomaly 직접 분기를 타입 가드/헬퍼로 치환
2026-04-16 16:18:18 +09:00
73개의 변경된 파일1407개의 추가작업 그리고 934개의 파일을 삭제

파일 보기

@ -24,14 +24,14 @@ kcg-ai-monitoring/
```
[Frontend Vite :5173] ──→ [Backend Spring :8080] ──→ [PostgreSQL kcgaidb]
↑ write
[Prediction FastAPI :8001] ──────┘ (5분 주기 분석 결과 저장)
↑ read ↑ read
[SNPDB PostgreSQL] (AIS 원본) [Iran Backend] (레거시 프록시, 선택)
[Prediction FastAPI :18092] ─────┘ (5분 주기 분석 결과 저장)
↑ read
[SNPDB PostgreSQL] (AIS 원본)
```
- **자체 백엔드**: 인증/권한/감사로그/관리자 + 운영자 의사결정 (확정/제외/학습)
- **iran 백엔드 프록시**: 분석 결과 read-only 참조 (vessel_analysis, group_polygons, correlations)
- **신규 DB (kcgaidb)**: 자체 생산 데이터만 저장, prediction 분석 테이블은 미복사
- **자체 백엔드**: 인증/권한/감사로그/관리자 + 운영자 의사결정 (확정/제외/학습) + prediction 분석 결과 조회 API (`/api/analysis/*`)
- **Prediction**: AIS → 분석 결과를 kcgaidb 에 직접 write (백엔드 호출 없음)
- **DB 공유 아키텍처**: 백엔드와 prediction 은 HTTP 호출 없이 kcgaidb 를 통해서만 연동
## 명령어

파일 보기

@ -12,7 +12,7 @@ Phase 2에서 초기화 예정.
## 책임
- 자체 인증/권한/감사로그
- 운영자 의사결정 (모선 확정/제외/학습)
- iran 백엔드 분석 데이터 프록시
- prediction 분석 결과 조회 API (`/api/analysis/*`)
- 관리자 화면 API
상세 설계: `.claude/plans/vast-tinkering-knuth.md`

파일 보기

@ -1,17 +1,11 @@
package gc.mda.kcg.admin;
import gc.mda.kcg.audit.AccessLogRepository;
import gc.mda.kcg.audit.AuditLogRepository;
import gc.mda.kcg.auth.LoginHistoryRepository;
import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
@ -28,127 +22,23 @@ import java.util.Map;
@RequiredArgsConstructor
public class AdminStatsController {
private final AuditLogRepository auditLogRepository;
private final AccessLogRepository accessLogRepository;
private final LoginHistoryRepository loginHistoryRepository;
private final JdbcTemplate jdbc;
private final AdminStatsService adminStatsService;
/**
* 감사 로그 통계.
* - total: 전체 건수
* - last24h: 24시간 건수
* - failed24h: 24시간 FAILED 건수
* - byAction: 액션별 카운트 (top 10)
* - hourly24: 시간별 24시간 추세
*/
@GetMapping("/audit")
@RequirePermission(resource = "admin:audit-logs", operation = "READ")
public Map<String, Object> auditStats() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", auditLogRepository.count());
result.put("last24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours'", Long.class));
result.put("failed24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours' AND result = 'FAILED'", Long.class));
List<Map<String, Object>> byAction = jdbc.queryForList(
"SELECT action_cd AS action, COUNT(*) AS count FROM kcg.auth_audit_log " +
"WHERE created_at > now() - interval '7 days' " +
"GROUP BY action_cd ORDER BY count DESC LIMIT 10");
result.put("byAction", byAction);
List<Map<String, Object>> hourly = jdbc.queryForList(
"SELECT date_trunc('hour', created_at) AS hour, COUNT(*) AS count " +
"FROM kcg.auth_audit_log " +
"WHERE created_at > now() - interval '24 hours' " +
"GROUP BY hour ORDER BY hour");
result.put("hourly24", hourly);
return result;
return adminStatsService.auditStats();
}
/**
* 접근 로그 통계.
* - total: 전체 건수
* - last24h: 24시간
* - error4xx, error5xx: 24시간 에러
* - avgDurationMs: 24시간 평균 응답 시간
* - topPaths: 24시간 호출 많은 경로
*/
@GetMapping("/access")
@RequirePermission(resource = "admin:access-logs", operation = "READ")
public Map<String, Object> accessStats() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", accessLogRepository.count());
result.put("last24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'", Long.class));
result.put("error4xx", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 400 AND status_code < 500", Long.class));
result.put("error5xx", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 500", Long.class));
Double avg = jdbc.queryForObject(
"SELECT AVG(duration_ms)::float FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'",
Double.class);
result.put("avgDurationMs", avg != null ? Math.round(avg * 10) / 10.0 : 0);
List<Map<String, Object>> topPaths = jdbc.queryForList(
"SELECT request_path AS path, COUNT(*) AS count, AVG(duration_ms)::int AS avg_ms " +
"FROM kcg.auth_access_log " +
"WHERE created_at > now() - interval '24 hours' AND request_path NOT LIKE '/actuator%' " +
"GROUP BY request_path ORDER BY count DESC LIMIT 10");
result.put("topPaths", topPaths);
return result;
return adminStatsService.accessStats();
}
/**
* 로그인 통계.
* - total: 전체 건수
* - success24h: 24시간 성공
* - failed24h: 24시간 실패
* - locked24h: 24시간 잠금
* - successRate: 성공률 (24시간 , %)
* - byUser: 사용자별 성공 카운트 (top 10)
* - daily7d: 7일 일별 추세
*/
@GetMapping("/login")
@RequirePermission(resource = "admin:login-history", operation = "READ")
public Map<String, Object> loginStats() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", loginHistoryRepository.count());
Long success24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'SUCCESS'", Long.class);
Long failed24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'FAILED'", Long.class);
Long locked24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'LOCKED'", Long.class);
result.put("success24h", success24h);
result.put("failed24h", failed24h);
result.put("locked24h", locked24h);
long total24h = (success24h == null ? 0 : success24h) + (failed24h == null ? 0 : failed24h) + (locked24h == null ? 0 : locked24h);
double rate = total24h == 0 ? 0 : (success24h == null ? 0 : success24h) * 100.0 / total24h;
result.put("successRate", Math.round(rate * 10) / 10.0);
List<Map<String, Object>> byUser = jdbc.queryForList(
"SELECT user_acnt, COUNT(*) AS count FROM kcg.auth_login_hist " +
"WHERE login_dtm > now() - interval '7 days' AND result = 'SUCCESS' " +
"GROUP BY user_acnt ORDER BY count DESC LIMIT 10");
result.put("byUser", byUser);
List<Map<String, Object>> daily = jdbc.queryForList(
"SELECT date_trunc('day', login_dtm) AS day, " +
"COUNT(*) FILTER (WHERE result='SUCCESS') AS success, " +
"COUNT(*) FILTER (WHERE result='FAILED') AS failed, " +
"COUNT(*) FILTER (WHERE result='LOCKED') AS locked " +
"FROM kcg.auth_login_hist " +
"WHERE login_dtm > now() - interval '7 days' " +
"GROUP BY day ORDER BY day");
result.put("daily7d", daily);
return result;
return adminStatsService.loginStats();
}
}

파일 보기

@ -0,0 +1,124 @@
package gc.mda.kcg.admin;
import gc.mda.kcg.audit.AccessLogRepository;
import gc.mda.kcg.audit.AuditLogRepository;
import gc.mda.kcg.auth.LoginHistoryRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 시스템 관리 대시보드 메트릭 서비스.
* 감사 로그 / 접근 로그 / 로그인 이력의 집계 쿼리를 담당한다.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AdminStatsService {
private final AuditLogRepository auditLogRepository;
private final AccessLogRepository accessLogRepository;
private final LoginHistoryRepository loginHistoryRepository;
private final JdbcTemplate jdbc;
/**
* 감사 로그 통계.
*/
public Map<String, Object> auditStats() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", auditLogRepository.count());
result.put("last24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours'", Long.class));
result.put("failed24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours' AND result = 'FAILED'", Long.class));
List<Map<String, Object>> byAction = jdbc.queryForList(
"SELECT action_cd AS action, COUNT(*) AS count FROM kcg.auth_audit_log " +
"WHERE created_at > now() - interval '7 days' " +
"GROUP BY action_cd ORDER BY count DESC LIMIT 10");
result.put("byAction", byAction);
List<Map<String, Object>> hourly = jdbc.queryForList(
"SELECT date_trunc('hour', created_at) AS hour, COUNT(*) AS count " +
"FROM kcg.auth_audit_log " +
"WHERE created_at > now() - interval '24 hours' " +
"GROUP BY hour ORDER BY hour");
result.put("hourly24", hourly);
return result;
}
/**
* 접근 로그 통계.
*/
public Map<String, Object> accessStats() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", accessLogRepository.count());
result.put("last24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'", Long.class));
result.put("error4xx", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 400 AND status_code < 500", Long.class));
result.put("error5xx", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 500", Long.class));
Double avg = jdbc.queryForObject(
"SELECT AVG(duration_ms)::float FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'",
Double.class);
result.put("avgDurationMs", avg != null ? Math.round(avg * 10) / 10.0 : 0);
List<Map<String, Object>> topPaths = jdbc.queryForList(
"SELECT request_path AS path, COUNT(*) AS count, AVG(duration_ms)::int AS avg_ms " +
"FROM kcg.auth_access_log " +
"WHERE created_at > now() - interval '24 hours' AND request_path NOT LIKE '/actuator%' " +
"GROUP BY request_path ORDER BY count DESC LIMIT 10");
result.put("topPaths", topPaths);
return result;
}
/**
* 로그인 통계.
*/
public Map<String, Object> loginStats() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", loginHistoryRepository.count());
Long success24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'SUCCESS'", Long.class);
Long failed24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'FAILED'", Long.class);
Long locked24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'LOCKED'", Long.class);
result.put("success24h", success24h);
result.put("failed24h", failed24h);
result.put("locked24h", locked24h);
long total24h = (success24h == null ? 0 : success24h) + (failed24h == null ? 0 : failed24h) + (locked24h == null ? 0 : locked24h);
double rate = total24h == 0 ? 0 : (success24h == null ? 0 : success24h) * 100.0 / total24h;
result.put("successRate", Math.round(rate * 10) / 10.0);
List<Map<String, Object>> byUser = jdbc.queryForList(
"SELECT user_acnt, COUNT(*) AS count FROM kcg.auth_login_hist " +
"WHERE login_dtm > now() - interval '7 days' AND result = 'SUCCESS' " +
"GROUP BY user_acnt ORDER BY count DESC LIMIT 10");
result.put("byUser", byUser);
List<Map<String, Object>> daily = jdbc.queryForList(
"SELECT date_trunc('day', login_dtm) AS day, " +
"COUNT(*) FILTER (WHERE result='SUCCESS') AS success, " +
"COUNT(*) FILTER (WHERE result='FAILED') AS failed, " +
"COUNT(*) FILTER (WHERE result='LOCKED') AS locked " +
"FROM kcg.auth_login_hist " +
"WHERE login_dtm > now() - interval '7 days' " +
"GROUP BY day ORDER BY day");
result.put("daily7d", daily);
return result;
}
}

파일 보기

@ -13,7 +13,6 @@ public class AppProperties {
private Prediction prediction = new Prediction();
private SignalBatch signalBatch = new SignalBatch();
private IranBackend iranBackend = new IranBackend();
private Cors cors = new Cors();
private Jwt jwt = new Jwt();
@ -27,11 +26,6 @@ public class AppProperties {
private String baseUrl;
}
@Getter @Setter
public static class IranBackend {
private String baseUrl;
}
@Getter @Setter
public static class Cors {
private String allowedOrigins;

파일 보기

@ -0,0 +1,49 @@
package gc.mda.kcg.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
/**
* 외부 서비스용 RestClient Bean 정의.
* Proxy controller 들이 @PostConstruct 에서 ad-hoc 생성하던 RestClient 일원화한다.
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class RestClientConfig {
private final AppProperties appProperties;
/**
* prediction FastAPI 서비스 호출용.
* base-url: ${PREDICTION_BASE_URL:http://localhost:8001}
*/
@Bean
public RestClient predictionRestClient(RestClient.Builder builder) {
String baseUrl = appProperties.getPrediction().getBaseUrl();
String resolved = (baseUrl != null && !baseUrl.isBlank()) ? baseUrl : "http://localhost:8001";
log.info("predictionRestClient initialized: baseUrl={}", resolved);
return builder
.baseUrl(resolved)
.defaultHeader("Accept", "application/json")
.build();
}
/**
* signal-batch 선박 항적 서비스 호출용.
* base-url: ${SIGNAL_BATCH_BASE_URL:http://192.168.1.18:18090/signal-batch}
*/
@Bean
public RestClient signalBatchRestClient(RestClient.Builder builder) {
String baseUrl = appProperties.getSignalBatch().getBaseUrl();
String resolved = (baseUrl != null && !baseUrl.isBlank()) ? baseUrl : "http://192.168.1.18:18090/signal-batch";
log.info("signalBatchRestClient initialized: baseUrl={}", resolved);
return builder
.baseUrl(resolved)
.defaultHeader("Accept", "application/json")
.build();
}
}

파일 보기

@ -1,70 +0,0 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.config.AppProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.time.Duration;
import java.util.Map;
/**
* iran 백엔드 REST 클라이언트.
*
* 운영 환경: https://kcg.gc-si.dev (Spring Boot + Prediction 통합)
* 호출 실패 graceful degradation: null 반환 프론트에 응답.
*
* 향후 prediction 이관 IranBackendClient를 PredictionDirectClient로 교체하면 .
*/
@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)
.defaultHeader("Accept", "application/json")
.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;
}
}
/**
* 임의 타입 GET 호출.
*/
public <T> T getAs(String path, Class<T> responseType) {
if (!enabled) return null;
try {
return restClient.get().uri(path).retrieve().body(responseType);
} catch (RestClientException e) {
log.debug("iran 백엔드 호출 실패: {} - {}", path, e.getMessage());
return null;
}
}
}

파일 보기

@ -1,10 +1,9 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.config.AppProperties;
import gc.mda.kcg.permission.annotation.RequirePermission;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestClient;
@ -14,7 +13,7 @@ import java.util.Map;
/**
* Prediction FastAPI 서비스 프록시.
* app.prediction.base-url (기본: http://localhost:8001, 운영: http://192.168.1.19:18092)
* 기본 baseUrl: app.prediction.base-url (개발 http://localhost:8001, 운영 http://192.168.1.19:18092)
*
* 엔드포인트:
* GET /api/prediction/health FastAPI /health
@ -30,20 +29,8 @@ import java.util.Map;
@RequiredArgsConstructor
public class PredictionProxyController {
private final AppProperties appProperties;
private final RestClient.Builder restClientBuilder;
private RestClient predictionClient;
@PostConstruct
void init() {
String baseUrl = appProperties.getPrediction().getBaseUrl();
predictionClient = restClientBuilder
.baseUrl(baseUrl != null && !baseUrl.isBlank() ? baseUrl : "http://localhost:8001")
.defaultHeader("Accept", "application/json")
.build();
log.info("PredictionProxyController initialized: baseUrl={}", baseUrl);
}
@Qualifier("predictionRestClient")
private final RestClient predictionClient;
@GetMapping("/health")
public ResponseEntity<?> health() {

파일 보기

@ -13,8 +13,7 @@ import java.util.List;
/**
* vessel_analysis_results 직접 조회 API.
* prediction이 kcgaidb에 저장한 분석 결과를 프론트엔드에 직접 제공.
* 기존 iran proxy와 별도 경로 (/api/analysis/*).
* prediction이 kcgaidb에 저장한 분석 결과를 프론트엔드에 직접 제공한다 (/api/analysis/*).
*/
@RestController
@RequestMapping("/api/analysis")

파일 보기

@ -2,6 +2,7 @@ package gc.mda.kcg.domain.analysis;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import gc.mda.kcg.audit.annotation.Auditable;
import gc.mda.kcg.domain.fleet.ParentResolution;
import gc.mda.kcg.domain.fleet.repository.ParentResolutionRepository;
import lombok.RequiredArgsConstructor;
@ -290,6 +291,7 @@ public class VesselAnalysisGroupService {
/**
* 모선 확정/제외 처리.
*/
@Auditable(action = "PARENT_RESOLVE", resourceType = "GEAR_GROUP")
public Map<String, Object> resolveParent(String groupKey, String action, String targetMmsi, String comment) {
try {
// 먼저 resolution 존재 확인

파일 보기

@ -1,10 +1,9 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.config.AppProperties;
import gc.mda.kcg.permission.annotation.RequirePermission;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestClient;
@ -16,10 +15,6 @@ import java.util.Map;
/**
* 분석 데이터 API group_polygon_snapshots / gear_correlation_scores 직접 DB 조회
* + signal-batch 선박 항적 프록시.
*/
/**
* 분석 데이터 API group_polygon_snapshots / gear_correlation_scores 직접 DB 조회.
*
* 라우팅:
* GET /api/vessel-analysis/groups 어구/선단 그룹 + parentResolution 합성
@ -35,19 +30,9 @@ import java.util.Map;
public class VesselAnalysisProxyController {
private final VesselAnalysisGroupService groupService;
private final AppProperties appProperties;
private final RestClient.Builder restClientBuilder;
private RestClient signalBatchClient;
@PostConstruct
void init() {
String sbUrl = appProperties.getSignalBatch().getBaseUrl();
signalBatchClient = restClientBuilder
.baseUrl(sbUrl != null && !sbUrl.isBlank() ? sbUrl : "http://192.168.1.18:18090/signal-batch")
.defaultHeader("Accept", "application/json")
.build();
}
@Qualifier("signalBatchRestClient")
private final RestClient signalBatchClient;
@GetMapping
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
@ -69,7 +54,7 @@ public class VesselAnalysisProxyController {
@GetMapping("/groups")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> getGroups(
@org.springframework.web.bind.annotation.RequestParam(required = false) String groupType
@RequestParam(required = false) String groupType
) {
Map<String, Object> result = groupService.getGroups(groupType);
return ResponseEntity.ok(result);

파일 보기

@ -1,5 +1,6 @@
package gc.mda.kcg.domain.enforcement;
import gc.mda.kcg.audit.annotation.Auditable;
import gc.mda.kcg.domain.enforcement.dto.CreatePlanRequest;
import gc.mda.kcg.domain.enforcement.dto.CreateRecordRequest;
import gc.mda.kcg.domain.enforcement.dto.UpdateRecordRequest;
@ -48,6 +49,7 @@ public class EnforcementService {
}
@Transactional
@Auditable(action = "ENFORCEMENT_CREATE", resourceType = "ENFORCEMENT")
public EnforcementRecord createRecord(CreateRecordRequest req) {
EnforcementRecord record = EnforcementRecord.builder()
.enfUid(generateEnfUid())
@ -87,6 +89,7 @@ public class EnforcementService {
}
@Transactional
@Auditable(action = "ENFORCEMENT_UPDATE", resourceType = "ENFORCEMENT")
public EnforcementRecord updateRecord(Long id, UpdateRecordRequest req) {
EnforcementRecord record = getRecord(id);
if (req.result() != null) record.setResult(req.result());
@ -107,6 +110,7 @@ public class EnforcementService {
}
@Transactional
@Auditable(action = "ENFORCEMENT_PLAN_CREATE", resourceType = "ENFORCEMENT")
public EnforcementPlan createPlan(CreatePlanRequest req) {
EnforcementPlan plan = EnforcementPlan.builder()
.planUid("PLN-" + LocalDate.now().format(UID_DATE_FMT) + "-" + UUID.randomUUID().toString().substring(0, 4).toUpperCase())

파일 보기

@ -2,12 +2,9 @@ package gc.mda.kcg.domain.event;
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.*;
import java.util.List;
/**
* 알림 조회 API.
* 예측 이벤트에 대해 발송된 알림(SMS, 푸시 ) 이력을 제공.
@ -17,7 +14,7 @@ import java.util.List;
@RequiredArgsConstructor
public class AlertController {
private final PredictionAlertRepository alertRepository;
private final AlertService alertService;
/**
* 알림 목록 조회 (페이징). eventId 파라미터로 특정 이벤트의 알림만 필터 가능.
@ -30,10 +27,8 @@ public class AlertController {
@RequestParam(defaultValue = "20") int size
) {
if (eventId != null) {
return alertRepository.findByEventIdOrderBySentAtDesc(eventId);
return alertService.findByEventId(eventId);
}
return alertRepository.findAllByOrderBySentAtDesc(
PageRequest.of(page, size)
);
return alertService.findAll(PageRequest.of(page, size));
}
}

파일 보기

@ -0,0 +1,29 @@
package gc.mda.kcg.domain.event;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 예측 알림 조회 서비스.
* 이벤트에 대해 발송된 알림(SMS/푸시 ) 이력을 조회한다.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AlertService {
private final PredictionAlertRepository alertRepository;
public List<PredictionAlert> findByEventId(Long eventId) {
return alertRepository.findByEventIdOrderBySentAtDesc(eventId);
}
public Page<PredictionAlert> findAll(Pageable pageable) {
return alertRepository.findAllByOrderBySentAtDesc(pageable);
}
}

파일 보기

@ -16,8 +16,8 @@ import java.time.OffsetDateTime;
import java.util.List;
/**
* 모선 워크플로우 핵심 서비스 (HYBRID).
* - 후보 데이터: iran 백엔드 API 호출 (현재 stub)
* 모선 워크플로우 핵심 서비스.
* - 후보 데이터: prediction이 kcgaidb 저장한 분석 결과를 참조
* - 운영자 결정: 자체 DB (gear_group_parent_resolution )
*
* 모든 쓰기 액션은 @Auditable로 감사로그 자동 기록.

파일 보기

@ -10,7 +10,7 @@ import java.util.UUID;
/**
* 모선 확정 결과 (운영자 의사결정).
* iran 백엔드의 후보 데이터(prediction이 생성) 별도로 운영자 결정만 자체 DB에 저장.
* prediction이 생성한 후보 데이터 별도로 운영자 결정만 자체 DB에 저장.
*/
@Entity
@Table(name = "gear_group_parent_resolution", schema = "kcg",

파일 보기

@ -4,9 +4,7 @@ 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.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@ -18,10 +16,7 @@ import java.util.List;
@RequiredArgsConstructor
public class MasterDataController {
private final CodeMasterRepository codeMasterRepository;
private final GearTypeRepository gearTypeRepository;
private final PatrolShipRepository patrolShipRepository;
private final VesselPermitRepository vesselPermitRepository;
private final MasterDataService masterDataService;
// ========================================================================
// 코드 마스터 (인증만, 권한 불필요)
@ -29,12 +24,12 @@ public class MasterDataController {
@GetMapping("/api/codes")
public List<CodeMaster> listCodes(@RequestParam String group) {
return codeMasterRepository.findByGroupCodeAndIsActiveTrueOrderBySortOrder(group);
return masterDataService.listCodes(group);
}
@GetMapping("/api/codes/{codeId}/children")
public List<CodeMaster> listChildren(@PathVariable String codeId) {
return codeMasterRepository.findByParentIdOrderBySortOrder(codeId);
return masterDataService.listChildren(codeId);
}
// ========================================================================
@ -43,35 +38,24 @@ public class MasterDataController {
@GetMapping("/api/gear-types")
public List<GearType> listGearTypes() {
return gearTypeRepository.findByIsActiveTrueOrderByDisplayOrder();
return masterDataService.listGearTypes();
}
@GetMapping("/api/gear-types/{gearCode}")
public GearType getGearType(@PathVariable String gearCode) {
return gearTypeRepository.findById(gearCode)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"어구 유형을 찾을 수 없습니다: " + gearCode));
return masterDataService.getGearType(gearCode);
}
@PostMapping("/api/gear-types")
@RequirePermission(resource = "admin:system-config", operation = "CREATE")
public GearType createGearType(@RequestBody GearType gearType) {
if (gearTypeRepository.existsById(gearType.getGearCode())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"이미 존재하는 어구 코드입니다: " + gearType.getGearCode());
}
return gearTypeRepository.save(gearType);
return masterDataService.createGearType(gearType);
}
@PutMapping("/api/gear-types/{gearCode}")
@RequirePermission(resource = "admin:system-config", operation = "UPDATE")
public GearType updateGearType(@PathVariable String gearCode, @RequestBody GearType gearType) {
if (!gearTypeRepository.existsById(gearCode)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
"어구 유형을 찾을 수 없습니다: " + gearCode);
}
gearType.setGearCode(gearCode);
return gearTypeRepository.save(gearType);
return masterDataService.updateGearType(gearCode, gearType);
}
// ========================================================================
@ -81,7 +65,7 @@ public class MasterDataController {
@GetMapping("/api/patrol-ships")
@RequirePermission(resource = "patrol:patrol-route", operation = "READ")
public List<PatrolShip> listPatrolShips() {
return patrolShipRepository.findByIsActiveTrueOrderByShipCode();
return masterDataService.listPatrolShips();
}
@PatchMapping("/api/patrol-ships/{id}/status")
@ -90,47 +74,28 @@ public class MasterDataController {
@PathVariable Long id,
@RequestBody PatrolShipStatusRequest request
) {
PatrolShip ship = patrolShipRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"함정을 찾을 수 없습니다: " + id));
if (request.status() != null) ship.setCurrentStatus(request.status());
if (request.lat() != null) ship.setCurrentLat(request.lat());
if (request.lon() != null) ship.setCurrentLon(request.lon());
if (request.zoneCode() != null) ship.setCurrentZoneCode(request.zoneCode());
if (request.fuelPct() != null) ship.setFuelPct(request.fuelPct());
return patrolShipRepository.save(ship);
return masterDataService.updatePatrolShipStatus(id, new MasterDataService.PatrolShipStatusCommand(
request.status(), request.lat(), request.lon(), request.zoneCode(), request.fuelPct()
));
}
// ========================================================================
// 선박 허가 (vessel 권한)
// 선박 허가 (인증만, 공통 마스터 데이터)
// ========================================================================
@GetMapping("/api/vessel-permits")
// 인증된 사용자 모두 접근 가능 (메뉴 권한이 아닌 공통 마스터 데이터)
public Page<VesselPermit> listVesselPermits(
@RequestParam(required = false) String flag,
@RequestParam(required = false) String permitStatus,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
PageRequest pageable = PageRequest.of(page, size);
if (flag != null) {
return vesselPermitRepository.findByFlagCountry(flag, pageable);
}
if (permitStatus != null) {
return vesselPermitRepository.findByPermitStatus(permitStatus, pageable);
}
return vesselPermitRepository.findAll(pageable);
return masterDataService.listVesselPermits(flag, permitStatus, PageRequest.of(page, size));
}
@GetMapping("/api/vessel-permits/{mmsi}")
// 인증된 사용자 모두 접근 가능 (메뉴 권한이 아닌 공통 마스터 데이터)
public VesselPermit getVesselPermit(@PathVariable String mmsi) {
return vesselPermitRepository.findByMmsi(mmsi)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"선박 허가 정보를 찾을 수 없습니다: " + mmsi));
return masterDataService.getVesselPermit(mmsi);
}
// ========================================================================

파일 보기

@ -0,0 +1,115 @@
package gc.mda.kcg.master;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
/**
* 마스터 데이터(코드/어구/함정/선박허가) 조회 관리 서비스.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MasterDataService {
private final CodeMasterRepository codeMasterRepository;
private final GearTypeRepository gearTypeRepository;
private final PatrolShipRepository patrolShipRepository;
private final VesselPermitRepository vesselPermitRepository;
// 코드 마스터
public List<CodeMaster> listCodes(String groupCode) {
return codeMasterRepository.findByGroupCodeAndIsActiveTrueOrderBySortOrder(groupCode);
}
public List<CodeMaster> listChildren(String parentId) {
return codeMasterRepository.findByParentIdOrderBySortOrder(parentId);
}
// 어구 유형
public List<GearType> listGearTypes() {
return gearTypeRepository.findByIsActiveTrueOrderByDisplayOrder();
}
public GearType getGearType(String gearCode) {
return gearTypeRepository.findById(gearCode)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"어구 유형을 찾을 수 없습니다: " + gearCode));
}
@Transactional
public GearType createGearType(GearType gearType) {
if (gearTypeRepository.existsById(gearType.getGearCode())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"이미 존재하는 어구 코드입니다: " + gearType.getGearCode());
}
return gearTypeRepository.save(gearType);
}
@Transactional
public GearType updateGearType(String gearCode, GearType gearType) {
if (!gearTypeRepository.existsById(gearCode)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
"어구 유형을 찾을 수 없습니다: " + gearCode);
}
gearType.setGearCode(gearCode);
return gearTypeRepository.save(gearType);
}
// 함정
public List<PatrolShip> listPatrolShips() {
return patrolShipRepository.findByIsActiveTrueOrderByShipCode();
}
@Transactional
public PatrolShip updatePatrolShipStatus(Long id, PatrolShipStatusCommand command) {
PatrolShip ship = patrolShipRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"함정을 찾을 수 없습니다: " + id));
if (command.status() != null) ship.setCurrentStatus(command.status());
if (command.lat() != null) ship.setCurrentLat(command.lat());
if (command.lon() != null) ship.setCurrentLon(command.lon());
if (command.zoneCode() != null) ship.setCurrentZoneCode(command.zoneCode());
if (command.fuelPct() != null) ship.setFuelPct(command.fuelPct());
return patrolShipRepository.save(ship);
}
// 선박 허가
public Page<VesselPermit> listVesselPermits(String flag, String permitStatus, Pageable pageable) {
if (flag != null) {
return vesselPermitRepository.findByFlagCountry(flag, pageable);
}
if (permitStatus != null) {
return vesselPermitRepository.findByPermitStatus(permitStatus, pageable);
}
return vesselPermitRepository.findAll(pageable);
}
public VesselPermit getVesselPermit(String mmsi) {
return vesselPermitRepository.findByMmsi(mmsi)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"선박 허가 정보를 찾을 수 없습니다: " + mmsi));
}
// Command DTO
public record PatrolShipStatusCommand(
String status,
Double lat,
Double lon,
String zoneCode,
Integer fuelPct
) {}
}

파일 보기

@ -66,9 +66,6 @@ app:
base-url: ${PREDICTION_BASE_URL:http://localhost:8001}
signal-batch:
base-url: ${SIGNAL_BATCH_BASE_URL:http://192.168.1.18:18090/signal-batch}
iran-backend:
# 운영 환경: https://kcg.gc-si.dev (Spring Boot + Prediction 통합)
base-url: ${IRAN_BACKEND_BASE_URL:https://kcg.gc-si.dev}
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:5174}
jwt:

파일 보기

@ -31,7 +31,7 @@
| 서비스 | systemd | 포트 | 로그 |
|---|---|---|---|
| kcg-ai-prediction | `kcg-ai-prediction.service` | 18092 | `journalctl -u kcg-ai-prediction -f` |
| kcg-prediction (기존 iran) | `kcg-prediction.service` | 8001 | `journalctl -u kcg-prediction -f` |
| kcg-prediction (레거시) | `kcg-prediction.service` | 8001 | `journalctl -u kcg-prediction -f` |
| kcg-prediction-lab | `kcg-prediction-lab.service` | 18091 | `journalctl -u kcg-prediction-lab -f` |
## 디렉토리 구조
@ -166,7 +166,7 @@ PGPASSWORD='Kcg2026ai' psql -h 211.208.115.83 -U kcg-app -d kcgaidb
| 443 | nginx (HTTPS) | rocky-211 |
| 18080 | kcg-ai-backend (Spring Boot) | rocky-211 |
| 18092 | kcg-ai-prediction (FastAPI) | redis-211 |
| 8001 | kcg-prediction (기존 iran) | redis-211 |
| 8001 | kcg-prediction (레거시) | redis-211 |
| 18091 | kcg-prediction-lab | redis-211 |
| 5432 | PostgreSQL (kcgaidb, snpdb) | 211.208.115.83 |
| 6379 | Redis | redis-211 |
@ -226,5 +226,5 @@ ssh redis-211 "systemctl restart kcg-ai-prediction"
| `/home/apps/kcg-ai-prediction/.env` | prediction 환경변수 |
| `/home/apps/kcg-ai-prediction/venv/` | Python 3.9 가상환경 |
| `/etc/systemd/system/kcg-ai-prediction.service` | prediction systemd 서비스 |
| `/home/apps/kcg-prediction/` | 기존 iran prediction (포트 8001) |
| `/home/apps/kcg-prediction/` | 레거시 prediction (포트 8001) |
| `/home/apps/kcg-prediction-lab/` | 기존 lab prediction (포트 18091) |

파일 보기

@ -4,6 +4,19 @@
## [Unreleased]
## [2026-04-17]
### 변경
- **디자인 시스템 SSOT 일괄 준수 (30파일)**`frontend/design-system.html` 쇼케이스의 공통 컴포넌트와 `shared/constants/` 카탈로그를 우회하던 하드코딩 UI 를 전영역 치환. raw `<button>``<Button variant>` / raw `<input>``<Input>` / raw `<select>``<Select>` / 커스텀 탭 → `<TabBar>` + `<TabButton>` / raw checkbox → `<Checkbox>`. `text-red-400` 같은 다크 전용 색상을 `text-red-600 dark:text-red-400` 쌍으로 라이트 모드 대응. StatBox `color: string` prop 을 `intent: BadgeIntent` + `INTENT_TEXT_CLASS` 매핑으로 재설계. 에러 메시지도 `t('error.errorPrefix', { msg })` 로 통일. 영역: detection(6) / detection/components(4) / enforcement / surveillance(2) / admin(7) / parent-inference(3) / statistics / ai-operations(3) / dashboard / field-ops(2) / auth
- **i18n 하드코딩 한글 제거 (alert/confirm/aria-label 우선순위)**`common.json``aria` / `error` / `dialog` / `success` / `message` 네임스페이스 추가 (ko/en 대칭, 52개 키). 운영자 노출 `alert('실패: ' + msg)` 11건과 접근성 위반 `aria-label="역할 코드"` 등 40+건을 `t('aria.*')` / `t('error.*')` / `t('dialog.*')` 로 일괄 치환. parent-inference / admin / detection / enforcement / vessel / statistics / ai-operations 전 영역. MainLayout 언어 토글은 `title={t('message.switchToEnglish')}` + `aria-label={t('aria.languageToggle')}` 로 정비
- **iran 백엔드 프록시 잔재 제거**`IranBackendClient` dead class 삭제, `application.yml``iran-backend:` 블록 + `AppProperties.IranBackend` inner class 정리. prediction 이 kcgaidb 에 직접 write 하는 현 아키텍처에 맞춰 CLAUDE.md 시스템 구성 다이어그램 최신화. Frontend UI 라벨 `iran 백엔드 (분석)``AI 분석 엔진` 로 교체, system-flow manifest `external.iran_backend` 노드는 `status: deprecated` 마킹(노드 ID 안정성 원칙 준수, 1~2 릴리즈 후 삭제 예정)
- **백엔드 계층 분리** — AlertController/MasterDataController/AdminStatsController 에서 repository·JdbcTemplate 직접 주입 패턴 제거. `AlertService` · `MasterDataService` · `AdminStatsService` 신규 계층 도입 + `@Transactional(readOnly=true)` 적용. 공통 `RestClientConfig @Configuration` 으로 `predictionRestClient` / `signalBatchRestClient` Bean 통합 → Proxy controller 들의 `@PostConstruct` ad-hoc 생성 제거
- **감사 로그 보강**`EnforcementService` 의 createRecord / updateRecord / createPlan 에 `@Auditable` 추가 (ENFORCEMENT_CREATE/UPDATE/PLAN_CREATE). `VesselAnalysisGroupService.resolveParent``PARENT_RESOLVE` 액션 기록. 모든 쓰기 액션이 `auth_audit_log` 에 자동 수집
- **alertLevels 카탈로그 확장** — 8개 화면의 `level === 'CRITICAL' ? ... : 'HIGH' ? ...` 식 직접 분기를 제거하기 위해 `isValidAlertLevel` (타입 가드) / `isHighSeverity` / `getAlertLevelOrder` / `ALERT_LEVEL_MARKER_OPACITY` / `ALERT_LEVEL_MARKER_RADIUS` / `ALERT_LEVEL_TIER_SCORE` 헬퍼·상수 신설. LiveMapView 마커 시각 매핑, DarkVesselDetection tier→점수, GearIdentification 타입 가드, vesselAnomaly 패널 severity 할당 헬퍼로 치환
### 추가
- **performanceStatus 카탈로그 등록** — 이미 존재하던 `shared/constants/performanceStatus.ts` (good/normal/warning/critical/running/passed/failed/active/scheduled/archived 10종) 를 `catalogRegistry` 에 등록. design-system 쇼케이스 자동 노출 + admin 성능/보관/검증 페이지 SSOT 일원화
## [2026-04-16.7]
### 변경

파일 보기

@ -282,8 +282,9 @@ export function MainLayout() {
{/* 언어 토글 */}
<button
onClick={toggleLanguage}
aria-label={t('aria.languageToggle')}
className="px-2 py-1 rounded-lg text-[10px] font-bold bg-surface-overlay border border-border text-label hover:text-heading transition-colors whitespace-nowrap"
title={language === 'ko' ? 'Switch to English' : '한국어로 전환'}
title={language === 'ko' ? t('message.switchToEnglish') : t('message.switchToKorean')}
>
{language === 'ko' ? 'EN' : '한국어'}
</button>
@ -338,7 +339,7 @@ export function MainLayout() {
<div className="relative flex items-center">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-hint pointer-events-none" />
<input
aria-label="페이지 내 검색"
aria-label={t('aria.searchInPage')}
value={pageSearch}
onChange={(e) => setPageSearch(e.target.value)}
onKeyDown={(e) => {

파일 보기

@ -94,12 +94,12 @@ export function AccessControl() {
}, [tab, loadUsers, loadAudit]);
const handleUnlock = async (userId: string, acnt: string) => {
if (!confirm(`계정 ${acnt} 잠금을 해제하시겠습니까?`)) return;
if (!confirm(`${acnt} ${tc('dialog.genericRemove')}`)) return;
try {
await unlockUser(userId);
await loadUsers();
} catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
}
};
@ -146,15 +146,23 @@ export function AccessControl() {
{ key: 'userId', label: '관리', width: '90px', align: 'center', sortable: false,
render: (_v, row) => (
<div className="flex items-center justify-center gap-1">
<button type="button" onClick={() => setAssignTarget(row)}
className="p-1 text-hint hover:text-heading" title="역할 배정">
<UserCog className="w-3 h-3" />
</button>
<Button
variant="ghost"
size="sm"
onClick={() => setAssignTarget(row)}
aria-label="역할 배정"
title="역할 배정"
icon={<UserCog className="w-3 h-3" />}
/>
{row.userSttsCd === 'LOCKED' && (
<button type="button" onClick={() => handleUnlock(row.userId, row.userAcnt)}
className="p-1 text-hint hover:text-label" title="잠금 해제">
<Key className="w-3 h-3" />
</button>
<Button
variant="ghost"
size="sm"
onClick={() => handleUnlock(row.userId, row.userAcnt)}
aria-label="잠금 해제"
title="잠금 해제"
icon={<Key className="w-3 h-3" />}
/>
)}
</div>
),

파일 보기

@ -341,6 +341,7 @@ type Tab = 'signal' | 'monitor' | 'collect' | 'load' | 'agents';
export function DataHub() {
const { t } = useTranslation('admin');
const { t: tc } = useTranslation('common');
const [tab, setTab] = useState<Tab>('signal');
const [selectedDate, setSelectedDate] = useState('2026-04-02');
const [statusFilter, setStatusFilter] = useState<'' | 'ON' | 'OFF'>('');
@ -442,7 +443,7 @@ export function DataHub() {
<div className="flex items-center gap-2">
<div className="relative">
<input
aria-label="수신 현황 기준일"
aria-label={tc('aria.receiptDate')}
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}

파일 보기

@ -124,7 +124,7 @@ export function DataModelVerification() {
<PageContainer>
<PageHeader
icon={ListChecks}
iconColor="text-green-400"
iconColor="text-green-600 dark:text-green-400"
title="데이터 모델 검증"
description="DAR-11 | 논리·물리 데이터 모델 검증 기준 정의·실시 및 결과 관리"
demo
@ -157,7 +157,7 @@ export function DataModelVerification() {
{/* 검증 절차 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<ClipboardCheck className="w-4 h-4 text-green-400" />
<ClipboardCheck className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-[12px] font-bold text-heading"> (4)</span>
</div>
<div className="grid grid-cols-4 gap-3">
@ -168,14 +168,14 @@ export function DataModelVerification() {
)}
<div className="bg-surface-overlay rounded-lg p-3 h-full">
<div className="flex items-center gap-2 mb-2">
<s.icon className="w-4 h-4 text-green-400" />
<span className="text-[11px] font-bold text-green-400">{s.phase}</span>
<s.icon className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-[11px] font-bold text-green-600 dark:text-green-400">{s.phase}</span>
</div>
<div className="text-[9px] text-cyan-400 mb-2">{s.responsible}</div>
<div className="text-[9px] text-cyan-600 dark:text-cyan-400 mb-2">{s.responsible}</div>
<ul className="space-y-1.5">
{s.actions.map(a => (
<li key={a} className="flex items-start gap-1.5 text-[9px] text-muted-foreground">
<CheckCircle className="w-3 h-3 text-green-500 shrink-0 mt-0.5" />
<CheckCircle className="w-3 h-3 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<span>{a}</span>
</li>
))}
@ -190,7 +190,7 @@ export function DataModelVerification() {
<div className="grid grid-cols-2 gap-3">
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Users className="w-4 h-4 text-blue-400" />
<Users className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span className="text-[12px] font-bold text-heading"> </span>
</div>
<div className="space-y-2">
@ -207,7 +207,7 @@ export function DataModelVerification() {
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Layers className="w-4 h-4 text-purple-400" />
<Layers className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-[12px] font-bold text-heading"> ({SUBJECT_AREAS.reduce((s, a) => s + a.count, 0)} )</span>
</div>
<div className="space-y-1.5">
@ -229,7 +229,7 @@ export function DataModelVerification() {
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<GitBranch className="w-4 h-4 text-green-400" />
<GitBranch className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-[12px] font-bold text-heading"> </span>
<Badge intent="success" size="xs">{LOGICAL_CHECKS.filter(c => c.status === '통과').length}/{LOGICAL_CHECKS.length} </Badge>
</div>
@ -249,7 +249,7 @@ export function DataModelVerification() {
<td className="py-2.5 px-2"><Badge intent="muted" size="xs">{c.category}</Badge></td>
<td className="py-2.5 text-heading font-medium">{c.item}</td>
<td className="py-2.5 text-muted-foreground text-[9px]">{c.desc}</td>
<td className="py-2.5 text-center text-cyan-400 font-medium text-[9px]">{c.result}</td>
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-medium text-[9px]">{c.result}</td>
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(c.status)} size="sm">{c.status}</Badge></td>
</tr>
))}
@ -279,7 +279,7 @@ export function DataModelVerification() {
{tab === 'physical' && (
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Database className="w-4 h-4 text-purple-400" />
<Database className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-[12px] font-bold text-heading"> </span>
<Badge intent="success" size="xs">{PHYSICAL_CHECKS.filter(c => c.status === '통과').length}/{PHYSICAL_CHECKS.length} </Badge>
{PHYSICAL_CHECKS.some(c => c.status === '주의') && (
@ -302,7 +302,7 @@ export function DataModelVerification() {
<td className="py-2.5 px-2"><Badge intent="muted" size="xs">{c.category}</Badge></td>
<td className="py-2.5 text-heading font-medium">{c.item}</td>
<td className="py-2.5 text-muted-foreground text-[9px]">{c.desc}</td>
<td className="py-2.5 text-center text-cyan-400 font-medium text-[9px]">{c.result}</td>
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-medium text-[9px]">{c.result}</td>
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(c.status)} size="sm">{c.status}</Badge></td>
</tr>
))}
@ -315,7 +315,7 @@ export function DataModelVerification() {
{tab === 'duplication' && (
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Shield className="w-4 h-4 text-cyan-400" />
<Shield className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
<span className="text-[12px] font-bold text-heading"> · </span>
<Badge intent="success" size="xs">{DUPLICATION_CHECKS.filter(c => c.status === '통과').length}/{DUPLICATION_CHECKS.length} </Badge>
</div>
@ -335,7 +335,7 @@ export function DataModelVerification() {
<td className="py-2.5 px-2 text-heading font-medium">{c.target}</td>
<td className="py-2.5 text-muted-foreground text-[9px]">{c.desc}</td>
<td className="py-2.5 text-center"><Badge intent="muted" size="xs">{c.scope}</Badge></td>
<td className="py-2.5 text-center text-cyan-400 font-medium text-[9px]">{c.result}</td>
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-medium text-[9px]">{c.result}</td>
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(c.status)} size="sm">{c.status}</Badge></td>
</tr>
))}
@ -348,7 +348,7 @@ export function DataModelVerification() {
{tab === 'history' && (
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<FileText className="w-4 h-4 text-blue-400" />
<FileText className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span className="text-[12px] font-bold text-heading"> </span>
<Badge intent="info" size="xs">{VERIFICATION_HISTORY.length}</Badge>
</div>
@ -372,7 +372,7 @@ export function DataModelVerification() {
<td className="py-2.5 text-center"><Badge intent="muted" size="xs">{h.phase}</Badge></td>
<td className="py-2.5 text-muted-foreground">{h.reviewer}</td>
<td className="py-2.5 text-heading font-medium text-[9px]">{h.target}</td>
<td className="py-2.5 text-center">{h.issues > 0 ? <span className="text-yellow-400 font-bold">{h.issues}</span> : <span className="text-green-400">0</span>}</td>
<td className="py-2.5 text-center">{h.issues > 0 ? <span className="text-yellow-600 dark:text-yellow-400 font-bold">{h.issues}</span> : <span className="text-green-600 dark:text-green-400">0</span>}</td>
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(h.result)} size="sm">{h.result}</Badge></td>
</tr>
))}

파일 보기

@ -108,7 +108,7 @@ export function DataRetentionPolicy() {
<PageContainer>
<PageHeader
icon={Database}
iconColor="text-blue-400"
iconColor="text-blue-600 dark:text-blue-400"
title="데이터 보관기간 및 파기 정책"
description="DAR-10 | 데이터 유형별 보관기간 기준표, 파기 절차, 보존 연장 예외 관리"
demo
@ -141,14 +141,14 @@ export function DataRetentionPolicy() {
{/* 보관 구조 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Settings className="w-4 h-4 text-blue-400" />
<Settings className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span className="text-[12px] font-bold text-heading"> (4-Tier)</span>
</div>
<div className="grid grid-cols-4 gap-3">
{STORAGE_ARCHITECTURE.map(s => (
<div key={s.tier} className="bg-surface-overlay rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<s.icon className="w-4 h-4 text-blue-400" />
<s.icon className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span className="text-[11px] font-bold text-heading">{s.tier}</span>
</div>
<p className="text-[9px] text-hint mb-2">{s.desc}</p>
@ -184,7 +184,7 @@ export function DataRetentionPolicy() {
['CCTV 30일 보관 준수', '미채택 영상 30일 초과 1건', '주의'],
].map(([k, v, s]) => (
<div key={k} className="flex items-center gap-2 px-2 py-1.5 bg-surface-overlay rounded">
{s === '완료' || s === '정상' ? <CheckCircle className="w-3 h-3 text-green-500" /> : <AlertTriangle className="w-3 h-3 text-yellow-500" />}
{s === '완료' || s === '정상' ? <CheckCircle className="w-3 h-3 text-green-600 dark:text-green-500" /> : <AlertTriangle className="w-3 h-3 text-yellow-600 dark:text-yellow-500" />}
<span className="text-heading flex-1">{k}</span>
<span className="text-hint">{v}</span>
</div>
@ -213,7 +213,7 @@ export function DataRetentionPolicy() {
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<CalendarClock className="w-4 h-4 text-blue-400" />
<CalendarClock className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span className="text-[12px] font-bold text-heading"> </span>
<Badge intent="info" size="xs">{RETENTION_TABLE.length} </Badge>
</div>
@ -235,7 +235,7 @@ export function DataRetentionPolicy() {
<td className="py-2.5 px-2 text-heading font-medium">{r.type}</td>
<td className="py-2.5"><Badge intent="muted" size="xs">{r.category}</Badge></td>
<td className="py-2.5 text-muted-foreground text-[9px]">{r.basis}</td>
<td className="py-2.5 text-center text-cyan-400 font-bold">{r.period}</td>
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-bold">{r.period}</td>
<td className="py-2.5 text-muted-foreground text-[9px]">{r.format}</td>
<td className="py-2.5 text-center text-muted-foreground">{r.volume}</td>
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(r.status)} size="sm">{r.status}</Badge></td>
@ -273,7 +273,7 @@ export function DataRetentionPolicy() {
{/* 파기 승인 워크플로우 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Trash2 className="w-4 h-4 text-red-400" />
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
<span className="text-[12px] font-bold text-heading"> (4)</span>
</div>
<div className="grid grid-cols-4 gap-3">
@ -284,14 +284,14 @@ export function DataRetentionPolicy() {
)}
<div className="bg-surface-overlay rounded-lg p-3 h-full">
<div className="flex items-center gap-2 mb-2">
<s.icon className="w-4 h-4 text-blue-400" />
<span className="text-[11px] font-bold text-blue-400">{s.phase}</span>
<s.icon className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span className="text-[11px] font-bold text-blue-600 dark:text-blue-400">{s.phase}</span>
</div>
<div className="text-[9px] text-cyan-400 mb-2">{s.responsible}</div>
<div className="text-[9px] text-cyan-600 dark:text-cyan-400 mb-2">{s.responsible}</div>
<ul className="space-y-1.5">
{s.actions.map(a => (
<li key={a} className="flex items-start gap-1.5 text-[9px] text-muted-foreground">
<CheckCircle className="w-3 h-3 text-green-500 shrink-0 mt-0.5" />
<CheckCircle className="w-3 h-3 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<span>{a}</span>
</li>
))}
@ -305,7 +305,7 @@ export function DataRetentionPolicy() {
{/* 파기 방식 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Lock className="w-4 h-4 text-red-400" />
<Lock className="w-4 h-4 text-red-600 dark:text-red-400" />
<span className="text-[12px] font-bold text-heading"> </span>
</div>
<table className="w-full text-[10px]">
@ -326,7 +326,7 @@ export function DataRetentionPolicy() {
<td className="py-2.5 text-muted-foreground text-[9px]">{m.desc}</td>
<td className="py-2.5 text-muted-foreground">{m.target}</td>
<td className="py-2.5 text-center"><Badge intent={m.encryption.includes('AES') ? 'success' : 'muted'} size="xs">{m.encryption}</Badge></td>
<td className="py-2.5 text-center text-red-400 text-[9px] font-medium">{m.recovery}</td>
<td className="py-2.5 text-center text-red-600 dark:text-red-400 text-[9px] font-medium">{m.recovery}</td>
<td className="py-2.5 text-center"><Badge intent="success" size="sm">{m.status}</Badge></td>
</tr>
))}
@ -341,7 +341,7 @@ export function DataRetentionPolicy() {
<div className="space-y-3">
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<ShieldCheck className="w-4 h-4 text-purple-400" />
<ShieldCheck className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-[12px] font-bold text-heading"> </span>
<Badge intent="warning" size="xs">{EXCEPTIONS.filter(e => e.status === '연장 중').length} </Badge>
</div>
@ -364,7 +364,7 @@ export function DataRetentionPolicy() {
<td className="py-2.5 text-heading font-medium">{e.dataType}</td>
<td className="py-2.5 text-muted-foreground text-[9px]">{e.reason}</td>
<td className="py-2.5 text-center text-muted-foreground">{e.originalExpiry}</td>
<td className="py-2.5 text-center text-cyan-400 font-medium text-[9px]">{e.extendedTo}</td>
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-medium text-[9px]">{e.extendedTo}</td>
<td className="py-2.5 text-center text-muted-foreground">{e.approver}</td>
<td className="py-2.5 text-center"><Badge intent={getStatusIntent(e.status)} size="sm">{e.status}</Badge></td>
</tr>
@ -375,13 +375,13 @@ export function DataRetentionPolicy() {
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<AlertTriangle className="w-4 h-4 text-yellow-400" />
<AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400" />
<span className="text-[12px] font-bold text-heading"> </span>
</div>
<div className="space-y-2">
{EXCEPTION_RULES.map(r => (
<div key={r.rule} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
<ShieldCheck className="w-4 h-4 text-purple-400" />
<ShieldCheck className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<div className="flex-1">
<div className="text-[11px] text-heading font-medium">{r.rule}</div>
<div className="text-[9px] text-hint">{r.desc}</div>
@ -398,7 +398,7 @@ export function DataRetentionPolicy() {
{tab === 'audit' && (
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<FileText className="w-4 h-4 text-green-400" />
<FileText className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-[12px] font-bold text-heading"> </span>
<Badge intent="info" size="xs">{DISPOSAL_AUDIT_LOG.length}</Badge>
</div>
@ -424,7 +424,7 @@ export function DataRetentionPolicy() {
<td className="py-2.5 text-heading font-medium text-[9px]">{d.target}</td>
<td className="py-2.5 text-center"><Badge intent="muted" size="xs">{d.type}</Badge></td>
<td className="py-2.5 text-center text-muted-foreground">{d.method}</td>
<td className="py-2.5 text-center text-cyan-400 font-mono">{d.volume}</td>
<td className="py-2.5 text-center text-cyan-600 dark:text-cyan-400 font-mono">{d.volume}</td>
<td className="py-2.5 text-center text-muted-foreground">{d.operator}</td>
<td className="py-2.5 text-center text-muted-foreground">{d.approver}</td>
<td className="py-2.5 text-center"><Badge intent="success" size="sm">{d.result}</Badge></td>

파일 보기

@ -74,6 +74,7 @@ const ROLE_OPTIONS = ['ADMIN', 'OPERATOR', 'ANALYST', 'FIELD', 'VIEWER'];
export function NoticeManagement() {
const { t } = useTranslation('admin');
const { t: tc } = useTranslation('common');
const { hasPermission } = useAuth();
const canCreate = hasPermission('admin:notices', 'CREATE');
const canUpdate = hasPermission('admin:notices', 'UPDATE');
@ -265,7 +266,7 @@ export function NoticeManagement() {
<span className="text-sm font-bold text-heading">
{editingId ? '알림 수정' : '새 알림 등록'}
</span>
<button type="button" aria-label="닫기" onClick={() => setShowForm(false)} className="text-hint hover:text-heading">
<button type="button" aria-label={tc('aria.close')} onClick={() => setShowForm(false)} className="text-hint hover:text-heading">
<X className="w-4 h-4" />
</button>
</div>
@ -275,7 +276,7 @@ export function NoticeManagement() {
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<input
aria-label="알림 제목"
aria-label={tc('aria.noticeTitle')}
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/50"
@ -287,7 +288,7 @@ export function NoticeManagement() {
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<textarea
aria-label="알림 내용"
aria-label={tc('aria.noticeContent')}
value={form.message}
onChange={(e) => setForm({ ...form, message: e.target.value })}
rows={3}
@ -343,7 +344,7 @@ export function NoticeManagement() {
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<input
aria-label="시작일"
aria-label={tc('aria.dateFrom')}
type="date"
value={form.startDate}
onChange={(e) => setForm({ ...form, startDate: e.target.value })}
@ -353,7 +354,7 @@ export function NoticeManagement() {
<div>
<label className="text-[10px] text-muted-foreground font-medium mb-1 block"></label>
<input
aria-label="종료일"
aria-label={tc('aria.dateTo')}
type="date"
value={form.endDate}
onChange={(e) => setForm({ ...form, endDate: e.target.value })}

파일 보기

@ -153,7 +153,7 @@ export function PerformanceMonitoring() {
<PageContainer>
<PageHeader
icon={Activity}
iconColor="text-cyan-400"
iconColor="text-cyan-600 dark:text-cyan-400"
title="성능 모니터링"
description="PER-01~06 | 응답성·처리용량·AI 모델·가용성·확장성 실시간 현황"
demo
@ -192,7 +192,7 @@ export function PerformanceMonitoring() {
{/* 사용자 그룹별 SLO */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Users className="w-4 h-4 text-cyan-400" />
<Users className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
<span className="text-[12px] font-bold text-heading"> SLO ( 2,900 + )</span>
<Badge intent="info" size="xs"> 200 · 100 </Badge>
</div>
@ -225,20 +225,20 @@ export function PerformanceMonitoring() {
{/* 성능 영향 최소화 전략 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Zap className="w-4 h-4 text-amber-400" />
<Zap className="w-4 h-4 text-amber-600 dark:text-amber-400" />
<span className="text-[12px] font-bold text-heading"> ( AIS )</span>
</div>
<div className="grid grid-cols-2 gap-2">
{IMPACT_REDUCTION.map((s, i) => (
<div key={s.strategy} className="flex items-start gap-2 px-3 py-2 bg-surface-overlay rounded-lg">
<div className="w-5 h-5 rounded-full bg-amber-500/20 flex items-center justify-center shrink-0 text-[9px] font-bold text-amber-400">{i + 1}</div>
<div className="w-5 h-5 rounded-full bg-amber-500/20 flex items-center justify-center shrink-0 text-[9px] font-bold text-amber-600 dark:text-amber-400">{i + 1}</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-[11px] text-heading font-medium">{s.strategy}</span>
<Badge intent="info" size="xs">{s.per}</Badge>
</div>
<div className="text-[9px] text-hint mb-0.5">: {s.target}</div>
<div className="text-[9px] text-green-400">: {s.effect}</div>
<div className="text-[9px] text-green-600 dark:text-green-400">: {s.effect}</div>
</div>
</div>
))}
@ -253,7 +253,7 @@ export function PerformanceMonitoring() {
<Card><CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Gauge className="w-4 h-4 text-cyan-400" />
<Gauge className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
<span className="text-[12px] font-bold text-heading">PER-01 SLO vs (p50/p95/p99)</span>
</div>
<Badge intent="success" size="sm">TER-03 </Badge>
@ -273,7 +273,7 @@ export function PerformanceMonitoring() {
{RESPONSE_SLO.map(r => (
<tr key={r.target} className="border-b border-border/40 hover:bg-surface-overlay/40">
<td className="py-2 px-2 text-label font-medium">{r.target}</td>
<td className="py-2 px-2 text-right text-cyan-400 font-medium">{r.slo}</td>
<td className="py-2 px-2 text-right text-cyan-600 dark:text-cyan-400 font-medium">{r.slo}</td>
<td className="py-2 px-2 text-right text-hint">{r.p50}</td>
<td className="py-2 px-2 text-right text-heading font-medium">{r.p95}</td>
<td className="py-2 px-2 text-right text-label">{r.p99}</td>
@ -287,7 +287,7 @@ export function PerformanceMonitoring() {
<div className="grid grid-cols-2 gap-3">
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Shield className="w-4 h-4 text-purple-400" />
<Shield className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-[12px] font-bold text-heading"> SLO (24/7 100)</span>
</div>
<div className="space-y-2">
@ -304,8 +304,8 @@ export function PerformanceMonitoring() {
<div className="text-[9px] text-hint">: {s.target}</div>
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] text-green-400 font-bold">{s.current}</span>
{s.met ? <CheckCircle className="w-4 h-4 text-green-500" /> : <AlertTriangle className="w-4 h-4 text-amber-500" />}
<span className="text-[11px] text-green-600 dark:text-green-400 font-bold">{s.current}</span>
{s.met ? <CheckCircle className="w-4 h-4 text-green-600 dark:text-green-500" /> : <AlertTriangle className="w-4 h-4 text-amber-600 dark:text-amber-500" />}
</div>
</div>
))}
@ -314,32 +314,32 @@ export function PerformanceMonitoring() {
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Clock className="w-4 h-4 text-blue-400" />
<Clock className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span className="text-[12px] font-bold text-heading"> </span>
</div>
<ul className="space-y-2 text-[11px]">
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading">:</strong> <span className="text-label">1 p50/p95/p99 </span></div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading">:</strong> <span className="text-label">OpenTelemetry + Prometheus + Grafana</span></div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading">APM:</strong> <span className="text-label"> + Trace ID </span></div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading">API :</strong> <span className="text-label">3 · Exponential Backoff · 3</span></div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading">:</strong> <span className="text-label">SLO 5 PagerDuty</span></div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading"> :</strong> <span className="text-label">RED/USE + ·· </span></div>
</li>
</ul>
@ -354,7 +354,7 @@ export function PerformanceMonitoring() {
{/* 동시접속·TPS */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Users className="w-4 h-4 text-blue-400" />
<Users className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span className="text-[12px] font-bold text-heading">PER-02 · ( 600 / 900)</span>
</div>
<div className="grid grid-cols-4 gap-3">
@ -375,7 +375,7 @@ export function PerformanceMonitoring() {
{/* 배치 작업 현황 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Database className="w-4 h-4 text-purple-400" />
<Database className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-[12px] font-bold text-heading">PER-03 · </span>
<Badge intent="success" size="xs">SLA 6/7</Badge>
</div>
@ -396,7 +396,7 @@ export function PerformanceMonitoring() {
<td className="py-2 px-2 text-label font-medium">{j.name}</td>
<td className="py-2 px-2 text-hint">{j.schedule}</td>
<td className="py-2 px-2 text-right text-label">{j.volume}</td>
<td className="py-2 px-2 text-right text-cyan-400">{j.sla}</td>
<td className="py-2 px-2 text-right text-cyan-600 dark:text-cyan-400">{j.sla}</td>
<td className="py-2 px-2 text-right text-heading font-medium">{j.avg}</td>
<td className="py-2 px-2 text-center"><Badge intent={statusIntent(j.status)} size="xs">{j.lastRun}</Badge></td>
</tr>
@ -408,7 +408,7 @@ export function PerformanceMonitoring() {
{/* 처리 볼륨 산정 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<HardDrive className="w-4 h-4 text-cyan-400" />
<HardDrive className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
<span className="text-[12px] font-bold text-heading"> ( + S&P )</span>
</div>
<div className="grid grid-cols-3 gap-3 text-[11px]">
@ -420,12 +420,12 @@ export function PerformanceMonitoring() {
<div className="px-3 py-3 bg-surface-overlay rounded-lg">
<div className="text-[10px] text-hint mb-1"> (· )</div>
<div className="text-xl font-bold text-heading">330 ~ 900 GB</div>
<div className="text-[9px] text-green-400 mt-1"> 50~80% </div>
<div className="text-[9px] text-green-600 dark:text-green-400 mt-1"> 50~80% </div>
</div>
<div className="px-3 py-3 bg-surface-overlay rounded-lg">
<div className="text-[10px] text-hint mb-1">3 ()</div>
<div className="text-xl font-bold text-heading">~360 TB ~ 1 PB</div>
<div className="text-[9px] text-amber-400 mt-1">NAS 100TB </div>
<div className="text-[9px] text-amber-600 dark:text-amber-400 mt-1">NAS 100TB </div>
</div>
</div>
</CardContent></Card>
@ -438,7 +438,7 @@ export function PerformanceMonitoring() {
<Card><CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Brain className="w-4 h-4 text-purple-400" />
<Brain className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-[12px] font-bold text-heading">PER-04 AI </span>
</div>
<div className="flex items-center gap-2">
@ -467,7 +467,7 @@ export function PerformanceMonitoring() {
<td className="py-2 px-2 text-right text-label">{m.precision}%</td>
<td className="py-2 px-2 text-right text-label">{m.recall}%</td>
<td className="py-2 px-2 text-right text-label">{m.f1}</td>
<td className="py-2 px-2 text-right text-cyan-400">{m.rocAuc}</td>
<td className="py-2 px-2 text-right text-cyan-600 dark:text-cyan-400">{m.rocAuc}</td>
<td className="py-2 px-2 text-hint text-[10px]">{m.target}</td>
<td className="py-2 px-2 text-center"><Badge intent={statusIntent(m.status)} size="xs">{m.status === 'good' ? '통과' : '주의'}</Badge></td>
</tr>
@ -479,28 +479,28 @@ export function PerformanceMonitoring() {
<div className="grid grid-cols-2 gap-3">
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<TrendingUp className="w-4 h-4 text-green-400" />
<TrendingUp className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-[12px] font-bold text-heading"> </span>
</div>
<ul className="space-y-2 text-[11px]">
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading">// :</strong> <span className="text-label">70/15/15 , K-Fold 5</span></div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading"> :</strong> <span className="text-label"> KL divergence </span></div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading"> :</strong> <span className="text-label">F1 3%p </span></div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading">:</strong> <span className="text-label">Feature Importance + SHAP </span></div>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-0.5" />
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
<div><strong className="text-heading">A/B :</strong> <span className="text-label">Shadow Canary 5% 50% 100% </span></div>
</li>
</ul>
@ -508,7 +508,7 @@ export function PerformanceMonitoring() {
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Cpu className="w-4 h-4 text-amber-400" />
<Cpu className="w-4 h-4 text-amber-600 dark:text-amber-400" />
<span className="text-[12px] font-bold text-heading"> (GPU )</span>
</div>
<div className="space-y-3">
@ -551,7 +551,7 @@ export function PerformanceMonitoring() {
{/* 가용성 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Shield className="w-4 h-4 text-cyan-400" />
<Shield className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
<span className="text-[12px] font-bold text-heading">PER-05 ( 99.9%)</span>
</div>
<table className="w-full text-[11px]">
@ -570,7 +570,7 @@ export function PerformanceMonitoring() {
<tr key={a.component} className="border-b border-border/40 hover:bg-surface-overlay/40">
<td className="py-2 px-2 text-label font-medium">{a.component}</td>
<td className="py-2 px-2 text-right text-heading font-medium">{a.uptime}</td>
<td className="py-2 px-2 text-right text-cyan-400">{a.rto}</td>
<td className="py-2 px-2 text-right text-cyan-600 dark:text-cyan-400">{a.rto}</td>
<td className="py-2 px-2 text-right text-label">{a.rpo}</td>
<td className="py-2 px-2 text-hint">{a.lastIncident}</td>
<td className="py-2 px-2 text-center"><Badge intent={statusIntent(a.status)} size="xs">{a.status === 'good' ? '정상' : '주의'}</Badge></td>
@ -583,7 +583,7 @@ export function PerformanceMonitoring() {
{/* 확장성 */}
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Server className="w-4 h-4 text-purple-400" />
<Server className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-[12px] font-bold text-heading">PER-06 </span>
<Badge intent="info" size="xs">2(6,000) </Badge>
</div>
@ -613,34 +613,34 @@ export function PerformanceMonitoring() {
<div className="grid grid-cols-4 gap-3">
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<Wifi className="w-4 h-4 text-cyan-400" />
<Wifi className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
<span className="text-[11px] font-bold text-heading"> </span>
</div>
<div className="text-2xl font-bold text-cyan-400">99.9%</div>
<div className="text-2xl font-bold text-cyan-600 dark:text-cyan-400">99.9%</div>
<div className="text-[9px] text-hint mt-1"> 43</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<Clock className="w-4 h-4 text-purple-400" />
<Clock className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-[11px] font-bold text-heading">RTO </span>
</div>
<div className="text-2xl font-bold text-purple-400"> 60</div>
<div className="text-2xl font-bold text-purple-600 dark:text-purple-400"> 60</div>
<div className="text-[9px] text-hint mt-1"> · Self-healing</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<Database className="w-4 h-4 text-green-400" />
<Database className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-[11px] font-bold text-heading">RPO </span>
</div>
<div className="text-2xl font-bold text-green-400"> 10</div>
<div className="text-2xl font-bold text-green-600 dark:text-green-400"> 10</div>
<div className="text-[9px] text-hint mt-1"> + </div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="w-4 h-4 text-amber-400" />
<TrendingUp className="w-4 h-4 text-amber-600 dark:text-amber-400" />
<span className="text-[11px] font-bold text-heading">Scale-out </span>
</div>
<div className="text-2xl font-bold text-amber-400">×2</div>
<div className="text-2xl font-bold text-amber-600 dark:text-amber-400">×2</div>
<div className="text-[9px] text-hint mt-1">6,000 </div>
</CardContent></Card>
</div>

파일 보기

@ -6,6 +6,7 @@ import {
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Input } from '@shared/components/ui/input';
import {
fetchRoles, fetchPermTree, createRole, deleteRole, updateRolePermissions,
type RoleWithPermissions, type PermTreeNode, type PermEntry,
@ -19,6 +20,7 @@ import { useSettingsStore } from '@stores/settingsStore';
import { getRoleBadgeStyle, ROLE_DEFAULT_PALETTE } from '@shared/constants/userRoles';
import { ColorPicker } from '@shared/components/common/ColorPicker';
import { updateRole as apiUpdateRole } from '@/services/adminApi';
import { useTranslation } from 'react-i18next';
/**
* (wing ).
@ -45,6 +47,7 @@ type DraftPerms = Map<string, 'Y' | 'N' | null>; // null = 명시 권한 제거
function makeKey(rsrcCd: string, operCd: string) { return `${rsrcCd}::${operCd}`; }
export function PermissionsPanel() {
const { t: tc } = useTranslation('common');
const { hasPermission } = useAuth();
const canCreateRole = hasPermission('admin:role-management', 'CREATE');
const canDeleteRole = hasPermission('admin:role-management', 'DELETE');
@ -230,7 +233,7 @@ export function PermissionsPanel() {
await updateRolePermissions(selectedRole.roleSn, changes);
await load(); // 새로 가져와서 동기화
alert(`권한 ${changes.length}건 갱신되었습니다.`);
alert(`${tc('success.permissionUpdated')} (${changes.length})`);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'unknown');
} finally {
@ -247,7 +250,7 @@ export function PermissionsPanel() {
setNewRoleColor(ROLE_DEFAULT_PALETTE[0]);
await load();
} catch (e: unknown) {
alert('생성 실패: ' + (e instanceof Error ? e.message : 'unknown'));
alert(tc('error.createFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
}
};
@ -257,23 +260,23 @@ export function PermissionsPanel() {
await load();
setEditingColor(null);
} catch (e: unknown) {
alert('색상 변경 실패: ' + (e instanceof Error ? e.message : 'unknown'));
alert(tc('error.updateFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
}
};
const handleDeleteRole = async () => {
if (!selectedRole) return;
if (selectedRole.builtinYn === 'Y') {
alert('내장 역할은 삭제할 수 없습니다.');
alert(tc('message.builtinRoleCannotDelete'));
return;
}
if (!confirm(`"${selectedRole.roleNm}" 역할을 삭제하시겠습니까?`)) return;
if (!confirm(`"${selectedRole.roleNm}" ${tc('dialog.deleteRole')}`)) return;
try {
await deleteRole(selectedRole.roleSn);
setSelectedRoleSn(null);
await load();
} catch (e: unknown) {
alert('삭제 실패: ' + (e instanceof Error ? e.message : 'unknown'));
alert(tc('error.deleteFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
}
};
@ -358,14 +361,17 @@ export function PermissionsPanel() {
</p>
</div>
<div className="flex items-center gap-1">
<button type="button" onClick={load}
className="p-1.5 rounded text-hint hover:text-label hover:bg-surface-overlay" title="새로고침">
<RefreshCw className="w-3.5 h-3.5" />
</button>
<Button
variant="ghost"
size="sm"
onClick={load}
aria-label={tc('aria.refresh')}
icon={<RefreshCw className="w-3.5 h-3.5" />}
/>
</div>
</div>
{error && <div className="text-xs text-heading">: {error}</div>}
{error && <div className="text-xs text-heading">{tc('error.errorPrefix', { msg: error })}</div>}
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
@ -378,28 +384,44 @@ export function PermissionsPanel() {
<div className="text-xs text-label font-bold"></div>
<div className="flex items-center gap-1">
{canCreateRole && (
<button type="button" onClick={() => setShowCreate(!showCreate)}
className="p-1 text-hint hover:text-label" title="신규 역할">
<Plus className="w-3.5 h-3.5" />
</button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowCreate(!showCreate)}
aria-label="신규 역할"
title="신규 역할"
icon={<Plus className="w-3.5 h-3.5" />}
/>
)}
{canDeleteRole && selectedRole && selectedRole.builtinYn !== 'Y' && (
<button type="button" onClick={handleDeleteRole}
className="p-1 text-hint hover:text-heading" title="역할 삭제">
<Trash2 className="w-3.5 h-3.5" />
</button>
<Button
variant="ghost"
size="sm"
onClick={handleDeleteRole}
aria-label="역할 삭제"
title="역할 삭제"
icon={<Trash2 className="w-3.5 h-3.5" />}
/>
)}
</div>
</div>
{showCreate && (
<div className="mb-2 p-2 bg-surface-overlay rounded space-y-1.5">
<input aria-label="역할 코드" value={newRoleCd} onChange={(e) => setNewRoleCd(e.target.value.toUpperCase())}
placeholder="ROLE_CD (대문자)"
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
<input aria-label="역할 이름" value={newRoleNm} onChange={(e) => setNewRoleNm(e.target.value)}
placeholder="역할 이름"
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
<Input
aria-label={tc('aria.roleCode')}
size="sm"
value={newRoleCd}
onChange={(e) => setNewRoleCd(e.target.value.toUpperCase())}
placeholder="ROLE_CD (대문자)"
/>
<Input
aria-label={tc('aria.roleName')}
size="sm"
value={newRoleNm}
onChange={(e) => setNewRoleNm(e.target.value)}
placeholder={tc('aria.roleName')}
/>
<ColorPicker label="배지 색상" value={newRoleColor} onChange={setNewRoleColor} />
<div className="flex gap-1 pt-1">
<Button variant="primary" size="sm" onClick={handleCreateRole} disabled={!newRoleCd || !newRoleNm} className="flex-1">

파일 보기

@ -77,6 +77,7 @@ const SYSTEM_SETTINGS = {
export function SystemConfig() {
const { t } = useTranslation('admin');
const { t: tc } = useTranslation('common');
const [tab, setTab] = useState<CodeTab>('areas');
const [query, setQuery] = useState('');
const [majorFilter, setMajorFilter] = useState('');
@ -218,7 +219,7 @@ export function SystemConfig() {
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<input
aria-label="코드 검색"
aria-label={tc('aria.searchCode')}
value={query}
onChange={(e) => { setQuery(e.target.value); setPage(0); }}
placeholder={
@ -233,7 +234,7 @@ export function SystemConfig() {
<div className="flex items-center gap-1.5">
<Filter className="w-3.5 h-3.5 text-hint" />
<select
aria-label="대분류 필터"
aria-label={tc('aria.categoryFilter')}
value={majorFilter}
onChange={(e) => { setMajorFilter(e.target.value); setPage(0); }}
className="bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-label focus:outline-none focus:border-cyan-500/50"

파일 보기

@ -1,6 +1,8 @@
import { useEffect, useState } from 'react';
import { X, Check, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { fetchRoles, assignUserRoles, type RoleWithPermissions, type AdminUser } from '@/services/adminApi';
import { getRoleBadgeStyle } from '@shared/constants/userRoles';
@ -11,6 +13,7 @@ interface Props {
}
export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
const { t: tc } = useTranslation('common');
const [roles, setRoles] = useState<RoleWithPermissions[]>([]);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [loading, setLoading] = useState(true);
@ -44,7 +47,7 @@ export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
onSaved();
onClose();
} catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} finally {
setSaving(false);
}
@ -60,7 +63,7 @@ export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
{user.userAcnt} ({user.userNm}) - (OR )
</div>
</div>
<button type="button" aria-label="닫기" onClick={onClose} className="text-hint hover:text-heading">
<button type="button" aria-label={tc('aria.closeDialog')} onClick={onClose} className="text-hint hover:text-heading">
<X className="w-4 h-4" />
</button>
</div>
@ -99,15 +102,18 @@ export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
</div>
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-border">
<button type="button" onClick={onClose}
className="px-4 py-1.5 bg-surface-overlay text-muted-foreground text-xs rounded hover:text-heading">
<Button variant="secondary" size="sm" onClick={onClose}>
</button>
<button type="button" onClick={handleSave} disabled={saving}
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/40 text-white text-xs rounded flex items-center gap-1">
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
</Button>
<Button
variant="primary"
size="sm"
onClick={handleSave}
disabled={saving}
icon={saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
>
</button>
</Button>
</div>
</div>
</div>

파일 보기

@ -1,5 +1,7 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@shared/components/ui/button';
import { Input } from '@shared/components/ui/input';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
@ -44,6 +46,7 @@ const INITIAL_MESSAGES: Message[] = [
export function AIAssistant() {
const { t } = useTranslation('ai');
const { t: tc } = useTranslation('common');
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
const [input, setInput] = useState('');
const [selectedConv, setSelectedConv] = useState('1');
@ -79,7 +82,7 @@ export function AIAssistant() {
<PageContainer className="h-full flex flex-col">
<PageHeader
icon={MessageSquare}
iconColor="text-green-400"
iconColor="text-green-600 dark:text-green-400"
title={t('assistant.title')}
description={t('assistant.desc')}
/>
@ -91,7 +94,7 @@ export function AIAssistant() {
<div className="space-y-1">
{SAMPLE_CONVERSATIONS.map(c => (
<div key={c.id} onClick={() => setSelectedConv(c.id)}
className={`px-2 py-1.5 rounded-lg cursor-pointer text-[10px] ${selectedConv === c.id ? 'bg-green-600/15 text-green-400 border border-green-500/20' : 'text-muted-foreground hover:bg-surface-overlay'}`}>
className={`px-2 py-1.5 rounded-lg cursor-pointer text-[10px] ${selectedConv === c.id ? 'bg-green-600/15 text-green-600 dark:text-green-400 border border-green-500/20' : 'text-muted-foreground hover:bg-surface-overlay'}`}>
<div className="truncate">{c.title}</div>
<div className="text-[8px] text-hint mt-0.5">{c.time}</div>
</div>
@ -111,7 +114,7 @@ export function AIAssistant() {
<div key={i} className={`flex gap-2 ${msg.role === 'user' ? 'justify-end' : ''}`}>
{msg.role === 'assistant' && (
<div className="w-7 h-7 rounded-full bg-green-600/20 border border-green-500/30 flex items-center justify-center shrink-0">
<Bot className="w-4 h-4 text-green-400" />
<Bot className="w-4 h-4 text-green-600 dark:text-green-400" />
</div>
)}
<div className={`max-w-[70%] rounded-xl px-4 py-3 ${
@ -123,7 +126,7 @@ export function AIAssistant() {
{msg.refs && msg.refs.length > 0 && (
<div className="mt-2 pt-2 border-t border-border flex flex-wrap gap-1">
{msg.refs.map(r => (
<Badge key={r} className="bg-green-500/10 text-green-400 border-0 text-[8px] flex items-center gap-0.5">
<Badge key={r} className="bg-green-500/10 text-green-600 dark:text-green-400 border-0 text-[8px] flex items-center gap-0.5">
<FileText className="w-2.5 h-2.5" />{r}
</Badge>
))}
@ -132,7 +135,7 @@ export function AIAssistant() {
</div>
{msg.role === 'user' && (
<div className="w-7 h-7 rounded-full bg-blue-600/20 border border-blue-500/30 flex items-center justify-center shrink-0">
<User className="w-4 h-4 text-blue-400" />
<User className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
)}
</div>
@ -140,17 +143,22 @@ export function AIAssistant() {
</div>
{/* 입력창 */}
<div className="flex gap-2 shrink-0">
<input
<Input
aria-label="AI 어시스턴트 질의"
size="md"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSend()}
placeholder="질의를 입력하세요... (법령, 단속 절차, AI 분석 결과 등)"
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded-xl px-4 py-2.5 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-green-500/50"
className="flex-1"
/>
<Button
variant="primary"
size="md"
onClick={handleSend}
aria-label={tc('aria.send')}
icon={<Send className="w-4 h-4" />}
/>
<button type="button" aria-label="전송" onClick={handleSend} className="px-4 py-2.5 bg-green-600 hover:bg-green-500 text-on-vivid rounded-xl transition-colors">
<Send className="w-4 h-4" />
</button>
</div>
</div>
</div>

파일 보기

@ -2,6 +2,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import {
@ -57,7 +58,7 @@ const MODELS: ModelVersion[] = [
];
const modelColumns: DataColumn<ModelVersion>[] = [
{ key: 'version', label: '버전', width: '80px', sortable: true, render: (v) => <span className="text-cyan-400 font-bold">{v as string}</span> },
{ key: 'version', label: '버전', width: '80px', sortable: true, render: (v) => <span className="text-cyan-600 dark:text-cyan-400 font-bold">{v as string}</span> },
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
render: (v) => {
const s = v as string;
@ -68,7 +69,7 @@ const modelColumns: DataColumn<ModelVersion>[] = [
{ key: 'recall', label: 'Recall', width: '70px', align: 'right', sortable: true, render: (v) => <span className="text-label">{v as number}%</span> },
{ key: 'f1', label: 'F1', width: '70px', align: 'right', sortable: true, render: (v) => <span className="text-label">{v as number}%</span> },
{ key: 'falseAlarm', label: '오탐률', width: '70px', align: 'right', sortable: true,
render: (v) => { const n = v as number; return <span className={n < 10 ? 'text-green-400' : n < 15 ? 'text-yellow-400' : 'text-red-400'}>{n}%</span>; },
render: (v) => { const n = v as number; return <span className={n < 10 ? 'text-green-600 dark:text-green-400' : n < 15 ? 'text-yellow-600 dark:text-yellow-400' : 'text-red-600 dark:text-red-400'}>{n}%</span>; },
},
{ key: 'trainData', label: '학습데이터', width: '100px', align: 'right' },
{ key: 'deployDate', label: '배포일', width: '100px', sortable: true, render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
@ -175,7 +176,7 @@ const GEAR_CODES: GearCode[] = [
];
const gearColumns: DataColumn<GearCode>[] = [
{ key: 'code', label: '코드', width: '60px', render: (v) => <span className="text-cyan-400 font-mono font-bold">{v as string}</span> },
{ key: 'code', label: '코드', width: '60px', render: (v) => <span className="text-cyan-600 dark:text-cyan-400 font-mono font-bold">{v as string}</span> },
{ key: 'name', label: '어구명 (유형)', sortable: true, render: (v) => <span className="text-heading font-medium">{v as string}</span> },
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
render: (v) => {
@ -396,14 +397,14 @@ export function AIModelManagement() {
<PageContainer>
<PageHeader
icon={Brain}
iconColor="text-purple-400"
iconColor="text-purple-600 dark:text-purple-400"
title={t('modelManagement.title')}
description={t('modelManagement.desc')}
demo
actions={
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-green-500/10 border border-green-500/20 rounded-lg">
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
<span className="text-[10px] text-green-400 font-bold"> : {currentModel.version}</span>
<span className="text-[10px] text-green-600 dark:text-green-400 font-bold"> : {currentModel.version}</span>
<span className="text-[10px] text-hint">Accuracy {currentModel.accuracy}%</span>
</div>
}
@ -412,12 +413,12 @@ export function AIModelManagement() {
{/* KPI */}
<div className="flex gap-2">
{[
{ label: '탐지 정확도', value: `${currentModel.accuracy}%`, icon: Target, color: 'text-green-400', bg: 'bg-green-500/10' },
{ label: '오탐률', value: `${currentModel.falseAlarm}%`, icon: AlertTriangle, color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
{ label: '평균 리드타임', value: '12min', icon: Clock, color: 'text-cyan-400', bg: 'bg-cyan-500/10' },
{ label: '학습 데이터', value: currentModel.trainData, icon: Database, color: 'text-blue-400', bg: 'bg-blue-500/10' },
{ label: '모델 버전', value: MODELS.length + '개', icon: GitBranch, color: 'text-purple-400', bg: 'bg-purple-500/10' },
{ label: '탐지 규칙', value: rules.filter((r) => r.enabled).length + '/' + rules.length, icon: Shield, color: 'text-orange-400', bg: 'bg-orange-500/10' },
{ label: '탐지 정확도', value: `${currentModel.accuracy}%`, icon: Target, color: 'text-green-600 dark:text-green-400', bg: 'bg-green-500/10' },
{ label: '오탐률', value: `${currentModel.falseAlarm}%`, icon: AlertTriangle, color: 'text-yellow-600 dark:text-yellow-400', bg: 'bg-yellow-500/10' },
{ label: '평균 리드타임', value: '12min', icon: Clock, color: 'text-cyan-600 dark:text-cyan-400', bg: 'bg-cyan-500/10' },
{ label: '학습 데이터', value: currentModel.trainData, icon: Database, color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-500/10' },
{ label: '모델 버전', value: MODELS.length + '개', icon: GitBranch, color: 'text-purple-600 dark:text-purple-400', bg: 'bg-purple-500/10' },
{ label: '탐지 규칙', value: rules.filter((r) => r.enabled).length + '/' + rules.length, icon: Shield, color: 'text-orange-600 dark:text-orange-400', bg: 'bg-orange-500/10' },
].map((kpi) => (
<div key={kpi.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<div className={`p-1.5 rounded-lg ${kpi.bg}`}>
@ -454,13 +455,13 @@ export function AIModelManagement() {
{/* 업데이트 알림 */}
<div className="bg-blue-950/20 border border-blue-900/30 rounded-xl p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Zap className="w-5 h-5 text-blue-400 shrink-0" />
<Zap className="w-5 h-5 text-blue-600 dark:text-blue-400 shrink-0" />
<div>
<div className="text-sm text-blue-300 font-bold"> v2.4.0 </div>
<div className="text-[10px] text-muted-foreground"> 93.2% (+3.1%) · 7.8% (-2.1%) · </div>
</div>
</div>
<button type="button" className="bg-blue-600 hover:bg-blue-500 text-on-vivid text-[11px] font-bold px-4 py-2 rounded-lg transition-colors shrink-0"> </button>
<Button variant="primary" size="sm" className="shrink-0"> </Button>
</div>
<DataTable data={MODELS} columns={modelColumns} pageSize={10} searchPlaceholder="버전, 비고 검색..." searchKeys={['version', 'note']} exportFilename="AI모델_버전이력" />
</div>
@ -495,7 +496,7 @@ export function AIModelManagement() {
</div>
<div className="text-right">
<div className="text-[9px] text-hint"></div>
<div className="text-[12px] font-bold text-cyan-400">{rule.weight}%</div>
<div className="text-[12px] font-bold text-cyan-600 dark:text-cyan-400">{rule.weight}%</div>
</div>
</div>
</CardContent>
@ -505,7 +506,7 @@ export function AIModelManagement() {
{/* 가중치 합계 */}
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-4 flex items-center gap-1.5"><Zap className="w-4 h-4 text-yellow-400" /> </div>
<div className="text-[12px] font-bold text-label mb-4 flex items-center gap-1.5"><Zap className="w-4 h-4 text-yellow-600 dark:text-yellow-400" /> </div>
<div className="space-y-4">
{rules.filter((r) => r.enabled).map((r, i) => (
<div key={i}>
@ -564,7 +565,7 @@ export function AIModelManagement() {
{/* 파이프라인 스테이지 */}
<div className="flex gap-2">
{PIPELINE_STAGES.map((stage, i) => {
const stColor = stage.status === '정상' ? 'text-green-400' : stage.status === '진행중' ? 'text-blue-400' : 'text-hint';
const stColor = stage.status === '정상' ? 'text-green-600 dark:text-green-400' : stage.status === '진행중' ? 'text-blue-600 dark:text-blue-400' : 'text-hint';
return (
<div key={stage.stage} className="flex-1 flex items-start gap-2">
<Card className="flex-1 bg-surface-raised border-border">
@ -695,7 +696,7 @@ export function AIModelManagement() {
<div key={kpi.label}>
<div className="flex justify-between text-[10px] mb-1">
<span className="text-muted-foreground">{kpi.label}</span>
<span className={achieved ? 'text-green-400 font-bold' : 'text-red-400 font-bold'}>
<span className={achieved ? 'text-green-600 dark:text-green-400 font-bold' : 'text-red-600 dark:text-red-400 font-bold'}>
{kpi.current}{kpi.unit} {achieved ? '(달성)' : `(목표 ${kpi.target}${kpi.unit})`}
</span>
</div>
@ -761,7 +762,7 @@ export function AIModelManagement() {
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Anchor className="w-4 h-4 text-cyan-400" />
<Anchor className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
5
</CardTitle>
</CardHeader>
@ -781,7 +782,7 @@ export function AIModelManagement() {
{DAR03_GEAR_SUMMARY.map((g) => (
<tr key={g.no} className="border-b border-border/50 hover:bg-surface-overlay transition-colors">
<td className="py-2 px-2">
<span className="text-cyan-400 font-mono mr-2">{g.no}</span>
<span className="text-cyan-600 dark:text-cyan-400 font-mono mr-2">{g.no}</span>
<span className="text-heading font-medium">{g.name}</span>
</td>
<td className="py-2 text-center text-label font-mono">{g.faoCode}</td>
@ -790,7 +791,7 @@ export function AIModelManagement() {
<Badge intent={DAR03_IUU_INTENT[g.iuuRisk]} size="xs">{g.iuuRisk}</Badge>
</td>
<td className="py-2 text-center text-muted-foreground">{g.aisType}</td>
<td className="py-2 px-2 text-cyan-400 font-mono text-[9px]">{g.gCodes}</td>
<td className="py-2 px-2 text-cyan-600 dark:text-cyan-400 font-mono text-[9px]">{g.gCodes}</td>
</tr>
))}
</tbody>
@ -802,7 +803,7 @@ export function AIModelManagement() {
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Eye className="w-4 h-4 text-blue-400" />
<Eye className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</CardTitle>
<p className="text-[9px] text-hint italic">
@ -815,7 +816,7 @@ export function AIModelManagement() {
<Card key={g.no} className="bg-surface-raised border-border">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<span className="text-cyan-400 font-mono font-bold text-sm">{g.no}</span>
<span className="text-cyan-600 dark:text-cyan-400 font-mono font-bold text-sm">{g.no}</span>
<div className="flex-1">
<div className="text-[12px] font-bold text-heading">{g.name}</div>
<div className="text-[9px] text-hint">{g.nameEn}</div>
@ -854,7 +855,7 @@ export function AIModelManagement() {
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Radio className="w-4 h-4 text-purple-400" />
<Radio className="w-4 h-4 text-purple-600 dark:text-purple-400" />
AIS
</CardTitle>
</CardHeader>
@ -873,7 +874,7 @@ export function AIModelManagement() {
{DAR03_AIS_SIGNALS.map((s) => (
<tr key={s.no} className="border-b border-border/50 hover:bg-surface-overlay transition-colors align-top">
<td className="py-2.5 px-2">
<span className="text-cyan-400 font-mono mr-1">{s.no}</span>
<span className="text-cyan-600 dark:text-cyan-400 font-mono mr-1">{s.no}</span>
<span className="text-heading font-medium">{s.name}</span>
</td>
<td className="py-2.5 text-label">{s.aisType}</td>
@ -891,13 +892,13 @@ export function AIModelManagement() {
<ul className="space-y-0.5">
{s.threshold.map((th) => (
<li key={th} className="text-muted-foreground flex items-start gap-1">
<AlertTriangle className="w-3 h-3 text-orange-400 shrink-0 mt-0.5" />
<AlertTriangle className="w-3 h-3 text-orange-600 dark:text-orange-400 shrink-0 mt-0.5" />
<span>{th}</span>
</li>
))}
</ul>
</td>
<td className="py-2.5 px-2 text-cyan-400 font-mono text-[9px]">{s.gCodes}</td>
<td className="py-2.5 px-2 text-cyan-600 dark:text-cyan-400 font-mono text-[9px]">{s.gCodes}</td>
</tr>
))}
</tbody>
@ -921,8 +922,8 @@ export function AIModelManagement() {
</div>
<div className="ml-auto flex gap-3 shrink-0 text-center">
<div><div className="text-lg font-bold text-heading">906</div><div className="text-[9px] text-hint"> </div></div>
<div><div className="text-lg font-bold text-cyan-400">7</div><div className="text-[9px] text-hint"> </div></div>
<div><div className="text-lg font-bold text-green-400">5</div><div className="text-[9px] text-hint"> </div></div>
<div><div className="text-lg font-bold text-cyan-600 dark:text-cyan-400">7</div><div className="text-[9px] text-hint"> </div></div>
<div><div className="text-lg font-bold text-green-600 dark:text-green-400">5</div><div className="text-[9px] text-hint"> </div></div>
</div>
</div>
@ -974,7 +975,7 @@ export function AIModelManagement() {
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
<Ship className="w-4 h-4 text-cyan-400" /> (906, 6 )
<Ship className="w-4 h-4 text-cyan-600 dark:text-cyan-400" /> (906, 6 )
</div>
<table className="w-full text-[10px]">
<thead>
@ -991,7 +992,7 @@ export function AIModelManagement() {
<tbody>
{TARGET_VESSELS.map((v) => (
<tr key={v.code} className="border-b border-border">
<td className="py-1.5 text-cyan-400 font-mono font-bold">{v.code}</td>
<td className="py-1.5 text-cyan-600 dark:text-cyan-400 font-mono font-bold">{v.code}</td>
<td className="py-1.5 text-label">{v.name}</td>
<td className="py-1.5 text-heading font-bold text-right">{v.count}</td>
<td className="py-1.5 text-muted-foreground pl-3">{v.zones}</td>
@ -1014,7 +1015,7 @@ export function AIModelManagement() {
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
<AlertTriangle className="w-4 h-4 text-yellow-400" />
<AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400" />
</div>
<div className="space-y-2">
{ALARM_SEVERITY.map((a) => (
@ -1064,8 +1065,8 @@ export function AIModelManagement() {
</div>
<div className="flex gap-4 shrink-0 text-center">
<div><div className="text-lg font-bold text-emerald-400">12</div><div className="text-[9px] text-hint">API </div></div>
<div><div className="text-lg font-bold text-cyan-400">3</div><div className="text-[9px] text-hint"> </div></div>
<div><div className="text-lg font-bold text-blue-400">99.7%</div><div className="text-[9px] text-hint"></div></div>
<div><div className="text-lg font-bold text-cyan-600 dark:text-cyan-400">3</div><div className="text-[9px] text-hint"> </div></div>
<div><div className="text-lg font-bold text-blue-600 dark:text-blue-400">99.7%</div><div className="text-[9px] text-hint"></div></div>
</div>
</div>
@ -1114,7 +1115,7 @@ export function AIModelManagement() {
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
<Code className="w-4 h-4 text-cyan-400" />
<Code className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
RESTful API
</div>
<table className="w-full text-[10px] table-fixed">
@ -1155,7 +1156,7 @@ export function AIModelManagement() {
<td className="py-1.5">
<Badge intent={api.method === 'GET' ? 'success' : 'info'} size="xs">{api.method}</Badge>
</td>
<td className="py-1.5 font-mono text-cyan-400">{api.endpoint}</td>
<td className="py-1.5 font-mono text-cyan-600 dark:text-cyan-400">{api.endpoint}</td>
<td className="py-1.5 text-hint">{api.unit}</td>
<td className="py-1.5 text-label">{api.desc}</td>
<td className="py-1.5 text-muted-foreground">{api.sfr}</td>
@ -1175,7 +1176,7 @@ export function AIModelManagement() {
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
<Code className="w-4 h-4 text-cyan-400" />
<Code className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
API
</div>
<div className="space-y-3">
@ -1183,7 +1184,7 @@ export function AIModelManagement() {
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-muted-foreground"> (파라미터: 좌표 , )</span>
<button type="button" aria-label="예시 URL 복사" onClick={() => navigator.clipboard.writeText('GET /api/v1/predictions/grid?lat_min=36.0&lat_max=38.0&lon_min=124.0&lon_max=126.0&time=2026-04-03T09:00Z')} className="text-hint hover:text-muted-foreground"><Copy className="w-3 h-3" /></button>
<button type="button" aria-label={tcCommon('aria.copyExampleUrl')} onClick={() => navigator.clipboard.writeText('GET /api/v1/predictions/grid?lat_min=36.0&lat_max=38.0&lon_min=124.0&lon_max=126.0&time=2026-04-03T09:00Z')} className="text-hint hover:text-muted-foreground"><Copy className="w-3 h-3" /></button>
</div>
<pre className="bg-background border border-border rounded-lg p-3 text-[9px] font-mono text-muted-foreground overflow-x-auto">
{`GET /api/v1/predictions/grid
@ -1231,7 +1232,7 @@ export function AIModelManagement() {
<Card>
<CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
<ExternalLink className="w-4 h-4 text-purple-400" />
<ExternalLink className="w-4 h-4 text-purple-600 dark:text-purple-400" />
</div>
<div className="space-y-2">
@ -1255,7 +1256,7 @@ export function AIModelManagement() {
<div className="text-[9px] text-hint mb-1.5">{s.desc}</div>
<div className="flex gap-1 flex-wrap">
{s.apis.map((a) => (
<span key={a} className="text-[8px] font-mono px-1.5 py-0.5 rounded bg-switch-background/50 text-cyan-400">{a}</span>
<span key={a} className="text-[8px] font-mono px-1.5 py-0.5 rounded bg-switch-background/50 text-cyan-600 dark:text-cyan-400">{a}</span>
))}
</div>
</div>
@ -1272,13 +1273,13 @@ export function AIModelManagement() {
<div className="flex gap-3">
{[
{ label: '총 호출', value: '142,856', color: 'text-heading' },
{ label: 'grid 조회', value: '68,420', color: 'text-blue-400' },
{ label: 'zone 조회', value: '32,115', color: 'text-green-400' },
{ label: 'time 조회', value: '18,903', color: 'text-yellow-400' },
{ label: 'vessel 조회', value: '15,210', color: 'text-orange-400' },
{ label: 'alarms', value: '8,208', color: 'text-red-400' },
{ label: '평균 응답', value: '23ms', color: 'text-cyan-400' },
{ label: '오류율', value: '0.03%', color: 'text-green-400' },
{ label: 'grid 조회', value: '68,420', color: 'text-blue-600 dark:text-blue-400' },
{ label: 'zone 조회', value: '32,115', color: 'text-green-600 dark:text-green-400' },
{ label: 'time 조회', value: '18,903', color: 'text-yellow-600 dark:text-yellow-400' },
{ label: 'vessel 조회', value: '15,210', color: 'text-orange-600 dark:text-orange-400' },
{ label: 'alarms', value: '8,208', color: 'text-red-600 dark:text-red-400' },
{ label: '평균 응답', value: '23ms', color: 'text-cyan-600 dark:text-cyan-400' },
{ label: '오류율', value: '0.03%', color: 'text-green-600 dark:text-green-400' },
].map((s) => (
<div key={s.label} className="flex-1 text-center px-3 py-2 rounded-lg bg-surface-overlay">
<div className={`text-sm font-bold ${s.color}`}>{s.value}</div>

파일 보기

@ -1,5 +1,6 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@shared/components/ui/button';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
@ -107,6 +108,7 @@ const WORKERS = [
export function MLOpsPage() {
const { t } = useTranslation('ai');
const { t: tc } = useTranslation('common');
const [tab, setTab] = useState<Tab>('dashboard');
const [llmSub, setLlmSub] = useState<LLMSubTab>('train');
const [selectedTmpl, setSelectedTmpl] = useState(0);
@ -116,7 +118,7 @@ export function MLOpsPage() {
<PageContainer>
<PageHeader
icon={Cpu}
iconColor="text-purple-400"
iconColor="text-purple-600 dark:text-purple-400"
title={t('mlops.title')}
description={t('mlops.desc')}
demo
@ -134,7 +136,7 @@ export function MLOpsPage() {
{ key: 'platform' as Tab, icon: Settings, label: '플랫폼 관리' },
]).map(t => (
<button type="button" key={t.key} onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-4 py-2.5 text-[11px] font-medium border-b-2 transition-colors ${tab === t.key ? 'text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}>
className={`flex items-center gap-1.5 px-4 py-2.5 text-[11px] font-medium border-b-2 transition-colors ${tab === t.key ? 'text-blue-600 dark:text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}>
<t.icon className="w-3.5 h-3.5" />{t.label}
</button>
))}
@ -159,7 +161,7 @@ export function MLOpsPage() {
<Badge intent="success" size="sm">DEPLOYED</Badge>
<span className="text-[11px] text-heading font-medium flex-1">{m.name}</span>
<span className="text-[10px] text-hint">{m.ver}</span>
<span className="text-[10px] text-green-400 font-bold">F1 {m.f1}%</span>
<span className="text-[10px] text-green-600 dark:text-green-400 font-bold">F1 {m.f1}%</span>
</div>
))}</div>
</CardContent></Card>
@ -187,7 +189,7 @@ export function MLOpsPage() {
{TEMPLATES.map((t, i) => (
<div key={t.name} onClick={() => setSelectedTmpl(i)}
className={`p-3 rounded-lg text-center cursor-pointer transition-colors ${selectedTmpl === i ? 'bg-blue-600/15 border border-blue-500/30' : 'bg-surface-overlay border border-transparent hover:border-border'}`}>
<t.icon className="w-6 h-6 mx-auto mb-2 text-blue-400" />
<t.icon className="w-6 h-6 mx-auto mb-2 text-blue-600 dark:text-blue-400" />
<div className="text-[10px] font-bold text-heading">{t.name}</div>
<div className="text-[8px] text-hint mt-0.5">{t.desc}</div>
</div>
@ -197,7 +199,7 @@ export function MLOpsPage() {
<Card><CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="text-[12px] font-bold text-heading"> </div>
<button type="button" className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg"><Play className="w-3 h-3" /> </button>
<Button variant="primary" size="sm" icon={<Play className="w-3 h-3" />}> </Button>
</div>
<div className="space-y-2">
{EXPERIMENTS.map(e => (
@ -208,7 +210,7 @@ export function MLOpsPage() {
<div className="w-24 h-1.5 bg-switch-background rounded-full overflow-hidden"><div className={`h-full rounded-full ${e.status === 'done' ? 'bg-green-500' : e.status === 'fail' ? 'bg-red-500' : 'bg-blue-500'}`} style={{ width: `${e.progress}%` }} /></div>
<span className="text-[10px] text-muted-foreground w-12">{e.epoch}</span>
<span className="text-[10px] text-muted-foreground w-16">{e.time}</span>
{e.f1 > 0 && <span className="text-[10px] text-cyan-400 font-bold">F1 {e.f1}</span>}
{e.f1 > 0 && <span className="text-[10px] text-cyan-600 dark:text-cyan-400 font-bold">F1 {e.f1}</span>}
</div>
))}
</div>
@ -261,7 +263,7 @@ export function MLOpsPage() {
<tbody>{DEPLOYS.map((d, i) => (
<tr key={i} className="border-b border-border hover:bg-surface-overlay">
<td className="px-3 py-2 text-heading font-medium">{d.model}</td>
<td className="px-3 py-2 text-cyan-400 font-mono">{d.ver}</td>
<td className="px-3 py-2 text-cyan-600 dark:text-cyan-400 font-mono">{d.ver}</td>
<td className="px-3 py-2 text-muted-foreground font-mono text-[10px]">{d.endpoint}</td>
<td className="px-3 py-2"><div className="flex items-center gap-1.5"><div className="w-16 h-2 bg-switch-background rounded-full overflow-hidden"><div className="h-full bg-blue-500 rounded-full" style={{ width: `${d.traffic}%` }} /></div><span className="text-heading font-bold">{d.traffic}%</span></div></td>
<td className="px-3 py-2 text-label">{d.latency}</td>
@ -288,7 +290,7 @@ export function MLOpsPage() {
{MODELS.filter(m => m.status === 'APPROVED').map(m => (
<div key={m.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
<span className="text-[11px] text-heading font-medium flex-1">{m.name} {m.ver}</span>
<button type="button" className="flex items-center gap-1 px-2.5 py-1 bg-green-600 hover:bg-green-500 text-on-vivid text-[9px] font-bold rounded"><Rocket className="w-3 h-3" /></button>
<Button variant="primary" size="sm" icon={<Rocket className="w-3 h-3" />}></Button>
</div>
))}
</div>
@ -313,15 +315,15 @@ export function MLOpsPage() {
"version": "v2.1.0"
}`} />
<div className="flex gap-2 mt-2">
<button type="button" className="flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg"><Zap className="w-3 h-3" /></button>
<Button variant="primary" size="sm" icon={<Zap className="w-3 h-3" />}></Button>
<button type="button" className="px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground"></button>
</div>
</CardContent></Card>
<Card className="bg-surface-raised border-border flex flex-col"><CardContent className="p-4 flex-1 flex flex-col">
<div className="text-[9px] font-bold text-hint mb-2">RESPONSE</div>
<div className="flex gap-4 text-[10px] px-2 py-1.5 bg-green-500/10 rounded mb-2">
<span className="text-muted-foreground"> <span className="text-green-400 font-bold">200 OK</span></span>
<span className="text-muted-foreground"> <span className="text-green-400 font-bold">23ms</span></span>
<span className="text-muted-foreground"> <span className="text-green-600 dark:text-green-400 font-bold">200 OK</span></span>
<span className="text-muted-foreground"> <span className="text-green-600 dark:text-green-400 font-bold">23ms</span></span>
</div>
<pre className="flex-1 bg-background border border-border rounded-lg p-3 text-[10px] text-green-300 font-mono overflow-auto">{`{
"risk_score": 87.5,
@ -353,7 +355,7 @@ export function MLOpsPage() {
{ key: 'llmtest' as LLMSubTab, label: 'LLM 테스트' },
]).map(t => (
<button type="button" key={t.key} onClick={() => setLlmSub(t.key)}
className={`px-4 py-2 text-[11px] font-medium border-b-2 ${llmSub === t.key ? 'text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}>{t.label}</button>
className={`px-4 py-2 text-[11px] font-medium border-b-2 ${llmSub === t.key ? 'text-blue-600 dark:text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}>{t.label}</button>
))}
</div>
@ -367,7 +369,7 @@ export function MLOpsPage() {
{LLM_MODELS.map((m, i) => (
<div key={m.name} onClick={() => setSelectedLLM(i)}
className={`p-3 rounded-lg text-center cursor-pointer ${selectedLLM === i ? 'bg-blue-600/15 border border-blue-500/30' : 'bg-surface-overlay border border-transparent'}`}>
<m.icon className="w-5 h-5 mx-auto mb-1 text-purple-400" />
<m.icon className="w-5 h-5 mx-auto mb-1 text-purple-600 dark:text-purple-400" />
<div className="text-[10px] font-bold text-heading">{m.name}</div>
<div className="text-[8px] text-hint">{m.sub}</div>
</div>
@ -381,7 +383,7 @@ export function MLOpsPage() {
<div key={k} className="flex flex-col gap-1"><span className="text-[9px] text-hint">{k}</span><div className="bg-background border border-border rounded px-2.5 py-1.5 text-label">{v}</div></div>
))}
</div>
<button type="button" className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg w-full justify-center"><Play className="w-3 h-3" /> </button>
<Button variant="primary" size="sm" className="mt-3 w-full" icon={<Play className="w-3 h-3" />}> </Button>
</CardContent></Card>
</div>
<Card><CardContent className="p-4">
@ -417,10 +419,10 @@ export function MLOpsPage() {
<div key={k} className="flex justify-between px-2 py-1 bg-surface-overlay rounded"><span className="text-hint font-mono">{k}</span><span className="text-label">{v}</span></div>
))}
</div>
<button type="button" className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg w-full justify-center"><Search className="w-3 h-3" /> </button>
<Button variant="primary" size="sm" className="mt-3 w-full" icon={<Search className="w-3 h-3" />}> </Button>
</CardContent></Card>
<Card className="col-span-2 bg-surface-raised border-border"><CardContent className="p-4">
<div className="flex justify-between mb-3"><div className="text-[12px] font-bold text-heading">HPS </div><span className="text-[10px] text-green-400 font-bold">Best: Trial #3 (F1=0.912)</span></div>
<div className="flex justify-between mb-3"><div className="text-[12px] font-bold text-heading">HPS </div><span className="text-[10px] text-green-600 dark:text-green-400 font-bold">Best: Trial #3 (F1=0.912)</span></div>
<table className="w-full text-[10px]">
<thead><tr className="border-b border-border text-hint">{['Trial', 'LR', 'Batch', 'Dropout', 'Hidden', 'F1 Score', ''].map(h => <th key={h} className="py-2 px-2 text-left font-medium">{h}</th>)}</tr></thead>
<tbody>{HPS_TRIALS.map(t => (
@ -505,7 +507,7 @@ export function MLOpsPage() {
</div>
<div className="flex gap-2 shrink-0">
<input aria-label="LLM 질의 입력" className="flex-1 bg-background border border-border rounded-xl px-4 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/40" placeholder="질의를 입력하세요..." />
<button type="button" aria-label="전송" className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-on-vivid rounded-xl"><Send className="w-4 h-4" /></button>
<Button variant="primary" size="md" aria-label={tc('aria.send')} icon={<Send className="w-4 h-4" />} />
</div>
</CardContent></Card>
</div>

파일 보기

@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Shield, Eye, EyeOff, Lock, User, Fingerprint, KeyRound, AlertCircle } from 'lucide-react';
import { Button } from '@shared/components/ui/button';
import { useAuth } from '@/app/auth/AuthContext';
import { LoginError } from '@/services/authApi';
import { DemoQuickLogin, type DemoAccount } from './DemoQuickLogin';
@ -105,7 +106,7 @@ export function LoginPage() {
{/* 로고 영역 */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-blue-600/20 border border-blue-500/30 mb-4">
<Shield className="w-8 h-8 text-blue-400" />
<Shield className="w-8 h-8 text-blue-600 dark:text-blue-400" />
</div>
<h1 className="text-xl font-bold text-heading">{t('title')}</h1>
<p className="text-[11px] text-hint mt-1">{t('subtitle')}</p>
@ -122,7 +123,7 @@ export function LoginPage() {
disabled={m.disabled}
className={`flex-1 flex flex-col items-center gap-1 py-3 transition-colors ${
authMethod === m.key
? 'bg-blue-600/10 border-b-2 border-blue-500 text-blue-400'
? 'bg-blue-600/10 border-b-2 border-blue-500 text-blue-600 dark:text-blue-400'
: 'text-hint hover:bg-surface-overlay hover:text-label'
} ${m.disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
title={m.disabled ? '향후 도입 예정' : ''}
@ -188,16 +189,18 @@ export function LoginPage() {
</div>
{error && (
<div className="flex items-center gap-2 text-red-400 text-[11px] bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
<div className="flex items-center gap-2 text-red-600 dark:text-red-400 text-[11px] bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
{error}
</div>
)}
<button
<Button
type="submit"
variant="primary"
size="md"
disabled={loading}
className="w-full py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/50 text-on-vivid text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2 whitespace-nowrap"
className="w-full font-bold"
>
{loading ? (
<>
@ -205,7 +208,7 @@ export function LoginPage() {
{t('button.authenticating')}
</>
) : t('button.login')}
</button>
</Button>
{/* 데모 퀵로그인 (VITE_SHOW_DEMO_LOGIN=true일 때만 렌더링) */}
<DemoQuickLogin onSelect={handleDemoSelect} disabled={loading} />
@ -215,7 +218,7 @@ export function LoginPage() {
{/* GPKI 인증 (Phase 9 도입 예정) */}
{authMethod === 'gpki' && (
<div className="space-y-4 text-center py-12">
<Fingerprint className="w-12 h-12 text-blue-400 mx-auto mb-3" />
<Fingerprint className="w-12 h-12 text-blue-600 dark:text-blue-400 mx-auto mb-3" />
<p className="text-sm text-heading font-medium">{t('gpki.title')}</p>
<p className="text-[10px] text-hint mt-1"> (Phase 9)</p>
</div>
@ -224,7 +227,7 @@ export function LoginPage() {
{/* SSO 연동 (Phase 9 도입 예정) */}
{authMethod === 'sso' && (
<div className="space-y-4 text-center py-12">
<KeyRound className="w-12 h-12 text-green-400 mx-auto mb-3" />
<KeyRound className="w-12 h-12 text-green-600 dark:text-green-400 mx-auto mb-3" />
<p className="text-sm text-heading font-medium">{t('sso.title')}</p>
<p className="text-[10px] text-hint mt-1"> (Phase 9)</p>
</div>

파일 보기

@ -55,7 +55,7 @@ function RiskBar({ value, size = 'default' }: { value: number; size?: 'default'
// backend riskScore.score는 0~100 정수. 0~1 범위로 들어오는 경우도 호환.
const pct = Math.max(0, Math.min(100, value <= 1 ? value * 100 : value));
const color = pct > 70 ? 'bg-red-500' : pct > 50 ? 'bg-orange-500' : pct > 30 ? 'bg-yellow-500' : 'bg-blue-500';
const textColor = pct > 70 ? 'text-red-400' : pct > 50 ? 'text-orange-400' : pct > 30 ? 'text-yellow-400' : 'text-blue-400';
const textColor = pct > 70 ? 'text-red-600 dark:text-red-400' : pct > 50 ? 'text-orange-600 dark:text-orange-400' : pct > 30 ? 'text-yellow-600 dark:text-yellow-400' : 'text-blue-600 dark:text-blue-400';
const barW = size === 'sm' ? 'w-16' : 'w-24';
return (
<div className="flex items-center gap-2">
@ -78,7 +78,7 @@ function KpiCard({ label, value, prev, icon: Icon, color, desc }: KpiCardProps)
<div className="p-2 rounded-lg" style={{ background: `${color}15` }}>
<Icon className="w-4 h-4" style={{ color }} />
</div>
<div className={`flex items-center gap-0.5 text-[10px] font-medium ${isUp ? 'text-red-400' : 'text-green-400'}`}>
<div className={`flex items-center gap-0.5 text-[10px] font-medium ${isUp ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}`}>
{isUp ? <ArrowUpRight className="w-3 h-3" /> : <ArrowDownRight className="w-3 h-3" />}
{Math.abs(diff)}
</div>
@ -207,16 +207,16 @@ function SeaAreaMap() {
<div className="absolute bottom-2 left-2 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-2 py-1.5">
<div className="text-[8px] text-muted-foreground font-bold mb-1"> </div>
<div className="flex items-center gap-1">
<span className="text-[7px] text-blue-400"></span>
<span className="text-[7px] text-blue-600 dark:text-blue-400"></span>
<div className="w-16 h-1.5 rounded-full" style={{ background: 'linear-gradient(to right, #1e40af, #3b82f6, #eab308, #f97316, #ef4444)' }} />
<span className="text-[7px] text-red-400"></span>
<span className="text-[7px] text-red-600 dark:text-red-400"></span>
</div>
</div>
{/* LIVE 인디케이터 */}
<div className="absolute top-2 left-2 z-[1000] flex items-center gap-1.5 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-2 py-1">
<div className="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse" />
<Radar className="w-3 h-3 text-blue-500" />
<span className="text-[9px] text-blue-400 font-medium"> </span>
<span className="text-[9px] text-blue-600 dark:text-blue-400 font-medium"> </span>
</div>
</div>
);
@ -468,8 +468,8 @@ export function Dashboard() {
<span className="text-[10px] font-bold tabular-nums w-7 text-right" style={{
color: area.risk > 85 ? '#ef4444' : area.risk > 60 ? '#f97316' : area.risk > 40 ? '#eab308' : '#3b82f6'
}}>{area.risk}</span>
{area.trend === 'up' && <ArrowUpRight className="w-3 h-3 text-red-400" />}
{area.trend === 'down' && <ArrowDownRight className="w-3 h-3 text-green-400" />}
{area.trend === 'up' && <ArrowUpRight className="w-3 h-3 text-red-600 dark:text-red-400" />}
{area.trend === 'down' && <ArrowDownRight className="w-3 h-3 text-green-600 dark:text-green-400" />}
{area.trend === 'stable' && <span className="w-3 h-3 flex items-center justify-center text-hint text-[8px]"></span>}
</div>
))}
@ -544,7 +544,7 @@ export function Dashboard() {
<Card>
<CardHeader className="px-4 pt-3 pb-0">
<CardTitle className="text-xs text-label flex items-center gap-1.5">
<Waves className="w-3.5 h-3.5 text-blue-400" />
<Waves className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
</CardTitle>
</CardHeader>
@ -557,19 +557,19 @@ export function Dashboard() {
<div className="text-[8px] text-hint"> {WEATHER_DATA.wind.gust}m/s</div>
</div>
<div className="text-center p-2 rounded-lg bg-surface-overlay">
<Waves className="w-3.5 h-3.5 text-blue-400 mx-auto mb-1" />
<Waves className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400 mx-auto mb-1" />
<div className="text-[10px] text-heading font-bold">{WEATHER_DATA.wave.height}m</div>
<div className="text-[8px] text-hint"></div>
<div className="text-[8px] text-hint"> {WEATHER_DATA.wave.period}s</div>
</div>
<div className="text-center p-2 rounded-lg bg-surface-overlay">
<Thermometer className="w-3.5 h-3.5 text-orange-400 mx-auto mb-1" />
<Thermometer className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400 mx-auto mb-1" />
<div className="text-[10px] text-heading font-bold">{WEATHER_DATA.temp.air}°C</div>
<div className="text-[8px] text-hint"></div>
<div className="text-[8px] text-hint"> {WEATHER_DATA.temp.water}°C</div>
</div>
<div className="text-center p-2 rounded-lg bg-surface-overlay">
<Eye className="w-3.5 h-3.5 text-green-400 mx-auto mb-1" />
<Eye className="w-3.5 h-3.5 text-green-600 dark:text-green-400 mx-auto mb-1" />
<div className="text-[10px] text-heading font-bold">{WEATHER_DATA.visibility}km</div>
<div className="text-[8px] text-hint"></div>
<div className="text-[8px] text-hint">{WEATHER_DATA.seaState}</div>

파일 보기

@ -1,6 +1,10 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Input } from '@shared/components/ui/input';
import { Select } from '@shared/components/ui/select';
import { TabBar, TabButton } from '@shared/components/ui/tabs';
import { PageContainer } from '@shared/components/layout';
import {
Search, Clock, ChevronRight, ChevronLeft, Cloud,
@ -339,22 +343,19 @@ export function ChinaFishing() {
return (
<PageContainer size="sm">
{/* ── 모드 탭 (AI 대시보드 / 환적 탐지) ── */}
<div className="flex items-center gap-1 bg-surface-raised rounded-lg p-1 border border-slate-700/30 w-fit">
<TabBar variant="segmented">
{modeTabs.map((tab) => (
<button type="button"
<TabButton
key={tab.key}
variant="segmented"
active={mode === tab.key}
onClick={() => setMode(tab.key)}
className={`flex items-center gap-1.5 px-4 py-2 rounded-md text-[11px] font-medium transition-colors ${
mode === tab.key
? 'bg-blue-600 text-on-vivid'
: 'text-muted-foreground hover:text-foreground hover:bg-surface-overlay'
}`}
icon={<tab.icon className="w-3.5 h-3.5" />}
>
<tab.icon className="w-3.5 h-3.5" />
{tab.label}
</button>
</TabButton>
))}
</div>
</TabBar>
{/* 환적 탐지 모드 */}
{mode === 'transfer' && <TransferView />}
@ -372,7 +373,7 @@ export function ChinaFishing() {
</div>
)}
{apiError && <div className="text-xs text-red-400">: {apiError}</div>}
{apiError && <div className="text-xs text-red-600 dark:text-red-400">{tcCommon('error.errorPrefix', { msg: apiError })}</div>}
{apiLoading && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
@ -380,7 +381,7 @@ export function ChinaFishing() {
</div>
)}
{/* iran 백엔드 실시간 분석 결과 */}
{/* 중국 선박 실시간 분석 결과 */}
<RealAllVessels />
{/* ── 상단 바: 기준일 + 검색 ── */}
@ -389,16 +390,21 @@ export function ChinaFishing() {
<Clock className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-[11px] text-label"> : {formatDateTime(new Date())}</span>
</div>
<button type="button" onClick={loadApi} className="p-1.5 rounded-lg bg-surface-overlay border border-slate-700/40 text-muted-foreground hover:text-heading transition-colors" title="새로고침">
<RotateCcw className="w-3.5 h-3.5" />
</button>
<Button
variant="secondary"
size="sm"
onClick={loadApi}
aria-label={tcCommon('aria.refresh')}
icon={<RotateCcw className="w-3.5 h-3.5" />}
/>
<div className="flex-1 flex items-center bg-surface-overlay border border-slate-700/40 rounded-lg px-3 py-1.5">
<Search className="w-3.5 h-3.5 text-hint mr-2" />
<input aria-label="해역 또는 해구 번호 검색"
<input
aria-label={tcCommon('aria.searchAreaOrZone')}
placeholder="해역 또는 해구 번호 검색"
className="bg-transparent text-[11px] text-label placeholder:text-hint flex-1 focus:outline-none"
/>
<Search className="w-4 h-4 text-blue-500 cursor-pointer" />
<Search className="w-4 h-4 text-blue-600 dark:text-blue-500 cursor-pointer" />
</div>
</div>
@ -456,13 +462,13 @@ export function ChinaFishing() {
<div className="flex items-center justify-around mt-4">
<div>
<div className="text-[10px] text-muted-foreground mb-2 text-center">
<span className="text-orange-400 font-medium"></span>
<span className="text-orange-600 dark:text-orange-400 font-medium"></span>
</div>
<SemiGauge value={safetyIndex.risk} label="" color="#f97316" />
</div>
<div>
<div className="text-[10px] text-muted-foreground mb-2 text-center">
<span className="text-blue-400 font-medium"></span>
<span className="text-blue-600 dark:text-blue-400 font-medium"></span>
</div>
<SemiGauge value={safetyIndex.safety} label="" color="#3b82f6" />
</div>
@ -480,29 +486,32 @@ export function ChinaFishing() {
<span className="text-sm font-bold text-heading"> </span>
<Badge intent="warning" size="xs" className="font-normal"> </Badge>
</div>
<select aria-label="관심영역 선택" className="bg-secondary border border-slate-700/50 rounded px-2 py-0.5 text-[10px] text-label focus:outline-none">
<Select
size="sm"
aria-label={tcCommon('aria.areaOfInterestSelect')}
>
<option> A</option>
<option> B</option>
</select>
</Select>
</div>
<p className="text-[9px] text-hint mb-3"> .</p>
<div className="flex items-center gap-4">
<div className="space-y-2 flex-1">
<div className="flex items-center gap-2 text-[11px]">
<Eye className="w-3.5 h-3.5 text-blue-400" />
<Eye className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
<span className="text-muted-foreground"></span>
<span className="text-green-400 font-bold ml-auto"></span>
<span className="text-green-600 dark:text-green-400 font-bold ml-auto"></span>
</div>
<div className="flex items-center gap-2 text-[11px]">
<AlertTriangle className="w-3.5 h-3.5 text-red-400" />
<AlertTriangle className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
<span className="text-muted-foreground"></span>
<span className="text-green-400 font-bold ml-auto"></span>
<span className="text-green-600 dark:text-green-400 font-bold ml-auto"></span>
</div>
<div className="flex items-center gap-2 text-[11px]">
<Radio className="w-3.5 h-3.5 text-purple-400" />
<Radio className="w-3.5 h-3.5 text-purple-600 dark:text-purple-400" />
<span className="text-muted-foreground"></span>
<span className="text-green-400 font-bold ml-auto"></span>
<span className="text-green-600 dark:text-green-400 font-bold ml-auto"></span>
</div>
</div>
<CircleGauge
@ -527,30 +536,26 @@ export function ChinaFishing() {
<Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-0">
{/* 탭 헤더 — 특이운항만 활성, 나머지 3개는 데이터 소스 미연동 */}
<div className="flex border-b border-slate-700/30">
<TabBar variant="underline" className="border-slate-700/30">
{vesselTabs.map((tab) => {
const disabled = tab !== '특이운항';
return (
<button type="button"
<TabButton
key={tab}
variant="underline"
active={vesselTab === tab}
onClick={() => !disabled && setVesselTab(tab)}
disabled={disabled}
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors flex items-center justify-center gap-1 ${
vesselTab === tab
? 'text-heading border-b-2 border-blue-500 bg-surface-overlay'
: disabled
? 'text-hint opacity-50 cursor-not-allowed'
: 'text-hint hover:text-label'
}`}
className="flex-1 justify-center"
>
{tab}
{disabled && (
<Badge intent="warning" size="xs" className="font-normal"></Badge>
)}
</button>
</TabButton>
);
})}
</div>
</TabBar>
{/* 선박 목록 */}
<div className="max-h-[420px] overflow-y-auto">
@ -599,22 +604,20 @@ export function ChinaFishing() {
<Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-0">
{/* 탭 — 월별 집계 API 미연동 */}
<div className="flex border-b border-slate-700/30">
<TabBar variant="underline" className="border-slate-700/30">
{statsTabs.map((tab) => (
<button type="button"
<TabButton
key={tab}
variant="underline"
active={statsTab === tab}
onClick={() => setStatsTab(tab)}
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors flex items-center justify-center gap-1 ${
statsTab === tab
? 'text-heading border-b-2 border-green-500 bg-surface-overlay'
: 'text-hint hover:text-label'
}`}
className="flex-1 justify-center"
>
{tab}
<Badge intent="warning" size="xs" className="font-normal"></Badge>
</button>
</TabButton>
))}
</div>
</TabBar>
<div className="p-4 flex gap-4">
{/* 월별 통계 - API 미지원, 준비중 안내 */}
@ -659,9 +662,9 @@ export function ChinaFishing() {
{/* 다운로드 버튼 */}
<div className="px-4 pb-3 flex justify-end">
<button type="button" className="px-3 py-1 bg-secondary border border-slate-700/50 rounded text-[10px] text-label hover:bg-switch-background transition-colors">
<Button variant="secondary" size="sm">
</button>
</Button>
</div>
</CardContent>
</Card>
@ -677,7 +680,7 @@ export function ChinaFishing() {
<span className="text-[11px] font-bold text-heading"> </span>
<Badge intent="warning" size="xs" className="font-normal"> </Badge>
</div>
<button type="button" className="text-[9px] text-blue-400 hover:underline"> </button>
<button type="button" className="text-[9px] text-blue-600 dark:text-blue-400 hover:underline"> </button>
</div>
<div className="space-y-1.5 text-[10px]">
<div className="flex gap-2">
@ -704,11 +707,11 @@ export function ChinaFishing() {
<span className="text-[11px] font-bold text-heading"> </span>
<Badge intent="warning" size="xs" className="font-normal"> </Badge>
</div>
<button type="button" className="text-[9px] text-blue-400 hover:underline"> </button>
<button type="button" className="text-[9px] text-blue-600 dark:text-blue-400 hover:underline"> </button>
</div>
<div className="flex items-center gap-3">
<div className="text-center">
<Cloud className="w-8 h-8 text-yellow-400 mx-auto" />
<Cloud className="w-8 h-8 text-yellow-600 dark:text-yellow-400 mx-auto" />
</div>
<div>
<div className="text-[9px] text-muted-foreground"></div>
@ -730,7 +733,7 @@ export function ChinaFishing() {
<span className="text-[11px] font-bold text-heading">VTS연계 </span>
<Badge intent="warning" size="xs" className="font-normal"> </Badge>
</div>
<button type="button" className="text-[9px] text-blue-400 hover:underline"> </button>
<button type="button" className="text-[9px] text-blue-600 dark:text-blue-400 hover:underline"> </button>
</div>
<div className="grid grid-cols-2 gap-1.5">
{VTS_ITEMS.map((vts) => (
@ -738,22 +741,28 @@ export function ChinaFishing() {
key={vts.name}
className={`flex items-center gap-1.5 px-2 py-1 rounded text-[10px] ${
vts.active
? 'bg-orange-500/15 text-orange-400 border border-orange-500/20'
? 'bg-orange-500/15 text-orange-600 dark:text-orange-400 border border-orange-500/20'
: 'bg-surface-overlay text-muted-foreground border border-slate-700/30'
}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${vts.active ? 'bg-orange-400' : 'bg-muted'}`} />
<span className={`w-1.5 h-1.5 rounded-full ${vts.active ? 'bg-orange-500' : 'bg-muted'}`} />
{vts.name}
</div>
))}
</div>
<div className="flex justify-between mt-2">
<button type="button" aria-label="이전" className="text-hint hover:text-heading transition-colors">
<ChevronLeft className="w-4 h-4" />
</button>
<button type="button" aria-label="다음" className="text-hint hover:text-heading transition-colors">
<ChevronRight className="w-4 h-4" />
</button>
<Button
variant="ghost"
size="sm"
aria-label={tcCommon('aria.previous')}
icon={<ChevronLeft className="w-4 h-4" />}
/>
<Button
variant="ghost"
size="sm"
aria-label={tcCommon('aria.next')}
icon={<ChevronRight className="w-4 h-4" />}
/>
</div>
</CardContent>
</Card>

파일 보기

@ -12,6 +12,7 @@ import type { MarkerData } from '@lib/map';
import { getDarkVessels, type VesselAnalysis } from '@/services/analysisApi';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getRiskIntent } from '@shared/constants/statusIntent';
import { getAlertLevelTierScore } from '@shared/constants/alertLevels';
import { useSettingsStore } from '@stores/settingsStore';
import { DarkDetailPanel } from './components/DarkDetailPanel';
@ -87,20 +88,23 @@ export function DarkVesselDetection() {
{ key: 'darkTier', label: '등급', width: '80px', sortable: true,
render: (v) => {
const tier = v as string;
return <Badge intent={getRiskIntent(tier === 'CRITICAL' ? 90 : tier === 'HIGH' ? 60 : tier === 'WATCH' ? 40 : 10)} size="sm">{tier}</Badge>;
return <Badge intent={getRiskIntent(tier === 'WATCH' ? 40 : getAlertLevelTierScore(tier))} size="sm">{tier}</Badge>;
} },
{ key: 'darkScore', label: '의심점수', width: '80px', align: 'center', sortable: true,
render: (v) => {
const n = v as number;
return <span className={`font-bold font-mono ${n >= 70 ? 'text-red-400' : n >= 50 ? 'text-orange-400' : 'text-yellow-400'}`}>{n}</span>;
const c = n >= 70 ? 'text-red-600 dark:text-red-400'
: n >= 50 ? 'text-orange-600 dark:text-orange-400'
: 'text-yellow-600 dark:text-yellow-400';
return <span className={`font-bold font-mono ${c}`}>{n}</span>;
} },
{ key: 'name', label: '선박 유형', sortable: true,
render: (v) => <span className="text-cyan-400 font-medium">{v as string}</span> },
render: (v) => <span className="text-cyan-600 dark:text-cyan-400 font-medium">{v as string}</span> },
{ key: 'mmsi', label: 'MMSI', width: '100px',
render: (v) => {
const mmsi = v as string;
return (
<button type="button" className="text-cyan-400 hover:text-cyan-300 hover:underline font-mono text-[10px]"
<button type="button" className="text-cyan-600 dark:text-cyan-400 hover:text-cyan-700 dark:hover:text-cyan-300 hover:underline font-mono text-[10px]"
onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${mmsi}`); }}>
{mmsi}
</button>
@ -115,7 +119,10 @@ export function DarkVesselDetection() {
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
render: (v) => {
const n = v as number;
return <span className={`font-bold ${n >= 70 ? 'text-red-400' : n >= 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}</span>;
const c = n >= 70 ? 'text-red-600 dark:text-red-400'
: n >= 50 ? 'text-yellow-600 dark:text-yellow-400'
: 'text-green-600 dark:text-green-400';
return <span className={`font-bold ${c}`}>{n}</span>;
} },
{ key: 'darkPatterns', label: '의심 패턴', minWidth: '120px',
render: (v) => <span className="text-hint text-[9px]">{v as string}</span> },
@ -251,7 +258,7 @@ export function DarkVesselDetection() {
}
/>
{error && <div className="text-xs text-red-400">: {error}</div>}
{error && <div className="text-xs text-red-600 dark:text-red-400">{tc('error.errorPrefix', { msg: error })}</div>}
{loading && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
@ -262,10 +269,10 @@ export function DarkVesselDetection() {
{/* KPI — tier 기반 */}
<div className="flex gap-2">
{[
{ l: '전체', v: tierCounts.total, c: 'text-red-400', filter: '' },
{ l: 'CRITICAL', v: tierCounts.CRITICAL, c: 'text-red-400', filter: 'CRITICAL' },
{ l: 'HIGH', v: tierCounts.HIGH, c: 'text-orange-400', filter: 'HIGH' },
{ l: 'WATCH', v: tierCounts.WATCH, c: 'text-yellow-400', filter: 'WATCH' },
{ l: '전체', v: tierCounts.total, c: 'text-red-600 dark:text-red-400', filter: '' },
{ l: 'CRITICAL', v: tierCounts.CRITICAL, c: 'text-red-600 dark:text-red-400', filter: 'CRITICAL' },
{ l: 'HIGH', v: tierCounts.HIGH, c: 'text-orange-600 dark:text-orange-400', filter: 'HIGH' },
{ l: 'WATCH', v: tierCounts.WATCH, c: 'text-yellow-600 dark:text-yellow-400', filter: 'WATCH' },
].map((k) => (
<div key={k.l}
onClick={() => setTierFilter(k.filter)}
@ -303,7 +310,7 @@ export function DarkVesselDetection() {
</div>
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
<span className="text-[10px] text-red-400 font-bold">{tierCounts.CRITICAL}</span>
<span className="text-[10px] text-red-600 dark:text-red-400 font-bold">{tierCounts.CRITICAL}</span>
<span className="text-[9px] text-hint">CRITICAL Dark Vessel</span>
</div>
</CardContent>

파일 보기

@ -2,6 +2,8 @@ import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Checkbox } from '@shared/components/ui/checkbox';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { Anchor, AlertTriangle, Loader2, Filter, X } from 'lucide-react';
@ -143,11 +145,13 @@ function FilterCheckGroup({ label, selected, onChange, options }: {
<div className="text-[10px] text-hint font-medium">{label} {selected.size > 0 && <span className="text-primary">({selected.size})</span>}</div>
<div className="flex flex-wrap gap-x-3 gap-y-1">
{options.map(o => (
<label key={o.value} className="flex items-center gap-1.5 text-[10px] text-label cursor-pointer hover:text-heading">
<input type="checkbox" checked={selected.has(o.value)} onChange={() => toggle(o.value)}
className="w-3 h-3 rounded border-border accent-primary" />
{o.label}
</label>
<Checkbox
key={o.value}
checked={selected.has(o.value)}
onChange={() => toggle(o.value)}
label={o.label}
className="w-3 h-3"
/>
))}
</div>
</div>
@ -169,7 +173,7 @@ export function GearDetection() {
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'type', label: '그룹 유형', width: '100px', sortable: true, render: v => <span className="text-heading font-medium text-[11px]">{v as string}</span> },
{ key: 'owner', label: '어구 그룹', sortable: true,
render: v => <span className="text-cyan-400 font-mono text-[11px]">{v as string}</span> },
render: v => <span className="text-cyan-600 dark:text-cyan-400 font-mono text-[11px]">{v as string}</span> },
{ key: 'memberCount', label: '멤버', width: '50px', align: 'center',
render: v => <span className="font-mono text-[10px] text-label">{v as number}</span> },
{ key: 'zone', label: '설치 해역', width: '130px', sortable: true,
@ -191,7 +195,13 @@ export function GearDetection() {
</div>
) : <span className="text-hint text-[10px]">-</span> },
{ key: 'risk', label: '위험도', width: '65px', align: 'center', sortable: true,
render: v => { const r = v as string; const c = r === '고위험' ? 'text-red-400' : r === '중위험' ? 'text-yellow-400' : 'text-green-400'; return <span className={`text-[10px] font-bold ${c}`}>{r}</span>; } },
render: v => {
const r = v as string;
const c = r === '고위험' ? 'text-red-600 dark:text-red-400'
: r === '중위험' ? 'text-yellow-600 dark:text-yellow-400'
: 'text-green-600 dark:text-green-400';
return <span className={`text-[10px] font-bold ${c}`}>{r}</span>;
} },
{ key: 'parentStatus', label: '모선 상태', width: '90px', sortable: true,
render: v => {
const s = v as string;
@ -200,13 +210,13 @@ export function GearDetection() {
return <Badge intent={intent} size="sm">{label}</Badge>;
} },
{ key: 'parentMmsi', label: '추정 모선', width: '100px',
render: v => { const m = v as string; return m !== '-' ? <span className="text-cyan-400 font-mono text-[10px]">{m}</span> : <span className="text-hint">-</span>; } },
render: v => { const m = v as string; return m !== '-' ? <span className="text-cyan-600 dark:text-cyan-400 font-mono text-[10px]">{m}</span> : <span className="text-hint">-</span>; } },
{ key: 'topScore', label: '후보 일치', width: '75px', align: 'center', sortable: true,
render: (v: unknown) => {
const s = v as number;
if (s <= 0) return <span className="text-hint text-[10px]">-</span>;
const pct = Math.round(s * 100);
const c = pct >= 72 ? 'text-green-400' : pct >= 50 ? 'text-yellow-400' : 'text-hint';
const c = pct >= 72 ? 'text-green-600 dark:text-green-400' : pct >= 50 ? 'text-yellow-600 dark:text-yellow-400' : 'text-hint';
return <span className={`font-mono text-[10px] font-bold ${c}`}>{pct}%</span>;
} },
{ key: 'lastSignal', label: '최종 신호', width: '80px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
@ -320,7 +330,7 @@ export function GearDetection() {
const mapRef = useRef<MapHandle>(null);
// overlay Proxy ref — mapRef.current.overlay를 항상 최신으로 참조
// iran 패턴: 리플레이 훅이 overlay.setProps() 직접 호출
// 리플레이 훅이 overlay.setProps() 직접 호출 (단일 렌더링 경로)
const overlayRef = useMemo<React.RefObject<MapboxOverlay | null>>(() => ({
get current() { return mapRef.current?.overlay ?? null; },
}), []);
@ -424,7 +434,7 @@ export function GearDetection() {
}, [DATA, selectedId, isReplayActive, replayGroupKey]);
// 리플레이 비활성 시만 useMapLayers가 overlay 제어
// 리플레이 활성 시 useGearReplayLayers가 overlay 독점 (iran 패턴: 단일 렌더링 경로)
// 리플레이 활성 시 useGearReplayLayers가 overlay 독점 (단일 렌더링 경로)
useEffect(() => {
if (isReplayActive) return; // replay hook이 overlay 독점
const raf = requestAnimationFrame(() => {
@ -456,11 +466,11 @@ export function GearDetection() {
{!serviceAvailable && (
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>iran - </span>
<span>AI - </span>
</div>
)}
{error && <div className="text-xs text-red-400">: {error}</div>}
{error && <div className="text-xs text-red-600 dark:text-red-400">{tc('error.errorPrefix', { msg: error })}</div>}
{loading && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
@ -472,9 +482,9 @@ export function GearDetection() {
<div className="flex gap-2 flex-wrap">
{[
{ l: '전체 어구 그룹', v: filteredData.length, c: 'text-heading' },
{ l: '불법 의심', v: filteredData.filter(d => d.status.includes('불법')).length, c: 'text-red-400' },
{ l: '확인 중', v: filteredData.filter(d => d.status === '확인 중').length, c: 'text-yellow-400' },
{ l: '정상', v: filteredData.filter(d => d.status === '정상').length, c: 'text-green-400' },
{ l: '불법 의심', v: filteredData.filter(d => d.status.includes('불법')).length, c: 'text-red-600 dark:text-red-400' },
{ l: '확인 중', v: filteredData.filter(d => d.status === '확인 중').length, c: 'text-yellow-600 dark:text-yellow-400' },
{ l: '정상', v: filteredData.filter(d => d.status === '정상').length, c: 'text-green-600 dark:text-green-400' },
].map(k => (
<div key={k.l} className="flex-1 min-w-[100px] flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
@ -493,7 +503,7 @@ export function GearDetection() {
{/* 필터 토글 버튼 */}
<div className="flex items-center gap-2">
<button type="button" onClick={() => setFilterOpen(!filterOpen)} aria-label="필터 설정"
<button type="button" onClick={() => setFilterOpen(!filterOpen)} aria-label={tc('aria.filterToggle')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-[11px] border rounded-lg transition-colors ${
hasActiveFilter
? 'bg-primary/10 border-primary/40 text-heading'
@ -510,11 +520,15 @@ export function GearDetection() {
{hasActiveFilter && (
<>
<span className="text-[10px] text-hint">{filteredData.length}/{DATA.length}</span>
<button type="button" aria-label="필터 초기화"
<Button
variant="ghost"
size="sm"
aria-label={tc('aria.filterReset')}
onClick={() => { setFilterZone(new Set()); setFilterStatus(new Set()); setFilterRisk(new Set()); setFilterParentStatus(new Set()); setFilterPermit(new Set()); setFilterMemberMin(2); setFilterMemberMax(filterOptions.maxMember || Infinity); }}
className="flex items-center gap-1 px-2 py-1 text-[10px] text-hint hover:text-label rounded hover:bg-surface-raised">
<X className="w-3 h-3" />
</button>
icon={<X className="w-3 h-3" />}
>
</Button>
</>
)}
</div>
@ -547,12 +561,12 @@ export function GearDetection() {
<span className="text-[9px] text-hint w-6 text-right">{filterMemberMin}</span>
<input type="range" min={2} max={filterOptions.maxMember}
value={filterMemberMin} onChange={e => setFilterMemberMin(Number(e.target.value))}
aria-label="최소 멤버 수"
aria-label={tc('aria.memberCountMin')}
className="flex-1 h-1 accent-primary cursor-pointer" />
<input type="range" min={2} max={filterOptions.maxMember}
value={filterMemberMax === Infinity ? filterOptions.maxMember : filterMemberMax}
onChange={e => setFilterMemberMax(Number(e.target.value))}
aria-label="최대 멤버 수"
aria-label={tc('aria.memberCountMax')}
className="flex-1 h-1 accent-primary cursor-pointer" />
<span className="text-[9px] text-hint w-6">{filterMemberMax === Infinity ? filterOptions.maxMember : filterMemberMax}</span>
</div>
@ -562,11 +576,15 @@ export function GearDetection() {
{/* 패널 내 초기화 */}
<div className="pt-2 border-t border-border flex items-center justify-between">
<span className="text-[10px] text-hint">{filteredData.length}/{DATA.length} </span>
<button type="button" aria-label="필터 초기화"
<Button
variant="ghost"
size="sm"
aria-label={tc('aria.filterReset')}
onClick={() => { setFilterZone(new Set()); setFilterStatus(new Set()); setFilterRisk(new Set()); setFilterParentStatus(new Set()); setFilterPermit(new Set()); setFilterMemberMin(2); setFilterMemberMax(filterOptions.maxMember || Infinity); }}
className="flex items-center gap-1 px-2 py-1 text-[10px] text-hint hover:text-label rounded hover:bg-surface-overlay">
<X className="w-3 h-3" />
</button>
icon={<X className="w-3 h-3" />}
>
</Button>
</div>
</div>
)}
@ -620,8 +638,8 @@ export function GearDetection() {
</div>
</div>
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
<Anchor className="w-3.5 h-3.5 text-orange-400" />
<span className="text-[10px] text-orange-400 font-bold">{DATA.length}</span>
<Anchor className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400" />
<span className="text-[10px] text-orange-600 dark:text-orange-400 font-bold">{DATA.length}</span>
<span className="text-[9px] text-hint"> </span>
</div>
{/* 리플레이 컨트롤러 (활성 시 표시) */}

파일 보기

@ -7,7 +7,8 @@ import {
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { getAlertLevelIntent } from '@shared/constants/alertLevels';
import { Button } from '@shared/components/ui/button';
import { getAlertLevelIntent, isValidAlertLevel } from '@shared/constants/alertLevels';
import { getZoneCodeLabel } from '@shared/constants/zoneCodes';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getGearDetections, type GearDetection } from '@/services/analysisApi';
@ -573,7 +574,7 @@ function GearComparisonTable() {
<Card>
<CardHeader className="px-4 pt-3 pb-0">
<CardTitle className="text-xs text-label flex items-center gap-1.5">
<Info className="w-3.5 h-3.5 text-blue-400" />
<Info className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
· (GB/T 5147-2003 )
</CardTitle>
</CardHeader>
@ -593,18 +594,18 @@ function GearComparisonTable() {
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="text-[9px] text-red-400 font-medium mb-1"> </div>
<div className="text-[9px] text-red-600 dark:text-red-400 font-medium mb-1"> </div>
{row.chinaFeatures.map((f, i) => (
<div key={i} className="text-[10px] text-muted-foreground flex items-start gap-1">
<span className="text-red-500 mt-0.5 shrink-0">-</span>{f}
<span className="text-red-600 dark:text-red-500 mt-0.5 shrink-0">-</span>{f}
</div>
))}
</div>
<div>
<div className="text-[9px] text-blue-400 font-medium mb-1"> </div>
<div className="text-[9px] text-blue-600 dark:text-blue-400 font-medium mb-1"> </div>
{row.koreaFeatures.map((f, i) => (
<div key={i} className="text-[10px] text-muted-foreground flex items-start gap-1">
<span className="text-blue-500 mt-0.5 shrink-0">-</span>{f}
<span className="text-blue-600 dark:text-blue-500 mt-0.5 shrink-0">-</span>{f}
</div>
))}
</div>
@ -687,9 +688,7 @@ export function GearIdentification() {
if (v.gearJudgment === 'GEAR_MISMATCH') warnings.push('허가 어구와 실제 탐지 어구 불일치');
if (v.gearJudgment === 'MULTIPLE_VIOLATION') warnings.push('복합 위반 — 두 개 이상 항목 동시 탐지');
const alertLevel = (v.riskLevel === 'CRITICAL' || v.riskLevel === 'HIGH' || v.riskLevel === 'MEDIUM' || v.riskLevel === 'LOW')
? v.riskLevel
: 'LOW';
const alertLevel = isValidAlertLevel(v.riskLevel) ? v.riskLevel : 'LOW';
setResult({
origin: 'china',
@ -716,7 +715,7 @@ export function GearIdentification() {
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Search className="w-5 h-5 text-cyan-500" />
<Search className="w-5 h-5 text-cyan-600 dark:text-cyan-500" />
{t('gearId.title')}
</h2>
<p className="text-[10px] text-hint mt-0.5">
@ -724,13 +723,14 @@ export function GearIdentification() {
</p>
</div>
<div className="flex items-center gap-2">
<button type="button"
<Button
variant="secondary"
size="sm"
onClick={() => setShowReference(!showReference)}
className="px-3 py-1.5 bg-secondary border border-slate-700/50 rounded-md text-[11px] text-label hover:bg-switch-background transition-colors flex items-center gap-1.5"
icon={<Info className="w-3 h-3" />}
>
<Info className="w-3 h-3" />
{showReference ? '레퍼런스 닫기' : '비교 레퍼런스'}
</button>
</Button>
</div>
</div>
@ -743,7 +743,7 @@ export function GearIdentification() {
<div className="flex items-center gap-2 flex-wrap">
<Badge intent="info" size="sm"> </Badge>
<span className="text-hint">MMSI</span>
<span className="font-mono text-cyan-400">{autoSelected.mmsi}</span>
<span className="font-mono text-cyan-600 dark:text-cyan-400">{autoSelected.mmsi}</span>
<span className="text-hint">·</span>
<span className="text-hint"></span>
<span className="font-mono text-label">{autoSelected.gearCode}</span>
@ -753,12 +753,14 @@ export function GearIdentification() {
</Badge>
<span className="text-hint ml-2"> . .</span>
</div>
<button type="button"
<Button
variant="ghost"
size="sm"
onClick={() => { setAutoSelected(null); setInput(DEFAULT_INPUT); setResult(null); }}
className="text-[10px] text-hint hover:text-heading shrink-0"
className="shrink-0 text-[10px]"
>
</button>
</Button>
</div>
)}
@ -880,19 +882,22 @@ export function GearIdentification() {
{/* 판별 버튼 */}
<div className="flex gap-2">
<button type="button"
<Button
variant="primary"
size="md"
onClick={runIdentification}
className="flex-1 py-2.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2"
icon={<Zap className="w-4 h-4" />}
className="flex-1 font-bold"
>
<Zap className="w-4 h-4" />
</button>
<button type="button"
</Button>
<Button
variant="secondary"
size="md"
onClick={resetForm}
className="px-4 py-2.5 bg-secondary hover:bg-switch-background text-label text-sm rounded-lg transition-colors border border-slate-700/50"
>
</button>
</Button>
</div>
</div>
@ -952,7 +957,7 @@ export function GearIdentification() {
<Card>
<CardHeader className="px-4 pt-3 pb-0">
<CardTitle className="text-xs text-label flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5 text-green-500" />
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-500" />
({result.reasons.length})
</CardTitle>
</CardHeader>
@ -960,7 +965,7 @@ export function GearIdentification() {
<div className="space-y-1.5">
{result.reasons.map((reason, i) => (
<div key={i} className="flex items-start gap-2 p-2 bg-surface-overlay rounded-md">
<ChevronRight className="w-3 h-3 text-green-500 mt-0.5 shrink-0" />
<ChevronRight className="w-3 h-3 text-green-600 dark:text-green-500 mt-0.5 shrink-0" />
<span className="text-[11px] text-label">{reason}</span>
</div>
))}
@ -972,7 +977,7 @@ export function GearIdentification() {
{result.warnings.length > 0 && (
<Card className="bg-surface-raised border-orange-500/20">
<CardHeader className="px-4 pt-3 pb-0">
<CardTitle className="text-xs text-orange-400 flex items-center gap-1.5">
<CardTitle className="text-xs text-orange-600 dark:text-orange-400 flex items-center gap-1.5">
<AlertTriangle className="w-3.5 h-3.5" />
/ ({result.warnings.length})
</CardTitle>
@ -981,8 +986,8 @@ export function GearIdentification() {
<div className="space-y-1.5">
{result.warnings.map((warning, i) => (
<div key={i} className="flex items-start gap-2 p-2 bg-orange-500/5 border border-orange-500/10 rounded-md">
<XCircle className="w-3 h-3 text-orange-500 mt-0.5 shrink-0" />
<span className="text-[11px] text-orange-300">{warning}</span>
<XCircle className="w-3 h-3 text-orange-600 dark:text-orange-500 mt-0.5 shrink-0" />
<span className="text-[11px] text-orange-700 dark:text-orange-300">{warning}</span>
</div>
))}
</div>
@ -994,13 +999,13 @@ export function GearIdentification() {
<Card>
<CardHeader className="px-4 pt-3 pb-0">
<CardTitle className="text-xs text-label flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5 text-purple-500" />
<Shield className="w-3.5 h-3.5 text-purple-600 dark:text-purple-500" />
AI Rule ( )
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 pt-2">
{result.gearType === 'trawl' && (
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-600 dark:text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
{`# 트롤 탐지 조건 (Trawl Detection Rule)
if speed in range(2.0, 5.0) # knots
and trajectory == 'parallel_sweep' #
@ -1015,7 +1020,7 @@ and speed_sync > 0.92 # 2선 속도 동기화`}
</pre>
)}
{result.gearType === 'gillnet' && (
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-600 dark:text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
{`# 자망 탐지 조건 (Gillnet Detection Rule)
if speed < 2.0 # knots
and stop_duration > 30 # min
@ -1030,7 +1035,7 @@ and sar_vessel_detect == True # SAR 위치 확인
</pre>
)}
{result.gearType === 'purseSeine' && (
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
<pre className="bg-background rounded-lg p-3 text-[10px] text-green-600 dark:text-green-400 font-mono overflow-x-auto whitespace-pre-wrap">
{`# 선망 탐지 조건 (Purse Seine Detection Rule)
if trajectory == 'circular' #
and speed_change > 5.0 # kt ( )
@ -1046,7 +1051,7 @@ and vessel_spacing < 1000 # m
</pre>
)}
{result.gearType === 'setNet' && (
<pre className="bg-background rounded-lg p-3 text-[10px] text-red-400 font-mono overflow-x-auto whitespace-pre-wrap">
<pre className="bg-background rounded-lg p-3 text-[10px] text-red-600 dark:text-red-400 font-mono overflow-x-auto whitespace-pre-wrap">
{`# 정치망 — EEZ 내 중국어선 미허가 어구
# GB/T 코드: Z__ (ZD: 단묘장망, ZS: 복묘장망)
#
@ -1072,7 +1077,7 @@ and vessel_spacing < 1000 # m
<Card>
<CardHeader className="px-4 pt-3 pb-0">
<CardTitle className="text-xs text-label flex items-center gap-1.5">
<Waves className="w-3.5 h-3.5 text-cyan-500" />
<Waves className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-500" />
</CardTitle>
</CardHeader>
@ -1166,20 +1171,23 @@ function AutoGearDetectionSection({
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-bold text-heading flex items-center gap-2">
<Radar className="w-4 h-4 text-cyan-500" />
<Radar className="w-4 h-4 text-cyan-600 dark:text-cyan-500" />
(prediction, 1 )
</div>
<div className="text-[10px] text-hint mt-0.5">
GET /api/analysis/gear-detections · MMSI 412 · gear_code / gear_judgment NOT NULL ·
</div>
</div>
<button type="button" onClick={load}
className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
<RefreshCw className="w-3.5 h-3.5" />
</button>
<Button
variant="ghost"
size="sm"
onClick={load}
aria-label={t('aria.refresh')}
icon={<RefreshCw className="w-3.5 h-3.5" />}
/>
</div>
{error && <div className="text-xs text-red-400">: {error}</div>}
{error && <div className="text-xs text-red-600 dark:text-red-400">{t('error.errorPrefix', { msg: error })}</div>}
{loading && <div className="flex items-center justify-center py-6 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
{!loading && (
@ -1214,7 +1222,7 @@ function AutoGearDetectionSection({
}`}
title="클릭하면 상단 입력 폼에 자동으로 채워집니다"
>
<td className="px-2 py-1.5 text-cyan-400 font-mono">{v.mmsi}</td>
<td className="px-2 py-1.5 text-cyan-600 dark:text-cyan-400 font-mono">{v.mmsi}</td>
<td className="px-2 py-1.5 text-heading font-medium">{v.vesselType ?? '-'}</td>
<td className="px-2 py-1.5 text-center font-mono text-label">{v.gearCode}</td>
<td className="px-2 py-1.5 text-center">

파일 보기

@ -2,6 +2,9 @@ import { useEffect, useState, useCallback } from 'react';
import { Loader2, RefreshCw, MapPin } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Select } from '@shared/components/ui/select';
import type { BadgeIntent } from '@lib/theme/variants';
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
import { getGearGroupTypeIntent, getGearGroupTypeLabel } from '@shared/constants/gearGroupTypes';
import { getParentResolutionIntent, getParentResolutionLabel } from '@shared/constants/parentResolutionStatuses';
@ -9,9 +12,9 @@ import { useSettingsStore } from '@stores/settingsStore';
import { useTranslation } from 'react-i18next';
/**
* iran / .
* prediction / .
* - GET /api/vessel-analysis/groups
* - DB의 ParentResolution
* - DB의 ParentResolution
*/
export function RealGearGroups() {
@ -54,7 +57,7 @@ export function RealGearGroups() {
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-bold text-heading flex items-center gap-2">
<MapPin className="w-4 h-4 text-orange-400" /> / (iran )
<MapPin className="w-4 h-4 text-orange-400" /> /
{!available && <Badge intent="critical" size="sm"></Badge>}
</div>
<div className="text-[10px] text-hint mt-0.5">
@ -62,29 +65,37 @@ export function RealGearGroups() {
</div>
</div>
<div className="flex items-center gap-2">
<select aria-label="그룹 유형 필터" value={filterType} onChange={(e) => setFilterType(e.target.value)}
className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading">
<Select
size="sm"
aria-label={tc('aria.groupTypeFilter')}
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
>
<option value=""></option>
<option value="FLEET">FLEET</option>
<option value="GEAR_IN_ZONE">GEAR_IN_ZONE</option>
<option value="GEAR_OUT_ZONE">GEAR_OUT_ZONE</option>
</select>
<button type="button" onClick={load} className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</Select>
<Button
variant="ghost"
size="sm"
onClick={load}
aria-label={tc('aria.refresh')}
icon={<RefreshCw className="w-3.5 h-3.5" />}
/>
</div>
</div>
{/* 통계 */}
<div className="grid grid-cols-5 gap-2">
<StatBox label="총 그룹" value={stats.total} color="text-heading" />
<StatBox label="FLEET" value={stats.fleet} color="text-blue-400" />
<StatBox label="어구 (지정해역)" value={stats.gearInZone} color="text-orange-400" />
<StatBox label="어구 (지정해역 외)" value={stats.gearOutZone} color="text-purple-400" />
<StatBox label="모선 확정됨" value={stats.confirmed} color="text-green-400" />
<StatBox label="총 그룹" value={stats.total} intent="muted" />
<StatBox label="FLEET" value={stats.fleet} intent="info" />
<StatBox label="어구 (지정해역)" value={stats.gearInZone} intent="high" />
<StatBox label="어구 (지정해역 외)" value={stats.gearOutZone} intent="purple" />
<StatBox label="모선 확정됨" value={stats.confirmed} intent="success" />
</div>
{error && <div className="text-xs text-red-400">: {error}</div>}
{error && <div className="text-xs text-red-600 dark:text-red-400">{tc('error.errorPrefix', { msg: error })}</div>}
{loading && <div className="flex items-center justify-center py-4 text-muted-foreground"><Loader2 className="w-4 h-4 animate-spin" /></div>}
{!loading && (
@ -142,11 +153,22 @@ export function RealGearGroups() {
);
}
function StatBox({ label, value, color }: { label: string; value: number; color: string }) {
const INTENT_TEXT_CLASS: Record<BadgeIntent, string> = {
critical: 'text-red-600 dark:text-red-400',
high: 'text-orange-600 dark:text-orange-400',
warning: 'text-yellow-600 dark:text-yellow-400',
info: 'text-blue-600 dark:text-blue-400',
success: 'text-green-600 dark:text-green-400',
muted: 'text-heading',
purple: 'text-purple-600 dark:text-purple-400',
cyan: 'text-cyan-600 dark:text-cyan-400',
};
function StatBox({ label, value, intent = 'muted' }: { label: string; value: number; intent?: BadgeIntent }) {
return (
<div className="px-3 py-2 rounded border border-border bg-surface-overlay">
<div className="text-[9px] text-hint">{label}</div>
<div className={`text-lg font-bold ${color}`}>{value}</div>
<div className={`text-lg font-bold ${INTENT_TEXT_CLASS[intent]}`}>{value}</div>
</div>
);
}

파일 보기

@ -3,6 +3,9 @@ import { Loader2, RefreshCw, EyeOff, AlertTriangle, Radar } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Select } from '@shared/components/ui/select';
import type { BadgeIntent } from '@lib/theme/variants';
import { getAlertLevelIntent } from '@shared/constants/alertLevels';
import { getVesselTypeLabel } from '@shared/constants/vesselTypes';
import type { VesselAnalysisItem } from '@/services/vesselAnalysisApi';
@ -118,31 +121,38 @@ export function RealVesselAnalysis({ mode, title, icon, mmsiPrefix, minRiskScore
</div>
</div>
<div className="flex items-center gap-2">
<select aria-label="해역 필터" value={zoneFilter} onChange={(e) => setZoneFilter(e.target.value)}
className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading">
<Select
size="sm"
aria-label={t('aria.regionFilter')}
value={zoneFilter}
onChange={(e) => setZoneFilter(e.target.value)}
>
<option value=""> </option>
<option value="TERRITORIAL_SEA"></option>
<option value="CONTIGUOUS_ZONE"></option>
<option value="EEZ_OR_BEYOND">EEZ </option>
</select>
<button type="button" onClick={load}
className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</Select>
<Button
variant="ghost"
size="sm"
onClick={load}
aria-label={t('aria.refresh')}
icon={<RefreshCw className="w-3.5 h-3.5" />}
/>
</div>
</div>
{/* 통계 카드 — items(= mode 필터 적용된 선박) 기준 집계 */}
<div className="grid grid-cols-6 gap-2">
<StatBox label="전체" value={stats.total} color="text-heading" />
<StatBox label="CRITICAL" value={stats.criticalCount} color="text-red-400" />
<StatBox label="HIGH" value={stats.highCount} color="text-orange-400" />
<StatBox label="MEDIUM" value={stats.mediumCount} color="text-yellow-400" />
<StatBox label="Dark" value={stats.darkCount} color="text-purple-400" />
<StatBox label="필터링" value={filtered.length} color="text-cyan-400" />
<StatBox label="전체" value={stats.total} intent="muted" />
<StatBox label="CRITICAL" value={stats.criticalCount} intent="critical" />
<StatBox label="HIGH" value={stats.highCount} intent="high" />
<StatBox label="MEDIUM" value={stats.mediumCount} intent="warning" />
<StatBox label="Dark" value={stats.darkCount} intent="purple" />
<StatBox label="필터링" value={filtered.length} intent="cyan" />
</div>
{error && <div className="text-xs text-red-400">: {error}</div>}
{error && <div className="text-xs text-red-600 dark:text-red-400">{t('error.errorPrefix', { msg: error })}</div>}
{loading && <div className="flex items-center justify-center py-6 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
{!loading && (
@ -168,7 +178,7 @@ export function RealVesselAnalysis({ mode, title, icon, mmsiPrefix, minRiskScore
)}
{sortedByRisk.slice(0, 100).map((v) => (
<tr key={v.mmsi} className="border-t border-border hover:bg-surface-overlay/50">
<td className="px-2 py-1.5 text-cyan-400 font-mono">{v.mmsi}</td>
<td className="px-2 py-1.5 text-cyan-600 dark:text-cyan-400 font-mono">{v.mmsi}</td>
<td className="px-2 py-1.5 text-heading font-medium">
{getVesselTypeLabel(v.classification.vesselType, t, lang)}
{v.classification.confidence > 0 && (
@ -193,7 +203,7 @@ export function RealVesselAnalysis({ mode, title, icon, mmsiPrefix, minRiskScore
</td>
<td className="px-2 py-1.5 text-right">
{v.algorithms.gpsSpoofing.spoofingScore > 0 ? (
<span className="text-orange-400 font-mono text-[10px]">{v.algorithms.gpsSpoofing.spoofingScore.toFixed(2)}</span>
<span className="text-orange-600 dark:text-orange-400 font-mono text-[10px]">{v.algorithms.gpsSpoofing.spoofingScore.toFixed(2)}</span>
) : <span className="text-hint">-</span>}
</td>
<td className="px-2 py-1.5 text-center">
@ -220,11 +230,22 @@ export function RealVesselAnalysis({ mode, title, icon, mmsiPrefix, minRiskScore
);
}
function StatBox({ label, value, color }: { label: string; value: number | undefined; color: string }) {
const INTENT_TEXT_CLASS: Record<BadgeIntent, string> = {
critical: 'text-red-600 dark:text-red-400',
high: 'text-orange-600 dark:text-orange-400',
warning: 'text-yellow-600 dark:text-yellow-400',
info: 'text-blue-600 dark:text-blue-400',
success: 'text-green-600 dark:text-green-400',
muted: 'text-heading',
purple: 'text-purple-600 dark:text-purple-400',
cyan: 'text-cyan-600 dark:text-cyan-400',
};
function StatBox({ label, value, intent = 'muted' }: { label: string; value: number | undefined; intent?: BadgeIntent }) {
return (
<div className="px-3 py-2 rounded border border-border bg-surface-overlay">
<div className="text-[9px] text-hint">{label}</div>
<div className={`text-lg font-bold ${color}`}>{(value ?? 0).toLocaleString()}</div>
<div className={`text-lg font-bold ${INTENT_TEXT_CLASS[intent]}`}>{(value ?? 0).toLocaleString()}</div>
</div>
);
}

파일 보기

@ -6,6 +6,7 @@
*/
import { useEffect, useState, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { ScoreBreakdown } from '@shared/components/common/ScoreBreakdown';
@ -24,6 +25,7 @@ interface DarkDetailPanelProps {
export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
const navigate = useNavigate();
const { t: tc } = useTranslation('common');
const [history, setHistory] = useState<VesselAnalysis[]>([]);
const features = vessel?.features ?? {};
@ -71,12 +73,12 @@ export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
{/* 헤더 */}
<div className="sticky top-0 bg-background/95 backdrop-blur-sm border-b border-border px-4 py-3 flex items-center justify-between z-10">
<div className="flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-red-400" />
<ShieldAlert className="w-4 h-4 text-red-600 dark:text-red-400" />
<span className="font-bold text-heading text-sm"> </span>
<Badge intent={getAlertLevelIntent(darkTier)} size="sm">{darkTier}</Badge>
<span className="text-xs font-mono font-bold text-heading">{darkScore}</span>
</div>
<button type="button" onClick={onClose} className="p-1 hover:bg-surface-raised rounded" aria-label="닫기">
<button type="button" onClick={onClose} className="p-1 hover:bg-surface-raised rounded" aria-label={tc('aria.close')}>
<X className="w-4 h-4 text-hint" />
</button>
</div>
@ -85,12 +87,12 @@ export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
{/* 선박 기본 정보 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<Ship className="w-3.5 h-3.5 text-cyan-400" />
<Ship className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" />
<span className="text-label font-medium"> </span>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">
<span className="text-hint">MMSI</span>
<button type="button" className="text-cyan-400 hover:underline text-right font-mono"
<button type="button" className="text-cyan-600 dark:text-cyan-400 hover:underline text-right font-mono"
onClick={() => navigate(`/vessel/${vessel.mmsi}`)}>
{vessel.mmsi} <ExternalLink className="w-2.5 h-2.5 inline" />
</button>
@ -114,7 +116,7 @@ export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
{/* 점수 산출 내역 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<AlertTriangle className="w-3.5 h-3.5 text-orange-400" />
<AlertTriangle className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400" />
<span className="text-label font-medium"> </span>
<span className="text-hint text-[10px]">({breakdown.items.length} )</span>
</div>
@ -124,7 +126,7 @@ export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
{/* GAP 상세 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<MapPin className="w-3.5 h-3.5 text-yellow-400" />
<MapPin className="w-3.5 h-3.5 text-yellow-600 dark:text-yellow-400" />
<span className="text-label font-medium">GAP </span>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">
@ -150,7 +152,7 @@ export function DarkDetailPanel({ vessel, onClose }: DarkDetailPanelProps) {
{/* 과거 이력 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<TrendingUp className="w-3.5 h-3.5 text-purple-400" />
<TrendingUp className="w-3.5 h-3.5 text-purple-600 dark:text-purple-400" />
<span className="text-label font-medium"> (7)</span>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">

파일 보기

@ -68,6 +68,7 @@ interface GearDetailPanelProps {
export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
const navigate = useNavigate();
const { t } = useTranslation('detection');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const [correlations, setCorrelations] = useState<CorrelationItem[]>([]);
const [corrLoading, setCorrLoading] = useState(false);
@ -269,14 +270,14 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
{/* 헤더 */}
<div className="sticky top-0 bg-background/95 backdrop-blur-sm border-b border-border px-4 py-3 flex items-center justify-between z-10">
<div className="flex items-center gap-2">
<ShieldAlert className="w-4 h-4 text-orange-400" />
<ShieldAlert className="w-4 h-4 text-orange-600 dark:text-orange-400" />
<span className="font-bold text-heading text-sm"> </span>
<span className="text-xs font-mono font-bold text-hint">{gear.id}</span>
<Badge intent={getZoneCodeIntent(gear.zone)} size="sm">
{getZoneCodeLabel(gear.zone, t, lang)}
</Badge>
</div>
<button type="button" onClick={onClose} className="p-1 hover:bg-surface-raised rounded" aria-label="닫기">
<button type="button" onClick={onClose} className="p-1 hover:bg-surface-raised rounded" aria-label={tc('aria.close')}>
<X className="w-4 h-4 text-hint" />
</button>
</div>
@ -286,7 +287,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
{gear.gCodes.length > 0 && (
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<ShieldAlert className="w-3.5 h-3.5 text-red-400" />
<ShieldAlert className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
<span className="text-label font-medium">G코드 </span>
<span className="text-hint text-[10px]"> {gear.gearViolationScore}</span>
</div>
@ -311,7 +312,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
{/* 어구 그룹 정보 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<Anchor className="w-3.5 h-3.5 text-orange-400" />
<Anchor className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400" />
<span className="text-label font-medium"> </span>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">
@ -343,7 +344,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
{/* 모선 추론 정보 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<Ship className="w-3.5 h-3.5 text-cyan-400" />
<Ship className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" />
<span className="text-label font-medium"> </span>
<Badge intent={parentStatusIntent} size="sm">{parentStatusLabel}</Badge>
</div>
@ -351,7 +352,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
<span className="text-hint"> </span>
<span className="text-label text-right font-mono">
{gear.parentMmsi !== '-' && gear.parentMmsi ? (
<button type="button" className="text-cyan-400 hover:underline"
<button type="button" className="text-cyan-600 dark:text-cyan-400 hover:underline"
onClick={() => navigate(`/vessel/${gear.parentMmsi}`)}>
{gear.parentMmsi}
</button>
@ -365,7 +366,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
{/* 모선 추론 후보 상세 (Correlation) */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<TrendingUp className="w-3.5 h-3.5 text-purple-400" />
<TrendingUp className="w-3.5 h-3.5 text-purple-600 dark:text-purple-400" />
<span className="text-label font-medium"> </span>
{corrLoading && <Loader2 className="w-3 h-3 animate-spin text-hint" />}
<span className="text-hint text-[10px]">{correlations.length}</span>
@ -392,7 +393,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
className="w-3 h-3 rounded border-border accent-emerald-500 shrink-0 cursor-pointer"
aria-label={`${c.targetMmsi} 리플레이 선택`} />
<button type="button"
className="text-cyan-400 hover:underline font-mono text-[11px]"
className="text-cyan-600 dark:text-cyan-400 hover:underline font-mono text-[11px]"
onClick={() => navigate(`/vessel/${c.targetMmsi}`)}>
{c.targetMmsi}
</button>
@ -466,9 +467,9 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
<div className="bg-surface-raised rounded-lg p-3 space-y-3 border border-purple-500/30">
{/* 헤더 */}
<div className="flex items-center gap-2 text-xs">
<BarChart3 className="w-3.5 h-3.5 text-purple-400" />
<BarChart3 className="w-3.5 h-3.5 text-purple-600 dark:text-purple-400" />
<span className="text-label font-medium"> </span>
<span className="text-cyan-400 font-mono text-[11px]">{cand.targetMmsi}</span>
<span className="text-cyan-600 dark:text-cyan-400 font-mono text-[11px]">{cand.targetMmsi}</span>
<span className="text-hint text-[9px] truncate">{cand.targetName}</span>
</div>
@ -574,7 +575,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
{hasPairTrawl && (
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<Users className="w-3.5 h-3.5 text-red-400" />
<Users className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
<span className="text-label font-medium"> </span>
<Badge intent="critical" size="sm">G-06</Badge>
</div>
@ -582,7 +583,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
<span className="text-hint"> </span>
<span className="text-label text-right font-mono">
{gear.pairTrawlPairMmsi ? (
<button type="button" className="text-cyan-400 hover:underline"
<button type="button" className="text-cyan-600 dark:text-cyan-400 hover:underline"
onClick={() => navigate(`/vessel/${gear.pairTrawlPairMmsi}`)}>
{gear.pairTrawlPairMmsi}
</button>
@ -614,7 +615,7 @@ export function GearDetailPanel({ gear, onClose }: GearDetailPanelProps) {
{/* 위치 + 액션 */}
<div className="bg-surface-raised rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2 text-xs">
<MapPin className="w-3.5 h-3.5 text-yellow-400" />
<MapPin className="w-3.5 h-3.5 text-yellow-600 dark:text-yellow-400" />
<span className="text-label font-medium"></span>
</div>
<div className="grid grid-cols-2 gap-y-1 text-xs">

파일 보기

@ -5,6 +5,7 @@
* Zustand subscribe DOM React re-render .
*/
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@shared/components/ui/button';
import { useGearReplayStore } from '@stores/gearReplayStore';
import { Play, Pause, X } from 'lucide-react';
@ -27,6 +28,7 @@ function formatEpochTime(epochMs: number): string {
}
export function GearReplayController({ onClose }: GearReplayControllerProps) {
const { t: tc } = useTranslation('common');
const play = useGearReplayStore((s) => s.play);
const pause = useGearReplayStore((s) => s.pause);
const seek = useGearReplayStore((s) => s.seek);
@ -133,7 +135,7 @@ export function GearReplayController({ onClose }: GearReplayControllerProps) {
className="flex-1 h-2 bg-surface-raised rounded-full cursor-pointer relative overflow-hidden min-w-[80px]"
onClick={handleTrackClick}
role="slider"
aria-label="재생 위치"
aria-label={tc('aria.replayPosition')}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(initialPct)}
@ -167,7 +169,7 @@ export function GearReplayController({ onClose }: GearReplayControllerProps) {
{/* Close */}
<button
type="button"
aria-label="재생 닫기"
aria-label={tc('aria.replayClose')}
onClick={onClose}
className="shrink-0 p-1 hover:bg-surface-raised rounded"
>

파일 보기

@ -38,14 +38,14 @@ export function VesselAnomalyPanel({ segments, loading, error, totalHistoryCount
<CardContent className="p-3 space-y-2">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2 flex-wrap">
<ShieldAlert className="w-3.5 h-3.5 text-red-400" />
<ShieldAlert className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
<span className="text-[11px] font-bold text-heading"> </span>
{segments.length > 0 && (
<span className="text-[10px] text-hint flex items-center gap-1 flex-wrap">
· {segments.length}
{criticalCount > 0 && <span className="text-red-400 ml-0.5">CRITICAL {criticalCount}</span>}
{warningCount > 0 && <span className="text-orange-400 ml-0.5">WARNING {warningCount}</span>}
{infoCount > 0 && <span className="text-blue-400 ml-0.5">INFO {infoCount}</span>}
{criticalCount > 0 && <span className="text-red-600 dark:text-red-400 ml-0.5">CRITICAL {criticalCount}</span>}
{warningCount > 0 && <span className="text-orange-600 dark:text-orange-400 ml-0.5">WARNING {warningCount}</span>}
{infoCount > 0 && <span className="text-blue-600 dark:text-blue-400 ml-0.5">INFO {infoCount}</span>}
</span>
)}
</div>
@ -55,7 +55,7 @@ export function VesselAnomalyPanel({ segments, loading, error, totalHistoryCount
</div>
{error && (
<div className="flex items-center gap-2 px-2 py-1.5 rounded border border-red-500/30 bg-red-500/5 text-[10px] text-red-400">
<div className="flex items-center gap-2 px-2 py-1.5 rounded border border-red-500/30 bg-red-500/5 text-[10px] text-red-600 dark:text-red-400">
<AlertTriangle className="w-3 h-3 shrink-0" />
<span>{error}</span>
</div>

파일 보기

@ -4,6 +4,7 @@
*/
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { Loader2, Ship, Clock, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PathLayer, ScatterplotLayer } from 'deck.gl';
@ -33,6 +34,7 @@ function fmt(ts: string | number): string {
}
export function VesselMiniMap({ mmsi, vesselName, hoursBack = 24, segments = [], onClose }: Props) {
const { t: tc } = useTranslation('common');
const mapRef = useRef<MapHandle | null>(null);
const [track, setTrack] = useState<VesselTrack | null>(null);
const [loading, setLoading] = useState(false);
@ -180,7 +182,7 @@ export function VesselMiniMap({ mmsi, vesselName, hoursBack = 24, segments = [],
<CardContent className="p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<Ship className="w-3.5 h-3.5 text-blue-400 shrink-0" />
<Ship className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400 shrink-0" />
<div className="min-w-0">
<div className="text-[11px] font-bold text-heading truncate flex items-center gap-1.5">
{vesselName ?? mmsi}
@ -201,7 +203,7 @@ export function VesselMiniMap({ mmsi, vesselName, hoursBack = 24, segments = [],
</div>
</div>
{onClose && (
<button type="button" onClick={onClose} aria-label="미니맵 닫기"
<button type="button" onClick={onClose} aria-label={tc('aria.miniMapClose')}
className="p-1 rounded text-hint hover:text-heading hover:bg-surface-overlay shrink-0">
<X className="w-3.5 h-3.5" />
</button>
@ -216,7 +218,7 @@ export function VesselMiniMap({ mmsi, vesselName, hoursBack = 24, segments = [],
</div>
)}
{!loading && error && (
<div className="absolute inset-0 bg-background/80 flex items-center justify-center text-xs text-red-400 px-3 text-center">
<div className="absolute inset-0 bg-background/80 flex items-center justify-center text-xs text-red-600 dark:text-red-400 px-3 text-center">
{error}
</div>
)}

파일 보기

@ -12,6 +12,14 @@
* , .
*/
import type { VesselAnalysis } from '@/services/analysisApi';
import type { AlertLevel } from '@shared/constants/alertLevels';
/** riskLevel → 특이운항 패널의 severity/한글 레이블 매핑. */
const HIGH_RISK_LEVEL_META: Partial<Record<AlertLevel, { severity: AnomalyPoint['severity']; label: string }>> = {
CRITICAL: { severity: 'critical', label: '고위험 CRITICAL' },
HIGH: { severity: 'warning', label: '위험 HIGH' },
MEDIUM: { severity: 'info', label: '주의 MEDIUM' },
};
export type AnomalyCategory =
| 'DARK'
@ -113,18 +121,15 @@ export function classifyAnomaly(v: VesselAnalysis): AnomalyPoint | null {
descs.push(`어구 판정 ${v.gearJudgment}${v.gearCode ? ` (${v.gearCode})` : ''}`);
severity = bumpSeverity(severity, 'warning');
}
if (v.riskLevel === 'CRITICAL') {
if (!cats.includes('HIGH_RISK')) cats.push('HIGH_RISK');
descs.push(`고위험 CRITICAL (score ${v.riskScore ?? 0})`);
severity = bumpSeverity(severity, 'critical');
} else if (v.riskLevel === 'HIGH') {
if (!cats.includes('HIGH_RISK')) cats.push('HIGH_RISK');
descs.push(`위험 HIGH (score ${v.riskScore ?? 0})`);
severity = bumpSeverity(severity, 'warning');
} else if (v.riskLevel === 'MEDIUM' && (v.riskScore ?? 0) >= 40) {
if (!cats.includes('HIGH_RISK')) cats.push('HIGH_RISK');
descs.push(`주의 MEDIUM (score ${v.riskScore ?? 0})`);
severity = bumpSeverity(severity, 'info');
const levelMeta = HIGH_RISK_LEVEL_META[v.riskLevel as AlertLevel];
if (levelMeta) {
const score = v.riskScore ?? 0;
const include = v.riskLevel === 'MEDIUM' ? score >= 40 : true;
if (include) {
if (!cats.includes('HIGH_RISK')) cats.push('HIGH_RISK');
descs.push(`${levelMeta.label} (score ${score})`);
severity = bumpSeverity(severity, levelMeta.severity);
}
}
if (cats.length === 0) return null;

파일 보기

@ -131,7 +131,7 @@ export function EventList() {
},
},
{ key: 'vesselName', label: '선박명', minWidth: '100px', maxWidth: '220px', sortable: true,
render: (val) => <span className="text-cyan-400 font-medium">{val as string}</span>,
render: (val) => <span className="text-cyan-600 dark:text-cyan-400 font-medium">{val as string}</span>,
},
{ key: 'mmsi', label: 'MMSI', minWidth: '90px', maxWidth: '120px',
render: (_val, row) => {
@ -140,7 +140,7 @@ export function EventList() {
return (
<button
type="button"
className="text-cyan-400 hover:text-cyan-300 hover:underline font-mono text-[10px]"
className="text-cyan-600 dark:text-cyan-400 hover:text-cyan-700 dark:hover:text-cyan-300 hover:underline font-mono text-[10px]"
onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${mmsi}`); }}
>
{mmsi}
@ -171,26 +171,26 @@ export function EventList() {
return (
<div className="flex items-center gap-1">
{isNew && (
<button type="button" aria-label="확인" title={canAck ? '확인(ACK)' : '확인 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-blue-500/20 text-blue-400 disabled:opacity-30 disabled:cursor-not-allowed"
<button type="button" aria-label={tc('aria.confirmAction')} title={canAck ? '확인(ACK)' : '확인 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-blue-500/20 text-blue-600 dark:text-blue-400 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={busy || !canAck} onClick={(e) => { e.stopPropagation(); handleAck(eid); }}>
<CheckCircle className="w-3.5 h-3.5" />
</button>
)}
<button type="button" aria-label="선박 상세" title="선박 상세"
className="p-0.5 rounded hover:bg-cyan-500/20 text-cyan-400"
<button type="button" aria-label={tc('aria.vesselDetail')} title="선박 상세"
className="p-0.5 rounded hover:bg-cyan-500/20 text-cyan-600 dark:text-cyan-400"
onClick={(e) => { e.stopPropagation(); if (row.mmsi !== '-') navigate(`/vessel/${row.mmsi}`); }}>
<Ship className="w-3.5 h-3.5" />
</button>
{isActionable && (
<>
<button type="button" aria-label="단속 등록" title={canCreateEnforcement ? '단속 등록' : '단속 등록 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-green-500/20 text-green-400 disabled:opacity-30 disabled:cursor-not-allowed"
<button type="button" aria-label={tc('aria.enforcementRegister')} title={canCreateEnforcement ? '단속 등록' : '단속 등록 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-green-500/20 text-green-600 dark:text-green-400 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={busy || !canCreateEnforcement} onClick={(e) => { e.stopPropagation(); handleCreateEnforcement(row); }}>
<Shield className="w-3.5 h-3.5" />
</button>
<button type="button" aria-label="오탐 처리" title={canAck ? '오탐 처리' : '상태 변경 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-red-500/20 text-red-400 disabled:opacity-30 disabled:cursor-not-allowed"
<button type="button" aria-label={tc('aria.falsePositiveProcess')} title={canAck ? '오탐 처리' : '상태 변경 권한이 필요합니다'}
className="p-0.5 rounded hover:bg-red-500/20 text-red-600 dark:text-red-400 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={busy || !canAck} onClick={(e) => { e.stopPropagation(); handleFalsePositive(eid); }}>
<Ban className="w-3.5 h-3.5" />
</button>
@ -317,7 +317,7 @@ export function EventList() {
{/* 에러 표시 */}
{error && (
<div className="rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-[11px] text-red-400">
<div className="rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-[11px] text-red-600 dark:text-red-400">
: {error}
</div>
)}
@ -327,7 +327,7 @@ export function EventList() {
<div className="rounded-xl border border-border bg-card p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] text-label font-bold"> </span>
<button type="button" title="닫기" onClick={() => setShowUpload(false)} className="text-hint hover:text-muted-foreground">
<button type="button" aria-label={tc('aria.close')} title="닫기" onClick={() => setShowUpload(false)} className="text-hint hover:text-muted-foreground">
<X className="w-4 h-4" />
</button>
</div>
@ -343,7 +343,7 @@ export function EventList() {
{/* 로딩 인디케이터 */}
{loading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 text-blue-400 animate-spin" />
<Loader2 className="w-5 h-5 text-blue-600 dark:text-blue-400 animate-spin" />
<span className="ml-2 text-[11px] text-muted-foreground"> ...</span>
</div>
)}

파일 보기

@ -37,7 +37,7 @@ const cols: DataColumn<AlertRow>[] = [
key: 'eventId',
label: '이벤트',
width: '80px',
render: (v) => <span className="text-cyan-400 font-mono text-[10px]">EVT-{v as number}</span>,
render: (v) => <span className="text-cyan-600 dark:text-cyan-400 font-mono text-[10px]">EVT-{v as number}</span>,
},
{
key: 'time',
@ -58,7 +58,7 @@ const cols: DataColumn<AlertRow>[] = [
{
key: 'recipient',
label: '수신 대상',
render: (v) => <span className="text-cyan-400">{v as string}</span>,
render: (v) => <span className="text-cyan-600 dark:text-cyan-400">{v as string}</span>,
},
{
key: 'confidence',
@ -70,7 +70,7 @@ const cols: DataColumn<AlertRow>[] = [
const s = v as string;
if (!s) return <span className="text-hint">-</span>;
const n = parseFloat(s);
const color = n > 0.9 ? 'text-red-400' : n > 0.8 ? 'text-orange-400' : 'text-yellow-400';
const color = n > 0.9 ? 'text-red-600 dark:text-red-400' : n > 0.8 ? 'text-orange-600 dark:text-orange-400' : 'text-yellow-600 dark:text-yellow-400';
return <span className={`font-bold ${color}`}>{(n * 100).toFixed(0)}%</span>;
},
},
@ -149,10 +149,10 @@ export function AIAlert() {
if (error) {
return (
<PageContainer>
<div className="flex items-center justify-center gap-2 text-red-400 py-8">
<div className="flex items-center justify-center gap-2 text-red-600 dark:text-red-400 py-8">
<AlertTriangle className="w-5 h-5" />
<span> : {error}</span>
<button type="button" onClick={fetchAlerts} className="ml-2 text-xs underline text-cyan-400">
<button type="button" onClick={fetchAlerts} className="ml-2 text-xs underline text-cyan-600 dark:text-cyan-400">
</button>
</div>
@ -164,15 +164,15 @@ export function AIAlert() {
<PageContainer>
<PageHeader
icon={Send}
iconColor="text-yellow-400"
iconColor="text-yellow-600 dark:text-yellow-400"
title={t('aiAlert.title')}
description={t('aiAlert.desc')}
/>
<div className="flex gap-2">
{[
{ l: '총 발송', v: totalElements, c: 'text-heading' },
{ l: '수신확인', v: deliveredCount, c: 'text-green-400' },
{ l: '실패', v: failedCount, c: 'text-red-400' },
{ l: '수신확인', v: deliveredCount, c: 'text-green-600 dark:text-green-400' },
{ l: '실패', v: failedCount, c: 'text-red-600 dark:text-red-400' },
].map((k) => (
<div
key={k.l}

파일 보기

@ -56,7 +56,7 @@ export function MobileService() {
<PageContainer>
<PageHeader
icon={Smartphone}
iconColor="text-blue-400"
iconColor="text-blue-600 dark:text-blue-400"
title={t('mobileService.title')}
description={t('mobileService.desc')}
/>
@ -70,7 +70,7 @@ export function MobileService() {
<div className="p-3 space-y-2">
{/* 긴급 경보 */}
<div className="bg-red-500/15 border border-red-500/20 rounded-lg p-2">
<div className="text-[9px] text-red-400 font-bold">[] EEZ </div>
<div className="text-[9px] text-red-600 dark:text-red-400 font-bold">[] EEZ </div>
<div className="text-[8px] text-hint">N37°12' E124°38' · 08:47</div>
</div>
{/* 지도 영역 — MapLibre GL */}
@ -125,14 +125,14 @@ export function MobileService() {
{ icon: WifiOff, name: '오프라인 지원', desc: '통신 불안정 시 지도·객체 임시 저장' },
].map(f => (
<div key={f.name} className="flex items-start gap-2 p-2 bg-surface-overlay rounded-lg">
<f.icon className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
<f.icon className="w-4 h-4 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
<div><div className="text-[10px] text-heading font-medium">{f.name}</div><div className="text-[9px] text-hint">{f.desc}</div></div>
</div>
))}
</div>
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5"><Bell className="w-4 h-4 text-yellow-400" /> </div>
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5"><Bell className="w-4 h-4 text-yellow-600 dark:text-yellow-400" /> </div>
<div className="space-y-2">
{PUSH_SETTINGS.map(p => (
<div key={p.name} className="flex items-center justify-between px-3 py-2 bg-surface-overlay rounded-lg">

파일 보기

@ -88,7 +88,7 @@ export function MonitoringDashboard() {
title={t('monitoring.title')}
description={t('monitoring.desc')}
/>
{/* iran 백엔드 + Prediction 시스템 상태 (실시간) */}
{/* 백엔드 + prediction 분석 엔진 시스템 상태 (실시간) */}
<SystemStatusPanel />
<div className="flex gap-2">

파일 보기

@ -29,7 +29,7 @@ interface AnalysisStatus {
*
* :
* 1. (kcg-ai-backend)
* 2. iran + Prediction ( )
* 2. (prediction) +
* 3. ( )
*/
export function SystemStatusPanel() {
@ -94,10 +94,10 @@ export function SystemStatusPanel() {
]}
/>
{/* iran 백엔드 */}
{/* 분석 엔진 */}
<ServiceCard
icon={<Wifi className="w-4 h-4" />}
title="iran 백엔드 (분석)"
title="AI 분석 엔진"
status={stats ? 'CONNECTED' : 'DISCONNECTED'}
statusIntent={stats ? 'success' : 'critical'}
details={[

파일 보기

@ -3,6 +3,7 @@ import { Tag, X, Loader2 } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Input } from '@shared/components/ui/input';
import { Select } from '@shared/components/ui/select';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { useAuth } from '@/app/auth/AuthContext';
@ -67,7 +68,7 @@ export function LabelSession() {
setGroupKey(''); setLabelMmsi('');
await load();
} catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} finally {
setBusy(null);
}
@ -75,13 +76,13 @@ export function LabelSession() {
const handleCancel = async (id: number) => {
if (!canUpdate) return;
if (!confirm('세션을 취소하시겠습니까?')) return;
if (!confirm(tc('dialog.cancelSession'))) return;
setBusy(id);
try {
await cancelLabelSession(id, '운영자 취소');
await load();
} catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} finally {
setBusy(null);
}
@ -98,7 +99,7 @@ export function LabelSession() {
<>
<Select
size="sm"
aria-label="상태 필터"
aria-label={tc('aria.statusFilter')}
title="상태 필터"
value={filter}
onChange={(e) => setFilter(e.target.value)}
@ -119,26 +120,29 @@ export function LabelSession() {
<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>}
{!canCreate && <span className="text-yellow-600 dark:text-yellow-400 text-[10px]"> </span>}
</div>
<div className="flex items-center gap-2">
<input aria-label="group_key" 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 aria-label="sub_cluster_id" 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 aria-label="정답 parent MMSI" 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" />}
<Input aria-label={tc('aria.groupKey')} size="sm" value={groupKey} onChange={(e) => setGroupKey(e.target.value)} placeholder="group_key"
className="flex-1" disabled={!canCreate} />
<Input aria-label={tc('aria.subClusterId')} size="sm" type="number" value={subCluster} onChange={(e) => setSubCluster(e.target.value)} placeholder="sub"
className="w-24" disabled={!canCreate} />
<Input aria-label={tc('aria.correctParentMmsi')} size="sm" value={labelMmsi} onChange={(e) => setLabelMmsi(e.target.value)} placeholder="정답 parent MMSI"
className="w-48" disabled={!canCreate} />
<Button
variant="primary"
size="sm"
onClick={handleCreate}
disabled={!canCreate || !groupKey || !labelMmsi || busy === -1}
icon={busy === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Tag className="w-3.5 h-3.5" />}
>
</button>
</Button>
</div>
</CardContent>
</Card>
{error && <div className="text-xs text-red-400">: {error}</div>}
{error && <div className="text-xs text-red-600 dark:text-red-400">{tc('error.errorPrefix', { msg: error })}</div>}
{loading && (
<div className="flex items-center justify-center py-12 text-muted-foreground">
@ -171,7 +175,7 @@ export function LabelSession() {
<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 text-cyan-600 dark:text-cyan-400 font-mono">{it.labelParentMmsi}</td>
<td className="px-3 py-2">
<Badge intent={getLabelSessionIntent(it.status)} size="sm">{getLabelSessionLabel(it.status, tc, lang)}</Badge>
</td>
@ -180,7 +184,7 @@ export function LabelSession() {
<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="취소">
className="p-1 rounded hover:bg-red-500/20 disabled:opacity-30 text-red-600 dark:text-red-400" title="취소" aria-label="취소">
<X className="w-3.5 h-3.5" />
</button>
)}

파일 보기

@ -3,6 +3,7 @@ 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 { Button } from '@shared/components/ui/button';
import { Input } from '@shared/components/ui/input';
import { Select } from '@shared/components/ui/select';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { useAuth } from '@/app/auth/AuthContext';
@ -14,6 +15,7 @@ import {
type CandidateExclusion,
} from '@/services/parentInferenceApi';
import { formatDateTime } from '@shared/utils/dateFormat';
import { useTranslation } from 'react-i18next';
/**
* .
@ -26,6 +28,7 @@ import { formatDateTime } from '@shared/utils/dateFormat';
*/
export function ParentExclusion() {
const { t: tc } = useTranslation('common');
const { hasPermission } = useAuth();
const canCreateGroup = hasPermission('parent-inference-workflow:parent-exclusion', 'CREATE');
const canRelease = hasPermission('parent-inference-workflow:parent-exclusion', 'UPDATE');
@ -71,7 +74,7 @@ export function ParentExclusion() {
setGrpKey(''); setGrpMmsi(''); setGrpReason('');
await load();
} catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} finally {
setBusy(null);
}
@ -85,7 +88,7 @@ export function ParentExclusion() {
setGlbMmsi(''); setGlbReason('');
await load();
} catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} finally {
setBusy(null);
}
@ -98,7 +101,7 @@ export function ParentExclusion() {
await releaseExclusion(id, '운영자 해제');
await load();
} catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
alert(tc('error.operationFailed', { msg: e instanceof Error ? e.message : 'unknown' }));
} finally {
setBusy(null);
}
@ -115,7 +118,7 @@ export function ParentExclusion() {
<>
<Select
size="sm"
aria-label="스코프 필터"
aria-label={tc('aria.scopeFilter')}
title="스코프 필터"
value={filter}
onChange={(e) => setFilter(e.target.value as '' | 'GROUP' | 'GLOBAL')}
@ -136,23 +139,26 @@ export function ParentExclusion() {
<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>}
{!canCreateGroup && <span className="text-yellow-600 dark:text-yellow-400 text-[10px]"> </span>}
</div>
<div className="flex items-center gap-2">
<input aria-label="group_key" 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 aria-label="sub_cluster_id" 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 aria-label="excluded MMSI" 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 aria-label="제외 사유" 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" />}
<Input aria-label={tc('aria.groupKey')} size="sm" value={grpKey} onChange={(e) => setGrpKey(e.target.value)} placeholder="group_key"
className="flex-1" disabled={!canCreateGroup} />
<Input aria-label={tc('aria.subClusterId')} size="sm" type="number" value={grpSub} onChange={(e) => setGrpSub(e.target.value)} placeholder="sub"
className="w-24" disabled={!canCreateGroup} />
<Input aria-label={tc('aria.excludedMmsi')} size="sm" value={grpMmsi} onChange={(e) => setGrpMmsi(e.target.value)} placeholder="excluded MMSI"
className="w-40" disabled={!canCreateGroup} />
<Input aria-label={tc('aria.exclusionReason')} size="sm" value={grpReason} onChange={(e) => setGrpReason(e.target.value)} placeholder="사유"
className="flex-1" disabled={!canCreateGroup} />
<Button
variant="primary"
size="sm"
onClick={handleAddGroup}
disabled={!canCreateGroup || !grpKey || !grpMmsi || busy === -1}
icon={busy === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Ban className="w-3.5 h-3.5" />}
>
</button>
</Button>
</div>
</CardContent>
</Card>
@ -162,24 +168,27 @@ export function ParentExclusion() {
<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>}
{!canCreateGlobal && <span className="text-yellow-600 dark:text-yellow-400 text-[10px]"> </span>}
</div>
<div className="flex items-center gap-2">
<input aria-label="excluded MMSI (전역)" 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 aria-label="전역 제외 사유" 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" />}
<Input aria-label={tc('aria.excludedMmsi')} size="sm" value={glbMmsi} onChange={(e) => setGlbMmsi(e.target.value)} placeholder="excluded MMSI"
className="w-40" disabled={!canCreateGlobal} />
<Input aria-label={tc('aria.globalExclusionReason')} size="sm" value={glbReason} onChange={(e) => setGlbReason(e.target.value)} placeholder="사유"
className="flex-1" disabled={!canCreateGlobal} />
<Button
variant="destructive"
size="sm"
onClick={handleAddGlobal}
disabled={!canCreateGlobal || !glbMmsi || busy === -2}
icon={busy === -2 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Globe className="w-3.5 h-3.5" />}
>
</button>
</Button>
</div>
</CardContent>
</Card>
{error && <div className="text-xs text-red-400">: {error}</div>}
{error && <div className="text-xs text-red-600 dark:text-red-400">{tc('error.errorPrefix', { msg: error })}</div>}
{loading && (
<div className="flex items-center justify-center py-12 text-muted-foreground">
@ -218,13 +227,13 @@ export function ParentExclusion() {
</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-cyan-600 dark: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]">{formatDateTime(it.createdAt)}</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="해제">
className="p-1 rounded hover:bg-blue-500/20 disabled:opacity-30 text-blue-600 dark:text-blue-400" title="해제" aria-label="해제">
<RotateCcw className="w-3.5 h-3.5" />
</button>
</td>

파일 보기

@ -3,6 +3,7 @@ import { CheckCircle, XCircle, RotateCcw, Loader2, GitMerge } from 'lucide-react
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Input } from '@shared/components/ui/input';
import { Select } from '@shared/components/ui/select';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { useAuth } from '@/app/auth/AuthContext';
@ -97,7 +98,7 @@ export function ParentReview() {
await load();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'unknown';
alert('처리 실패: ' + msg);
alert(tc('error.processFailed', { msg }));
} finally {
setActionLoading(null);
}
@ -117,7 +118,7 @@ export function ParentReview() {
await load();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'unknown';
alert('등록 실패: ' + msg);
alert(tc('error.registerFailed', { msg }));
} finally {
setActionLoading(null);
}
@ -151,39 +152,40 @@ export function ParentReview() {
<CardContent className="p-4">
<div className="text-xs text-muted-foreground mb-2"> ()</div>
<div className="flex items-center gap-2">
<input
aria-label="group_key"
type="text"
<Input
aria-label={tc('aria.groupKey')}
size="sm"
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"
className="flex-1"
/>
<input
aria-label="sub_cluster_id"
<Input
aria-label={tc('aria.subClusterId')}
size="sm"
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"
className="w-32"
/>
<input
<Input
aria-label="parent MMSI"
type="text"
size="sm"
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"
className="w-40"
/>
<button
type="button"
<Button
variant="primary"
size="sm"
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"
icon={actionLoading === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <CheckCircle className="w-3.5 h-3.5" />}
>
{actionLoading === -1 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <CheckCircle className="w-3.5 h-3.5" />}
</button>
</Button>
</div>
</CardContent>
</Card>
@ -192,7 +194,7 @@ export function ParentReview() {
{!canUpdate && (
<Card>
<CardContent className="p-4">
<div className="text-xs text-yellow-400">
<div className="text-xs text-yellow-600 dark:text-yellow-400">
(UPDATE ). // .
</div>
</CardContent>
@ -202,7 +204,7 @@ export function ParentReview() {
{error && (
<Card>
<CardContent className="p-4">
<div className="text-xs text-red-400">: {error}</div>
<div className="text-xs text-red-600 dark:text-red-400">{tc('error.errorPrefix', { msg: error })}</div>
</CardContent>
</Card>
)}
@ -247,39 +249,42 @@ export function ParentReview() {
{getParentResolutionLabel(it.status, tc, lang)}
</Badge>
</td>
<td className="px-3 py-2 text-cyan-400 font-mono">{it.selectedParentMmsi || '-'}</td>
<td className="px-3 py-2 text-cyan-600 dark:text-cyan-400 font-mono">{it.selectedParentMmsi || '-'}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">
{formatDateTime(it.updatedAt)}
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-center gap-1">
<button
type="button"
<Button
variant="ghost"
size="sm"
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"
aria-label="확정"
className="text-green-600 dark:text-green-400 hover:bg-green-500/20"
icon={<CheckCircle className="w-3.5 h-3.5" />}
/>
<Button
variant="ghost"
size="sm"
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"
aria-label="거부"
className="text-red-600 dark:text-red-400 hover:bg-red-500/20"
icon={<XCircle className="w-3.5 h-3.5" />}
/>
<Button
variant="ghost"
size="sm"
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>
aria-label="리셋"
className="text-blue-600 dark:text-blue-400 hover:bg-blue-500/20"
icon={<RotateCcw className="w-3.5 h-3.5" />}
/>
</div>
</td>
</tr>

파일 보기

@ -32,6 +32,7 @@ const reports: Report[] = [
export function ReportManagement() {
const { t } = useTranslation('statistics');
const { t: tc } = useTranslation('common');
const [selected, setSelected] = useState<Report>(reports[0]);
const [search, setSearch] = useState('');
const [showUpload, setShowUpload] = useState(false);
@ -81,7 +82,7 @@ export function ReportManagement() {
<div className="rounded-xl border border-border bg-card p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] text-label font-bold"> (··)</span>
<button type="button" aria-label="업로드 패널 닫기" onClick={() => setShowUpload(false)} className="text-hint hover:text-muted-foreground"><X className="w-4 h-4" /></button>
<button type="button" aria-label={tc('aria.uploadPanelClose')} onClick={() => setShowUpload(false)} className="text-hint hover:text-muted-foreground"><X className="w-4 h-4" /></button>
</div>
<FileUpload accept=".jpg,.jpeg,.png,.mp4,.pdf,.hwp,.docx" multiple maxSizeMB={100} />
<div className="flex justify-end mt-3">
@ -120,8 +121,8 @@ export function ReportManagement() {
</div>
<div className="text-[11px] text-hint mt-1"> {r.evidence}</div>
<div className="flex gap-2 mt-2">
<button type="button" className="bg-blue-600 text-on-vivid text-[11px] px-3 py-1 rounded hover:bg-blue-500 transition-colors">PDF</button>
<button type="button" className="bg-muted text-heading text-[11px] px-3 py-1 rounded hover:bg-muted transition-colors"></button>
<Button variant="primary" size="sm">PDF</Button>
<Button variant="secondary" size="sm"></Button>
</div>
</div>
))}
@ -135,9 +136,9 @@ export function ReportManagement() {
<CardContent className="p-5">
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-label"> </div>
<button type="button" className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid px-3 py-1.5 rounded-lg text-xs transition-colors">
<Download className="w-3.5 h-3.5" />
</button>
<Button variant="primary" size="sm" icon={<Download className="w-3.5 h-3.5" />}>
</Button>
</div>
<div className="bg-surface-raised border border-slate-700/40 rounded-xl p-6 space-y-5">

파일 보기

@ -15,7 +15,11 @@ import {
type PredictionEvent,
} from '@/services/event';
import { getAlertLevelHex } from '@shared/constants/alertLevels';
import {
getAlertLevelHex,
getAlertLevelMarkerOpacity,
getAlertLevelMarkerRadius,
} from '@shared/constants/alertLevels';
interface MapEvent {
id: string;
@ -52,7 +56,7 @@ function RiskBar({ value, size = 'md' }: { value: number; size?: 'sm' | 'md' })
<div className={`flex-1 ${h} bg-secondary rounded-full overflow-hidden`}>
<div className={`${h} bg-red-500 rounded-full`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs font-medium text-red-400">{value.toFixed(2)}</span>
<span className="text-xs font-medium text-red-600 dark:text-red-400">{value.toFixed(2)}</span>
</div>
);
}
@ -113,7 +117,7 @@ export function LiveMapView() {
nationality: e.vesselMmsi?.startsWith('412') ? 'CN' : e.vesselMmsi?.startsWith('440') ? 'KR' : '미상',
time: e.occurredAt.includes(' ') ? e.occurredAt.split(' ')[1]?.slice(0, 5) ?? e.occurredAt : e.occurredAt,
vesselName: e.vesselName ?? '미상',
risk: e.aiConfidence ?? (e.level === 'CRITICAL' ? 0.94 : e.level === 'HIGH' ? 0.91 : 0.88),
risk: e.aiConfidence ?? getAlertLevelMarkerOpacity(e.level),
lat: e.lat!,
lng: e.lon!,
level: e.level,
@ -171,7 +175,7 @@ export function LiveMapView() {
lat: v.lat,
lng: v.lng,
color,
radius: level === 'CRITICAL' ? 900 : level === 'HIGH' ? 750 : 600,
radius: getAlertLevelMarkerRadius(level),
label: v.item.mmsi,
};
}),
@ -240,7 +244,7 @@ export function LiveMapView() {
{loading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 text-blue-400 animate-spin" />
<Loader2 className="w-5 h-5 text-blue-600 dark:text-blue-400 animate-spin" />
<span className="ml-2 text-[11px] text-hint"> ...</span>
</div>
)}
@ -248,8 +252,8 @@ export function LiveMapView() {
{!serviceAvailable && !loading && (
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-center gap-2">
<WifiOff className="w-4 h-4 text-yellow-400" />
<span className="text-[11px] text-yellow-400 font-medium"> </span>
<WifiOff className="w-4 h-4 text-yellow-600 dark:text-yellow-400" />
<span className="text-[11px] text-yellow-600 dark:text-yellow-400 font-medium"> </span>
</div>
<p className="text-[10px] text-hint mt-1"> .</p>
</div>
@ -274,7 +278,7 @@ export function LiveMapView() {
<IconComp className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-semibold text-heading">{evt.type}</span>
</div>
<Pin className="w-3.5 h-3.5 text-hint hover:text-orange-400 transition-colors" />
<Pin className="w-3.5 h-3.5 text-hint hover:text-orange-600 dark:hover:text-orange-400 transition-colors" />
</div>
<div className="text-[11px] text-hint mb-2">{evt.mmsi} · {evt.nationality} · {evt.time}</div>
<RiskBar value={evt.risk} size="sm" />
@ -314,7 +318,7 @@ export function LiveMapView() {
{/* 실시간 표시 */}
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
<span className="text-[10px] text-red-400 font-bold">LIVE</span>
<span className="text-[10px] text-red-600 dark:text-red-400 font-bold">LIVE</span>
<span className="text-[9px] text-hint"> {mapEvents.length} · {vesselItems.length}</span>
</div>
</div>
@ -329,7 +333,7 @@ export function LiveMapView() {
<div className="bg-red-950/40 border border-red-900/40 rounded-xl p-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 bg-red-600/30 rounded-lg flex items-center justify-center">
<Ship className="w-4.5 h-4.5 text-red-400" />
<Ship className="w-4.5 h-4.5 text-red-600 dark:text-red-400" />
</div>
<div>
<div className="text-heading font-bold text-sm">{selectedEvent.vesselName}</div>
@ -344,7 +348,7 @@ export function LiveMapView() {
<CardContent className="p-4">
<div className="text-[10px] text-muted-foreground mb-1"> </div>
<div className="flex items-baseline gap-1 mb-2">
<span className="text-3xl font-bold text-red-400">{Math.round(selectedEvent.risk * 100)}</span>
<span className="text-3xl font-bold text-red-600 dark:text-red-400">{Math.round(selectedEvent.risk * 100)}</span>
<span className="text-sm text-hint">/100</span>
</div>
<div className="h-2 bg-switch-background rounded-full overflow-hidden">
@ -360,29 +364,29 @@ export function LiveMapView() {
<Card className="bg-surface-overlay border-slate-700/40">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Zap className="w-4 h-4 text-blue-400" />
<Zap className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm text-heading font-medium">AI </span>
<Badge intent="critical" size="md">신뢰도: High</Badge>
</div>
<div className="space-y-3">
<div className="border-l-2 border-red-500 pl-3">
<div className="flex items-center gap-1.5 text-xs">
<AlertTriangle className="w-3 h-3 text-red-400" />
<span className="text-red-400 font-medium">{selectedEvent.type}</span>
<AlertTriangle className="w-3 h-3 text-red-600 dark:text-red-400" />
<span className="text-red-600 dark:text-red-400 font-medium">{selectedEvent.type}</span>
</div>
<div className="text-[10px] text-hint mt-0.5">: {selectedEvent.vesselName} ({selectedEvent.mmsi})</div>
</div>
<div className="border-l-2 border-orange-500 pl-3">
<div className="flex items-center gap-1.5 text-xs">
<Activity className="w-3 h-3 text-orange-400" />
<span className="text-orange-400 font-medium"> </span>
<Activity className="w-3 h-3 text-orange-600 dark:text-orange-400" />
<span className="text-orange-600 dark:text-orange-400 font-medium"> </span>
</div>
<div className="text-[10px] text-hint mt-0.5">: {selectedEvent.lat.toFixed(4)}, {selectedEvent.lng.toFixed(4)}</div>
</div>
<div className="border-l-2 border-green-500 pl-3">
<div className="flex items-center gap-1.5 text-xs">
<Clock className="w-3 h-3 text-green-400" />
<span className="text-green-400 font-medium"> </span>
<Clock className="w-3 h-3 text-green-600 dark:text-green-400" />
<span className="text-green-600 dark:text-green-400 font-medium"> </span>
</div>
<div className="text-[10px] text-hint mt-0.5">{selectedEvent.time}</div>
</div>

파일 보기

@ -124,7 +124,7 @@ const NTM_DATA: NtmRecord[] = [
const NTM_CATEGORIES = ['전체', '사격훈련', '군사훈련', '기뢰제거', '해양공사', '항로표지', '항로변경', '해양오염', '수중작업'];
const ntmColumns: DataColumn<NtmRecord>[] = [
{ key: 'no', label: '통보번호', width: '120px', sortable: true, render: v => <span className="text-cyan-400 font-mono font-bold text-[10px]">{v as string}</span> },
{ key: 'no', label: '통보번호', width: '120px', sortable: true, render: v => <span className="text-cyan-600 dark:text-cyan-400 font-mono font-bold text-[10px]">{v as string}</span> },
{ key: 'date', label: '발령일', width: '90px', sortable: true, render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
{ key: 'category', label: '구분', width: '70px', align: 'center', sortable: true,
render: v => {
@ -146,7 +146,7 @@ const ntmColumns: DataColumn<NtmRecord>[] = [
// 훈련구역 색상은 trainingZoneTypes 카탈로그에서 lookup
const columns: DataColumn<TrainingZone>[] = [
{ key: 'id', label: '구역번호', width: '80px', sortable: true, render: v => <span className="text-cyan-400 font-mono font-bold">{v as string}</span> },
{ key: 'id', label: '구역번호', width: '80px', sortable: true, render: v => <span className="text-cyan-600 dark:text-cyan-400 font-mono font-bold">{v as string}</span> },
{ key: 'type', label: '구분', width: '60px', align: 'center', sortable: true,
render: v => <Badge intent={getTrainingZoneIntent(v as string)} size="sm">{v as string}</Badge> },
{ key: 'sea', label: '해역', width: '60px', sortable: true },
@ -301,7 +301,7 @@ export function MapControl() {
{ key: 'ntm' as Tab, label: '항행통보', icon: Bell },
]).map(t => (
<button type="button" key={t.key} onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-4 py-2 text-[11px] font-medium border-b-2 ${tab === t.key ? 'text-cyan-400 border-cyan-400' : 'text-hint border-transparent hover:text-label'}`}>
className={`flex items-center gap-1.5 px-4 py-2 text-[11px] font-medium border-b-2 ${tab === t.key ? 'text-cyan-600 dark:text-cyan-400 border-cyan-500 dark:border-cyan-400' : 'text-hint border-transparent hover:text-label'}`}>
<t.icon className="w-3.5 h-3.5" />{t.label}
</button>
))}
@ -310,7 +310,7 @@ export function MapControl() {
<Filter className="w-3.5 h-3.5 text-hint" />
{['', '서해', '남해', '동해', '제주'].map(s => (
<button type="button" key={s} onClick={() => setSeaFilter(s)}
className={`px-2.5 py-1 rounded text-[10px] ${seaFilter === s ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>
className={`px-2.5 py-1 rounded text-[10px] ${seaFilter === s ? 'bg-cyan-600 hover:bg-cyan-500 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>
{s || '전체'}
</button>
))}
@ -324,9 +324,9 @@ export function MapControl() {
<div className="flex gap-2">
{[
{ label: '전체 통보', value: NTM_DATA.length, color: 'text-heading' },
{ label: '발령중', value: NTM_DATA.filter(n => n.status === '발령중').length, color: 'text-red-400' },
{ label: '발령중', value: NTM_DATA.filter(n => n.status === '발령중').length, color: 'text-red-600 dark:text-red-400' },
{ label: '해제', value: NTM_DATA.filter(n => n.status === '해제').length, color: 'text-muted-foreground' },
{ label: '사격훈련', value: NTM_DATA.filter(n => n.category.includes('사격')).length, color: 'text-orange-400' },
{ label: '사격훈련', value: NTM_DATA.filter(n => n.category.includes('사격')).length, color: 'text-orange-600 dark:text-orange-400' },
].map(k => (
<div key={k.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<span className={`text-base font-bold ${k.color}`}>{k.value}</span>
@ -341,14 +341,14 @@ export function MapControl() {
<span className="text-[10px] text-hint">:</span>
{NTM_CATEGORIES.map(c => (
<button type="button" key={c} onClick={() => setNtmCatFilter(c === '전체' ? '' : c)}
className={`px-2.5 py-1 rounded text-[10px] ${(c === '전체' && !ntmCatFilter) || ntmCatFilter === c ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>{c}</button>
className={`px-2.5 py-1 rounded text-[10px] ${(c === '전체' && !ntmCatFilter) || ntmCatFilter === c ? 'bg-cyan-600 hover:bg-cyan-500 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>{c}</button>
))}
</div>
{/* 최근 발령 중 통보 하이라이트 */}
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-heading mb-3 flex items-center gap-1.5">
<AlertTriangle className="w-4 h-4 text-red-400" />
<AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400" />
</div>
<div className="space-y-2">
{NTM_DATA.filter(n => n.status === '발령중').map(n => (
@ -411,7 +411,7 @@ export function MapControl() {
</div>
{/* 표시 구역 수 */}
<div className="absolute top-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
<span className="text-[10px] text-cyan-400 font-bold">{visibleZones.length}</span>
<span className="text-[10px] text-cyan-600 dark:text-cyan-400 font-bold">{visibleZones.length}</span>
<span className="text-[9px] text-hint ml-1"> </span>
</div>
</CardContent>

파일 보기

@ -201,11 +201,11 @@ export function VesselDetail() {
<h2 className="text-sm font-bold text-heading"> </h2>
<div className="flex items-center gap-1">
<span className="text-[9px] text-hint w-14 shrink-0">/</span>
<input aria-label="조회 시작 시각" value={startDate} onChange={(e) => setStartDate(e.target.value)}
<input aria-label={tc('aria.queryFrom')} value={startDate} onChange={(e) => setStartDate(e.target.value)}
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50"
placeholder="YYYY-MM-DD HH:mm" />
<span className="text-hint text-[10px]">~</span>
<input aria-label="조회 종료 시각" value={endDate} onChange={(e) => setEndDate(e.target.value)}
<input aria-label={tc('aria.queryTo')} value={endDate} onChange={(e) => setEndDate(e.target.value)}
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50"
placeholder="YYYY-MM-DD HH:mm" />
</div>

파일 보기

@ -161,7 +161,7 @@
{
"id": "api.vessel_analysis",
"label": "GET /api/vessel-analysis",
"shortDescription": "선박 분석 결과 (iran 프록시)",
"shortDescription": "선박 분석 결과 (레거시 경로, 신규는 /api/analysis/*)",
"stage": "API",
"kind": "api",
"trigger": "on_demand",

파일 보기

@ -1,13 +1,13 @@
[
{
"id": "external.iran_backend",
"label": "Iran 백엔드 (레거시)",
"shortDescription": "어구 그룹 read-only 프록시",
"label": "Iran 백엔드 (레거시·미사용)",
"shortDescription": "prediction 직접 연동으로 대체됨",
"stage": "외부",
"kind": "external",
"trigger": "on_demand",
"status": "partial",
"notes": "어구 그룹 read-only proxy (선택적, 향후 자체 prediction으로 대체 예정)"
"status": "deprecated",
"notes": "prediction 이 kcgaidb 에 직접 write 하므로 더 이상 호출하지 않는다. 1~2 릴리즈 후 노드 삭제 예정."
},
{
"id": "external.redis",

파일 보기

@ -30,7 +30,7 @@ export type NodeKind =
| 'api' // 백엔드 API 엔드포인트
| 'ui' // 프론트 화면
| 'decision' // 운영자 의사결정 액션
| 'external'; // 외부 시스템 (iran, GPKI 등)
| 'external'; // 외부 시스템 (GPKI 등)
export type NodeTrigger =
| 'scheduled' // 5분 주기 등 자동

파일 보기

@ -1,7 +1,7 @@
/**
* useGearReplayLayers
*
* iran useReplayLayer.ts :
* ( ):
*
* 1. animationStore rAF set({ currentTime }) (gearReplayStore)
* 2. zustand.subscribe(currentTime) renderFrame()
@ -9,10 +9,10 @@
* - seek/정지: 즉시
* 3. renderFrame() overlay.setProps({ layers })
*
* (iran KCG ):
* - PathLayer: 중심 (gold) iran의 PathLayer에
* - TripsLayer: 멤버 fade trail iran과
* - IconLayer: 멤버 () iran의
* :
* - PathLayer: 중심 (gold)
* - TripsLayer: 멤버 fade trail
* - IconLayer: 멤버 ()
* - PolygonLayer: 현재 ( / )
* - TextLayer: MMSI
*
@ -55,7 +55,7 @@ const SLATE: [number, number, number, number] = [148, 163, 184, 120];
const POLYGON_FILL: [number, number, number, number] = [245, 158, 11, 30];
const POLYGON_STROKE: [number, number, number, number] = [245, 158, 11, 120];
const RENDER_INTERVAL_MS = 100; // iran과 동일: ~10fps 쓰로틀
const RENDER_INTERVAL_MS = 100; // ~10fps 쓰로틀
function memberIconColor(m: MemberPosition): [number, number, number, number] {
if (m.stale) return SLATE;
@ -71,7 +71,7 @@ export function useGearReplayLayers(
buildBaseLayers: () => Layer[],
) {
const frameCursorRef = useRef(0);
// iran의 positionCursors — 멤버별 커서 (재생 시 O(1) 위치 탐색)
// positionCursors — 멤버별 커서 (재생 시 O(1) 위치 탐색)
const memberCursorsRef = useRef(new Map<string, number>());
// buildBaseLayers를 최신 참조로 유지
@ -79,7 +79,7 @@ export function useGearReplayLayers(
baseLayersRef.current = buildBaseLayers;
/**
* renderFrame iran의 renderFrame과 :
* renderFrame + + overlay.setProps :
* 1. ()
* 2.
* 3. overlay.setProps({ layers })
@ -106,7 +106,7 @@ export function useGearReplayLayers(
);
frameCursorRef.current = newCursor;
// 멤버 보간 — iran의 getCurrentVesselPositions 패턴:
// 멤버 보간 — getCurrentVesselPositions 패턴:
// 프레임 기반이 아닌 멤버별 개별 타임라인에서 보간 → 빈 구간도 연속 보간
const relativeTime = currentTime - startTime;
const members = interpolateFromTripsData(
@ -121,7 +121,7 @@ export function useGearReplayLayers(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const replayLayers: any[] = [];
// 1. TripsLayer — 멤버 궤적 fade trail (iran과 동일 패턴)
// 1. TripsLayer — 멤버 궤적 fade trail
// TripsLayer가 자체적으로 부드러운 궤적 애니메이션 처리
if (memberTripsData.length > 0) {
replayLayers.push(createTripsLayer(
@ -132,7 +132,7 @@ export function useGearReplayLayers(
));
}
// 4. 멤버 현재 위치 IconLayer (iran의 createVirtualShipLayers에 대응)
// 4. 멤버 현재 위치 IconLayer
const ships = members.filter(m => m.isParent);
const gears = members.filter(m => m.isGear);
const others = members.filter(m => !m.isParent && !m.isGear);
@ -330,13 +330,13 @@ export function useGearReplayLayers(
}
}
// iran 핵심: baseLayers + replayLayers 합쳐서 overlay.setProps() 직접 호출
// 핵심: baseLayers + replayLayers 합쳐서 overlay.setProps() 직접 호출
const baseLayers = baseLayersRef.current();
overlay.setProps({ layers: [...baseLayers, ...replayLayers] });
}, [overlayRef]);
/**
* currentTime iran useReplayLayer.ts:425~458
* currentTime + seek
*
* 핵심: 재생 pendingRafId로 rAF에
*
@ -354,12 +354,12 @@ export function useGearReplayLayers(
if (!useGearReplayStore.getState().groupKey) return;
const isPlaying = useGearReplayStore.getState().isPlaying;
// seek/정지: 즉시 렌더 (iran:437~439)
// seek/정지: 즉시 렌더
if (!isPlaying) {
renderFrame();
return;
}
// 재생 중: 쓰로틀 + pending rAF (iran:441~451)
// 재생 중: 쓰로틀 + pending rAF
const now = performance.now();
if (now - lastRenderTime >= RENDER_INTERVAL_MS) {
lastRenderTime = now;

파일 보기

@ -250,5 +250,84 @@
"statistics": "Statistics",
"aiOps": "AI Ops",
"admin": "Admin"
},
"aria": {
"close": "Close",
"closeDialog": "Close dialog",
"closeNotification": "Close notification",
"edit": "Edit",
"delete": "Delete",
"search": "Search",
"clearSearch": "Clear search",
"searchInPage": "Search in page",
"refresh": "Refresh",
"filter": "Filter",
"filterToggle": "Toggle filter",
"filterReset": "Reset filter",
"statusFilter": "Status filter",
"scopeFilter": "Scope filter",
"groupTypeFilter": "Group type filter",
"categoryFilter": "Category filter",
"regionFilter": "Region filter",
"previous": "Previous",
"next": "Next",
"send": "Send",
"confirmAction": "Confirm",
"dateFrom": "Start date",
"dateTo": "End date",
"queryFrom": "Query start time",
"queryTo": "Query end time",
"roleCode": "Role code",
"roleName": "Role name",
"roleDesc": "Role description",
"groupKey": "Group key",
"subClusterId": "Sub cluster ID",
"excludedMmsi": "Excluded MMSI",
"exclusionReason": "Exclusion reason",
"globalExclusionReason": "Global exclusion reason",
"correctParentMmsi": "Correct parent MMSI",
"uploadPanelClose": "Close upload panel",
"noticeTitle": "Notice title",
"noticeContent": "Notice content",
"languageToggle": "Language toggle",
"searchCode": "Search code",
"searchAreaOrZone": "Search area or zone",
"areaOfInterestSelect": "Select area of interest",
"replayPosition": "Replay position",
"replayClose": "Close replay",
"miniMapClose": "Close mini map",
"memberCountMin": "Min members",
"memberCountMax": "Max members",
"receiptDate": "Receipt reference date",
"copyExampleUrl": "Copy example URL",
"vesselDetail": "Vessel detail",
"enforcementRegister": "Register enforcement",
"falsePositiveProcess": "Mark false positive"
},
"error": {
"operationFailed": "Operation failed: {{msg}}",
"createFailed": "Create failed: {{msg}}",
"updateFailed": "Update failed: {{msg}}",
"deleteFailed": "Delete failed: {{msg}}",
"registerFailed": "Register failed: {{msg}}",
"processFailed": "Process failed: {{msg}}",
"errorPrefix": "Error: {{msg}}"
},
"dialog": {
"cancelSession": "Cancel this session?",
"deleteRole": "Delete this role?",
"genericDelete": "Delete?",
"genericRemove": "Remove?"
},
"success": {
"permissionUpdated": "Permissions updated",
"saved": "Saved"
},
"message": {
"noPermission": "No access permission",
"loading": "Loading...",
"builtinRoleCannotDelete": "Built-in role cannot be deleted",
"switchToEnglish": "Switch to English",
"switchToKorean": "Switch to Korean"
}
}

파일 보기

@ -250,5 +250,84 @@
"statistics": "통계·보고",
"aiOps": "AI 운영",
"admin": "시스템 관리"
},
"aria": {
"close": "닫기",
"closeDialog": "대화상자 닫기",
"closeNotification": "알림 닫기",
"edit": "편집",
"delete": "삭제",
"search": "검색",
"clearSearch": "검색어 지우기",
"searchInPage": "페이지 내 검색",
"refresh": "새로고침",
"filter": "필터",
"filterToggle": "필터 설정",
"filterReset": "필터 초기화",
"statusFilter": "상태 필터",
"scopeFilter": "스코프 필터",
"groupTypeFilter": "그룹 유형 필터",
"categoryFilter": "대분류 필터",
"regionFilter": "해역 필터",
"previous": "이전",
"next": "다음",
"send": "전송",
"confirmAction": "확인",
"dateFrom": "시작일",
"dateTo": "종료일",
"queryFrom": "조회 시작 시각",
"queryTo": "조회 종료 시각",
"roleCode": "역할 코드",
"roleName": "역할 이름",
"roleDesc": "역할 설명",
"groupKey": "그룹 키",
"subClusterId": "서브 클러스터 ID",
"excludedMmsi": "제외 MMSI",
"exclusionReason": "제외 사유",
"globalExclusionReason": "전역 제외 사유",
"correctParentMmsi": "정답 parent MMSI",
"uploadPanelClose": "업로드 패널 닫기",
"noticeTitle": "알림 제목",
"noticeContent": "알림 내용",
"languageToggle": "언어 전환",
"searchCode": "코드 검색",
"searchAreaOrZone": "해역 또는 해구 번호 검색",
"areaOfInterestSelect": "관심영역 선택",
"replayPosition": "재생 위치",
"replayClose": "재생 닫기",
"miniMapClose": "미니맵 닫기",
"memberCountMin": "최소 멤버 수",
"memberCountMax": "최대 멤버 수",
"receiptDate": "수신 현황 기준일",
"copyExampleUrl": "예시 URL 복사",
"vesselDetail": "선박 상세",
"enforcementRegister": "단속 등록",
"falsePositiveProcess": "오탐 처리"
},
"error": {
"operationFailed": "작업 실패: {{msg}}",
"createFailed": "생성 실패: {{msg}}",
"updateFailed": "갱신 실패: {{msg}}",
"deleteFailed": "삭제 실패: {{msg}}",
"registerFailed": "등록 실패: {{msg}}",
"processFailed": "처리 실패: {{msg}}",
"errorPrefix": "에러: {{msg}}"
},
"dialog": {
"cancelSession": "세션을 취소하시겠습니까?",
"deleteRole": "해당 역할을 삭제하시겠습니까?",
"genericDelete": "삭제하시겠습니까?",
"genericRemove": "제거하시겠습니까?"
},
"success": {
"permissionUpdated": "권한 갱신",
"saved": "저장되었습니다"
},
"message": {
"noPermission": "접근 권한이 없습니다",
"loading": "로딩 중...",
"builtinRoleCannotDelete": "내장 역할은 삭제할 수 없습니다",
"switchToEnglish": "Switch to English",
"switchToKorean": "한국어로 전환"
}
}

파일 보기

@ -1,7 +1,6 @@
/**
* API .
* - /리뷰: 자체 ( DB의 )
* - 향후: iran (HYBRID)
* - // + DB(gear_group_parent_resolution) .
*/
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';

파일 보기

@ -1,6 +1,6 @@
/**
* iran API.
* - () iran + HYBRID .
* prediction API ( proxy ).
* @/services/analysisApi /api/analysis/* .
*/
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';

파일 보기

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { X, AlertTriangle, Info, Bell, Megaphone } from 'lucide-react';
import { useTranslation } from 'react-i18next';
/*
* SFR-02 공통컴포넌트: 알림 /
@ -36,6 +37,7 @@ interface NotificationBannerProps {
}
export function NotificationBanner({ notices, userRole }: NotificationBannerProps) {
const { t } = useTranslation('common');
const [dismissed, setDismissed] = useState<Set<string>>(() => {
const stored = sessionStorage.getItem('dismissed_notices');
return new Set(stored ? JSON.parse(stored) : []);
@ -80,7 +82,7 @@ export function NotificationBanner({ notices, userRole }: NotificationBannerProp
{notice.dismissible && (
<button
type="button"
aria-label="알림 닫기"
aria-label={t('aria.closeNotification')}
onClick={() => dismiss(notice.id)}
className="text-hint hover:text-muted-foreground shrink-0"
>

파일 보기

@ -1,4 +1,5 @@
import { Search, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
/*
* SFR-02 공통컴포넌트: 검색
@ -11,22 +12,24 @@ interface SearchInputProps {
className?: string;
}
export function SearchInput({ value, onChange, placeholder = '검색...', className = '' }: SearchInputProps) {
export function SearchInput({ value, onChange, placeholder, className = '' }: SearchInputProps) {
const { t } = useTranslation('common');
const effectivePlaceholder = placeholder ?? `${t('action.search')}...`;
return (
<div className={`relative ${className}`}>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<input
type="text"
aria-label={placeholder}
aria-label={effectivePlaceholder}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
placeholder={effectivePlaceholder}
className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg pl-9 pr-8 py-2 text-[11px] text-label placeholder:text-hint focus:outline-none focus:border-blue-500/50"
/>
{value && (
<button
type="button"
aria-label="검색어 지우기"
aria-label={t('aria.clearSearch')}
onClick={() => onChange('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-hint hover:text-muted-foreground"
>

파일 보기

@ -123,3 +123,54 @@ export function getAlertLevelHex(level: string): string {
export function getAlertLevelIntent(level: string): BadgeIntent {
return getAlertLevelMeta(level)?.intent ?? 'muted';
}
/** 타입 가드 — 외부 문자열이 유효한 AlertLevel 코드인지 확인 */
export function isValidAlertLevel(value: unknown): value is AlertLevel {
return typeof value === 'string' && value in ALERT_LEVELS;
}
/** CRITICAL / HIGH 필터용 (상위 위험만 잡는 KPI·경보 집계에서 사용) */
export function isHighSeverity(level: string): boolean {
return level === 'CRITICAL' || level === 'HIGH';
}
/** 정렬용 우선순위 (CRITICAL 이 가장 앞, UNKNOWN 은 마지막) */
export function getAlertLevelOrder(level: string): number {
return getAlertLevelMeta(level)?.order ?? 999;
}
/** 지도 마커 불투명도 (CRITICAL 이 가장 선명) */
export const ALERT_LEVEL_MARKER_OPACITY: Record<AlertLevel, number> = {
CRITICAL: 0.94,
HIGH: 0.91,
MEDIUM: 0.88,
LOW: 0.85,
};
export function getAlertLevelMarkerOpacity(level: string): number {
return ALERT_LEVEL_MARKER_OPACITY[level as AlertLevel] ?? 0.85;
}
/** 지도 마커 반경 (미터) — 위험도가 높을수록 크게 */
export const ALERT_LEVEL_MARKER_RADIUS: Record<AlertLevel, number> = {
CRITICAL: 900,
HIGH: 750,
MEDIUM: 600,
LOW: 500,
};
export function getAlertLevelMarkerRadius(level: string): number {
return ALERT_LEVEL_MARKER_RADIUS[level as AlertLevel] ?? 500;
}
/** Tier(다크베셀 등) 점수 매핑 — 시각화용 숫자 가중치 */
export const ALERT_LEVEL_TIER_SCORE: Record<AlertLevel, number> = {
CRITICAL: 90,
HIGH: 60,
MEDIUM: 30,
LOW: 10,
};
export function getAlertLevelTierScore(level: string): number {
return ALERT_LEVEL_TIER_SCORE[level as AlertLevel] ?? 0;
}

파일 보기

@ -44,6 +44,7 @@ import { THREAT_LEVELS, AGENT_PERM_TYPES, AGENT_EXEC_RESULTS } from './aiSecurit
import { ZONE_CODES } from './zoneCodes';
import { GEAR_VIOLATION_CODES } from './gearViolationCodes';
import { VESSEL_TYPES } from './vesselTypes';
import { PERFORMANCE_STATUS_META } from './performanceStatus';
/**
* UI
@ -327,6 +328,15 @@ export const CATALOG_REGISTRY: CatalogEntry[] = [
source: 'prediction/algorithms/gear_violation.py',
items: GEAR_VIOLATION_CODES,
},
{
id: 'performance-status',
showcaseId: 'TRK-CAT-performance-status',
titleKo: '성능/시스템 상태',
titleEn: 'Performance / System Status',
description: 'good / normal / warning / critical / running / passed / failed / active / scheduled / archived',
source: 'admin 성능·데이터 보관·모델 검증 공통',
items: PERFORMANCE_STATUS_META,
},
];
/** ID로 특정 카탈로그 조회 */

파일 보기

@ -2,7 +2,6 @@
*
*
* API (history frames) deck.gl TripsLayer .
* iran KCG에 .
*/
// ── 타입 ──────────────────────────────────────────────────────────────
@ -317,7 +316,6 @@ export function buildMemberMetadata(
/**
* heading() .
* iran animationStore.ts의 calculateHeading과 .
*/
function calcHeading(p1: [number, number], p2: [number, number]): number {
const dx = p2[0] - p1[0];
@ -328,11 +326,11 @@ function calcHeading(p1: [number, number], p2: [number, number]): number {
}
/**
* iran의 getCurrentVesselPositions .
* .
*
* (frameA/frameB) (memberTripsData)
* 24 .
* (O(1)) + fallback (iran과 ).
* (O(1)) + fallback (seek ).
*
* @param memberTripsData (timestamps는 startTime )
* @param memberMeta (name, role, isParent)
@ -375,7 +373,7 @@ export function interpolateFromTripsData(
continue;
}
// 커서 기반 탐색 (iran positionCursors 패턴)
// 커서 기반 탐색 (positionCursors 패턴)
let cursor = cursors.get(mmsi) ?? 0;
if (
@ -406,7 +404,7 @@ export function interpolateFromTripsData(
const cog = idx1 > 0 ? calcHeading(path[idx1 - 1], path[idx1]) : 0;
positions.push({ ...base, lon: path[idx1][0], lat: path[idx1][1], cog });
} else {
// 선형 보간 (iran의 interpolatePosition과 동일)
// 선형 보간
const ratio = (relativeTimeMs - timestamps[idx1]) / (timestamps[idx2] - timestamps[idx1]);
const lon = path[idx1][0] + (path[idx2][0] - path[idx1][0]) * ratio;
const lat = path[idx1][1] + (path[idx2][1] - path[idx1][1]) * ratio;