Merge pull request 'fix: prediction e2e + 프론트 mock 전수 정리 + KST 통일' (#6) from feature/prediction-e2e-backend-fix into develop

This commit is contained in:
htlee 2026-04-07 15:37:07 +09:00
커밋 8dd86b692e
51개의 변경된 파일831개의 추가작업 그리고 805개의 파일을 삭제

파일 보기

@ -24,7 +24,7 @@ public class PredictionKpi {
@Column(name = "trend", length = 10)
private String trend;
@Column(name = "delta_pct", precision = 5, scale = 2)
@Column(name = "delta_pct", precision = 12, scale = 2)
private BigDecimal deltaPct;
@Column(name = "updated_at")

파일 보기

@ -57,7 +57,7 @@ public class PredictionStatsDaily {
@Column(name = "false_positive_count")
private Integer falsePositiveCount;
@Column(name = "ai_accuracy_pct", precision = 5, scale = 2)
@Column(name = "ai_accuracy_pct", precision = 12, scale = 2)
private BigDecimal aiAccuracyPct;
@Column(name = "updated_at")

파일 보기

@ -0,0 +1,43 @@
package gc.mda.kcg.domain.stats;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime;
import java.util.Map;
@Entity
@Table(name = "prediction_stats_hourly", schema = "kcg")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class PredictionStatsHourly {
@Id
@Column(name = "stat_hour")
private OffsetDateTime statHour;
@Column(name = "total_detections")
private Integer totalDetections;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "by_category", columnDefinition = "jsonb")
private Map<String, Object> byCategory;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "by_zone", columnDefinition = "jsonb")
private Map<String, Object> byZone;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "by_risk_level", columnDefinition = "jsonb")
private Map<String, Object> byRiskLevel;
@Column(name = "event_count")
private Integer eventCount;
@Column(name = "critical_count")
private Integer criticalCount;
@Column(name = "updated_at")
private OffsetDateTime updatedAt;
}

파일 보기

@ -0,0 +1,10 @@
package gc.mda.kcg.domain.stats;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.OffsetDateTime;
import java.util.List;
public interface PredictionStatsHourlyRepository extends JpaRepository<PredictionStatsHourly, OffsetDateTime> {
List<PredictionStatsHourly> findByStatHourBetweenOrderByStatHourAsc(OffsetDateTime from, OffsetDateTime to);
}

파일 보기

@ -54,7 +54,7 @@ public class PredictionStatsMonthly {
@Column(name = "false_positive_count")
private Integer falsePositiveCount;
@Column(name = "ai_accuracy_pct", precision = 5, scale = 2)
@Column(name = "ai_accuracy_pct", precision = 12, scale = 2)
private BigDecimal aiAccuracyPct;
@Column(name = "updated_at")

파일 보기

@ -6,6 +6,7 @@ import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.List;
/**
@ -20,6 +21,7 @@ public class StatsController {
private final PredictionKpiRepository kpiRepository;
private final PredictionStatsMonthlyRepository monthlyRepository;
private final PredictionStatsDailyRepository dailyRepository;
private final PredictionStatsHourlyRepository hourlyRepository;
/**
* 실시간 KPI 전체 목록 조회
@ -57,4 +59,18 @@ public class StatsController {
) {
return dailyRepository.findByStatDateBetweenOrderByStatDateAsc(from, to);
}
/**
* 시간별 통계 조회 (최근 N시간)
* @param hours 조회 시간 범위 (기본 24시간)
*/
@GetMapping("/hourly")
@RequirePermission(resource = "statistics", operation = "READ")
public List<PredictionStatsHourly> getHourly(
@RequestParam(defaultValue = "24") int hours
) {
OffsetDateTime to = OffsetDateTime.now();
OffsetDateTime from = to.minusHours(hours);
return hourlyRepository.findByStatHourBetweenOrderByStatHourAsc(from, to);
}
}

파일 보기

