From 7f35103c609bdfa1bbabe89a151eb561f5ba18fe Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 8 Apr 2026 07:10:22 +0900 Subject: [PATCH 01/22] =?UTF-8?q?docs:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EB=AC=B8=EC=84=9C=20=EC=B5=9C=EC=8B=A0=ED=99=94=20?= =?UTF-8?q?(2026-04-08)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 메모리 갱신 (Claude 내부) - project-snapshot.md: 48테이블, V001~V016, prediction e2e 정상, System Flow 뷰어, 데모계정 5종 - project-history.md: 2026-04-07~08 릴리즈 이력 요약 (MR #3~#15) - next-task: 1순위를 UI/표기 다듬기로 전환 - api-types: /api/stats/hourly + V014~V016 보조 테이블 추가 - debugging: 최근 해결된 11개 이슈 패턴 정리 - 구버전 참고 파일 정리 (data-analysis, refactoring-decisions) ## 리포지토리 문서 - docs/RELEASE-NOTES.md: Unreleased 섹션에 prediction e2e 수정, System Flow 포커스 모드, hourly API, V014~V016, mock 정리, KST 통일, DemoQuickLogin hostname 등 추가 - CLAUDE.md: database/ 설명 V001~V016, 48 테이블로 갱신 Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 2 +- docs/RELEASE-NOTES.md | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5d3d644..e17f2b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ kcg-ai-monitoring/ ├── frontend/ # React 19 + TypeScript + Vite (UI) ├── backend/ # Spring Boot 3.x + Java 21 (인증/권한/감사 + 분석 API) ├── prediction/ # Python 3.9 + FastAPI (AIS 분석 엔진, 5분 주기) -├── database/ # PostgreSQL 마이그레이션 (Flyway V001~V013) +├── database/ # PostgreSQL 마이그레이션 (Flyway V001~V016, 48 테이블) │ └── migration/ ├── deploy/ # 배포 가이드 + 서버 설정 문서 ├── docs/ # 프로젝트 문서 (SFR, 아키텍처) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 23c68c5..af3aa96 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -8,9 +8,28 @@ - System Flow 뷰어 (`/system-flow.html`) — 시스템 전체 데이터 흐름 시각화 - 102 노드 + 133 엣지, 10개 카테고리 매니페스트 - stage/menu 두 가지 그룹화 토글, 검색/필터/딥링크 지원 + - 포커스 모드 (1-hop 연결 노드만 활성화, 나머지 dim) - 메인 SPA와 분리된 별도 entry, 산출문서 노드 ID 참조용 - `/version` 스킬 사후 처리로 manifest version 자동 동기화 - CI/CD에서 버전별 스냅샷을 서버 archive에 영구 보존 +- 백엔드 `GET /api/stats/hourly?hours=24` — 시간별 통계 조회 (PredictionStatsHourly) +- V014 prediction 보조 테이블 12개 (fleet_vessels, gear_correlation_scores 등) +- V015 NUMERIC precision 일괄 확대 (score→7,4, pct→12,2) +- V016 parent workflow 누락 컬럼 일괄 추가 (17+ 컬럼, candidate_mmsi generated column) + +### 수정 +- **prediction e2e 5가지 이슈 수정** (2026-04-08) + - gear_correlation: psycopg2 Decimal × float TypeError → `_load_all_scores()` float 변환 + - violation_classifier: `(mmsi, analyzed_at)` 기준 UPDATE + 중국선박 EEZ 판정 로직 + - kpi_writer / stats_aggregator: UTC → KST 날짜 경계 통일 + - parent workflow 스키마 ↔ 코드 불일치 → V016로 일괄 해결 +- DemoQuickLogin hostname 기반 노출 (Gitea CI `.env` 차단 대응) +- 프론트 전수 mock 정리: eventStore.alerts, enforcementStore.plans, transferStore 완전 제거 +- Dashboard/MonitoringDashboard/Statistics 하드코딩 → 실 API 전환 +- UTC → KST 시간 표시 통일 (`@shared/utils/dateFormat.ts` 공통 유틸) +- i18n `group.parentInference` JSON 중복키 제거 +- RiskMap Math.random() 격자 제거, MTIS 라벨 + "AI 분석 데이터 수집 중" 안내 +- 12개 mock 화면에 "데모 데이터" 노란 배지 추가 ## [2026-04-07] From 20d6743c1752c3e3838862535511669e7c9e2dfa Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 8 Apr 2026 10:52:36 +0900 Subject: [PATCH 02/22] =?UTF-8?q?feat(backend):=20Role.colorHex=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20+=20V017=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth_role.color_hex VARCHAR(7) 컬럼 추가 (Flyway V017) - 빌트인 5개 역할 기본 색상 시드 (ADMIN/OPERATOR/ANALYST/FIELD/VIEWER) - Role 엔티티 + RoleCreate/UpdateRequest DTO + RoleManagementService - PermTreeController 응답에 colorHex 필드 포함 --- .../kcg/permission/PermTreeController.java | 19 ++++++++++--------- .../main/java/gc/mda/kcg/permission/Role.java | 4 ++++ .../kcg/permission/RoleManagementService.java | 2 ++ .../kcg/permission/dto/RoleCreateRequest.java | 1 + .../kcg/permission/dto/RoleUpdateRequest.java | 1 + .../db/migration/V017__role_color_hex.sql | 18 ++++++++++++++++++ 6 files changed, 36 insertions(+), 9 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V017__role_color_hex.sql diff --git a/backend/src/main/java/gc/mda/kcg/permission/PermTreeController.java b/backend/src/main/java/gc/mda/kcg/permission/PermTreeController.java index 1e1df65..8736262 100644 --- a/backend/src/main/java/gc/mda/kcg/permission/PermTreeController.java +++ b/backend/src/main/java/gc/mda/kcg/permission/PermTreeController.java @@ -47,15 +47,16 @@ public class PermTreeController { List roles = roleRepository.findAllByOrderByRoleSnAsc(); return roles.stream().>map(r -> { List perms = permRepository.findByRoleSn(r.getRoleSn()); - return Map.of( - "roleSn", r.getRoleSn(), - "roleCd", r.getRoleCd(), - "roleNm", r.getRoleNm(), - "roleDc", r.getRoleDc() == null ? "" : r.getRoleDc(), - "dfltYn", r.getDfltYn(), - "builtinYn", r.getBuiltinYn(), - "permissions", perms - ); + java.util.Map result = new java.util.LinkedHashMap<>(); + result.put("roleSn", r.getRoleSn()); + result.put("roleCd", r.getRoleCd()); + result.put("roleNm", r.getRoleNm()); + result.put("roleDc", r.getRoleDc() == null ? "" : r.getRoleDc()); + result.put("colorHex", r.getColorHex()); + result.put("dfltYn", r.getDfltYn()); + result.put("builtinYn", r.getBuiltinYn()); + result.put("permissions", perms); + return result; }).toList(); } diff --git a/backend/src/main/java/gc/mda/kcg/permission/Role.java b/backend/src/main/java/gc/mda/kcg/permission/Role.java index c8e22de..90b42d2 100644 --- a/backend/src/main/java/gc/mda/kcg/permission/Role.java +++ b/backend/src/main/java/gc/mda/kcg/permission/Role.java @@ -28,6 +28,10 @@ public class Role { @Column(name = "role_dc", columnDefinition = "text") private String roleDc; + /** 역할 UI 표기 색상 (#RRGGBB). NULL 가능 — 프론트가 기본 팔레트 사용. */ + @Column(name = "color_hex", length = 7) + private String colorHex; + @Column(name = "dflt_yn", nullable = false, length = 1) private String dfltYn; diff --git a/backend/src/main/java/gc/mda/kcg/permission/RoleManagementService.java b/backend/src/main/java/gc/mda/kcg/permission/RoleManagementService.java index 57f867b..d4e6a95 100644 --- a/backend/src/main/java/gc/mda/kcg/permission/RoleManagementService.java +++ b/backend/src/main/java/gc/mda/kcg/permission/RoleManagementService.java @@ -38,6 +38,7 @@ public class RoleManagementService { .roleCd(req.roleCd().toUpperCase()) .roleNm(req.roleNm()) .roleDc(req.roleDc()) + .colorHex(req.colorHex()) .dfltYn("Y".equalsIgnoreCase(req.dfltYn()) ? "Y" : "N") .builtinYn("N") .build(); @@ -70,6 +71,7 @@ public class RoleManagementService { } if (req.roleNm() != null) role.setRoleNm(req.roleNm()); if (req.roleDc() != null) role.setRoleDc(req.roleDc()); + if (req.colorHex() != null) role.setColorHex(req.colorHex()); if (req.dfltYn() != null) role.setDfltYn("Y".equalsIgnoreCase(req.dfltYn()) ? "Y" : "N"); return roleRepository.save(role); } diff --git a/backend/src/main/java/gc/mda/kcg/permission/dto/RoleCreateRequest.java b/backend/src/main/java/gc/mda/kcg/permission/dto/RoleCreateRequest.java index 600c750..f8e2d1b 100644 --- a/backend/src/main/java/gc/mda/kcg/permission/dto/RoleCreateRequest.java +++ b/backend/src/main/java/gc/mda/kcg/permission/dto/RoleCreateRequest.java @@ -6,5 +6,6 @@ public record RoleCreateRequest( @NotBlank String roleCd, @NotBlank String roleNm, String roleDc, + String colorHex, String dfltYn ) {} diff --git a/backend/src/main/java/gc/mda/kcg/permission/dto/RoleUpdateRequest.java b/backend/src/main/java/gc/mda/kcg/permission/dto/RoleUpdateRequest.java index 999908d..d932dc8 100644 --- a/backend/src/main/java/gc/mda/kcg/permission/dto/RoleUpdateRequest.java +++ b/backend/src/main/java/gc/mda/kcg/permission/dto/RoleUpdateRequest.java @@ -3,5 +3,6 @@ package gc.mda.kcg.permission.dto; public record RoleUpdateRequest( String roleNm, String roleDc, + String colorHex, String dfltYn ) {} diff --git a/backend/src/main/resources/db/migration/V017__role_color_hex.sql b/backend/src/main/resources/db/migration/V017__role_color_hex.sql new file mode 100644 index 0000000..a5214a1 --- /dev/null +++ b/backend/src/main/resources/db/migration/V017__role_color_hex.sql @@ -0,0 +1,18 @@ +-- ============================================================================ +-- V017: auth_role.color_hex 컬럼 추가 +-- ============================================================================ +-- 역할별 UI 표기 색상을 DB에서 관리. +-- 프론트엔드는 RoleResponse.colorHex로 받아 ColorPicker 카탈로그에 적용. +-- ---------------------------------------------------------------------------- + +ALTER TABLE kcg.auth_role + ADD COLUMN IF NOT EXISTS color_hex VARCHAR(7); + +COMMENT ON COLUMN kcg.auth_role.color_hex IS '역할 표기 색상 (#RRGGBB hex). NULL이면 프론트 기본 팔레트에서 결정.'; + +-- 빌트인 5개 역할의 기본 색상 (기존 프론트 ROLE_COLORS 정책과 매칭) +UPDATE kcg.auth_role SET color_hex = '#ef4444' WHERE role_cd = 'ADMIN'; +UPDATE kcg.auth_role SET color_hex = '#3b82f6' WHERE role_cd = 'OPERATOR'; +UPDATE kcg.auth_role SET color_hex = '#a855f7' WHERE role_cd = 'ANALYST'; +UPDATE kcg.auth_role SET color_hex = '#22c55e' WHERE role_cd = 'FIELD'; +UPDATE kcg.auth_role SET color_hex = '#eab308' WHERE role_cd = 'VIEWER'; From 5812d9dea31eda826626ccb98c9ff511f8063f8b Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 8 Apr 2026 10:53:40 +0900 Subject: [PATCH 03/22] =?UTF-8?q?feat(frontend):=20UI=20=EA=B3=B5=ED=86=B5?= =?UTF-8?q?=20=EC=9D=B8=ED=94=84=EB=9D=BC=20+=2019=EA=B0=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A5=98=20=EC=B9=B4=ED=83=88=EB=A1=9C=EA=B7=B8=20=EA=B5=AC?= =?UTF-8?q?=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cn() 유틸 신규 (clsx + tailwind-merge, 시맨틱 토큰 classGroup 등록) - theme.css @layer utilities로 직접 정의 (Tailwind v4 복합 이름 매핑 실패 대응): text-heading/label/hint/on-vivid/on-bright, bg-surface-raised/overlay - badgeVariants (CVA) 재구축: 8 intent x 4 size, rem 기반, !important 제거 - Badge 컴포넌트가 cn(badgeVariants, className)로 override 허용 - DataTable width 의미 변경: 고정 -> 선호 최소 너비 (minWidth), truncate + title 툴팁 - dateFormat.ts sv-SE 로케일로 YYYY-MM-DD HH:mm:ss 일관된 KST 출력 - ColorPicker 신규 (팔레트 + native color + hex 입력) - shared/constants/ 19개 카탈로그: violation/alert/event/enforcement/patrol/ engine/userRole/device/parentResolution/modelDeployment/gearGroup/darkVessel/ httpStatus/userAccount/loginResult/permission/vesselAnalysis/connection/trainingZone + kpiUiMap. 백엔드 enum/code_master 기반 SSOT - i18n ko/en common.json에 카테고리 섹션 추가 - adminApi.fetchRoles()가 updateRoleColorCache 자동 호출 - 공통 컴포넌트 (ExcelExport/NotificationBanner/Pagination/SaveButton) 시맨틱 토큰 적용 --- frontend/package-lock.json | 14 +- frontend/package.json | 2 + frontend/src/lib/i18n/locales/en/common.json | 128 ++++++++++++++++++ frontend/src/lib/i18n/locales/ko/common.json | 128 ++++++++++++++++++ frontend/src/lib/theme/variants.ts | 43 ++++-- frontend/src/lib/utils/cn.ts | 35 +++++ frontend/src/services/adminApi.ts | 13 +- .../shared/components/common/ColorPicker.tsx | 90 ++++++++++++ .../shared/components/common/DataTable.tsx | 28 ++-- .../shared/components/common/ExcelExport.tsx | 2 +- .../components/common/NotificationBanner.tsx | 2 +- .../shared/components/common/Pagination.tsx | 2 +- .../shared/components/common/SaveButton.tsx | 4 +- frontend/src/shared/components/ui/badge.tsx | 7 +- frontend/src/shared/constants/alertLevels.ts | 125 +++++++++++++++++ .../shared/constants/connectionStatuses.ts | 52 +++++++ .../shared/constants/darkVesselPatterns.ts | 91 +++++++++++++ .../src/shared/constants/deviceStatuses.ts | 74 ++++++++++ .../shared/constants/enforcementActions.ts | 83 ++++++++++++ .../shared/constants/enforcementResults.ts | 79 +++++++++++ .../src/shared/constants/engineSeverities.ts | 90 ++++++++++++ .../src/shared/constants/eventStatuses.ts | 79 +++++++++++ .../src/shared/constants/gearGroupTypes.ts | 51 +++++++ .../src/shared/constants/httpStatusCodes.ts | 23 ++++ frontend/src/shared/constants/index.ts | 31 +++++ frontend/src/shared/constants/kpiUiMap.ts | 38 ++++++ .../shared/constants/loginResultStatuses.ts | 51 +++++++ .../constants/modelDeploymentStatuses.ts | 73 ++++++++++ .../constants/parentResolutionStatuses.ts | 96 +++++++++++++ .../src/shared/constants/patrolStatuses.ts | 117 ++++++++++++++++ .../shared/constants/permissionStatuses.ts | 93 +++++++++++++ .../src/shared/constants/trainingZoneTypes.ts | 85 ++++++++++++ .../shared/constants/userAccountStatuses.ts | 57 ++++++++ frontend/src/shared/constants/userRoles.ts | 84 ++++++++++++ .../constants/vesselAnalysisStatuses.ts | 84 ++++++++++++ .../src/shared/constants/violationTypes.ts | 128 ++++++++++++++++++ frontend/src/shared/utils/dateFormat.ts | 8 +- frontend/src/styles/theme.css | 20 +++ 38 files changed, 2170 insertions(+), 40 deletions(-) create mode 100644 frontend/src/lib/utils/cn.ts create mode 100644 frontend/src/shared/components/common/ColorPicker.tsx create mode 100644 frontend/src/shared/constants/alertLevels.ts create mode 100644 frontend/src/shared/constants/connectionStatuses.ts create mode 100644 frontend/src/shared/constants/darkVesselPatterns.ts create mode 100644 frontend/src/shared/constants/deviceStatuses.ts create mode 100644 frontend/src/shared/constants/enforcementActions.ts create mode 100644 frontend/src/shared/constants/enforcementResults.ts create mode 100644 frontend/src/shared/constants/engineSeverities.ts create mode 100644 frontend/src/shared/constants/eventStatuses.ts create mode 100644 frontend/src/shared/constants/gearGroupTypes.ts create mode 100644 frontend/src/shared/constants/httpStatusCodes.ts create mode 100644 frontend/src/shared/constants/index.ts create mode 100644 frontend/src/shared/constants/kpiUiMap.ts create mode 100644 frontend/src/shared/constants/loginResultStatuses.ts create mode 100644 frontend/src/shared/constants/modelDeploymentStatuses.ts create mode 100644 frontend/src/shared/constants/parentResolutionStatuses.ts create mode 100644 frontend/src/shared/constants/patrolStatuses.ts create mode 100644 frontend/src/shared/constants/permissionStatuses.ts create mode 100644 frontend/src/shared/constants/trainingZoneTypes.ts create mode 100644 frontend/src/shared/constants/userAccountStatuses.ts create mode 100644 frontend/src/shared/constants/userRoles.ts create mode 100644 frontend/src/shared/constants/vesselAnalysisStatuses.ts create mode 100644 frontend/src/shared/constants/violationTypes.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fed012b..84490ad 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@deck.gl/mapbox": "^9.2.11", "@xyflow/react": "^12.10.2", "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "deck.gl": "^9.2.11", "echarts": "^6.0.0", "i18next": "^26.0.3", @@ -20,6 +21,7 @@ "react-dom": "^19.2.4", "react-i18next": "^17.0.2", "react-router-dom": "^7.12.0", + "tailwind-merge": "^3.5.0", "zustand": "^5.0.12" }, "devDependencies": { @@ -2824,7 +2826,7 @@ }, "node_modules/clsx": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "license": "MIT", "engines": { @@ -4907,6 +4909,16 @@ "kdbush": "^4.0.2" } }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://nexus.gc-si.dev/repository/npm-public/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index a9efafd..e2a4a4a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "@deck.gl/mapbox": "^9.2.11", "@xyflow/react": "^12.10.2", "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "deck.gl": "^9.2.11", "echarts": "^6.0.0", "i18next": "^26.0.3", @@ -24,6 +25,7 @@ "react-dom": "^19.2.4", "react-i18next": "^17.0.2", "react-router-dom": "^7.12.0", + "tailwind-merge": "^3.5.0", "zustand": "^5.0.12" }, "devDependencies": { diff --git a/frontend/src/lib/i18n/locales/en/common.json b/frontend/src/lib/i18n/locales/en/common.json index a23a840..c3f032b 100644 --- a/frontend/src/lib/i18n/locales/en/common.json +++ b/frontend/src/lib/i18n/locales/en/common.json @@ -10,6 +10,7 @@ "patrolRoute": "Patrol Route", "fleetOptimization": "Fleet Optimize", "enforcementHistory": "History", + "realtimeEvent": "Realtime Events", "eventList": "Event List", "mobileService": "Mobile", "shipAgent": "Ship Agent", @@ -48,6 +49,133 @@ "medium": "Medium", "low": "Low" }, + "violation": { + "eezViolation": "EEZ Violation", + "darkVessel": "Dark Vessel", + "mmsiTampering": "MMSI Tampering", + "illegalTransship": "Illegal Transship", + "illegalGear": "Illegal Gear", + "riskBehavior": "Risk Behavior" + }, + "eventStatus": { + "NEW": "New", + "ACK": "Acknowledged", + "IN_PROGRESS": "In Progress", + "RESOLVED": "Resolved", + "FALSE_POSITIVE": "False Positive" + }, + "enforcementAction": { + "CAPTURE": "Capture", + "INSPECT": "Inspect", + "WARN": "Warn", + "DISPERSE": "Disperse", + "TRACK": "Track", + "EVIDENCE": "Evidence" + }, + "enforcementResult": { + "PUNISHED": "Punished", + "REFERRED": "Referred", + "WARNED": "Warned", + "RELEASED": "Released", + "FALSE_POSITIVE": "False Positive" + }, + "patrolStatus": { + "AVAILABLE": "Available", + "ON_PATROL": "On Patrol", + "IN_PURSUIT": "In Pursuit", + "INSPECTING": "Inspecting", + "RETURNING": "Returning", + "STANDBY": "Standby", + "MAINTENANCE": "Maintenance" + }, + "engineSeverity": { + "CRITICAL": "Critical", + "HIGH_CRITICAL": "High~Critical", + "HIGH": "High", + "MEDIUM_CRITICAL": "Medium~Critical", + "MEDIUM": "Medium", + "LOW": "Low", + "NONE": "-" + }, + "deviceStatus": { + "ONLINE": "Online", + "OFFLINE": "Offline", + "SYNCING": "Syncing", + "NOT_DEPLOYED": "Not Deployed" + }, + "parentResolution": { + "UNRESOLVED": "Unresolved", + "REVIEW_REQUIRED": "Review Required", + "MANUAL_CONFIRMED": "Manually Confirmed" + }, + "labelSession": { + "ACTIVE": "Active", + "COMPLETED": "Completed", + "CANCELLED": "Cancelled" + }, + "modelStatus": { + "DEPLOYED": "Deployed", + "APPROVED": "Approved", + "TESTING": "Testing", + "DRAFT": "Draft" + }, + "gearGroupType": { + "FLEET": "Fleet", + "GEAR_IN_ZONE": "In-Zone Gear", + "GEAR_OUT_ZONE": "Out-Zone Gear" + }, + "darkPattern": { + "AIS_FULL_BLOCK": "AIS Full Block", + "MMSI_SPOOFING": "MMSI Spoofing", + "LONG_LOSS": "Long Loss", + "INTERMITTENT": "Intermittent", + "SPEED_ANOMALY": "Speed Anomaly" + }, + "userAccountStatus": { + "ACTIVE": "Active", + "PENDING": "Pending", + "LOCKED": "Locked", + "INACTIVE": "Inactive" + }, + "loginResult": { + "SUCCESS": "Success", + "FAILED": "Failed", + "LOCKED": "Locked" + }, + "permitStatus": { + "VALID": "Valid", + "PENDING": "Pending", + "EXPIRED": "Expired", + "UNLICENSED": "Unlicensed" + }, + "gearJudgment": { + "NORMAL": "Normal", + "CHECKING": "Checking", + "SUSPECT_ILLEGAL": "Suspect Illegal" + }, + "vesselSurveillance": { + "TRACKING": "Tracking", + "WATCHING": "Watching", + "CHECKING": "Checking", + "NORMAL": "Normal" + }, + "vesselRing": { + "SAFE": "Safe", + "SUSPECT": "Suspect", + "WARNING": "Warning" + }, + "connectionStatus": { + "OK": "OK", + "WARNING": "Warning", + "ERROR": "Error" + }, + "trainingZone": { + "NAVY": "Navy Zone", + "AIRFORCE": "Airforce Zone", + "ARMY": "Army Zone", + "ADD": "ADD", + "KCG": "KCG" + }, "action": { "search": "Search", "save": "Save", diff --git a/frontend/src/lib/i18n/locales/ko/common.json b/frontend/src/lib/i18n/locales/ko/common.json index 3cfafec..c910f2e 100644 --- a/frontend/src/lib/i18n/locales/ko/common.json +++ b/frontend/src/lib/i18n/locales/ko/common.json @@ -10,6 +10,7 @@ "patrolRoute": "순찰경로 추천", "fleetOptimization": "다함정 최적화", "enforcementHistory": "단속 이력", + "realtimeEvent": "실시간 이벤트", "eventList": "이벤트 목록", "mobileService": "모바일 서비스", "shipAgent": "함정 Agent", @@ -48,6 +49,133 @@ "medium": "보통", "low": "낮음" }, + "violation": { + "eezViolation": "EEZ 침범", + "darkVessel": "다크베셀", + "mmsiTampering": "MMSI 변조", + "illegalTransship": "불법 환적", + "illegalGear": "불법 어구", + "riskBehavior": "위험 행동" + }, + "eventStatus": { + "NEW": "신규", + "ACK": "확인", + "IN_PROGRESS": "처리중", + "RESOLVED": "완료", + "FALSE_POSITIVE": "오탐" + }, + "enforcementAction": { + "CAPTURE": "나포", + "INSPECT": "검문", + "WARN": "경고", + "DISPERSE": "퇴거", + "TRACK": "추적", + "EVIDENCE": "증거수집" + }, + "enforcementResult": { + "PUNISHED": "처벌", + "REFERRED": "수사의뢰", + "WARNED": "경고", + "RELEASED": "훈방", + "FALSE_POSITIVE": "오탐(정상)" + }, + "patrolStatus": { + "AVAILABLE": "가용", + "ON_PATROL": "초계중", + "IN_PURSUIT": "추적중", + "INSPECTING": "검문중", + "RETURNING": "귀항중", + "STANDBY": "대기", + "MAINTENANCE": "정비중" + }, + "engineSeverity": { + "CRITICAL": "심각", + "HIGH_CRITICAL": "높음~심각", + "HIGH": "높음", + "MEDIUM_CRITICAL": "보통~심각", + "MEDIUM": "보통", + "LOW": "낮음", + "NONE": "-" + }, + "deviceStatus": { + "ONLINE": "온라인", + "OFFLINE": "오프라인", + "SYNCING": "동기화중", + "NOT_DEPLOYED": "미배포" + }, + "parentResolution": { + "UNRESOLVED": "미해결", + "REVIEW_REQUIRED": "검토 필요", + "MANUAL_CONFIRMED": "수동 확정" + }, + "labelSession": { + "ACTIVE": "진행중", + "COMPLETED": "완료", + "CANCELLED": "취소" + }, + "modelStatus": { + "DEPLOYED": "배포됨", + "APPROVED": "승인", + "TESTING": "테스트", + "DRAFT": "초안" + }, + "gearGroupType": { + "FLEET": "선단", + "GEAR_IN_ZONE": "구역 내 어구", + "GEAR_OUT_ZONE": "구역 외 어구" + }, + "darkPattern": { + "AIS_FULL_BLOCK": "AIS 완전차단", + "MMSI_SPOOFING": "MMSI 변조 의심", + "LONG_LOSS": "장기 소실", + "INTERMITTENT": "신호 간헐송출", + "SPEED_ANOMALY": "속도 이상" + }, + "userAccountStatus": { + "ACTIVE": "활성", + "PENDING": "승인 대기", + "LOCKED": "잠금", + "INACTIVE": "비활성" + }, + "loginResult": { + "SUCCESS": "성공", + "FAILED": "실패", + "LOCKED": "계정 잠금" + }, + "permitStatus": { + "VALID": "유효", + "PENDING": "확인 중", + "EXPIRED": "만료", + "UNLICENSED": "무허가" + }, + "gearJudgment": { + "NORMAL": "정상", + "CHECKING": "확인 중", + "SUSPECT_ILLEGAL": "불법 의심" + }, + "vesselSurveillance": { + "TRACKING": "추적중", + "WATCHING": "감시중", + "CHECKING": "확인중", + "NORMAL": "정상" + }, + "vesselRing": { + "SAFE": "양호", + "SUSPECT": "의심", + "WARNING": "경고" + }, + "connectionStatus": { + "OK": "정상", + "WARNING": "경고", + "ERROR": "오류" + }, + "trainingZone": { + "NAVY": "해군 훈련 구역", + "AIRFORCE": "공군 훈련 구역", + "ARMY": "육군 훈련 구역", + "ADD": "국방과학연구소", + "KCG": "해양경찰청" + }, "action": { "search": "검색", "save": "저장", diff --git a/frontend/src/lib/theme/variants.ts b/frontend/src/lib/theme/variants.ts index a9d08a8..e8756e6 100644 --- a/frontend/src/lib/theme/variants.ts +++ b/frontend/src/lib/theme/variants.ts @@ -17,29 +17,42 @@ export const cardVariants = cva('rounded-xl border border-border', { defaultVariants: { variant: 'default' }, }); -/** 뱃지 변형 — 위험도/상태별 150회+ 반복 패턴 통합 */ +/** 뱃지 변형 — 위험도/상태별 150회+ 반복 패턴 통합 + * + * 가독성 정책: + * - 배경: 색상별 진한 솔리드(-400) — 명확한 분류 식별 + * - 텍스트: 시맨틱 토큰 text-on-bright (theme.css = #0f172a) — 테마 무관 일관 가독성 + * - 보더: 같은 색상 계열 -600 (배경 강조) + * - 가운데 정렬 + * - 폰트 크기: rem 기반 (root font-size 대비 비율) — 화면 비율에 따라 자동 조정 + * + * className override는 cn(tailwind-merge) 덕분에 같은 그룹(text-color/font-size/bg) 충돌 시 + * 마지막 명시값이 적용 — !important 없이 의도된 override만 허용. + */ export const badgeVariants = cva( - 'inline-flex items-center whitespace-nowrap rounded-md border px-2 py-0.5 font-semibold transition-colors', + 'inline-flex items-center justify-center whitespace-nowrap rounded-md border px-2 py-0.5 font-semibold transition-colors text-center text-on-bright', { variants: { intent: { - critical: 'bg-red-500/20 text-red-400 border-red-500/30', - high: 'bg-orange-500/20 text-orange-400 border-orange-500/30', - warning: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', - info: 'bg-blue-500/20 text-blue-400 border-blue-500/30', - success: 'bg-green-500/20 text-green-400 border-green-500/30', - muted: 'bg-slate-500/20 text-slate-400 border-slate-500/30', - purple: 'bg-purple-500/20 text-purple-400 border-purple-500/30', - cyan: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30', + critical: 'bg-red-400 border-red-600', + high: 'bg-orange-400 border-orange-600', + warning: 'bg-yellow-400 border-yellow-600', + info: 'bg-blue-400 border-blue-600', + success: 'bg-green-400 border-green-600', + muted: 'bg-slate-400 border-slate-600', + purple: 'bg-purple-400 border-purple-600', + cyan: 'bg-cyan-400 border-cyan-600', }, + // rem 기반 — root font-size 대비 비율, 화면 비율 변경 시 자동 조정 + // xs ≈ 11px, sm ≈ 12px, md ≈ 13px, lg ≈ 14px (root 14px 기준) size: { - xs: 'text-[8px]', - sm: 'text-[9px]', - md: 'text-[10px]', - lg: 'text-[11px]', + xs: 'text-[0.6875rem] leading-tight', + sm: 'text-[0.75rem] leading-tight', + md: 'text-[0.8125rem] leading-tight', + lg: 'text-[0.875rem] leading-tight', }, }, - defaultVariants: { intent: 'info', size: 'md' }, + defaultVariants: { intent: 'info', size: 'sm' }, }, ); diff --git a/frontend/src/lib/utils/cn.ts b/frontend/src/lib/utils/cn.ts new file mode 100644 index 0000000..d39b5ea --- /dev/null +++ b/frontend/src/lib/utils/cn.ts @@ -0,0 +1,35 @@ +/** + * className 병합 유틸 — tailwind-merge로 충돌 자동 해결. + * + * cva()의 base class와 컴포넌트 className prop을 합칠 때 사용. + * 같은 그룹(text color, padding, bg 등) 클래스가 여러 개면 마지막 것만 적용. + * + * 사용 예: + * cn(badgeVariants({ intent }), className) + * → className에서 들어오는 text-red-400이 base의 text-on-bright을 override (마지막 우선) + * + * 시맨틱 토큰 (text-on-vivid, text-on-bright, text-heading 등)을 tailwind-merge가 + * text-color 그룹으로 인식하도록 extendTailwindMerge로 확장. + */ + +import { extendTailwindMerge } from 'tailwind-merge'; +import { clsx, type ClassValue } from 'clsx'; + +const twMerge = extendTailwindMerge({ + extend: { + classGroups: { + // 프로젝트 시맨틱 텍스트 색상 (theme.css 정의) — text-* 그룹 충돌 해결 + 'text-color': [ + 'text-heading', + 'text-label', + 'text-hint', + 'text-on-vivid', + 'text-on-bright', + ], + }, + }, +}); + +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} diff --git a/frontend/src/services/adminApi.ts b/frontend/src/services/adminApi.ts index 95571a9..2951b30 100644 --- a/frontend/src/services/adminApi.ts +++ b/frontend/src/services/adminApi.ts @@ -68,6 +68,8 @@ export interface RoleWithPermissions { roleCd: string; roleNm: string; roleDc: string; + /** UI 표기 색상 (#RRGGBB). NULL이면 프론트 기본 팔레트에서 결정 */ + colorHex: string | null; dfltYn: string; builtinYn: string; permissions: { permSn: number; roleSn: number; rsrcCd: string; operCd: string; grantYn: string }[]; @@ -95,8 +97,13 @@ export function fetchPermTree() { return apiGet('/perm-tree'); } -export function fetchRoles() { - return apiGet('/roles'); +import { updateRoleColorCache } from '@shared/constants/userRoles'; + +export async function fetchRoles(): Promise { + const roles = await apiGet('/roles'); + // 역할 색상 캐시 즉시 갱신 → 모든 사용처 자동 반영 + updateRoleColorCache(roles); + return roles; } // ─── 역할 CRUD ─────────────────────────────── @@ -104,12 +111,14 @@ export interface RoleCreatePayload { roleCd: string; roleNm: string; roleDc?: string; + colorHex?: string; dfltYn?: string; } export interface RoleUpdatePayload { roleNm?: string; roleDc?: string; + colorHex?: string; dfltYn?: string; } diff --git a/frontend/src/shared/components/common/ColorPicker.tsx b/frontend/src/shared/components/common/ColorPicker.tsx new file mode 100644 index 0000000..153107c --- /dev/null +++ b/frontend/src/shared/components/common/ColorPicker.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; +import { Check } from 'lucide-react'; +import { ROLE_DEFAULT_PALETTE } from '@shared/constants/userRoles'; + +interface ColorPickerProps { + /** 현재 선택된 hex 색상 (#RRGGBB) */ + value: string | null | undefined; + /** 색상 변경 콜백 */ + onChange: (hex: string) => void; + /** 사용자 지정 팔레트. 미지정 시 ROLE_DEFAULT_PALETTE 사용 */ + palette?: string[]; + /** 직접 입력 허용 여부 (default: true) */ + allowCustom?: boolean; + className?: string; + label?: string; +} + +/** + * 미리 정의된 팔레트에서 색상을 선택하는 컴포넌트. + * 사용자 정의 hex 입력도 지원. + * + * 사용처: 역할 생성/수정 다이얼로그 + */ +export function ColorPicker({ + value, + onChange, + palette = ROLE_DEFAULT_PALETTE, + allowCustom = true, + className = '', + label, +}: ColorPickerProps) { + const [customHex, setCustomHex] = useState(value ?? ''); + + const handleCustomChange = (hex: string) => { + setCustomHex(hex); + if (/^#[0-9a-fA-F]{6}$/.test(hex)) { + onChange(hex); + } + }; + + return ( +
+ {label &&
{label}
} +
+ {palette.map((color) => { + const selected = value?.toLowerCase() === color.toLowerCase(); + return ( + + ); + })} +
+ {allowCustom && ( +
+ { + onChange(e.target.value); + setCustomHex(e.target.value); + }} + className="w-7 h-7 rounded border border-border bg-transparent cursor-pointer" + title="색상 직접 선택" + /> + handleCustomChange(e.target.value)} + placeholder="#RRGGBB" + maxLength={7} + className="flex-1 px-2 py-1 bg-surface-overlay border border-border rounded text-[11px] text-label font-mono focus:outline-none focus:border-blue-500/50" + /> +
+ )} +
+ ); +} diff --git a/frontend/src/shared/components/common/DataTable.tsx b/frontend/src/shared/components/common/DataTable.tsx index 5653778..ad26afa 100644 --- a/frontend/src/shared/components/common/DataTable.tsx +++ b/frontend/src/shared/components/common/DataTable.tsx @@ -14,11 +14,11 @@ import { useAuth } from '@/app/auth/AuthContext'; export interface DataColumn { key: string; label: string; - /** 고정 너비 (설정 시 가변 비활성) */ + /** 선호 최소 너비. 컨텐츠가 더 길면 자동 확장. */ width?: string; - /** 최소 너비 (가변 모드에서 적용) */ + /** 명시 최소 너비 (width와 사실상 동일하나 의미 강조용) */ minWidth?: string; - /** 최대 너비 (가변 모드에서 적용) */ + /** 최대 너비 (초과 시 ellipsis 트런케이트) */ maxWidth?: string; align?: 'left' | 'center' | 'right'; sortable?: boolean; @@ -144,12 +144,14 @@ export function DataTable>({ {columns.map((col) => { const cellStyle: React.CSSProperties = {}; - if (col.width) { cellStyle.width = col.width; cellStyle.minWidth = col.width; cellStyle.maxWidth = col.width; } - else { if (col.minWidth) cellStyle.minWidth = col.minWidth; if (col.maxWidth) cellStyle.maxWidth = col.maxWidth; } + // width = 선호 최소 너비. 컨텐츠가 더 길면 자동 확장. + if (col.width) cellStyle.minWidth = col.width; + if (col.minWidth) cellStyle.minWidth = col.minWidth; + if (col.maxWidth) cellStyle.maxWidth = col.maxWidth; return ( >({ > {columns.map((col) => { const cellStyle: React.CSSProperties = {}; - if (col.width) { cellStyle.width = col.width; cellStyle.minWidth = col.width; cellStyle.maxWidth = col.width; } - else { if (col.minWidth) cellStyle.minWidth = col.minWidth; if (col.maxWidth) cellStyle.maxWidth = col.maxWidth; } + if (col.width) cellStyle.minWidth = col.width; + if (col.minWidth) cellStyle.minWidth = col.minWidth; + if (col.maxWidth) cellStyle.maxWidth = col.maxWidth; + const rawValue = row[col.key]; + const titleText = rawValue != null ? String(rawValue) : ''; return ( {col.render - ? col.render(row[col.key], row, page * pageSize + i) - : {row[col.key] != null ? String(row[col.key]) : ''} + ? col.render(rawValue, row, page * pageSize + i) + : {titleText} } ); diff --git a/frontend/src/shared/components/common/ExcelExport.tsx b/frontend/src/shared/components/common/ExcelExport.tsx index efc5ba9..cfd2fb9 100644 --- a/frontend/src/shared/components/common/ExcelExport.tsx +++ b/frontend/src/shared/components/common/ExcelExport.tsx @@ -61,7 +61,7 @@ export function ExcelExport({ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-medium transition-colors disabled:opacity-30 ${ exported ? 'bg-green-600/20 text-green-400 border border-green-500/30' - : 'bg-surface-overlay border border-border text-muted-foreground hover:text-heading hover:border-border' + : 'bg-surface-overlay border border-border text-muted-foreground hover:text-on-vivid hover:border-border' } ${className}`} > {exported ? : } diff --git a/frontend/src/shared/components/common/NotificationBanner.tsx b/frontend/src/shared/components/common/NotificationBanner.tsx index daf434f..1e29f8f 100644 --- a/frontend/src/shared/components/common/NotificationBanner.tsx +++ b/frontend/src/shared/components/common/NotificationBanner.tsx @@ -145,7 +145,7 @@ export function NotificationPopup({ notices, userRole }: NotificationPopupProps)
diff --git a/frontend/src/shared/components/common/Pagination.tsx b/frontend/src/shared/components/common/Pagination.tsx index 3d02b5b..3a23981 100644 --- a/frontend/src/shared/components/common/Pagination.tsx +++ b/frontend/src/shared/components/common/Pagination.tsx @@ -56,7 +56,7 @@ export function Pagination({ page, totalPages, totalItems, pageSize, onPageChang onClick={() => onPageChange(p)} className={`min-w-[24px] h-6 px-1 rounded text-[10px] font-medium transition-colors ${ p === page - ? 'bg-blue-600 text-heading' + ? 'bg-blue-600 text-on-vivid' : 'text-muted-foreground hover:text-heading hover:bg-surface-overlay' }`} > diff --git a/frontend/src/shared/components/common/SaveButton.tsx b/frontend/src/shared/components/common/SaveButton.tsx index 3b35518..98be3d1 100644 --- a/frontend/src/shared/components/common/SaveButton.tsx +++ b/frontend/src/shared/components/common/SaveButton.tsx @@ -33,8 +33,8 @@ export function SaveButton({ onClick, label = '저장', disabled = false, classN disabled={disabled || state !== 'idle'} className={`flex items-center gap-1.5 px-4 py-1.5 rounded-lg text-[11px] font-bold transition-colors disabled:opacity-40 ${ state === 'done' - ? 'bg-green-600 text-heading' - : 'bg-blue-600 hover:bg-blue-500 text-heading' + ? 'bg-green-600 text-on-vivid' + : 'bg-blue-600 hover:bg-blue-500 text-on-vivid' } ${className}`} > {state === 'saving' ? diff --git a/frontend/src/shared/components/ui/badge.tsx b/frontend/src/shared/components/ui/badge.tsx index edfbef9..73cc3c4 100644 --- a/frontend/src/shared/components/ui/badge.tsx +++ b/frontend/src/shared/components/ui/badge.tsx @@ -1,5 +1,6 @@ import { type HTMLAttributes } from 'react'; import { badgeVariants, type BadgeIntent, type BadgeSize } from '@lib/theme/variants'; +import { cn } from '@lib/utils/cn'; interface BadgeProps extends HTMLAttributes { /** 의미 기반 색상 (기존 variant 대체) */ @@ -17,11 +18,13 @@ const LEGACY_MAP: Record = { outline: 'muted', }; -export function Badge({ className = '', intent, size, variant, ...props }: BadgeProps) { +export function Badge({ className, intent, size, variant, ...props }: BadgeProps) { const resolvedIntent = intent ?? (variant ? LEGACY_MAP[variant] : undefined); + // cn() = clsx + tailwind-merge: 같은 그룹(text-color, bg, padding 등) 충돌 시 + // 마지막 className이 우선 → base 클래스의 일관 정책을 className에서 명시적으로만 override 가능 return (
); diff --git a/frontend/src/shared/constants/alertLevels.ts b/frontend/src/shared/constants/alertLevels.ts new file mode 100644 index 0000000..c80a8e4 --- /dev/null +++ b/frontend/src/shared/constants/alertLevels.ts @@ -0,0 +1,125 @@ +/** + * 위험도/알림 등급 공통 카탈로그 + * + * SSOT: backend `code_master` 그룹 EVENT_LEVEL (V008 시드). + * 향후 `GET /api/code-master?groupCode=EVENT_LEVEL`로 fetch 예정. + * + * **공통 Badge 컴포넌트와 연동**: 가능한 한 `` 사용. + * 카드 컨테이너 등 분리된 클래스가 필요한 경우만 `ALERT_LEVELS.classes` 사용. + * + * 사용처: Dashboard, MonitoringDashboard, EventList, LiveMapView, VesselDetail, + * MobileService, ChinaFishing 등 모든 위험도 배지 / 알림 / 마커 + */ + +import type { BadgeIntent } from '@lib/theme/variants'; + +export type AlertLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'; + +export interface AlertLevelMeta { + code: AlertLevel; + i18nKey: string; + fallback: { ko: string; en: string }; + /** 공통 Badge 컴포넌트 intent (badgeVariants 와 동기화) */ + intent: BadgeIntent; + /** Tailwind 클래스 묶음 — 카드/컨테이너 전용 (배지가 아님) + * 배지에는 `` 사용 권장. + * 여기서 bg/border는 약한 알파 — 카드 배경에 텍스트가 묻히지 않도록. + */ + classes: { + bg: string; + text: string; + border: string; + dot: string; + /** 진한 배경 (액션 버튼 등) */ + bgSolid: string; + }; + /** hex 색상 (지도 마커, 차트, 인라인 style 용) */ + hex: string; + order: number; +} + +export const ALERT_LEVELS: Record = { + CRITICAL: { + code: 'CRITICAL', + i18nKey: 'alert.critical', + fallback: { ko: '심각', en: 'Critical' }, + intent: 'critical', + classes: { + bg: 'bg-red-500/10', + text: 'text-red-300', + border: 'border-red-500/30', + dot: 'bg-red-500', + bgSolid: 'bg-red-500', + }, + hex: '#ef4444', + order: 1, + }, + HIGH: { + code: 'HIGH', + i18nKey: 'alert.high', + fallback: { ko: '높음', en: 'High' }, + intent: 'high', + classes: { + bg: 'bg-orange-500/10', + text: 'text-orange-300', + border: 'border-orange-500/30', + dot: 'bg-orange-500', + bgSolid: 'bg-orange-500', + }, + hex: '#f97316', + order: 2, + }, + MEDIUM: { + code: 'MEDIUM', + i18nKey: 'alert.medium', + fallback: { ko: '보통', en: 'Medium' }, + intent: 'warning', + classes: { + bg: 'bg-yellow-500/10', + text: 'text-yellow-300', + border: 'border-yellow-500/30', + dot: 'bg-yellow-500', + bgSolid: 'bg-yellow-500', + }, + hex: '#eab308', + order: 3, + }, + LOW: { + code: 'LOW', + i18nKey: 'alert.low', + fallback: { ko: '낮음', en: 'Low' }, + intent: 'info', + classes: { + bg: 'bg-blue-500/10', + text: 'text-blue-300', + border: 'border-blue-500/30', + dot: 'bg-blue-500', + bgSolid: 'bg-blue-500', + }, + hex: '#3b82f6', + order: 4, + }, +}; + +export function getAlertLevelMeta(level: string): AlertLevelMeta | undefined { + return ALERT_LEVELS[level as AlertLevel]; +} + +export function getAlertLevelLabel( + level: string, + t: (key: string, opts?: { defaultValue?: string }) => string, + lang: 'ko' | 'en' = 'ko', +): string { + const meta = getAlertLevelMeta(level); + if (!meta) return level; + return t(meta.i18nKey, { defaultValue: meta.fallback[lang] }); +} + +export function getAlertLevelHex(level: string): string { + return getAlertLevelMeta(level)?.hex ?? '#6b7280'; +} + +/** 공통 Badge intent 매핑 */ +export function getAlertLevelIntent(level: string): BadgeIntent { + return getAlertLevelMeta(level)?.intent ?? 'muted'; +} diff --git a/frontend/src/shared/constants/connectionStatuses.ts b/frontend/src/shared/constants/connectionStatuses.ts new file mode 100644 index 0000000..df04b3b --- /dev/null +++ b/frontend/src/shared/constants/connectionStatuses.ts @@ -0,0 +1,52 @@ +/** + * 데이터 연결/신호 상태 카탈로그 + * + * 사용처: DataHub signal heatmap + */ + +import type { BadgeIntent } from '@lib/theme/variants'; + +export type ConnectionStatus = 'OK' | 'WARNING' | 'ERROR'; + +export const CONNECTION_STATUSES: Record = { + OK: { + i18nKey: 'connectionStatus.OK', + fallback: { ko: '정상', en: 'OK' }, + intent: 'success', + hex: '#22c55e', + }, + WARNING: { + i18nKey: 'connectionStatus.WARNING', + fallback: { ko: '경고', en: 'Warning' }, + intent: 'warning', + hex: '#eab308', + }, + ERROR: { + i18nKey: 'connectionStatus.ERROR', + fallback: { ko: '오류', en: 'Error' }, + intent: 'critical', + hex: '#ef4444', + }, +}; + +/** 소문자 호환 (DataHub 'ok' | 'warn' | 'error') */ +const LEGACY: Record = { + ok: 'OK', + warn: 'WARNING', + warning: 'WARNING', + error: 'ERROR', +}; + +export function getConnectionStatusMeta(s: string) { + if (CONNECTION_STATUSES[s as ConnectionStatus]) return CONNECTION_STATUSES[s as ConnectionStatus]; + const code = LEGACY[s.toLowerCase()]; + return code ? CONNECTION_STATUSES[code] : CONNECTION_STATUSES.OK; +} + +export function getConnectionStatusHex(s: string): string { + return getConnectionStatusMeta(s).hex; +} + +export function getConnectionStatusIntent(s: string): BadgeIntent { + return getConnectionStatusMeta(s).intent; +} diff --git a/frontend/src/shared/constants/darkVesselPatterns.ts b/frontend/src/shared/constants/darkVesselPatterns.ts new file mode 100644 index 0000000..6a57b0e --- /dev/null +++ b/frontend/src/shared/constants/darkVesselPatterns.ts @@ -0,0 +1,91 @@ +/** + * 다크베셀 탐지 패턴 카탈로그 + * + * SSOT: backend dark_pattern enum (V008 시드 DARK_PATTERN 그룹) + * 사용처: DarkVesselDetection + */ + +import type { BadgeIntent } from '@lib/theme/variants'; + +export type DarkVesselPattern = + | 'AIS_FULL_BLOCK' // AIS 완전차단 + | 'MMSI_SPOOFING' // MMSI 변조 의심 + | 'LONG_LOSS' // 장기소실 + | 'INTERMITTENT' // 신호 간헐송출 + | 'SPEED_ANOMALY'; // 속도 이상 + +interface DarkVesselPatternMeta { + code: DarkVesselPattern; + i18nKey: string; + fallback: { ko: string; en: string }; + intent: BadgeIntent; + hex: string; +} + +export const DARK_VESSEL_PATTERNS: Record = { + AIS_FULL_BLOCK: { + code: 'AIS_FULL_BLOCK', + i18nKey: 'darkPattern.AIS_FULL_BLOCK', + fallback: { ko: 'AIS 완전차단', en: 'AIS Full Block' }, + intent: 'critical', + hex: '#ef4444', + }, + MMSI_SPOOFING: { + code: 'MMSI_SPOOFING', + i18nKey: 'darkPattern.MMSI_SPOOFING', + fallback: { ko: 'MMSI 변조 의심', en: 'MMSI Spoofing' }, + intent: 'high', + hex: '#f97316', + }, + LONG_LOSS: { + code: 'LONG_LOSS', + i18nKey: 'darkPattern.LONG_LOSS', + fallback: { ko: '장기 소실', en: 'Long Loss' }, + intent: 'warning', + hex: '#eab308', + }, + INTERMITTENT: { + code: 'INTERMITTENT', + i18nKey: 'darkPattern.INTERMITTENT', + fallback: { ko: '신호 간헐송출', en: 'Intermittent' }, + intent: 'purple', + hex: '#a855f7', + }, + SPEED_ANOMALY: { + code: 'SPEED_ANOMALY', + i18nKey: 'darkPattern.SPEED_ANOMALY', + fallback: { ko: '속도 이상', en: 'Speed Anomaly' }, + intent: 'cyan', + hex: '#06b6d4', + }, +}; + +/** 한글 라벨 호환 매핑 (mock 데이터에 한글이 들어있어서) */ +const LEGACY_KO: Record = { + 'AIS 완전차단': 'AIS_FULL_BLOCK', + 'MMSI 변조 의심': 'MMSI_SPOOFING', + '장기소실': 'LONG_LOSS', + '장기 소실': 'LONG_LOSS', + '신호 간헐송출': 'INTERMITTENT', + '속도 이상': 'SPEED_ANOMALY', +}; + +export function getDarkVesselPatternMeta(p: string): DarkVesselPatternMeta | undefined { + if (DARK_VESSEL_PATTERNS[p as DarkVesselPattern]) return DARK_VESSEL_PATTERNS[p as DarkVesselPattern]; + const code = LEGACY_KO[p]; + return code ? DARK_VESSEL_PATTERNS[code] : undefined; +} + +export function getDarkVesselPatternIntent(p: string): BadgeIntent { + return getDarkVesselPatternMeta(p)?.intent ?? 'muted'; +} + +export function getDarkVesselPatternLabel( + p: string, + t: (k: string, opts?: { defaultValue?: string }) => string, + lang: 'ko' | 'en' = 'ko', +): string { + const meta = getDarkVesselPatternMeta(p); + if (!meta) return p; + return t(meta.i18nKey, { defaultValue: meta.fallback[lang] }); +} diff --git a/frontend/src/shared/constants/deviceStatuses.ts b/frontend/src/shared/constants/deviceStatuses.ts new file mode 100644 index 0000000..52fe89b --- /dev/null +++ b/frontend/src/shared/constants/deviceStatuses.ts @@ -0,0 +1,74 @@ +/** + * 디바이스/Agent 상태 카탈로그 + * + * 함정 Agent, 외부 시스템 연결 등 디바이스의 운영 상태 표기. + * + * 사용처: ShipAgent, DataHub agent 상태, ExternalService 등 + */ + +import type { BadgeIntent } from '@lib/theme/variants'; + +export type DeviceStatus = 'ONLINE' | 'OFFLINE' | 'NOT_DEPLOYED' | 'SYNCING'; + +export interface DeviceStatusMeta { + code: DeviceStatus; + i18nKey: string; + fallback: { ko: string; en: string }; + intent: BadgeIntent; +} + +export const DEVICE_STATUSES: Record = { + ONLINE: { + code: 'ONLINE', + i18nKey: 'deviceStatus.ONLINE', + fallback: { ko: '온라인', en: 'Online' }, + intent: 'success', + }, + SYNCING: { + code: 'SYNCING', + i18nKey: 'deviceStatus.SYNCING', + fallback: { ko: '동기화중', en: 'Syncing' }, + intent: 'info', + }, + OFFLINE: { + code: 'OFFLINE', + i18nKey: 'deviceStatus.OFFLINE', + fallback: { ko: '오프라인', en: 'Offline' }, + intent: 'critical', + }, + NOT_DEPLOYED: { + code: 'NOT_DEPLOYED', + i18nKey: 'deviceStatus.NOT_DEPLOYED', + fallback: { ko: '미배포', en: 'Not Deployed' }, + intent: 'muted', + }, +}; + +/** 한글 라벨도 키로 받음 (mock 호환) */ +const LEGACY_KO: Record = { + '온라인': 'ONLINE', + '오프라인': 'OFFLINE', + '미배포': 'NOT_DEPLOYED', + '동기화중': 'SYNCING', + '동기화 중': 'SYNCING', +}; + +export function getDeviceStatusMeta(status: string): DeviceStatusMeta | undefined { + if (DEVICE_STATUSES[status as DeviceStatus]) return DEVICE_STATUSES[status as DeviceStatus]; + const code = LEGACY_KO[status]; + return code ? DEVICE_STATUSES[code] : undefined; +} + +export function getDeviceStatusIntent(status: string): BadgeIntent { + return getDeviceStatusMeta(status)?.intent ?? 'muted'; +} + +export function getDeviceStatusLabel( + status: string, + t: (key: string, opts?: { defaultValue?: string }) => string, + lang: 'ko' | 'en' = 'ko', +): string { + const meta = getDeviceStatusMeta(status); + if (!meta) return status; + return t(meta.i18nKey, { defaultValue: meta.fallback[lang] }); +} diff --git a/frontend/src/shared/constants/enforcementActions.ts b/frontend/src/shared/constants/enforcementActions.ts new file mode 100644 index 0000000..a95b107 --- /dev/null +++ b/frontend/src/shared/constants/enforcementActions.ts @@ -0,0 +1,83 @@ +/** + * 단속 조치 코드 공통 카탈로그 + * + * SSOT: backend `code_master` 그룹 ENFORCEMENT_ACTION (V008 시드). + * 백엔드 EnforcementRecord.action enum. + * + * 사용처: EnforcementHistory(조치 컬럼), 단속 등록 폼 + */ + +export type EnforcementAction = + | 'CAPTURE' + | 'INSPECT' + | 'WARN' + | 'DISPERSE' + | 'TRACK' + | 'EVIDENCE'; + +export interface EnforcementActionMeta { + code: EnforcementAction; + i18nKey: string; + fallback: { ko: string; en: string }; + hex: string; + order: number; +} + +export const ENFORCEMENT_ACTIONS: Record = { + CAPTURE: { + code: 'CAPTURE', + i18nKey: 'enforcementAction.CAPTURE', + fallback: { ko: '나포', en: 'Capture' }, + hex: '#ef4444', + order: 1, + }, + INSPECT: { + code: 'INSPECT', + i18nKey: 'enforcementAction.INSPECT', + fallback: { ko: '검문', en: 'Inspect' }, + hex: '#f59e0b', + order: 2, + }, + WARN: { + code: 'WARN', + i18nKey: 'enforcementAction.WARN', + fallback: { ko: '경고', en: 'Warn' }, + hex: '#3b82f6', + order: 3, + }, + DISPERSE: { + code: 'DISPERSE', + i18nKey: 'enforcementAction.DISPERSE', + fallback: { ko: '퇴거', en: 'Disperse' }, + hex: '#8b5cf6', + order: 4, + }, + TRACK: { + code: 'TRACK', + i18nKey: 'enforcementAction.TRACK', + fallback: { ko: '추적', en: 'Track' }, + hex: '#06b6d4', + order: 5, + }, + EVIDENCE: { + code: 'EVIDENCE', + i18nKey: 'enforcementAction.EVIDENCE', + fallback: { ko: '증거수집', en: 'Evidence' }, + hex: '#64748b', + order: 6, + }, +}; + +export function getEnforcementActionMeta(action: string): EnforcementActionMeta | undefined { + return ENFORCEMENT_ACTIONS[action as EnforcementAction]; +} + +export function getEnforcementActionLabel( + action: string, + t: (key: string, opts?: { defaultValue?: string }) => string, + lang: 'ko' | 'en' = 'ko', +): string { + const meta = getEnforcementActionMeta(action); + if (!meta) return action; + return t(meta.i18nKey, { defaultValue: meta.fallback[lang] }); +} diff --git a/frontend/src/shared/constants/enforcementResults.ts b/frontend/src/shared/constants/enforcementResults.ts new file mode 100644 index 0000000..2ebe058 --- /dev/null +++ b/frontend/src/shared/constants/enforcementResults.ts @@ -0,0 +1,79 @@ +/** + * 단속 결과 코드 공통 카탈로그 + * + * SSOT: backend `code_master` 그룹 ENFORCEMENT_RESULT (V008 시드). + * 백엔드 EnforcementRecord.result enum. + * + * 사용처: EnforcementHistory(결과 컬럼), 단속 통계 + */ + +export type EnforcementResult = + | 'PUNISHED' + | 'WARNED' + | 'RELEASED' + | 'REFERRED' + | 'FALSE_POSITIVE'; + +export interface EnforcementResultMeta { + code: EnforcementResult; + i18nKey: string; + fallback: { ko: string; en: string }; + classes: string; + order: number; +} + +export const ENFORCEMENT_RESULTS: Record = { + PUNISHED: { + code: 'PUNISHED', + i18nKey: 'enforcementResult.PUNISHED', + fallback: { ko: '처벌', en: 'Punished' }, + classes: 'bg-red-500/20 text-red-400', + order: 1, + }, + REFERRED: { + code: 'REFERRED', + i18nKey: 'enforcementResult.REFERRED', + fallback: { ko: '수사의뢰', en: 'Referred' }, + classes: 'bg-purple-500/20 text-purple-400', + order: 2, + }, + WARNED: { + code: 'WARNED', + i18nKey: 'enforcementResult.WARNED', + fallback: { ko: '경고', en: 'Warned' }, + classes: 'bg-yellow-500/20 text-yellow-400', + order: 3, + }, + RELEASED: { + code: 'RELEASED', + i18nKey: 'enforcementResult.RELEASED', + fallback: { ko: '훈방', en: 'Released' }, + classes: 'bg-green-500/20 text-green-400', + order: 4, + }, + FALSE_POSITIVE: { + code: 'FALSE_POSITIVE', + i18nKey: 'enforcementResult.FALSE_POSITIVE', + fallback: { ko: '오탐(정상)', en: 'False Positive' }, + classes: 'bg-muted text-muted-foreground', + order: 5, + }, +}; + +export function getEnforcementResultMeta(result: string): EnforcementResultMeta | undefined { + return ENFORCEMENT_RESULTS[result as EnforcementResult]; +} + +export function getEnforcementResultLabel( + result: string, + t: (key: string, opts?: { defaultValue?: string }) => string, + lang: 'ko' | 'en' = 'ko', +): string { + const meta = getEnforcementResultMeta(result); + if (!meta) return result; + return t(meta.i18nKey, { defaultValue: meta.fallback[lang] }); +} + +export function getEnforcementResultClasses(result: string): string { + return getEnforcementResultMeta(result)?.classes ?? 'bg-muted text-muted-foreground'; +} diff --git a/frontend/src/shared/constants/engineSeverities.ts b/frontend/src/shared/constants/engineSeverities.ts new file mode 100644 index 0000000..b8587f2 --- /dev/null +++ b/frontend/src/shared/constants/engineSeverities.ts @@ -0,0 +1,90 @@ +/** + * AI 엔진 심각도 카탈로그 + * + * 일반 위험도(AlertLevel)와 다른 별도 분류 체계. + * 단일 값(CRITICAL, HIGH 등) 외에도 **범위 표기**를 지원: 'HIGH~CRITICAL', 'MEDIUM~CRITICAL'. + * + * 사용처: AIModelManagement (탐지 엔진별 심각도 표기) + * + * 향후 확장 가능: prediction_engine 메타데이터, MLOps 모델별 임계치 정보 + */ + +import type { BadgeIntent } from '@lib/theme/variants'; + +export type EngineSeverity = + | 'CRITICAL' + | 'HIGH~CRITICAL' + | 'HIGH' + | 'MEDIUM~CRITICAL' + | 'MEDIUM' + | 'LOW' + | '-'; + +interface EngineSeverityMeta { + code: EngineSeverity; + i18nKey: string; + fallback: { ko: string; en: string }; + intent: BadgeIntent; +} + +export const ENGINE_SEVERITIES: Record = { + 'CRITICAL': { + code: 'CRITICAL', + i18nKey: 'engineSeverity.CRITICAL', + fallback: { ko: '심각', en: 'Critical' }, + intent: 'critical', + }, + 'HIGH~CRITICAL': { + code: 'HIGH~CRITICAL', + i18nKey: 'engineSeverity.HIGH_CRITICAL', + fallback: { ko: '높음~심각', en: 'High~Critical' }, + intent: 'critical', + }, + 'HIGH': { + code: 'HIGH', + i18nKey: 'engineSeverity.HIGH', + fallback: { ko: '높음', en: 'High' }, + intent: 'high', + }, + 'MEDIUM~CRITICAL': { + code: 'MEDIUM~CRITICAL', + i18nKey: 'engineSeverity.MEDIUM_CRITICAL', + fallback: { ko: '보통~심각', en: 'Medium~Critical' }, + intent: 'high', + }, + 'MEDIUM': { + code: 'MEDIUM', + i18nKey: 'engineSeverity.MEDIUM', + fallback: { ko: '보통', en: 'Medium' }, + intent: 'warning', + }, + 'LOW': { + code: 'LOW', + i18nKey: 'engineSeverity.LOW', + fallback: { ko: '낮음', en: 'Low' }, + intent: 'info', + }, + '-': { + code: '-', + i18nKey: 'engineSeverity.NONE', + fallback: { ko: '-', en: '-' }, + intent: 'muted', + }, +}; + +export function getEngineSeverityMeta(sev: string): EngineSeverityMeta { + return ENGINE_SEVERITIES[sev as EngineSeverity] ?? ENGINE_SEVERITIES['-']; +} + +export function getEngineSeverityIntent(sev: string): BadgeIntent { + return getEngineSeverityMeta(sev).intent; +} + +export function getEngineSeverityLabel( + sev: string, + t: (key: string, opts?: { defaultValue?: string }) => string, + lang: 'ko' | 'en' = 'ko', +): string { + const meta = getEngineSeverityMeta(sev); + return t(meta.i18nKey, { defaultValue: meta.fallback[lang] }); +} diff --git a/frontend/src/shared/constants/eventStatuses.ts b/frontend/src/shared/constants/eventStatuses.ts new file mode 100644 index 0000000..0c83c4a --- /dev/null +++ b/frontend/src/shared/constants/eventStatuses.ts @@ -0,0 +1,79 @@ +/** + * 이벤트 처리 상태 공통 카탈로그 + * + * SSOT: backend `code_master` 그룹 EVENT_STATUS (V008 시드). + * 향후 `GET /api/code-master?groupCode=EVENT_STATUS`로 fetch 예정. + * + * 사용처: EventList(처리상태 컬럼), 알림 처리, 단속 등록 액션 + */ + +export type EventStatus = + | 'NEW' + | 'ACK' + | 'IN_PROGRESS' + | 'RESOLVED' + | 'FALSE_POSITIVE'; + +export interface EventStatusMeta { + code: EventStatus; + i18nKey: string; + fallback: { ko: string; en: string }; + classes: string; // bg + text 묶음 + order: number; +} + +export const EVENT_STATUSES: Record = { + NEW: { + code: 'NEW', + i18nKey: 'eventStatus.NEW', + fallback: { ko: '신규', en: 'New' }, + classes: 'bg-red-500/20 text-red-400', + order: 1, + }, + ACK: { + code: 'ACK', + i18nKey: 'eventStatus.ACK', + fallback: { ko: '확인', en: 'Acknowledged' }, + classes: 'bg-orange-500/20 text-orange-400', + order: 2, + }, + IN_PROGRESS: { + code: 'IN_PROGRESS', + i18nKey: 'eventStatus.IN_PROGRESS', + fallback: { ko: '처리중', en: 'In Progress' }, + classes: 'bg-blue-500/20 text-blue-400', + order: 3, + }, + RESOLVED: { + code: 'RESOLVED', + i18nKey: 'eventStatus.RESOLVED', + fallback: { ko: '완료', en: 'Resolved' }, + classes: 'bg-green-500/20 text-green-400', + order: 4, + }, + FALSE_POSITIVE: { + code: 'FALSE_POSITIVE', + i18nKey: 'eventStatus.FALSE_POSITIVE', + fallback: { ko: '오탐', en: 'False Positive' }, + classes: 'bg-muted text-muted-foreground', + order: 5, + }, +}; + +export function getEventStatusMeta(status: string): EventStatusMeta | undefined { + return EVENT_STATUSES[status as EventStatus]; +} + +export function getEventStatusLabel( + status: string, + t: (key: string, opts?: { defaultValue?: string }) => string, + lang: 'ko' | 'en' = 'ko', +): string { + const meta = getEventStatusMeta(status); + if (!meta) return status; + return t(meta.i18nKey, { defaultValue: meta.fallback[lang] }); +} + +export function getEventStatusClasses(status: string): string { + return getEventStatusMeta(status)?.classes ?? 'bg-muted text-muted-foreground'; +} diff --git a/frontend/src/shared/constants/gearGroupTypes.ts b/frontend/src/shared/constants/gearGroupTypes.ts new file mode 100644 index 0000000..10f828d --- /dev/null +++ b/frontend/src/shared/constants/gearGroupTypes.ts @@ -0,0 +1,51 @@ +/** + * 어구 그룹 타입 카탈로그 + * + * SSOT: backend group_polygon_snapshots.group_type + * 사용처: RealGearGroups + */ + +import type { BadgeIntent } from '@lib/theme/variants'; + +export type GearGroupType = 'FLEET' | 'GEAR_IN_ZONE' | 'GEAR_OUT_ZONE'; + +interface GearGroupTypeMeta { + code: GearGroupType; + i18nKey: string; + fallback: { ko: string; en: string }; + intent: BadgeIntent; +} + +export const GEAR_GROUP_TYPES: Record = { + FLEET: { + code: 'FLEET', + i18nKey: 'gearGroupType.FLEET', + fallback: { ko: '선단', en: 'Fleet' }, + intent: 'info', + }, + GEAR_IN_ZONE: { + code: 'GEAR_IN_ZONE', + i18nKey: 'gearGroupType.GEAR_IN_ZONE', + fallback: { ko: '구역 내 어구', en: 'In-Zone Gear' }, + intent: 'high', + }, + GEAR_OUT_ZONE: { + code: 'GEAR_OUT_ZONE', + i18nKey: 'gearGroupType.GEAR_OUT_ZONE', + fallback: { ko: '구역 외 어구', en: 'Out-Zone Gear' }, + intent: 'purple', + }, +}; + +export function getGearGroupTypeIntent(t: string): BadgeIntent { + return GEAR_GROUP_TYPES[t as GearGroupType]?.intent ?? 'muted'; +} +export function getGearGroupTypeLabel( + type: string, + t: (k: string, opts?: { defaultValue?: string }) => string, + lang: 'ko' | 'en' = 'ko', +): string { + const meta = GEAR_GROUP_TYPES[type as GearGroupType]; + if (!meta) return type; + return t(meta.i18nKey, { defaultValue: meta.fallback[lang] }); +} diff --git a/frontend/src/shared/constants/httpStatusCodes.ts b/frontend/src/shared/constants/httpStatusCodes.ts new file mode 100644 index 0000000..3640ae0 --- /dev/null +++ b/frontend/src/shared/constants/httpStatusCodes.ts @@ -0,0 +1,23 @@ +/** + * HTTP 상태 코드 카탈로그 (범위 기반) + * + * 사용처: AccessLogs, AuditLogs API 응답 상태 표시 + */ + +import type { BadgeIntent } from '@lib/theme/variants'; + +/** + * HTTP status code → BadgeIntent + * - 5xx: critical (서버 에러) + * - 4xx: high (클라이언트 에러) + * - 3xx: warning (리다이렉트) + * - 2xx: success + * - 그 외: muted + */ +export function getHttpStatusIntent(code: number): BadgeIntent { + if (code >= 500) return 'critical'; + if (code >= 400) return 'high'; + if (code >= 300) return 'warning'; + if (code >= 200) return 'success'; + return 'muted'; +} diff --git a/frontend/src/shared/constants/index.ts b/frontend/src/shared/constants/index.ts new file mode 100644 index 0000000..6d395b6 --- /dev/null +++ b/frontend/src/shared/constants/index.ts @@ -0,0 +1,31 @@ +/** + * 분류/코드 카탈로그 통합 export + * + * 모든 분류 enum (위반 유형, 위험도, 이벤트 상태, 단속 조치/결과, 함정 상태 등)을 + * 이 파일을 통해 import하여 일관성 유지. + * + * 향후 백엔드 `GET /api/code-master?groupCode={...}` API가 준비되면 + * codeMasterStore에서 fetch한 값으로 정적 카탈로그를 override 할 수 있다. + */ + +export * from './violationTypes'; +export * from './alertLevels'; +export * from './eventStatuses'; +export * from './enforcementActions'; +export * from './enforcementResults'; +export * from './patrolStatuses'; +export * from './engineSeverities'; +export * from './userRoles'; +export * from './deviceStatuses'; +export * from './parentResolutionStatuses'; +export * from './modelDeploymentStatuses'; +export * from './gearGroupTypes'; +export * from './darkVesselPatterns'; +export * from './httpStatusCodes'; +export * from './userAccountStatuses'; +export * from './loginResultStatuses'; +export * from './permissionStatuses'; +export * from './vesselAnalysisStatuses'; +export * from './connectionStatuses'; +export * from './trainingZoneTypes'; +export * from './kpiUiMap'; diff --git a/frontend/src/shared/constants/kpiUiMap.ts b/frontend/src/shared/constants/kpiUiMap.ts new file mode 100644 index 0000000..c478576 --- /dev/null +++ b/frontend/src/shared/constants/kpiUiMap.ts @@ -0,0 +1,38 @@ +/** + * KPI 카드 UI 매핑 (아이콘 + 색상) + * + * Dashboard와 MonitoringDashboard에서 중복 정의되던 KPI_UI_MAP 통합. + * 한글 라벨 / 영문 kpiKey 모두 지원. + */ + +import type { LucideIcon } from 'lucide-react'; +import { Radar, AlertTriangle, Eye, Anchor, Crosshair, Shield, Target } from 'lucide-react'; + +export interface KpiUi { + icon: LucideIcon; + color: string; +} + +export const KPI_UI_MAP: Record = { + // 한글 라벨 (DB prediction_kpi.kpi_label) + '실시간 탐지': { icon: Radar, color: '#3b82f6' }, + 'EEZ 침범': { icon: AlertTriangle, color: '#ef4444' }, + '다크베셀': { icon: Eye, color: '#f97316' }, + '불법환적 의심': { icon: Anchor, color: '#a855f7' }, + '추적 중': { icon: Crosshair, color: '#06b6d4' }, + '나포/검문': { icon: Shield, color: '#10b981' }, + // 영문 kpi_key (백엔드 코드) + realtime_detection: { icon: Radar, color: '#3b82f6' }, + eez_violation: { icon: AlertTriangle, color: '#ef4444' }, + dark_vessel: { icon: Eye, color: '#f97316' }, + illegal_transshipment: { icon: Anchor, color: '#a855f7' }, + illegal_transship: { icon: Anchor, color: '#a855f7' }, + tracking: { icon: Target, color: '#06b6d4' }, + tracking_active: { icon: Target, color: '#06b6d4' }, + enforcement: { icon: Shield, color: '#10b981' }, + captured_inspected: { icon: Shield, color: '#10b981' }, +}; + +export function getKpiUi(key: string): KpiUi { + return KPI_UI_MAP[key] ?? { icon: Radar, color: '#3b82f6' }; +} diff --git a/frontend/src/shared/constants/loginResultStatuses.ts b/frontend/src/shared/constants/loginResultStatuses.ts new file mode 100644 index 0000000..86bbbea --- /dev/null +++ b/frontend/src/shared/constants/loginResultStatuses.ts @@ -0,0 +1,51 @@ +/** + * 로그인 결과 카탈로그 + * + * SSOT: backend kcg.auth_login_history.login_result + * 사용처: LoginHistoryView + */ + +import type { BadgeIntent } from '@lib/theme/variants'; + +export type LoginResult = 'SUCCESS' | 'FAILED' | 'LOCKED'; + +interface LoginResultMeta { + code: LoginResult; + i18nKey: string; + fallback: { ko: string; en: string }; + intent: BadgeIntent; +} + +export const LOGIN_RESULTS: Record = { + SUCCESS: { + code: 'SUCCESS', + i18nKey: 'loginResult.SUCCESS', + fallback: { ko: '성공', en: 'Success' }, + intent: 'success', + }, + FAILED: { + code: 'FAILED', + i18nKey: 'loginResult.FAILED', + fallback: { ko: '실패', en: 'Failed' }, + intent: 'high', + }, + LOCKED: { + code: 'LOCKED', + i18nKey: 'loginResult.LOCKED', + fallback: { ko: '계정 잠금', en: 'Locked' }, + intent: 'critical', + }, +}; + +export function getLoginResultIntent(s: string): BadgeIntent { + return LOGIN_RESULTS[s as LoginResult]?.intent ?? 'muted'; +} +export function getLoginResultLabel( + s: string, + t: (k: string, opts?: { defaultValue?: string }) => string, + lang: 'ko' | 'en' = 'ko', +): string { + const meta = LOGIN_RESULTS[s as LoginResult]; + if (!meta) return s; + return t(meta.i18nKey, { defaultValue: meta.fallback[lang] }); +} diff --git a/frontend/src/shared/constants/modelDeploymentStatuses.ts b/frontend/src/shared/constants/modelDeploymentStatuses.ts new file mode 100644 index 0000000..626caac --- /dev/null +++ b/frontend/src/shared/constants/modelDeploymentStatuses.ts @@ -0,0 +1,73 @@ +/** + * AI 모델 배포 / 품질 게이트 / 실험 상태 카탈로그 + * + * 사용처: MLOpsPage + */ + +import type { BadgeIntent } from '@lib/theme/variants'; + +// ─── 모델 배포 상태 ────────────── +export type ModelStatus = 'DEPLOYED' | 'APPROVED' | 'TESTING' | 'DRAFT'; + +export const MODEL_STATUSES: Record = { + DEPLOYED: { + i18nKey: 'modelStatus.DEPLOYED', + fallback: { ko: '배포됨', en: 'Deployed' }, + intent: 'success', + }, + APPROVED: { + i18nKey: 'modelStatus.APPROVED', + fallback: { ko: '승인', en: 'Approved' }, + intent: 'info', + }, + TESTING: { + i18nKey: 'modelStatus.TESTING', + fallback: { ko: '테스트', en: 'Testing' }, + intent: 'warning', + }, + DRAFT: { + i18nKey: 'modelStatus.DRAFT', + fallback: { ko: '초안', en: 'Draft' }, + intent: 'muted', + }, +}; + +export function getModelStatusIntent(s: string): BadgeIntent { + return MODEL_STATUSES[s as ModelStatus]?.intent ?? 'muted'; +} +export function getModelStatusLabel( + s: string, + t: (k: string, opts?: { defaultValue?: string }) => string, + lang: 'ko' | 'en' = 'ko', +): string { + const meta = MODEL_STATUSES[s as ModelStatus]; + if (!meta) return s; + return t(meta.i18nKey, { defaultValue: meta.fallback[lang] }); +} + +// ─── 품질 게이트 상태 ────────────── +export type QualityGateStatus = 'pass' | 'fail' | 'run' | 'pending'; + +export const QUALITY_GATE_STATUSES: Record = { + pass: { intent: 'success', fallback: { ko: '통과', en: 'Pass' } }, + fail: { intent: 'critical', fallback: { ko: '실패', en: 'Fail' } }, + run: { intent: 'warning', pulse: true, fallback: { ko: '실행중', en: 'Running' } }, + pending: { intent: 'muted', fallback: { ko: '대기', en: 'Pending' } }, +}; + +export function getQualityGateIntent(s: string): BadgeIntent { + return QUALITY_GATE_STATUSES[s as QualityGateStatus]?.intent ?? 'muted'; +} + +// ─── 실험 상태 ────────────── +export type ExperimentStatus = 'running' | 'done' | 'failed'; + +export const EXPERIMENT_STATUSES: Record = { + running: { intent: 'info', pulse: true, fallback: { ko: '실행중', en: 'Running' } }, + done: { intent: 'success', fallback: { ko: '완료', en: 'Done' } }, + failed: { intent: 'critical', fallback: { ko: '실패', en: 'Failed' } }, +}; + +export function getExperimentIntent(s: string): BadgeIntent { + return EXPERIMENT_STATUSES[s as ExperimentStatus]?.intent ?? 'muted'; +} diff --git a/frontend/src/shared/constants/parentResolutionStatuses.ts b/frontend/src/shared/constants/parentResolutionStatuses.ts new file mode 100644 index 0000000..487e296 --- /dev/null +++ b/frontend/src/shared/constants/parentResolutionStatuses.ts @@ -0,0 +1,96 @@ +/** + * 모선 추론(Parent Resolution) 상태 + 라벨 세션 상태 카탈로그 + * + * SSOT: backend gear_group_parent_resolution.status + * 사용처: ParentReview, LabelSession, RealGearGroups + */ + +import type { BadgeIntent } from '@lib/theme/variants'; + +// ─── 부모 해석(Resolution) 상태 ────────────── +export type ParentResolutionStatus = 'UNRESOLVED' | 'MANUAL_CONFIRMED' | 'REVIEW_REQUIRED'; + +interface ParentResolutionMeta { + code: ParentResolutionStatus; + i18nKey: string; + fallback: { ko: string; en: string }; + intent: BadgeIntent; +} + +export const PARENT_RESOLUTION_STATUSES: Record = { + UNRESOLVED: { + code: 'UNRESOLVED', + i18nKey: 'parentResolution.UNRESOLVED', + fallback: { ko: '미해결', en: 'Unresolved' }, + intent: 'warning', + }, + REVIEW_REQUIRED: { + code: 'REVIEW_REQUIRED', + i18nKey: 'parentResolution.REVIEW_REQUIRED', + fallback: { ko: '검토 필요', en: 'Review Required' }, + intent: 'critical', + }, + MANUAL_CONFIRMED: { + code: 'MANUAL_CONFIRMED', + i18nKey: 'parentResolution.MANUAL_CONFIRMED', + fallback: { ko: '수동 확정', en: 'Manually Confirmed' }, + intent: 'success', + }, +}; + +export function getParentResolutionIntent(s: string): BadgeIntent { + return PARENT_RESOLUTION_STATUSES[s as ParentResolutionStatus]?.intent ?? 'muted'; +} +export function getParentResolutionLabel( + s: string, + t: (k: string, opts?: { defaultValue?: string }) => string, + lang: 'ko' | 'en' = 'ko', +): string { + const meta = PARENT_RESOLUTION_STATUSES[s as ParentResolutionStatus]; + if (!meta) return s; + return t(meta.i18nKey, { defaultValue: meta.fallback[lang] }); +} + +// ─── 라벨 세션 상태 ────────────── +export type LabelSessionStatus = 'ACTIVE' | 'CANCELLED' | 'COMPLETED'; + +interface LabelSessionMeta { + code: LabelSessionStatus; + i18nKey: string; + fallback: { ko: string; en: string }; + intent: BadgeIntent; +} + +export const LABEL_SESSION_STATUSES: Record = { + ACTIVE: { + code: 'ACTIVE', + i18nKey: 'labelSession.ACTIVE', + fallback: { ko: '진행중', en: 'Active' }, + intent: 'success', + }, + COMPLETED: { + code: 'COMPLETED', + i18nKey: 'labelSession.COMPLETED', + fallback: { ko: '완료', en: 'Completed' }, + intent: 'info', + }, + CANCELLED: { + code: 'CANCELLED', + i18nKey: 'labelSession.CANCELLED', + fallback: { ko: '취소', en: 'Cancelled' }, + intent: 'muted', + }, +}; + +export function getLabelSessionIntent(s: string): BadgeIntent { + return LABEL_SESSION_STATUSES[s as LabelSessionStatus]?.intent ?? 'muted'; +} +export function getLabelSessionLabel( + s: string, + t: (k: string, opts?: { defaultValue?: string }) => string, + lang: 'ko' | 'en' = 'ko', +): string { + const meta = LABEL_SESSION_STATUSES[s as LabelSessionStatus]; + if (!meta) return s; + return t(meta.i18nKey, { defaultValue: meta.fallback[lang] }); +} diff --git a/frontend/src/shared/constants/patrolStatuses.ts b/frontend/src/shared/constants/patrolStatuses.ts new file mode 100644 index 0000000..a8a314c --- /dev/null +++ b/frontend/src/shared/constants/patrolStatuses.ts @@ -0,0 +1,117 @@ +/** + * 함정 상태 공통 카탈로그 + * + * SSOT: backend `code_master` 그룹 PATROL_STATUS (V008 시드). + * 향후 patrol_ship_master.status 컬럼 enum. + * + * 사용처: Dashboard PatrolStatusBadge, ShipAgent + */ + +export type PatrolStatus = + | 'AVAILABLE' + | 'ON_PATROL' + | 'IN_PURSUIT' + | 'INSPECTING' + | 'RETURNING' + | 'STANDBY' + | 'MAINTENANCE'; + +export interface PatrolStatusMeta { + code: PatrolStatus; + i18nKey: string; + fallback: { ko: string; en: string }; + classes: string; + order: number; +} + +export const PATROL_STATUSES: Record = { + IN_PURSUIT: { + code: 'IN_PURSUIT', + i18nKey: 'patrolStatus.IN_PURSUIT', + fallback: { ko: '추적중', en: 'In Pursuit' }, + classes: 'bg-red-500/20 text-red-400 border-red-500/30', + order: 1, + }, + INSPECTING: { + code: 'INSPECTING', + i18nKey: 'patrolStatus.INSPECTING', + fallback: { ko: '검문중', en: 'Inspecting' }, + classes: 'bg-orange-500/20 text-orange-400 border-orange-500/30', + order: 2, + }, + ON_PATROL: { + code: 'ON_PATROL', + i18nKey: 'patrolStatus.ON_PATROL', + fallback: { ko: '초계중', en: 'On Patrol' }, + classes: 'bg-blue-500/20 text-blue-400 border-blue-500/30', + order: 3, + }, + RETURNING: { + code: 'RETURNING', + i18nKey: 'patrolStatus.RETURNING', + fallback: { ko: '귀항중', en: 'Returning' }, + classes: 'bg-purple-500/20 text-purple-400 border-purple-500/30', + order: 4, + }, + AVAILABLE: { + code: 'AVAILABLE', + i18nKey: 'patrolStatus.AVAILABLE', + fallback: { ko: '가용', en: 'Available' }, + classes: 'bg-green-500/20 text-green-400 border-green-500/30', + order: 5, + }, + STANDBY: { + code: 'STANDBY', + i18nKey: 'patrolStatus.STANDBY', + fallback: { ko: '대기', en: 'Standby' }, + classes: 'bg-slate-500/20 text-slate-400 border-slate-500/30', + order: 6, + }, + MAINTENANCE: { + code: 'MAINTENANCE', + i18nKey: 'patrolStatus.MAINTENANCE', + fallback: { ko: '정비중', en: 'Maintenance' }, + classes: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', + order: 7, + }, +}; + +/** 한글 라벨도 키로 받아주는 호환성 매핑 (mock 데이터에서 한글 사용 중) */ +const LEGACY_KO_LABELS: Record = { + '추적 중': 'IN_PURSUIT', + '추적중': 'IN_PURSUIT', + '검문 중': 'INSPECTING', + '검문중': 'INSPECTING', + '초계 중': 'ON_PATROL', + '초계중': 'ON_PATROL', + '귀항 중': 'RETURNING', + '귀항중': 'RETURNING', + '가용': 'AVAILABLE', + '대기': 'STANDBY', + '정비 중': 'MAINTENANCE', + '정비중': 'MAINTENANCE', +}; + +export function getPatrolStatusMeta(status: string): PatrolStatusMeta | undefined { + // 영문 enum 우선 + if (PATROL_STATUSES[status as PatrolStatus]) { + return PATROL_STATUSES[status as PatrolStatus]; + } + // 한글 라벨 폴백 (mock 호환) + const code = LEGACY_KO_LABELS[status]; + return code ? PATROL_STATUSES[code] : undefined; +} + +export function getPatrolStatusLabel( + status: string, + t: (key: string, opts?: { defaultValue?: string }) => string, + lang: 'ko' | 'en' = 'ko', +): string { + const meta = getPatrolStatusMeta(status); + if (!meta) return status; + return t(meta.i18nKey, { defaultValue: meta.fallback[lang] }); +} + +export function getPatrolStatusClasses(status: string): string { + return getPatrolStatusMeta(status)?.classes ?? 'bg-muted text-muted-foreground border-border'; +} diff --git a/frontend/src/shared/constants/permissionStatuses.ts b/frontend/src/shared/constants/permissionStatuses.ts new file mode 100644 index 0000000..471626e --- /dev/null +++ b/frontend/src/shared/constants/permissionStatuses.ts @@ -0,0 +1,93 @@ +/** + * 어업 허가/판정 상태 카탈로그 + * + * 사용처: GearDetection + */ + +import type { BadgeIntent } from '@lib/theme/variants'; + +export type PermitStatus = 'VALID' | 'EXPIRED' | 'UNLICENSED' | 'PENDING'; + +interface PermitStatusMeta { + code: PermitStatus; + i18nKey: string; + fallback: { ko: string; en: string }; + intent: BadgeIntent; +} + +export const PERMIT_STATUSES: Record = { + VALID: { + code: 'VALID', + i18nKey: 'permitStatus.VALID', + fallback: { ko: '유효', en: 'Valid' }, + intent: 'success', + }, + PENDING: { + code: 'PENDING', + i18nKey: 'permitStatus.PENDING', + fallback: { ko: '확인 중', en: 'Pending' }, + intent: 'warning', + }, + EXPIRED: { + code: 'EXPIRED', + i18nKey: 'permitStatus.EXPIRED', + fallback: { ko: '만료', en: 'Expired' }, + intent: 'high', + }, + UNLICENSED: { + code: 'UNLICENSED', + i18nKey: 'permitStatus.UNLICENSED', + fallback: { ko: '무허가', en: 'Unlicensed' }, + intent: 'critical', + }, +}; + +const LEGACY_KO: Record = { + '유효': 'VALID', + '무허가': 'UNLICENSED', + '만료': 'EXPIRED', + '확인 중': 'PENDING', + '확인중': 'PENDING', +}; + +export function getPermitStatusIntent(s: string): BadgeIntent { + if (PERMIT_STATUSES[s as PermitStatus]) return PERMIT_STATUSES[s as PermitStatus].intent; + const code = LEGACY_KO[s]; + return code ? PERMIT_STATUSES[code].intent : 'warning'; +} +export function getPermitStatusLabel( + s: string, + t: (k: string, opts?: { defaultValue?: string }) => string, + lang: 'ko' | 'en' = 'ko', +): string { + let meta = PERMIT_STATUSES[s as PermitStatus]; + if (!meta) { + const code = LEGACY_KO[s]; + if (code) meta = PERMIT_STATUSES[code]; + } + if (!meta) return s; + return t(meta.i18nKey, { defaultValue: meta.fallback[lang] }); +} + +// ─── 어구 판정 상태 ────────────── +export type GearJudgment = 'NORMAL' | 'SUSPECT_ILLEGAL' | 'CHECKING'; + +export const GEAR_JUDGMENTS: Record = { + NORMAL: { i18nKey: 'gearJudgment.NORMAL', fallback: { ko: '정상', en: 'Normal' }, intent: 'success' }, + CHECKING: { i18nKey: 'gearJudgment.CHECKING', fallback: { ko: '확인 중', en: 'Checking' }, intent: 'warning' }, + SUSPECT_ILLEGAL: { i18nKey: 'gearJudgment.SUSPECT_ILLEGAL', fallback: { ko: '불법 의심', en: 'Suspect Illegal' }, intent: 'critical' }, +}; + +const GEAR_LEGACY_KO: Record = { + '정상': 'NORMAL', + '확인 중': 'CHECKING', + '확인중': 'CHECKING', + '불법 의심': 'SUSPECT_ILLEGAL', + '불법의심': 'SUSPECT_ILLEGAL', +}; + +export function getGearJudgmentIntent(s: string): BadgeIntent { + if (GEAR_JUDGMENTS[s as GearJudgment]) return GEAR_JUDGMENTS[s as GearJudgment].intent; + const code = GEAR_LEGACY_KO[s] ?? (s.includes('불법') ? 'SUSPECT_ILLEGAL' : undefined); + return code ? GEAR_JUDGMENTS[code].intent : 'warning'; +} diff --git a/frontend/src/shared/constants/trainingZoneTypes.ts b/frontend/src/shared/constants/trainingZoneTypes.ts new file mode 100644 index 0000000..8fe39f2 --- /dev/null +++ b/frontend/src/shared/constants/trainingZoneTypes.ts @@ -0,0 +1,85 @@ +/** + * 군사 훈련구역 타입 카탈로그 + * + * 사용처: MapControl 훈련구역 마커/배지 + */ + +import type { BadgeIntent } from '@lib/theme/variants'; + +export type TrainingZoneType = 'NAVY' | 'AIRFORCE' | 'ARMY' | 'ADD' | 'KCG'; + +interface TrainingZoneTypeMeta { + code: TrainingZoneType; + i18nKey: string; + fallback: { ko: string; en: string }; + intent: BadgeIntent; + hex: string; +} + +export const TRAINING_ZONE_TYPES: Record = { + NAVY: { + code: 'NAVY', + i18nKey: 'trainingZone.NAVY', + fallback: { ko: '해군 훈련 구역', en: 'Navy Training Zone' }, + intent: 'warning', + hex: '#eab308', + }, + AIRFORCE: { + code: 'AIRFORCE', + i18nKey: 'trainingZone.AIRFORCE', + fallback: { ko: '공군 훈련 구역', en: 'Airforce Training Zone' }, + intent: 'critical', + hex: '#ec4899', + }, + ARMY: { + code: 'ARMY', + i18nKey: 'trainingZone.ARMY', + fallback: { ko: '육군 훈련 구역', en: 'Army Training Zone' }, + intent: 'success', + hex: '#22c55e', + }, + ADD: { + code: 'ADD', + i18nKey: 'trainingZone.ADD', + fallback: { ko: '국방과학연구소', en: 'ADD' }, + intent: 'info', + hex: '#3b82f6', + }, + KCG: { + code: 'KCG', + i18nKey: 'trainingZone.KCG', + fallback: { ko: '해양경찰청', en: 'KCG' }, + intent: 'purple', + hex: '#a855f7', + }, +}; + +const LEGACY_KO: Record = { + '해군': 'NAVY', + '공군': 'AIRFORCE', + '육군': 'ARMY', + '국과연': 'ADD', + '해경': 'KCG', +}; + +export function getTrainingZoneMeta(t: string): TrainingZoneTypeMeta | undefined { + if (TRAINING_ZONE_TYPES[t as TrainingZoneType]) return TRAINING_ZONE_TYPES[t as TrainingZoneType]; + const code = LEGACY_KO[t]; + return code ? TRAINING_ZONE_TYPES[code] : undefined; +} + +export function getTrainingZoneIntent(t: string): BadgeIntent { + return getTrainingZoneMeta(t)?.intent ?? 'muted'; +} +export function getTrainingZoneHex(t: string): string { + return getTrainingZoneMeta(t)?.hex ?? '#6b7280'; +} +export function getTrainingZoneLabel( + type: string, + t: (k: string, opts?: { defaultValue?: string }) => string, + lang: 'ko' | 'en' = 'ko', +): string { + const meta = getTrainingZoneMeta(type); + if (!meta) return type; + return t(meta.i18nKey, { defaultValue: meta.fallback[lang] }); +} diff --git a/frontend/src/shared/constants/userAccountStatuses.ts b/frontend/src/shared/constants/userAccountStatuses.ts new file mode 100644 index 0000000..59dfe54 --- /dev/null +++ b/frontend/src/shared/constants/userAccountStatuses.ts @@ -0,0 +1,57 @@ +/** + * 사용자 계정 상태 카탈로그 + * + * SSOT: backend kcg.auth_user.user_stts_cd + * 사용처: AccessControl, 사용자 관리 화면 + */ + +import type { BadgeIntent } from '@lib/theme/variants'; + +export type UserAccountStatus = 'ACTIVE' | 'LOCKED' | 'INACTIVE' | 'PENDING'; + +interface UserAccountStatusMeta { + code: UserAccountStatus; + i18nKey: string; + fallback: { ko: string; en: string }; + intent: BadgeIntent; +} + +export const USER_ACCOUNT_STATUSES: Record = { + ACTIVE: { + code: 'ACTIVE', + i18nKey: 'userAccountStatus.ACTIVE', + fallback: { ko: '활성', en: 'Active' }, + intent: 'success', + }, + PENDING: { + code: 'PENDING', + i18nKey: 'userAccountStatus.PENDING', + fallback: { ko: '승인 대기', en: 'Pending' }, + intent: 'warning', + }, + LOCKED: { + code: 'LOCKED', + i18nKey: 'userAccountStatus.LOCKED', + fallback: { ko: '잠금', en: 'Locked' }, + intent: 'critical', + }, + INACTIVE: { + code: 'INACTIVE', + i18nKey: 'userAccountStatus.INACTIVE', + fallback: { ko: '비활성', en: 'Inactive' }, + intent: 'muted', + }, +}; + +export function getUserAccountStatusIntent(s: string): BadgeIntent { + return USER_ACCOUNT_STATUSES[s as UserAccountStatus]?.intent ?? 'muted'; +} +export function getUserAccountStatusLabel( + s: string, + t: (k: string, opts?: { defaultValue?: string }) => string, + lang: 'ko' | 'en' = 'ko', +): string { + const meta = USER_ACCOUNT_STATUSES[s as UserAccountStatus]; + if (!meta) return s; + return t(meta.i18nKey, { defaultValue: meta.fallback[lang] }); +} diff --git a/frontend/src/shared/constants/userRoles.ts b/frontend/src/shared/constants/userRoles.ts new file mode 100644 index 0000000..91695af --- /dev/null +++ b/frontend/src/shared/constants/userRoles.ts @@ -0,0 +1,84 @@ +/** + * 사용자 역할 색상 카탈로그 + * + * SSOT: backend `auth_role.color_hex` (V017 추가). + * + * 동작 방식: + * - 백엔드에서 fetch한 RoleWithPermissions[]의 colorHex가 1차 source + * - DB에 colorHex가 NULL이거나 미등록 역할은 ROLE_FALLBACK_PALETTE에서 + * role code 해시 기반으로 안정적 색상 할당 + * + * 사용처: MainLayout, UserRoleAssignDialog, PermissionsPanel, AccessControl + */ + +/** 기본 색상 팔레트 — DB color_hex가 없을 때 폴백 */ +export const ROLE_DEFAULT_PALETTE: string[] = [ + '#ef4444', // red + '#f97316', // orange + '#eab308', // yellow + '#22c55e', // green + '#06b6d4', // cyan + '#3b82f6', // blue + '#a855f7', // purple + '#ec4899', // pink + '#64748b', // slate + '#84cc16', // lime + '#14b8a6', // teal + '#f59e0b', // amber +]; + +/** 빌트인 5개 역할의 기본 색상 (백엔드 V017 시드와 동일) */ +const BUILTIN_ROLE_COLORS: Record = { + ADMIN: '#ef4444', + OPERATOR: '#3b82f6', + ANALYST: '#a855f7', + FIELD: '#22c55e', + VIEWER: '#eab308', +}; + +/** 백엔드 fetch 결과를 캐시 — 역할 코드 → colorHex */ +let roleColorCache: Record = {}; + +/** RoleWithPermissions[] fetch 결과로 캐시 갱신 (RoleStore 등에서 호출) */ +export function updateRoleColorCache(roles: { roleCd: string; colorHex: string | null }[]): void { + roleColorCache = {}; + roles.forEach((r) => { + roleColorCache[r.roleCd] = r.colorHex; + }); +} + +/** 코드 해시 기반 안정적 폴백 색상 */ +function hashFallbackColor(roleCd: string): string { + let hash = 0; + for (let i = 0; i < roleCd.length; i++) hash = (hash * 31 + roleCd.charCodeAt(i)) | 0; + return ROLE_DEFAULT_PALETTE[Math.abs(hash) % ROLE_DEFAULT_PALETTE.length]; +} + +/** + * 역할 코드 → hex 색상. + * 우선순위: 캐시(DB) → 빌트인 → 해시 폴백 + */ +export function getRoleColorHex(roleCd: string): string { + const cached = roleColorCache[roleCd]; + if (cached) return cached; + if (BUILTIN_ROLE_COLORS[roleCd]) return BUILTIN_ROLE_COLORS[roleCd]; + return hashFallbackColor(roleCd); +} + +/** + * hex → Tailwind 유사 클래스 묶음. + * 인라인 style이 가능한 환경에서는 getRoleColorHex 직접 사용 권장. + * + * 클래스 기반이 필요한 곳을 위한 호환 함수. + */ +export function getRoleBadgeStyle(roleCd: string): React.CSSProperties { + const hex = getRoleColorHex(roleCd); + return { + backgroundColor: hex, + borderColor: hex, + color: '#0f172a', // slate-900 (badgeVariants 정책과 동일) + }; +} + +/** import('react') 회피용 type-only import */ +import type React from 'react'; diff --git a/frontend/src/shared/constants/vesselAnalysisStatuses.ts b/frontend/src/shared/constants/vesselAnalysisStatuses.ts new file mode 100644 index 0000000..50d4527 --- /dev/null +++ b/frontend/src/shared/constants/vesselAnalysisStatuses.ts @@ -0,0 +1,84 @@ +/** + * 선박 분석/감시 상태 카탈로그 + * + * 사용처: ChinaFishing StatusRing, DarkVesselDetection 상태 + */ + +import type { BadgeIntent } from '@lib/theme/variants'; + +export type VesselSurveillanceStatus = 'TRACKING' | 'WATCHING' | 'CHECKING' | 'NORMAL'; + +export const VESSEL_SURVEILLANCE_STATUSES: Record = { + TRACKING: { + i18nKey: 'vesselSurveillance.TRACKING', + fallback: { ko: '추적중', en: 'Tracking' }, + intent: 'critical', + }, + WATCHING: { + i18nKey: 'vesselSurveillance.WATCHING', + fallback: { ko: '감시중', en: 'Watching' }, + intent: 'warning', + }, + CHECKING: { + i18nKey: 'vesselSurveillance.CHECKING', + fallback: { ko: '확인중', en: 'Checking' }, + intent: 'info', + }, + NORMAL: { + i18nKey: 'vesselSurveillance.NORMAL', + fallback: { ko: '정상', en: 'Normal' }, + intent: 'success', + }, +}; + +const LEGACY_KO: Record = { + '추적중': 'TRACKING', + '추적 중': 'TRACKING', + '감시중': 'WATCHING', + '감시 중': 'WATCHING', + '확인중': 'CHECKING', + '확인 중': 'CHECKING', + '정상': 'NORMAL', +}; + +export function getVesselSurveillanceIntent(s: string): BadgeIntent { + if (VESSEL_SURVEILLANCE_STATUSES[s as VesselSurveillanceStatus]) { + return VESSEL_SURVEILLANCE_STATUSES[s as VesselSurveillanceStatus].intent; + } + const code = LEGACY_KO[s]; + return code ? VESSEL_SURVEILLANCE_STATUSES[code].intent : 'muted'; +} +export function getVesselSurveillanceLabel( + s: string, + t: (k: string, opts?: { defaultValue?: string }) => string, + lang: 'ko' | 'en' = 'ko', +): string { + let meta = VESSEL_SURVEILLANCE_STATUSES[s as VesselSurveillanceStatus]; + if (!meta) { + const code = LEGACY_KO[s]; + if (code) meta = VESSEL_SURVEILLANCE_STATUSES[code]; + } + if (!meta) return s; + return t(meta.i18nKey, { defaultValue: meta.fallback[lang] }); +} + +// ─── ChinaFishing StatusRing 용 (한글 라벨 매핑) ────────────── +export type VesselRiskRing = 'SAFE' | 'SUSPECT' | 'WARNING'; + +export const VESSEL_RISK_RINGS: Record = { + SAFE: { i18nKey: 'vesselRing.SAFE', fallback: { ko: '양호', en: 'Safe' }, intent: 'success', hex: '#10b981' }, + SUSPECT: { i18nKey: 'vesselRing.SUSPECT', fallback: { ko: '의심', en: 'Suspect' }, intent: 'high', hex: '#f97316' }, + WARNING: { i18nKey: 'vesselRing.WARNING', fallback: { ko: '경고', en: 'Warning' }, intent: 'critical', hex: '#ef4444' }, +}; + +const RING_LEGACY_KO: Record = { + '양호': 'SAFE', + '의심': 'SUSPECT', + '경고': 'WARNING', +}; + +export function getVesselRingMeta(s: string) { + if (VESSEL_RISK_RINGS[s as VesselRiskRing]) return VESSEL_RISK_RINGS[s as VesselRiskRing]; + const code = RING_LEGACY_KO[s]; + return code ? VESSEL_RISK_RINGS[code] : VESSEL_RISK_RINGS.SAFE; +} diff --git a/frontend/src/shared/constants/violationTypes.ts b/frontend/src/shared/constants/violationTypes.ts new file mode 100644 index 0000000..541bdd4 --- /dev/null +++ b/frontend/src/shared/constants/violationTypes.ts @@ -0,0 +1,128 @@ +/** + * 위반(탐지) 유형 공통 카탈로그 + * + * 백엔드 violation_classifier.py의 enum 코드를 SSOT로 사용한다. + * 색상, 라벨(i18n), 정렬 순서를 한 곳에서 관리. + * + * 신규 유형 추가/색상 변경 시 이 파일만 수정하면 모든 화면에 반영된다. + * + * 사용처: + * - Dashboard / MonitoringDashboard 위반 유형 분포 차트 + * - Statistics 위반 유형별 분포 + * - EventList / EnforcementHistory 위반 유형 표시 + */ + +import type { BadgeIntent } from '@lib/theme/variants'; + +export type ViolationCode = + | 'EEZ_VIOLATION' + | 'DARK_VESSEL' + | 'MMSI_TAMPERING' + | 'ILLEGAL_TRANSSHIP' + | 'ILLEGAL_GEAR' + | 'RISK_BEHAVIOR'; + +export interface ViolationTypeMeta { + code: ViolationCode; + /** i18n 키 (common.json) */ + i18nKey: string; + /** 차트/UI 색상 (hex) — 차트나 인라인 style용 */ + color: string; + /** 공통 Badge 컴포넌트 intent */ + intent: BadgeIntent; + /** i18n 미적용 환경의 폴백 라벨 */ + fallback: { ko: string; en: string }; + /** 정렬 순서 (낮을수록 상단) */ + order: number; +} + +export const VIOLATION_TYPES: Record = { + EEZ_VIOLATION: { + code: 'EEZ_VIOLATION', + i18nKey: 'violation.eezViolation', + color: '#ef4444', + intent: 'critical', + fallback: { ko: 'EEZ 침범', en: 'EEZ Violation' }, + order: 1, + }, + DARK_VESSEL: { + code: 'DARK_VESSEL', + i18nKey: 'violation.darkVessel', + color: '#f97316', + intent: 'high', + fallback: { ko: '다크베셀', en: 'Dark Vessel' }, + order: 2, + }, + MMSI_TAMPERING: { + code: 'MMSI_TAMPERING', + i18nKey: 'violation.mmsiTampering', + color: '#eab308', + intent: 'warning', + fallback: { ko: 'MMSI 변조', en: 'MMSI Tampering' }, + order: 3, + }, + ILLEGAL_TRANSSHIP: { + code: 'ILLEGAL_TRANSSHIP', + i18nKey: 'violation.illegalTransship', + color: '#a855f7', + intent: 'purple', + fallback: { ko: '불법 환적', en: 'Illegal Transship' }, + order: 4, + }, + ILLEGAL_GEAR: { + code: 'ILLEGAL_GEAR', + i18nKey: 'violation.illegalGear', + color: '#06b6d4', + intent: 'cyan', + fallback: { ko: '불법 어구', en: 'Illegal Gear' }, + order: 5, + }, + RISK_BEHAVIOR: { + code: 'RISK_BEHAVIOR', + i18nKey: 'violation.riskBehavior', + color: '#6b7280', + intent: 'muted', + fallback: { ko: '위험 행동', en: 'Risk Behavior' }, + order: 6, + }, +}; + +export function getViolationIntent(code: string): BadgeIntent { + return VIOLATION_TYPES[code as ViolationCode]?.intent ?? 'muted'; +} + +/** 등록되지 않은 코드를 위한 폴백 색상 팔레트 (안정적 매핑) */ +const FALLBACK_PALETTE = ['#ef4444', '#f97316', '#a855f7', '#eab308', '#06b6d4', '#3b82f6', '#10b981', '#6b7280']; + +/** 코드 → 색상 (등록 안 된 코드는 코드 해시 기반 안정적 색상 반환) */ +export function getViolationColor(code: string): string { + const meta = VIOLATION_TYPES[code as ViolationCode]; + if (meta) return meta.color; + // 등록되지 않은 코드: 문자열 해시 기반으로 안정적 색상 할당 + let hash = 0; + for (let i = 0; i < code.length; i++) hash = (hash * 31 + code.charCodeAt(i)) | 0; + return FALLBACK_PALETTE[Math.abs(hash) % FALLBACK_PALETTE.length]; +} + +/** + * 코드 → 표시 라벨 (i18n) + * @param code 백엔드 enum 코드 + * @param t react-i18next의 t 함수 (common namespace) + * @param lang 'ko' | 'en' (현재 언어, fallback 용도) + */ +export function getViolationLabel( + code: string, + t: (key: string, opts?: { defaultValue?: string }) => string, + lang: 'ko' | 'en' = 'ko', +): string { + const meta = VIOLATION_TYPES[code as ViolationCode]; + if (!meta) return code; + return t(meta.i18nKey, { defaultValue: meta.fallback[lang] }); +} + +/** 카탈로그 정렬 순으로 코드 목록 반환 */ +export function listViolationCodes(): ViolationCode[] { + return Object.values(VIOLATION_TYPES) + .sort((a, b) => a.order - b.order) + .map((m) => m.code); +} diff --git a/frontend/src/shared/utils/dateFormat.ts b/frontend/src/shared/utils/dateFormat.ts index 44a83df..55f6a03 100644 --- a/frontend/src/shared/utils/dateFormat.ts +++ b/frontend/src/shared/utils/dateFormat.ts @@ -7,12 +7,12 @@ const KST: Intl.DateTimeFormatOptions = { timeZone: 'Asia/Seoul' }; -/** 2026-04-07 14:30:00 형식 (KST) */ +/** 2026-04-07 14:30:00 형식 (KST). sv-SE 로케일은 ISO 유사 출력을 보장. */ 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', { + return d.toLocaleString('sv-SE', { ...KST, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', @@ -25,7 +25,7 @@ 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', { + return d.toLocaleDateString('sv-SE', { ...KST, year: 'numeric', month: '2-digit', day: '2-digit', }); @@ -36,7 +36,7 @@ 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', { + return d.toLocaleTimeString('sv-SE', { ...KST, hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index 615f6b0..1407978 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -46,6 +46,9 @@ --text-heading: #ffffff; --text-label: #cbd5e1; --text-hint: #64748b; + /* 컬러풀 배경 위 텍스트 — 테마 무관 고정 */ + --text-on-vivid: #ffffff; /* -600/-700 진한 배경 위 (액션 버튼 등) */ + --text-on-bright: #0f172a; /* -300/-400 밝은 배경 위 (배지 등) */ --scrollbar-thumb: #334155; --scrollbar-hover: #475569; } @@ -91,6 +94,9 @@ --text-heading: #0f172a; --text-label: #334155; --text-hint: #94a3b8; + /* 컬러풀 배경 위 텍스트 — 라이트 모드도 동일 (가독성 일관) */ + --text-on-vivid: #ffffff; + --text-on-bright: #0f172a; --scrollbar-thumb: #cbd5e1; --scrollbar-hover: #94a3b8; } @@ -139,6 +145,20 @@ --color-text-heading: var(--text-heading); --color-text-label: var(--text-label); --color-text-hint: var(--text-hint); + --color-text-on-vivid: var(--text-on-vivid); + --color-text-on-bright: var(--text-on-bright); +} + +/* 시맨틱 텍스트/배경 유틸리티 — Tailwind v4 자동 생성 의존하지 않고 명시 정의 + * (--color-text-* 같은 복합 이름은 v4가 utility 자동 매핑하지 않으므로 직접 선언) */ +@layer utilities { + .text-heading { color: var(--text-heading); } + .text-label { color: var(--text-label); } + .text-hint { color: var(--text-hint); } + .text-on-vivid { color: var(--text-on-vivid); } + .text-on-bright { color: var(--text-on-bright); } + .bg-surface-raised { background-color: var(--surface-raised); } + .bg-surface-overlay { background-color: var(--surface-overlay); } } @layer base { From a07c745cbca240f062e7cf1e8fbda87a6d4627d9 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 8 Apr 2026 10:53:58 +0900 Subject: [PATCH 04/22] =?UTF-8?q?feat(frontend):=2040+=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20Badge/=EC=8B=9C=EB=A7=A8=ED=8B=B1=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모든 feature 페이지의 Badge className 패턴을 intent/size prop으로 변환 - 컬러풀 액션 버튼 (bg-*-500/600/700 + text-heading) -> text-on-vivid - 검색/필터 버튼 배경 bg-blue-400 + text-on-bright (밝은 배경 위 검정) - ROLE_COLORS 4곳 중복 제거 (MainLayout/UserRoleAssignDialog/ PermissionsPanel/AccessControl) -> getRoleBadgeStyle 공통 호출 - PermissionsPanel 역할 생성/수정에 ColorPicker 통합 - MainLayout: PagePagination + scroll page state 제거 (데이터 페이지네이션 혼동) - Dashboard RiskBar 단위 버그 수정 (0~100 정수 처리) - ReportManagement, TransferDetection p-5 space-y-4 padding 복구 - EnforcementHistory 그리드 minmax 적용으로 컬럼 잘림 해소 - timeline 시간 formatDateTime 적용 (ISO T 구분자 처리) - 각 feature 페이지가 공통 카탈로그 API (getXxxIntent/Label/Classes) 사용 --- frontend/src/app/layout/MainLayout.tsx | 90 +-------- frontend/src/features/admin/AccessControl.tsx | 33 +--- frontend/src/features/admin/AccessLogs.tsx | 8 +- frontend/src/features/admin/DataHub.tsx | 33 ++-- .../src/features/admin/LoginHistoryView.tsx | 13 +- .../src/features/admin/NoticeManagement.tsx | 2 +- .../src/features/admin/PermissionsPanel.tsx | 82 +++++--- frontend/src/features/admin/SystemConfig.tsx | 4 +- .../features/admin/UserRoleAssignDialog.tsx | 11 +- .../features/ai-operations/AIAssistant.tsx | 2 +- .../ai-operations/AIModelManagement.tsx | 17 +- .../src/features/ai-operations/MLOpsPage.tsx | 37 ++-- frontend/src/features/auth/LoginPage.tsx | 2 +- frontend/src/features/dashboard/Dashboard.tsx | 124 +++++------- .../src/features/detection/ChinaFishing.tsx | 30 +-- .../detection/DarkVesselDetection.tsx | 64 ++++--- .../src/features/detection/GearDetection.tsx | 45 +++-- .../features/detection/GearIdentification.tsx | 2 +- .../src/features/detection/RealGearGroups.tsx | 26 +-- .../features/detection/RealVesselAnalysis.tsx | 16 +- .../enforcement/EnforcementHistory.tsx | 179 ++++++++++-------- .../src/features/enforcement/EventList.tsx | 114 ++++++----- frontend/src/features/field-ops/AIAlert.tsx | 2 +- .../src/features/field-ops/MobileService.tsx | 3 +- frontend/src/features/field-ops/ShipAgent.tsx | 35 +++- .../monitoring/MonitoringDashboard.tsx | 43 ++--- .../parent-inference/LabelSession.tsx | 13 +- .../parent-inference/ParentReview.tsx | 21 +- .../src/features/patrol/FleetOptimization.tsx | 4 +- frontend/src/features/patrol/PatrolRoute.tsx | 2 +- .../risk-assessment/EnforcementPlan.tsx | 6 +- .../features/statistics/ExternalService.tsx | 2 +- .../features/statistics/ReportManagement.tsx | 8 +- .../src/features/statistics/Statistics.tsx | 37 ++-- .../src/features/surveillance/LiveMapView.tsx | 12 +- .../src/features/surveillance/MapControl.tsx | 53 +++--- .../src/features/vessel/TransferDetection.tsx | 2 +- frontend/src/features/vessel/VesselDetail.tsx | 36 ++-- 38 files changed, 572 insertions(+), 641 deletions(-) diff --git a/frontend/src/app/layout/MainLayout.tsx b/frontend/src/app/layout/MainLayout.tsx index 65eff20..e6ba47e 100644 --- a/frontend/src/app/layout/MainLayout.tsx +++ b/frontend/src/app/layout/MainLayout.tsx @@ -6,12 +6,12 @@ import { FileText, Settings, LogOut, ChevronLeft, ChevronRight, Shield, Bell, Search, Fingerprint, Clock, Lock, Database, Megaphone, Layers, Download, FileSpreadsheet, Printer, Wifi, Brain, Activity, - ChevronsLeft, ChevronsRight, Navigation, Users, EyeOff, BarChart3, Globe, Smartphone, Monitor, Send, Cpu, MessageSquare, GitBranch, CheckSquare, Ban, Tag, ScrollText, History, KeyRound, } from 'lucide-react'; import { useAuth, type UserRole } from '@/app/auth/AuthContext'; +import { getRoleColorHex } from '@shared/constants/userRoles'; import { NotificationBanner, NotificationPopup, type SystemNotice } from '@shared/components/common/NotificationBanner'; import { useSettingsStore } from '@stores/settingsStore'; @@ -27,13 +27,6 @@ import { useSettingsStore } from '@stores/settingsStore'; * - 모든 페이지 하단: 페이지네이션 */ -const ROLE_COLORS: Record = { - ADMIN: 'text-red-400', - OPERATOR: 'text-blue-400', - ANALYST: 'text-purple-400', - FIELD: 'text-green-400', - VIEWER: 'text-yellow-400', -}; const AUTH_METHOD_LABELS: Record = { password: 'ID/PW', @@ -51,7 +44,7 @@ const NAV_ENTRIES: NavEntry[] = [ // ── 상황판·감시 ── { to: '/dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' }, { to: '/monitoring', icon: Activity, labelKey: 'nav.monitoring' }, - { to: '/events', icon: Radar, labelKey: 'nav.eventList' }, + { to: '/events', icon: Radar, labelKey: 'nav.realtimeEvent' }, { to: '/map-control', icon: Map, labelKey: 'nav.riskMap' }, // ── 위험도·단속 ── { to: '/risk-map', icon: Layers, labelKey: 'nav.riskMap' }, @@ -114,40 +107,6 @@ function formatRemaining(seconds: number) { return `${m}:${String(s).padStart(2, '0')}`; } -// ─── 공통 페이지네이션 (간소형) ───────────── -function PagePagination({ page, totalPages, onPageChange }: { - page: number; totalPages: number; onPageChange: (p: number) => void; -}) { - if (totalPages <= 1) return null; - const range: number[] = []; - const maxVis = 5; - let s = Math.max(0, page - Math.floor(maxVis / 2)); - const e = Math.min(totalPages - 1, s + maxVis - 1); - if (e - s < maxVis - 1) s = Math.max(0, e - maxVis + 1); - for (let i = s; i <= e; i++) range.push(i); - - const btnCls = "p-1 rounded text-hint hover:text-heading hover:bg-surface-overlay disabled:opacity-30 disabled:cursor-not-allowed transition-colors"; - - return ( -
- - - {range.map((p) => ( - - ))} - - - {page + 1} / {totalPages} -
- ); -} - export function MainLayout() { const { t } = useTranslation('common'); const { theme, toggleTheme, language, toggleLanguage } = useSettingsStore(); @@ -166,33 +125,6 @@ export function MainLayout() { // 공통 검색 const [pageSearch, setPageSearch] = useState(''); - // 공통 스크롤 페이징 (페이지 단위 스크롤) - const [scrollPage, setScrollPage] = useState(0); - const scrollPageSize = 800; // px per page - - const handleScrollPageChange = (p: number) => { - setScrollPage(p); - if (contentRef.current) { - contentRef.current.scrollTo({ top: p * scrollPageSize, behavior: 'smooth' }); - } - }; - - // 스크롤 이벤트로 현재 페이지 추적 - const handleScroll = () => { - if (contentRef.current) { - const { scrollTop, scrollHeight, clientHeight } = contentRef.current; - const totalScrollPages = Math.max(1, Math.ceil((scrollHeight - clientHeight) / scrollPageSize) + 1); - const currentPage = Math.min(Math.floor(scrollTop / scrollPageSize), totalScrollPages - 1); - setScrollPage(currentPage); - } - }; - - const getTotalScrollPages = () => { - if (!contentRef.current) return 1; - const { scrollHeight, clientHeight } = contentRef.current; - return Math.max(1, Math.ceil((scrollHeight - clientHeight) / scrollPageSize) + 1); - }; - // 인쇄 const handlePrint = () => { const el = contentRef.current; @@ -257,7 +189,7 @@ export function MainLayout() { }); // RBAC - const roleColor = user ? ROLE_COLORS[user.role] : null; + const roleColor = user ? getRoleColorHex(user.role) : null; const isSessionWarning = sessionRemaining <= 5 * 60; // SFR-02: 공통알림 데이터 @@ -310,7 +242,7 @@ export function MainLayout() {
- {t(`role.${user.role}`)} + {t(`role.${user.role}`)}
{t('layout.auth')} {AUTH_METHOD_LABELS[user.authMethod] || user.authMethod} @@ -485,7 +417,7 @@ export function MainLayout() {
{user.org}
{roleColor && ( - + {user.role} )} @@ -522,7 +454,7 @@ export function MainLayout() { (window as unknown as { find: (s: string) => boolean }).find?.(pageSearch); } }} - className="flex items-center gap-1 px-2.5 py-1 rounded-r-md text-[9px] bg-blue-600 hover:bg-blue-500 text-heading font-medium border border-blue-600 transition-colors" + className="flex items-center gap-1 px-2.5 py-1 rounded-r-md text-[9px] bg-blue-400 hover:bg-blue-300 text-on-bright font-medium border border-blue-600 transition-colors" > {t('action.search')} @@ -559,19 +491,9 @@ export function MainLayout() {
- - {/* SFR-02: 공통 페이지네이션 (하단) */} -
- -
{/* SFR-02: 공통알림 팝업 */} diff --git a/frontend/src/features/admin/AccessControl.tsx b/frontend/src/features/admin/AccessControl.tsx index bfa4716..2b6d5f9 100644 --- a/frontend/src/features/admin/AccessControl.tsx +++ b/frontend/src/features/admin/AccessControl.tsx @@ -18,6 +18,9 @@ import { type AuditStats, } from '@/services/adminApi'; import { formatDateTime } from '@shared/utils/dateFormat'; +import { getRoleBadgeStyle } from '@shared/constants/userRoles'; +import { getUserAccountStatusIntent, getUserAccountStatusLabel } from '@shared/constants/userAccountStatuses'; +import { useSettingsStore } from '@stores/settingsStore'; import { PermissionsPanel } from './PermissionsPanel'; import { UserRoleAssignDialog } from './UserRoleAssignDialog'; @@ -31,32 +34,12 @@ import { UserRoleAssignDialog } from './UserRoleAssignDialog'; * 4) 보안 정책 - 정적 정보 */ -const ROLE_COLORS: Record = { - ADMIN: 'bg-red-500/20 text-red-400', - OPERATOR: 'bg-blue-500/20 text-blue-400', - ANALYST: 'bg-purple-500/20 text-purple-400', - FIELD: 'bg-green-500/20 text-green-400', - VIEWER: 'bg-yellow-500/20 text-yellow-400', -}; - -const STATUS_COLORS: Record = { - ACTIVE: 'bg-green-500/20 text-green-400', - LOCKED: 'bg-red-500/20 text-red-400', - INACTIVE: 'bg-gray-500/20 text-gray-400', - PENDING: 'bg-yellow-500/20 text-yellow-400', -}; - -const STATUS_LABELS: Record = { - ACTIVE: '활성', - LOCKED: '잠금', - INACTIVE: '비활성', - PENDING: '승인대기', -}; - type Tab = 'roles' | 'users' | 'audit' | 'policy'; export function AccessControl() { const { t } = useTranslation('admin'); + const { t: tc } = useTranslation('common'); + const lang = useSettingsStore((s) => s.language); const [tab, setTab] = useState('roles'); // 공통 상태 @@ -135,7 +118,7 @@ export function AccessControl() { return (
{list.map((r) => ( - {r} + {r} ))}
); @@ -144,7 +127,7 @@ export function AccessControl() { { key: 'userSttsCd', label: '상태', width: '70px', sortable: true, render: (v) => { const s = v as string; - return {STATUS_LABELS[s] || s}; + return {getUserAccountStatusLabel(s, tc, lang)}; }, }, { key: 'failCnt', label: '실패', width: '50px', align: 'center', @@ -241,7 +224,7 @@ export function AccessControl() { type="button" onClick={() => setTab(tt.key)} className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs transition-colors ${ - tab === tt.key ? 'bg-blue-600 text-heading' : 'text-muted-foreground hover:bg-secondary hover:text-foreground' + tab === tt.key ? 'bg-blue-600 text-on-vivid' : 'text-muted-foreground hover:bg-secondary hover:text-foreground' }`} > diff --git a/frontend/src/features/admin/AccessLogs.tsx b/frontend/src/features/admin/AccessLogs.tsx index e660abe..329417e 100644 --- a/frontend/src/features/admin/AccessLogs.tsx +++ b/frontend/src/features/admin/AccessLogs.tsx @@ -4,6 +4,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/ import { Badge } from '@shared/components/ui/badge'; import { fetchAccessLogs, fetchAccessStats, type AccessLog, type AccessStats } from '@/services/adminApi'; import { formatDateTime } from '@shared/utils/dateFormat'; +import { getHttpStatusIntent } from '@shared/constants/httpStatusCodes'; /** * 접근 이력 조회 + 메트릭 카드. @@ -30,11 +31,6 @@ export function AccessLogs() { useEffect(() => { load(); }, [load]); - const statusColor = (s: number) => - s >= 500 ? 'bg-red-500/20 text-red-400' - : s >= 400 ? 'bg-orange-500/20 text-orange-400' - : 'bg-green-500/20 text-green-400'; - return (
@@ -113,7 +109,7 @@ export function AccessLogs() { {it.httpMethod} {it.requestPath} - {it.statusCode} + {it.statusCode} {it.durationMs} {it.ipAddress || '-'} diff --git a/frontend/src/features/admin/DataHub.tsx b/frontend/src/features/admin/DataHub.tsx index de4376f..713376e 100644 --- a/frontend/src/features/admin/DataHub.tsx +++ b/frontend/src/features/admin/DataHub.tsx @@ -4,6 +4,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/ import { Badge } from '@shared/components/ui/badge'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { SaveButton } from '@shared/components/common/SaveButton'; +import { getConnectionStatusHex } from '@shared/constants/connectionStatuses'; import { Database, RefreshCw, Calendar, Wifi, WifiOff, Radio, Activity, Server, ArrowDownToLine, Clock, AlertTriangle, @@ -43,11 +44,7 @@ const SIGNAL_SOURCES: SignalSource[] = [ { name: 'S&P AIS', rate: 85.4, timeline: generateTimeline() }, ]; -const SIGNAL_COLORS: Record = { - ok: '#22c55e', - warn: '#eab308', - error: '#ef4444', -}; +// SIGNAL_COLORS는 connectionStatuses 카탈로그에서 가져옴 (getConnectionStatusHex) const HOURS = Array.from({ length: 25 }, (_, i) => `${String(i).padStart(2, '0')}시`); @@ -111,7 +108,7 @@ const channelColumns: DataColumn[] = [ { key: 'linkInfo', label: '연계정보', width: '65px' }, { key: 'storage', label: '저장장소', render: (v) => {v as string} }, { key: 'linkMethod', label: '연계방식', width: '70px', align: 'center', - render: (v) => {v as string}, + render: (v) => {v as string}, }, { key: 'cycle', label: '수집주기', width: '80px', align: 'center', render: (v) => { @@ -129,7 +126,7 @@ const channelColumns: DataColumn[] = [ const on = v === 'ON'; return (
- + {v as string} {row.lastUpdate && ( @@ -163,7 +160,7 @@ function SignalTimeline({ source }: { source: SignalSource }) {
))} @@ -274,7 +271,7 @@ const LOAD_JOBS: LoadJob[] = [ const loadColumns: DataColumn[] = [ { key: 'id', label: 'ID', width: '80px', render: (v) => {v as string} }, { key: 'name', label: '작업명', sortable: true, render: (v) => {v as string} }, - { key: 'sourceJob', label: '수집원', width: '80px', render: (v) => {v as string} }, + { key: 'sourceJob', label: '수집원', width: '80px', render: (v) => {v as string} }, { key: 'targetTable', label: '대상 테이블', width: '140px', render: (v) => {v as string} }, { key: 'targetDb', label: 'DB', width: '70px', align: 'center' }, { key: 'status', label: '상태', width: '80px', align: 'center', sortable: true, @@ -439,7 +436,7 @@ export function DataHub() { key={t.key} onClick={() => setTab(t.key)} className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs transition-colors ${ - tab === t.key ? 'bg-cyan-600 text-heading' : 'text-muted-foreground hover:bg-secondary hover:text-foreground' + tab === t.key ? 'bg-cyan-600 text-on-vivid' : 'text-muted-foreground hover:bg-secondary hover:text-foreground' }`} > @@ -541,7 +538,7 @@ export function DataHub() { onClick={() => setStatusFilter(f)} className={`px-2.5 py-1 rounded text-[10px] transition-colors ${ statusFilter === f - ? 'bg-cyan-600 text-heading font-bold' + ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay hover:text-label' }`} > @@ -570,16 +567,16 @@ export function DataHub() { 서버 타입: {(['', 'SQL', 'FILE', 'FTP'] as const).map((f) => ( ))} 상태: {(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => ( ))} -
@@ -595,14 +592,14 @@ export function DataHub() { 상태: {(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => ( ))}
-
@@ -619,13 +616,13 @@ export function DataHub() { 종류: {(['', '수집', '적재'] as const).map((f) => ( ))} 상태: {(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => ( ))}
{showCreate && ( -
+
setNewRoleCd(e.target.value.toUpperCase())} placeholder="ROLE_CD (대문자)" className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" /> setNewRoleNm(e.target.value)} placeholder="역할 이름" className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" /> -
+ +
+
+ {r.builtinYn === 'Y' && BUILT-IN} + {canUpdatePerm && ( + + )} +
-
{r.roleNm}
-
권한 {r.permissions.length}건
- + + {isEditingColor && ( +
+ handleUpdateColor(r.roleSn, hex)} + /> +
+ )} +
); })}
diff --git a/frontend/src/features/admin/SystemConfig.tsx b/frontend/src/features/admin/SystemConfig.tsx index 3104293..9b55e59 100644 --- a/frontend/src/features/admin/SystemConfig.tsx +++ b/frontend/src/features/admin/SystemConfig.tsx @@ -203,7 +203,7 @@ export function SystemConfig() { key={t.key} onClick={() => changeTab(t.key)} className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs transition-colors ${ - tab === t.key ? 'bg-cyan-600 text-heading' : 'text-muted-foreground hover:bg-secondary hover:text-foreground' + tab === t.key ? 'bg-cyan-600 text-on-vivid' : 'text-muted-foreground hover:bg-secondary hover:text-foreground' }`} > @@ -321,7 +321,7 @@ export function SystemConfig() { > {s.code} - {s.major} + {s.major} {s.mid} {s.name} diff --git a/frontend/src/features/admin/UserRoleAssignDialog.tsx b/frontend/src/features/admin/UserRoleAssignDialog.tsx index 4414d3d..88c7ca4 100644 --- a/frontend/src/features/admin/UserRoleAssignDialog.tsx +++ b/frontend/src/features/admin/UserRoleAssignDialog.tsx @@ -2,14 +2,7 @@ import { useEffect, useState } from 'react'; import { X, Check, Loader2 } from 'lucide-react'; import { Badge } from '@shared/components/ui/badge'; import { fetchRoles, assignUserRoles, type RoleWithPermissions, type AdminUser } from '@/services/adminApi'; - -const ROLE_COLORS: Record = { - ADMIN: 'bg-red-500/20 text-red-400', - OPERATOR: 'bg-blue-500/20 text-blue-400', - ANALYST: 'bg-purple-500/20 text-purple-400', - FIELD: 'bg-green-500/20 text-green-400', - VIEWER: 'bg-yellow-500/20 text-yellow-400', -}; +import { getRoleBadgeStyle } from '@shared/constants/userRoles'; interface Props { user: AdminUser; @@ -91,7 +84,7 @@ export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) { }`}> {isSelected && }
- + {r.roleCd}
diff --git a/frontend/src/features/ai-operations/AIAssistant.tsx b/frontend/src/features/ai-operations/AIAssistant.tsx index f1d5333..a25d190 100644 --- a/frontend/src/features/ai-operations/AIAssistant.tsx +++ b/frontend/src/features/ai-operations/AIAssistant.tsx @@ -144,7 +144,7 @@ export function AIAssistant() { placeholder="질의를 입력하세요... (법령, 단속 절차, AI 분석 결과 등)" className="flex-1 bg-surface-overlay border border-slate-700/50 rounded-xl px-4 py-2.5 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-green-500/50" /> -
diff --git a/frontend/src/features/ai-operations/AIModelManagement.tsx b/frontend/src/features/ai-operations/AIModelManagement.tsx index 56898d9..ed45269 100644 --- a/frontend/src/features/ai-operations/AIModelManagement.tsx +++ b/frontend/src/features/ai-operations/AIModelManagement.tsx @@ -11,6 +11,8 @@ import { FileText, ChevronRight, Info, Cpu, Database, Globe, Code, Copy, ExternalLink, } from 'lucide-react'; import { AreaChart as EcAreaChart, BarChart as EcBarChart, PieChart as EcPieChart } from '@lib/charts'; +import { getEngineSeverityIntent, getEngineSeverityLabel } from '@shared/constants/engineSeverities'; +import { useSettingsStore } from '@stores/settingsStore'; /* * SFR-04: AI 불법조업 예측 모델 관리 @@ -237,6 +239,8 @@ const ALARM_SEVERITY = [ export function AIModelManagement() { const { t } = useTranslation('ai'); + const { t: tcCommon } = useTranslation('common'); + const lang = useSettingsStore((s) => s.language); const [tab, setTab] = useState('registry'); const [rules, setRules] = useState(defaultRules); @@ -301,7 +305,7 @@ export function AIModelManagement() { { key: 'api' as Tab, icon: Globe, label: '예측 결과 API' }, ].map((t) => ( ))} @@ -319,7 +323,7 @@ export function AIModelManagement() {
정확도 93.2% (+3.1%) · 오탐률 7.8% (-2.1%) · 다크베셀 탐지 강화
- +
@@ -340,7 +344,7 @@ export function AIModelManagement() {
{rule.name} - {rule.model} + {rule.model}
{rule.desc}
@@ -628,7 +632,6 @@ export function AIModelManagement() { {/* 7대 엔진 카드 */}
{DETECTION_ENGINES.map((eng) => { - const sevColor = eng.severity.includes('CRITICAL') ? 'text-red-400 bg-red-500/15' : eng.severity.includes('HIGH') ? 'text-orange-400 bg-orange-500/15' : eng.severity === 'MEDIUM~CRITICAL' ? 'text-yellow-400 bg-yellow-500/15' : 'text-hint bg-muted'; const stColor = eng.status === '운영중' ? 'bg-green-500/20 text-green-400' : eng.status === '테스트' ? 'bg-blue-500/20 text-blue-400' : 'bg-muted text-muted-foreground'; return ( @@ -654,7 +657,9 @@ export function AIModelManagement() {
심각도
- {eng.severity} + + {getEngineSeverityLabel(eng.severity, tcCommon, lang)} +
쿨다운
@@ -948,7 +953,7 @@ export function AIModelManagement() { ].map((s) => (
- {s.sfr} + {s.sfr} {s.name}
{s.desc}
diff --git a/frontend/src/features/ai-operations/MLOpsPage.tsx b/frontend/src/features/ai-operations/MLOpsPage.tsx index 5978dde..4091cba 100644 --- a/frontend/src/features/ai-operations/MLOpsPage.tsx +++ b/frontend/src/features/ai-operations/MLOpsPage.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { getModelStatusIntent, getQualityGateIntent, getExperimentIntent, MODEL_STATUSES, QUALITY_GATE_STATUSES, EXPERIMENT_STATUSES } from '@shared/constants/modelDeploymentStatuses'; import { Cpu, Brain, Database, GitBranch, Activity, RefreshCw, Server, Shield, FileText, Settings, Layers, Globe, Lock, BarChart3, Code, Play, Square, @@ -109,10 +110,6 @@ export function MLOpsPage() { const [selectedTmpl, setSelectedTmpl] = useState(0); const [selectedLLM, setSelectedLLM] = useState(0); - const stColor = (s: string) => s === 'DEPLOYED' ? 'bg-green-500/20 text-green-400 border-green-500' : s === 'APPROVED' ? 'bg-blue-500/20 text-blue-400 border-blue-500' : s === 'TESTING' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500' : 'bg-muted text-muted-foreground border-slate-600'; - const gateColor = (s: string) => s === 'pass' ? 'bg-green-500/20 text-green-400' : s === 'fail' ? 'bg-red-500/20 text-red-400' : s === 'run' ? 'bg-yellow-500/20 text-yellow-400 animate-pulse' : 'bg-switch-background/50 text-hint'; - const expColor = (s: string) => s === 'running' ? 'bg-blue-500/20 text-blue-400 animate-pulse' : s === 'done' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'; - return (
@@ -161,7 +158,7 @@ export function MLOpsPage() {
배포 모델 현황
{MODELS.filter(m => m.status === 'DEPLOYED').map(m => (
- DEPLOYED + DEPLOYED {m.name} {m.ver} F1 {m.f1}% @@ -172,7 +169,7 @@ export function MLOpsPage() {
진행 중 실험
{EXPERIMENTS.filter(e => e.status === 'running').map(e => (
- 실행중 + 실행중 {e.name}
{e.progress}% @@ -202,14 +199,14 @@ export function MLOpsPage() {
실험 목록
- +
{EXPERIMENTS.map(e => (
{e.id} {e.name} - {e.status} + {e.status}
{e.epoch} {e.time} @@ -228,7 +225,7 @@ export function MLOpsPage() {
{m.name}
{m.ver}
- {m.status} + {MODEL_STATUSES[m.status as keyof typeof MODEL_STATUSES]?.fallback.ko ?? m.status}
{/* 성능 지표 */} {m.accuracy > 0 && ( @@ -245,7 +242,7 @@ export function MLOpsPage() {
Quality Gates
{m.gates.map((g, i) => ( - {g} + {g} ))}
@@ -283,8 +280,8 @@ export function MLOpsPage() {
카나리 / A·B 테스트
위험도 v2.1.0 (80%) ↔ v2.0.3 (20%)
-
v2.1.0 80%
-
v2.0.3 20%
+
v2.1.0 80%
+
v2.0.3 20%
@@ -293,7 +290,7 @@ export function MLOpsPage() { {MODELS.filter(m => m.status === 'APPROVED').map(m => (
{m.name} {m.ver} - +
))}
@@ -318,7 +315,7 @@ export function MLOpsPage() { "version": "v2.1.0" }`} />
- +
@@ -386,7 +383,7 @@ export function MLOpsPage() {
{k}
{v}
))}
- +
@@ -422,7 +419,7 @@ export function MLOpsPage() {
{k}{v}
))}
- +
HPS 시도 결과
Best: Trial #3 (F1=0.912)
@@ -436,7 +433,7 @@ export function MLOpsPage() { {t.dropout} {t.hidden} {t.f1.toFixed(3)} - {t.best && BEST} + {t.best && BEST} ))} @@ -503,14 +500,14 @@ export function MLOpsPage() {

2. **최종 위치**: EEZ/NLL 경계 5NM 이내 여부

3. **과거 이력**: MMSI 변조, 이전 단속 기록 확인

- 배타적경제수역법 §5 - 한중어업협정 §6 + 배타적경제수역법 §5 + 한중어업협정 §6
- +
diff --git a/frontend/src/features/auth/LoginPage.tsx b/frontend/src/features/auth/LoginPage.tsx index a8d7f8b..e9d80a6 100644 --- a/frontend/src/features/auth/LoginPage.tsx +++ b/frontend/src/features/auth/LoginPage.tsx @@ -195,7 +195,7 @@ export function LoginPage() { : {l}; } }, -]; - export function DarkVesselDetection() { const { t } = useTranslation('detection'); + const { t: tc } = useTranslation('common'); + const lang = useSettingsStore((s) => s.language); + + const cols: DataColumn[] = useMemo(() => [ + { key: 'id', label: 'ID', width: '70px', render: v => {v as string} }, + { key: 'pattern', label: '탐지 패턴', width: '120px', sortable: true, + render: v => {getDarkVesselPatternLabel(v as string, tc, lang)} }, + { key: 'name', label: '선박 유형', sortable: true, render: v => {v as string} }, + { key: 'mmsi', label: 'MMSI', width: '100px', render: v => {v as string} }, + { key: 'flag', label: '국적', width: '50px' }, + { key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true, + render: v => { const n = v as number; return 80 ? 'text-red-400' : n > 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}; } }, + { key: 'lastAIS', label: '최종 AIS', width: '90px', render: v => {v as string} }, + { key: 'status', label: '상태', width: '70px', align: 'center', sortable: true, + render: v => {getVesselSurveillanceLabel(v as string, tc, lang)} }, + { key: 'label', label: '라벨', width: '60px', align: 'center', + render: v => { const l = v as string; return l === '-' ? : {l}; } }, + ], [tc, lang]); + const [darkItems, setDarkItems] = useState([]); const [serviceAvailable, setServiceAvailable] = useState(true); const [loading, setLoading] = useState(false); @@ -128,7 +128,7 @@ export function DarkVesselDetection() { lat: d.lat, lng: d.lng, radius: 10000, - color: PATTERN_COLORS[d.pattern] || '#ef4444', + color: getDarkVesselPatternMeta(d.pattern)?.hex || '#ef4444', })), 0.08, ), @@ -137,7 +137,7 @@ export function DarkVesselDetection() { DATA.map(d => ({ lat: d.lat, lng: d.lng, - color: PATTERN_COLORS[d.pattern] || '#ef4444', + color: getDarkVesselPatternMeta(d.pattern)?.hex || '#ef4444', radius: d.risk > 80 ? 1200 : 800, label: `${d.id} ${d.name}`, } as MarkerData)), @@ -193,12 +193,16 @@ export function DarkVesselDetection() {
탐지 패턴
- {Object.entries(PATTERN_COLORS).map(([p, c]) => ( -
-
- {p} -
- ))} + {(['AIS_FULL_BLOCK', 'MMSI_SPOOFING', 'LONG_LOSS', 'INTERMITTENT'] as const).map((p) => { + const meta = getDarkVesselPatternMeta(p); + if (!meta) return null; + return ( +
+
+ {meta.fallback.ko} +
+ ); + })}
EEZ
diff --git a/frontend/src/features/detection/GearDetection.tsx b/frontend/src/features/detection/GearDetection.tsx index 7212364..cd3f734 100644 --- a/frontend/src/features/detection/GearDetection.tsx +++ b/frontend/src/features/detection/GearDetection.tsx @@ -8,14 +8,18 @@ import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLay import type { MarkerData } from '@lib/map'; import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi'; import { formatDate } from '@shared/utils/dateFormat'; +import { getPermitStatusIntent, getPermitStatusLabel, getGearJudgmentIntent } from '@shared/constants/permissionStatuses'; +import { getAlertLevelHex } from '@shared/constants/alertLevels'; +import { useSettingsStore } from '@stores/settingsStore'; /* SFR-10: 불법 어망·어구 탐지 및 관리 */ type Gear = { id: string; type: string; owner: string; zone: string; status: string; permit: string; installed: string; lastSignal: string; risk: string; lat: number; lng: number; [key: string]: unknown; }; -const RISK_COLORS: Record = { - '고위험': '#ef4444', - '중위험': '#eab308', +// 한글 위험도 → AlertLevel hex 매핑 +const RISK_HEX: Record = { + '고위험': getAlertLevelHex('CRITICAL'), + '중위험': getAlertLevelHex('MEDIUM'), '안전': '#22c55e', }; @@ -50,22 +54,25 @@ function mapGroupToGear(g: GearGroupItem, idx: number): Gear { }; } -const cols: DataColumn[] = [ - { key: 'id', label: 'ID', width: '70px', render: v => {v as string} }, - { key: 'type', label: '어구 유형', width: '100px', sortable: true, render: v => {v as string} }, - { key: 'owner', label: '소유 선박', sortable: true, render: v => {v as string} }, - { key: 'zone', label: '설치 해역', width: '90px', sortable: true }, - { key: 'permit', label: '허가 상태', width: '80px', align: 'center', - render: v => { const p = v as string; const c = p === '유효' ? 'bg-green-500/20 text-green-400' : p === '무허가' ? 'bg-red-500/20 text-red-400' : 'bg-yellow-500/20 text-yellow-400'; return {p}; } }, - { key: 'status', label: '판정', width: '80px', align: 'center', sortable: true, - render: v => { const s = v as string; const c = s.includes('불법') ? 'bg-red-500/20 text-red-400' : s === '정상' ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'; return {s}; } }, - { key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true, - render: v => { const r = v as string; const c = r === '고위험' ? 'text-red-400' : r === '중위험' ? 'text-yellow-400' : 'text-green-400'; return {r}; } }, - { key: 'lastSignal', label: '최종 신호', width: '80px', render: v => {v as string} }, -]; - export function GearDetection() { const { t } = useTranslation('detection'); + const { t: tc } = useTranslation('common'); + const lang = useSettingsStore((s) => s.language); + + const cols: DataColumn[] = useMemo(() => [ + { key: 'id', label: 'ID', width: '70px', render: v => {v as string} }, + { key: 'type', label: '어구 유형', width: '100px', sortable: true, render: v => {v as string} }, + { key: 'owner', label: '소유 선박', sortable: true, render: v => {v as string} }, + { key: 'zone', label: '설치 해역', width: '90px', sortable: true }, + { key: 'permit', label: '허가 상태', width: '80px', align: 'center', + render: v => {getPermitStatusLabel(v as string, tc, lang)} }, + { key: 'status', label: '판정', width: '80px', align: 'center', sortable: true, + render: v => {v as string} }, + { key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true, + render: v => { const r = v as string; const c = r === '고위험' ? 'text-red-400' : r === '중위험' ? 'text-yellow-400' : 'text-green-400'; return {r}; } }, + { key: 'lastSignal', label: '최종 신호', width: '80px', render: v => {v as string} }, + ], [tc, lang]); + const [groups, setGroups] = useState([]); const [serviceAvailable, setServiceAvailable] = useState(true); const [loading, setLoading] = useState(false); @@ -105,7 +112,7 @@ export function GearDetection() { lat: g.lat, lng: g.lng, radius: 6000, - color: RISK_COLORS[g.risk] || '#64748b', + color: RISK_HEX[g.risk] || "#64748b", })), 0.1, ), @@ -114,7 +121,7 @@ export function GearDetection() { DATA.map(g => ({ lat: g.lat, lng: g.lng, - color: RISK_COLORS[g.risk] || '#64748b', + color: RISK_HEX[g.risk] || "#64748b", radius: g.risk === '고위험' ? 1200 : 800, label: `${g.id} ${g.type}`, } as MarkerData)), diff --git a/frontend/src/features/detection/GearIdentification.tsx b/frontend/src/features/detection/GearIdentification.tsx index 5de1084..828b6b3 100644 --- a/frontend/src/features/detection/GearIdentification.tsx +++ b/frontend/src/features/detection/GearIdentification.tsx @@ -781,7 +781,7 @@ export function GearIdentification() {
+ + className="flex items-center gap-1 px-3 py-1.5 bg-green-600 hover:bg-green-500 disabled:opacity-30 text-on-vivid text-[10px] font-bold rounded-lg">최종 승인
diff --git a/frontend/src/features/patrol/PatrolRoute.tsx b/frontend/src/features/patrol/PatrolRoute.tsx index 500af52..f5f27cc 100644 --- a/frontend/src/features/patrol/PatrolRoute.tsx +++ b/frontend/src/features/patrol/PatrolRoute.tsx @@ -106,7 +106,7 @@ export function PatrolRoute() {

{t('patrolRoute.desc')}

- +
diff --git a/frontend/src/features/risk-assessment/EnforcementPlan.tsx b/frontend/src/features/risk-assessment/EnforcementPlan.tsx index d2ed00e..5c37d28 100644 --- a/frontend/src/features/risk-assessment/EnforcementPlan.tsx +++ b/frontend/src/features/risk-assessment/EnforcementPlan.tsx @@ -39,7 +39,7 @@ const cols: DataColumn[] = [ { key: 'status', label: '상태', width: '70px', align: 'center', sortable: true, render: v => { const s = v as string; return {s}; } }, { key: 'alert', label: '경보', width: '80px', align: 'center', - render: v => { const a = v as string; return a === '경보 발령' || a === 'ALERT' ? {a} : {a}; } }, + render: v => { const a = v as string; return a === '경보 발령' || a === 'ALERT' ? {a} : {a}; } }, ]; export function EnforcementPlan() { @@ -124,7 +124,7 @@ export function EnforcementPlan() {

{t('enforcementPlan.title')}

{t('enforcementPlan.desc')}

- +
{/* 로딩/에러 상태 */} @@ -153,7 +153,7 @@ export function EnforcementPlan() {
{[['위험도 ≥ 80', '상황실 즉시 경보 (알림+SMS)'], ['위험도 ≥ 60', '관련 부서 주의 알림'], ['위험도 ≥ 40', '참고 로그 기록']].map(([k, v]) => (
- {k} + {k} {v}
))} diff --git a/frontend/src/features/statistics/ExternalService.tsx b/frontend/src/features/statistics/ExternalService.tsx index bdc24a0..546b416 100644 --- a/frontend/src/features/statistics/ExternalService.tsx +++ b/frontend/src/features/statistics/ExternalService.tsx @@ -18,7 +18,7 @@ const cols: DataColumn[] = [ { key: 'id', label: 'ID', width: '70px', render: v => {v as string} }, { key: 'name', label: '서비스명', sortable: true, render: v => {v as string} }, { key: 'target', label: '제공 대상', width: '80px', sortable: true }, - { key: 'type', label: '방식', width: '50px', align: 'center', render: v => {v as string} }, + { key: 'type', label: '방식', width: '50px', align: 'center', render: v => {v as string} }, { key: 'format', label: '포맷', width: '60px', align: 'center' }, { key: 'cycle', label: '갱신주기', width: '70px' }, { key: 'privacy', label: '정보등급', width: '70px', align: 'center', diff --git a/frontend/src/features/statistics/ReportManagement.tsx b/frontend/src/features/statistics/ReportManagement.tsx index b6b1277..f9fee79 100644 --- a/frontend/src/features/statistics/ReportManagement.tsx +++ b/frontend/src/features/statistics/ReportManagement.tsx @@ -37,7 +37,7 @@ export function ReportManagement() { ); return ( -
+

@@ -66,7 +66,7 @@ export function ReportManagement() { > 증거 업로드 -

@@ -116,7 +116,7 @@ export function ReportManagement() {
증거 {r.evidence}건
- +
@@ -131,7 +131,7 @@ export function ReportManagement() {
보고서 미리보기
-
diff --git a/frontend/src/features/statistics/Statistics.tsx b/frontend/src/features/statistics/Statistics.tsx index 231357a..099d29b 100644 --- a/frontend/src/features/statistics/Statistics.tsx +++ b/frontend/src/features/statistics/Statistics.tsx @@ -15,6 +15,8 @@ import { } from '@/services/kpi'; import type { MonthlyTrend, ViolationType } from '@data/mock/kpi'; import { toDateParam } from '@shared/utils/dateFormat'; +import { getViolationColor, getViolationLabel } from '@shared/constants/violationTypes'; +import { useSettingsStore } from '@stores/settingsStore'; /* SFR-13: 통계·지표·성과 분석 */ @@ -60,7 +62,7 @@ const kpiCols: DataColumn[] = [ width: '60px', align: 'center', render: (v) => ( - + {v as string} ), @@ -69,6 +71,8 @@ const kpiCols: DataColumn[] = [ export function Statistics() { const { t } = useTranslation('statistics'); + const { t: tc } = useTranslation('common'); + const lang = useSettingsStore((s) => s.language); const [monthly, setMonthly] = useState([]); const [violationTypes, setViolationTypes] = useState([]); @@ -205,20 +209,25 @@ export function Statistics() { 위반 유형별 분포
- {BY_TYPE.map((item) => ( -
-
- {item.count} + {BY_TYPE.map((item) => { + const color = getViolationColor(item.type); + const label = getViolationLabel(item.type, tc, lang); + return ( +
+
+ {item.count} +
+
+ {label} +
+
{item.pct}%
-
- {item.type} -
-
{item.pct}%
-
- ))} + ); + })}
diff --git a/frontend/src/features/surveillance/LiveMapView.tsx b/frontend/src/features/surveillance/LiveMapView.tsx index 69103aa..be54d76 100644 --- a/frontend/src/features/surveillance/LiveMapView.tsx +++ b/frontend/src/features/surveillance/LiveMapView.tsx @@ -14,13 +14,7 @@ import { type PredictionEvent, } from '@/services/event'; -// ─── 위험도 레벨 → 마커 색상 ───────────────── -const RISK_MARKER_COLOR: Record = { - CRITICAL: '#ef4444', - HIGH: '#f97316', - MEDIUM: '#3b82f6', - LOW: '#6b7280', -}; +import { getAlertLevelHex } from '@shared/constants/alertLevels'; interface MapEvent { id: string; @@ -171,7 +165,7 @@ export function LiveMapView() { 'ais-vessels', vesselMarkers.map((v): MarkerData => { const level = v.item.algorithms.riskScore.level; - const color = RISK_MARKER_COLOR[level] ?? '#6b7280'; + const color = getAlertLevelHex(level); return { lat: v.lat, lng: v.lng, @@ -367,7 +361,7 @@ export function LiveMapView() {
AI 판단 근거 - 신뢰도: High + 신뢰도: High
diff --git a/frontend/src/features/surveillance/MapControl.tsx b/frontend/src/features/surveillance/MapControl.tsx index 1b4a46d..7f1bde1 100644 --- a/frontend/src/features/surveillance/MapControl.tsx +++ b/frontend/src/features/surveillance/MapControl.tsx @@ -4,6 +4,7 @@ import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { Map, Shield, Crosshair, AlertTriangle, Eye, Anchor, Ship, Filter, Layers, Target, Clock, MapPin, Bell, Navigation, Info } from 'lucide-react'; +import { getTrainingZoneIntent, getTrainingZoneHex, getTrainingZoneMeta } from '@shared/constants/trainingZoneTypes'; /* * 해역 통제 — 한국연안 해상사격 훈련구역도 (No.462) 반영 @@ -140,20 +141,12 @@ const ntmColumns: DataColumn[] = [ render: v => {v as string} }, ]; -// ─── 범례 색상 ────────────────────────── - -const TYPE_COLORS: Record = { - '해군': { bg: 'bg-yellow-500/20', text: 'text-yellow-400', label: '해군 훈련 구역', mapColor: '#eab308' }, - '공군': { bg: 'bg-pink-500/20', text: 'text-pink-400', label: '공군 훈련 구역', mapColor: '#ec4899' }, - '육군': { bg: 'bg-green-500/20', text: 'text-green-400', label: '육군 훈련 구역', mapColor: '#22c55e' }, - '국과연': { bg: 'bg-blue-500/20', text: 'text-blue-400', label: '국방과학연구소', mapColor: '#3b82f6' }, - '해경': { bg: 'bg-purple-500/20', text: 'text-purple-400', label: '해양경찰청 훈련구역', mapColor: '#a855f7' }, -}; +// 훈련구역 색상은 trainingZoneTypes 카탈로그에서 lookup const columns: DataColumn[] = [ { key: 'id', label: '구역번호', width: '80px', sortable: true, render: v => {v as string} }, { key: 'type', label: '구분', width: '60px', align: 'center', sortable: true, - render: v => { const t = TYPE_COLORS[v as string]; return {v as string}; } }, + render: v => {v as string} }, { key: 'sea', label: '해역', width: '60px', sortable: true }, { key: 'lat', label: '위도', width: '110px', render: v => {v as string} }, { key: 'lng', label: '경도', width: '110px', render: v => {v as string} }, @@ -214,7 +207,7 @@ export function MapControl() { const lat = parseDMS(z.lat); const lng = parseDMS(z.lng); if (lat === null || lng === null) return; - const color = TYPE_COLORS[z.type]?.mapColor || '#6b7280'; + const color = getTrainingZoneHex(z.type); const radiusM = parseRadius(z.radius); const isActive = z.status === '활성'; parsedZones.push({ lat, lng, color, radiusM, isActive, zone: z }); @@ -285,12 +278,16 @@ export function MapControl() { {/* 범례 */}
범례: - {Object.entries(TYPE_COLORS).map(([type, c]) => ( -
-
- {c.label} -
- ))} + {(['해군', '공군', '육군', '국과연', '해경'] as const).map((type) => { + const meta = getTrainingZoneMeta(type); + if (!meta) return null; + return ( +
+
+ {meta.fallback.ko} +
+ ); + })}
{/* 탭 + 해역 필터 */} @@ -315,7 +312,7 @@ export function MapControl() { {['', '서해', '남해', '동해', '제주'].map(s => ( ))} @@ -346,7 +343,7 @@ export function MapControl() { 구분: {NTM_CATEGORIES.map(c => ( + className={`px-2.5 py-1 rounded text-[10px] ${(c === '전체' && !ntmCatFilter) || ntmCatFilter === c ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>{c} ))}
@@ -358,7 +355,7 @@ export function MapControl() {
{NTM_DATA.filter(n => n.status === '발령중').map(n => (
- {n.category} + {n.category}
{n.title}
{n.detail}
@@ -397,12 +394,16 @@ export function MapControl() {
훈련구역 범례
- {Object.entries(TYPE_COLORS).map(([type, c]) => ( -
-
- {c.label} -
- ))} + {(['해군', '공군', '육군', '국과연', '해경'] as const).map((type) => { + const meta = getTrainingZoneMeta(type); + if (!meta) return null; + return ( +
+
+ {meta.fallback.ko} +
+ ); + })}
EEZ
diff --git a/frontend/src/features/vessel/TransferDetection.tsx b/frontend/src/features/vessel/TransferDetection.tsx index 3c3da5f..bb962e5 100644 --- a/frontend/src/features/vessel/TransferDetection.tsx +++ b/frontend/src/features/vessel/TransferDetection.tsx @@ -3,7 +3,7 @@ import { RealTransshipSuspects } from '@features/detection/RealVesselAnalysis'; export function TransferDetection() { return ( -
+

환적·접촉 탐지

선박 간 근접 접촉 및 환적 의심 행위 분석

diff --git a/frontend/src/features/vessel/VesselDetail.tsx b/frontend/src/features/vessel/VesselDetail.tsx index 4f73499..ccd3d6c 100644 --- a/frontend/src/features/vessel/VesselDetail.tsx +++ b/frontend/src/features/vessel/VesselDetail.tsx @@ -14,6 +14,9 @@ import { } from '@/services/vesselAnalysisApi'; import { formatDateTime } from '@shared/utils/dateFormat'; import { getEvents, type PredictionEvent } from '@/services/event'; +import { ALERT_LEVELS, type AlertLevel, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels'; +import { useSettingsStore } from '@stores/settingsStore'; +import { useTranslation } from 'react-i18next'; // ─── 허가 정보 타입 ────────────────────── interface VesselPermitData { @@ -47,14 +50,6 @@ async function fetchVesselPermit(mmsi: string): Promise } } -// ─── 위험도 레벨 → 색상 매핑 ────────────── -const RISK_LEVEL_CONFIG: Record = { - CRITICAL: { label: '심각', color: 'text-red-400', bg: 'bg-red-500/15' }, - HIGH: { label: '높음', color: 'text-orange-400', bg: 'bg-orange-500/15' }, - MEDIUM: { label: '보통', color: 'text-yellow-400', bg: 'bg-yellow-500/15' }, - LOW: { label: '낮음', color: 'text-blue-400', bg: 'bg-blue-500/15' }, -}; - const RIGHT_TOOLS = [ { icon: Crosshair, label: '구역설정' }, { icon: Ruler, label: '거리' }, { icon: CircleDot, label: '면적' }, { icon: Clock, label: '거리환' }, @@ -152,10 +147,14 @@ export function VesselDetail() { useMapLayers(mapRef, buildLayers, []); + // i18n + 카탈로그 + const { t: tc } = useTranslation('common'); + const lang = useSettingsStore((s) => s.language); + // 위험도 점수 바 const riskScore = vessel?.algorithms.riskScore.score ?? 0; - const riskLevel = vessel?.algorithms.riskScore.level ?? 'LOW'; - const riskConfig = RISK_LEVEL_CONFIG[riskLevel] ?? RISK_LEVEL_CONFIG.LOW; + const riskLevel = (vessel?.algorithms.riskScore.level ?? 'LOW') as AlertLevel; + const riskMeta = ALERT_LEVELS[riskLevel] ?? ALERT_LEVELS.LOW; return (
@@ -280,12 +279,12 @@ export function VesselDetail() {
위험도 - - {riskConfig.label} + + {getAlertLevelLabel(riskLevel, tc, lang)}
- + {Math.round(riskScore * 100)} /100 @@ -341,15 +340,14 @@ export function VesselDetail() { ) : (
{events.map((evt) => { - const lvl = RISK_LEVEL_CONFIG[evt.level] ?? RISK_LEVEL_CONFIG.LOW; return (
- - {evt.level} + + {getAlertLevelLabel(evt.level, tc, lang)} {evt.title} - + {evt.status}
@@ -378,8 +376,8 @@ export function VesselDetail() { MMSI: {mmsiParam} {vessel && ( - - 위험도: {riskConfig.label} + + 위험도: {getAlertLevelLabel(riskLevel, tc, lang)} )}
From a07b7d9ba5e09e5dd45a30e63023ed3a65d9fa39 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 8 Apr 2026 10:55:09 +0900 Subject: [PATCH 05/22] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EA=B0=B1=EC=8B=A0=20(UI=20=EC=B9=B4?= =?UTF-8?q?=ED=83=88=EB=A1=9C=EA=B7=B8=20=EC=8B=9C=EC=8A=A4=ED=85=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index af3aa96..623fbfa 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -5,6 +5,17 @@ ## [Unreleased] ### 추가 +- **UI 공통 카탈로그 19종** (`frontend/src/shared/constants/`) — 백엔드 enum/code_master 기반 SSOT + - violation/alert/event/enforcement/patrol/engine/userRole/device/parentResolution/ + modelDeployment/gearGroup/darkVessel/httpStatus/userAccount/loginResult/permission/ + vesselAnalysis/connection/trainingZone + kpiUiMap + - 표준 API: `get{Cat}Intent(code)`, `get{Cat}Label(code, t, lang)`, `get{Cat}Classes/Hex` +- **시맨틱 텍스트 토큰** (`theme.css @layer utilities`) — Tailwind v4 복합 이름 매핑 실패 대응 + - `text-heading/label/hint/on-vivid/on-bright`, `bg-surface-raised/overlay` 직접 정의 +- **Badge 시스템 재구축** — CVA 기반 8 intent × 4 size (rem), !important 제거 +- **cn() 유틸** (`lib/utils/cn.ts`) — clsx + tailwind-merge, 시맨틱 토큰 classGroup 등록 +- **ColorPicker 컴포넌트** — 팔레트 grid + native color + hex 입력 +- **Role.colorHex** (백엔드 V017 migration) — auth_role.color_hex VARCHAR(7), 빌트인 5개 역할 기본 색상 시드 - System Flow 뷰어 (`/system-flow.html`) — 시스템 전체 데이터 흐름 시각화 - 102 노드 + 133 엣지, 10개 카테고리 매니페스트 - stage/menu 두 가지 그룹화 토글, 검색/필터/딥링크 지원 @@ -17,7 +28,21 @@ - V015 NUMERIC precision 일괄 확대 (score→7,4, pct→12,2) - V016 parent workflow 누락 컬럼 일괄 추가 (17+ 컬럼, candidate_mmsi generated column) +### 변경 +- **40+ 페이지 Badge/시맨틱 토큰 마이그레이션** + - Badge className 직접 작성 → intent/size prop 변환 + - 컬러풀 액션 버튼 → `text-on-vivid` (흰색), 검색/필터 버튼 → `bg-blue-400 text-on-bright` + - ROLE_COLORS 4곳 중복 제거 → `getRoleBadgeStyle()` 공통 호출 + - PermissionsPanel 역할 생성/수정에 ColorPicker 통합 +- DataTable `width` 의미 변경: 고정 → 선호 최소 너비 (minWidth), 컨텐츠 자동 확장 + truncate +- `dateFormat.ts` sv-SE 로케일로 `YYYY-MM-DD HH:mm:ss` 일관된 KST 출력 +- MonitoringDashboard `PagePagination` 제거 (데이터 페이지네이션 오해 해소) + ### 수정 +- Dashboard RiskBar 단위 버그 (0~100 정수를 `*100` 하던 코드 → 범위 감지) +- ReportManagement, TransferDetection `p-5 space-y-4` padding 복구 +- EnforcementHistory 그리드 minmax 적용으로 컬럼 잘림 해소 +- timeline 시간 `formatDateTime` 적용 (ISO `T` 구분자 처리) - **prediction e2e 5가지 이슈 수정** (2026-04-08) - gear_correlation: psycopg2 Decimal × float TypeError → `_load_all_scores()` float 변환 - violation_classifier: `(mmsi, analyzed_at)` 기준 UPDATE + 중국선박 EEZ 판정 로직 From e0b51efc549707d8104caacb1a4c62212938f9f8 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 8 Apr 2026 11:09:36 +0900 Subject: [PATCH 06/22] =?UTF-8?q?feat(frontend):=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B8=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=87=BC=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=ED=8E=98=EC=9D=B4=EC=A7=80=20+=20?= =?UTF-8?q?=EC=8B=A0=EA=B7=9C=20=EA=B3=B5=ED=86=B5=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 쇼케이스 (/design-system.html): - 별도 Vite entry (System Flow 패턴 재사용, 메인 SPA 분리) - 10개 섹션: Intro / Token / Typography / Badge / Button / Form / Card / Layout / Catalog (19+) / Guide - 추적 ID 체계 (TRK-CATEGORY-SLUG): - hover 시 툴팁 + "ID 복사 모드"에서 클릭 시 클립보드 복사 - URL hash 딥링크 (#trk=TRK-BADGE-critical-sm) 스크롤+하이라이트 - 산출문서/논의에서 특정 변형 정확히 참조 가능 - Dark/Light 테마 토글로 양쪽 시각 검증 신규 공통 컴포넌트: - Button (@shared/components/ui/button.tsx) - 5 variant × 3 size = 15 변형 - primary/secondary/ghost/outline/destructive × sm/md/lg - Input / Select / Textarea / Checkbox / Radio - Input · Select 공통 inputVariants 공유 (sm/md/lg × default/error/success) - PageContainer / PageHeader / Section (shared/components/layout/) - PageContainer: size sm/md/lg + fullBleed (지도/풀화면 예외) - PageHeader: title + description + icon + demo 배지 + actions 슬롯 - Section: Card + CardHeader + CardTitle + CardContent 단축 variants.ts 확장: - buttonVariants / inputVariants / pageContainerVariants CVA 정의 - Button/Input/Select는 variants.ts에서 import하여 fast-refresh 경고 회피 빌드 검증 완료: - TypeScript 타입 체크 통과 - ESLint 통과 (경고 0) - vite build: designSystem-*.js 54KB (메인 SPA와 분리) 이 쇼케이스가 확정된 후 실제 40+ 페이지 마이그레이션 진행 예정. --- frontend/design-system.html | 12 + .../src/design-system/DesignSystemApp.css | 138 +++++++++ .../src/design-system/DesignSystemApp.tsx | 174 +++++++++++ frontend/src/design-system/lib/Trk.tsx | 71 +++++ frontend/src/design-system/lib/TrkContext.tsx | 50 ++++ .../design-system/sections/BadgeSection.tsx | 114 +++++++ .../design-system/sections/ButtonSection.tsx | 131 +++++++++ .../design-system/sections/CardSection.tsx | 111 +++++++ .../design-system/sections/CatalogSection.tsx | 278 ++++++++++++++++++ .../design-system/sections/FormSection.tsx | 124 ++++++++ .../design-system/sections/GuideSection.tsx | 130 ++++++++ .../design-system/sections/IntroSection.tsx | 68 +++++ .../design-system/sections/LayoutSection.tsx | 177 +++++++++++ .../design-system/sections/TokenSection.tsx | 160 ++++++++++ .../sections/TypographySection.tsx | 35 +++ frontend/src/designSystemMain.tsx | 10 + frontend/src/lib/theme/variants.ts | 86 ++++++ .../components/layout/PageContainer.tsx | 34 +++ .../shared/components/layout/PageHeader.tsx | 63 ++++ .../src/shared/components/layout/Section.tsx | 51 ++++ .../src/shared/components/layout/index.ts | 3 + frontend/src/shared/components/ui/button.tsx | 29 ++ .../src/shared/components/ui/checkbox.tsx | 30 ++ frontend/src/shared/components/ui/input.tsx | 23 ++ frontend/src/shared/components/ui/radio.tsx | 29 ++ frontend/src/shared/components/ui/select.tsx | 24 ++ .../src/shared/components/ui/textarea.tsx | 31 ++ frontend/vite.config.ts | 1 + 28 files changed, 2187 insertions(+) create mode 100644 frontend/design-system.html create mode 100644 frontend/src/design-system/DesignSystemApp.css create mode 100644 frontend/src/design-system/DesignSystemApp.tsx create mode 100644 frontend/src/design-system/lib/Trk.tsx create mode 100644 frontend/src/design-system/lib/TrkContext.tsx create mode 100644 frontend/src/design-system/sections/BadgeSection.tsx create mode 100644 frontend/src/design-system/sections/ButtonSection.tsx create mode 100644 frontend/src/design-system/sections/CardSection.tsx create mode 100644 frontend/src/design-system/sections/CatalogSection.tsx create mode 100644 frontend/src/design-system/sections/FormSection.tsx create mode 100644 frontend/src/design-system/sections/GuideSection.tsx create mode 100644 frontend/src/design-system/sections/IntroSection.tsx create mode 100644 frontend/src/design-system/sections/LayoutSection.tsx create mode 100644 frontend/src/design-system/sections/TokenSection.tsx create mode 100644 frontend/src/design-system/sections/TypographySection.tsx create mode 100644 frontend/src/designSystemMain.tsx create mode 100644 frontend/src/shared/components/layout/PageContainer.tsx create mode 100644 frontend/src/shared/components/layout/PageHeader.tsx create mode 100644 frontend/src/shared/components/layout/Section.tsx create mode 100644 frontend/src/shared/components/layout/index.ts create mode 100644 frontend/src/shared/components/ui/button.tsx create mode 100644 frontend/src/shared/components/ui/checkbox.tsx create mode 100644 frontend/src/shared/components/ui/input.tsx create mode 100644 frontend/src/shared/components/ui/radio.tsx create mode 100644 frontend/src/shared/components/ui/select.tsx create mode 100644 frontend/src/shared/components/ui/textarea.tsx diff --git a/frontend/design-system.html b/frontend/design-system.html new file mode 100644 index 0000000..01e35ec --- /dev/null +++ b/frontend/design-system.html @@ -0,0 +1,12 @@ + + + + + + KCG AI Monitoring — Design System + + +
+ + + diff --git a/frontend/src/design-system/DesignSystemApp.css b/frontend/src/design-system/DesignSystemApp.css new file mode 100644 index 0000000..a8a8893 --- /dev/null +++ b/frontend/src/design-system/DesignSystemApp.css @@ -0,0 +1,138 @@ +/* 디자인 쇼케이스 전용 스타일 */ + +.ds-shell { + display: flex; + flex-direction: column; + height: 100vh; + background: var(--background, #0b1220); + color: var(--foreground, #e2e8f0); +} + +.ds-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1.5rem; + border-bottom: 1px solid rgb(51 65 85 / 0.5); + flex-shrink: 0; + background: var(--surface-overlay, rgb(15 23 42 / 0.6)); + backdrop-filter: blur(8px); +} + +.ds-body { + display: flex; + flex: 1; + min-height: 0; +} + +.ds-nav { + width: 220px; + flex-shrink: 0; + padding: 1rem 0.75rem; + border-right: 1px solid rgb(51 65 85 / 0.5); + overflow-y: auto; + position: sticky; + top: 0; +} + +.ds-main { + flex: 1; + overflow-y: auto; + padding: 2rem 2.5rem 6rem; + scroll-behavior: smooth; +} + +.ds-main section { + margin-bottom: 4rem; +} + +/* 추적 ID 시스템 */ +.trk-item { + position: relative; + transition: outline-color 0.2s; +} + +.trk-copyable { + cursor: pointer; +} + +.trk-copyable:hover { + outline: 1px dashed rgb(59 130 246 / 0.5); + outline-offset: 4px; +} + +.trk-active { + outline: 2px solid rgb(59 130 246); + outline-offset: 4px; + animation: trk-pulse 1.2s ease-out; +} + +.trk-item[data-copied='true'] { + outline: 2px solid rgb(34 197 94) !important; + outline-offset: 4px; +} + +.trk-item[data-copied='true']::after { + content: '복사됨 ✓'; + position: absolute; + top: -1.5rem; + left: 0; + font-size: 0.625rem; + color: rgb(34 197 94); + background: rgb(34 197 94 / 0.15); + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + pointer-events: none; + z-index: 10; +} + +@keyframes trk-pulse { + 0% { + outline-color: rgb(59 130 246); + } + 50% { + outline-color: rgb(59 130 246 / 0.3); + } + 100% { + outline-color: rgb(59 130 246); + } +} + +/* 쇼케이스 그리드 */ +.ds-grid { + display: grid; + gap: 0.75rem; +} + +.ds-grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.ds-grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.ds-grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.ds-grid-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); } + +.ds-sample { + padding: 1rem; + background: var(--surface-raised, rgb(30 41 59 / 0.4)); + border: 1px solid rgb(51 65 85 / 0.4); + border-radius: 0.5rem; +} + +.ds-sample-label { + font-size: 0.625rem; + color: var(--text-hint, rgb(148 163 184)); + font-family: ui-monospace, monospace; + margin-top: 0.5rem; + word-break: break-all; +} + +.ds-code { + display: block; + padding: 0.75rem 1rem; + background: rgb(15 23 42 / 0.7); + border: 1px solid rgb(51 65 85 / 0.4); + border-radius: 0.375rem; + font-family: ui-monospace, monospace; + font-size: 0.75rem; + color: rgb(203 213 225); + white-space: pre; + overflow-x: auto; +} diff --git a/frontend/src/design-system/DesignSystemApp.tsx b/frontend/src/design-system/DesignSystemApp.tsx new file mode 100644 index 0000000..edb916a --- /dev/null +++ b/frontend/src/design-system/DesignSystemApp.tsx @@ -0,0 +1,174 @@ +import { useEffect, useState } from 'react'; +import { TrkProvider, useTrk } from './lib/TrkContext'; +import { IntroSection } from './sections/IntroSection'; +import { TokenSection } from './sections/TokenSection'; +import { TypographySection } from './sections/TypographySection'; +import { BadgeSection } from './sections/BadgeSection'; +import { ButtonSection } from './sections/ButtonSection'; +import { FormSection } from './sections/FormSection'; +import { CardSectionShowcase } from './sections/CardSection'; +import { LayoutSection } from './sections/LayoutSection'; +import { CatalogSection } from './sections/CatalogSection'; +import { GuideSection } from './sections/GuideSection'; +import './DesignSystemApp.css'; + +interface NavItem { + id: string; + label: string; + anchor: string; +} + +const NAV_ITEMS: NavItem[] = [ + { id: 'intro', label: '1. 소개', anchor: 'TRK-SEC-intro' }, + { id: 'token', label: '2. 테마 · 토큰', anchor: 'TRK-SEC-token' }, + { id: 'typography', label: '3. 타이포그래피', anchor: 'TRK-SEC-typography' }, + { id: 'badge', label: '4. Badge', anchor: 'TRK-SEC-badge' }, + { id: 'button', label: '5. Button', anchor: 'TRK-SEC-button' }, + { id: 'form', label: '6. Form', anchor: 'TRK-SEC-form' }, + { id: 'card', label: '7. Card / Section', anchor: 'TRK-SEC-card' }, + { id: 'layout', label: '8. Layout', anchor: 'TRK-SEC-layout' }, + { id: 'catalog', label: '9. 분류 카탈로그', anchor: 'TRK-SEC-catalog' }, + { id: 'guide', label: '10. 예외 / 가이드', anchor: 'TRK-SEC-guide' }, +]; + +function DesignSystemShell() { + const { copyMode, setCopyMode } = useTrk(); + const [theme, setTheme] = useState<'dark' | 'light'>('dark'); + const [activeNav, setActiveNav] = useState('intro'); + + // 테마 토글 + useEffect(() => { + const root = document.documentElement; + root.classList.remove('dark', 'light'); + root.classList.add(theme); + }, [theme]); + + // 스크롤 감지로 현재 네비 하이라이트 + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + const id = entry.target.getAttribute('data-section-id'); + if (id) setActiveNav(id); + } + } + }, + { rootMargin: '-40% 0px -55% 0px', threshold: 0 }, + ); + const sections = document.querySelectorAll('[data-section-id]'); + sections.forEach((s) => observer.observe(s)); + return () => observer.disconnect(); + }, []); + + const scrollTo = (anchor: string) => { + const el = document.querySelector(`[data-trk="${anchor}"]`); + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }; + + return ( +
+ {/* 고정 헤더 */} +
+
+

KCG Design System

+ v0.1.0 · 쇼케이스 +
+
+ + +
+
+ +
+ {/* 좌측 네비 */} + + + {/* 우측 컨텐츠 */} +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ ); +} + +export function DesignSystemApp() { + return ( + + + + ); +} diff --git a/frontend/src/design-system/lib/Trk.tsx b/frontend/src/design-system/lib/Trk.tsx new file mode 100644 index 0000000..0d3e2a1 --- /dev/null +++ b/frontend/src/design-system/lib/Trk.tsx @@ -0,0 +1,71 @@ +import { type ReactNode, type CSSProperties, type MouseEvent } from 'react'; +import { useTrk } from './TrkContext'; + +/** + * 쇼케이스 추적 ID 시스템 + * 각 쇼케이스 항목에 고유 ID를 부여하여: + * 1. 호버 시 툴팁으로 ID 노출 + * 2. "ID 복사 모드"에서 클릭 시 클립보드 복사 + * 3. URL hash 딥링크 (#trk=TRK-BADGE-critical-sm) 지원 + */ +interface TrkProps { + id: string; + children: ReactNode; + className?: string; + style?: CSSProperties; + /** 인라인 요소로 렌더할지 여부 (기본: block) */ + inline?: boolean; +} + +export function Trk({ id, children, className = '', style, inline = false }: TrkProps) { + const { copyMode, activeId } = useTrk(); + const isActive = activeId === id; + + const handleClick = async (e: MouseEvent) => { + if (!copyMode) return; + e.preventDefault(); + e.stopPropagation(); + try { + await navigator.clipboard.writeText(id); + const el = e.currentTarget as HTMLElement; + el.dataset.copied = 'true'; + setTimeout(() => delete el.dataset.copied, 800); + } catch { + // clipboard API 미지원 시 무시 + } + }; + + const Wrapper = inline ? 'span' : 'div'; + + return ( + + {children} + + ); +} + +export function TrkSectionHeader({ + id, + title, + description, +}: { + id: string; + title: string; + description?: string; +}) { + return ( +
+
+

{title}

+ {id} +
+ {description &&

{description}

} +
+ ); +} diff --git a/frontend/src/design-system/lib/TrkContext.tsx b/frontend/src/design-system/lib/TrkContext.tsx new file mode 100644 index 0000000..a30d305 --- /dev/null +++ b/frontend/src/design-system/lib/TrkContext.tsx @@ -0,0 +1,50 @@ +import { useContext, createContext, useState, useEffect, type ReactNode } from 'react'; + +interface TrkContextValue { + copyMode: boolean; + setCopyMode: (v: boolean) => void; + activeId: string | null; + setActiveId: (id: string | null) => void; +} + +// eslint-disable-next-line react-refresh/only-export-components +export const TrkContext = createContext(null); + +export function TrkProvider({ children }: { children: ReactNode }) { + const [copyMode, setCopyMode] = useState(false); + const [activeId, setActiveId] = useState(null); + + // 딥링크 처리: #trk=TRK-BADGE-critical-sm + useEffect(() => { + const applyHash = () => { + const hash = window.location.hash; + const match = hash.match(/#trk=([\w-]+)/); + if (match) { + const id = match[1]; + setActiveId(id); + setTimeout(() => { + const el = document.querySelector(`[data-trk="${id}"]`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, 50); + } + }; + applyHash(); + window.addEventListener('hashchange', applyHash); + return () => window.removeEventListener('hashchange', applyHash); + }, []); + + return ( + + {children} + + ); +} + +// eslint-disable-next-line react-refresh/only-export-components +export function useTrk() { + const ctx = useContext(TrkContext); + if (!ctx) throw new Error('useTrk must be used within TrkProvider'); + return ctx; +} diff --git a/frontend/src/design-system/sections/BadgeSection.tsx b/frontend/src/design-system/sections/BadgeSection.tsx new file mode 100644 index 0000000..2af48bc --- /dev/null +++ b/frontend/src/design-system/sections/BadgeSection.tsx @@ -0,0 +1,114 @@ +import { TrkSectionHeader, Trk } from '../lib/Trk'; +import { Badge } from '@shared/components/ui/badge'; +import type { BadgeIntent, BadgeSize } from '@lib/theme/variants'; + +const INTENTS: BadgeIntent[] = ['critical', 'high', 'warning', 'info', 'success', 'muted', 'purple', 'cyan']; +const SIZES: BadgeSize[] = ['xs', 'sm', 'md', 'lg']; + +const INTENT_USAGE: Record = { + critical: '심각 · 긴급 · 위험 (빨강 계열)', + high: '높음 · 경고 (주황 계열)', + warning: '주의 · 보류 (노랑 계열)', + info: '일반 · 정보 (파랑 계열, 기본값)', + success: '성공 · 완료 · 정상 (초록 계열)', + muted: '비활성 · 중립 · 기타 (회색 계열)', + purple: '분석 · AI · 특수 (보라 계열)', + cyan: '모니터링 · 스트림 (청록 계열)', +}; + +export function BadgeSection() { + return ( + <> + + + {/* 32 변형 그리드 */} +

32 변형 매트릭스

+ +
+ + + + + {SIZES.map((size) => ( + + ))} + + + + {INTENTS.map((intent) => ( + + + {SIZES.map((size) => ( + + ))} + + ))} + +
intent ↓ / size → + {size} +
{intent} + + + {intent.toUpperCase()} + + +
+
+
+ + {/* intent별 의미 가이드 */} +

Intent 의미 가이드

+
+ {INTENTS.map((intent) => ( + +
+ + {intent.toUpperCase()} + + {INTENT_USAGE[intent]} +
+
+ ))} +
+ + {/* 사용 예시 코드 */} +

사용 예시

+ + + {`import { Badge } from '@shared/components/ui/badge'; +import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels'; + +// 카탈로그 API와 결합 + + {getAlertLevelLabel('CRITICAL', t, lang)} + + +// className override (tailwind-merge가 같은 그룹 충돌 감지) + + 커스텀 둥근 배지 +`} + + + + {/* 금지 패턴 */} +

금지 패턴

+ +
❌ 아래 패턴은 사용하지 마세요
+ + {`// ❌ className 직접 작성 (intent prop 무시) +... + +// ❌ !important 사용 +... + +// ❌
→ Badge 컴포넌트 사용 필수 +
위험
`} +
+ + + ); +} diff --git a/frontend/src/design-system/sections/ButtonSection.tsx b/frontend/src/design-system/sections/ButtonSection.tsx new file mode 100644 index 0000000..21233bf --- /dev/null +++ b/frontend/src/design-system/sections/ButtonSection.tsx @@ -0,0 +1,131 @@ +import { TrkSectionHeader, Trk } from '../lib/Trk'; +import { Button } from '@shared/components/ui/button'; +import type { ButtonVariant, ButtonSize } from '@lib/theme/variants'; +import { Plus, Download, Trash2, Search, Save } from 'lucide-react'; + +const VARIANTS: ButtonVariant[] = ['primary', 'secondary', 'ghost', 'outline', 'destructive']; +const SIZES: ButtonSize[] = ['sm', 'md', 'lg']; + +const VARIANT_USAGE: Record = { + primary: '주요 액션 · 기본 CTA (페이지당 1개 권장)', + secondary: '보조 액션 · 툴바 버튼 (기본값)', + ghost: '고요한 액션 · 리스트 행 내부', + outline: '강조 보조 · 필터 활성화 상태', + destructive: '삭제 · 비활성화 등 위험 액션', +}; + +export function ButtonSection() { + return ( + <> + + + {/* 매트릭스 */} +

15 변형 매트릭스

+ +
+ + + + + {SIZES.map((size) => ( + + ))} + + + + {VARIANTS.map((variant) => ( + + + {SIZES.map((size) => ( + + ))} + + ))} + +
variant ↓ / size → + {size} +
{variant} + + + +
+
+
+ + {/* 아이콘 버튼 */} +

아이콘 포함 패턴

+ +
+ + + + + +
+
+ + {/* 상태 */} +

상태

+ +
+ + +
+
+ + {/* variant 의미 */} +

Variant 의미 가이드

+
+ {VARIANTS.map((variant) => ( + +
+ + {VARIANT_USAGE[variant]} +
+
+ ))} +
+ + {/* 사용 예시 */} +

사용 예시

+ + + {`import { Button } from '@shared/components/ui/button'; +import { Plus } from 'lucide-react'; + + + + + +// 금지 +// ❌