Merge pull request 'release: 2026-04-07.2 (prediction e2e + 프론트 mock 정리)' (#7) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 16s
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 16s
This commit is contained in:
커밋
1c69e2cdb8
@ -24,7 +24,7 @@ public class PredictionKpi {
|
|||||||
@Column(name = "trend", length = 10)
|
@Column(name = "trend", length = 10)
|
||||||
private String trend;
|
private String trend;
|
||||||
|
|
||||||
@Column(name = "delta_pct", precision = 5, scale = 2)
|
@Column(name = "delta_pct", precision = 12, scale = 2)
|
||||||
private BigDecimal deltaPct;
|
private BigDecimal deltaPct;
|
||||||
|
|
||||||
@Column(name = "updated_at")
|
@Column(name = "updated_at")
|
||||||
|
|||||||
@ -57,7 +57,7 @@ public class PredictionStatsDaily {
|
|||||||
@Column(name = "false_positive_count")
|
@Column(name = "false_positive_count")
|
||||||
private Integer falsePositiveCount;
|
private Integer falsePositiveCount;
|
||||||
|
|
||||||
@Column(name = "ai_accuracy_pct", precision = 5, scale = 2)
|
@Column(name = "ai_accuracy_pct", precision = 12, scale = 2)
|
||||||
private BigDecimal aiAccuracyPct;
|
private BigDecimal aiAccuracyPct;
|
||||||
|
|
||||||
@Column(name = "updated_at")
|
@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")
|
@Column(name = "false_positive_count")
|
||||||
private Integer falsePositiveCount;
|
private Integer falsePositiveCount;
|
||||||
|
|
||||||
@Column(name = "ai_accuracy_pct", precision = 5, scale = 2)
|
@Column(name = "ai_accuracy_pct", precision = 12, scale = 2)
|
||||||
private BigDecimal aiAccuracyPct;
|
private BigDecimal aiAccuracyPct;
|
||||||
|
|
||||||
@Column(name = "updated_at")
|
@Column(name = "updated_at")
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import org.springframework.format.annotation.DateTimeFormat;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -20,6 +21,7 @@ public class StatsController {
|
|||||||
private final PredictionKpiRepository kpiRepository;
|
private final PredictionKpiRepository kpiRepository;
|
||||||
private final PredictionStatsMonthlyRepository monthlyRepository;
|
private final PredictionStatsMonthlyRepository monthlyRepository;
|
||||||
private final PredictionStatsDailyRepository dailyRepository;
|
private final PredictionStatsDailyRepository dailyRepository;
|
||||||
|
private final PredictionStatsHourlyRepository hourlyRepository;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 실시간 KPI 전체 목록 조회
|
* 실시간 KPI 전체 목록 조회
|
||||||
@ -57,4 +59,18 @@ public class StatsController {
|
|||||||
) {
|
) {
|
||||||
return dailyRepository.findByStatDateBetweenOrderByStatDateAsc(from, to);
|
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 AuditLog as ApiAuditLog,
|
||||||
type AuditStats,
|
type AuditStats,
|
||||||
} from '@/services/adminApi';
|
} from '@/services/adminApi';
|
||||||
|
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||||
import { PermissionsPanel } from './PermissionsPanel';
|
import { PermissionsPanel } from './PermissionsPanel';
|
||||||
import { UserRoleAssignDialog } from './UserRoleAssignDialog';
|
import { UserRoleAssignDialog } from './UserRoleAssignDialog';
|
||||||
|
|
||||||
@ -153,7 +154,7 @@ export function AccessControl() {
|
|||||||
{ key: 'lastLoginDtm', label: '최종 로그인', width: '140px', sortable: true,
|
{ key: 'lastLoginDtm', label: '최종 로그인', width: '140px', sortable: true,
|
||||||
render: (v) => (
|
render: (v) => (
|
||||||
<span className="text-muted-foreground font-mono text-[10px]">
|
<span className="text-muted-foreground font-mono text-[10px]">
|
||||||
{v ? new Date(v as string).toLocaleString('ko-KR') : '-'}
|
{formatDateTime(v as string)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -178,7 +179,7 @@ export function AccessControl() {
|
|||||||
// ── 감사 로그 컬럼 ──────────────
|
// ── 감사 로그 컬럼 ──────────────
|
||||||
const auditColumns: DataColumn<ApiAuditLog & Record<string, unknown>>[] = useMemo(() => [
|
const auditColumns: DataColumn<ApiAuditLog & Record<string, unknown>>[] = useMemo(() => [
|
||||||
{ key: 'createdAt', label: '일시', width: '160px', sortable: true,
|
{ 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,
|
{ key: 'userAcnt', label: '사용자', width: '90px', sortable: true,
|
||||||
render: (v) => <span className="text-cyan-400 font-mono">{(v as string) || '-'}</span> },
|
render: (v) => <span className="text-cyan-400 font-mono">{(v as string) || '-'}</span> },
|
||||||
{ key: 'actionCd', label: '액션', width: '180px', sortable: true,
|
{ 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 { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
import { fetchAccessLogs, fetchAccessStats, type AccessLog, type AccessStats } from '@/services/adminApi';
|
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) => (
|
{items.map((it) => (
|
||||||
<tr key={it.accessSn} className="border-t border-border hover:bg-surface-overlay/50">
|
<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-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-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-purple-400 font-mono">{it.httpMethod}</td>
|
||||||
<td className="px-3 py-2 text-heading font-mono text-[10px] max-w-md truncate">{it.requestPath}</td>
|
<td className="px-3 py-2 text-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">
|
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||||
<Settings className="w-5 h-5 text-muted-foreground" />
|
<Settings className="w-5 h-5 text-muted-foreground" />
|
||||||
{t('adminPanel.title')}
|
{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>
|
</h2>
|
||||||
<p className="text-[10px] text-hint mt-0.5">{t('adminPanel.desc')}</p>
|
<p className="text-[10px] text-hint mt-0.5">{t('adminPanel.desc')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { Loader2, RefreshCw } from 'lucide-react';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
import { fetchAuditLogs, fetchAuditStats, type AuditLog, type AuditStats } from '@/services/adminApi';
|
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) => (
|
{items.map((it) => (
|
||||||
<tr key={it.auditSn} className="border-t border-border hover:bg-surface-overlay/50">
|
<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-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-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-heading font-medium">{it.actionCd}</td>
|
||||||
<td className="px-3 py-2 text-muted-foreground">{it.resourceType ?? '-'} {it.resourceId ? `(${it.resourceId})` : ''}</td>
|
<td className="px-3 py-2 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">
|
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||||
<Database className="w-5 h-5 text-cyan-400" />
|
<Database className="w-5 h-5 text-cyan-400" />
|
||||||
{t('dataHub.title')}
|
{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>
|
</h2>
|
||||||
<p className="text-[10px] text-hint mt-0.5">
|
<p className="text-[10px] text-hint mt-0.5">
|
||||||
{t('dataHub.desc')}
|
{t('dataHub.desc')}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { Loader2, RefreshCw } from 'lucide-react';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
import { fetchLoginHistory, fetchLoginStats, type LoginHistory, type LoginStats } from '@/services/adminApi';
|
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">
|
<CardContent className="px-4 pb-4 space-y-1">
|
||||||
{stats.daily7d.map((d) => (
|
{stats.daily7d.map((d) => (
|
||||||
<div key={d.day} className="flex items-center justify-between text-[11px]">
|
<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">
|
<div className="flex gap-3">
|
||||||
<span className="text-green-400">성공 {d.success}</span>
|
<span className="text-green-400">성공 {d.success}</span>
|
||||||
<span className="text-orange-400">실패 {d.failed}</span>
|
<span className="text-orange-400">실패 {d.failed}</span>
|
||||||
@ -118,7 +119,7 @@ export function LoginHistoryView() {
|
|||||||
{items.map((it) => (
|
{items.map((it) => (
|
||||||
<tr key={it.histSn} className="border-t border-border hover:bg-surface-overlay/50">
|
<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-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 text-cyan-400">{it.userAcnt}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<Badge className={`border-0 text-[9px] ${resultColor(it.result)}`}>{it.result}</Badge>
|
<Badge className={`border-0 text-[9px] ${resultColor(it.result)}`}>{it.result}</Badge>
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
CheckCircle, Clock, Pin, Monitor, MessageSquare, X,
|
CheckCircle, Clock, Pin, Monitor, MessageSquare, X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||||
|
import { toDateParam } from '@shared/utils/dateFormat';
|
||||||
import { SaveButton } from '@shared/components/common/SaveButton';
|
import { SaveButton } from '@shared/components/common/SaveButton';
|
||||||
import type { SystemNotice, NoticeType, NoticeDisplay } from '@shared/components/common/NotificationBanner';
|
import type { SystemNotice, NoticeType, NoticeDisplay } from '@shared/components/common/NotificationBanner';
|
||||||
|
|
||||||
@ -75,19 +76,19 @@ export function NoticeManagement() {
|
|||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [form, setForm] = useState<SystemNotice>({
|
const [form, setForm] = useState<SystemNotice>({
|
||||||
id: '', type: 'info', display: 'banner', title: '', message: '',
|
id: '', type: 'info', display: 'banner', title: '', message: '',
|
||||||
startDate: new Date().toISOString().slice(0, 10),
|
startDate: toDateParam(),
|
||||||
endDate: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10),
|
endDate: toDateParam(new Date(Date.now() + 7 * 86400000)),
|
||||||
targetRoles: [], dismissible: true, pinned: false,
|
targetRoles: [], dismissible: true, pinned: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const now = new Date().toISOString().slice(0, 10);
|
const now = toDateParam();
|
||||||
|
|
||||||
const openNew = () => {
|
const openNew = () => {
|
||||||
setForm({
|
setForm({
|
||||||
id: `N-${String(notices.length + 1).padStart(3, '0')}`,
|
id: `N-${String(notices.length + 1).padStart(3, '0')}`,
|
||||||
type: 'info', display: 'banner', title: '', message: '',
|
type: 'info', display: 'banner', title: '', message: '',
|
||||||
startDate: now,
|
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,
|
targetRoles: [], dismissible: true, pinned: false,
|
||||||
});
|
});
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
@ -141,6 +142,9 @@ export function NoticeManagement() {
|
|||||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||||
<Bell className="w-5 h-5 text-yellow-400" />
|
<Bell className="w-5 h-5 text-yellow-400" />
|
||||||
{t('notices.title')}
|
{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>
|
</h2>
|
||||||
<p className="text-[10px] text-hint mt-0.5">
|
<p className="text-[10px] text-hint mt-0.5">
|
||||||
{t('notices.desc')}
|
{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">
|
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||||
<Database className="w-5 h-5 text-cyan-400" />
|
<Database className="w-5 h-5 text-cyan-400" />
|
||||||
{t('systemConfig.title')}
|
{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>
|
</h2>
|
||||||
<p className="text-[10px] text-hint mt-0.5">
|
<p className="text-[10px] text-hint mt-0.5">
|
||||||
{t('systemConfig.desc')}
|
{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">
|
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||||
<Brain className="w-5 h-5 text-purple-400" />
|
<Brain className="w-5 h-5 text-purple-400" />
|
||||||
{t('modelManagement.title')}
|
{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>
|
</h2>
|
||||||
<p className="text-[10px] text-hint mt-0.5">
|
<p className="text-[10px] text-hint mt-0.5">
|
||||||
{t('modelManagement.desc')}
|
{t('modelManagement.desc')}
|
||||||
|
|||||||
@ -117,7 +117,12 @@ export function MLOpsPage() {
|
|||||||
<div className="p-5 space-y-4">
|
<div className="p-5 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<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>
|
<p className="text-[10px] text-hint mt-0.5">{t('mlops.desc')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -16,6 +16,13 @@ import { useKpiStore } from '@stores/kpiStore';
|
|||||||
import { useEventStore } from '@stores/eventStore';
|
import { useEventStore } from '@stores/eventStore';
|
||||||
import { usePatrolStore } from '@stores/patrolStore';
|
import { usePatrolStore } from '@stores/patrolStore';
|
||||||
import { fetchVesselAnalysis, type VesselAnalysisItem } from '@/services/vesselAnalysisApi';
|
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';
|
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 = [
|
const VESSEL_TYPE_COLORS: Record<string, string> = {
|
||||||
{ area: '서해 NLL', vessels: 8, risk: 95, trend: 'up' },
|
'EEZ 침범': '#ef4444',
|
||||||
{ area: 'EEZ 북부', vessels: 14, risk: 91, trend: 'up' },
|
'다크베셀': '#f97316',
|
||||||
{ area: '서해 5도', vessels: 11, risk: 88, trend: 'stable' },
|
'불법환적': '#a855f7',
|
||||||
{ area: 'EEZ 서부', vessels: 6, risk: 72, trend: 'down' },
|
'MMSI변조': '#eab308',
|
||||||
{ area: '동해 중부', vessels: 4, risk: 58, trend: 'up' },
|
'고속도주': '#06b6d4',
|
||||||
{ area: 'EEZ 남부', vessels: 3, risk: 45, trend: 'down' },
|
'어구 불법': '#6b7280',
|
||||||
{ area: '남해 서부', vessels: 1, risk: 22, trend: 'stable' },
|
};
|
||||||
];
|
const DEFAULT_PIE_COLORS = ['#ef4444', '#f97316', '#a855f7', '#eab308', '#06b6d4', '#3b82f6', '#10b981', '#6b7280'];
|
||||||
|
|
||||||
// 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' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// TODO: /api/weather 연동 예정
|
// TODO: /api/weather 연동 예정
|
||||||
const WEATHER_DATA = {
|
const WEATHER_DATA = {
|
||||||
@ -292,6 +283,14 @@ export function Dashboard() {
|
|||||||
const patrolStore = usePatrolStore();
|
const patrolStore = usePatrolStore();
|
||||||
|
|
||||||
const [riskVessels, setRiskVessels] = useState<VesselAnalysisItem[]>([]);
|
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 (!kpiStore.loaded) kpiStore.load(); }, [kpiStore.loaded, kpiStore.load]);
|
||||||
useEffect(() => { if (!eventStore.loaded) eventStore.load(); }, [eventStore.loaded, eventStore.load]);
|
useEffect(() => { if (!eventStore.loaded) eventStore.load(); }, [eventStore.loaded, eventStore.load]);
|
||||||
@ -356,6 +355,64 @@ export function Dashboard() {
|
|||||||
fuel: s.fuel,
|
fuel: s.fuel,
|
||||||
})), [patrolStore.ships]);
|
})), [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 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'];
|
const defconLabels = ['', 'DEFCON 1', 'DEFCON 2', 'DEFCON 3', 'DEFCON 4', 'DEFCON 5'];
|
||||||
|
|
||||||
|
|||||||
@ -6,10 +6,10 @@ import {
|
|||||||
Eye, AlertTriangle, Radio, RotateCcw,
|
Eye, AlertTriangle, Radio, RotateCcw,
|
||||||
MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2
|
MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||||
import { GearIdentification } from './GearIdentification';
|
import { GearIdentification } from './GearIdentification';
|
||||||
import { RealAllVessels } from './RealVesselAnalysis';
|
import { RealAllVessels, RealTransshipSuspects } from './RealVesselAnalysis';
|
||||||
import { PieChart as EcPieChart } from '@lib/charts';
|
import { PieChart as EcPieChart } from '@lib/charts';
|
||||||
import { useTransferStore } from '@stores/transferStore';
|
|
||||||
import {
|
import {
|
||||||
fetchVesselAnalysis,
|
fetchVesselAnalysis,
|
||||||
filterDarkVessels,
|
filterDarkVessels,
|
||||||
@ -72,7 +72,7 @@ const VTS_ITEMS = [
|
|||||||
{ name: '태안연안', active: false },
|
{ name: '태안연안', active: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── 환적 탐지 데이터: useTransferStore().transfers 사용 ───
|
// ─── 환적 탐지 뷰: RealTransshipSuspects 컴포넌트 사용 ───
|
||||||
|
|
||||||
// ─── 서브 컴포넌트 ─────────────────────
|
// ─── 서브 컴포넌트 ─────────────────────
|
||||||
|
|
||||||
@ -159,25 +159,6 @@ function StatusRing({ status, riskPct }: { status: VesselStatus; riskPct: number
|
|||||||
// ─── 환적 탐지 뷰 ─────────────────────
|
// ─── 환적 탐지 뷰 ─────────────────────
|
||||||
|
|
||||||
function TransferView() {
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@ -206,84 +187,8 @@ function TransferView() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 환적 이벤트 */}
|
{/* prediction 분석 결과 기반 환적 의심 선박 */}
|
||||||
{TRANSFER_DATA.map((tr) => (
|
<RealTransshipSuspects />
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -430,7 +335,7 @@ export function ChinaFishing() {
|
|||||||
<div className="flex items-center gap-3">
|
<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">
|
<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" />
|
<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>
|
</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="새로고침">
|
<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" />
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
filterDarkVessels,
|
filterDarkVessels,
|
||||||
type VesselAnalysisItem,
|
type VesselAnalysisItem,
|
||||||
} from '@/services/vesselAnalysisApi';
|
} from '@/services/vesselAnalysisApi';
|
||||||
|
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||||
|
|
||||||
/* SFR-09: 불법 어선(AIS 조작·위장·Dark Vessel) 패턴 탐지 */
|
/* SFR-09: 불법 어선(AIS 조작·위장·Dark Vessel) 패턴 탐지 */
|
||||||
|
|
||||||
@ -53,7 +54,7 @@ function mapItemToSuspect(item: VesselAnalysisItem, idx: number): Suspect {
|
|||||||
flag: deriveFlag(item.mmsi),
|
flag: deriveFlag(item.mmsi),
|
||||||
pattern: derivePattern(item),
|
pattern: derivePattern(item),
|
||||||
risk,
|
risk,
|
||||||
lastAIS: item.timestamp ? new Date(item.timestamp).toLocaleString('ko-KR') : '-',
|
lastAIS: formatDateTime(item.timestamp),
|
||||||
status,
|
status,
|
||||||
label: risk >= 90 ? (status === '추적중' ? '불법' : '-') : status === '정상' ? '정상' : '-',
|
label: risk >= 90 ? (status === '추적중' ? '불법' : '-') : status === '정상' ? '정상' : '-',
|
||||||
lat: 0,
|
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 { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
|
||||||
import type { MarkerData } from '@lib/map';
|
import type { MarkerData } from '@lib/map';
|
||||||
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
|
import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi';
|
||||||
|
import { formatDate } from '@shared/utils/dateFormat';
|
||||||
|
|
||||||
/* SFR-10: 불법 어망·어구 탐지 및 관리 */
|
/* SFR-10: 불법 어망·어구 탐지 및 관리 */
|
||||||
|
|
||||||
@ -41,7 +42,7 @@ function mapGroupToGear(g: GearGroupItem, idx: number): Gear {
|
|||||||
zone: g.groupType === 'GEAR_IN_ZONE' ? '지정해역' : '지정해역 외',
|
zone: g.groupType === 'GEAR_IN_ZONE' ? '지정해역' : '지정해역 외',
|
||||||
status,
|
status,
|
||||||
permit: 'NONE',
|
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') : '-',
|
lastSignal: g.snapshotTime ? new Date(g.snapshotTime).toLocaleTimeString('ko-KR') : '-',
|
||||||
risk,
|
risk,
|
||||||
lat: g.centerLat,
|
lat: g.centerLat,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { Badge } from '@shared/components/ui/badge';
|
|||||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||||
import { Send, Loader2, AlertTriangle } from 'lucide-react';
|
import { Send, Loader2, AlertTriangle } from 'lucide-react';
|
||||||
import { getAlerts, type PredictionAlert } from '@/services/event';
|
import { getAlerts, type PredictionAlert } from '@/services/event';
|
||||||
|
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||||
|
|
||||||
/* SFR-17: 현장 함정 즉각 대응 AI 알림 메시지 발송 기능 */
|
/* SFR-17: 현장 함정 즉각 대응 AI 알림 메시지 발송 기능 */
|
||||||
|
|
||||||
@ -125,7 +126,7 @@ export function AIAlert() {
|
|||||||
alerts.map((a) => ({
|
alerts.map((a) => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
eventId: a.eventId,
|
eventId: a.eventId,
|
||||||
time: a.sentAt ? new Date(a.sentAt).toLocaleString('ko-KR') : '-',
|
time: formatDateTime(a.sentAt),
|
||||||
channel: a.channel ?? '-',
|
channel: a.channel ?? '-',
|
||||||
recipient: a.recipient ?? '-',
|
recipient: a.recipient ?? '-',
|
||||||
confidence: a.aiConfidence != null ? String(a.aiConfidence) : '',
|
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 { Smartphone, MapPin, Bell, Wifi, WifiOff, Shield, AlertTriangle, Navigation } from 'lucide-react';
|
||||||
import { BaseMap, createMarkerLayer, createPolylineLayer, useMapLayers, type MapHandle, type MarkerData } from '@lib/map';
|
import { BaseMap, createMarkerLayer, createPolylineLayer, useMapLayers, type MapHandle, type MarkerData } from '@lib/map';
|
||||||
import { useEventStore } from '@stores/eventStore';
|
import { useEventStore } from '@stores/eventStore';
|
||||||
|
import { formatTime } from '@shared/utils/dateFormat';
|
||||||
|
|
||||||
/* SFR-15: 단속요원 이용 모바일 대응 서비스 */
|
/* SFR-15: 단속요원 이용 모바일 대응 서비스 */
|
||||||
|
|
||||||
@ -25,7 +26,7 @@ const MOBILE_MARKERS = [
|
|||||||
export function MobileService() {
|
export function MobileService() {
|
||||||
const { t } = useTranslation('fieldOps');
|
const { t } = useTranslation('fieldOps');
|
||||||
const mapRef = useRef<MapHandle>(null);
|
const mapRef = useRef<MapHandle>(null);
|
||||||
const { alerts: storeAlerts, load } = useEventStore();
|
const { events, load } = useEventStore();
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
const buildLayers = useCallback(() => [
|
const buildLayers = useCallback(() => [
|
||||||
@ -40,13 +41,13 @@ export function MobileService() {
|
|||||||
|
|
||||||
const ALERTS = useMemo(
|
const ALERTS = useMemo(
|
||||||
() =>
|
() =>
|
||||||
storeAlerts.slice(0, 3).map((a) => ({
|
events.slice(0, 3).map((e) => ({
|
||||||
time: a.time.slice(0, 5),
|
time: formatTime(e.time).slice(0, 5),
|
||||||
title: a.type === 'EEZ 침범' ? `[긴급] ${a.type} 탐지` : a.type,
|
title: e.type === 'EEZ 침범' || e.level === 'CRITICAL' ? `[긴급] ${e.title}` : e.title,
|
||||||
detail: `${a.location}`,
|
detail: e.area ?? e.detail,
|
||||||
level: a.confidence >= 95 ? 'CRITICAL' : a.confidence >= 90 ? 'HIGH' : 'MEDIUM',
|
level: e.level,
|
||||||
})),
|
})),
|
||||||
[storeAlerts],
|
[events],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -31,7 +31,12 @@ export function ShipAgent() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-5 space-y-4">
|
<div className="p-5 space-y-4">
|
||||||
<div>
|
<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>
|
<p className="text-[10px] text-hint mt-0.5">{t('shipAgent.desc')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<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 { useTranslation } from 'react-i18next';
|
||||||
import { Card, CardContent } from '@shared/components/ui/card';
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
@ -7,6 +7,7 @@ import type { LucideIcon } from 'lucide-react';
|
|||||||
import { AreaChart, PieChart } from '@lib/charts';
|
import { AreaChart, PieChart } from '@lib/charts';
|
||||||
import { useKpiStore } from '@stores/kpiStore';
|
import { useKpiStore } from '@stores/kpiStore';
|
||||||
import { useEventStore } from '@stores/eventStore';
|
import { useEventStore } from '@stores/eventStore';
|
||||||
|
import { getHourlyStats, type PredictionStatsHourly } from '@/services/kpi';
|
||||||
import { SystemStatusPanel } from './SystemStatusPanel';
|
import { SystemStatusPanel } from './SystemStatusPanel';
|
||||||
|
|
||||||
/* SFR-12: 모니터링 및 경보 현황판(대시보드) */
|
/* SFR-12: 모니터링 및 경보 현황판(대시보드) */
|
||||||
@ -20,7 +21,6 @@ const KPI_UI_MAP: Record<string, { icon: LucideIcon; color: string }> = {
|
|||||||
'추적 중': { icon: Target, color: '#06b6d4' },
|
'추적 중': { icon: Target, color: '#06b6d4' },
|
||||||
'나포/검문': { icon: Shield, color: '#10b981' },
|
'나포/검문': { 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> = {
|
const PIE_COLOR_MAP: Record<string, string> = {
|
||||||
'EEZ 침범': '#ef4444', '다크베셀': '#f97316', 'MMSI 변조': '#eab308',
|
'EEZ 침범': '#ef4444', '다크베셀': '#f97316', 'MMSI 변조': '#eab308',
|
||||||
@ -33,8 +33,35 @@ export function MonitoringDashboard() {
|
|||||||
const kpiStore = useKpiStore();
|
const kpiStore = useKpiStore();
|
||||||
const eventStore = useEventStore();
|
const eventStore = useEventStore();
|
||||||
|
|
||||||
|
const [hourlyStats, setHourlyStats] = useState<PredictionStatsHourly[]>([]);
|
||||||
|
|
||||||
useEffect(() => { if (!kpiStore.loaded) kpiStore.load(); }, [kpiStore.loaded, kpiStore.load]);
|
useEffect(() => { if (!kpiStore.loaded) kpiStore.load(); }, [kpiStore.loaded, kpiStore.load]);
|
||||||
useEffect(() => { if (!eventStore.loaded) eventStore.load(); }, [eventStore.loaded, eventStore.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 매핑
|
// KPI: store metrics + UI 매핑
|
||||||
const KPI = kpiStore.metrics.map((m) => ({
|
const KPI = kpiStore.metrics.map((m) => ({
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
cancelLabelSession,
|
cancelLabelSession,
|
||||||
type LabelSession as LabelSessionType,
|
type LabelSession as LabelSessionType,
|
||||||
} from '@/services/parentInferenceApi';
|
} 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>
|
<Badge className={`border-0 text-[9px] ${STATUS_COLORS[it.status] || ''}`}>{it.status}</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-muted-foreground">{it.createdByAcnt || '-'}</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">
|
<td className="px-3 py-2 text-center">
|
||||||
{it.status === 'ACTIVE' && (
|
{it.status === 'ACTIVE' && (
|
||||||
<button type="button" disabled={!canUpdate || busy === it.id} onClick={() => handleCancel(it.id)}
|
<button type="button" disabled={!canUpdate || busy === it.id} onClick={() => handleCancel(it.id)}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
releaseExclusion,
|
releaseExclusion,
|
||||||
type CandidateExclusion,
|
type CandidateExclusion,
|
||||||
} from '@/services/parentInferenceApi';
|
} 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-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.reason || '-'}</td>
|
||||||
<td className="px-3 py-2 text-muted-foreground">{it.actorAcnt || '-'}</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">
|
<td className="px-3 py-2 text-center">
|
||||||
<button type="button" disabled={!canRelease || busy === it.id} onClick={() => handleRelease(it.id)}
|
<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-400" title="해제">
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
reviewParent,
|
reviewParent,
|
||||||
type ParentResolution,
|
type ParentResolution,
|
||||||
} from '@/services/parentInferenceApi';
|
} from '@/services/parentInferenceApi';
|
||||||
|
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 모선 확정/거부/리셋 페이지.
|
* 모선 확정/거부/리셋 페이지.
|
||||||
@ -233,7 +234,7 @@ export function ParentReview() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-cyan-400 font-mono">{it.selectedParentMmsi || '-'}</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]">
|
<td className="px-3 py-2 text-muted-foreground text-[10px]">
|
||||||
{new Date(it.updatedAt).toLocaleString('ko-KR')}
|
{formatDateTime(it.updatedAt)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<div className="flex items-center justify-center gap-1">
|
<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="p-5 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<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>
|
<p className="text-[10px] text-hint mt-0.5">{t('fleetOptimization.desc')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
|
|||||||
@ -96,7 +96,13 @@ export function PatrolRoute() {
|
|||||||
<div className="p-5 space-y-4">
|
<div className="p-5 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<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>
|
<p className="text-[10px] text-hint mt-0.5">{t('patrolRoute.desc')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5">
|
<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 { BaseMap, STATIC_LAYERS, createHeatmapLayer, useMapLayers, type MapHandle } from '@lib/map';
|
||||||
import type { HeatPoint } from '@lib/map';
|
import type { HeatPoint } from '@lib/map';
|
||||||
import { Card, CardContent } from '@shared/components/ui/card';
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Map, Layers, Clock, BarChart3, AlertTriangle, Printer, Download, Ship, TrendingUp } from 'lucide-react';
|
||||||
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 } from '@lib/charts';
|
||||||
import { AreaChart as EcAreaChart, LineChart as EcLineChart, PieChart as EcPieChart, BarChart as EcBarChart, BaseChart } from '@lib/charts';
|
|
||||||
import type { EChartsOption } from 'echarts';
|
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: 격자 기반 불법조업 위험도 지도 생성·시각화
|
* SFR-05: 격자 기반 불법조업 위험도 지도 생성·시각화
|
||||||
@ -23,11 +32,8 @@ const RISK_LEVELS = [
|
|||||||
{ level: 2, label: '낮음', color: '#3b82f6', count: 687, pct: 37.3 },
|
{ level: 2, label: '낮음', color: '#3b82f6', count: 687, pct: 37.3 },
|
||||||
{ level: 1, label: '안전', color: '#22c55e', count: 629, pct: 34.2 },
|
{ level: 1, label: '안전', color: '#22c55e', count: 629, pct: 34.2 },
|
||||||
];
|
];
|
||||||
const GRID_ROWS = 10;
|
// prediction_risk_grid 테이블 데이터 수집 전이라 빈 격자 반환
|
||||||
const GRID_COLS = 18;
|
const generateGrid = (): number[][] => [];
|
||||||
const generateGrid = () => Array.from({ length: GRID_ROWS }, () =>
|
|
||||||
Array.from({ length: GRID_COLS }, () => Math.floor(Math.random() * 5) + 1)
|
|
||||||
);
|
|
||||||
const ZONE_SUMMARY = [
|
const ZONE_SUMMARY = [
|
||||||
{ zone: '서해 NLL', risk: 87, trend: '+5', vessels: 18 },
|
{ zone: '서해 NLL', risk: 87, trend: '+5', vessels: 18 },
|
||||||
{ zone: 'EEZ 북부', risk: 72, trend: '+3', vessels: 24 },
|
{ zone: 'EEZ 북부', risk: 72, trend: '+3', vessels: 24 },
|
||||||
@ -151,16 +157,13 @@ function generateHeatPoints(): [number, number, number][] {
|
|||||||
|
|
||||||
const HEAT_POINTS = generateHeatPoints();
|
const HEAT_POINTS = generateHeatPoints();
|
||||||
|
|
||||||
type SelectedGrid = { row: number; col: number } | null;
|
|
||||||
|
|
||||||
export function RiskMap() {
|
export function RiskMap() {
|
||||||
const [grid] = useState(generateGrid);
|
// prediction_risk_grid 데이터 수집 전이라 빈 격자 유지
|
||||||
const [selectedGrid, setSelectedGrid] = useState<SelectedGrid>(null);
|
const grid = generateGrid();
|
||||||
|
void grid;
|
||||||
const [tab, setTab] = useState<Tab>('heatmap');
|
const [tab, setTab] = useState<Tab>('heatmap');
|
||||||
const mapRef = useRef<MapHandle>(null);
|
const mapRef = useRef<MapHandle>(null);
|
||||||
|
|
||||||
const riskColor = (level: number) => RISK_LEVELS.find(r => r.level === level)?.color || '#6b7280';
|
|
||||||
|
|
||||||
const buildLayers = useCallback(() => {
|
const buildLayers = useCallback(() => {
|
||||||
if (tab !== 'heatmap') return [];
|
if (tab !== 'heatmap') return [];
|
||||||
return [
|
return [
|
||||||
@ -175,8 +178,11 @@ export function RiskMap() {
|
|||||||
<div className="p-5 space-y-4">
|
<div className="p-5 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold text-heading flex items-center gap-2"><Map className="w-5 h-5 text-red-400" />격자 기반 불법조업 위험도 지도</h2>
|
<h2 className="text-lg font-bold text-heading flex items-center gap-2">
|
||||||
<p className="text-[10px] text-hint mt-0.5">SFR-05 | 위험도 히트맵 + MTIS 해양사고 통계 (중앙해양안전심판원)</p>
|
<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>
|
||||||
<div className="flex gap-1.5">
|
<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>
|
<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' && (
|
{tab === 'heatmap' && (
|
||||||
<div className="space-y-3">
|
<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">
|
<div className="flex gap-2">
|
||||||
{RISK_LEVELS.map(r => (
|
{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">
|
<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' && (
|
{tab === 'yearly' && (
|
||||||
<div className="space-y-3">
|
<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">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Card><CardContent className="p-4">
|
<Card><CardContent className="p-4">
|
||||||
<div className="text-[12px] font-bold text-label mb-3">해양사고 추세</div>
|
<div className="text-[12px] font-bold text-label mb-3">해양사고 추세</div>
|
||||||
@ -338,7 +351,7 @@ export function RiskMap() {
|
|||||||
{/* ── ② 선박 특성별 ── */}
|
{/* ── ② 선박 특성별 ── */}
|
||||||
{tab === 'shipProp' && (
|
{tab === 'shipProp' && (
|
||||||
<div className="space-y-3">
|
<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">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<Card><CardContent className="p-4">
|
<Card><CardContent className="p-4">
|
||||||
<div className="text-[12px] font-bold text-label mb-3">선박용도별</div>
|
<div className="text-[12px] font-bold text-label mb-3">선박용도별</div>
|
||||||
@ -379,7 +392,7 @@ export function RiskMap() {
|
|||||||
{/* ── ③ 사고종류별 ── */}
|
{/* ── ③ 사고종류별 ── */}
|
||||||
{tab === 'accType' && (
|
{tab === 'accType' && (
|
||||||
<div className="space-y-3">
|
<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">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Card><CardContent className="p-4">
|
<Card><CardContent className="p-4">
|
||||||
<div className="text-[12px] font-bold text-label mb-3">사고종류별 분포</div>
|
<div className="text-[12px] font-bold text-label mb-3">사고종류별 분포</div>
|
||||||
@ -413,7 +426,7 @@ export function RiskMap() {
|
|||||||
{/* ── ④ 시간적 특성별 ── */}
|
{/* ── ④ 시간적 특성별 ── */}
|
||||||
{tab === 'timeStat' && (
|
{tab === 'timeStat' && (
|
||||||
<div className="space-y-3">
|
<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">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<Card><CardContent className="p-4">
|
<Card><CardContent className="p-4">
|
||||||
<div className="text-[12px] font-bold text-label mb-3">월별 사고건수</div>
|
<div className="text-[12px] font-bold text-label mb-3">월별 사고건수</div>
|
||||||
@ -451,7 +464,7 @@ export function RiskMap() {
|
|||||||
{/* ── ⑤ 사고율 ── */}
|
{/* ── ⑤ 사고율 ── */}
|
||||||
{tab === 'accRate' && (
|
{tab === 'accRate' && (
|
||||||
<div className="space-y-3">
|
<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">
|
<Card><CardContent className="p-4">
|
||||||
<div className="text-[12px] font-bold text-label mb-3">선박용도별 사고율</div>
|
<div className="text-[12px] font-bold text-label mb-3">선박용도별 사고율</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
|||||||
@ -33,7 +33,12 @@ export function ExternalService() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-5 space-y-4">
|
<div className="p-5 space-y-4">
|
||||||
<div>
|
<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>
|
<p className="text-[10px] text-hint mt-0.5">{t('externalService.desc')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@ -40,7 +40,12 @@ export function ReportManagement() {
|
|||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<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>
|
<p className="text-xs text-hint mt-0.5">{t('reports.desc')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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 { BarChart3, Download } from 'lucide-react';
|
||||||
import { BarChart, AreaChart } from '@lib/charts';
|
import { BarChart, AreaChart } from '@lib/charts';
|
||||||
import {
|
import {
|
||||||
|
getKpiMetrics,
|
||||||
getMonthlyStats,
|
getMonthlyStats,
|
||||||
toMonthlyTrend,
|
toMonthlyTrend,
|
||||||
toViolationTypes,
|
toViolationTypes,
|
||||||
|
type PredictionKpi,
|
||||||
type PredictionStatsMonthly,
|
type PredictionStatsMonthly,
|
||||||
} from '@/services/kpi';
|
} from '@/services/kpi';
|
||||||
import type { MonthlyTrend, ViolationType } from '@data/mock/kpi';
|
import type { MonthlyTrend, ViolationType } from '@data/mock/kpi';
|
||||||
|
import { toDateParam } from '@shared/utils/dateFormat';
|
||||||
|
|
||||||
/* SFR-13: 통계·지표·성과 분석 */
|
/* SFR-13: 통계·지표·성과 분석 */
|
||||||
|
|
||||||
const KPI_DATA: {
|
interface KpiRow {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
target: string;
|
target: string;
|
||||||
current: string;
|
current: string;
|
||||||
status: string;
|
status: string;
|
||||||
[key: string]: unknown;
|
[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',
|
key: 'id',
|
||||||
label: 'ID',
|
label: 'ID',
|
||||||
@ -105,6 +72,7 @@ export function Statistics() {
|
|||||||
|
|
||||||
const [monthly, setMonthly] = useState<MonthlyTrend[]>([]);
|
const [monthly, setMonthly] = useState<MonthlyTrend[]>([]);
|
||||||
const [violationTypes, setViolationTypes] = useState<ViolationType[]>([]);
|
const [violationTypes, setViolationTypes] = useState<ViolationType[]>([]);
|
||||||
|
const [kpiMetrics, setKpiMetrics] = useState<PredictionKpi[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -117,17 +85,17 @@ export function Statistics() {
|
|||||||
try {
|
try {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const from = new Date(now.getFullYear(), now.getMonth() - 6, 1);
|
const from = new Date(now.getFullYear(), now.getMonth() - 6, 1);
|
||||||
const formatDate = (d: Date) => d.toISOString().substring(0, 10);
|
|
||||||
|
|
||||||
const data: PredictionStatsMonthly[] = await getMonthlyStats(
|
const [data, kpiData] = await Promise.all([
|
||||||
formatDate(from),
|
getMonthlyStats(toDateParam(from), toDateParam(now)),
|
||||||
formatDate(now),
|
getKpiMetrics().catch(() => [] as PredictionKpi[]),
|
||||||
);
|
]);
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
setMonthly(data.map(toMonthlyTrend));
|
setMonthly(data.map(toMonthlyTrend));
|
||||||
setViolationTypes(toViolationTypes(data));
|
setViolationTypes(toViolationTypes(data));
|
||||||
|
setKpiMetrics(kpiData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setError(
|
setError(
|
||||||
@ -154,6 +122,19 @@ export function Statistics() {
|
|||||||
|
|
||||||
const BY_TYPE = violationTypes;
|
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 (
|
return (
|
||||||
<div className="p-5 space-y-4">
|
<div className="p-5 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@ -254,7 +254,12 @@ export function MapControl() {
|
|||||||
<div className="p-5 space-y-4">
|
<div className="p-5 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<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>
|
<p className="text-[10px] text-hint mt-0.5">한국연안 해상사격 훈련구역도 No.462 | Chart of Firing and Bombing Exercise Areas | WGS-84 | 출처: 국립해양조사원</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,29 +1,7 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
|
||||||
import { Card, CardContent } from '@shared/components/ui/card';
|
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';
|
import { RealTransshipSuspects } from '@features/detection/RealVesselAnalysis';
|
||||||
|
|
||||||
export function TransferDetection() {
|
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 (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
@ -31,7 +9,7 @@ export function TransferDetection() {
|
|||||||
<p className="text-xs text-hint mt-0.5">선박 간 근접 접촉 및 환적 의심 행위 분석</p>
|
<p className="text-xs text-hint mt-0.5">선박 간 근접 접촉 및 환적 의심 행위 분석</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* iran 백엔드 실시간 전재 의심 */}
|
{/* prediction 분석 결과 기반 실시간 환적 의심 선박 */}
|
||||||
<RealTransshipSuspects />
|
<RealTransshipSuspects />
|
||||||
|
|
||||||
{/* 탐지 조건 */}
|
{/* 탐지 조건 */}
|
||||||
@ -54,104 +32,6 @@ export function TransferDetection() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
fetchVesselAnalysis,
|
fetchVesselAnalysis,
|
||||||
type VesselAnalysisItem,
|
type VesselAnalysisItem,
|
||||||
} from '@/services/vesselAnalysisApi';
|
} from '@/services/vesselAnalysisApi';
|
||||||
|
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||||
import { getEvents, type PredictionEvent } from '@/services/event';
|
import { getEvents, type PredictionEvent } from '@/services/event';
|
||||||
|
|
||||||
// ─── 허가 정보 타입 ──────────────────────
|
// ─── 허가 정보 타입 ──────────────────────
|
||||||
@ -406,7 +407,7 @@ export function VesselDetail() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-[8px]">
|
<span className="text-[8px]">
|
||||||
<span className="text-blue-400 font-bold">UTC</span>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -32,11 +32,6 @@
|
|||||||
"accessLogs": "Access Logs",
|
"accessLogs": "Access Logs",
|
||||||
"loginHistory": "Login History"
|
"loginHistory": "Login History"
|
||||||
},
|
},
|
||||||
"group": {
|
|
||||||
"fieldOps": "Field Ops",
|
|
||||||
"parentInference": "Parent Workflow",
|
|
||||||
"admin": "Admin"
|
|
||||||
},
|
|
||||||
"status": {
|
"status": {
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
"inactive": "Inactive",
|
"inactive": "Inactive",
|
||||||
@ -98,6 +93,7 @@
|
|||||||
"patrol": "Patrol",
|
"patrol": "Patrol",
|
||||||
"enforcement": "Enforcement",
|
"enforcement": "Enforcement",
|
||||||
"fieldOps": "Field Ops",
|
"fieldOps": "Field Ops",
|
||||||
|
"parentInference": "Parent Workflow",
|
||||||
"statistics": "Statistics",
|
"statistics": "Statistics",
|
||||||
"aiOps": "AI Ops",
|
"aiOps": "AI Ops",
|
||||||
"admin": "Admin"
|
"admin": "Admin"
|
||||||
|
|||||||
@ -32,11 +32,6 @@
|
|||||||
"accessLogs": "접근 이력",
|
"accessLogs": "접근 이력",
|
||||||
"loginHistory": "로그인 이력"
|
"loginHistory": "로그인 이력"
|
||||||
},
|
},
|
||||||
"group": {
|
|
||||||
"fieldOps": "함정·현장",
|
|
||||||
"parentInference": "모선 워크플로우",
|
|
||||||
"admin": "관리자"
|
|
||||||
},
|
|
||||||
"status": {
|
"status": {
|
||||||
"active": "활성",
|
"active": "활성",
|
||||||
"inactive": "비활성",
|
"inactive": "비활성",
|
||||||
@ -97,7 +92,8 @@
|
|||||||
"detection": "탐지·분석",
|
"detection": "탐지·분석",
|
||||||
"patrol": "순찰·경로",
|
"patrol": "순찰·경로",
|
||||||
"enforcement": "단속·이력",
|
"enforcement": "단속·이력",
|
||||||
"fieldOps": "현장 대응",
|
"fieldOps": "함정·현장",
|
||||||
|
"parentInference": "모선 워크플로우",
|
||||||
"statistics": "통계·보고",
|
"statistics": "통계·보고",
|
||||||
"aiOps": "AI 운영",
|
"aiOps": "AI 운영",
|
||||||
"admin": "시스템 관리"
|
"admin": "시스템 관리"
|
||||||
|
|||||||
@ -52,6 +52,51 @@ export async function getMonthlyStats(
|
|||||||
return res.json();
|
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에서 사용) */
|
/** PredictionKpi -> 기존 KpiMetric 형태로 변환 (Dashboard에서 사용) */
|
||||||
|
|||||||
53
frontend/src/shared/utils/dateFormat.ts
Normal file
53
frontend/src/shared/utils/dateFormat.ts
Normal file
@ -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 EnforcementRecord,
|
||||||
type LegacyEnforcementRecord,
|
type LegacyEnforcementRecord,
|
||||||
} from '@/services/enforcement';
|
} from '@/services/enforcement';
|
||||||
import type { EnforcementPlanRecord } from '@/data/mock/enforcement';
|
|
||||||
|
|
||||||
interface EnforcementStore {
|
interface EnforcementStore {
|
||||||
/** 원본 API 단속 기록 */
|
/** 원본 API 단속 기록 */
|
||||||
rawRecords: EnforcementRecord[];
|
rawRecords: EnforcementRecord[];
|
||||||
/** 하위 호환용 레거시 형식 */
|
/** 하위 호환용 레거시 형식 */
|
||||||
records: LegacyEnforcementRecord[];
|
records: LegacyEnforcementRecord[];
|
||||||
/** 단속 계획 (아직 mock — EnforcementPlan.tsx에서 사용) */
|
|
||||||
plans: EnforcementPlanRecord[];
|
|
||||||
/** 페이지네이션 */
|
/** 페이지네이션 */
|
||||||
totalElements: number;
|
totalElements: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
@ -28,7 +25,6 @@ interface EnforcementStore {
|
|||||||
export const useEnforcementStore = create<EnforcementStore>((set, get) => ({
|
export const useEnforcementStore = create<EnforcementStore>((set, get) => ({
|
||||||
rawRecords: [],
|
rawRecords: [],
|
||||||
records: [],
|
records: [],
|
||||||
plans: [],
|
|
||||||
totalElements: 0,
|
totalElements: 0,
|
||||||
totalPages: 0,
|
totalPages: 0,
|
||||||
loading: false,
|
loading: false,
|
||||||
@ -41,17 +37,10 @@ export const useEnforcementStore = create<EnforcementStore>((set, get) => ({
|
|||||||
|
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const [res, planModule] = await Promise.all([
|
const res = await getEnforcementRecords(params);
|
||||||
getEnforcementRecords(params),
|
|
||||||
// plans는 아직 mock 유지 (EnforcementPlan.tsx에서 사용)
|
|
||||||
get().plans.length > 0
|
|
||||||
? Promise.resolve(null)
|
|
||||||
: import('@/data/mock/enforcement').then((m) => m.MOCK_ENFORCEMENT_PLANS),
|
|
||||||
]);
|
|
||||||
set({
|
set({
|
||||||
rawRecords: res.content,
|
rawRecords: res.content,
|
||||||
records: res.content.map(toLegacyRecord),
|
records: res.content.map(toLegacyRecord),
|
||||||
plans: planModule ?? get().plans,
|
|
||||||
totalElements: res.totalElements,
|
totalElements: res.totalElements,
|
||||||
totalPages: res.totalPages,
|
totalPages: res.totalPages,
|
||||||
loaded: true,
|
loaded: true,
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import {
|
|||||||
type EventStats,
|
type EventStats,
|
||||||
type LegacyEventRecord,
|
type LegacyEventRecord,
|
||||||
} from '@/services/event';
|
} from '@/services/event';
|
||||||
import type { AlertRecord } from '@data/mock/events';
|
|
||||||
|
|
||||||
/** @deprecated LegacyEventRecord 대신 PredictionEvent 사용 권장 */
|
/** @deprecated LegacyEventRecord 대신 PredictionEvent 사용 권장 */
|
||||||
export type { LegacyEventRecord as EventRecord } from '@/services/event';
|
export type { LegacyEventRecord as EventRecord } from '@/services/event';
|
||||||
@ -17,8 +16,6 @@ interface EventStore {
|
|||||||
rawEvents: PredictionEvent[];
|
rawEvents: PredictionEvent[];
|
||||||
/** 하위 호환용 레거시 형식 이벤트 */
|
/** 하위 호환용 레거시 형식 이벤트 */
|
||||||
events: LegacyEventRecord[];
|
events: LegacyEventRecord[];
|
||||||
/** 알림 (아직 mock — AIAlert, MobileService에서 사용) */
|
|
||||||
alerts: AlertRecord[];
|
|
||||||
/** 상태별 통계 */
|
/** 상태별 통계 */
|
||||||
stats: EventStats;
|
stats: EventStats;
|
||||||
/** 페이지네이션 */
|
/** 페이지네이션 */
|
||||||
@ -39,7 +36,6 @@ interface EventStore {
|
|||||||
export const useEventStore = create<EventStore>((set, get) => ({
|
export const useEventStore = create<EventStore>((set, get) => ({
|
||||||
rawEvents: [],
|
rawEvents: [],
|
||||||
events: [],
|
events: [],
|
||||||
alerts: [],
|
|
||||||
stats: {},
|
stats: {},
|
||||||
totalElements: 0,
|
totalElements: 0,
|
||||||
totalPages: 0,
|
totalPages: 0,
|
||||||
@ -55,18 +51,11 @@ export const useEventStore = create<EventStore>((set, get) => ({
|
|||||||
|
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const [res, alertModule] = await Promise.all([
|
const res = await getEvents(params);
|
||||||
getEvents(params),
|
|
||||||
// alerts는 아직 mock 유지 (다른 화면에서 사용)
|
|
||||||
get().alerts.length > 0
|
|
||||||
? Promise.resolve(null)
|
|
||||||
: import('@data/mock/events').then((m) => m.MOCK_ALERTS),
|
|
||||||
]);
|
|
||||||
const legacy = res.content.map(toLegacyEvent);
|
const legacy = res.content.map(toLegacyEvent);
|
||||||
set({
|
set({
|
||||||
rawEvents: res.content,
|
rawEvents: res.content,
|
||||||
events: legacy,
|
events: legacy,
|
||||||
alerts: alertModule ?? get().alerts,
|
|
||||||
totalElements: res.totalElements,
|
totalElements: res.totalElements,
|
||||||
totalPages: res.totalPages,
|
totalPages: res.totalPages,
|
||||||
currentPage: res.number,
|
currentPage: res.number,
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
toMonthlyTrend,
|
toMonthlyTrend,
|
||||||
toViolationTypes,
|
toViolationTypes,
|
||||||
} from '@/services/kpi';
|
} from '@/services/kpi';
|
||||||
|
import { toDateParam } from '@shared/utils/dateFormat';
|
||||||
|
|
||||||
interface KpiStore {
|
interface KpiStore {
|
||||||
metrics: KpiMetric[];
|
metrics: KpiMetric[];
|
||||||
@ -32,11 +33,10 @@ export const useKpiStore = create<KpiStore>((set, get) => ({
|
|||||||
// 6개월 범위로 월별 통계 조회
|
// 6개월 범위로 월별 통계 조회
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const from = new Date(now.getFullYear(), now.getMonth() - 6, 1);
|
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([
|
const [kpiData, monthlyData] = await Promise.all([
|
||||||
getKpiMetrics(),
|
getKpiMetrics(),
|
||||||
getMonthlyStats(formatDate(from), formatDate(now)),
|
getMonthlyStats(toDateParam(from), toDateParam(now)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
set({
|
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_sql = """
|
||||||
INSERT INTO vessel_analysis_results (
|
INSERT INTO vessel_analysis_results (
|
||||||
mmsi, timestamp, vessel_type, confidence, fishing_pct,
|
mmsi, analyzed_at, vessel_type, confidence, fishing_pct,
|
||||||
cluster_id, season, zone, dist_to_baseline_nm, activity_state,
|
cluster_id, season, zone_code, dist_to_baseline_nm, activity_state,
|
||||||
ucaf_score, ucft_score, is_dark, gap_duration_min,
|
ucaf_score, ucft_score, is_dark, gap_duration_min,
|
||||||
spoofing_score, bd09_offset_m, speed_jump_count,
|
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,
|
risk_score, risk_level,
|
||||||
is_transship_suspect, transship_pair_mmsi, transship_duration_min,
|
transship_suspect, transship_pair_mmsi, transship_duration_min,
|
||||||
features, analyzed_at
|
features
|
||||||
) VALUES %s
|
) VALUES %s
|
||||||
ON CONFLICT (mmsi, timestamp) DO UPDATE SET
|
ON CONFLICT (mmsi, analyzed_at) DO UPDATE SET
|
||||||
vessel_type = EXCLUDED.vessel_type,
|
vessel_type = EXCLUDED.vessel_type,
|
||||||
confidence = EXCLUDED.confidence,
|
confidence = EXCLUDED.confidence,
|
||||||
fishing_pct = EXCLUDED.fishing_pct,
|
fishing_pct = EXCLUDED.fishing_pct,
|
||||||
cluster_id = EXCLUDED.cluster_id,
|
cluster_id = EXCLUDED.cluster_id,
|
||||||
season = EXCLUDED.season,
|
season = EXCLUDED.season,
|
||||||
zone = EXCLUDED.zone,
|
zone_code = EXCLUDED.zone_code,
|
||||||
dist_to_baseline_nm = EXCLUDED.dist_to_baseline_nm,
|
dist_to_baseline_nm = EXCLUDED.dist_to_baseline_nm,
|
||||||
activity_state = EXCLUDED.activity_state,
|
activity_state = EXCLUDED.activity_state,
|
||||||
ucaf_score = EXCLUDED.ucaf_score,
|
ucaf_score = EXCLUDED.ucaf_score,
|
||||||
@ -96,16 +96,15 @@ def upsert_results(results: list['AnalysisResult']) -> int:
|
|||||||
spoofing_score = EXCLUDED.spoofing_score,
|
spoofing_score = EXCLUDED.spoofing_score,
|
||||||
bd09_offset_m = EXCLUDED.bd09_offset_m,
|
bd09_offset_m = EXCLUDED.bd09_offset_m,
|
||||||
speed_jump_count = EXCLUDED.speed_jump_count,
|
speed_jump_count = EXCLUDED.speed_jump_count,
|
||||||
cluster_size = EXCLUDED.cluster_size,
|
fleet_cluster_id = EXCLUDED.fleet_cluster_id,
|
||||||
is_leader = EXCLUDED.is_leader,
|
fleet_is_leader = EXCLUDED.fleet_is_leader,
|
||||||
fleet_role = EXCLUDED.fleet_role,
|
fleet_role = EXCLUDED.fleet_role,
|
||||||
risk_score = EXCLUDED.risk_score,
|
risk_score = EXCLUDED.risk_score,
|
||||||
risk_level = EXCLUDED.risk_level,
|
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_pair_mmsi = EXCLUDED.transship_pair_mmsi,
|
||||||
transship_duration_min = EXCLUDED.transship_duration_min,
|
transship_duration_min = EXCLUDED.transship_duration_min,
|
||||||
features = EXCLUDED.features,
|
features = EXCLUDED.features
|
||||||
analyzed_at = EXCLUDED.analyzed_at
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -75,13 +75,13 @@ class AnalysisResult:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
str(self.mmsi),
|
str(self.mmsi),
|
||||||
self.timestamp,
|
self.analyzed_at, # analyzed_at (PK 파티션키)
|
||||||
str(self.vessel_type),
|
str(self.vessel_type),
|
||||||
_f(self.confidence),
|
_f(self.confidence),
|
||||||
_f(self.fishing_pct),
|
_f(self.fishing_pct),
|
||||||
_i(self.cluster_id),
|
_i(self.cluster_id),
|
||||||
str(self.season),
|
str(self.season),
|
||||||
str(self.zone),
|
str(self.zone), # → zone_code
|
||||||
_f(self.dist_to_baseline_nm),
|
_f(self.dist_to_baseline_nm),
|
||||||
str(self.activity_state),
|
str(self.activity_state),
|
||||||
_f(self.ucaf_score),
|
_f(self.ucaf_score),
|
||||||
@ -91,14 +91,13 @@ class AnalysisResult:
|
|||||||
_f(self.spoofing_score),
|
_f(self.spoofing_score),
|
||||||
_f(self.bd09_offset_m),
|
_f(self.bd09_offset_m),
|
||||||
_i(self.speed_jump_count),
|
_i(self.speed_jump_count),
|
||||||
_i(self.cluster_size),
|
_i(self.cluster_id), # → fleet_cluster_id
|
||||||
bool(self.is_leader),
|
bool(self.is_leader), # → fleet_is_leader
|
||||||
str(self.fleet_role),
|
str(self.fleet_role),
|
||||||
_i(self.risk_score),
|
_i(self.risk_score),
|
||||||
str(self.risk_level),
|
str(self.risk_level),
|
||||||
bool(self.is_transship_suspect),
|
bool(self.is_transship_suspect), # → transship_suspect
|
||||||
str(self.transship_pair_mmsi),
|
str(self.transship_pair_mmsi),
|
||||||
_i(self.transship_duration_min),
|
_i(self.transship_duration_min),
|
||||||
json.dumps(safe_features),
|
json.dumps(safe_features),
|
||||||
self.analyzed_at,
|
|
||||||
)
|
)
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user