@ -0,0 +1,283 @@
-- V014: prediction에서 참조하는 누락 테이블 추가
-- fleet_vessels, fleet_tracking_snapshot, gear_identity_log,
-- gear_correlation_scores, gear_correlation_raw_metrics, correlation_param_models,
-- group_polygon_snapshots, gear_group_episodes, gear_group_episode_snapshots,
-- gear_group_parent_candidate_snapshots, gear_parent_label_tracking_cycles, system_config
-- ===== 1. fleet_vessels =====
CREATE TABLE IF NOT EXISTS kcg.fleet_vessels (
id BIGSERIAL PRIMARY KEY,
company_id BIGINT NOT NULL REFERENCES kcg.fleet_companies(id),
permit_no VARCHAR(50),
name_cn VARCHAR(100),
name_en VARCHAR(100),
tonnage NUMERIC(10,2),
gear_code VARCHAR(20),
fleet_role VARCHAR(20) DEFAULT 'CREW', -- MAIN, CREW, TRANSPORT, NOISE
pair_vessel_id BIGINT,
mmsi VARCHAR(20),
match_confidence NUMERIC(4,3),
match_method VARCHAR(30), -- NAME_EXACT, NAME_PARENT
last_seen_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_fleet_vessels_company ON kcg.fleet_vessels(company_id);
CREATE INDEX idx_fleet_vessels_mmsi ON kcg.fleet_vessels(mmsi);
CREATE INDEX idx_fleet_vessels_name_cn ON kcg.fleet_vessels(name_cn);
-- ===== 2. fleet_tracking_snapshot =====
CREATE TABLE IF NOT EXISTS kcg.fleet_tracking_snapshot (
id BIGSERIAL PRIMARY KEY,
company_id BIGINT NOT NULL REFERENCES kcg.fleet_companies(id),
snapshot_time TIMESTAMPTZ NOT NULL,
total_vessels INT NOT NULL DEFAULT 0,
active_vessels INT NOT NULL DEFAULT 0,
center_lat NUMERIC(9,6),
center_lon NUMERIC(10,6)
);
CREATE INDEX idx_fleet_snapshot_company ON kcg.fleet_tracking_snapshot(company_id, snapshot_time DESC);
-- ===== 3. gear_identity_log =====
CREATE TABLE IF NOT EXISTS kcg.gear_identity_log (
id BIGSERIAL PRIMARY KEY,
mmsi VARCHAR(20) NOT NULL,
name VARCHAR(200) NOT NULL,
parent_name VARCHAR(100),
parent_mmsi VARCHAR(20),
parent_vessel_id BIGINT,
gear_index_1 INT,
gear_index_2 INT,
lat NUMERIC(9,6),
lon NUMERIC(10,6),
match_method VARCHAR(30),
match_confidence NUMERIC(4,3),
first_seen_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE INDEX idx_gear_identity_mmsi ON kcg.gear_identity_log(mmsi, is_active);
CREATE INDEX idx_gear_identity_name ON kcg.gear_identity_log(name, is_active);
-- ===== 4. correlation_param_models =====
CREATE TABLE IF NOT EXISTS kcg.correlation_param_models (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
params JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 기본 모델 시드
INSERT INTO kcg.correlation_param_models (name, params, is_active, is_default) VALUES
('default', '{"ema_alpha": 0.3, "proximity_weight": 0.25, "visit_weight": 0.2, "activity_sync_weight": 0.15, "heading_weight": 0.15, "dtw_weight": 0.1, "speed_weight": 0.1, "drift_weight": 0.05}', TRUE, TRUE)
ON CONFLICT DO NOTHING;
-- ===== 5. gear_correlation_scores =====
CREATE TABLE IF NOT EXISTS kcg.gear_correlation_scores (
model_id BIGINT NOT NULL REFERENCES kcg.correlation_param_models(id),
group_key VARCHAR(255) NOT NULL,
sub_cluster_id INT NOT NULL,
target_mmsi VARCHAR(20) NOT NULL,
target_type VARCHAR(20), -- VESSEL, GEAR_BUOY
target_name VARCHAR(200),
current_score NUMERIC(6,4) DEFAULT 0,
proximity_ratio NUMERIC(6,4),
visit_score NUMERIC(6,4),
heading_coherence NUMERIC(6,4),
streak_count INT DEFAULT 0,
freeze_state VARCHAR(20) DEFAULT 'ACTIVE', -- ACTIVE, FREEZE, SIGNAL_LOSS
observation_count INT DEFAULT 0,
first_observed_at TIMESTAMPTZ,
last_observed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (model_id, group_key, sub_cluster_id, target_mmsi)
);
CREATE INDEX idx_corr_scores_group ON kcg.gear_correlation_scores(group_key, sub_cluster_id);
CREATE INDEX idx_corr_scores_target ON kcg.gear_correlation_scores(target_mmsi);
-- ===== 6. gear_correlation_raw_metrics =====
CREATE TABLE IF NOT EXISTS kcg.gear_correlation_raw_metrics (
id BIGSERIAL PRIMARY KEY,
observed_at TIMESTAMPTZ NOT NULL,
group_key VARCHAR(255) NOT NULL,
sub_cluster_id INT NOT NULL,
target_mmsi VARCHAR(20) NOT NULL,
target_type VARCHAR(20),
target_name VARCHAR(200),
proximity_ratio NUMERIC(6,4),
visit_score NUMERIC(6,4),
activity_sync NUMERIC(6,4),
dtw_similarity NUMERIC(6,4),
speed_correlation NUMERIC(6,4),
heading_coherence NUMERIC(6,4),
drift_similarity NUMERIC(6,4),
shadow_stay BOOLEAN DEFAULT FALSE,
shadow_return BOOLEAN DEFAULT FALSE,
gear_group_active_ratio NUMERIC(6,4)
);
CREATE INDEX idx_raw_metrics_observed ON kcg.gear_correlation_raw_metrics(observed_at);
CREATE INDEX idx_raw_metrics_group ON kcg.gear_correlation_raw_metrics(group_key, sub_cluster_id, observed_at);
-- ===== 7. group_polygon_snapshots =====
CREATE TABLE IF NOT EXISTS kcg.group_polygon_snapshots (
id BIGSERIAL PRIMARY KEY,
group_type VARCHAR(30) NOT NULL, -- FLEET, GEAR_IN_ZONE, GEAR_OUT_ZONE
group_key VARCHAR(255) NOT NULL,
group_label VARCHAR(255),
sub_cluster_id INT NOT NULL DEFAULT 0,
resolution VARCHAR(10) DEFAULT '6h', -- 6h, 1h
snapshot_time TIMESTAMPTZ NOT NULL,
polygon GEOMETRY(Polygon, 4326),
center_point GEOMETRY(Point, 4326),
area_sq_nm NUMERIC(12,4),
member_count INT DEFAULT 0,
zone_id VARCHAR(50),
zone_name VARCHAR(100),
members JSONB,
color VARCHAR(20)
);
CREATE INDEX idx_polygon_snap_group ON kcg.group_polygon_snapshots(group_key, sub_cluster_id, snapshot_time DESC);
CREATE INDEX idx_polygon_snap_time ON kcg.group_polygon_snapshots(snapshot_time);
CREATE INDEX idx_polygon_snap_geom ON kcg.group_polygon_snapshots USING GIST (polygon);
-- ===== 8. gear_group_episodes =====
CREATE TABLE IF NOT EXISTS kcg.gear_group_episodes (
episode_id VARCHAR(50) PRIMARY KEY, -- 'ep-{12hex}'
lineage_key VARCHAR(255) NOT NULL,
group_key VARCHAR(255) NOT NULL,
normalized_parent_name VARCHAR(100),
current_sub_cluster_id INT,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE, EXPIRED, MERGED
continuity_source VARCHAR(30), -- NEW, CONTINUED, SPLIT_NEW, SPLIT_CONTINUE, MERGE_NEW
continuity_score NUMERIC(6,4),
first_seen_at TIMESTAMPTZ,
last_seen_at TIMESTAMPTZ,
last_snapshot_time TIMESTAMPTZ,
current_member_count INT DEFAULT 0,
current_member_mmsis JSONB,
current_center_point GEOMETRY(Point, 4326),
split_from_episode_id VARCHAR(50),
merged_from_episode_ids JSONB,
merged_into_episode_id VARCHAR(50),
metadata JSONB,
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_episodes_lineage ON kcg.gear_group_episodes(lineage_key, status);
CREATE INDEX idx_episodes_group ON kcg.gear_group_episodes(group_key, status);
CREATE INDEX idx_episodes_snapshot ON kcg.gear_group_episodes(last_snapshot_time);
-- ===== 9. gear_group_episode_snapshots =====
CREATE TABLE IF NOT EXISTS kcg.gear_group_episode_snapshots (
episode_id VARCHAR(50) NOT NULL,
lineage_key VARCHAR(255),
group_key VARCHAR(255) NOT NULL,
normalized_parent_name VARCHAR(100),
sub_cluster_id INT NOT NULL,
observed_at TIMESTAMPTZ NOT NULL,
member_count INT DEFAULT 0,
member_mmsis JSONB,
center_point GEOMETRY(Point, 4326),
continuity_source VARCHAR(30),
continuity_score NUMERIC(6,4),
parent_episode_ids JSONB,
top_candidate_mmsi VARCHAR(20),
top_candidate_score NUMERIC(6,4),
resolution_status VARCHAR(30),
metadata JSONB,
PRIMARY KEY (episode_id, observed_at)
);
CREATE INDEX idx_ep_snap_group ON kcg.gear_group_episode_snapshots(group_key, sub_cluster_id, observed_at DESC);
-- ===== 10. gear_group_parent_candidate_snapshots =====
CREATE TABLE IF NOT EXISTS kcg.gear_group_parent_candidate_snapshots (
id BIGSERIAL PRIMARY KEY,
observed_at TIMESTAMPTZ NOT NULL,
group_key VARCHAR(255) NOT NULL,
sub_cluster_id INT NOT NULL,
parent_name VARCHAR(100),
normalized_parent_name VARCHAR(100),
episode_id VARCHAR(50),
candidate_mmsi VARCHAR(20) NOT NULL,
candidate_name VARCHAR(200),
candidate_vessel_id BIGINT,
rank INT,
candidate_source VARCHAR(30),
model_id BIGINT,
model_name VARCHAR(100),
base_corr_score NUMERIC(6,4),
name_match_score NUMERIC(6,4),
track_similarity_score NUMERIC(6,4),
visit_score_6h NUMERIC(6,4),
proximity_score_6h NUMERIC(6,4),
activity_sync_score_6h NUMERIC(6,4),
stability_score NUMERIC(6,4),
registry_bonus NUMERIC(6,4),
episode_prior_bonus NUMERIC(6,4),
lineage_prior_bonus NUMERIC(6,4),
label_prior_bonus NUMERIC(6,4),
final_score NUMERIC(6,4),
margin_from_top NUMERIC(6,4),
evidence JSONB
);
CREATE INDEX idx_candidate_snap_group ON kcg.gear_group_parent_candidate_snapshots(group_key, sub_cluster_id, observed_at DESC);
CREATE INDEX idx_candidate_snap_episode ON kcg.gear_group_parent_candidate_snapshots(episode_id, observed_at DESC);
CREATE INDEX idx_candidate_snap_norm ON kcg.gear_group_parent_candidate_snapshots(normalized_parent_name);
-- ===== 11. gear_parent_label_tracking_cycles =====
CREATE TABLE IF NOT EXISTS kcg.gear_parent_label_tracking_cycles (
label_session_id BIGINT NOT NULL REFERENCES kcg.gear_parent_label_sessions(id),
observed_at TIMESTAMPTZ NOT NULL,
candidate_snapshot_observed_at TIMESTAMPTZ,
auto_status VARCHAR(30),
top_candidate_mmsi VARCHAR(20),
top_candidate_name VARCHAR(200),
top_candidate_score NUMERIC(6,4),
top_candidate_margin NUMERIC(6,4),
candidate_count INT,
labeled_candidate_present BOOLEAN,
labeled_candidate_rank INT,
labeled_candidate_score NUMERIC(6,4),
labeled_candidate_pre_bonus_score NUMERIC(6,4),
labeled_candidate_margin_from_top NUMERIC(6,4),
matched_top1 BOOLEAN,
matched_top3 BOOLEAN,
evidence_summary JSONB,
PRIMARY KEY (label_session_id, observed_at)
);
-- ===== 12. system_config =====
CREATE TABLE IF NOT EXISTS kcg.system_config (
key VARCHAR(100) PRIMARY KEY,
value JSONB,
description TEXT,
updated_at TIMESTAMPTZ DEFAULT now()
);
-- 파티션 기본값 시드
INSERT INTO kcg.system_config (key, value, description) VALUES
('partition.raw_metrics.retention_days', '7', '원시 메트릭 보관 일수'),
('partition.raw_metrics.create_ahead_days', '3', '파티션 사전 생성 일수'),
('partition.scores.cleanup_days', '30', '점수 정리 일수')
ON CONFLICT (key) DO NOTHING;
-- ===== fleet_vessels 시드 데이터 (fleet_companies 기반) =====
-- 중국 원양어업 회사 소속 선박 (데모용)
INSERT INTO kcg.fleet_vessels (company_id, permit_no, name_cn, name_en, tonnage, gear_code, fleet_role) VALUES
(1, 'ZY-2024-001', '鲁荣渔2682', 'LU RONG YU 2682', 450.0, 'TRAWL', 'MAIN'),
(1, 'ZY-2024-002', '鲁荣渔2683', 'LU RONG YU 2683', 380.0, 'TRAWL', 'CREW'),
(1, 'ZY-2024-003', '鲁荣渔2680', 'LU RONG YU 2680', 520.0, 'PURSE', 'MAIN'),
(2, 'ZY-2024-010', '浙岱渔11032', 'ZHE DAI YU 11032', 600.0, 'PURSE', 'MAIN'),
(2, 'ZY-2024-011', '浙岱渔11033', 'ZHE DAI YU 11033', 550.0, 'PURSE', 'CREW'),
(2, 'ZY-2024-012', '浙岱渔运108', 'ZHE DAI YU YUN 108', 800.0, 'TRANSPORT', 'TRANSPORT')
ON CONFLICT DO NOTHING;

파일 보기

@ -0,0 +1,80 @@
-- V015: 점수/비율 NUMERIC 컬럼 precision 일괄 확대 + vessel_analysis_results UNIQUE 제약
-- === 1. vessel_analysis_results 점수 컬럼 ===
ALTER TABLE kcg.vessel_analysis_results ALTER COLUMN confidence TYPE NUMERIC(7,4);
ALTER TABLE kcg.vessel_analysis_results ALTER COLUMN fishing_pct TYPE NUMERIC(7,4);
ALTER TABLE kcg.vessel_analysis_results ALTER COLUMN ucaf_score TYPE NUMERIC(7,4);
ALTER TABLE kcg.vessel_analysis_results ALTER COLUMN ucft_score TYPE NUMERIC(7,4);
ALTER TABLE kcg.vessel_analysis_results ALTER COLUMN spoofing_score TYPE NUMERIC(7,4);
-- ON CONFLICT용 UNIQUE 제약 (파티션 테이블은 PK가 있지만 ON CONFLICT 절에서 인식 안 됨)
-- 파티션 PK (id, analyzed_at)와 별도로 mmsi+analyzed_at UNIQUE 추가
CREATE UNIQUE INDEX IF NOT EXISTS idx_var_mmsi_analyzed ON kcg.vessel_analysis_results (mmsi, analyzed_at);
-- === 2. prediction_events/alerts ai_confidence ===
ALTER TABLE kcg.prediction_events ALTER COLUMN ai_confidence TYPE NUMERIC(7,4);
ALTER TABLE kcg.prediction_alerts ALTER COLUMN ai_confidence TYPE NUMERIC(7,4);
ALTER TABLE kcg.enforcement_records ALTER COLUMN ai_confidence TYPE NUMERIC(7,4);
ALTER TABLE kcg.prediction_label_input ALTER COLUMN confidence TYPE NUMERIC(7,4);
-- === 3. AI 모델 메트릭 ===
ALTER TABLE kcg.ai_model_versions ALTER COLUMN accuracy_pct TYPE NUMERIC(7,2);
ALTER TABLE kcg.ai_model_versions ALTER COLUMN precision_pct TYPE NUMERIC(7,2);
ALTER TABLE kcg.ai_model_versions ALTER COLUMN recall_pct TYPE NUMERIC(7,2);
ALTER TABLE kcg.ai_model_versions ALTER COLUMN f1_score TYPE NUMERIC(7,4);
-- === 4. gear 관련 score 컬럼 NUMERIC(6,4) → NUMERIC(7,4) ===
-- gear_correlation_scores
ALTER TABLE kcg.gear_correlation_scores ALTER COLUMN current_score TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_correlation_scores ALTER COLUMN proximity_ratio TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_correlation_scores ALTER COLUMN visit_score TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_correlation_scores ALTER COLUMN heading_coherence TYPE NUMERIC(7,4);
-- gear_correlation_raw_metrics
ALTER TABLE kcg.gear_correlation_raw_metrics ALTER COLUMN proximity_ratio TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_correlation_raw_metrics ALTER COLUMN visit_score TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_correlation_raw_metrics ALTER COLUMN activity_sync TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_correlation_raw_metrics ALTER COLUMN dtw_similarity TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_correlation_raw_metrics ALTER COLUMN speed_correlation TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_correlation_raw_metrics ALTER COLUMN heading_coherence TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_correlation_raw_metrics ALTER COLUMN drift_similarity TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_correlation_raw_metrics ALTER COLUMN gear_group_active_ratio TYPE NUMERIC(7,4);
-- gear_group_episodes
ALTER TABLE kcg.gear_group_episodes ALTER COLUMN continuity_score TYPE NUMERIC(7,4);
-- gear_group_episode_snapshots
ALTER TABLE kcg.gear_group_episode_snapshots ALTER COLUMN continuity_score TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_group_episode_snapshots ALTER COLUMN top_candidate_score TYPE NUMERIC(7,4);
-- gear_group_parent_candidate_snapshots (13개 score 컬럼)
ALTER TABLE kcg.gear_group_parent_candidate_snapshots ALTER COLUMN base_corr_score TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_group_parent_candidate_snapshots ALTER COLUMN name_match_score TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_group_parent_candidate_snapshots ALTER COLUMN track_similarity_score TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_group_parent_candidate_snapshots ALTER COLUMN visit_score_6h TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_group_parent_candidate_snapshots ALTER COLUMN proximity_score_6h TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_group_parent_candidate_snapshots ALTER COLUMN activity_sync_score_6h TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_group_parent_candidate_snapshots ALTER COLUMN stability_score TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_group_parent_candidate_snapshots ALTER COLUMN registry_bonus TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_group_parent_candidate_snapshots ALTER COLUMN episode_prior_bonus TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_group_parent_candidate_snapshots ALTER COLUMN lineage_prior_bonus TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_group_parent_candidate_snapshots ALTER COLUMN label_prior_bonus TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_group_parent_candidate_snapshots ALTER COLUMN final_score TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_group_parent_candidate_snapshots ALTER COLUMN margin_from_top TYPE NUMERIC(7,4);
-- gear_parent_label_tracking_cycles
ALTER TABLE kcg.gear_parent_label_tracking_cycles ALTER COLUMN top_candidate_score TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_parent_label_tracking_cycles ALTER COLUMN top_candidate_margin TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_parent_label_tracking_cycles ALTER COLUMN labeled_candidate_score TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_parent_label_tracking_cycles ALTER COLUMN labeled_candidate_pre_bonus_score TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_parent_label_tracking_cycles ALTER COLUMN labeled_candidate_margin_from_top TYPE NUMERIC(7,4);
-- === 5. match_confidence NUMERIC(4,3) → NUMERIC(7,4) ===
ALTER TABLE kcg.fleet_vessels ALTER COLUMN match_confidence TYPE NUMERIC(7,4);
ALTER TABLE kcg.gear_identity_log ALTER COLUMN match_confidence TYPE NUMERIC(7,4);
-- === 6. stats/kpi 테이블 ===
ALTER TABLE kcg.prediction_stats_daily ALTER COLUMN ai_accuracy_pct TYPE NUMERIC(12,2);
ALTER TABLE kcg.prediction_stats_monthly ALTER COLUMN ai_accuracy_pct TYPE NUMERIC(12,2);
ALTER TABLE kcg.prediction_kpi_realtime ALTER COLUMN delta_pct TYPE NUMERIC(12,2);
ALTER TABLE kcg.prediction_risk_grid ALTER COLUMN avg_risk TYPE NUMERIC(12,2);

파일 보기

@ -1,48 +0,0 @@
/**
* @deprecated EnforcementHistory는 API로 .
* EnforcementPlan.tsx가 MOCK_ENFORCEMENT_PLANS를 .
*/
/** @deprecated services/enforcement.ts의 EnforcementRecord 사용 권장 */
export interface EnforcementRecord {
id: string;
date: string;
zone: string;
vessel: string;
violation: string;
action: string;
aiMatch: string;
result: string;
}
export interface EnforcementPlanRecord {
id: string;
zone: string;
lat: number;
lng: number;
risk: number;
period: string;
ships: string;
crew: number;
status: string;
alert: string;
}
/** Enforcement history (6 records) — src/features/enforcement/EnforcementHistory.tsx */
export const MOCK_ENFORCEMENT_RECORDS: EnforcementRecord[] = [
{ id: 'ENF-001', date: '2026-04-03 08:47', zone: 'EEZ 북부', vessel: '鲁荣渔56555', violation: 'EEZ 침범', action: '나포', aiMatch: '일치', result: '처벌' },
{ id: 'ENF-002', date: '2026-04-03 07:23', zone: '서해 NLL', vessel: '津塘渔03966', violation: '무허가 조업', action: '검문·경고', aiMatch: '일치', result: '경고' },
{ id: 'ENF-003', date: '2026-04-02 22:15', zone: '서해 5도', vessel: '浙岱渔02856 외 7척', violation: '선단 침범', action: '퇴거 조치', aiMatch: '일치', result: '퇴거' },
{ id: 'ENF-004', date: '2026-04-02 14:30', zone: 'EEZ 서부', vessel: '冀黄港渔05001', violation: '불법환적', action: '증거 수집', aiMatch: '일치', result: '수사 의뢰' },
{ id: 'ENF-005', date: '2026-04-01 09:00', zone: '남해 연안', vessel: '한국어선-03', violation: '조업구역 이탈', action: '검문', aiMatch: '불일치', result: '오탐(정상)' },
{ id: 'ENF-006', date: '2026-03-30 16:40', zone: '동해 EEZ', vessel: '鲁荣渔51277', violation: '고속 도주', action: '추적·나포', aiMatch: '일치', result: '처벌' },
];
/** Enforcement plans (5 plans) — src/features/risk-assessment/EnforcementPlan.tsx */
export const MOCK_ENFORCEMENT_PLANS: EnforcementPlanRecord[] = [
{ id: 'EP-001', zone: '서해 NLL', lat: 37.80, lng: 124.90, risk: 92, period: '04-04 00:00~06:00', ships: '3001함, 3005함', crew: 48, status: '확정', alert: '경보 발령' },
{ id: 'EP-002', zone: 'EEZ 북부', lat: 37.20, lng: 124.63, risk: 78, period: '04-04 06:00~12:00', ships: '3009함', crew: 24, status: '확정', alert: '주의' },
{ id: 'EP-003', zone: '서해 5도', lat: 37.50, lng: 124.60, risk: 72, period: '04-04 12:00~18:00', ships: '서특단 1정', crew: 18, status: '계획중', alert: '주의' },
{ id: 'EP-004', zone: 'EEZ 서부', lat: 36.00, lng: 123.80, risk: 65, period: '04-05 00:00~06:00', ships: '3001함', crew: 24, status: '계획중', alert: '-' },
{ id: 'EP-005', zone: '남해 외해', lat: 34.20, lng: 127.50, risk: 45, period: '04-05 06:00~12:00', ships: '미정', crew: 0, status: '검토중', alert: '-' },
];

파일 보기

@ -1,290 +0,0 @@
/**
* @deprecated EventList, Dashboard, MonitoringDashboard는 API로 .
* AIAlert, MobileService가 AlertRecord mock을 .
*
* Shared mock data: events & alerts
*
* Sources:
* - AIAlert.tsx DATA (5 alerts) mock
* - MobileService.tsx ALERTS (3) mock
*/
// ────────────────────────────────────────────
// Event record (EventList.tsx as primary, supplemented with Dashboard titles/details)
// ────────────────────────────────────────────
export interface EventRecord {
id: string;
time: string;
level: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
type: string;
title: string;
detail: string;
vesselName?: string;
mmsi?: string;
area?: string;
lat?: number;
lng?: number;
speed?: number;
status?: string;
assignee?: string;
}
export const MOCK_EVENTS: EventRecord[] = [
{
id: 'EVT-0001',
time: '2026-04-03 08:47:12',
level: 'CRITICAL',
type: 'EEZ 침범',
title: 'EEZ 침범 탐지',
detail: '鲁荣渔56555 외 2척 — N37°12\' E124°38\' 진입',
vesselName: '鲁荣渔56555',
mmsi: '412345678',
area: 'EEZ 북부',
lat: 37.2012,
lng: 124.6345,
speed: 8.2,
status: '추적 중',
assignee: '3001함',
},
{
id: 'EVT-0002',
time: '2026-04-03 08:32:05',
level: 'HIGH',
type: '다크베셀',
title: '다크베셀 출현',
detail: 'MMSI 미상 선박 3척 — 서해 NLL 인근 AIS 소실',
vesselName: '미상선박-A',
mmsi: '미상',
area: '서해 NLL',
lat: 37.7512,
lng: 125.0234,
speed: 6.1,
status: '감시 중',
assignee: '상황실',
},
{
id: 'EVT-0003',
time: '2026-04-03 08:15:33',
level: 'CRITICAL',
type: '선단밀집',
title: '선단 밀집 경보',
detail: '중국어선 14척 밀집 — N36°48\' E124°22\' 반경 2nm',
vesselName: '선단(14척)',
mmsi: '다수',
area: 'EEZ 서부',
lat: 36.8001,
lng: 124.3678,
speed: 4.5,
status: '경보 발령',
assignee: '서해청',
},
{
id: 'EVT-0004',
time: '2026-04-03 07:58:44',
level: 'MEDIUM',
type: '불법환적',
title: '불법환적 의심',
detail: '冀黄港渔05001 + 운반선 접현 30분 이상',
vesselName: '冀黄港渔05001',
mmsi: '412987654',
area: '서해 중부',
lat: 36.4789,
lng: 124.2234,
speed: 0.3,
status: '확인 중',
assignee: '분석팀',
},
{
id: 'EVT-0005',
time: '2026-04-03 07:41:18',
level: 'HIGH',
type: 'MMSI 변조',
title: 'MMSI 변조 탐지',
detail: '浙甬渔60651 — MMSI 3회 변경 이력 감지',
vesselName: '浙甬渔60651',
mmsi: '412111222',
area: 'EEZ 남부',
lat: 35.8678,
lng: 125.5012,
speed: 5.8,
status: '감시 중',
assignee: '상황실',
},
{
id: 'EVT-0006',
time: '2026-04-03 07:23:01',
level: 'LOW',
type: '검문 완료',
title: '함정 검문 완료',
detail: '3009함 — 津塘渔03966 검문 완료, 경고 조치',
vesselName: '津塘渔03966',
mmsi: '412333444',
area: '서해 북부',
lat: 37.5012,
lng: 124.7890,
speed: 0,
status: '완료',
assignee: '3009함',
},
{
id: 'EVT-0007',
time: '2026-04-03 07:05:55',
level: 'MEDIUM',
type: 'AIS 재송출',
title: 'AIS 재송출',
detail: '辽庄渔55567 — 4시간 소실 후 재송출',
vesselName: '辽庄渔55567',
mmsi: '412555666',
area: 'EEZ 북부',
lat: 37.3456,
lng: 124.8901,
speed: 3.2,
status: '확인 완료',
assignee: '상황실',
},
{
id: 'EVT-0008',
time: '2026-04-03 06:48:22',
level: 'CRITICAL',
type: 'EEZ 침범',
title: '긴급 침범 경보',
detail: '浙岱渔02856 외 7척 — 서해 5도 수역 진입',
vesselName: '浙岱渔02856',
mmsi: '412777888',
area: '서해 5도',
lat: 37.0567,
lng: 124.9234,
speed: 4.5,
status: '추적 중',
assignee: '서특단',
},
{
id: 'EVT-0009',
time: '2026-04-03 06:30:00',
level: 'LOW',
type: '정기 보고',
title: '정기 보고',
detail: '전 해역 야간 감시 결과 보고 완료',
vesselName: undefined,
mmsi: undefined,
area: '전 해역',
status: '완료',
assignee: '상황실',
},
{
id: 'EVT-0010',
time: '2026-04-03 06:12:33',
level: 'HIGH',
type: '속력 이상',
title: '속력 이상 탐지',
detail: '鲁荣渔51277 — 18kt 고속 이동, 도주 패턴',
vesselName: '鲁荣渔51277',
mmsi: '412999000',
area: '동해 중부',
lat: 36.2512,
lng: 130.0890,
speed: 18.1,
status: '추적 중',
assignee: '동해청',
},
{
id: 'EVT-0011',
time: '2026-04-03 05:45:10',
level: 'MEDIUM',
type: 'AIS 소실',
title: 'AIS 소실',
detail: '浙甬渔30112 남해 외해 AIS 소실',
vesselName: '浙甬渔30112',
mmsi: '412444555',
area: '남해 외해',
lat: 34.1234,
lng: 128.5678,
status: '감시 중',
assignee: '남해청',
},
{
id: 'EVT-0012',
time: '2026-04-03 05:20:48',
level: 'HIGH',
type: '불법환적',
title: '불법환적 의심',
detail: '冀黄港渔03012 EEZ 서부 환적 의심',
vesselName: '冀黄港渔03012',
mmsi: '412666777',
area: 'EEZ 서부',
lat: 36.5678,
lng: 124.1234,
speed: 0.5,
status: '확인 중',
assignee: '분석팀',
},
{
id: 'EVT-0013',
time: '2026-04-03 04:55:30',
level: 'LOW',
type: '구역 이탈',
title: '구역 이탈',
detail: '한국어선-12 연안 구역 이탈 경고',
vesselName: '한국어선-12',
mmsi: '440123456',
area: '연안 구역',
lat: 35.4567,
lng: 129.3456,
speed: 7.0,
status: '경고 완료',
assignee: '포항서',
},
{
id: 'EVT-0014',
time: '2026-04-03 04:30:15',
level: 'CRITICAL',
type: 'EEZ 침범',
title: 'EEZ 침범 — 나포 작전',
detail: '鲁威渔15028 EEZ 북부 나포 작전 진행',
vesselName: '鲁威渔15028',
mmsi: '412888999',
area: 'EEZ 북부',
lat: 37.4012,
lng: 124.5567,
speed: 6.9,
status: '나포 작전',
assignee: '3001함',
},
{
id: 'EVT-0015',
time: '2026-04-03 04:10:00',
level: 'MEDIUM',
type: 'MMSI 변조',
title: 'MMSI 변조 의심',
detail: '浙甬渔99871 남해 연안 MMSI 변조 의심',
vesselName: '浙甬渔99871',
mmsi: '412222333',
area: '남해 연안',
lat: 34.5678,
lng: 127.8901,
speed: 4.2,
status: '확인 중',
assignee: '상황실',
},
];
// ────────────────────────────────────────────
// Alert records (AIAlert.tsx as primary)
// ────────────────────────────────────────────
export interface AlertRecord {
id: string;
time: string;
type: string;
location: string;
confidence: number;
target: string;
status: string;
}
export const MOCK_ALERTS: AlertRecord[] = [
{ id: 'ALR-001', time: '08:47:12', type: 'EEZ 침범', location: 'N37.20 E124.63', confidence: 96, target: '3001함, 상황실', status: '수신확인' },
{ id: 'ALR-002', time: '08:32:05', type: '다크베셀', location: 'N37.75 E125.02', confidence: 91, target: '상황실', status: '수신확인' },
{ id: 'ALR-003', time: '08:15:33', type: '선단밀집', location: 'N36.80 E124.37', confidence: 88, target: '서특단, 상황실', status: '발송완료' },
{ id: 'ALR-004', time: '07:58:44', type: '불법환적', location: 'N36.48 E124.22', confidence: 82, target: '3005함', status: '수신확인' },
{ id: 'ALR-005', time: '07:41:18', type: 'MMSI변조', location: 'N35.87 E125.50', confidence: 94, target: '상황실', status: '미수신' },
];

파일 보기

@ -1,48 +0,0 @@
export interface TransferRecord {
id: string;
time: string;
vesselA: { name: string; mmsi: string };
vesselB: { name: string; mmsi: string };
distance: number;
duration: number;
speed: number;
score: number;
location: string;
}
/** Transfer detection data (3 records) — shared by TransferDetection.tsx & ChinaFishing.tsx */
export const MOCK_TRANSFERS: TransferRecord[] = [
{
id: 'TR-001',
time: '2026-01-20 13:42:11',
vesselA: { name: '장저우8호', mmsi: '412345680' },
vesselB: { name: '黑江9호', mmsi: '412345690' },
distance: 45,
duration: 52,
speed: 2.3,
score: 89,
location: '서해 중부',
},
{
id: 'TR-002',
time: '2026-01-20 11:15:33',
vesselA: { name: '江苏如东號', mmsi: '412345683' },
vesselB: { name: '산동위해호', mmsi: '412345691' },
distance: 38,
duration: 67,
speed: 1.8,
score: 92,
location: '서해 북부',
},
{
id: 'TR-003',
time: '2026-01-20 09:23:45',
vesselA: { name: '辽宁大连號', mmsi: '412345682' },
vesselB: { name: '무명선박-D', mmsi: '412345692' },
distance: 62,
duration: 41,
speed: 2.7,
score: 78,
location: 'EEZ 북부',
},
];

파일 보기

@ -17,6 +17,7 @@ import {
type AuditLog as ApiAuditLog,
type AuditStats,
} from '@/services/adminApi';
import { formatDateTime } from '@shared/utils/dateFormat';
import { PermissionsPanel } from './PermissionsPanel';
import { UserRoleAssignDialog } from './UserRoleAssignDialog';
@ -153,7 +154,7 @@ export function AccessControl() {
{ key: 'lastLoginDtm', label: '최종 로그인', width: '140px', sortable: true,
render: (v) => (
<span className="text-muted-foreground font-mono text-[10px]">
{v ? new Date(v as string).toLocaleString('ko-KR') : '-'}
{formatDateTime(v as string)}
</span>
),
},
@ -178,7 +179,7 @@ export function AccessControl() {
// ── 감사 로그 컬럼 ──────────────
const auditColumns: DataColumn<ApiAuditLog & Record<string, unknown>>[] = useMemo(() => [
{ key: 'createdAt', label: '일시', width: '160px', sortable: true,
render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{new Date(v as string).toLocaleString('ko-KR')}</span> },
render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{formatDateTime(v as string)}</span> },
{ key: 'userAcnt', label: '사용자', width: '90px', sortable: true,
render: (v) => <span className="text-cyan-400 font-mono">{(v as string) || '-'}</span> },
{ key: 'actionCd', label: '액션', width: '180px', sortable: true,

파일 보기

@ -3,6 +3,7 @@ import { Loader2, RefreshCw } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { fetchAccessLogs, fetchAccessStats, type AccessLog, type AccessStats } from '@/services/adminApi';
import { formatDateTime } from '@shared/utils/dateFormat';
/**
* + .
@ -107,7 +108,7 @@ export function AccessLogs() {
{items.map((it) => (
<tr key={it.accessSn} className="border-t border-border hover:bg-surface-overlay/50">
<td className="px-3 py-2 text-hint font-mono">{it.accessSn}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{new Date(it.createdAt).toLocaleString('ko-KR')}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.createdAt)}</td>
<td className="px-3 py-2 text-cyan-400">{it.userAcnt || '-'}</td>
<td className="px-3 py-2 text-purple-400 font-mono">{it.httpMethod}</td>
<td className="px-3 py-2 text-heading font-mono text-[10px] max-w-md truncate">{it.requestPath}</td>

파일 보기

@ -35,6 +35,9 @@ export function AdminPanel() {
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Settings className="w-5 h-5 text-muted-foreground" />
{t('adminPanel.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span><span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('adminPanel.desc')}</p>
</div>

파일 보기

@ -3,6 +3,7 @@ import { Loader2, RefreshCw } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { fetchAuditLogs, fetchAuditStats, type AuditLog, type AuditStats } from '@/services/adminApi';
import { formatDateTime } from '@shared/utils/dateFormat';
/**
* + .
@ -93,7 +94,7 @@ export function AuditLogs() {
{items.map((it) => (
<tr key={it.auditSn} className="border-t border-border hover:bg-surface-overlay/50">
<td className="px-3 py-2 text-hint font-mono">{it.auditSn}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{new Date(it.createdAt).toLocaleString('ko-KR')}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.createdAt)}</td>
<td className="px-3 py-2 text-cyan-400">{it.userAcnt || '-'}</td>
<td className="px-3 py-2 text-heading font-medium">{it.actionCd}</td>
<td className="px-3 py-2 text-muted-foreground">{it.resourceType ?? '-'} {it.resourceId ? `(${it.resourceId})` : ''}</td>

파일 보기

@ -390,6 +390,9 @@ export function DataHub() {
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Database className="w-5 h-5 text-cyan-400" />
{t('dataHub.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span><span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5">
{t('dataHub.desc')}

파일 보기

@ -3,6 +3,7 @@ import { Loader2, RefreshCw } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { fetchLoginHistory, fetchLoginStats, type LoginHistory, type LoginStats } from '@/services/adminApi';
import { formatDateTime, formatDate } from '@shared/utils/dateFormat';
/**
* + .
@ -80,7 +81,7 @@ export function LoginHistoryView() {
<CardContent className="px-4 pb-4 space-y-1">
{stats.daily7d.map((d) => (
<div key={d.day} className="flex items-center justify-between text-[11px]">
<span className="text-muted-foreground font-mono">{new Date(d.day).toLocaleDateString('ko-KR')}</span>
<span className="text-muted-foreground font-mono">{formatDate(d.day)}</span>
<div className="flex gap-3">
<span className="text-green-400"> {d.success}</span>
<span className="text-orange-400"> {d.failed}</span>
@ -118,7 +119,7 @@ export function LoginHistoryView() {
{items.map((it) => (
<tr key={it.histSn} className="border-t border-border hover:bg-surface-overlay/50">
<td className="px-3 py-2 text-hint font-mono">{it.histSn}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{new Date(it.loginDtm).toLocaleString('ko-KR')}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.loginDtm)}</td>
<td className="px-3 py-2 text-cyan-400">{it.userAcnt}</td>
<td className="px-3 py-2">
<Badge className={`border-0 text-[9px] ${resultColor(it.result)}`}>{it.result}</Badge>

파일 보기

@ -8,6 +8,7 @@ import {
CheckCircle, Clock, Pin, Monitor, MessageSquare, X,
} from 'lucide-react';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { toDateParam } from '@shared/utils/dateFormat';
import { SaveButton } from '@shared/components/common/SaveButton';
import type { SystemNotice, NoticeType, NoticeDisplay } from '@shared/components/common/NotificationBanner';
@ -75,19 +76,19 @@ export function NoticeManagement() {
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState<SystemNotice>({
id: '', type: 'info', display: 'banner', title: '', message: '',
startDate: new Date().toISOString().slice(0, 10),
endDate: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10),
startDate: toDateParam(),
endDate: toDateParam(new Date(Date.now() + 7 * 86400000)),
targetRoles: [], dismissible: true, pinned: false,
});
const now = new Date().toISOString().slice(0, 10);
const now = toDateParam();
const openNew = () => {
setForm({
id: `N-${String(notices.length + 1).padStart(3, '0')}`,
type: 'info', display: 'banner', title: '', message: '',
startDate: now,
endDate: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10),
endDate: toDateParam(new Date(Date.now() + 7 * 86400000)),
targetRoles: [], dismissible: true, pinned: false,
});
setEditingId(null);
@ -141,6 +142,9 @@ export function NoticeManagement() {
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Bell className="w-5 h-5 text-yellow-400" />
{t('notices.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span><span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5">
{t('notices.desc')}

파일 보기

@ -150,6 +150,9 @@ export function SystemConfig() {
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Database className="w-5 h-5 text-cyan-400" />
{t('systemConfig.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span><span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5">
{t('systemConfig.desc')}

파일 보기

@ -251,6 +251,9 @@ export function AIModelManagement() {
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Brain className="w-5 h-5 text-purple-400" />
{t('modelManagement.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span><span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5">
{t('modelManagement.desc')}

파일 보기

@ -117,7 +117,12 @@ export function MLOpsPage() {
<div className="p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Cpu className="w-5 h-5 text-purple-400" />{t('mlops.title')}</h2>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Cpu className="w-5 h-5 text-purple-400" />{t('mlops.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span><span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('mlops.desc')}</p>
</div>
</div>

파일 보기

@ -16,6 +16,13 @@ import { useKpiStore } from '@stores/kpiStore';
import { useEventStore } from '@stores/eventStore';
import { usePatrolStore } from '@stores/patrolStore';
import { fetchVesselAnalysis, type VesselAnalysisItem } from '@/services/vesselAnalysisApi';
import {
getDailyStats,
getHourlyStats,
type PredictionStatsDaily,
type PredictionStatsHourly,
} from '@/services/kpi';
import { toDateParam } from '@shared/utils/dateFormat';
// ─── 작전 경보 등급 ─────────────────────
type AlertLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
@ -44,32 +51,16 @@ const KPI_UI_MAP: Record<string, { icon: LucideIcon; color: string }> = {
};
// TODO: /api/risk-grid 연동 예정
const AREA_RISK_DATA = [
{ area: '서해 NLL', vessels: 8, risk: 95, trend: 'up' },
{ area: 'EEZ 북부', vessels: 14, risk: 91, trend: 'up' },
{ area: '서해 5도', vessels: 11, risk: 88, trend: 'stable' },
{ area: 'EEZ 서부', vessels: 6, risk: 72, trend: 'down' },
{ area: '동해 중부', vessels: 4, risk: 58, trend: 'up' },
{ area: 'EEZ 남부', vessels: 3, risk: 45, trend: 'down' },
{ area: '남해 서부', vessels: 1, risk: 22, trend: 'stable' },
];
// TODO: /api/stats/daily 연동 예정
const HOURLY_DETECTION = [
{ hour: '00', count: 5, eez: 2 }, { hour: '01', count: 4, eez: 1 }, { hour: '02', count: 6, eez: 3 },
{ hour: '03', count: 8, eez: 4 }, { hour: '04', count: 12, eez: 6 }, { hour: '05', count: 18, eez: 8 },
{ hour: '06', count: 28, eez: 12 }, { hour: '07', count: 35, eez: 15 }, { hour: '08', count: 47, eez: 18 },
];
// TODO: /api/stats/daily 연동 예정
const VESSEL_TYPE_DATA = [
{ name: 'EEZ 침범', value: 18, color: '#ef4444' },
{ name: '다크베셀', value: 12, color: '#f97316' },
{ name: '불법환적', value: 8, color: '#a855f7' },
{ name: 'MMSI변조', value: 5, color: '#eab308' },
{ name: '고속도주', value: 4, color: '#06b6d4' },
];
// 위반 유형/어구 → 차트 색상 매핑
const VESSEL_TYPE_COLORS: Record<string, string> = {
'EEZ 침범': '#ef4444',
'다크베셀': '#f97316',
'불법환적': '#a855f7',
'MMSI변조': '#eab308',
'고속도주': '#06b6d4',
'어구 불법': '#6b7280',
};
const DEFAULT_PIE_COLORS = ['#ef4444', '#f97316', '#a855f7', '#eab308', '#06b6d4', '#3b82f6', '#10b981', '#6b7280'];
// TODO: /api/weather 연동 예정
const WEATHER_DATA = {
@ -292,6 +283,14 @@ export function Dashboard() {
const patrolStore = usePatrolStore();
const [riskVessels, setRiskVessels] = useState<VesselAnalysisItem[]>([]);
const [hourlyStats, setHourlyStats] = useState<PredictionStatsHourly[]>([]);
const [dailyStats, setDailyStats] = useState<PredictionStatsDaily[]>([]);
useEffect(() => {
getHourlyStats(24).then(setHourlyStats).catch(() => setHourlyStats([]));
const today = toDateParam(new Date());
getDailyStats(today, today).then(setDailyStats).catch(() => setDailyStats([]));
}, []);
useEffect(() => { if (!kpiStore.loaded) kpiStore.load(); }, [kpiStore.loaded, kpiStore.load]);
useEffect(() => { if (!eventStore.loaded) eventStore.load(); }, [eventStore.loaded, eventStore.load]);
@ -356,6 +355,64 @@ export function Dashboard() {
fuel: s.fuel,
})), [patrolStore.ships]);
// 시간대별 탐지 추이: hourly stats → 차트 데이터
const HOURLY_DETECTION = useMemo(() => hourlyStats.map((h) => {
const hourLabel = h.statHour ? `${new Date(h.statHour).getHours()}` : '';
const eez = h.byZone
? Object.entries(h.byZone)
.filter(([k]) => k.toUpperCase().includes('EEZ'))
.reduce((sum, [, v]) => sum + (Number(v) || 0), 0)
: 0;
return {
hour: hourLabel,
count: h.totalDetections ?? 0,
eez,
};
}), [hourlyStats]);
// 위반 유형/어구 분포: daily byGearType 우선, 없으면 byCategory
const VESSEL_TYPE_DATA = useMemo(() => {
if (dailyStats.length === 0) return [] as { name: string; value: number; color: string }[];
const totals: Record<string, number> = {};
dailyStats.forEach((d) => {
const src = d.byGearType ?? d.byCategory ?? null;
if (src) {
Object.entries(src).forEach(([k, v]) => {
totals[k] = (totals[k] ?? 0) + (Number(v) || 0);
});
}
});
return Object.entries(totals)
.sort((a, b) => b[1] - a[1])
.map(([name, value], i) => ({
name,
value,
color: VESSEL_TYPE_COLORS[name] ?? DEFAULT_PIE_COLORS[i % DEFAULT_PIE_COLORS.length],
}));
}, [dailyStats]);
// 해역별 위험도: daily byZone → 표 데이터
const AREA_RISK_DATA = useMemo(() => {
if (dailyStats.length === 0) return [] as { area: string; vessels: number; risk: number; trend: string }[];
const totals: Record<string, number> = {};
dailyStats.forEach((d) => {
if (d.byZone) {
Object.entries(d.byZone).forEach(([k, v]) => {
totals[k] = (totals[k] ?? 0) + (Number(v) || 0);
});
}
});
const max = Math.max(1, ...Object.values(totals));
return Object.entries(totals)
.sort((a, b) => b[1] - a[1])
.map(([area, vessels]) => ({
area,
vessels,
risk: Math.round((vessels / max) * 100),
trend: 'stable',
}));
}, [dailyStats]);
const defconColors = ['', 'bg-red-600', 'bg-orange-500', 'bg-yellow-500', 'bg-green-500', 'bg-blue-500'];
const defconLabels = ['', 'DEFCON 1', 'DEFCON 2', 'DEFCON 3', 'DEFCON 4', 'DEFCON 5'];

파일 보기

@ -6,10 +6,10 @@ import {
Eye, AlertTriangle, Radio, RotateCcw,
MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2
} from 'lucide-react';
import { formatDateTime } from '@shared/utils/dateFormat';
import { GearIdentification } from './GearIdentification';
import { RealAllVessels } from './RealVesselAnalysis';
import { RealAllVessels, RealTransshipSuspects } from './RealVesselAnalysis';
import { PieChart as EcPieChart } from '@lib/charts';
import { useTransferStore } from '@stores/transferStore';
import {
fetchVesselAnalysis,
filterDarkVessels,
@ -72,7 +72,7 @@ const VTS_ITEMS = [
{ name: '태안연안', active: false },
];
// ─── 환적 탐지 데이터: useTransferStore().transfers 사용 ───
// ─── 환적 탐지 뷰: RealTransshipSuspects 컴포넌트 사용 ───
// ─── 서브 컴포넌트 ─────────────────────
@ -159,25 +159,6 @@ function StatusRing({ status, riskPct }: { status: VesselStatus; riskPct: number
// ─── 환적 탐지 뷰 ─────────────────────
function TransferView() {
const { transfers, load } = useTransferStore();
useEffect(() => { load(); }, [load]);
const TRANSFER_DATA = useMemo(
() =>
transfers.map((t) => ({
id: t.id,
time: t.time,
a: t.vesselA,
b: t.vesselB,
dist: t.distance,
dur: t.duration,
spd: t.speed,
score: t.score,
loc: t.location,
})),
[transfers],
);
return (
<div className="space-y-4">
<div>
@ -206,84 +187,8 @@ function TransferView() {
</CardContent>
</Card>
{/* 환적 이벤트 */}
{TRANSFER_DATA.map((tr) => (
<Card key={tr.id} className="bg-surface-raised border-slate-700/30">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-heading font-bold">{tr.id}</h3>
<div className="text-[10px] text-hint">{tr.time}</div>
</div>
<Badge className="bg-red-500/20 text-red-400 border border-red-500/30 text-[10px]"> : {tr.score}%</Badge>
</div>
<div className="flex gap-3">
<div className="flex-1 space-y-2">
{/* 선박 A & B */}
<div className="flex gap-2">
<div className="flex-1 bg-blue-950/30 border border-blue-900/30 rounded-lg p-3">
<div className="flex items-center gap-1.5 text-[10px] text-blue-400 mb-1"><Ship className="w-3 h-3" /> A</div>
<div className="text-sm text-heading font-bold">{tr.a.name}</div>
<div className="text-[9px] text-hint">MMSI: {tr.a.mmsi}</div>
</div>
<div className="flex-1 bg-teal-950/30 border border-teal-900/30 rounded-lg p-3">
<div className="flex items-center gap-1.5 text-[10px] text-teal-400 mb-1"><Ship className="w-3 h-3" /> B</div>
<div className="text-sm text-heading font-bold">{tr.b.name}</div>
<div className="text-[9px] text-hint">MMSI: {tr.b.mmsi}</div>
</div>
</div>
{/* 타임라인 */}
<div className="bg-surface-overlay rounded-lg p-3">
<div className="text-[10px] text-muted-foreground mb-2"> </div>
<div className="space-y-1.5">
<div className="flex items-center gap-2 text-[10px]">
<span className="w-2 h-2 rounded-full bg-blue-500" />
<span className="text-blue-400"> </span>
<span className="text-hint">거리: 500m</span>
</div>
<div className="flex items-center gap-2 text-[10px]">
<span className="w-2 h-2 rounded-full bg-yellow-500" />
<span className="text-yellow-400"> </span>
<span className="text-hint">: {tr.dist}m, : {tr.dur}</span>
</div>
<div className="flex items-center gap-2 text-[10px]">
<span className="w-2 h-2 rounded-full bg-red-500" />
<span className="text-red-400"> </span>
<span className="text-hint"> : {tr.spd}kn</span>
</div>
</div>
</div>
</div>
{/* 우측 정보 */}
<div className="w-44 shrink-0 space-y-2">
<div className="bg-surface-overlay rounded-lg p-3 space-y-1.5 text-[10px]">
<div className="text-muted-foreground mb-1"> </div>
<div className="flex justify-between"><span className="text-hint"> </span><span className="text-heading font-medium">{tr.dist}m</span></div>
<div className="flex justify-between"><span className="text-hint"> </span><span className="text-heading font-medium">{tr.dur}</span></div>
<div className="flex justify-between"><span className="text-hint"> </span><span className="text-heading font-medium">{tr.spd}kn</span></div>
</div>
<div className="bg-surface-overlay rounded-lg p-3">
<div className="flex items-center gap-1 text-[10px] text-muted-foreground mb-1"><MapPin className="w-3 h-3" /></div>
<div className="text-heading text-sm font-medium">{tr.loc}</div>
</div>
<div className="bg-purple-950/30 border border-purple-900/30 rounded-lg p-3">
<div className="text-[10px] text-purple-400 mb-1.5"> </div>
<div className="h-1.5 bg-switch-background rounded-full overflow-hidden mb-1.5">
<div className="h-full bg-gradient-to-r from-blue-500 to-purple-500 rounded-full" style={{ width: `${tr.score}%` }} />
</div>
<div className="text-xl font-bold text-heading">{tr.score}%</div>
</div>
<button className="w-full bg-blue-600 hover:bg-blue-500 text-heading text-[11px] py-2 rounded-lg transition-colors">
</button>
</div>
</div>
</CardContent>
</Card>
))}
{/* prediction 분석 결과 기반 환적 의심 선박 */}
<RealTransshipSuspects />
</div>
);
}
@ -430,7 +335,7 @@ export function ChinaFishing() {
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 bg-surface-overlay rounded-lg px-3 py-1.5 border border-slate-700/40">
<Clock className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-[11px] text-label"> : {new Date().toLocaleString('ko-KR')}</span>
<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" />

파일 보기

@ -11,6 +11,7 @@ import {
filterDarkVessels,
type VesselAnalysisItem,
} from '@/services/vesselAnalysisApi';
import { formatDateTime } from '@shared/utils/dateFormat';
/* SFR-09: 불법 어선(AIS 조작·위장·Dark Vessel) 패턴 탐지 */
@ -53,7 +54,7 @@ function mapItemToSuspect(item: VesselAnalysisItem, idx: number): Suspect {
flag: deriveFlag(item.mmsi),
pattern: derivePattern(item),
risk,
lastAIS: item.timestamp ? new Date(item.timestamp).toLocaleString('ko-KR') : '-',
lastAIS: formatDateTime(item.timestamp),
status,
label: risk >= 90 ? (status === '추적중' ? '불법' : '-') : status === '정상' ? '정상' : '-',
lat: 0,

파일 보기

@ -7,6 +7,7 @@ import { Anchor, AlertTriangle, Loader2 } from 'lucide-react';
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { MarkerData } from '@lib/map';
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
import { formatDate } from '@shared/utils/dateFormat';
/* SFR-10: 불법 어망·어구 탐지 및 관리 */
@ -41,7 +42,7 @@ function mapGroupToGear(g: GearGroupItem, idx: number): Gear {
zone: g.groupType === 'GEAR_IN_ZONE' ? '지정해역' : '지정해역 외',
status,
permit: 'NONE',
installed: g.snapshotTime ? new Date(g.snapshotTime).toLocaleDateString('ko-KR') : '-',
installed: formatDate(g.snapshotTime),
lastSignal: g.snapshotTime ? new Date(g.snapshotTime).toLocaleTimeString('ko-KR') : '-',
risk,
lat: g.centerLat,

파일 보기

@ -4,6 +4,7 @@ import { Badge } from '@shared/components/ui/badge';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { Send, Loader2, AlertTriangle } from 'lucide-react';
import { getAlerts, type PredictionAlert } from '@/services/event';
import { formatDateTime } from '@shared/utils/dateFormat';
/* SFR-17: 현장 함정 즉각 대응 AI 알림 메시지 발송 기능 */
@ -125,7 +126,7 @@ export function AIAlert() {
alerts.map((a) => ({
id: a.id,
eventId: a.eventId,
time: a.sentAt ? new Date(a.sentAt).toLocaleString('ko-KR') : '-',
time: formatDateTime(a.sentAt),
channel: a.channel ?? '-',
recipient: a.recipient ?? '-',
confidence: a.aiConfidence != null ? String(a.aiConfidence) : '',

파일 보기

@ -5,6 +5,7 @@ import { Badge } from '@shared/components/ui/badge';
import { Smartphone, MapPin, Bell, Wifi, WifiOff, Shield, AlertTriangle, Navigation } from 'lucide-react';
import { BaseMap, createMarkerLayer, createPolylineLayer, useMapLayers, type MapHandle, type MarkerData } from '@lib/map';
import { useEventStore } from '@stores/eventStore';
import { formatTime } from '@shared/utils/dateFormat';
/* SFR-15: 단속요원 이용 모바일 대응 서비스 */
@ -25,7 +26,7 @@ const MOBILE_MARKERS = [
export function MobileService() {
const { t } = useTranslation('fieldOps');
const mapRef = useRef<MapHandle>(null);
const { alerts: storeAlerts, load } = useEventStore();
const { events, load } = useEventStore();
useEffect(() => { load(); }, [load]);
const buildLayers = useCallback(() => [
@ -40,13 +41,13 @@ export function MobileService() {
const ALERTS = useMemo(
() =>
storeAlerts.slice(0, 3).map((a) => ({
time: a.time.slice(0, 5),
title: a.type === 'EEZ 침범' ? `[긴급] ${a.type} 탐지` : a.type,
detail: `${a.location}`,
level: a.confidence >= 95 ? 'CRITICAL' : a.confidence >= 90 ? 'HIGH' : 'MEDIUM',
events.slice(0, 3).map((e) => ({
time: formatTime(e.time).slice(0, 5),
title: e.type === 'EEZ 침범' || e.level === 'CRITICAL' ? `[긴급] ${e.title}` : e.title,
detail: e.area ?? e.detail,
level: e.level,
})),
[storeAlerts],
[events],
);
return (

파일 보기

@ -31,7 +31,12 @@ export function ShipAgent() {
return (
<div className="p-5 space-y-4">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Monitor className="w-5 h-5 text-cyan-400" />{t('shipAgent.title')}</h2>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Monitor className="w-5 h-5 text-cyan-400" />{t('shipAgent.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span><span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('shipAgent.desc')}</p>
</div>
<div className="flex gap-2">

파일 보기

@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
@ -7,6 +7,7 @@ import type { LucideIcon } from 'lucide-react';
import { AreaChart, PieChart } from '@lib/charts';
import { useKpiStore } from '@stores/kpiStore';
import { useEventStore } from '@stores/eventStore';
import { getHourlyStats, type PredictionStatsHourly } from '@/services/kpi';
import { SystemStatusPanel } from './SystemStatusPanel';
/* SFR-12: 모니터링 및 경보 현황판(대시보드) */
@ -20,7 +21,6 @@ const KPI_UI_MAP: Record<string, { icon: LucideIcon; color: string }> = {
'추적 중': { icon: Target, color: '#06b6d4' },
'나포/검문': { icon: Shield, color: '#10b981' },
};
const TREND = Array.from({ length: 24 }, (_, i) => ({ h: `${i}`, risk: 30 + Math.floor(Math.random() * 50), alarms: Math.floor(Math.random() * 8) }));
// 위반 유형 → 차트 색상 매핑
const PIE_COLOR_MAP: Record<string, string> = {
'EEZ 침범': '#ef4444', '다크베셀': '#f97316', 'MMSI 변조': '#eab308',
@ -33,8 +33,35 @@ export function MonitoringDashboard() {
const kpiStore = useKpiStore();
const eventStore = useEventStore();
const [hourlyStats, setHourlyStats] = useState<PredictionStatsHourly[]>([]);
useEffect(() => { if (!kpiStore.loaded) kpiStore.load(); }, [kpiStore.loaded, kpiStore.load]);
useEffect(() => { if (!eventStore.loaded) eventStore.load(); }, [eventStore.loaded, eventStore.load]);
useEffect(() => {
getHourlyStats(24).then(setHourlyStats).catch(() => setHourlyStats([]));
}, []);
// 24시간 위험도/경보 추이: hourly stats → 차트 데이터
const TREND = useMemo(() => hourlyStats.map((h) => {
const hourLabel = h.statHour ? `${new Date(h.statHour).getHours()}` : '';
// 위험도 점수: byRiskLevel 가중합 (CRITICAL=100, HIGH=70, MEDIUM=40, LOW=10) 정규화
let riskScore = 0;
let total = 0;
if (h.byRiskLevel) {
const weights: Record<string, number> = { CRITICAL: 100, HIGH: 70, MEDIUM: 40, LOW: 10 };
Object.entries(h.byRiskLevel).forEach(([k, v]) => {
const cnt = Number(v) || 0;
riskScore += (weights[k.toUpperCase()] ?? 0) * cnt;
total += cnt;
});
}
const risk = total > 0 ? Math.round(riskScore / total) : 0;
return {
h: hourLabel,
risk,
alarms: (h.eventCount ?? 0) + (h.criticalCount ?? 0),
};
}), [hourlyStats]);
// KPI: store metrics + UI 매핑
const KPI = kpiStore.metrics.map((m) => ({

파일 보기

@ -9,6 +9,7 @@ import {
cancelLabelSession,
type LabelSession as LabelSessionType,
} from '@/services/parentInferenceApi';
import { formatDateTime } from '@shared/utils/dateFormat';
/**
* .
@ -164,7 +165,7 @@ export function LabelSession() {
<Badge className={`border-0 text-[9px] ${STATUS_COLORS[it.status] || ''}`}>{it.status}</Badge>
</td>
<td className="px-3 py-2 text-muted-foreground">{it.createdByAcnt || '-'}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{new Date(it.activeFrom).toLocaleString('ko-KR')}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.activeFrom)}</td>
<td className="px-3 py-2 text-center">
{it.status === 'ACTIVE' && (
<button type="button" disabled={!canUpdate || busy === it.id} onClick={() => handleCancel(it.id)}

파일 보기

@ -10,6 +10,7 @@ import {
releaseExclusion,
type CandidateExclusion,
} from '@/services/parentInferenceApi';
import { formatDateTime } from '@shared/utils/dateFormat';
/**
* .
@ -211,7 +212,7 @@ export function ParentExclusion() {
<td className="px-3 py-2 text-cyan-400 font-mono">{it.excludedMmsi}</td>
<td className="px-3 py-2 text-muted-foreground">{it.reason || '-'}</td>
<td className="px-3 py-2 text-muted-foreground">{it.actorAcnt || '-'}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">{new Date(it.createdAt).toLocaleString('ko-KR')}</td>
<td className="px-3 py-2 text-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="해제">

파일 보기

@ -8,6 +8,7 @@ import {
reviewParent,
type ParentResolution,
} from '@/services/parentInferenceApi';
import { formatDateTime } from '@shared/utils/dateFormat';
/**
* // .
@ -233,7 +234,7 @@ export function ParentReview() {
</td>
<td className="px-3 py-2 text-cyan-400 font-mono">{it.selectedParentMmsi || '-'}</td>
<td className="px-3 py-2 text-muted-foreground text-[10px]">
{new Date(it.updatedAt).toLocaleString('ko-KR')}
{formatDateTime(it.updatedAt)}
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-center gap-1">

파일 보기

@ -99,7 +99,13 @@ export function FleetOptimization() {
<div className="p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Users className="w-5 h-5 text-purple-400" />{t('fleetOptimization.title')}</h2>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Users className="w-5 h-5 text-purple-400" />{t('fleetOptimization.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span>
<span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('fleetOptimization.desc')}</p>
</div>
<div className="flex gap-1.5">

파일 보기

@ -96,7 +96,13 @@ export function PatrolRoute() {
<div className="p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Navigation className="w-5 h-5 text-cyan-400" />{t('patrolRoute.title')}</h2>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Navigation className="w-5 h-5 text-cyan-400" />{t('patrolRoute.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span>
<span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('patrolRoute.desc')}</p>
</div>
<div className="flex gap-1.5">

파일 보기

@ -2,10 +2,19 @@ import { useState, useRef, useCallback } from 'react';
import { BaseMap, STATIC_LAYERS, createHeatmapLayer, useMapLayers, type MapHandle } from '@lib/map';
import type { HeatPoint } from '@lib/map';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Map, Layers, Filter, Clock, BarChart3, Target, AlertTriangle, Eye, RefreshCw, Printer, Download, Ship, Anchor, Calendar, TrendingUp } from 'lucide-react';
import { AreaChart as EcAreaChart, LineChart as EcLineChart, PieChart as EcPieChart, BarChart as EcBarChart, BaseChart } from '@lib/charts';
import type { EChartsOption } from 'echarts';
import { Map, Layers, Clock, BarChart3, AlertTriangle, Printer, Download, Ship, TrendingUp } from 'lucide-react';
import { AreaChart as EcAreaChart, LineChart as EcLineChart, PieChart as EcPieChart, BarChart as EcBarChart } from '@lib/charts';
const MTIS_BADGE = (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-blue-500/15 border border-blue-500/30 rounded text-[9px] text-blue-300 font-normal">
[MTIS ]
</span>
);
const COLLECTING_BADGE = (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-orange-500/15 border border-orange-500/30 rounded text-[9px] text-orange-300 font-normal">
AI
</span>
);
/*
* SFR-05: 격자 ·
@ -23,11 +32,8 @@ const RISK_LEVELS = [
{ level: 2, label: '낮음', color: '#3b82f6', count: 687, pct: 37.3 },
{ level: 1, label: '안전', color: '#22c55e', count: 629, pct: 34.2 },
];
const GRID_ROWS = 10;
const GRID_COLS = 18;
const generateGrid = () => Array.from({ length: GRID_ROWS }, () =>
Array.from({ length: GRID_COLS }, () => Math.floor(Math.random() * 5) + 1)
);
// prediction_risk_grid 테이블 데이터 수집 전이라 빈 격자 반환
const generateGrid = (): number[][] => [];
const ZONE_SUMMARY = [
{ zone: '서해 NLL', risk: 87, trend: '+5', vessels: 18 },
{ zone: 'EEZ 북부', risk: 72, trend: '+3', vessels: 24 },
@ -151,16 +157,13 @@ function generateHeatPoints(): [number, number, number][] {
const HEAT_POINTS = generateHeatPoints();
type SelectedGrid = { row: number; col: number } | null;
export function RiskMap() {
const [grid] = useState(generateGrid);
const [selectedGrid, setSelectedGrid] = useState<SelectedGrid>(null);
// prediction_risk_grid 데이터 수집 전이라 빈 격자 유지
const grid = generateGrid();
void grid;
const [tab, setTab] = useState<Tab>('heatmap');
const mapRef = useRef<MapHandle>(null);
const riskColor = (level: number) => RISK_LEVELS.find(r => r.level === level)?.color || '#6b7280';
const buildLayers = useCallback(() => {
if (tab !== 'heatmap') return [];
return [
@ -175,8 +178,11 @@ export function RiskMap() {
<div className="p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading flex items-center gap-2"><Map className="w-5 h-5 text-red-400" /> </h2>
<p className="text-[10px] text-hint mt-0.5">SFR-05 | + MTIS ()</p>
<h2 className="text-lg font-bold text-heading flex items-center gap-2">
<Map className="w-5 h-5 text-red-400" />
{COLLECTING_BADGE}
</h2>
<p className="text-[10px] text-hint mt-0.5">SFR-05 | ( ) + MTIS ()</p>
</div>
<div className="flex gap-1.5">
<button className="flex items-center gap-1 px-2.5 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading"><Printer className="w-3 h-3" /></button>
@ -204,7 +210,14 @@ export function RiskMap() {
{/* ── 위험도 히트맵 (지도 기반) ── */}
{tab === 'heatmap' && (
<div className="space-y-3">
<div className="flex items-center gap-2 px-3 py-2 bg-orange-500/10 border border-orange-500/30 rounded-lg">
<AlertTriangle className="w-3.5 h-3.5 text-orange-400" />
<span className="text-[10px] text-orange-300">
prediction_risk_grid . / / .
</span>
</div>
{/* 위험도 등급 요약 카드 */}
<div className="flex items-center gap-2 text-[10px] text-hint"> 출처: AI ( )</div>
<div className="flex gap-2">
{RISK_LEVELS.map(r => (
<div key={r.level} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
@ -287,7 +300,7 @@ export function RiskMap() {
{/* ── ① 년도별 통계 ── */}
{tab === 'yearly' && (
<div className="space-y-3">
<div className="text-[10px] text-hint">출처: 중앙해양안전심판원 (MTIS)</div>
<div className="flex items-center gap-2 text-[10px] text-hint">{MTIS_BADGE}<span>출처: 중앙해양안전심판원 (MTIS)</span></div>
<div className="grid grid-cols-2 gap-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3"> </div>
@ -338,7 +351,7 @@ export function RiskMap() {
{/* ── ② 선박 특성별 ── */}
{tab === 'shipProp' && (
<div className="space-y-3">
<div className="text-[10px] text-hint">출처: 중앙해양안전심판원 (MTIS)</div>
<div className="flex items-center gap-2 text-[10px] text-hint">{MTIS_BADGE}<span>출처: 중앙해양안전심판원 (MTIS)</span></div>
<div className="grid grid-cols-3 gap-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3"></div>
@ -379,7 +392,7 @@ export function RiskMap() {
{/* ── ③ 사고종류별 ── */}
{tab === 'accType' && (
<div className="space-y-3">
<div className="text-[10px] text-hint">출처: 중앙해양안전심판원 (MTIS)</div>
<div className="flex items-center gap-2 text-[10px] text-hint">{MTIS_BADGE}<span>출처: 중앙해양안전심판원 (MTIS)</span></div>
<div className="grid grid-cols-2 gap-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3"> </div>
@ -413,7 +426,7 @@ export function RiskMap() {
{/* ── ④ 시간적 특성별 ── */}
{tab === 'timeStat' && (
<div className="space-y-3">
<div className="text-[10px] text-hint">출처: 중앙해양안전심판원 (MTIS)</div>
<div className="flex items-center gap-2 text-[10px] text-hint">{MTIS_BADGE}<span>출처: 중앙해양안전심판원 (MTIS)</span></div>
<div className="grid grid-cols-3 gap-3">
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3"> </div>
@ -451,7 +464,7 @@ export function RiskMap() {
{/* ── ⑤ 사고율 ── */}
{tab === 'accRate' && (
<div className="space-y-3">
<div className="text-[10px] text-hint">출처: 중앙해양안전심판원 (MTIS) · = ( / ) × 100</div>
<div className="flex items-center gap-2 text-[10px] text-hint">{MTIS_BADGE}<span>출처: 중앙해양안전심판원 (MTIS) · = ( / ) × 100</span></div>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3"> </div>
<div className="grid grid-cols-2 gap-3">

파일 보기

@ -33,7 +33,12 @@ export function ExternalService() {
return (
<div className="p-5 space-y-4">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Globe className="w-5 h-5 text-green-400" />{t('externalService.title')}</h2>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Globe className="w-5 h-5 text-green-400" />{t('externalService.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span><span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('externalService.desc')}</p>
</div>
<div className="flex gap-2">

파일 보기

@ -40,7 +40,12 @@ export function ReportManagement() {
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-heading whitespace-nowrap">{t('reports.title')}</h2>
<h2 className="text-xl font-bold text-heading whitespace-nowrap flex items-center gap-2">
{t('reports.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span><span> ( API )</span>
</span>
</h2>
<p className="text-xs text-hint mt-0.5">{t('reports.desc')}</p>
</div>
<div className="flex items-center gap-2">

파일 보기

@ -6,61 +6,28 @@ import { DataTable, type DataColumn } from '@shared/components/common/DataTable'
import { BarChart3, Download } from 'lucide-react';
import { BarChart, AreaChart } from '@lib/charts';
import {
getKpiMetrics,
getMonthlyStats,
toMonthlyTrend,
toViolationTypes,
type PredictionKpi,
type PredictionStatsMonthly,
} from '@/services/kpi';
import type { MonthlyTrend, ViolationType } from '@data/mock/kpi';
import { toDateParam } from '@shared/utils/dateFormat';
/* SFR-13: 통계·지표·성과 분석 */
const KPI_DATA: {
interface KpiRow {
id: string;
name: string;
target: string;
current: string;
status: string;
[key: string]: unknown;
}[] = [
{
id: 'KPI-01',
name: 'AI 탐지 정확도',
target: '90%',
current: '93.2%',
status: '달성',
},
{
id: 'KPI-02',
name: '오탐률',
target: '≤10%',
current: '7.8%',
status: '달성',
},
{
id: 'KPI-03',
name: '평균 리드타임',
target: '≤15분',
current: '12분',
status: '달성',
},
{
id: 'KPI-04',
name: '단속 성공률',
target: '≥60%',
current: '68%',
status: '달성',
},
{
id: 'KPI-05',
name: '경보 응답시간',
target: '≤5분',
current: '3.2분',
status: '달성',
},
];
}
const kpiCols: DataColumn<(typeof KPI_DATA)[0]>[] = [
const kpiCols: DataColumn<KpiRow>[] = [
{
key: 'id',
label: 'ID',
@ -105,6 +72,7 @@ export function Statistics() {
const [monthly, setMonthly] = useState<MonthlyTrend[]>([]);
const [violationTypes, setViolationTypes] = useState<ViolationType[]>([]);
const [kpiMetrics, setKpiMetrics] = useState<PredictionKpi[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -117,17 +85,17 @@ export function Statistics() {
try {
const now = new Date();
const from = new Date(now.getFullYear(), now.getMonth() - 6, 1);
const formatDate = (d: Date) => d.toISOString().substring(0, 10);
const data: PredictionStatsMonthly[] = await getMonthlyStats(
formatDate(from),
formatDate(now),
);
const [data, kpiData] = await Promise.all([
getMonthlyStats(toDateParam(from), toDateParam(now)),
getKpiMetrics().catch(() => [] as PredictionKpi[]),
]);
if (cancelled) return;
setMonthly(data.map(toMonthlyTrend));
setViolationTypes(toViolationTypes(data));
setKpiMetrics(kpiData);
} catch (err) {
if (!cancelled) {
setError(
@ -154,6 +122,19 @@ export function Statistics() {
const BY_TYPE = violationTypes;
const KPI_DATA: KpiRow[] = kpiMetrics.map((k, i) => {
const trendLabel =
k.trend === 'up' ? '상승' : k.trend === 'down' ? '하락' : k.trend === 'flat' ? '유지' : '-';
const deltaLabel = k.deltaPct != null ? ` (${k.deltaPct > 0 ? '+' : ''}${k.deltaPct}%)` : '';
return {
id: `KPI-${String(i + 1).padStart(2, '0')}`,
name: k.kpiLabel,
target: '-',
current: String(k.value),
status: `${trendLabel}${deltaLabel}`,
};
});
return (
<div className="p-5 space-y-4">
<div className="flex items-center justify-between">

파일 보기

@ -254,7 +254,12 @@ export function MapControl() {
<div className="p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading flex items-center gap-2"><Map className="w-5 h-5 text-cyan-400" /> </h2>
<h2 className="text-lg font-bold text-heading flex items-center gap-2">
<Map className="w-5 h-5 text-cyan-400" />
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span><span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5"> No.462 | Chart of Firing and Bombing Exercise Areas | WGS-84 | 출처: 국립해양조사원</p>
</div>
</div>

파일 보기

@ -1,29 +1,7 @@
import { useEffect, useMemo } from 'react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Ship, MapPin } from 'lucide-react';
import { useTransferStore } from '@stores/transferStore';
import { RealTransshipSuspects } from '@features/detection/RealVesselAnalysis';
export function TransferDetection() {
const { transfers, load } = useTransferStore();
useEffect(() => { load(); }, [load]);
const transferData = useMemo(
() =>
transfers.map((t) => ({
id: t.id,
time: t.time,
a: t.vesselA,
b: t.vesselB,
dist: t.distance,
dur: t.duration,
spd: t.speed,
score: t.score,
loc: t.location,
})),
[transfers],
);
return (
<div className="space-y-5">
<div>
@ -31,7 +9,7 @@ export function TransferDetection() {
<p className="text-xs text-hint mt-0.5"> </p>
</div>
{/* iran 백엔드 실시간 전재 의심 */}
{/* prediction 분석 결과 기반 실시간 환적 의심 선박 */}
<RealTransshipSuspects />
{/* 탐지 조건 */}
@ -54,104 +32,6 @@ export function TransferDetection() {
</div>
</CardContent>
</Card>
{/* 환적 이벤트 카드 */}
{transferData.map((tr) => (
<Card key={tr.id} className="bg-surface-overlay border-slate-700/40">
<CardContent className="p-5">
{/* 헤더 */}
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-heading font-bold text-lg">{tr.id}</h3>
<div className="text-[11px] text-hint">{tr.time}</div>
</div>
<Badge className="bg-red-500 text-heading text-xs"> : {tr.score}%</Badge>
</div>
<div className="flex gap-4">
{/* 선박 A & B + 타임라인 */}
<div className="flex-1 space-y-3">
<div className="flex gap-3">
<div className="flex-1 bg-blue-950/30 border border-blue-900/30 rounded-xl p-4">
<div className="flex items-center gap-2 text-[11px] text-blue-400 mb-2">
<Ship className="w-3.5 h-3.5" /> A
</div>
<div className="text-heading font-bold">{tr.a.name}</div>
<div className="text-[11px] text-hint">MMSI: {tr.a.mmsi}</div>
</div>
<div className="flex-1 bg-teal-950/30 border border-teal-900/30 rounded-xl p-4">
<div className="flex items-center gap-2 text-[11px] text-teal-400 mb-2">
<Ship className="w-3.5 h-3.5" /> B
</div>
<div className="text-heading font-bold">{tr.b.name}</div>
<div className="text-[11px] text-hint">MMSI: {tr.b.mmsi}</div>
</div>
</div>
{/* 접촉 타임라인 */}
<div className="bg-surface-raised rounded-lg p-3">
<div className="text-[11px] text-muted-foreground mb-2"> </div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-[11px]">
<div className="w-2 h-2 rounded-full bg-blue-500" />
<span className="text-blue-400"> </span>
<span className="text-hint">거리: 500m</span>
</div>
<div className="flex items-center gap-2 text-[11px]">
<div className="w-2 h-2 rounded-full bg-yellow-500" />
<span className="text-yellow-400"> </span>
<span className="text-hint">: {tr.dist}m, : {tr.dur}</span>
</div>
<div className="flex items-center gap-2 text-[11px]">
<div className="w-2 h-2 rounded-full bg-red-500" />
<span className="text-red-400"> </span>
<span className="text-hint"> : {tr.spd}kn</span>
</div>
</div>
</div>
</div>
{/* 우측: 접촉 정보 */}
<div className="w-[200px] shrink-0 space-y-3">
<Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-3">
<div className="text-[11px] text-muted-foreground mb-2"> </div>
<div className="space-y-1.5 text-[11px]">
<div className="flex justify-between"><span className="text-hint"> </span><span className="text-heading font-medium">{tr.dist}m</span></div>
<div className="flex justify-between"><span className="text-hint"> </span><span className="text-heading font-medium">{tr.dur}</span></div>
<div className="flex justify-between"><span className="text-hint"> </span><span className="text-heading font-medium">{tr.spd}kn</span></div>
</div>
</CardContent>
</Card>
<Card className="bg-surface-raised border-slate-700/30">
<CardContent className="p-3">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground mb-1">
<MapPin className="w-3 h-3" />
</div>
<div className="text-heading text-sm font-medium">{tr.loc}</div>
</CardContent>
</Card>
<div className="bg-purple-950/30 border border-purple-900/30 rounded-xl p-3">
<div className="text-[11px] text-purple-400 mb-1.5"> </div>
<div className="h-2 bg-switch-background rounded-full overflow-hidden mb-1.5">
<div
className="h-2 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full"
style={{ width: `${tr.score}%` }}
/>
</div>
<div className="text-2xl font-bold text-heading">{tr.score}%</div>
</div>
<button className="w-full bg-blue-600 hover:bg-blue-500 text-heading text-sm py-2.5 rounded-lg transition-colors">
</button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
);
}

파일 보기

@ -12,6 +12,7 @@ import {
fetchVesselAnalysis,
type VesselAnalysisItem,
} from '@/services/vesselAnalysisApi';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getEvents, type PredictionEvent } from '@/services/event';
// ─── 허가 정보 타입 ──────────────────────
@ -406,7 +407,7 @@ export function VesselDetail() {
</span>
<span className="text-[8px]">
<span className="text-blue-400 font-bold">UTC</span>
<span className="text-label font-mono ml-1">{new Date().toISOString().substring(0, 19).replace('T', ' ')}</span>
<span className="text-label font-mono ml-1">{formatDateTime(new Date())}</span>
</span>
</div>
</div>

파일 보기

@ -32,11 +32,6 @@
"accessLogs": "Access Logs",
"loginHistory": "Login History"
},
"group": {
"fieldOps": "Field Ops",
"parentInference": "Parent Workflow",
"admin": "Admin"
},
"status": {
"active": "Active",
"inactive": "Inactive",
@ -98,6 +93,7 @@
"patrol": "Patrol",
"enforcement": "Enforcement",
"fieldOps": "Field Ops",
"parentInference": "Parent Workflow",
"statistics": "Statistics",
"aiOps": "AI Ops",
"admin": "Admin"

파일 보기

@ -32,11 +32,6 @@
"accessLogs": "접근 이력",
"loginHistory": "로그인 이력"
},
"group": {
"fieldOps": "함정·현장",
"parentInference": "모선 워크플로우",
"admin": "관리자"
},
"status": {
"active": "활성",
"inactive": "비활성",
@ -97,7 +92,8 @@
"detection": "탐지·분석",
"patrol": "순찰·경로",
"enforcement": "단속·이력",
"fieldOps": "현장 대응",
"fieldOps": "함정·현장",
"parentInference": "모선 워크플로우",
"statistics": "통계·보고",
"aiOps": "AI 운영",
"admin": "시스템 관리"

파일 보기

@ -52,6 +52,51 @@ export async function getMonthlyStats(
return res.json();
}
export interface PredictionStatsDaily {
statDate: string;
totalDetections: number;
enforcementCount: number;
eventCount: number;
criticalEventCount: number;
byCategory: Record<string, number> | null;
byZone: Record<string, number> | null;
byRiskLevel: Record<string, number> | null;
byGearType: Record<string, number> | null;
byViolationType: Record<string, number> | null;
aiAccuracyPct: number | null;
}
export interface PredictionStatsHourly {
statHour: string;
totalDetections: number;
eventCount: number;
criticalCount: number;
byCategory: Record<string, number> | null;
byZone: Record<string, number> | null;
byRiskLevel: Record<string, number> | null;
}
export async function getDailyStats(
from: string,
to: string,
): Promise<PredictionStatsDaily[]> {
const res = await fetch(`${API_BASE}/stats/daily?from=${from}&to=${to}`, {
credentials: 'include',
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
export async function getHourlyStats(
hours: number = 24,
): Promise<PredictionStatsHourly[]> {
const res = await fetch(`${API_BASE}/stats/hourly?hours=${hours}`, {
credentials: 'include',
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
// ─── 하위 호환 변환 헬퍼 ───────────────────
/** PredictionKpi -> 기존 KpiMetric 형태로 변환 (Dashboard에서 사용) */

파일 보기

@ -0,0 +1,53 @@
/**
* / KST(Asia/Seoul) .
*
* UTC ISO , Date
* KST로 .
*/
const KST: Intl.DateTimeFormatOptions = { timeZone: 'Asia/Seoul' };
/** 2026-04-07 14:30:00 형식 (KST) */
export const formatDateTime = (value: string | Date | null | undefined): string => {
if (!value) return '-';
const d = typeof value === 'string' ? new Date(value) : value;
if (isNaN(d.getTime())) return '-';
return d.toLocaleString('ko-KR', {
...KST,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
});
};
/** 2026-04-07 형식 (KST) */
export const formatDate = (value: string | Date | null | undefined): string => {
if (!value) return '-';
const d = typeof value === 'string' ? new Date(value) : value;
if (isNaN(d.getTime())) return '-';
return d.toLocaleDateString('ko-KR', {
...KST,
year: 'numeric', month: '2-digit', day: '2-digit',
});
};
/** 14:30:00 형식 (KST) */
export const formatTime = (value: string | Date | null | undefined): string => {
if (!value) return '-';
const d = typeof value === 'string' ? new Date(value) : value;
if (isNaN(d.getTime())) return '-';
return d.toLocaleTimeString('ko-KR', {
...KST,
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
});
};
/** yyyy-MM-dd 형식 문자열 (KST 기준, API 파라미터용) */
export const toDateParam = (d: Date = new Date()): string => {
const kst = new Date(d.toLocaleString('en-US', KST));
const y = kst.getFullYear();
const m = String(kst.getMonth() + 1).padStart(2, '0');
const day = String(kst.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
};

파일 보기

@ -5,15 +5,12 @@ import {
type EnforcementRecord,
type LegacyEnforcementRecord,
} from '@/services/enforcement';
import type { EnforcementPlanRecord } from '@/data/mock/enforcement';
interface EnforcementStore {
/** 원본 API 단속 기록 */
rawRecords: EnforcementRecord[];
/** 하위 호환용 레거시 형식 */
records: LegacyEnforcementRecord[];
/** 단속 계획 (아직 mock — EnforcementPlan.tsx에서 사용) */
plans: EnforcementPlanRecord[];
/** 페이지네이션 */
totalElements: number;
totalPages: number;
@ -28,7 +25,6 @@ interface EnforcementStore {
export const useEnforcementStore = create<EnforcementStore>((set, get) => ({
rawRecords: [],
records: [],
plans: [],
totalElements: 0,
totalPages: 0,
loading: false,
@ -41,17 +37,10 @@ export const useEnforcementStore = create<EnforcementStore>((set, get) => ({
set({ loading: true, error: null });
try {
const [res, planModule] = await Promise.all([
getEnforcementRecords(params),
// plans는 아직 mock 유지 (EnforcementPlan.tsx에서 사용)
get().plans.length > 0
? Promise.resolve(null)
: import('@/data/mock/enforcement').then((m) => m.MOCK_ENFORCEMENT_PLANS),
]);
const res = await getEnforcementRecords(params);
set({
rawRecords: res.content,
records: res.content.map(toLegacyRecord),
plans: planModule ?? get().plans,
totalElements: res.totalElements,
totalPages: res.totalPages,
loaded: true,

파일 보기

@ -7,7 +7,6 @@ import {
type EventStats,
type LegacyEventRecord,
} from '@/services/event';
import type { AlertRecord } from '@data/mock/events';
/** @deprecated LegacyEventRecord 대신 PredictionEvent 사용 권장 */
export type { LegacyEventRecord as EventRecord } from '@/services/event';
@ -17,8 +16,6 @@ interface EventStore {
rawEvents: PredictionEvent[];
/** 하위 호환용 레거시 형식 이벤트 */
events: LegacyEventRecord[];
/** 알림 (아직 mock — AIAlert, MobileService에서 사용) */
alerts: AlertRecord[];
/** 상태별 통계 */
stats: EventStats;
/** 페이지네이션 */
@ -39,7 +36,6 @@ interface EventStore {
export const useEventStore = create<EventStore>((set, get) => ({
rawEvents: [],
events: [],
alerts: [],
stats: {},
totalElements: 0,
totalPages: 0,
@ -55,18 +51,11 @@ export const useEventStore = create<EventStore>((set, get) => ({
set({ loading: true, error: null });
try {
const [res, alertModule] = await Promise.all([
getEvents(params),
// alerts는 아직 mock 유지 (다른 화면에서 사용)
get().alerts.length > 0
? Promise.resolve(null)
: import('@data/mock/events').then((m) => m.MOCK_ALERTS),
]);
const res = await getEvents(params);
const legacy = res.content.map(toLegacyEvent);
set({
rawEvents: res.content,
events: legacy,
alerts: alertModule ?? get().alerts,
totalElements: res.totalElements,
totalPages: res.totalPages,
currentPage: res.number,

파일 보기

@ -7,6 +7,7 @@ import {
toMonthlyTrend,
toViolationTypes,
} from '@/services/kpi';
import { toDateParam } from '@shared/utils/dateFormat';
interface KpiStore {
metrics: KpiMetric[];
@ -32,11 +33,10 @@ export const useKpiStore = create<KpiStore>((set, get) => ({
// 6개월 범위로 월별 통계 조회
const now = new Date();
const from = new Date(now.getFullYear(), now.getMonth() - 6, 1);
const formatDate = (d: Date) => d.toISOString().substring(0, 10);
const [kpiData, monthlyData] = await Promise.all([
getKpiMetrics(),
getMonthlyStats(formatDate(from), formatDate(now)),
getMonthlyStats(toDateParam(from), toDateParam(now)),
]);
set({

파일 보기

@ -1,14 +0,0 @@
import { create } from 'zustand';
import { MOCK_TRANSFERS, type TransferRecord } from '@/data/mock/transfers';
interface TransferStore {
transfers: TransferRecord[];
loaded: boolean;
load: () => void;
}
export const useTransferStore = create<TransferStore>((set) => ({
transfers: [],
loaded: false,
load: () => set({ transfers: MOCK_TRANSFERS, loaded: true }),
}));

파일 보기

@ -71,22 +71,22 @@ def upsert_results(results: list['AnalysisResult']) -> int:
insert_sql = """
INSERT INTO vessel_analysis_results (
mmsi, timestamp, vessel_type, confidence, fishing_pct,
cluster_id, season, zone, dist_to_baseline_nm, activity_state,
mmsi, analyzed_at, vessel_type, confidence, fishing_pct,
cluster_id, season, zone_code, dist_to_baseline_nm, activity_state,
ucaf_score, ucft_score, is_dark, gap_duration_min,
spoofing_score, bd09_offset_m, speed_jump_count,
cluster_size, is_leader, fleet_role,
fleet_cluster_id, fleet_is_leader, fleet_role,
risk_score, risk_level,
is_transship_suspect, transship_pair_mmsi, transship_duration_min,
features, analyzed_at
transship_suspect, transship_pair_mmsi, transship_duration_min,
features
) VALUES %s
ON CONFLICT (mmsi, timestamp) DO UPDATE SET
ON CONFLICT (mmsi, analyzed_at) DO UPDATE SET
vessel_type = EXCLUDED.vessel_type,
confidence = EXCLUDED.confidence,
fishing_pct = EXCLUDED.fishing_pct,
cluster_id = EXCLUDED.cluster_id,
season = EXCLUDED.season,
zone = EXCLUDED.zone,
zone_code = EXCLUDED.zone_code,
dist_to_baseline_nm = EXCLUDED.dist_to_baseline_nm,
activity_state = EXCLUDED.activity_state,
ucaf_score = EXCLUDED.ucaf_score,
@ -96,16 +96,15 @@ def upsert_results(results: list['AnalysisResult']) -> int:
spoofing_score = EXCLUDED.spoofing_score,
bd09_offset_m = EXCLUDED.bd09_offset_m,
speed_jump_count = EXCLUDED.speed_jump_count,
cluster_size = EXCLUDED.cluster_size,
is_leader = EXCLUDED.is_leader,
fleet_cluster_id = EXCLUDED.fleet_cluster_id,
fleet_is_leader = EXCLUDED.fleet_is_leader,
fleet_role = EXCLUDED.fleet_role,
risk_score = EXCLUDED.risk_score,
risk_level = EXCLUDED.risk_level,
is_transship_suspect = EXCLUDED.is_transship_suspect,
transship_suspect = EXCLUDED.transship_suspect,
transship_pair_mmsi = EXCLUDED.transship_pair_mmsi,
transship_duration_min = EXCLUDED.transship_duration_min,
features = EXCLUDED.features,
analyzed_at = EXCLUDED.analyzed_at
features = EXCLUDED.features
"""
try:

파일 보기

@ -75,13 +75,13 @@ class AnalysisResult:
return (
str(self.mmsi),
self.timestamp,
self.analyzed_at, # analyzed_at (PK 파티션키)
str(self.vessel_type),
_f(self.confidence),
_f(self.fishing_pct),
_i(self.cluster_id),
str(self.season),
str(self.zone),
str(self.zone), # → zone_code
_f(self.dist_to_baseline_nm),
str(self.activity_state),
_f(self.ucaf_score),
@ -91,14 +91,13 @@ class AnalysisResult:
_f(self.spoofing_score),
_f(self.bd09_offset_m),
_i(self.speed_jump_count),
_i(self.cluster_size),
bool(self.is_leader),
_i(self.cluster_id), # → fleet_cluster_id
bool(self.is_leader), # → fleet_is_leader
str(self.fleet_role),
_i(self.risk_score),
str(self.risk_level),
bool(self.is_transship_suspect),
bool(self.is_transship_suspect), # → transship_suspect
str(self.transship_pair_mmsi),
_i(self.transship_duration_min),
json.dumps(safe_features),
self.analyzed_at,
)