diff --git a/CLAUDE.md b/CLAUDE.md index e17f2b0..b119896 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,6 +101,126 @@ make format # 프론트 prettier - 커밋: Conventional Commits (한국어), `.githooks/commit-msg` 검증 - pre-commit: `frontend/` 디렉토리 기준 TypeScript + ESLint 검증 +## 디자인 시스템 (필수 준수) + +프론트엔드 UI는 **`/design-system.html` 쇼케이스를 단일 진실 공급원(SSOT)** 으로 한다. +모든 페이지/컴포넌트는 쇼케이스에 정의된 컴포넌트와 토큰만 사용한다. + +### 쇼케이스 진입 +- **URL**: https://kcg-ai-monitoring.gc-si.dev/design-system.html (메인 SPA와 별개) +- **소스**: `frontend/design-system.html` + `frontend/src/designSystemMain.tsx` + `frontend/src/design-system/` +- **추적 ID 체계**: `TRK-<카테고리>-<슬러그>` (예: `TRK-BADGE-critical-sm`) + - 호버 시 툴팁, "ID 복사 모드"에서 클릭 시 클립보드 복사 + - URL 해시 딥링크: `#trk=TRK-BUTTON-primary-md` +- **단축키 `A`**: 다크/라이트 테마 토글 + +### 공통 컴포넌트 (반드시 사용) + +| 컴포넌트 | 위치 | 용도 | +|---|---|---| +| `Badge` | `@shared/components/ui/badge` | 8 intent × 4 size, **className으로 색상 override 금지** | +| `Button` | `@shared/components/ui/button` | 5 variant × 3 size (primary/secondary/ghost/outline/destructive) | +| `Input` / `Select` / `Textarea` / `Checkbox` / `Radio` | `@shared/components/ui/` | 폼 요소 (Select는 aria-label 타입 강제) | +| `TabBar` / `TabButton` | `@shared/components/ui/tabs` | underline / pill / segmented 3 variant | +| `Card` / `CardHeader` / `CardTitle` / `CardContent` | `@shared/components/ui/card` | 4 variant | +| `PageContainer` | `@shared/components/layout` | 페이지 루트 (size sm/md/lg + fullBleed) | +| `PageHeader` | `@shared/components/layout` | 페이지 헤더 (icon + title + description + demo + actions) | +| `Section` | `@shared/components/layout` | Card + CardHeader + CardTitle + CardContent 단축 | + +### 카탈로그 기반 라벨/색상 + +분류 데이터는 `frontend/src/shared/constants/`의 19+ 카탈로그를 참조한다. +중앙 레지스트리는 `catalogRegistry.ts`이며, 쇼케이스가 자동 열거한다. + +```tsx +import { Badge } from '@shared/components/ui/badge'; +import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels'; + + + {getAlertLevelLabel(event.level, t, lang)} + +``` + +ad-hoc 한글/영문 상태 문자열은 `getStatusIntent()` (statusIntent.ts) 사용. +숫자 위험도는 `getRiskIntent(0~100)` 사용. + +### CSS 작성 규칙 + +1. **인라인 색상 금지** — `style={{ backgroundColor: '#ef4444' }}` 같은 정적 색상은 작성 금지 + - 예외: 동적 데이터 기반 (`backgroundColor: meta.hex`, progress width `${value}%`) +2. **하드코딩 Tailwind 색상 금지** — `bg-red-500/20 text-red-400` 같은 직접 작성 금지 + - 반드시 Badge intent 또는 카탈로그 API 호출 +3. **className override 정책** + - ✅ 레이아웃/위치 보정: `` + - ❌ 색상/글자 크기 override: `` +4. **시맨틱 토큰 우선** — `theme.css @layer utilities`의 토큰 사용 + - `text-heading` / `text-label` / `text-hint` / `text-on-vivid` / `text-on-bright` + - `bg-surface-raised` / `bg-surface-overlay` / `bg-card` / `bg-background` +5. **!important 절대 금지** — `cn()` + `tailwind-merge`로 충돌 해결 +6. **`-webkit-` 벤더 prefix** — 수동 작성 CSS는 `backdrop-filter` 등 prefix 직접 추가 (Tailwind는 자동) + +### 페이지 작성 표준 템플릿 + +```tsx +import { PageContainer, PageHeader, Section } from '@shared/components/layout'; +import { Button } from '@shared/components/ui/button'; +import { Badge } from '@shared/components/ui/badge'; +import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels'; +import { Shield, Plus } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +export function MyPage() { + const { t, i18n } = useTranslation('common'); + const lang = i18n.language as 'ko' | 'en'; + + return ( + + }> + 추가 + + } + /> + + + {getAlertLevelLabel('HIGH', t, lang)} + + + + ); +} +``` + +### 접근성 (a11y) 필수 + +- **``**: `type="button"` 명시 + 아이콘 전용은 `aria-label` 필수 +- **`` / `` / ``**: `aria-label` 또는 `` 필수 +- **`Select` 컴포넌트**: TypeScript union type으로 `aria-label`/`aria-labelledby`/`title` 중 하나 컴파일 타임 강제 +- 위반 시 WCAG 2.1 Level A 위반 + axe DevTools 경고 + +### 변경 사이클 + +1. 디자인 변경이 필요하면 → **쇼케이스에서 먼저 미세조정** → 시각 검증 +2. 카탈로그 라벨/색상 변경 → `shared/constants/*` 또는 `variantMeta.ts`만 수정 +3. 컴포넌트 변형 추가 → `lib/theme/variants.ts` CVA에만 추가 +4. 실 페이지는 **컴포넌트만 사용**, 변경 시 자동 반영 + +### 금지 패턴 체크리스트 + +- ❌ `` → `` +- ❌ `` → `` +- ❌ `` → `` +- ❌ `` 페이지 루트 → `` +- ❌ `-m-4` negative margin 해킹 → `` +- ❌ `style={{ color: '#ef4444' }}` 정적 색상 → 시맨틱 토큰 또는 카탈로그 +- ❌ `!important` → `cn()` 활용 +- ❌ 페이지 내 `const STATUS_COLORS = {...}` 로컬 재정의 → shared/constants 카탈로그 + ## System Flow 뷰어 (개발 단계용) - **URL**: https://kcg-ai-monitoring.gc-si.dev/system-flow.html (메인 SPA와 별개) 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'; diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index af3aa96..fc13d09 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -5,6 +5,36 @@ ## [Unreleased] ### 추가 +- **디자인 시스템 쇼케이스** (`/design-system.html`) — UI 단일 진실 공급원(SSOT) + - 별도 Vite entry, 메인 SPA와 분리 (designSystem-*.js 54KB) + - 10개 섹션: Intro / Token / Typography / Badge / Button / Form / Card / Layout / Catalog / Guide + - 추적 ID 체계 `TRK-<카테고리>-<슬러그>` (예: `TRK-BADGE-critical-sm`) + - 호버 시 툴팁, "ID 복사 모드", URL 해시 딥링크 `#trk=...` + - 단축키 `A`로 다크/라이트 토글 + - 한글/영문 라벨 병기로 카탈로그 검토 용이 +- **신규 공통 컴포넌트**: + - `Button` (5 variant × 3 size = 15) `@shared/components/ui/button` + - `Input` / `Select` / `Textarea` / `Checkbox` / `Radio` `@shared/components/ui/` + - `TabBar` / `TabButton` (underline / pill / segmented 3 variant) `@shared/components/ui/tabs` + - `PageContainer` (size sm/md/lg + fullBleed) `@shared/components/layout/PageContainer` + - `PageHeader` (icon + title + description + demo + actions) `@shared/components/layout/PageHeader` + - `Section` (Card 단축) `@shared/components/layout/Section` +- **중앙 레지스트리**: + - `catalogRegistry.ts` — 23개 카탈로그 메타 (id/title/description/source/items 자동 enumerate) + - `variantMeta.ts` — Badge intent 8종 + Button variant 5종 의미 가이드 + - `statusIntent.ts` — 한글/영문 ad-hoc 상태 → BadgeIntent + getRiskIntent(0~100) +- **4 catalog에 intent 필드 추가**: eventStatuses / enforcementResults / enforcementActions / patrolStatuses + getXxxIntent() 헬퍼 +- **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 +47,36 @@ - V015 NUMERIC precision 일괄 확대 (score→7,4, pct→12,2) - V016 parent workflow 누락 컬럼 일괄 추가 (17+ 컬럼, candidate_mmsi generated column) +### 변경 +- **35+ feature 페이지 PageContainer/PageHeader 마이그레이션** — admin/detection/enforcement/field-ops/patrol/statistics/ai-operations/parent-inference/dashboard/monitoring/surveillance/vessel/risk-assessment 전체 +- **VesselDetail `-m-4` negative margin 해킹 → ``** 정리 +- **LiveMapView fullBleed 패턴** 적용 +- **Badge intent 팔레트 테마 분리**: 라이트(파스텔 `bg-X-100 text-X-900`) + 다크(translucent `bg-X-500/20 text-X-400`) +- **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` 제거 (데이터 페이지네이션 오해 해소) + ### 수정 +- **접근성 (WCAG 2.1 Level A)** — axe DevTools 위반 전수 처리: + - `` 컴포넌트 TypeScript union 타입으로 `aria-label`/`aria-labelledby`/`title` 컴파일 강제 + - 네이티브 `` 5곳 aria-label + - 아이콘 전용 `` 16곳 aria-label + - ``/`` 28곳 aria-label (placeholder 자동 복제 포함) + - AIModelManagement 토글 → `role="switch"` + `aria-checked` +- **Badge className 위반 37건 전수 제거** — `` 패턴으로 통일 +- **하드코딩 `bg-X-500/20 text-X-400` 56곳 제거** — 카탈로그 API + intent 사용 +- **인라인 `` type 누락 86곳 보정** +- **CSS Safari 호환성**: `backdrop-filter` `-webkit-` prefix 추가 (디자인 쇼케이스) +- `trk-pulse` keyframe outline-color → opacity (composite-only 최적화) +- 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 판정 로직 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/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/app/layout/MainLayout.tsx b/frontend/src/app/layout/MainLayout.tsx index 65eff20..e0a3e15 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 ( - - onPageChange(0)} disabled={page === 0} className={btnCls}> - onPageChange(page - 1)} disabled={page === 0} className={btnCls}> - {range.map((p) => ( - onPageChange(p)} - className={`min-w-[22px] h-5 px-1 rounded text-[10px] font-medium transition-colors ${ - p === page ? 'bg-blue-600 text-heading' : 'text-muted-foreground hover:text-heading hover:bg-surface-overlay' - }`} - >{p + 1} - ))} - onPageChange(page + 1)} disabled={page >= totalPages - 1} className={btnCls}> - onPageChange(totalPages - 1)} disabled={page >= totalPages - 1} className={btnCls}> - {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} @@ -429,7 +361,7 @@ export function MainLayout() { - {t('layout.alertCount', { count: 3 })} - + @@ -485,7 +417,7 @@ export function MainLayout() { {user.org} {roleColor && ( - + {user.role} )} @@ -506,6 +438,7 @@ export function MainLayout() { setPageSearch(e.target.value)} onKeyDown={(e) => { @@ -522,7 +455,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 +492,9 @@ export function MainLayout() { - - {/* SFR-02: 공통 페이지네이션 (하단) */} - - - {/* SFR-02: 공통알림 팝업 */} diff --git a/frontend/src/design-system/DesignSystemApp.css b/frontend/src/design-system/DesignSystemApp.css new file mode 100644 index 0000000..77ae9e6 --- /dev/null +++ b/frontend/src/design-system/DesignSystemApp.css @@ -0,0 +1,140 @@ +/* 디자인 쇼케이스 전용 스타일 */ + +.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)); + -webkit-backdrop-filter: blur(8px); + 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; + will-change: opacity; +} + +.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% { + opacity: 1; + } + 50% { + opacity: 0.35; + } + 100% { + opacity: 1; + } +} + +/* 쇼케이스 그리드 */ +.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..cc517c7 --- /dev/null +++ b/frontend/src/design-system/DesignSystemApp.tsx @@ -0,0 +1,192 @@ +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]); + + // 단축키: 'a' 입력 시 테마 전환 (input/textarea 등 편집 중에는 무시) + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key !== 'a' && e.key !== 'A') return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + const target = e.target as HTMLElement | null; + const tag = target?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) { + return; + } + setTheme((prev) => (prev === 'dark' ? 'light' : 'dark')); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); + + // 스크롤 감지로 현재 네비 하이라이트 + 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 · 쇼케이스 + + + + setCopyMode(e.target.checked)} + className="accent-blue-500" + /> + ID 복사 모드 + + setTheme(theme === 'dark' ? 'light' : 'dark')} + title="단축키: A" + className="px-3 py-1 rounded-md border border-slate-600/40 text-xs text-label hover:bg-slate-700/30 flex items-center gap-1.5" + > + {theme === 'dark' ? '☾ Dark' : '☀ Light'} + A + + + + + + {/* 좌측 네비 */} + + Sections + + {NAV_ITEMS.map((item) => ( + + scrollTo(item.anchor)} + className={`w-full text-left px-2 py-1.5 rounded text-xs transition-colors ${ + activeNav === item.id + ? 'bg-blue-500/15 text-blue-400 border-l-2 border-blue-500' + : 'text-label hover:bg-slate-700/20' + }`} + > + {item.label} + + + ))} + + + + + 추적 ID 체계 + + TRK-<카테고리>-<슬러그> + + 딥링크: #trk=ID + + + + + {/* 우측 컨텐츠 */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +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..7a5a07c --- /dev/null +++ b/frontend/src/design-system/sections/BadgeSection.tsx @@ -0,0 +1,109 @@ +import { TrkSectionHeader, Trk } from '../lib/Trk'; +import { Badge } from '@shared/components/ui/badge'; +import { + BADGE_INTENT_META, + BADGE_INTENT_ORDER, + BADGE_SIZE_ORDER, +} from '@lib/theme/variantMeta'; + +export function BadgeSection() { + return ( + <> + + + {/* 변형 그리드 */} + 변형 매트릭스 + + + + + + + intent ↓ / size → + + {BADGE_SIZE_ORDER.map((size) => ( + + {size} + + ))} + + + + {BADGE_INTENT_ORDER.map((intent) => ( + + {intent} + {BADGE_SIZE_ORDER.map((size) => ( + + + + {intent.toUpperCase()} + + + + ))} + + ))} + + + + + + {/* intent 의미 가이드 (variantMeta에서 자동 열거) */} + Intent 의미 가이드 + + {BADGE_INTENT_ORDER.map((intent) => { + const meta = BADGE_INTENT_META[intent]; + return ( + + + + {meta.titleKo} + + {meta.description} + + + ); + })} + + + {/* 사용 예시 코드 */} + 사용 예시 + + + {`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..0987990 --- /dev/null +++ b/frontend/src/design-system/sections/ButtonSection.tsx @@ -0,0 +1,129 @@ +import { TrkSectionHeader, Trk } from '../lib/Trk'; +import { Button } from '@shared/components/ui/button'; +import { + BUTTON_VARIANT_META, + BUTTON_VARIANT_ORDER, + BUTTON_SIZE_ORDER, +} from '@lib/theme/variantMeta'; +import { Plus, Download, Trash2, Search, Save } from 'lucide-react'; + +export function ButtonSection() { + return ( + <> + + + {/* 매트릭스 */} + 변형 매트릭스 + + + + + + + variant ↓ / size → + + {BUTTON_SIZE_ORDER.map((size) => ( + + {size} + + ))} + + + + {BUTTON_VARIANT_ORDER.map((variant) => ( + + {variant} + {BUTTON_SIZE_ORDER.map((size) => ( + + + + {variant} + + + + ))} + + ))} + + + + + + {/* 아이콘 버튼 */} + 아이콘 포함 패턴 + + + }> + 새 항목 추가 + + }> + 다운로드 + + }> + 검색 + + }> + 저장 + + }> + 삭제 + + + + + {/* 상태 */} + 상태 + + + Normal + + Disabled + + + + + {/* variant 의미 가이드 (variantMeta에서 자동 열거) */} + Variant 의미 가이드 + + {BUTTON_VARIANT_ORDER.map((variant) => { + const meta = BUTTON_VARIANT_META[variant]; + return ( + + + + {meta.titleKo} + + {meta.description} + + + ); + })} + + + {/* 사용 예시 */} + 사용 예시 + + + {`import { Button } from '@shared/components/ui/button'; +import { Plus } from 'lucide-react'; + +}> + 새 보고서 + + + + 삭제 + + +// 금지 +// ❌ +// ❌ → variant="destructive" 사용`} + + + > + ); +} diff --git a/frontend/src/design-system/sections/CardSection.tsx b/frontend/src/design-system/sections/CardSection.tsx new file mode 100644 index 0000000..c526482 --- /dev/null +++ b/frontend/src/design-system/sections/CardSection.tsx @@ -0,0 +1,111 @@ +import { TrkSectionHeader, Trk } from '../lib/Trk'; +import { Card, CardHeader, CardTitle, CardContent } from '@shared/components/ui/card'; +import { Database, Activity, Bell } from 'lucide-react'; + +export function CardSectionShowcase() { + return ( + <> + + + {/* 4 variant */} + Variant + + + variant=default + + + + + 데이터베이스 + + + + + PostgreSQL + v15.4 운영중 + + + 연결 + 8 / 20 + + + + + + + variant=elevated (기본값) + + + + + 시스템 상태 + + + + + API + 정상 + + + Prediction + 5분 주기 + + + + + + + variant=inner + + + + + 알림 + + + + 중첩 카드 내부에 사용. 외부 카드보다 한 단계 낮은 depth. + + + + + + variant=transparent + + + 투명 카드 + + + 배경/보더 없이 구조만 활용 (그룹핑 목적). + + + + + + {/* 사용 예시 */} + 사용 예시 + + + {`import { Card, CardHeader, CardTitle, CardContent } from '@shared/components/ui/card'; +import { Database } from 'lucide-react'; + + + + + + 데이터베이스 + + + + {/* 콘텐츠 */} + +`} + + + > + ); +} diff --git a/frontend/src/design-system/sections/CatalogSection.tsx b/frontend/src/design-system/sections/CatalogSection.tsx new file mode 100644 index 0000000..e18a475 --- /dev/null +++ b/frontend/src/design-system/sections/CatalogSection.tsx @@ -0,0 +1,130 @@ +import { type ReactNode } from 'react'; +import { TrkSectionHeader, Trk } from '../lib/Trk'; +import { Badge } from '@shared/components/ui/badge'; +import type { BadgeIntent } from '@lib/theme/variants'; +import { CATALOG_REGISTRY, type CatalogEntry } from '@shared/constants/catalogRegistry'; + +/** + * 카탈로그 섹션 — `CATALOG_REGISTRY`를 자동 열거. + * 새 카탈로그 추가는 `catalogRegistry.ts`에 한 줄 추가하면 끝. + * 여기는 렌더링 로직만 담당. + */ + +interface AnyMeta { + code: string; + intent?: BadgeIntent; + fallback?: { ko: string; en: string }; + classes?: string | { bg?: string; text?: string; border?: string }; + label?: string; +} + +function getKoLabel(meta: AnyMeta): string { + return meta.fallback?.ko ?? meta.label ?? meta.code; +} + +function getEnLabel(meta: AnyMeta): string | undefined { + return meta.fallback?.en; +} + +function getFallbackClasses(meta: AnyMeta): string | undefined { + if (typeof meta.classes === 'string') return meta.classes; + if (typeof meta.classes === 'object' && meta.classes) { + return [meta.classes.bg, meta.classes.text, meta.classes.border].filter(Boolean).join(' '); + } + return undefined; +} + +function renderBadge(meta: AnyMeta, label: string): ReactNode { + if (meta.intent) { + return ( + + {label} + + ); + } + const classes = + getFallbackClasses(meta) ?? + 'bg-slate-100 text-slate-700 border-slate-300 dark:bg-slate-500/20 dark:text-slate-300 dark:border-slate-500/30'; + return ( + + {label} + + ); +} + +function CatalogBadges({ entry }: { entry: CatalogEntry }) { + const items = Object.values(entry.items) as AnyMeta[]; + return ( + + {items.map((meta) => { + const koLabel = getKoLabel(meta); + const enLabel = getEnLabel(meta); + const trkId = `${entry.showcaseId}-${meta.code}`; + return ( + + + {meta.code} + + {renderBadge(meta, koLabel)} + + {enLabel ? ( + renderBadge(meta, enLabel) + ) : ( + (no en) + )} + + + ); + })} + + ); +} + +export function CatalogSection() { + return ( + <> + + + + + 페이지에서 배지를 렌더할 때는 반드시 카탈로그 API를 사용한다 (라벨/intent/색상 단일 관리): + + + {`import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels'; + + + {getAlertLevelLabel(event.level, t, lang)} +`} + + + 새 카탈로그 추가: shared/constants/catalogRegistry.ts에 + 항목을 추가하면 이 쇼케이스에 자동 노출됩니다. + + + + {CATALOG_REGISTRY.map((entry) => ( + + + + + {entry.titleKo} · {entry.titleEn} + + {entry.showcaseId} + + {entry.description} + {entry.source && ( + 출처: {entry.source} + )} + + + + ))} + > + ); +} diff --git a/frontend/src/design-system/sections/FormSection.tsx b/frontend/src/design-system/sections/FormSection.tsx new file mode 100644 index 0000000..655f1d4 --- /dev/null +++ b/frontend/src/design-system/sections/FormSection.tsx @@ -0,0 +1,124 @@ +import { useState } from 'react'; +import { TrkSectionHeader, Trk } from '../lib/Trk'; +import { Input } from '@shared/components/ui/input'; +import { Select } from '@shared/components/ui/select'; +import { Textarea } from '@shared/components/ui/textarea'; +import { Checkbox } from '@shared/components/ui/checkbox'; +import { Radio } from '@shared/components/ui/radio'; + +export function FormSection() { + const [radio, setRadio] = useState('a'); + + return ( + <> + + + {/* Input 사이즈 */} + Input · 사이즈 + + {(['sm', 'md', 'lg'] as const).map((size) => ( + + size={size} + + + ))} + + + {/* Input 상태 */} + Input · 상태 + + + state=default + + + + state=error + + + + state=success + + + + disabled + + + + readOnly + + + + type=number + + + + + {/* Select */} + Select + + {(['sm', 'md', 'lg'] as const).map((size) => ( + + size={size} + + 전체 선택 + CRITICAL + HIGH + MEDIUM + + + ))} + + + {/* Textarea */} + Textarea + + + + + {/* Checkbox */} + Checkbox + + + + + + + + + + {/* Radio */} + Radio + + + setRadio('a')} /> + setRadio('b')} /> + setRadio('c')} /> + + + + + {/* 사용 예시 */} + 사용 예시 + + + {`import { Input } from '@shared/components/ui/input'; +import { Select } from '@shared/components/ui/select'; +import { Checkbox } from '@shared/components/ui/checkbox'; + + setQ(e.target.value)} /> + + setLevel(e.target.value)}> + 전체 등급 + CRITICAL + + + setActive(e.target.checked)} />`} + + + > + ); +} diff --git a/frontend/src/design-system/sections/GuideSection.tsx b/frontend/src/design-system/sections/GuideSection.tsx new file mode 100644 index 0000000..53376f7 --- /dev/null +++ b/frontend/src/design-system/sections/GuideSection.tsx @@ -0,0 +1,130 @@ +import { TrkSectionHeader, Trk } from '../lib/Trk'; + +export function GuideSection() { + return ( + <> + + + + 언제 fullBleed를 쓰는가? + + + 지도 중심 페이지 — 가장자리까지 지도가 확장되어야 할 때 (LiveMapView, + VesselDetail) + + + 3단 패널 레이아웃 — 좌측 리스트 + 중앙 콘텐츠 + 우측 상세 구조로 + padding이 상위에서 관리되지 않는 경우 + + + 높이 100% 요구 — 브라우저 뷰포트 전체를 차지해야 할 때 + + + + 예외: <PageContainer fullBleed>로 명시.{' '} + -m-4, -mx-4 같은 negative margin 해킹 금지. + + + + + 언제 className override를 허용하는가? + + + 레이아웃 보정 — 부모 컨테이너 특성상 width/margin 조정이 필요할 때 ( + w-48, flex-1 등) + + + 반응형 조정 — sm/md/lg 브레이크포인트별 조정 + + + + 허용:{' '} + + <Badge intent="info" className="w-full justify-center"> + + + + 금지:{' '} + <Badge className="bg-red-500 text-white">{' '} + — intent prop을 대체하려 하지 말 것 + + + + + 동적 hex 색상이 필요한 경우 + + + DB에서 사용자가 정의한 색상 (예: Role.colorHex) → style={`{{ background: role.colorHex }}`}{' '} + 인라인 허용 + + + 차트 팔레트 → getAlertLevelHex(level) 같은 카탈로그 API에서 hex 조회 + + + 지도 마커 deck.gl → RGB 튜플로 변환 필요, 카탈로그 hex 기반 + + + + + + 금지 패턴 체크리스트 + + ❌ !important prefix (!bg-red-500) + ❌ className="bg-X text-Y"로 Badge 스타일을 재정의 + ❌ <button className="bg-blue-600 ..."> — Button 컴포넌트 사용 필수 + ❌ <input className="bg-surface ..."> — Input 컴포넌트 사용 필수 + ❌ p-4 space-y-5 같은 제각각 padding — PageContainer size 사용 + ❌ -m-4, -mx-4 negative margin 해킹 — fullBleed 사용 + ❌ 페이지에서 const STATUS_COLORS = {`{...}`} 로컬 상수 정의 — shared/constants 카탈로그 사용 + ❌ date.toLocaleString('ko-KR', ...) 직접 호출 — formatDateTime 사용 + + + + + 새 페이지 작성 템플릿 + + {`import { PageContainer, PageHeader, Section } from '@shared/components/layout'; +import { Button } from '@shared/components/ui/button'; +import { Input } from '@shared/components/ui/input'; +import { Badge } from '@shared/components/ui/badge'; +import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels'; +import { formatDateTime } from '@shared/utils/dateFormat'; +import { Shield, Plus } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +export function MyNewPage() { + const { t, i18n } = useTranslation('common'); + const lang = i18n.language as 'ko' | 'en'; + + return ( + + }> + 추가 + + } + /> + + + + {getAlertLevelLabel('HIGH', t, lang)} + + {formatDateTime(row.createdAt)} + + + ); +}`} + + + > + ); +} diff --git a/frontend/src/design-system/sections/IntroSection.tsx b/frontend/src/design-system/sections/IntroSection.tsx new file mode 100644 index 0000000..cde784f --- /dev/null +++ b/frontend/src/design-system/sections/IntroSection.tsx @@ -0,0 +1,68 @@ +import { TrkSectionHeader, Trk } from '../lib/Trk'; + +export function IntroSection() { + return ( + <> + + + + 이 페이지의 목적 + + + 모든 스타일의 뼈대 — 페이지 파일은 이 쇼케이스에 정의된 컴포넌트와 토큰만 + 사용한다. 임의의 `className="bg-red-600"` 같은 직접 스타일은 금지. + + + 미세조정의 단일 지점 — 색상 · 여백 · 텍스트 크기 등 모든 변경은 이 페이지에서 + 먼저 시각적으로 검증한 후 실제 페이지에 적용한다. + + + ID 기반 참조 — 각 쇼케이스 항목에 TRK-* 추적 ID가 부여되어 있어, + 특정 변형을 정확히 가리키며 논의 · 수정이 가능하다. + + + + + + 사용 방법 + + + 상단 "ID 복사 모드" 체크박스를 켜면 쇼케이스 항목 클릭 시 ID가 클립보드에 + 복사된다. + + + URL 해시 #trk=TRK-BADGE-critical-sm 으로 특정 항목 딥링크 — 스크롤 + + 하이라이트. + + + 상단 Dark / Light 토글로 두 테마에서 동시에 검증. + + + 좌측 네비게이션으로 섹션 이동. 각 섹션 제목 옆에 섹션의 TRK-SEC-* ID가 노출된다. + + + + + + 추적 ID 명명 규칙 + + {`TRK-<카테고리>-<슬러그>[-<변형>] + +예시: + TRK-TOKEN-text-heading → 시맨틱 토큰 --text-heading + TRK-BADGE-critical-sm → Badge intent=critical size=sm + TRK-BUTTON-primary-md → Button variant=primary size=md + TRK-LAYOUT-page-container → PageContainer 루트 + TRK-CAT-alert-level-HIGH → alertLevels 카탈로그의 HIGH 배지 + TRK-SEC-badge → Badge 섹션 자체 + +카테고리: SEC, INTRO, TOKEN, TYPO, BADGE, BUTTON, FORM, CARD, LAYOUT, CAT, GUIDE`} + + + > + ); +} diff --git a/frontend/src/design-system/sections/LayoutSection.tsx b/frontend/src/design-system/sections/LayoutSection.tsx new file mode 100644 index 0000000..c810e50 --- /dev/null +++ b/frontend/src/design-system/sections/LayoutSection.tsx @@ -0,0 +1,177 @@ +import { TrkSectionHeader, Trk } from '../lib/Trk'; +import { PageContainer, PageHeader, Section } from '@shared/components/layout'; +import { Button } from '@shared/components/ui/button'; +import { Input } from '@shared/components/ui/input'; +import { Shield, BarChart3, Plus, Search } from 'lucide-react'; + +export function LayoutSection() { + return ( + <> + + + {/* PageContainer */} + PageContainer + + + size=sm (p-4 space-y-3) + + + + + + + + + size=md · 기본값 (p-5 space-y-4) + + + + + + + + + size=lg (p-6 space-y-4) + + + + + + + + + + + + fullBleed — 지도/풀화면 페이지 (padding 0, space-y 0) + + + + + fullBleed: 가장자리까지 콘텐츠 (LiveMapView / VesselDetail 패턴) + + + + + + {/* PageHeader */} + PageHeader + + + 단순형 (title + description) + + + + + 아이콘 포함 + + + + + 데모 배지 + + + + + 우측 액션 슬롯 + + + }> + 검색 + + }> + 새 보고서 + + > + } + /> + + + + {/* Section */} + Section (Card 단축) + + + 기본 Section + + + + 동해 + 23건 + + + 서해 + 12건 + + + + + + + 우측 액션 + + 전체 보기 + + } + > + 이벤트 3건 + + + + + {/* 전체 조합 예시 */} + 전체 조합 예시 + + + {`import { PageContainer, PageHeader, Section } from '@shared/components/layout'; +import { Button } from '@shared/components/ui/button'; +import { Shield, Plus } from 'lucide-react'; + +export function AccessControlPage() { + return ( + + }> + 역할 추가 + + } + /> + + + + + ); +}`} + + + > + ); +} diff --git a/frontend/src/design-system/sections/TokenSection.tsx b/frontend/src/design-system/sections/TokenSection.tsx new file mode 100644 index 0000000..908edda --- /dev/null +++ b/frontend/src/design-system/sections/TokenSection.tsx @@ -0,0 +1,160 @@ +import { TrkSectionHeader, Trk } from '../lib/Trk'; + +/** 시맨틱 색상 토큰 목록. theme.css :root 정의 기준 */ +const SURFACE_TOKENS = [ + { id: 'background', label: '--background', desc: '페이지 최하위 배경' }, + { id: 'card', label: '--card', desc: 'Card 컴포넌트 배경' }, + { id: 'surface-raised', label: '--surface-raised', desc: '카드 내부 섹션' }, + { id: 'surface-overlay', label: '--surface-overlay', desc: '모달/오버레이' }, + { id: 'muted', label: '--muted', desc: '약한 배경 (inactive/disabled)' }, + { id: 'popover', label: '--popover', desc: '드롭다운/툴팁 배경' }, +]; + +const TEXT_TOKENS = [ + { id: 'text-heading', label: '--text-heading', example: '제목 Heading', desc: 'h1~h4, 강조 텍스트' }, + { id: 'text-label', label: '--text-label', example: '본문 Label', desc: '일반 본문, 라벨' }, + { id: 'text-hint', label: '--text-hint', example: '보조 Hint', desc: '설명, 힌트, placeholder' }, + { id: 'text-on-vivid', label: '--text-on-vivid', example: '컬러풀 위 텍스트', desc: '-600/-700 진한 배경 위 (버튼)', bg: '#2563eb' }, + { id: 'text-on-bright', label: '--text-on-bright', example: '밝은 위 텍스트', desc: '-300/-400 밝은 배경 위 (배지)', bg: '#60a5fa' }, +]; + +const BRAND_COLORS = [ + { id: 'primary', label: '--primary', desc: '기본 강조색 (파랑)' }, + { id: 'destructive', label: '--destructive', desc: '위험/삭제 (빨강)' }, + { id: 'ring', label: '--ring', desc: 'focus ring' }, + { id: 'border', label: '--border', desc: '경계선' }, +]; + +const CHART_COLORS = [ + { id: 'chart-1', label: '--chart-1' }, + { id: 'chart-2', label: '--chart-2' }, + { id: 'chart-3', label: '--chart-3' }, + { id: 'chart-4', label: '--chart-4' }, + { id: 'chart-5', label: '--chart-5' }, +]; + +const RADIUS_SCALE = [ + { id: 'sm', label: 'radius-sm', cls: 'rounded-sm', px: 'calc(radius - 4px)' }, + { id: 'md', label: 'radius-md', cls: 'rounded-md', px: 'calc(radius - 2px)' }, + { id: 'lg', label: 'radius-lg', cls: 'rounded-lg', px: '0.5rem (default)' }, + { id: 'xl', label: 'radius-xl', cls: 'rounded-xl', px: 'calc(radius + 4px)' }, + { id: 'full', label: 'rounded-full', cls: 'rounded-full', px: '9999px' }, +]; + +const SPACING_SCALE = [ + { id: '1', size: 4 }, { id: '2', size: 8 }, { id: '3', size: 12 }, + { id: '4', size: 16 }, { id: '5', size: 20 }, { id: '6', size: 24 }, + { id: '8', size: 32 }, { id: '10', size: 40 }, +]; + +export function TokenSection() { + return ( + <> + + + {/* Surface 토큰 */} + Surface / 배경 + + {SURFACE_TOKENS.map((t) => ( + + + {t.label} + {t.desc} + TRK-TOKEN-{t.id} + + ))} + + + {/* 텍스트 토큰 */} + Text / 텍스트 + + {TEXT_TOKENS.map((t) => ( + + + {t.example} + + {t.label} + {t.desc} + TRK-TOKEN-{t.id} + + ))} + + + {/* 브랜드 색 */} + Brand / 기능색 + + {BRAND_COLORS.map((t) => ( + + + {t.label} + {t.desc} + + ))} + + + {/* Chart 색 */} + Chart / 차트 팔레트 + + + {CHART_COLORS.map((t) => ( + + + {t.label} + + ))} + + TRK-TOKEN-chart-palette + + + {/* Radius 스케일 */} + Radius / 모서리 반경 + + + {RADIUS_SCALE.map((r) => ( + + + {r.label} + {r.px} + + ))} + + + + {/* Spacing 스케일 */} + Spacing / 간격 스케일 + + + {SPACING_SCALE.map((s) => ( + + p-{s.id} + + {s.size}px + + ))} + + + > + ); +} diff --git a/frontend/src/design-system/sections/TypographySection.tsx b/frontend/src/design-system/sections/TypographySection.tsx new file mode 100644 index 0000000..964888c --- /dev/null +++ b/frontend/src/design-system/sections/TypographySection.tsx @@ -0,0 +1,35 @@ +import { TrkSectionHeader, Trk } from '../lib/Trk'; + +const TYPE_SCALE = [ + { id: 'h1', tag: 'h1' as const, cls: 'text-2xl font-bold text-heading', example: '주요 타이틀 (H1 · text-2xl)' }, + { id: 'h2', tag: 'h2' as const, cls: 'text-xl font-bold text-heading', example: '페이지 제목 (H2 · text-xl)' }, + { id: 'h3', tag: 'h3' as const, cls: 'text-lg font-semibold text-heading', example: '섹션 제목 (H3 · text-lg)' }, + { id: 'h4', tag: 'h4' as const, cls: 'text-base font-semibold text-heading', example: '카드 제목 (H4 · text-base)' }, + { id: 'body', tag: 'p' as const, cls: 'text-sm text-label', example: '본문 텍스트 (body · text-sm)' }, + { id: 'body-sm', tag: 'p' as const, cls: 'text-xs text-label', example: '작은 본문 (body-sm · text-xs)' }, + { id: 'label', tag: 'span' as const, cls: 'text-[11px] text-label font-medium', example: '라벨 (label · 11px)' }, + { id: 'hint', tag: 'span' as const, cls: 'text-[10px] text-hint', example: '힌트/캡션 (hint · 10px)' }, +]; + +export function TypographySection() { + return ( + <> + + + + {TYPE_SCALE.map(({ id, tag: Tag, cls, example }) => ( + + + {example} + {cls} + + + ))} + + > + ); +} diff --git a/frontend/src/designSystemMain.tsx b/frontend/src/designSystemMain.tsx new file mode 100644 index 0000000..3a71b9d --- /dev/null +++ b/frontend/src/designSystemMain.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import './styles/index.css'; +import { DesignSystemApp } from './design-system/DesignSystemApp'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/frontend/src/features/admin/AccessControl.tsx b/frontend/src/features/admin/AccessControl.tsx index bfa4716..67e5013 100644 --- a/frontend/src/features/admin/AccessControl.tsx +++ b/frontend/src/features/admin/AccessControl.tsx @@ -2,6 +2,8 @@ import { useEffect, useState, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { Button } from '@shared/components/ui/button'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { Shield, Users, UserCheck, Key, Lock, FileText, Loader2, RefreshCw, UserCog, @@ -18,6 +20,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 +36,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 +120,7 @@ export function AccessControl() { return ( {list.map((r) => ( - {r} + {r} ))} ); @@ -144,7 +129,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', @@ -191,8 +176,7 @@ export function AccessControl() { { key: 'result', label: '결과', width: '70px', sortable: true, render: (v) => { const r = v as string; - const c = r === 'SUCCESS' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'; - return {r || '-'}; + return {r || '-'}; }, }, { key: 'failReason', label: '실패 사유', @@ -200,33 +184,36 @@ export function AccessControl() { ], []); return ( - - - - - - {t('accessControl.title')} - - {t('accessControl.desc')} - - - {userStats && ( - - - 활성 {userStats.active}명 - | - 잠금 {userStats.locked} - | - 총 {userStats.total} - - )} - { if (tab === 'users') loadUsers(); else if (tab === 'audit') loadAudit(); }} - className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침"> - - - - + + + {userStats && ( + + + 활성 {userStats.active}명 + | + 잠금 {userStats.locked} + | + 총 {userStats.total} + + )} + { if (tab === 'users') loadUsers(); else if (tab === 'audit') loadAudit(); }} + title="새로고침" + icon={} + > + 새로고침 + + > + } + /> {/* 탭 */} @@ -241,7 +228,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' }`} > @@ -371,7 +358,7 @@ export function AccessControl() { ]} /> )} - + ); } diff --git a/frontend/src/features/admin/AccessLogs.tsx b/frontend/src/features/admin/AccessLogs.tsx index e660abe..6e367c3 100644 --- a/frontend/src/features/admin/AccessLogs.tsx +++ b/frontend/src/features/admin/AccessLogs.tsx @@ -1,9 +1,12 @@ import { useEffect, useState, useCallback } from 'react'; -import { Loader2, RefreshCw } from 'lucide-react'; +import { Loader2, RefreshCw, Activity } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { Button } from '@shared/components/ui/button'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { fetchAccessLogs, fetchAccessStats, type AccessLog, type AccessStats } from '@/services/adminApi'; import { formatDateTime } from '@shared/utils/dateFormat'; +import { getHttpStatusIntent } from '@shared/constants/httpStatusCodes'; /** * 접근 이력 조회 + 메트릭 카드. @@ -30,22 +33,19 @@ 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 ( - - - - 접근 이력 - AccessLogFilter가 모든 HTTP 요청 비동기 기록 - - - 새로고침 - - + + }> + 새로고침 + + } + /> {stats && ( @@ -113,7 +113,7 @@ export function AccessLogs() { {it.httpMethod} {it.requestPath} - {it.statusCode} + {it.statusCode} {it.durationMs} {it.ipAddress || '-'} @@ -124,7 +124,7 @@ export function AccessLogs() { )} - + ); } diff --git a/frontend/src/features/admin/AdminPanel.tsx b/frontend/src/features/admin/AdminPanel.tsx index 6a66e06..e83874c 100644 --- a/frontend/src/features/admin/AdminPanel.tsx +++ b/frontend/src/features/admin/AdminPanel.tsx @@ -1,6 +1,9 @@ import { useTranslation } from 'react-i18next'; import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; -import { Settings, Server, HardDrive, Shield, Clock, Database } from 'lucide-react'; +import { Badge } from '@shared/components/ui/badge'; +import { PageContainer, PageHeader } from '@shared/components/layout'; +import { getStatusIntent } from '@shared/constants/statusIntent'; +import { Settings, Server, Shield, Database } from 'lucide-react'; /* * 시스템 관리 — 서버 상태, 디스크, 보안 설정 등 인프라 관리 @@ -30,17 +33,13 @@ function UsageBar({ value }: { value: number }) { export function AdminPanel() { const { t } = useTranslation('admin'); return ( - - - - - {t('adminPanel.title')} - - ⚠데모 데이터 (백엔드 API 미구현) - - - {t('adminPanel.desc')} - + + {/* 서버 상태 */} @@ -52,9 +51,7 @@ export function AdminPanel() { {s.name} - {s.status} + {s.status} CPU @@ -89,6 +86,6 @@ export function AdminPanel() { - + ); } diff --git a/frontend/src/features/admin/AuditLogs.tsx b/frontend/src/features/admin/AuditLogs.tsx index c427527..08d9625 100644 --- a/frontend/src/features/admin/AuditLogs.tsx +++ b/frontend/src/features/admin/AuditLogs.tsx @@ -1,7 +1,9 @@ import { useEffect, useState, useCallback } from 'react'; -import { Loader2, RefreshCw } from 'lucide-react'; +import { Loader2, RefreshCw, FileSearch } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { Button } from '@shared/components/ui/button'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { fetchAuditLogs, fetchAuditStats, type AuditLog, type AuditStats } from '@/services/adminApi'; import { formatDateTime } from '@shared/utils/dateFormat'; @@ -31,16 +33,18 @@ export function AuditLogs() { useEffect(() => { load(); }, [load]); return ( - - - - 감사 로그 - @Auditable AOP가 모든 운영자 의사결정 자동 기록 - - - 새로고침 - - + + }> + 새로고침 + + } + /> {/* 통계 카드 */} {stats && ( @@ -99,7 +103,7 @@ export function AuditLogs() { {it.actionCd} {it.resourceType ?? '-'} {it.resourceId ? `(${it.resourceId})` : ''} - + {it.result || '-'} @@ -115,7 +119,7 @@ export function AuditLogs() { )} - + ); } diff --git a/frontend/src/features/admin/DataHub.tsx b/frontend/src/features/admin/DataHub.tsx index de4376f..7c1b6cf 100644 --- a/frontend/src/features/admin/DataHub.tsx +++ b/frontend/src/features/admin/DataHub.tsx @@ -2,8 +2,21 @@ import { useState, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { Button } from '@shared/components/ui/button'; +import { TabBar, TabButton } from '@shared/components/ui/tabs'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { SaveButton } from '@shared/components/common/SaveButton'; +import { getConnectionStatusHex } from '@shared/constants/connectionStatuses'; +import type { BadgeIntent } from '@lib/theme/variants'; + +/** 수집/적재 작업 상태 → BadgeIntent 매핑 (DataHub 로컬 전용) */ +function jobStatusIntent(s: string): BadgeIntent { + if (s === '수행중') return 'success'; + if (s === '대기중') return 'warning'; + if (s === '장애발생') return 'critical'; + return 'muted'; +} import { Database, RefreshCw, Calendar, Wifi, WifiOff, Radio, Activity, Server, ArrowDownToLine, Clock, AlertTriangle, @@ -43,11 +56,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 +120,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 +138,7 @@ const channelColumns: DataColumn[] = [ const on = v === 'ON'; return ( - + {v as string} {row.lastUpdate && ( @@ -163,7 +172,7 @@ function SignalTimeline({ source }: { source: SignalSource }) { ))} @@ -209,18 +218,14 @@ const collectColumns: DataColumn[] = [ { key: 'serverType', label: '타입', width: '60px', align: 'center', sortable: true, render: (v) => { const t = v as string; - const c = t === 'SQL' ? 'bg-blue-500/20 text-blue-400' : t === 'FILE' ? 'bg-green-500/20 text-green-400' : 'bg-purple-500/20 text-purple-400'; - return {t}; + const intent: BadgeIntent = t === 'SQL' ? 'info' : t === 'FILE' ? 'success' : 'purple'; + return {t}; }, }, { key: 'serverName', label: '서버명', width: '120px', render: (v) => {v as string} }, { key: 'serverIp', label: 'IP', width: '120px', render: (v) => {v as string} }, { key: 'status', label: '상태', width: '80px', align: 'center', sortable: true, - render: (v) => { - const s = v as JobStatus; - const c = s === '수행중' ? 'bg-green-500/20 text-green-400' : s === '대기중' ? 'bg-yellow-500/20 text-yellow-400' : s === '장애발생' ? 'bg-red-500/20 text-red-400' : 'bg-muted text-muted-foreground'; - return {s}; - }, + render: (v) => {v as string}, }, { key: 'schedule', label: '스케줄', width: '80px' }, { key: 'lastRun', label: '최종 수행', width: '140px', sortable: true, render: (v) => {v as string} }, @@ -235,12 +240,12 @@ const collectColumns: DataColumn[] = [ render: (_v, row) => ( {row.status === '정지' ? ( - + ) : row.status !== '장애발생' ? ( - + ) : null} - - + + ), }, @@ -274,15 +279,11 @@ 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, - render: (v) => { - const s = v as JobStatus; - const c = s === '수행중' ? 'bg-green-500/20 text-green-400' : s === '대기중' ? 'bg-yellow-500/20 text-yellow-400' : s === '장애발생' ? 'bg-red-500/20 text-red-400' : 'bg-muted text-muted-foreground'; - return {s}; - }, + render: (v) => {v as string}, }, { key: 'schedule', label: '스케줄', width: '80px' }, { key: 'lastRun', label: '최종 적재', width: '140px', sortable: true, render: (v) => {v as string} }, @@ -290,9 +291,9 @@ const loadColumns: DataColumn[] = [ { key: 'id', label: '', width: '70px', align: 'center', sortable: false, render: () => ( - - - + + + ), }, @@ -383,28 +384,19 @@ export function DataHub() { ); return ( - - {/* 헤더 */} - - - - - {t('dataHub.title')} - - ⚠데모 데이터 (백엔드 API 미구현) - - - - {t('dataHub.desc')} - - - - - + + }> 새로고침 - - - + + } + /> {/* KPI */} @@ -427,7 +419,7 @@ export function DataHub() { {/* 탭 */} - + {[ { key: 'signal' as Tab, icon: Activity, label: '선박신호 수신 현황' }, { key: 'monitor' as Tab, icon: Server, label: '선박위치정보 모니터링' }, @@ -435,18 +427,17 @@ export function DataHub() { { key: 'load' as Tab, icon: HardDrive, label: '적재 작업 관리' }, { key: 'agents' as Tab, icon: Network, label: '연계서버 모니터링' }, ].map((t) => ( - 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' - }`} + icon={} > - {t.label} - + ))} - + {/* ── ① 선박신호 수신 현황 ── */} {tab === 'signal' && ( @@ -458,16 +449,16 @@ export function DataHub() { setSelectedDate(e.target.value)} className="bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-1.5 text-[11px] text-heading focus:outline-none focus:border-cyan-500/50" /> - - + }> 새로고침 - + @@ -536,17 +527,15 @@ export function DataHub() { {/* 상태 필터 */} {(['', 'ON', 'OFF'] as const).map((f) => ( - setStatusFilter(f)} - className={`px-2.5 py-1 rounded text-[10px] transition-colors ${ - statusFilter === f - ? 'bg-cyan-600 text-heading font-bold' - : 'text-hint hover:bg-surface-overlay hover:text-label' - }`} + className="px-2.5 py-1 text-[10px]" > {f || '전체'} - + ))} @@ -569,19 +558,21 @@ export function DataHub() { 서버 타입: {(['', 'SQL', 'FILE', 'FTP'] as const).map((f) => ( - setCollectTypeFilter(f)} - className={`px-2.5 py-1 rounded text-[10px] transition-colors ${collectTypeFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`} - >{f || '전체'} + setCollectTypeFilter(f)} className="px-2.5 py-1 text-[10px]"> + {f || '전체'} + ))} 상태: {(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => ( - setCollectStatusFilter(f)} - className={`px-2.5 py-1 rounded text-[10px] transition-colors ${collectStatusFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`} - >{f || '전체'} + setCollectStatusFilter(f)} className="px-2.5 py-1 text-[10px]"> + {f || '전체'} + ))} - - 작업 등록 - + + }> + 작업 등록 + + @@ -594,17 +585,17 @@ export function DataHub() { 상태: {(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => ( - setLoadStatusFilter(f)} - className={`px-2.5 py-1 rounded text-[10px] transition-colors ${loadStatusFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`} - >{f || '전체'} + setLoadStatusFilter(f)} className="px-2.5 py-1 text-[10px]"> + {f || '전체'} + ))} - - 스토리지 관리 - - - 작업 등록 - + }> + 스토리지 관리 + + }> + 작업 등록 + 종류: {(['', '수집', '적재'] as const).map((f) => ( - setAgentRoleFilter(f)} - className={`px-2.5 py-1 rounded text-[10px] transition-colors ${agentRoleFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`} - >{f || '전체'} + setAgentRoleFilter(f)} className="px-2.5 py-1 text-[10px]"> + {f || '전체'} + ))} 상태: {(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => ( - setAgentStatusFilter(f)} - className={`px-2.5 py-1 rounded text-[10px] transition-colors ${agentStatusFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`} - >{f || '전체'} + setAgentStatusFilter(f)} className="px-2.5 py-1 text-[10px]"> + {f || '전체'} + ))} - - 새로고침 - + + }> + 새로고침 + + {/* 연계서버 카드 그리드 */} {filteredAgents.map((agent) => { - const stColor = agent.status === '수행중' ? 'text-green-400 bg-green-500/15' : agent.status === '대기중' ? 'text-yellow-400 bg-yellow-500/15' : agent.status === '장애발생' ? 'text-red-400 bg-red-500/15' : 'text-muted-foreground bg-muted'; return ( @@ -645,7 +637,7 @@ export function DataHub() { {agent.name} {agent.id} · {agent.role}에이전트 - {agent.status} + {agent.status} Hostname{agent.hostname} @@ -661,9 +653,9 @@ export function DataHub() { 작업 {agent.taskCount}건 · heartbeat {agent.lastHeartbeat.slice(11)} - - - + + + @@ -673,6 +665,6 @@ export function DataHub() { )} - + ); } diff --git a/frontend/src/features/admin/LoginHistoryView.tsx b/frontend/src/features/admin/LoginHistoryView.tsx index 0fc75ed..0578536 100644 --- a/frontend/src/features/admin/LoginHistoryView.tsx +++ b/frontend/src/features/admin/LoginHistoryView.tsx @@ -1,15 +1,22 @@ import { useEffect, useState, useCallback } from 'react'; -import { Loader2, RefreshCw } from 'lucide-react'; +import { Loader2, RefreshCw, LogIn } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { Button } from '@shared/components/ui/button'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { fetchLoginHistory, fetchLoginStats, type LoginHistory, type LoginStats } from '@/services/adminApi'; import { formatDateTime, formatDate } from '@shared/utils/dateFormat'; +import { getLoginResultIntent, getLoginResultLabel } from '@shared/constants/loginResultStatuses'; +import { useSettingsStore } from '@stores/settingsStore'; +import { useTranslation } from 'react-i18next'; /** * 로그인 이력 조회 + 메트릭 카드. * 권한: admin:login-history (READ) */ export function LoginHistoryView() { + const { t: tc } = useTranslation('common'); + const lang = useSettingsStore((s) => s.language); const [items, setItems] = useState([]); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(false); @@ -30,23 +37,19 @@ export function LoginHistoryView() { useEffect(() => { load(); }, [load]); - const resultColor = (r: string) => { - if (r === 'SUCCESS') return 'bg-green-500/20 text-green-400'; - if (r === 'LOCKED') return 'bg-red-500/20 text-red-400'; - return 'bg-orange-500/20 text-orange-400'; - }; - return ( - - - - 로그인 이력 - 성공/실패 로그인 시도 기록 (5회 실패 시 자동 잠금) - - - 새로고침 - - + + }> + 새로고침 + + } + /> {/* 통계 카드 */} {stats && ( @@ -122,7 +125,7 @@ export function LoginHistoryView() { {formatDateTime(it.loginDtm)} {it.userAcnt} - {it.result} + {getLoginResultLabel(it.result, tc, lang)} {it.failReason || '-'} {it.authProvider || '-'} @@ -134,7 +137,7 @@ export function LoginHistoryView() { )} - + ); } diff --git a/frontend/src/features/admin/NoticeManagement.tsx b/frontend/src/features/admin/NoticeManagement.tsx index 405f63a..d4b1622 100644 --- a/frontend/src/features/admin/NoticeManagement.tsx +++ b/frontend/src/features/admin/NoticeManagement.tsx @@ -2,6 +2,9 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { Button } from '@shared/components/ui/button'; +import { PageContainer, PageHeader } from '@shared/components/layout'; +import type { BadgeIntent } from '@lib/theme/variants'; import { Bell, Plus, Edit2, Trash2, Eye, EyeOff, Calendar, Users, Megaphone, AlertTriangle, Info, Search, Filter, @@ -123,10 +126,10 @@ export function NoticeManagement() { })); }; - const getStatus = (n: SystemNotice) => { - if (n.endDate < now) return { label: '종료', color: 'bg-muted text-muted-foreground' }; - if (n.startDate > now) return { label: '예약', color: 'bg-blue-500/20 text-blue-400' }; - return { label: '노출 중', color: 'bg-green-500/20 text-green-400' }; + const getStatus = (n: SystemNotice): { label: string; intent: BadgeIntent } => { + if (n.endDate < now) return { label: '종료', intent: 'muted' }; + if (n.startDate > now) return { label: '예약', intent: 'info' }; + return { label: '노출 중', intent: 'success' }; }; // KPI @@ -135,29 +138,19 @@ export function NoticeManagement() { const urgentCount = notices.filter((n) => n.type === 'urgent' && n.startDate <= now && n.endDate >= now).length; return ( - - {/* 헤더 */} - - - - - {t('notices.title')} - - ⚠데모 데이터 (백엔드 API 미구현) - - - - {t('notices.desc')} - - - - - 새 알림 등록 - - + + }> + 새 알림 등록 + + } + /> {/* KPI — 가로 한 줄 */} @@ -211,7 +204,7 @@ export function NoticeManagement() { return ( - {status.label} + {status.label} @@ -244,10 +237,10 @@ export function NoticeManagement() { - openEdit(n)} className="p-1 text-hint hover:text-blue-400" title="수정"> + openEdit(n)} className="p-1 text-hint hover:text-blue-400" title="수정"> - handleDelete(n.id)} className="p-1 text-hint hover:text-red-400" title="삭제"> + handleDelete(n.id)} className="p-1 text-hint hover:text-red-400" title="삭제"> @@ -268,7 +261,7 @@ export function NoticeManagement() { {editingId ? '알림 수정' : '새 알림 등록'} - setShowForm(false)} className="text-hint hover:text-heading"> + setShowForm(false)} className="text-hint hover:text-heading"> @@ -278,6 +271,7 @@ export function NoticeManagement() { 제목 setForm({ ...form, title: e.target.value })} className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/50" @@ -289,6 +283,7 @@ export function NoticeManagement() { 내용 setForm({ ...form, message: e.target.value })} rows={3} @@ -303,7 +298,7 @@ export function NoticeManagement() { 알림 유형 {TYPE_OPTIONS.map((opt) => ( - setForm({ ...form, type: opt.key })} className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-[10px] transition-colors ${ @@ -322,7 +317,7 @@ export function NoticeManagement() { 표시 방식 {DISPLAY_OPTIONS.map((opt) => ( - setForm({ ...form, display: opt.key })} className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-[10px] transition-colors ${ @@ -344,6 +339,7 @@ export function NoticeManagement() { 시작일 setForm({ ...form, startDate: e.target.value })} @@ -353,6 +349,7 @@ export function NoticeManagement() { 종료일 setForm({ ...form, endDate: e.target.value })} @@ -368,7 +365,7 @@ export function NoticeManagement() { {ROLE_OPTIONS.map((role) => ( - toggleRole(role)} className={`px-3 py-1.5 rounded-lg text-[10px] transition-colors ${ @@ -408,7 +405,7 @@ export function NoticeManagement() { {/* 하단 버튼 */} - setShowForm(false)} className="px-4 py-1.5 text-[11px] text-muted-foreground hover:text-heading transition-colors" > @@ -423,6 +420,6 @@ export function NoticeManagement() { )} - + ); } diff --git a/frontend/src/features/admin/PermissionsPanel.tsx b/frontend/src/features/admin/PermissionsPanel.tsx index 8c5947b..4976f62 100644 --- a/frontend/src/features/admin/PermissionsPanel.tsx +++ b/frontend/src/features/admin/PermissionsPanel.tsx @@ -4,6 +4,7 @@ import { } from 'lucide-react'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { Button } from '@shared/components/ui/button'; import { fetchRoles, fetchPermTree, createRole, deleteRole, updateRolePermissions, type RoleWithPermissions, type PermTreeNode, type PermEntry, @@ -13,6 +14,9 @@ import { type Operation, type TreeNode, type PermRow, } from '@/lib/permission/permResolver'; import { useAuth } from '@/app/auth/AuthContext'; +import { getRoleBadgeStyle, ROLE_DEFAULT_PALETTE } from '@shared/constants/userRoles'; +import { ColorPicker } from '@shared/components/common/ColorPicker'; +import { updateRole as apiUpdateRole } from '@/services/adminApi'; /** * 트리 기반 권한 관리 패널 (wing 패턴). @@ -34,14 +38,6 @@ import { useAuth } from '@/app/auth/AuthContext'; * - admin:permission-management (UPDATE): 권한 매트릭스 갱신 */ -const ROLE_COLORS: Record = { - ADMIN: 'bg-red-500/20 text-red-400 border-red-500/30', - OPERATOR: 'bg-blue-500/20 text-blue-400 border-blue-500/30', - ANALYST: 'bg-purple-500/20 text-purple-400 border-purple-500/30', - FIELD: 'bg-green-500/20 text-green-400 border-green-500/30', - VIEWER: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', -}; - type DraftPerms = Map; // null = 명시 권한 제거 function makeKey(rsrcCd: string, operCd: string) { return `${rsrcCd}::${operCd}`; } @@ -65,6 +61,8 @@ export function PermissionsPanel() { const [showCreate, setShowCreate] = useState(false); const [newRoleCd, setNewRoleCd] = useState(''); const [newRoleNm, setNewRoleNm] = useState(''); + const [newRoleColor, setNewRoleColor] = useState(ROLE_DEFAULT_PALETTE[0]); + const [editingColor, setEditingColor] = useState(null); const load = useCallback(async () => { setLoading(true); setError(''); @@ -233,15 +231,26 @@ export function PermissionsPanel() { const handleCreateRole = async () => { if (!newRoleCd || !newRoleNm) return; try { - await createRole({ roleCd: newRoleCd, roleNm: newRoleNm }); + await createRole({ roleCd: newRoleCd, roleNm: newRoleNm, colorHex: newRoleColor }); setShowCreate(false); setNewRoleCd(''); setNewRoleNm(''); + setNewRoleColor(ROLE_DEFAULT_PALETTE[0]); await load(); } catch (e: unknown) { alert('생성 실패: ' + (e instanceof Error ? e.message : 'unknown')); } }; + const handleUpdateColor = async (roleSn: number, hex: string) => { + try { + await apiUpdateRole(roleSn, { colorHex: hex }); + await load(); + setEditingColor(null); + } catch (e: unknown) { + alert('색상 변경 실패: ' + (e instanceof Error ? e.message : 'unknown')); + } + }; + const handleDeleteRole = async () => { if (!selectedRole) return; if (selectedRole.builtinYn === 'Y') { @@ -364,18 +373,21 @@ export function PermissionsPanel() { {showCreate && ( - - setNewRoleCd(e.target.value.toUpperCase())} + + 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)} + setNewRoleNm(e.target.value)} placeholder="역할 이름" className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" /> - - 생성 - setShowCreate(false)} - className="flex-1 py-1 bg-gray-600 hover:bg-gray-500 text-white text-[10px] rounded">취소 + + + + 생성 + + setShowCreate(false)} className="flex-1"> + 취소 + )} @@ -383,26 +395,55 @@ export function PermissionsPanel() { {roles.map((r) => { const selected = r.roleSn === selectedRoleSn; + const isEditingColor = editingColor === String(r.roleSn); return ( - setSelectedRoleSn(r.roleSn)} - className={`w-full text-left px-2 py-1.5 rounded border transition-colors ${ + className={`px-2 py-1.5 rounded border transition-colors ${ selected ? 'bg-blue-600/20 border-blue-500/40 text-heading' : 'bg-surface-overlay border-border text-muted-foreground hover:text-heading hover:bg-surface-overlay/80' }`} > - - {r.roleCd} - - {r.builtinYn === 'Y' && BUILT-IN} + setSelectedRoleSn(r.roleSn)} + className="flex items-center gap-1.5 cursor-pointer" + title="역할 선택" + > + + {r.roleCd} + + + + {r.builtinYn === 'Y' && BUILT-IN} + {canUpdatePerm && ( + setEditingColor(isEditingColor ? null : String(r.roleSn))} + className="text-[8px] text-hint hover:text-blue-400" + title="색상 변경" + > + ● + + )} + - {r.roleNm} - 권한 {r.permissions.length}건 - + setSelectedRoleSn(r.roleSn)} className="w-full text-left"> + {r.roleNm} + 권한 {r.permissions.length}건 + + {isEditingColor && ( + + handleUpdateColor(r.roleSn, hex)} + /> + + )} + ); })} @@ -426,11 +467,15 @@ export function PermissionsPanel() { {canUpdatePerm && selectedRole && ( - - {saving ? : } + : } + > 저장 {isDirty && ●} - + )} diff --git a/frontend/src/features/admin/SystemConfig.tsx b/frontend/src/features/admin/SystemConfig.tsx index 3104293..5e2138a 100644 --- a/frontend/src/features/admin/SystemConfig.tsx +++ b/frontend/src/features/admin/SystemConfig.tsx @@ -2,6 +2,9 @@ import { useState, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { Button } from '@shared/components/ui/button'; +import { PageContainer, PageHeader } from '@shared/components/layout'; +import type { BadgeIntent } from '@lib/theme/variants'; import { Settings, Database, Search, ChevronDown, ChevronRight, Map, Fish, Anchor, Ship, Globe, BarChart3, Download, @@ -143,32 +146,24 @@ export function SystemConfig() { : []; return ( - - {/* 헤더 */} - - - - - {t('systemConfig.title')} - - ⚠데모 데이터 (백엔드 API 미구현) - - - - {t('systemConfig.desc')} - - - - - - 내보내기 - - - - 코드 동기화 - - - + + + }> + 내보내기 + + }> + 코드 동기화 + + > + } + /> {/* KPI 카드 */} @@ -199,11 +194,11 @@ export function SystemConfig() { {/* 탭 */} {TAB_ITEMS.map((t) => ( - 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' }`} > @@ -223,6 +218,7 @@ export function SystemConfig() { { setQuery(e.target.value); setPage(0); }} placeholder={ @@ -237,6 +233,7 @@ export function SystemConfig() { { setMajorFilter(e.target.value); setPage(0); }} className="bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-label focus:outline-none focus:border-cyan-500/50" @@ -275,13 +272,10 @@ export function SystemConfig() { {a.code} - {a.major} + {(() => { + const intent: BadgeIntent = a.major === '서해' ? 'info' : a.major === '남해' ? 'success' : a.major === '동해' ? 'purple' : a.major === '제주' ? 'high' : 'cyan'; + return {a.major}; + })()} {a.mid} {a.name} @@ -321,7 +315,7 @@ export function SystemConfig() { > {s.code} - {s.major} + {s.major} {s.mid} {s.name} @@ -365,25 +359,16 @@ export function SystemConfig() { {f.code} - {f.major} + {(() => { + const intent: BadgeIntent = f.major === '근해어업' ? 'info' : f.major === '연안어업' ? 'success' : f.major === '양식어업' ? 'cyan' : f.major === '원양어업' ? 'purple' : f.major === '구획어업' ? 'high' : f.major === '마을어업' ? 'warning' : 'muted'; + return {f.major}; + })()} {f.mid} {f.name} {f.target} - {f.permit} + {f.permit} {f.law} @@ -416,15 +401,10 @@ export function SystemConfig() { {v.code} - {v.major} + {(() => { + const intent: BadgeIntent = v.major === '어선' ? 'info' : v.major === '여객선' ? 'success' : v.major === '화물선' ? 'high' : v.major === '유조선' ? 'critical' : v.major === '관공선' ? 'purple' : v.major === '함정' ? 'cyan' : 'muted'; + return {v.major}; + })()} {v.mid} {v.name} @@ -451,7 +431,7 @@ export function SystemConfig() { {/* 페이지네이션 (코드 탭에서만) */} {tab !== 'settings' && totalPages > 1 && ( - setPage(Math.max(0, page - 1))} disabled={page === 0} className="px-3 py-1.5 rounded-lg text-[11px] bg-surface-overlay border border-border text-muted-foreground hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed transition-colors" @@ -461,7 +441,7 @@ export function SystemConfig() { {page + 1} / {totalPages} 페이지 ({totalItems.toLocaleString()}건) - setPage(Math.min(totalPages - 1, page + 1))} disabled={page >= totalPages - 1} className="px-3 py-1.5 rounded-lg text-[11px] bg-surface-overlay border border-border text-muted-foreground hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed transition-colors" @@ -505,6 +485,6 @@ export function SystemConfig() { })} )} - + ); } diff --git a/frontend/src/features/admin/UserRoleAssignDialog.tsx b/frontend/src/features/admin/UserRoleAssignDialog.tsx index 4414d3d..36b5e90 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; @@ -67,7 +60,7 @@ export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) { {user.userAcnt} ({user.userNm}) - 다중 역할 가능 (OR 합집합) - + @@ -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..a3f6b29 100644 --- a/frontend/src/features/ai-operations/AIAssistant.tsx +++ b/frontend/src/features/ai-operations/AIAssistant.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 { PageContainer, PageHeader } from '@shared/components/layout'; import { MessageSquare, Send, Bot, User, BookOpen, Shield, AlertTriangle, FileText, ExternalLink } from 'lucide-react'; import { sendChatMessage } from '@/services/chatApi'; @@ -75,11 +76,13 @@ export function AIAssistant() { }; return ( - - - {t('assistant.title')} - {t('assistant.desc')} - + + {/* 대화 이력 사이드바 */} @@ -138,18 +141,19 @@ export function AIAssistant() { {/* 입력창 */} setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSend()} placeholder="질의를 입력하세요... (법령, 단속 절차, AI 분석 결과 등)" className="flex-1 bg-surface-overlay border border-slate-700/50 rounded-xl px-4 py-2.5 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-green-500/50" /> - + - + ); } diff --git a/frontend/src/features/ai-operations/AIModelManagement.tsx b/frontend/src/features/ai-operations/AIModelManagement.tsx index 56898d9..c28ce2b 100644 --- a/frontend/src/features/ai-operations/AIModelManagement.tsx +++ b/frontend/src/features/ai-operations/AIModelManagement.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { Brain, Settings, Zap, Activity, TrendingUp, BarChart3, @@ -11,6 +12,10 @@ 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 { getStatusIntent } from '@shared/constants/statusIntent'; +import type { BadgeIntent } from '@lib/theme/variants'; +import { useSettingsStore } from '@stores/settingsStore'; /* * SFR-04: AI 불법조업 예측 모델 관리 @@ -56,8 +61,7 @@ const modelColumns: DataColumn[] = [ { key: 'status', label: '상태', width: '70px', align: 'center', sortable: true, render: (v) => { const s = v as string; - const c = s === '운영중' ? 'bg-green-500/20 text-green-400' : s === '테스트' ? 'bg-blue-500/20 text-blue-400' : s === '대기' ? 'bg-yellow-500/20 text-yellow-400' : 'bg-muted text-hint'; - return {s}; + return {s}; }, }, { key: 'accuracy', label: 'Accuracy', width: '80px', align: 'right', sortable: true, render: (v) => {v as number}% }, @@ -176,8 +180,8 @@ const gearColumns: DataColumn[] = [ { key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true, render: (v) => { const r = v as string; - const c = r === '고위험' ? 'bg-red-500/20 text-red-400' : r === '중위험' ? 'bg-yellow-500/20 text-yellow-400' : 'bg-green-500/20 text-green-400'; - return {r}; + const intent: BadgeIntent = r === '고위험' ? 'critical' : r === '중위험' ? 'warning' : 'success'; + return {r}; }, }, { key: 'speed', label: '탐지 속도', width: '90px', align: 'center', render: (v) => {v as string} }, @@ -237,6 +241,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); @@ -244,29 +250,21 @@ export function AIModelManagement() { const currentModel = MODELS.find((m) => m.status === '운영중')!; return ( - - {/* 헤더 */} - - - - - {t('modelManagement.title')} - - ⚠데모 데이터 (백엔드 API 미구현) - - - - {t('modelManagement.desc')} - - - + + 운영 모델: {currentModel.version} Accuracy {currentModel.accuracy}% - - + } + /> {/* KPI */} @@ -300,8 +298,8 @@ export function AIModelManagement() { { key: 'engines' as Tab, icon: Shield, label: '7대 탐지 엔진' }, { key: 'api' as Tab, icon: Globe, label: '예측 결과 API' }, ].map((t) => ( - setTab(t.key)} - className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs transition-colors ${tab === t.key ? 'bg-purple-600 text-heading' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'}`}> + setTab(t.key)} + className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs transition-colors ${tab === t.key ? 'bg-purple-600 text-on-vivid' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'}`}> {t.label} ))} @@ -319,7 +317,7 @@ export function AIModelManagement() { 정확도 93.2% (+3.1%) · 오탐률 7.8% (-2.1%) · 다크베셀 탐지 강화 - 운영 배포 + 운영 배포 @@ -333,14 +331,14 @@ export function AIModelManagement() { {rules.map((rule, i) => ( - toggleRule(i)} + toggleRule(i)} className={`w-10 h-5 rounded-full transition-colors relative shrink-0 ${rule.enabled ? 'bg-blue-600' : 'bg-switch-background'}`}> {rule.name} - {rule.model} + {rule.model} {rule.desc} @@ -628,8 +626,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 ( @@ -641,7 +637,7 @@ export function AIModelManagement() { {eng.name} - {eng.status} + {eng.status} {eng.purpose} {eng.detail} @@ -654,7 +650,9 @@ export function AIModelManagement() { 심각도 - {eng.severity} + + {getEngineSeverityLabel(eng.severity, tcCommon, lang)} + 쿨다운 @@ -852,14 +850,14 @@ export function AIModelManagement() { ].map((api, i) => ( - {api.method} + {api.method} {api.endpoint} {api.unit} {api.desc} {api.sfr} - {api.status} + {api.status} ))} @@ -882,7 +880,7 @@ export function AIModelManagement() { 격자별 위험도 조회 (파라미터: 좌표 범위, 시간) - navigator.clipboard.writeText('GET /api/v1/predictions/grid?lat_min=36.0&lat_max=38.0&lon_min=124.0&lon_max=126.0&time=2026-04-03T09:00Z')} className="text-hint hover:text-muted-foreground"> + navigator.clipboard.writeText('GET /api/v1/predictions/grid?lat_min=36.0&lat_max=38.0&lon_min=124.0&lon_max=126.0&time=2026-04-03T09:00Z')} className="text-hint hover:text-muted-foreground"> {`GET /api/v1/predictions/grid @@ -948,7 +946,7 @@ export function AIModelManagement() { ].map((s) => ( - {s.sfr} + {s.sfr} {s.name} {s.desc} @@ -989,6 +987,6 @@ export function AIModelManagement() { )} - + ); } diff --git a/frontend/src/features/ai-operations/MLOpsPage.tsx b/frontend/src/features/ai-operations/MLOpsPage.tsx index 5978dde..5b574bf 100644 --- a/frontend/src/features/ai-operations/MLOpsPage.tsx +++ b/frontend/src/features/ai-operations/MLOpsPage.tsx @@ -2,6 +2,9 @@ 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 { PageContainer, PageHeader } from '@shared/components/layout'; +import { getModelStatusIntent, getQualityGateIntent, getExperimentIntent, MODEL_STATUSES, QUALITY_GATE_STATUSES, EXPERIMENT_STATUSES } from '@shared/constants/modelDeploymentStatuses'; +import { getStatusIntent } from '@shared/constants/statusIntent'; import { Cpu, Brain, Database, GitBranch, Activity, RefreshCw, Server, Shield, FileText, Settings, Layers, Globe, Lock, BarChart3, Code, Play, Square, @@ -109,23 +112,15 @@ 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 ( - - - - - {t('mlops.title')} - - ⚠데모 데이터 (백엔드 API 미구현) - - - {t('mlops.desc')} - - + + {/* 탭 */} @@ -138,7 +133,7 @@ export function MLOpsPage() { { key: 'llmops' as Tab, icon: Brain, label: 'LLMOps' }, { key: 'platform' as Tab, icon: Settings, label: '플랫폼 관리' }, ]).map(t => ( - setTab(t.key)} + setTab(t.key)} className={`flex items-center gap-1.5 px-4 py-2.5 text-[11px] font-medium border-b-2 transition-colors ${tab === t.key ? 'text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}> {t.label} @@ -161,7 +156,7 @@ export function MLOpsPage() { 배포 모델 현황 {MODELS.filter(m => m.status === 'DEPLOYED').map(m => ( - DEPLOYED + DEPLOYED {m.name} {m.ver} F1 {m.f1}% @@ -172,7 +167,7 @@ export function MLOpsPage() { 진행 중 실험 {EXPERIMENTS.filter(e => e.status === 'running').map(e => ( - 실행중 + 실행중 {e.name} {e.progress}% @@ -202,14 +197,14 @@ export function MLOpsPage() { 실험 목록 - 새 실험 + 새 실험 {EXPERIMENTS.map(e => ( {e.id} {e.name} - {e.status} + {e.status} {e.epoch} {e.time} @@ -228,7 +223,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 +240,7 @@ export function MLOpsPage() { Quality Gates {m.gates.map((g, i) => ( - {g} + {g} ))} @@ -272,7 +267,7 @@ export function MLOpsPage() { {d.latency} {d.falseAlarm} {d.rps} - {d.status} + {d.status} {d.date} ))} @@ -283,8 +278,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 +288,7 @@ export function MLOpsPage() { {MODELS.filter(m => m.status === 'APPROVED').map(m => ( {m.name} {m.ver} - 배포 + 배포 ))} @@ -307,7 +302,7 @@ export function MLOpsPage() { REQUEST BODY (JSON) - - 실행 - 초기화 + 실행 + 초기화 @@ -357,7 +352,7 @@ export function MLOpsPage() { { key: 'worker' as LLMSubTab, label: '배포 워커' }, { key: 'llmtest' as LLMSubTab, label: 'LLM 테스트' }, ]).map(t => ( - setLlmSub(t.key)} + setLlmSub(t.key)} className={`px-4 py-2 text-[11px] font-medium border-b-2 ${llmSub === t.key ? 'text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}>{t.label} ))} @@ -386,7 +381,7 @@ export function MLOpsPage() { {k}{v} ))} - 학습 시작 + 학습 시작 @@ -396,7 +391,7 @@ export function MLOpsPage() { {j.id} {j.model} - {j.status} + {j.status} {j.elapsed} @@ -422,7 +417,7 @@ export function MLOpsPage() { {k}{v} ))} - 검색 시작 + 검색 시작 HPS 시도 결과Best: Trial #3 (F1=0.912) @@ -436,7 +431,7 @@ export function MLOpsPage() { {t.dropout} {t.hidden} {t.f1.toFixed(3)} - {t.best && BEST} + {t.best && BEST} ))} @@ -503,14 +498,14 @@ export function MLOpsPage() { 2. **최종 위치**: EEZ/NLL 경계 5NM 이내 여부 3. **과거 이력**: MMSI 변조, 이전 단속 기록 확인 - 배타적경제수역법 §5 - 한중어업협정 §6 + 배타적경제수역법 §5 + 한중어업협정 §6 - - + + @@ -565,6 +560,6 @@ export function MLOpsPage() { )} - + ); } diff --git a/frontend/src/features/auth/LoginPage.tsx b/frontend/src/features/auth/LoginPage.tsx index a8d7f8b..6f488fe 100644 --- a/frontend/src/features/auth/LoginPage.tsx +++ b/frontend/src/features/auth/LoginPage.tsx @@ -142,6 +142,7 @@ export function LoginPage() { setUserId(e.target.value)} @@ -157,6 +158,7 @@ export function LoginPage() { setPassword(e.target.value)} @@ -195,7 +197,7 @@ export function LoginPage() { {loading ? ( <> diff --git a/frontend/src/features/dashboard/Dashboard.tsx b/frontend/src/features/dashboard/Dashboard.tsx index 33e1a64..98f3cd7 100644 --- a/frontend/src/features/dashboard/Dashboard.tsx +++ b/frontend/src/features/dashboard/Dashboard.tsx @@ -11,6 +11,7 @@ import { import type { LucideIcon } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { PageContainer } from '@shared/components/layout'; import { AreaChart, PieChart } from '@lib/charts'; import { useKpiStore } from '@stores/kpiStore'; import { useEventStore } from '@stores/eventStore'; @@ -22,45 +23,13 @@ import { type PredictionStatsDaily, type PredictionStatsHourly, } from '@/services/kpi'; -import { toDateParam } from '@shared/utils/dateFormat'; +import { toDateParam, formatDate, formatTime } from '@shared/utils/dateFormat'; +import { getViolationColor, getViolationLabel } from '@shared/constants/violationTypes'; +import { ALERT_LEVELS, type AlertLevel, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels'; +import { getPatrolStatusIntent, getPatrolStatusLabel } from '@shared/constants/patrolStatuses'; +import { getKpiUi } from '@shared/constants/kpiUiMap'; +import { useSettingsStore } from '@stores/settingsStore'; -// ─── 작전 경보 등급 ───────────────────── -type AlertLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'; -const ALERT_COLORS: Record = { - CRITICAL: { bg: 'bg-red-500/15', text: 'text-red-400', border: 'border-red-500/30', dot: 'bg-red-500' }, - HIGH: { bg: 'bg-orange-500/15', text: 'text-orange-400', border: 'border-orange-500/30', dot: 'bg-orange-500' }, - MEDIUM: { bg: 'bg-yellow-500/15', text: 'text-yellow-400', border: 'border-yellow-500/30', dot: 'bg-yellow-500' }, - LOW: { bg: 'bg-blue-500/15', text: 'text-blue-400', border: 'border-blue-500/30', dot: 'bg-blue-500' }, -}; - -// ─── KPI UI 매핑 (라벨 + kpiKey 모두 지원) ───────── -const KPI_UI_MAP: Record = { - '실시간 탐지': { 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' }, - // kpiKey 기반 매핑 (백엔드 API 응답) - realtime_detection: { icon: Radar, color: '#3b82f6' }, - eez_violation: { icon: AlertTriangle, color: '#ef4444' }, - dark_vessel: { icon: Eye, color: '#f97316' }, - illegal_transshipment: { icon: Anchor, color: '#a855f7' }, - tracking: { icon: Crosshair, color: '#06b6d4' }, - enforcement: { icon: Shield, color: '#10b981' }, -}; - - -// 위반 유형/어구 → 차트 색상 매핑 -const VESSEL_TYPE_COLORS: Record = { - 'EEZ 침범': '#ef4444', - '다크베셀': '#f97316', - '불법환적': '#a855f7', - 'MMSI변조': '#eab308', - '고속도주': '#06b6d4', - '어구 불법': '#6b7280', -}; -const DEFAULT_PIE_COLORS = ['#ef4444', '#f97316', '#a855f7', '#eab308', '#06b6d4', '#3b82f6', '#10b981', '#6b7280']; // TODO: /api/weather 연동 예정 const WEATHER_DATA = { @@ -83,9 +52,10 @@ function PulsingDot({ color }: { color: string }) { } function RiskBar({ value, size = 'default' }: { value: number; size?: 'default' | 'sm' }) { - const pct = value * 100; - const color = pct > 90 ? 'bg-red-500' : pct > 80 ? 'bg-orange-500' : pct > 70 ? 'bg-yellow-500' : 'bg-blue-500'; - const textColor = pct > 90 ? 'text-red-400' : pct > 80 ? 'text-orange-400' : pct > 70 ? 'text-yellow-400' : 'text-blue-400'; + // backend riskScore.score는 0~100 정수. 0~1 범위로 들어오는 경우도 호환. + const pct = Math.max(0, Math.min(100, value <= 1 ? value * 100 : value)); + const color = pct > 70 ? 'bg-red-500' : pct > 50 ? 'bg-orange-500' : pct > 30 ? 'bg-yellow-500' : 'bg-blue-500'; + const textColor = pct > 70 ? 'text-red-400' : pct > 50 ? 'text-orange-400' : pct > 30 ? 'text-yellow-400' : 'text-blue-400'; const barW = size === 'sm' ? 'w-16' : 'w-24'; return ( @@ -122,22 +92,27 @@ function KpiCard({ label, value, prev, icon: Icon, color, desc }: KpiCardProps) interface TimelineEvent { time: string; level: AlertLevel; title: string; detail: string; vessel: string; area: string } function TimelineItem({ event }: { event: TimelineEvent }) { - const c = ALERT_COLORS[event.level]; + const { t: tc } = useTranslation('common'); + const lang = useSettingsStore((s) => s.language); + const c = ALERT_LEVELS[event.level].classes; return ( - + - {event.time} + {formatDate(event.time)} + {formatTime(event.time)} {event.title} - {event.level} + + {getAlertLevelLabel(event.level, tc, lang)} + - {event.detail} + {event.detail} - {event.vessel} - {event.area} + {event.vessel} + {event.area} @@ -146,14 +121,13 @@ function TimelineItem({ event }: { event: TimelineEvent }) { } function PatrolStatusBadge({ status }: { status: string }) { - const styles: Record = { - '추적 중': 'bg-red-500/20 text-red-400 border-red-500/30', - '검문 중': 'bg-orange-500/20 text-orange-400 border-orange-500/30', - '초계 중': 'bg-blue-500/20 text-blue-400 border-blue-500/30', - '귀항 중': 'bg-muted text-muted-foreground border-slate-500/30', - '대기': 'bg-green-500/20 text-green-400 border-green-500/30', - }; - return {status}; + const { t: tc } = useTranslation('common'); + const lang = useSettingsStore((s) => s.language); + return ( + + {getPatrolStatusLabel(status, tc, lang)} + + ); } function FuelGauge({ percent }: { percent: number }) { @@ -276,6 +250,8 @@ const MemoSeaAreaMap = memo(SeaAreaMap); export function Dashboard() { const { t } = useTranslation('dashboard'); + const { t: tc } = useTranslation('common'); + const lang = useSettingsStore((s) => s.language); const [defconLevel] = useState(2); const kpiStore = useKpiStore(); @@ -309,7 +285,7 @@ export function Dashboard() { }, []); const KPI_DATA = useMemo(() => kpiStore.metrics.map((m) => { - const ui = KPI_UI_MAP[m.id] ?? KPI_UI_MAP[m.label] ?? { icon: Radar, color: '#3b82f6' }; + const ui = getKpiUi(m.id) ?? getKpiUi(m.label); return { label: m.label, value: m.value, @@ -321,7 +297,7 @@ export function Dashboard() { }), [kpiStore.metrics]); const TIMELINE_EVENTS: TimelineEvent[] = useMemo(() => eventStore.events.slice(0, 10).map((e) => ({ - time: e.time.includes(' ') ? e.time.split(' ')[1].slice(0, 5) : e.time, + time: e.time, level: e.level, title: e.title, detail: e.detail, @@ -370,12 +346,13 @@ export function Dashboard() { }; }), [hourlyStats]); - // 위반 유형/어구 분포: daily byGearType 우선, 없으면 byCategory + // 위반 유형 분포: daily byCategory(위반 enum) 우선, 없으면 byGearType + // 라벨/색상은 공통 카탈로그(violationTypes)에서 일괄 lookup const VESSEL_TYPE_DATA = useMemo(() => { if (dailyStats.length === 0) return [] as { name: string; value: number; color: string }[]; const totals: Record = {}; dailyStats.forEach((d) => { - const src = d.byGearType ?? d.byCategory ?? null; + const src = d.byCategory ?? d.byGearType ?? null; if (src) { Object.entries(src).forEach(([k, v]) => { totals[k] = (totals[k] ?? 0) + (Number(v) || 0); @@ -384,12 +361,12 @@ export function Dashboard() { }); return Object.entries(totals) .sort((a, b) => b[1] - a[1]) - .map(([name, value], i) => ({ - name, + .map(([code, value]) => ({ + name: getViolationLabel(code, tc, lang), value, - color: VESSEL_TYPE_COLORS[name] ?? DEFAULT_PIE_COLORS[i % DEFAULT_PIE_COLORS.length], + color: getViolationColor(code), })); - }, [dailyStats]); + }, [dailyStats, tc, lang]); // 해역별 위험도: daily byZone → 표 데이터 const AREA_RISK_DATA = useMemo(() => { @@ -417,8 +394,8 @@ export function Dashboard() { const defconLabels = ['', 'DEFCON 1', 'DEFCON 2', 'DEFCON 3', 'DEFCON 4', 'DEFCON 5']; return ( - - {/* ── 상단 헤더 바 ── */} + + {/* ── 상단 헤더 바 (DEFCON + 라이브 상태) — 커스텀 구조 유지 ── */} @@ -511,10 +488,10 @@ export function Dashboard() { 실시간 상황 타임라인 - + 긴급 {TIMELINE_EVENTS.filter(e => e.level === 'CRITICAL').length} - + 경고 {TIMELINE_EVENTS.filter(e => e.level === 'HIGH').length} @@ -538,7 +515,7 @@ export function Dashboard() { 함정 배치 현황 - + {PATROL_SHIPS.length}척 운용 중 @@ -654,12 +631,12 @@ export function Dashboard() { 고위험 선박 추적 현황 (AI 우선순위) - {TOP_RISK_VESSELS.length}척 감시 중 + {TOP_RISK_VESSELS.length}척 감시 중 {/* 테이블 헤더 */} - + # MMSI 선종 @@ -672,20 +649,20 @@ export function Dashboard() { {TOP_RISK_VESSELS.map((vessel, index) => ( #{index + 1} - 0.9 ? 'bg-red-500' : vessel.risk > 0.7 ? 'bg-orange-500' : 'bg-yellow-500'} /> + 70 ? 'bg-red-500' : vessel.risk > 50 ? 'bg-orange-500' : 'bg-yellow-500'} /> {vessel.name} {vessel.type} {vessel.zone} {vessel.activity} - {vessel.isDark && 다크} - {vessel.isSpoofing && GPS변조} - {vessel.isTransship && 전재} + {vessel.isDark && 다크} + {vessel.isSpoofing && GPS변조} + {vessel.isTransship && 전재} {!vessel.isDark && !vessel.isSpoofing && !vessel.isTransship && -} @@ -694,6 +671,6 @@ export function Dashboard() { - + ); } diff --git a/frontend/src/features/detection/ChinaFishing.tsx b/frontend/src/features/detection/ChinaFishing.tsx index 3dd7682..2ea7e8f 100644 --- a/frontend/src/features/detection/ChinaFishing.tsx +++ b/frontend/src/features/detection/ChinaFishing.tsx @@ -1,12 +1,17 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { PageContainer } from '@shared/components/layout'; import { Search, Ship, Clock, ChevronRight, ChevronLeft, Cloud, Eye, AlertTriangle, Radio, RotateCcw, MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2 } from 'lucide-react'; import { formatDateTime } from '@shared/utils/dateFormat'; +import { ALERT_LEVELS, getAlertLevelLabel, type AlertLevel } from '@shared/constants/alertLevels'; +import { getVesselRingMeta } from '@shared/constants/vesselAnalysisStatuses'; +import { useSettingsStore } from '@stores/settingsStore'; +import { useTranslation } from 'react-i18next'; import { GearIdentification } from './GearIdentification'; import { RealAllVessels, RealTransshipSuspects } from './RealVesselAnalysis'; import { PieChart as EcPieChart } from '@lib/charts'; @@ -130,12 +135,8 @@ function CircleGauge({ value, label }: { value: number; label: string }) { } function StatusRing({ status, riskPct }: { status: VesselStatus; riskPct: number }) { - const colors: Record = { - '의심': { ring: '#f97316', bg: 'bg-orange-500/10', text: 'text-orange-400' }, - '양호': { ring: '#10b981', bg: 'bg-green-500/10', text: 'text-green-400' }, - '경고': { ring: '#ef4444', bg: 'bg-red-500/10', text: 'text-red-400' }, - }; - const c = colors[status]; + const meta = getVesselRingMeta(status); + const c = { ring: meta.hex, text: `text-${meta.intent === 'critical' ? 'red' : meta.intent === 'high' ? 'orange' : 'green'}-400` }; const circumference = 2 * Math.PI * 18; const offset = circumference - (riskPct / 100) * circumference; @@ -196,6 +197,8 @@ function TransferView() { // ─── 메인 페이지 ────────────────────── export function ChinaFishing() { + const { t: tcCommon } = useTranslation('common'); + const lang = useSettingsStore((s) => s.language); const [mode, setMode] = useState<'dashboard' | 'transfer' | 'gear'>('dashboard'); const [vesselTab, setVesselTab] = useState<'특이운항' | '비허가 선박' | '제재 선박' | '관심 선박'>('특이운항'); const [statsTab, setStatsTab] = useState<'불법조업 통계' | '특이선박 통계' | '위험선박 통계'>('불법조업 통계'); @@ -285,16 +288,16 @@ export function ChinaFishing() { ]; return ( - + {/* ── 모드 탭 (AI 대시보드 / 환적 탐지) ── */} {modeTabs.map((tab) => ( - setMode(tab.key)} className={`flex items-center gap-1.5 px-4 py-2 rounded-md text-[11px] font-medium transition-colors ${ mode === tab.key - ? 'bg-blue-600 text-heading' + ? 'bg-blue-600 text-on-vivid' : 'text-muted-foreground hover:text-foreground hover:bg-surface-overlay' }`} > @@ -342,7 +345,7 @@ export function ChinaFishing() { - @@ -425,7 +428,7 @@ export function ChinaFishing() { 관심영역 안전도 - + 영역 A 영역 B @@ -467,7 +470,7 @@ export function ChinaFishing() { {/* 탭 헤더 */} {vesselTabs.map((tab) => ( - setVesselTab(tab)} className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${ @@ -501,7 +504,7 @@ export function ChinaFishing() { {v.name} - {v.type} + {v.type} {v.country} @@ -524,7 +527,7 @@ export function ChinaFishing() { {/* 탭 */} {statsTabs.map((tab) => ( - setStatsTab(tab)} className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${ @@ -567,10 +570,14 @@ export function ChinaFishing() { - CRITICAL {riskDistribution.critical} - HIGH {riskDistribution.high} - MEDIUM {riskDistribution.medium} - LOW {riskDistribution.low} + {(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as AlertLevel[]).map((lv) => ( + + + + {getAlertLevelLabel(lv, tcCommon, lang)} {riskDistribution[lv.toLowerCase() as 'critical' | 'high' | 'medium' | 'low']} + + + ))} @@ -592,7 +599,7 @@ export function ChinaFishing() { 최근 위성영상 분석 - 자세히 보기 + 자세히 보기 @@ -616,7 +623,7 @@ export function ChinaFishing() { 기상 예보 - 자세히 보기 + 자세히 보기 @@ -639,7 +646,7 @@ export function ChinaFishing() { VTS연계 현황 - 자세히 보기 + 자세히 보기 {VTS_ITEMS.map((vts) => ( @@ -657,10 +664,10 @@ export function ChinaFishing() { ))} - + - + @@ -671,6 +678,6 @@ export function ChinaFishing() { >} - + ); } diff --git a/frontend/src/features/detection/DarkVesselDetection.tsx b/frontend/src/features/detection/DarkVesselDetection.tsx index 1664ba8..ccd8568 100644 --- a/frontend/src/features/detection/DarkVesselDetection.tsx +++ b/frontend/src/features/detection/DarkVesselDetection.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useMemo, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { EyeOff, AlertTriangle, Radio, Tag, Loader2 } from 'lucide-react'; import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; @@ -12,6 +13,9 @@ import { type VesselAnalysisItem, } from '@/services/vesselAnalysisApi'; import { formatDateTime } from '@shared/utils/dateFormat'; +import { getDarkVesselPatternIntent, getDarkVesselPatternLabel, getDarkVesselPatternMeta } from '@shared/constants/darkVesselPatterns'; +import { getVesselSurveillanceIntent, getVesselSurveillanceLabel } from '@shared/constants/vesselAnalysisStatuses'; +import { useSettingsStore } from '@stores/settingsStore'; /* SFR-09: 불법 어선(AIS 조작·위장·Dark Vessel) 패턴 탐지 */ @@ -62,30 +66,27 @@ function mapItemToSuspect(item: VesselAnalysisItem, idx: number): Suspect { }; } -const PATTERN_COLORS: Record = { - 'AIS 완전차단': '#ef4444', - 'MMSI 변조 의심': '#f97316', - '장기소실': '#eab308', - '신호 간헐송출': '#a855f7', -}; - -const cols: DataColumn[] = [ - { key: 'id', label: 'ID', width: '70px', render: v => {v as string} }, - { key: 'pattern', label: '탐지 패턴', width: '120px', sortable: true, render: v => {v as string} }, - { 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 => { const s = v as string; const c = s === '추적중' ? 'bg-red-500/20 text-red-400' : s === '감시중' ? 'bg-yellow-500/20 text-yellow-400' : s === '확인중' ? 'bg-blue-500/20 text-blue-400' : 'bg-green-500/20 text-green-400'; return {s}; } }, - { key: 'label', label: '라벨', width: '60px', align: 'center', - render: v => { const l = v as string; return l === '-' ? 분류 : {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 +129,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 +138,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)), @@ -147,13 +148,13 @@ export function DarkVesselDetection() { useMapLayers(mapRef, buildLayers, [DATA]); return ( - - - - {t('darkVessel.title')} - {t('darkVessel.desc')} - - + + {!serviceAvailable && ( @@ -193,12 +194,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 @@ -212,6 +217,6 @@ export function DarkVesselDetection() { - + ); } diff --git a/frontend/src/features/detection/GearDetection.tsx b/frontend/src/features/detection/GearDetection.tsx index 7212364..aad29cb 100644 --- a/frontend/src/features/detection/GearDetection.tsx +++ b/frontend/src/features/detection/GearDetection.tsx @@ -2,20 +2,25 @@ import { useEffect, useState, useMemo, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { Anchor, AlertTriangle, Loader2 } from 'lucide-react'; import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import type { MarkerData } from '@lib/map'; import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi'; import { formatDate } from '@shared/utils/dateFormat'; +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 +55,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 +113,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 +122,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)), @@ -124,11 +132,13 @@ export function GearDetection() { useMapLayers(mapRef, buildLayers, [DATA]); return ( - - - {t('gearDetection.title')} - {t('gearDetection.desc')} - + + {!serviceAvailable && ( @@ -186,6 +196,6 @@ export function GearDetection() { - + ); } diff --git a/frontend/src/features/detection/GearIdentification.tsx b/frontend/src/features/detection/GearIdentification.tsx index 5de1084..cf24e90 100644 --- a/frontend/src/features/detection/GearIdentification.tsx +++ b/frontend/src/features/detection/GearIdentification.tsx @@ -436,15 +436,17 @@ function FormField({ label, children, hint }: { label: string; children: React.R ); } -function InputField({ value, onChange, placeholder, type = 'text', className = '' }: { +function InputField({ value, onChange, placeholder, type = 'text', className = '', label }: { value: string | number | null; onChange: (v: string) => void; placeholder: string; type?: string; className?: string; + label?: string; }) { return ( onChange(e.target.value)} @@ -470,13 +472,15 @@ function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: ); } -function SelectField({ value, onChange, options }: { +function SelectField({ label, value, onChange, options }: { + label: string; value: string; onChange: (v: string) => void; options: { value: string; label: string }[]; }) { return ( onChange(e.target.value)} className="w-full bg-surface-overlay border border-slate-700/50 rounded-md px-2.5 py-1.5 text-xs text-heading focus:border-blue-500/50 focus:outline-none" @@ -489,30 +493,30 @@ function SelectField({ value, onChange, options }: { } function ResultBadge({ origin, confidence }: { origin: Origin; confidence: Confidence }) { - const colors: Record = { - china: 'bg-red-500/20 text-red-400 border-red-500/40', - korea: 'bg-blue-500/20 text-blue-400 border-blue-500/40', - uncertain: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/40', + const intents: Record = { + china: 'critical', + korea: 'info', + uncertain: 'warning', }; const labels: Record = { china: '중국어선 어구', korea: '한국어선 어구', uncertain: '판별 불가' }; const confLabels: Record = { high: '높음', medium: '보통', low: '낮음' }; return ( - {labels[origin]} + {labels[origin]} 신뢰도: {confLabels[confidence]} ); } function AlertBadge({ level }: { level: string }) { - const styles: Record = { - CRITICAL: 'bg-red-600/20 text-red-400 border-red-600/40', - HIGH: 'bg-orange-500/20 text-orange-400 border-orange-500/40', - MEDIUM: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/40', - LOW: 'bg-blue-500/20 text-blue-400 border-blue-500/40', + const intents: Record = { + CRITICAL: 'critical', + HIGH: 'high', + MEDIUM: 'warning', + LOW: 'info', }; - return {level}; + return {level}; } // ─── 어구 비교 레퍼런스 테이블 ────────── @@ -648,7 +652,7 @@ export function GearIdentification() { - setShowReference(!showReference)} className="px-3 py-1.5 bg-secondary border border-slate-700/50 rounded-md text-[11px] text-label hover:bg-switch-background transition-colors flex items-center gap-1.5" > @@ -692,7 +696,7 @@ export function GearIdentification() { - update('gearCategory', v as GearType)} options={[ + update('gearCategory', v as GearType)} options={[ { value: 'unknown', label: '미확인 / 모름' }, { value: 'trawl', label: '트롤 (저인망) — 끌고 다니는 어망' }, { value: 'gillnet', label: '자망 (유자망) — 세워놓는 어망' }, @@ -746,7 +750,7 @@ export function GearIdentification() { - update('trajectoryPattern', v as GearInput['trajectoryPattern'])} options={[ + update('trajectoryPattern', v as GearInput['trajectoryPattern'])} options={[ { value: 'unknown', label: '미확인' }, { value: 'lawnmowing', label: '지그재그 반복 (Lawn-mowing) → 트롤' }, { value: 'stationary', label: '정지/극저속 (Stationary) → 자망' }, @@ -779,14 +783,14 @@ export function GearIdentification() { {/* 판별 버튼 */} - 어구 국적 판별 실행 - diff --git a/frontend/src/features/detection/RealGearGroups.tsx b/frontend/src/features/detection/RealGearGroups.tsx index af52ce1..b1fb42d 100644 --- a/frontend/src/features/detection/RealGearGroups.tsx +++ b/frontend/src/features/detection/RealGearGroups.tsx @@ -3,6 +3,10 @@ import { Loader2, RefreshCw, MapPin } from 'lucide-react'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi'; +import { getGearGroupTypeIntent, getGearGroupTypeLabel } from '@shared/constants/gearGroupTypes'; +import { getParentResolutionIntent, getParentResolutionLabel } from '@shared/constants/parentResolutionStatuses'; +import { useSettingsStore } from '@stores/settingsStore'; +import { useTranslation } from 'react-i18next'; /** * iran 백엔드의 실시간 어구/선단 그룹을 표시. @@ -10,19 +14,9 @@ import { fetchGroups, type GearGroupItem } from '@/services/vesselAnalysisApi'; * - 자체 DB의 ParentResolution이 합성되어 있음 */ -const TYPE_COLORS: Record = { - FLEET: 'bg-blue-500/20 text-blue-400', - GEAR_IN_ZONE: 'bg-orange-500/20 text-orange-400', - GEAR_OUT_ZONE: 'bg-purple-500/20 text-purple-400', -}; - -const STATUS_COLORS: Record = { - MANUAL_CONFIRMED: 'bg-green-500/20 text-green-400', - REVIEW_REQUIRED: 'bg-red-500/20 text-red-400', - UNRESOLVED: 'bg-yellow-500/20 text-yellow-400', -}; - export function RealGearGroups() { + const { t: tc } = useTranslation('common'); + const lang = useSettingsStore((s) => s.language); const [items, setItems] = useState([]); const [available, setAvailable] = useState(true); const [loading, setLoading] = useState(false); @@ -61,14 +55,14 @@ export function RealGearGroups() { 실시간 어구/선단 그룹 (iran 백엔드) - {!available && 미연결} + {!available && 미연결} GET /api/vessel-analysis/groups · 자체 DB의 운영자 결정(resolution) 합성됨 - setFilterType(e.target.value)} + setFilterType(e.target.value)} className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading"> 전체 FLEET @@ -115,7 +109,7 @@ export function RealGearGroups() { {filtered.slice(0, 100).map((g) => ( - {g.groupType} + {getGearGroupTypeLabel(g.groupType, tc, lang)} {g.groupKey} {g.subClusterId} @@ -126,8 +120,8 @@ export function RealGearGroups() { {g.resolution ? ( - - {g.resolution.status} + + {getParentResolutionLabel(g.resolution.status, tc, lang)} ) : -} diff --git a/frontend/src/features/detection/RealVesselAnalysis.tsx b/frontend/src/features/detection/RealVesselAnalysis.tsx index 6c8b48d..62bb149 100644 --- a/frontend/src/features/detection/RealVesselAnalysis.tsx +++ b/frontend/src/features/detection/RealVesselAnalysis.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useCallback, useMemo } from 'react'; import { Loader2, RefreshCw, EyeOff, AlertTriangle, Radar } from 'lucide-react'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { getAlertLevelIntent } from '@shared/constants/alertLevels'; import { fetchVesselAnalysis, type VesselAnalysisItem, @@ -20,12 +21,7 @@ interface Props { icon?: React.ReactNode; } -const RISK_COLORS: Record = { - CRITICAL: 'bg-red-500/20 text-red-400', - HIGH: 'bg-orange-500/20 text-orange-400', - MEDIUM: 'bg-yellow-500/20 text-yellow-400', - LOW: 'bg-blue-500/20 text-blue-400', -}; +// 위험도 색상은 alertLevels 카탈로그 (intent prop) 사용 const ZONE_LABELS: Record = { TERRITORIAL_SEA: '영해', @@ -82,14 +78,14 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) { {icon} {title} - {!available && 미연결} + {!available && 미연결} GET /api/vessel-analysis · iran 백엔드 실시간 분석 결과 - setZoneFilter(e.target.value)} + setZoneFilter(e.target.value)} className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading"> 전체 해역 영해 @@ -147,7 +143,7 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) { ({(v.classification.confidence * 100).toFixed(0)}%) - + {v.algorithms.riskScore.level} @@ -159,7 +155,7 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) { {v.algorithms.activity.state} {v.algorithms.darkVessel.isDark ? ( - {v.algorithms.darkVessel.gapDurationMin}분 + {v.algorithms.darkVessel.gapDurationMin}분 ) : -} @@ -169,7 +165,7 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) { {v.algorithms.transship.isSuspect ? ( - {v.algorithms.transship.durationMin}분 + {v.algorithms.transship.durationMin}분 ) : -} diff --git a/frontend/src/features/enforcement/EnforcementHistory.tsx b/frontend/src/features/enforcement/EnforcementHistory.tsx index 5b20f37..79838e9 100644 --- a/frontend/src/features/enforcement/EnforcementHistory.tsx +++ b/frontend/src/features/enforcement/EnforcementHistory.tsx @@ -1,9 +1,15 @@ -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Badge } from '@shared/components/ui/badge'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { FileText, CheckCircle, XCircle, Loader2 } from 'lucide-react'; import { useEnforcementStore } from '@stores/enforcementStore'; +import { formatDateTime } from '@shared/utils/dateFormat'; +import { getViolationLabel, getViolationIntent } from '@shared/constants/violationTypes'; +import { getEnforcementActionLabel } from '@shared/constants/enforcementActions'; +import { getEnforcementResultLabel, getEnforcementResultIntent } from '@shared/constants/enforcementResults'; +import { useSettingsStore } from '@stores/settingsStore'; /* SFR-11: 단속 이력 관리 — 실제 백엔드 API 연동 */ @@ -19,86 +25,96 @@ interface Record { [key: string]: unknown; } -const cols: DataColumn[] = [ - { - key: 'id', - label: 'ID', - width: '80px', - render: (v) => ( - {v as string} - ), - }, - { - key: 'date', - label: '일시', - width: '130px', - sortable: true, - render: (v) => ( - - {v as string} - - ), - }, - { key: 'zone', label: '해역', width: '90px', sortable: true }, - { - key: 'vessel', - label: '대상 선박', - sortable: true, - render: (v) => ( - {v as string} - ), - }, - { - key: 'violation', - label: '위반 내용', - width: '100px', - sortable: true, - render: (v) => ( - - {v as string} - - ), - }, - { key: 'action', label: '조치', width: '90px' }, - { - key: 'aiMatch', - label: 'AI 매칭', - width: '70px', - align: 'center', - render: (v) => { - const m = v as string; - return m === '일치' ? ( - - ) : ( - - ); - }, - }, - { - key: 'result', - label: '결과', - width: '80px', - align: 'center', - sortable: true, - render: (v) => { - const r = v as string; - const c = - r.includes('처벌') || r.includes('수사') - ? 'bg-red-500/20 text-red-400' - : r.includes('오탐') - ? 'bg-muted text-muted-foreground' - : 'bg-yellow-500/20 text-yellow-400'; - return ( - {r} - ); - }, - }, -]; - export function EnforcementHistory() { const { t } = useTranslation('enforcement'); + const { t: tc } = useTranslation('common'); + const lang = useSettingsStore((s) => s.language); const { records, loading, error, load } = useEnforcementStore(); + const cols: DataColumn[] = useMemo(() => [ + { + key: 'id', + label: 'ID', + width: '80px', + render: (v) => ( + {v as string} + ), + }, + { + key: 'date', + label: '일시', + sortable: true, + render: (v) => ( + + {formatDateTime(v as string)} + + ), + }, + { key: 'zone', label: '해역', width: '90px', sortable: true }, + { + key: 'vessel', + label: '대상 선박', + sortable: true, + render: (v) => ( + {v as string} + ), + }, + { + key: 'violation', + label: '위반 내용', + minWidth: '90px', + maxWidth: '160px', + sortable: true, + render: (v) => { + const code = v as string; + return ( + + {getViolationLabel(code, tc, lang)} + + ); + }, + }, + { + key: 'action', + label: '조치', + minWidth: '70px', + maxWidth: '110px', + render: (v) => ( + {getEnforcementActionLabel(v as string, tc, lang)} + ), + }, + { + key: 'aiMatch', + label: 'AI 매칭', + width: '70px', + align: 'center', + render: (v) => { + const m = v as string; + return m === '일치' || m === 'MATCH' ? ( + + ) : ( + + ); + }, + }, + { + key: 'result', + label: '결과', + minWidth: '80px', + maxWidth: '120px', + align: 'center', + sortable: true, + render: (v) => { + const code = v as string; + return ( + + {getEnforcementResultLabel(code, tc, lang)} + + ); + }, + }, + ], [tc, lang]); + useEffect(() => { load(); }, [load]); @@ -106,32 +122,31 @@ export function EnforcementHistory() { const DATA: Record[] = records as Record[]; return ( - - - - - {t('history.title')} - - {t('history.desc')} - + + - {/* KPI 카드 */} + {/* KPI 카드 — backend enum 코드(PUNISHED/REFERRED/FALSE_POSITIVE) 기반 비교 */} {[ { l: '총 단속', v: DATA.length, c: 'text-heading' }, { - l: '처벌', - v: DATA.filter((d) => d.result.includes('처벌')).length, + l: '처벌·수사', + v: DATA.filter((d) => d.result === 'PUNISHED' || d.result === 'REFERRED').length, c: 'text-red-400', }, { l: 'AI 일치', - v: DATA.filter((d) => d.aiMatch === '일치').length, + v: DATA.filter((d) => d.aiMatch === '일치' || d.aiMatch === 'MATCH').length, c: 'text-green-400', }, { l: '오탐', - v: DATA.filter((d) => d.result.includes('오탐')).length, + v: DATA.filter((d) => d.result === 'FALSE_POSITIVE').length, c: 'text-yellow-400', }, ].map((k) => ( @@ -173,6 +188,6 @@ export function EnforcementHistory() { exportFilename="단속이력" /> )} - + ); } diff --git a/frontend/src/features/enforcement/EventList.tsx b/frontend/src/features/enforcement/EventList.tsx index 9be5547..45e9dde 100644 --- a/frontend/src/features/enforcement/EventList.tsx +++ b/frontend/src/features/enforcement/EventList.tsx @@ -1,6 +1,9 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Badge } from '@shared/components/ui/badge'; +import { Button } from '@shared/components/ui/button'; +import { Select } from '@shared/components/ui/select'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { FileUpload } from '@shared/components/common/FileUpload'; import { @@ -8,6 +11,11 @@ import { Filter, Upload, X, Loader2, } from 'lucide-react'; import { useEventStore } from '@stores/eventStore'; +import { formatDateTime } from '@shared/utils/dateFormat'; +import { type AlertLevel as AlertLevelType, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels'; +import { getEventStatusIntent, getEventStatusLabel } from '@shared/constants/eventStatuses'; +import { getViolationLabel, getViolationIntent } from '@shared/constants/violationTypes'; +import { useSettingsStore } from '@stores/settingsStore'; /* * 이벤트 목록 — SFR-02 공통컴포넌트 적용 @@ -15,7 +23,7 @@ import { useEventStore } from '@stores/eventStore'; * 실제 백엔드 API 연동 */ -type AlertLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'; +type AlertLevel = AlertLevelType; interface EventRow { id: string; @@ -33,64 +41,10 @@ interface EventRow { [key: string]: unknown; } -const LEVEL_STYLES: Record = { - CRITICAL: { bg: 'bg-red-500/15', text: 'text-red-400' }, - HIGH: { bg: 'bg-orange-500/15', text: 'text-orange-400' }, - MEDIUM: { bg: 'bg-yellow-500/15', text: 'text-yellow-400' }, - LOW: { bg: 'bg-blue-500/15', text: 'text-blue-400' }, -}; - -const STATUS_COLORS: Record = { - NEW: 'bg-red-500/20 text-red-400', - ACK: 'bg-orange-500/20 text-orange-400', - IN_PROGRESS: 'bg-blue-500/20 text-blue-400', - RESOLVED: 'bg-green-500/20 text-green-400', - FALSE_POSITIVE: 'bg-muted text-muted-foreground', -}; - -function statusColor(s: string): string { - if (STATUS_COLORS[s]) return STATUS_COLORS[s]; - if (s === '완료' || s === '확인 완료' || s === '경고 완료') return 'bg-green-500/20 text-green-400'; - if (s.includes('추적') || s.includes('나포')) return 'bg-red-500/20 text-red-400'; - if (s.includes('감시') || s.includes('확인')) return 'bg-yellow-500/20 text-yellow-400'; - return 'bg-blue-500/20 text-blue-400'; -} - -const columns: DataColumn[] = [ - { - key: 'level', label: '등급', width: '70px', sortable: true, - render: (val) => { - const lv = val as AlertLevel; - const s = LEVEL_STYLES[lv]; - return {lv}; - }, - }, - { key: 'time', label: '발생시간', width: '140px', sortable: true, - render: (val) => {val as string}, - }, - { key: 'type', label: '유형', width: '90px', sortable: true, - render: (val) => {val as string}, - }, - { key: 'vesselName', label: '선박명', sortable: true, - render: (val) => {val as string}, - }, - { key: 'mmsi', label: 'MMSI', width: '100px', - render: (val) => {val as string}, - }, - { key: 'area', label: '해역', width: '90px', sortable: true }, - { key: 'speed', label: '속력', width: '60px', align: 'right' }, - { - key: 'status', label: '처리상태', width: '80px', sortable: true, - render: (val) => { - const s = val as string; - return {s}; - }, - }, - { key: 'assignee', label: '담당', width: '70px' }, -]; - export function EventList() { const { t } = useTranslation('enforcement'); + const { t: tc } = useTranslation('common'); + const lang = useSettingsStore((s) => s.language); const { events: storeEvents, stats, @@ -100,6 +54,53 @@ export function EventList() { loadStats, } = useEventStore(); + const columns: DataColumn[] = useMemo(() => [ + { + key: 'level', label: '등급', minWidth: '64px', maxWidth: '110px', sortable: true, + render: (val) => { + const lv = val as AlertLevel; + return ( + + {getAlertLevelLabel(lv, tc, lang)} + + ); + }, + }, + { key: 'time', label: '발생시간', minWidth: '140px', maxWidth: '170px', sortable: true, + render: (val) => {formatDateTime(val as string)}, + }, + { key: 'type', label: '유형', minWidth: '90px', maxWidth: '160px', sortable: true, + render: (val) => { + const code = val as string; + return ( + + {getViolationLabel(code, tc, lang)} + + ); + }, + }, + { key: 'vesselName', label: '선박명', minWidth: '100px', maxWidth: '220px', sortable: true, + render: (val) => {val as string}, + }, + { key: 'mmsi', label: 'MMSI', minWidth: '90px', maxWidth: '120px', + render: (val) => {val as string}, + }, + { key: 'area', label: '해역', minWidth: '80px', maxWidth: '140px', sortable: true }, + { key: 'speed', label: '속력', minWidth: '56px', maxWidth: '80px', align: 'right' }, + { + key: 'status', label: '처리상태', minWidth: '80px', maxWidth: '120px', sortable: true, + render: (val) => { + const s = val as string; + return ( + + {getEventStatusLabel(s, tc, lang)} + + ); + }, + }, + { key: 'assignee', label: '담당', minWidth: '60px', maxWidth: '100px' }, + ], [tc, lang]); + const [levelFilter, setLevelFilter] = useState(''); const [showUpload, setShowUpload] = useState(false); @@ -137,45 +138,41 @@ export function EventList() { const kpiTotal = (stats['TOTAL'] as number | undefined) ?? EVENTS.length; return ( - - {/* 헤더 */} - - - - - {t('eventList.title')} - - - {t('eventList.desc')} - - - - {/* 등급 필터 */} - - - setLevelFilter(e.target.value)} - title="등급 필터" - className="bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-1.5 text-[11px] text-label focus:outline-none focus:border-blue-500/50" + + + + + setLevelFilter(e.target.value)} + title="등급 필터" + className="w-32" + > + 전체 등급 + CRITICAL + HIGH + MEDIUM + LOW + + + setShowUpload(!showUpload)} + icon={} > - 전체 등급 - CRITICAL - HIGH - MEDIUM - LOW - - - setShowUpload(!showUpload)} - className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading transition-colors" - > - - 파일 업로드 - - - + 파일 업로드 + + > + } + /> {/* KPI 요약 */} @@ -250,6 +247,6 @@ export function EventList() { exportFilename="이벤트목록" /> )} - + ); } diff --git a/frontend/src/features/field-ops/AIAlert.tsx b/frontend/src/features/field-ops/AIAlert.tsx index a829770..4c12489 100644 --- a/frontend/src/features/field-ops/AIAlert.tsx +++ b/frontend/src/features/field-ops/AIAlert.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Badge } from '@shared/components/ui/badge'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { Send, Loader2, AlertTriangle } from 'lucide-react'; import { getAlerts, type PredictionAlert } from '@/services/event'; @@ -51,7 +52,7 @@ const cols: DataColumn[] = [ width: '80px', sortable: true, render: (v) => ( - {v as string} + {v as string} ), }, { @@ -81,14 +82,10 @@ const cols: DataColumn[] = [ sortable: true, render: (v) => { const s = v as string; - const c = - s === 'DELIVERED' - ? 'bg-green-500/20 text-green-400' - : s === 'SENT' - ? 'bg-blue-500/20 text-blue-400' - : 'bg-red-500/20 text-red-400'; + const intent: 'success' | 'info' | 'critical' = + s === 'DELIVERED' ? 'success' : s === 'SENT' ? 'info' : 'critical'; return ( - {STATUS_LABEL[s] ?? s} + {STATUS_LABEL[s] ?? s} ); }, }, @@ -140,34 +137,37 @@ export function AIAlert() { if (loading) { return ( - - - 알림 데이터 로딩 중... - + + + + 알림 데이터 로딩 중... + + ); } if (error) { return ( - - - 알림 조회 실패: {error} - - 재시도 - - + + + + 알림 조회 실패: {error} + + 재시도 + + + ); } return ( - - - - - {t('aiAlert.title')} - - {t('aiAlert.desc')} - + + {[ { l: '총 발송', v: totalElements, c: 'text-heading' }, @@ -191,6 +191,6 @@ export function AIAlert() { searchKeys={['channel', 'recipient']} exportFilename="AI알림이력" /> - + ); } diff --git a/frontend/src/features/field-ops/MobileService.tsx b/frontend/src/features/field-ops/MobileService.tsx index 0e8505f..135de7f 100644 --- a/frontend/src/features/field-ops/MobileService.tsx +++ b/frontend/src/features/field-ops/MobileService.tsx @@ -2,10 +2,12 @@ import { useEffect, useMemo, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { Smartphone, MapPin, Bell, Wifi, WifiOff, Shield, AlertTriangle, Navigation } from 'lucide-react'; import { BaseMap, createMarkerLayer, createPolylineLayer, useMapLayers, type MapHandle, type MarkerData } from '@lib/map'; import { useEventStore } from '@stores/eventStore'; import { formatTime } from '@shared/utils/dateFormat'; +import { ALERT_LEVELS, type AlertLevel } from '@shared/constants/alertLevels'; /* SFR-15: 단속요원 이용 모바일 대응 서비스 */ @@ -51,11 +53,13 @@ export function MobileService() { ); return ( - - - {t('mobileService.title')} - {t('mobileService.desc')} - + + {/* 모바일 프리뷰 */} @@ -98,7 +102,7 @@ export function MobileService() { {ALERTS.slice(0, 2).map((a, i) => ( - + {a.title} ))} @@ -142,6 +146,6 @@ export function MobileService() { - + ); } diff --git a/frontend/src/features/field-ops/ShipAgent.tsx b/frontend/src/features/field-ops/ShipAgent.tsx index 83551a1..7f0f118 100644 --- a/frontend/src/features/field-ops/ShipAgent.tsx +++ b/frontend/src/features/field-ops/ShipAgent.tsx @@ -1,8 +1,11 @@ +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; -import { Monitor, Ship, Wifi, WifiOff, RefreshCw, MapPin, Clock, CheckCircle } from 'lucide-react'; +import { Monitor } from 'lucide-react'; +import { getDeviceStatusIntent, getDeviceStatusLabel } from '@shared/constants/deviceStatuses'; +import { useSettingsStore } from '@stores/settingsStore'; /* SFR-16: 함정용 단말에서 이용가능한 Agent 개발 */ @@ -15,30 +18,40 @@ const DATA: Agent[] = [ { id: 'AGT-105', ship: '서특단 1정', version: 'v1.2.0', status: '온라인', sync: '동기화 완료', lastSync: '09:19:55', tasks: 2 }, { id: 'AGT-106', ship: '1503함', version: '-', status: '미배포', sync: '-', lastSync: '-', tasks: 0 }, ]; -const cols: DataColumn[] = [ - { key: 'id', label: 'Agent ID', width: '80px', render: v => {v as string} }, - { key: 'ship', label: '함정', sortable: true, render: v => {v as string} }, - { key: 'version', label: '버전', width: '70px' }, - { key: 'status', label: '상태', width: '70px', align: 'center', sortable: true, - render: v => { const s = v as string; const c = s === '온라인' ? 'bg-green-500/20 text-green-400' : s === '오프라인' ? 'bg-red-500/20 text-red-400' : 'bg-muted text-muted-foreground'; return {s}; } }, - { key: 'sync', label: '동기화', width: '90px', render: v => {v as string} }, - { key: 'lastSync', label: '최종 동기화', width: '90px', render: v => {v as string} }, - { key: 'tasks', label: '작업 수', width: '60px', align: 'right', render: v => {v as number} }, -]; export function ShipAgent() { const { t } = useTranslation('fieldOps'); + const { t: tc } = useTranslation('common'); + const lang = useSettingsStore((s) => s.language); + + const cols: DataColumn[] = useMemo(() => [ + { key: 'id', label: 'Agent ID', width: '80px', render: v => {v as string} }, + { key: 'ship', label: '함정', sortable: true, render: v => {v as string} }, + { key: 'version', label: '버전', width: '70px' }, + { key: 'status', label: '상태', width: '70px', align: 'center', sortable: true, + render: v => { + const s = v as string; + return ( + + {getDeviceStatusLabel(s, tc, lang)} + + ); + }, + }, + { key: 'sync', label: '동기화', width: '90px', render: v => {v as string} }, + { key: 'lastSync', label: '최종 동기화', width: '90px', render: v => {v as string} }, + { key: 'tasks', label: '작업 수', width: '60px', align: 'right', render: v => {v as number} }, + ], [tc, lang]); + return ( - - - - {t('shipAgent.title')} - - ⚠데모 데이터 (백엔드 API 미구현) - - - {t('shipAgent.desc')} - + + {[{ l: '전체 Agent', v: DATA.length, c: 'text-heading' }, { l: '온라인', v: DATA.filter(d => d.status === '온라인').length, c: 'text-green-400' }, { l: '오프라인', v: DATA.filter(d => d.status === '오프라인').length, c: 'text-red-400' }, { l: '미배포', v: DATA.filter(d => d.status === '미배포').length, c: 'text-muted-foreground' }].map(k => ( @@ -47,6 +60,6 @@ export function ShipAgent() { ))} - + ); } diff --git a/frontend/src/features/monitoring/MonitoringDashboard.tsx b/frontend/src/features/monitoring/MonitoringDashboard.tsx index 9d10787..9532769 100644 --- a/frontend/src/features/monitoring/MonitoringDashboard.tsx +++ b/frontend/src/features/monitoring/MonitoringDashboard.tsx @@ -2,34 +2,28 @@ import { useEffect, useState, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { Activity, AlertTriangle, Ship, Eye, Anchor, Radar, Shield, Bell, Clock, Target, ChevronRight } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { AreaChart, PieChart } from '@lib/charts'; import { useKpiStore } from '@stores/kpiStore'; import { useEventStore } from '@stores/eventStore'; import { getHourlyStats, type PredictionStatsHourly } from '@/services/kpi'; +import { getKpiUi } from '@shared/constants/kpiUiMap'; +import { formatDateTime } from '@shared/utils/dateFormat'; +import { getViolationColor, getViolationLabel } from '@shared/constants/violationTypes'; +import { type AlertLevel, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels'; +import { useSettingsStore } from '@stores/settingsStore'; import { SystemStatusPanel } from './SystemStatusPanel'; /* SFR-12: 모니터링 및 경보 현황판(대시보드) */ // KPI UI 매핑 (icon, color는 store에 없으므로 라벨 기반 매핑) -const KPI_UI_MAP: Record = { - '실시간 탐지': { icon: Radar, color: '#3b82f6' }, - 'EEZ 침범': { icon: AlertTriangle, color: '#ef4444' }, - '다크베셀': { icon: Eye, color: '#f97316' }, - '불법환적 의심': { icon: Anchor, color: '#a855f7' }, - '추적 중': { icon: Target, color: '#06b6d4' }, - '나포/검문': { icon: Shield, color: '#10b981' }, -}; -// 위반 유형 → 차트 색상 매핑 -const PIE_COLOR_MAP: Record = { - 'EEZ 침범': '#ef4444', '다크베셀': '#f97316', 'MMSI 변조': '#eab308', - '불법환적': '#a855f7', '어구 불법': '#6b7280', -}; -const LV: Record = { CRITICAL: 'text-red-400 bg-red-500/15', HIGH: 'text-orange-400 bg-orange-500/15', MEDIUM: 'text-yellow-400 bg-yellow-500/15', LOW: 'text-blue-400 bg-blue-500/15' }; - +// KPI_UI_MAP은 shared/constants/kpiUiMap 공통 모듈 사용 export function MonitoringDashboard() { const { t } = useTranslation('dashboard'); + const { t: tc } = useTranslation('common'); + const lang = useSettingsStore((s) => s.language); const kpiStore = useKpiStore(); const eventStore = useEventStore(); @@ -67,31 +61,33 @@ export function MonitoringDashboard() { const KPI = kpiStore.metrics.map((m) => ({ label: m.label, value: m.value, - icon: KPI_UI_MAP[m.label]?.icon ?? Radar, - color: KPI_UI_MAP[m.label]?.color ?? '#3b82f6', + icon: getKpiUi(m.label).icon, + color: getKpiUi(m.label).color, })); - // PIE: store violationTypes → 차트 데이터 변환 + // PIE: store violationTypes → 공통 카탈로그 기반 라벨/색상 const PIE = kpiStore.violationTypes.map((v) => ({ - name: v.type, + name: getViolationLabel(v.type, tc, lang), value: v.pct, - color: PIE_COLOR_MAP[v.type] ?? '#6b7280', + color: getViolationColor(v.type), })); - // 이벤트: store events → 첫 6개, time 포맷 변환 + // 이벤트: store events → 첫 6개, time은 KST로 포맷 const EVENTS = eventStore.events.slice(0, 6).map((e) => ({ - time: e.time.includes(' ') ? e.time.split(' ')[1].slice(0, 5) : e.time, + time: formatDateTime(e.time), level: e.level, title: e.title, detail: e.detail, })); return ( - - - {t('monitoring.title')} - {t('monitoring.desc')} - + + {/* iran 백엔드 + Prediction 시스템 상태 (실시간) */} @@ -122,14 +118,16 @@ export function MonitoringDashboard() { {EVENTS.map((e, i) => ( - {e.time} - {e.level} + {e.time} + + {getAlertLevelLabel(e.level, tc, lang)} + {e.title} {e.detail} ))} - + ); } diff --git a/frontend/src/features/monitoring/SystemStatusPanel.tsx b/frontend/src/features/monitoring/SystemStatusPanel.tsx index 4cf1d69..99bc29e 100644 --- a/frontend/src/features/monitoring/SystemStatusPanel.tsx +++ b/frontend/src/features/monitoring/SystemStatusPanel.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useCallback } from 'react'; import { Loader2, RefreshCw, Activity, Database, Wifi } from 'lucide-react'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import type { BadgeIntent } from '@lib/theme/variants'; import { fetchVesselAnalysis, type VesselAnalysisStats } from '@/services/vesselAnalysisApi'; const API_BASE = import.meta.env.VITE_API_URL ?? '/api'; @@ -85,7 +86,7 @@ export function SystemStatusPanel() { icon={} title="KCG AI Backend" status="UP" - statusColor="text-green-400" + statusIntent="success" details={[ ['포트', ':8080'], ['프로파일', 'local'], @@ -98,7 +99,7 @@ export function SystemStatusPanel() { icon={} title="iran 백엔드 (분석)" status={stats ? 'CONNECTED' : 'DISCONNECTED'} - statusColor={stats ? 'text-green-400' : 'text-red-400'} + statusIntent={stats ? 'success' : 'critical'} details={[ ['선박 분석', stats ? `${stats.total.toLocaleString()}건` : '-'], ['클러스터', stats ? `${stats.clusterCount}` : '-'], @@ -111,7 +112,7 @@ export function SystemStatusPanel() { icon={} title="Prediction Service" status={health?.status || 'UNKNOWN'} - statusColor={health?.status === 'ok' ? 'text-green-400' : 'text-yellow-400'} + statusIntent={health?.status === 'ok' ? 'success' : 'warning'} details={[ ['SNPDB', health?.snpdb === true ? 'OK' : '-'], ['KCGDB', health?.kcgdb === true ? 'OK' : '-'], @@ -134,11 +135,11 @@ export function SystemStatusPanel() { ); } -function ServiceCard({ icon, title, status, statusColor, details }: { +function ServiceCard({ icon, title, status, statusIntent, details }: { icon: React.ReactNode; title: string; status: string; - statusColor: string; + statusIntent: BadgeIntent; details: [string, string][]; }) { return ( @@ -148,7 +149,7 @@ function ServiceCard({ icon, title, status, statusColor, details }: { {icon} {title} - + {status} diff --git a/frontend/src/features/parent-inference/LabelSession.tsx b/frontend/src/features/parent-inference/LabelSession.tsx index d772149..dc72a2e 100644 --- a/frontend/src/features/parent-inference/LabelSession.tsx +++ b/frontend/src/features/parent-inference/LabelSession.tsx @@ -2,6 +2,9 @@ import { useEffect, useState, useCallback } from 'react'; import { Tag, X, Loader2 } from 'lucide-react'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { Button } from '@shared/components/ui/button'; +import { Select } from '@shared/components/ui/select'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { useAuth } from '@/app/auth/AuthContext'; import { fetchLabelSessions, @@ -10,6 +13,9 @@ import { type LabelSession as LabelSessionType, } from '@/services/parentInferenceApi'; import { formatDateTime } from '@shared/utils/dateFormat'; +import { getLabelSessionIntent, getLabelSessionLabel } from '@shared/constants/parentResolutionStatuses'; +import { useSettingsStore } from '@stores/settingsStore'; +import { useTranslation } from 'react-i18next'; /** * 모선 추론 학습 세션 페이지. @@ -18,13 +24,9 @@ import { formatDateTime } from '@shared/utils/dateFormat'; * 권한: parent-inference-workflow:label-session (READ + CREATE + UPDATE) */ -const STATUS_COLORS: Record = { - ACTIVE: 'bg-green-500/20 text-green-400', - CANCELLED: 'bg-gray-500/20 text-gray-400', - COMPLETED: 'bg-blue-500/20 text-blue-400', -}; - export function LabelSession() { + const { t: tc } = useTranslation('common'); + const lang = useSettingsStore((s) => s.language); const { hasPermission } = useAuth(); const canCreate = hasPermission('parent-inference-workflow:label-session', 'CREATE'); const canUpdate = hasPermission('parent-inference-workflow:label-session', 'UPDATE'); @@ -86,23 +88,32 @@ export function LabelSession() { }; return ( - - - - 학습 세션 - 정답 라벨링 → prediction 모델 학습 데이터로 활용 - - - setFilter(e.target.value)} - className="bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs text-heading"> - 전체 상태 - ACTIVE - CANCELLED - COMPLETED - - 새로고침 - - + + + setFilter(e.target.value)} + > + 전체 상태 + ACTIVE + CANCELLED + COMPLETED + + + 새로고침 + + > + } + /> @@ -111,11 +122,11 @@ export function LabelSession() { {!canCreate && 권한 없음} - setGroupKey(e.target.value)} placeholder="group_key" + setGroupKey(e.target.value)} placeholder="group_key" className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} /> - setSubCluster(e.target.value)} placeholder="sub" + setSubCluster(e.target.value)} placeholder="sub" className="w-24 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} /> - setLabelMmsi(e.target.value)} placeholder="정답 parent MMSI" + setLabelMmsi(e.target.value)} placeholder="정답 parent MMSI" className="w-48 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} /> {it.subClusterId} {it.labelParentMmsi} - {it.status} + {getLabelSessionLabel(it.status, tc, lang)} {it.createdByAcnt || '-'} {formatDateTime(it.activeFrom)} @@ -181,6 +192,6 @@ export function LabelSession() { )} - + ); } diff --git a/frontend/src/features/parent-inference/ParentExclusion.tsx b/frontend/src/features/parent-inference/ParentExclusion.tsx index 489d018..36d3a32 100644 --- a/frontend/src/features/parent-inference/ParentExclusion.tsx +++ b/frontend/src/features/parent-inference/ParentExclusion.tsx @@ -2,6 +2,9 @@ import { useEffect, useState, useCallback } from 'react'; import { Ban, RotateCcw, Loader2, Globe, Layers } from 'lucide-react'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { Button } from '@shared/components/ui/button'; +import { Select } from '@shared/components/ui/select'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { useAuth } from '@/app/auth/AuthContext'; import { fetchExclusions, @@ -102,25 +105,31 @@ export function ParentExclusion() { }; return ( - - - - 모선 후보 제외 - GROUP/GLOBAL 스코프로 잘못된 후보를 차단합니다. - - - setFilter(e.target.value as '' | 'GROUP' | 'GLOBAL')} - className="bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs text-heading" - > - 전체 스코프 - GROUP - GLOBAL - - 새로고침 - - + + + setFilter(e.target.value as '' | 'GROUP' | 'GLOBAL')} + > + 전체 스코프 + GROUP + GLOBAL + + + 새로고침 + + > + } + /> {/* 신규 등록: GROUP */} @@ -130,13 +139,13 @@ export function ParentExclusion() { {!canCreateGroup && 권한 없음} - setGrpKey(e.target.value)} placeholder="group_key" + setGrpKey(e.target.value)} placeholder="group_key" className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} /> - setGrpSub(e.target.value)} placeholder="sub" + setGrpSub(e.target.value)} placeholder="sub" className="w-24 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} /> - setGrpMmsi(e.target.value)} placeholder="excluded MMSI" + setGrpMmsi(e.target.value)} placeholder="excluded MMSI" className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} /> - setGrpReason(e.target.value)} placeholder="사유" + setGrpReason(e.target.value)} placeholder="사유" className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} /> 권한 없음} - setGlbMmsi(e.target.value)} placeholder="excluded MMSI" + setGlbMmsi(e.target.value)} placeholder="excluded MMSI" className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGlobal} /> - setGlbReason(e.target.value)} placeholder="사유" + setGlbReason(e.target.value)} placeholder="사유" className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGlobal} /> {it.id} - + {it.scopeType} @@ -226,6 +235,6 @@ export function ParentExclusion() { )} - + ); } diff --git a/frontend/src/features/parent-inference/ParentReview.tsx b/frontend/src/features/parent-inference/ParentReview.tsx index ff234c3..72c73f5 100644 --- a/frontend/src/features/parent-inference/ParentReview.tsx +++ b/frontend/src/features/parent-inference/ParentReview.tsx @@ -1,7 +1,10 @@ import { useEffect, useState, useCallback } from 'react'; -import { CheckCircle, XCircle, RotateCcw, Loader2 } from 'lucide-react'; +import { CheckCircle, XCircle, RotateCcw, Loader2, GitMerge } from 'lucide-react'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { Button } from '@shared/components/ui/button'; +import { Select } from '@shared/components/ui/select'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { useAuth } from '@/app/auth/AuthContext'; import { fetchReviewList, @@ -9,6 +12,9 @@ import { type ParentResolution, } from '@/services/parentInferenceApi'; import { formatDateTime } from '@shared/utils/dateFormat'; +import { getParentResolutionIntent, getParentResolutionLabel } from '@shared/constants/parentResolutionStatuses'; +import { useSettingsStore } from '@stores/settingsStore'; +import { useTranslation } from 'react-i18next'; /** * 모선 확정/거부/리셋 페이지. @@ -17,19 +23,9 @@ import { formatDateTime } from '@shared/utils/dateFormat'; * - 모든 액션은 백엔드에서 audit_log + review_log에 기록 */ -const STATUS_COLORS: Record = { - UNRESOLVED: 'bg-yellow-500/20 text-yellow-400', - MANUAL_CONFIRMED: 'bg-green-500/20 text-green-400', - REVIEW_REQUIRED: 'bg-red-500/20 text-red-400', -}; - -const STATUS_LABELS: Record = { - UNRESOLVED: '미해결', - MANUAL_CONFIRMED: '확정됨', - REVIEW_REQUIRED: '검토필요', -}; - export function ParentReview() { + const { t: tc } = useTranslation('common'); + const lang = useSettingsStore((s) => s.language); const { hasPermission } = useAuth(); const canUpdate = hasPermission('parent-inference-workflow:parent-review', 'UPDATE'); const [items, setItems] = useState([]); @@ -104,34 +100,26 @@ export function ParentReview() { }; return ( - - - - 모선 확정/거부 - - 추론된 모선 후보를 확정/거부합니다. 권한: parent-inference-workflow:parent-review (UPDATE) - - - - setFilter(e.target.value)} - className="bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs text-heading" - > - 전체 상태 - 미해결 - 확정됨 - 검토필요 - - - 새로고침 - - - + + + setFilter(e.target.value)}> + 전체 상태 + 미해결 + 확정됨 + 검토필요 + + + 새로고침 + + > + } + /> {/* 신규 등록 폼 (테스트용) */} {canUpdate && ( @@ -140,6 +128,7 @@ export function ParentReview() { 신규 모선 확정 등록 (테스트) setNewGroupKey(e.target.value)} @@ -147,6 +136,7 @@ export function ParentReview() { className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" /> setNewSubCluster(e.target.value)} @@ -154,6 +144,7 @@ export function ParentReview() { className="w-32 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" /> setNewMmsi(e.target.value)} @@ -228,8 +219,8 @@ export function ParentReview() { {it.groupKey} {it.subClusterId} - - {STATUS_LABELS[it.status] || it.status} + + {getParentResolutionLabel(it.status, tc, lang)} {it.selectedParentMmsi || '-'} @@ -274,6 +265,6 @@ export function ParentReview() { )} - + ); } diff --git a/frontend/src/features/patrol/FleetOptimization.tsx b/frontend/src/features/patrol/FleetOptimization.tsx index a397f6c..c66e7d7 100644 --- a/frontend/src/features/patrol/FleetOptimization.tsx +++ b/frontend/src/features/patrol/FleetOptimization.tsx @@ -3,7 +3,10 @@ import { useTranslation } from 'react-i18next'; import { BaseMap, STATIC_LAYERS, createMarkerLayer, createPolylineLayer, createZoneLayer, useMapLayers, type MapHandle } from '@lib/map'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { Button } from '@shared/components/ui/button'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { Users, Ship, Target, BarChart3, Play, CheckCircle, AlertTriangle, Layers, RefreshCw } from 'lucide-react'; +import { getStatusIntent } from '@shared/constants/statusIntent'; import { usePatrolStore } from '@stores/patrolStore'; /* SFR-08: AI 경비함정 다함정 협력형 경로 최적화 서비스 */ @@ -96,24 +99,31 @@ export function FleetOptimization() { useMapLayers(mapRef, buildLayers, [ships, COVERAGE, FLEET_ROUTES]); return ( - - - - - {t('fleetOptimization.title')} - - ⚠ - 데모 데이터 (백엔드 API 미구현) - - - {t('fleetOptimization.desc')} - - - setSimRunning(true)} className="flex items-center gap-1 px-3 py-1.5 bg-purple-600 hover:bg-purple-500 text-heading text-[10px] font-bold rounded-lg">시뮬레이션 - setApproved(true)} disabled={!simRunning} - className="flex items-center gap-1 px-3 py-1.5 bg-green-600 hover:bg-green-500 disabled:opacity-30 text-heading text-[10px] font-bold rounded-lg">최종 승인 - - + + + setSimRunning(true)} icon={}> + 시뮬레이션 + + setApproved(true)} + disabled={!simRunning} + icon={} + className="bg-green-600 hover:bg-green-500 border-green-700" + > + 최종 승인 + + > + } + /> {/* KPI */} @@ -141,7 +151,7 @@ export function FleetOptimization() { {f.name} - {f.status} + {f.status} 구역: {f.zone}속력: {f.speed}연료: {f.fuel}% @@ -218,6 +228,6 @@ export function FleetOptimization() { - + ); } diff --git a/frontend/src/features/patrol/PatrolRoute.tsx b/frontend/src/features/patrol/PatrolRoute.tsx index 500af52..d098708 100644 --- a/frontend/src/features/patrol/PatrolRoute.tsx +++ b/frontend/src/features/patrol/PatrolRoute.tsx @@ -4,6 +4,8 @@ import type maplibregl from 'maplibre-gl'; import { BaseMap, STATIC_LAYERS, createMarkerLayer, createPolylineLayer, useMapLayers, type MapHandle } from '@lib/map'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { Button } from '@shared/components/ui/button'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { Navigation, Ship, MapPin, Clock, Wind, Anchor, Play, BarChart3, Target, Settings, CheckCircle, Share2 } from 'lucide-react'; import { usePatrolStore } from '@stores/patrolStore'; @@ -93,23 +95,24 @@ export function PatrolRoute() { if (!currentShip || !route) return null; return ( - - - - - {t('patrolRoute.title')} - - ⚠ - 데모 데이터 (백엔드 API 미구현) - - - {t('patrolRoute.desc')} - - - 경로 생성 - 공유 - - + + + }> + 경로 생성 + + }> + 공유 + + > + } + /> {/* 함정 선택 */} @@ -122,7 +125,7 @@ export function PatrolRoute() { className={`px-3 py-2 rounded-lg cursor-pointer transition-colors ${selectedShip === s.id ? 'bg-cyan-600/20 border border-cyan-500/30' : 'bg-surface-overlay border border-transparent hover:border-border'} ${s.status !== '가용' ? 'opacity-40 cursor-not-allowed' : ''}`}> {s.name} - {s.status} + {s.status} {s.class} · {s.speed} · {s.range} @@ -215,6 +218,6 @@ export function PatrolRoute() { - + ); } diff --git a/frontend/src/features/risk-assessment/EnforcementPlan.tsx b/frontend/src/features/risk-assessment/EnforcementPlan.tsx index d2ed00e..3d0979b 100644 --- a/frontend/src/features/risk-assessment/EnforcementPlan.tsx +++ b/frontend/src/features/risk-assessment/EnforcementPlan.tsx @@ -2,7 +2,10 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { Button } from '@shared/components/ui/button'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; +import { getRiskIntent, getStatusIntent } from '@shared/constants/statusIntent'; import { Shield, AlertTriangle, Ship, Plus, Calendar, Users } from 'lucide-react'; import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import type { MarkerData } from '@lib/map'; @@ -32,14 +35,14 @@ const cols: DataColumn[] = [ { key: 'id', label: 'ID', width: '70px', render: v => {v as string} }, { key: 'zone', label: '단속 구역', sortable: true, render: v => {v as string} }, { key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true, - render: v => { const n = v as number; return 80 ? 'bg-red-500/20 text-red-400' : n > 60 ? 'bg-orange-500/20 text-orange-400' : 'bg-yellow-500/20 text-yellow-400'}`}>{n}점; } }, + render: v => { const n = v as number; return {n}점; } }, { key: 'period', label: '단속 시간', width: '160px', render: v => {v as string} }, { key: 'ships', label: '참여 함정', render: v => {v as string} }, { key: 'crew', label: '인력', width: '50px', align: 'right', render: v => {v as number || '-'} }, { key: 'status', label: '상태', width: '70px', align: 'center', sortable: true, - render: v => { const s = v as string; return {s}; } }, + 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() { @@ -118,14 +121,18 @@ export function EnforcementPlan() { const totalCrew = PLANS.reduce((sum, p) => sum + p.crew, 0); return ( - - - - {t('enforcementPlan.title')} - {t('enforcementPlan.desc')} - - 단속 계획 수립 - + + }> + 단속 계획 수립 + + } + /> {/* 로딩/에러 상태 */} {loading && ( @@ -153,7 +160,7 @@ export function EnforcementPlan() { {[['위험도 ≥ 80', '상황실 즉시 경보 (알림+SMS)'], ['위험도 ≥ 60', '관련 부서 주의 알림'], ['위험도 ≥ 40', '참고 로그 기록']].map(([k, v]) => ( - {k} + {k} {v} ))} @@ -185,6 +192,6 @@ export function EnforcementPlan() { - + ); } diff --git a/frontend/src/features/risk-assessment/RiskMap.tsx b/frontend/src/features/risk-assessment/RiskMap.tsx index a041a0c..082deb4 100644 --- a/frontend/src/features/risk-assessment/RiskMap.tsx +++ b/frontend/src/features/risk-assessment/RiskMap.tsx @@ -2,6 +2,8 @@ import { useState, useRef, useCallback } from 'react'; import { BaseMap, STATIC_LAYERS, createHeatmapLayer, useMapLayers, type MapHandle } from '@lib/map'; import type { HeatPoint } from '@lib/map'; import { Card, CardContent } from '@shared/components/ui/card'; +import { Button } from '@shared/components/ui/button'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { Map, Layers, Clock, BarChart3, AlertTriangle, Printer, Download, Ship, TrendingUp } from 'lucide-react'; import { AreaChart as EcAreaChart, LineChart as EcLineChart, PieChart as EcPieChart, BarChart as EcBarChart } from '@lib/charts'; @@ -175,20 +177,24 @@ export function RiskMap() { useMapLayers(mapRef, buildLayers, [tab]); return ( - - - - - 격자 기반 불법조업 위험도 지도 + + {COLLECTING_BADGE} - - SFR-05 | 위험도 히트맵 (수집 중) + MTIS 해양사고 통계 (중앙해양안전심판원) - - - 인쇄 - 이미지 - - + }> + 인쇄 + + }> + 이미지 + + > + } + /> {/* 탭 */} @@ -200,7 +206,7 @@ export function RiskMap() { { key: 'timeStat' as Tab, icon: Clock, label: '시간적 특성별' }, { key: 'accRate' as Tab, icon: BarChart3, label: '사고율' }, ]).map(t => ( - setTab(t.key)} + setTab(t.key)} className={`flex items-center gap-1.5 px-4 py-2 text-[11px] font-medium border-b-2 ${tab === t.key ? 'text-red-400 border-red-400' : 'text-hint border-transparent hover:text-label'}`}> {t.label} @@ -489,6 +495,6 @@ export function RiskMap() { )} - + ); } diff --git a/frontend/src/features/statistics/ExternalService.tsx b/frontend/src/features/statistics/ExternalService.tsx index bdc24a0..776ad2c 100644 --- a/frontend/src/features/statistics/ExternalService.tsx +++ b/frontend/src/features/statistics/ExternalService.tsx @@ -1,8 +1,10 @@ import { useTranslation } from 'react-i18next'; -import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; -import { Globe, Shield, Clock, BarChart3, ExternalLink, Lock, Unlock } from 'lucide-react'; +import { getStatusIntent } from '@shared/constants/statusIntent'; +import type { BadgeIntent } from '@lib/theme/variants'; +import { Globe } from 'lucide-react'; /* SFR-14: 외부 서비스(예보·경보) 제공 결과 연계 */ @@ -18,29 +20,31 @@ 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', - render: v => { const p = v as string; const c = p === '비공개' ? 'bg-red-500/20 text-red-400' : p === '비식별' ? 'bg-yellow-500/20 text-yellow-400' : p === '익명화' ? 'bg-blue-500/20 text-blue-400' : 'bg-green-500/20 text-green-400'; return {p}; } }, + render: v => { + const p = v as string; + const intent: BadgeIntent = p === '비공개' ? 'critical' : p === '비식별' ? 'warning' : p === '익명화' ? 'info' : 'success'; + return {p}; + } }, { key: 'status', label: '상태', width: '60px', align: 'center', sortable: true, - render: v => { const s = v as string; const c = s === '운영' ? 'bg-green-500/20 text-green-400' : s === '테스트' ? 'bg-blue-500/20 text-blue-400' : 'bg-muted text-muted-foreground'; return {s}; } }, + render: v => { const s = v as string; return {s}; } }, { key: 'calls', label: '호출 수', width: '70px', align: 'right', render: v => {v as string} }, ]; export function ExternalService() { const { t } = useTranslation('statistics'); return ( - - - - {t('externalService.title')} - - ⚠데모 데이터 (백엔드 API 미구현) - - - {t('externalService.desc')} - + + {[{ l: '운영 서비스', v: DATA.filter(d => d.status === '운영').length, c: 'text-green-400' }, { l: '테스트', v: DATA.filter(d => d.status === '테스트').length, c: 'text-blue-400' }, { l: '총 호출', v: '21,675', c: 'text-heading' }].map(k => ( @@ -49,6 +53,6 @@ export function ExternalService() { ))} - + ); } diff --git a/frontend/src/features/statistics/ReportManagement.tsx b/frontend/src/features/statistics/ReportManagement.tsx index b6b1277..48138b8 100644 --- a/frontend/src/features/statistics/ReportManagement.tsx +++ b/frontend/src/features/statistics/ReportManagement.tsx @@ -2,28 +2,32 @@ 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 { Button } from '@shared/components/ui/button'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { ExcelExport } from '@shared/components/common/ExcelExport'; import { PrintButton } from '@shared/components/common/PrintButton'; import { FileUpload } from '@shared/components/common/FileUpload'; import { SaveButton } from '@shared/components/common/SaveButton'; import { SearchInput } from '@shared/components/common/SearchInput'; -import { Plus, Download, Clock, MapPin, Upload, X } from 'lucide-react'; +import { FileText, Plus, Upload, X, Clock, MapPin, Download } from 'lucide-react'; + +import type { BadgeIntent } from '@lib/theme/variants'; interface Report { id: string; name: string; type: string; status: string; - statusColor: string; + statusIntent: BadgeIntent; date: string; mmsiNote: string; evidence: number; } const reports: Report[] = [ - { id: 'RPT-2024-0142', name: '浙江렌센號', type: 'EEZ 침범', status: 'EEZ', statusColor: 'bg-green-500', date: '2026-01-20 14:30:00', mmsiNote: 'MMSI 변조', evidence: 12 }, - { id: 'RPT-2024-0231', name: '福建海丰號', type: 'EEZ 침범', status: '확인', statusColor: 'bg-blue-500', date: '2026-01-20 14:29:00', mmsiNote: '', evidence: 8 }, - { id: 'RPT-2024-0089', name: '무명선박-A', type: '다크베셀', status: '처리중', statusColor: 'bg-yellow-500', date: '2026-01-20 14:05:00', mmsiNote: '', evidence: 6 }, + { id: 'RPT-2024-0142', name: '浙江렌센號', type: 'EEZ 침범', status: 'EEZ', statusIntent: 'success', date: '2026-01-20 14:30:00', mmsiNote: 'MMSI 변조', evidence: 12 }, + { id: 'RPT-2024-0231', name: '福建海丰號', type: 'EEZ 침범', status: '확인', statusIntent: 'info', date: '2026-01-20 14:29:00', mmsiNote: '', evidence: 8 }, + { id: 'RPT-2024-0089', name: '무명선박-A', type: '다크베셀', status: '처리중', statusIntent: 'warning', date: '2026-01-20 14:05:00', mmsiNote: '', evidence: 6 }, ]; export function ReportManagement() { @@ -37,47 +41,47 @@ export function ReportManagement() { ); return ( - - - - - {t('reports.title')} - - ⚠데모 데이터 (백엔드 API 미구현) - - - {t('reports.desc')} - - - - []} - columns={[ - { key: 'id', label: '보고서번호' }, { key: 'name', label: '선박명' }, - { key: 'type', label: '유형' }, { key: 'status', label: '상태' }, - { key: 'date', label: '일시' }, { key: 'evidence', label: '증거수' }, - ]} - filename="보고서목록" - /> - - setShowUpload(!showUpload)} - className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading transition-colors" - > - 증거 업로드 - - - 새 보고서 - - - + + + + []} + columns={[ + { key: 'id', label: '보고서번호' }, { key: 'name', label: '선박명' }, + { key: 'type', label: '유형' }, { key: 'status', label: '상태' }, + { key: 'date', label: '일시' }, { key: 'evidence', label: '증거수' }, + ]} + filename="보고서목록" + /> + + setShowUpload(!showUpload)} + icon={} + > + 증거 업로드 + + }> + 새 보고서 + + > + } + /> {/* 증거 파일 업로드 */} {showUpload && ( 증거 파일 업로드 (사진·영상·문서) - setShowUpload(false)} className="text-hint hover:text-muted-foreground"> + setShowUpload(false)} className="text-hint hover:text-muted-foreground"> @@ -104,7 +108,7 @@ export function ReportManagement() { > {r.name} - {r.status} + {r.status} {r.id} @@ -116,8 +120,8 @@ export function ReportManagement() { 증거 {r.evidence}건 - PDF - 한글 + PDF + 한글 ))} @@ -131,7 +135,7 @@ export function ReportManagement() { 보고서 미리보기 - + 다운로드 @@ -186,6 +190,6 @@ export function ReportManagement() { )} - + ); } diff --git a/frontend/src/features/statistics/Statistics.tsx b/frontend/src/features/statistics/Statistics.tsx index 231357a..726661f 100644 --- a/frontend/src/features/statistics/Statistics.tsx +++ b/frontend/src/features/statistics/Statistics.tsx @@ -2,6 +2,8 @@ import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { Button } from '@shared/components/ui/button'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { BarChart3, Download } from 'lucide-react'; import { BarChart, AreaChart } from '@lib/charts'; @@ -15,6 +17,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 +64,7 @@ const kpiCols: DataColumn[] = [ width: '60px', align: 'center', render: (v) => ( - + {v as string} ), @@ -69,6 +73,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([]); @@ -136,22 +142,18 @@ export function Statistics() { }); return ( - - - - - - {t('statistics.title')} - - - {t('statistics.desc')} - - - - - 보고서 생성 - - + + }> + 보고서 생성 + + } + /> {loading && ( @@ -205,20 +207,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}% - - ))} + ); + })} @@ -233,6 +240,6 @@ export function Statistics() { searchPlaceholder="지표명 검색..." exportFilename="성과지표" /> - + ); } diff --git a/frontend/src/features/surveillance/LiveMapView.tsx b/frontend/src/features/surveillance/LiveMapView.tsx index 69103aa..9b81840 100644 --- a/frontend/src/features/surveillance/LiveMapView.tsx +++ b/frontend/src/features/surveillance/LiveMapView.tsx @@ -4,6 +4,7 @@ import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLay import type { MarkerData } from '@lib/map'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { PageContainer } from '@shared/components/layout'; import { AlertTriangle, Ship, Radio, Zap, Activity, Clock, Pin, Loader2, WifiOff } from 'lucide-react'; import { fetchVesselAnalysis, @@ -14,13 +15,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 +166,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, @@ -233,7 +228,7 @@ export function LiveMapView() { }, [selectedEvent]); return ( - + {/* 좌측: 이벤트 목록 + 지도 */} {/* 이벤트 카드 목록 */} @@ -294,7 +289,7 @@ export function LiveMapView() { {/* 지도 영역 */} - + {/* 범례 */} 선박 범례 @@ -367,7 +362,7 @@ export function LiveMapView() { AI 판단 근거 - 신뢰도: High + 신뢰도: High @@ -397,6 +392,6 @@ export function LiveMapView() { )} - + ); } diff --git a/frontend/src/features/surveillance/MapControl.tsx b/frontend/src/features/surveillance/MapControl.tsx index 1b4a46d..60a0657 100644 --- a/frontend/src/features/surveillance/MapControl.tsx +++ b/frontend/src/features/surveillance/MapControl.tsx @@ -2,8 +2,11 @@ import { useState, useRef, useCallback } from 'react'; import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map'; import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { PageContainer, PageHeader } from '@shared/components/layout'; 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'; +import type { BadgeIntent } from '@lib/theme/variants'; /* * 해역 통제 — 한국연안 해상사격 훈련구역도 (No.462) 반영 @@ -126,40 +129,32 @@ const ntmColumns: DataColumn[] = [ { key: 'category', label: '구분', width: '70px', align: 'center', sortable: true, render: v => { const c = v as string; - const color = c.includes('사격') || c.includes('군사') ? 'bg-red-500/20 text-red-400' - : c.includes('기뢰') ? 'bg-orange-500/20 text-orange-400' - : c.includes('오염') ? 'bg-yellow-500/20 text-yellow-400' - : 'bg-blue-500/20 text-blue-400'; - return {c}; + const intent: BadgeIntent = c.includes('사격') || c.includes('군사') ? 'critical' + : c.includes('기뢰') ? 'high' + : c.includes('오염') ? 'warning' + : 'info'; + return {c}; }, }, { key: 'sea', label: '해역', width: '50px', sortable: true }, { key: 'title', label: '제목', sortable: true, render: v => {v as string} }, { key: 'position', label: '위치', width: '120px', render: v => {v as string} }, { key: 'status', label: '상태', width: '70px', align: 'center', sortable: true, - render: v => {v as string} }, + 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} }, { key: 'radius', label: '반경', width: '60px', align: 'center' }, { key: 'status', label: '상태', width: '60px', align: 'center', sortable: true, - render: v => {v as string} }, + render: v => {v as string} }, { key: 'schedule', label: '운용', width: '60px', align: 'center' }, { key: 'note', label: '비고', render: v => {v as string} }, ]; @@ -214,7 +209,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 }); @@ -251,18 +246,14 @@ export function MapControl() { useMapLayers(mapRef, buildLayers, [visibleZones]); return ( - - - - - 해역 통제 - - ⚠데모 데이터 (백엔드 API 미구현) - - - 한국연안 해상사격 훈련구역도 No.462 | Chart of Firing and Bombing Exercise Areas | WGS-84 | 출처: 국립해양조사원 - - + + {/* KPI */} @@ -285,12 +276,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} + + ); + })} {/* 탭 + 해역 필터 */} @@ -305,7 +300,7 @@ export function MapControl() { { key: 'kcg' as Tab, label: '해경', icon: Anchor }, { key: 'ntm' as Tab, label: '항행통보', icon: Bell }, ]).map(t => ( - setTab(t.key)} + setTab(t.key)} className={`flex items-center gap-1.5 px-4 py-2 text-[11px] font-medium border-b-2 ${tab === t.key ? 'text-cyan-400 border-cyan-400' : 'text-hint border-transparent hover:text-label'}`}> {t.label} @@ -314,8 +309,8 @@ export function MapControl() { {['', '서해', '남해', '동해', '제주'].map(s => ( - setSeaFilter(s)} - className={`px-2.5 py-1 rounded text-[10px] ${seaFilter === s ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}> + setSeaFilter(s)} + className={`px-2.5 py-1 rounded text-[10px] ${seaFilter === s ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}> {s || '전체'} ))} @@ -345,8 +340,8 @@ export function MapControl() { 구분: {NTM_CATEGORIES.map(c => ( - setNtmCatFilter(c === '전체' ? '' : c)} - className={`px-2.5 py-1 rounded text-[10px] ${(c === '전체' && !ntmCatFilter) || ntmCatFilter === c ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`}>{c} + setNtmCatFilter(c === '전체' ? '' : c)} + className={`px-2.5 py-1 rounded text-[10px] ${(c === '전체' && !ntmCatFilter) || ntmCatFilter === c ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>{c} ))} @@ -358,7 +353,7 @@ export function MapControl() { {NTM_DATA.filter(n => n.status === '발령중').map(n => ( - {n.category} + {n.category} {n.title} {n.detail} @@ -397,12 +392,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 @@ -427,6 +426,6 @@ export function MapControl() { /> > )} - + ); } diff --git a/frontend/src/features/vessel/TransferDetection.tsx b/frontend/src/features/vessel/TransferDetection.tsx index 3c3da5f..0ca79c5 100644 --- a/frontend/src/features/vessel/TransferDetection.tsx +++ b/frontend/src/features/vessel/TransferDetection.tsx @@ -1,13 +1,17 @@ import { Card, CardContent } from '@shared/components/ui/card'; +import { PageContainer, PageHeader } from '@shared/components/layout'; +import { RefreshCw } from 'lucide-react'; import { RealTransshipSuspects } from '@features/detection/RealVesselAnalysis'; export function TransferDetection() { return ( - - - 환적·접촉 탐지 - 선박 간 근접 접촉 및 환적 의심 행위 분석 - + + {/* prediction 분석 결과 기반 실시간 환적 의심 선박 */} @@ -32,6 +36,6 @@ export function TransferDetection() { - + ); } diff --git a/frontend/src/features/vessel/VesselDetail.tsx b/frontend/src/features/vessel/VesselDetail.tsx index 4f73499..b97d198 100644 --- a/frontend/src/features/vessel/VesselDetail.tsx +++ b/frontend/src/features/vessel/VesselDetail.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { useParams } from 'react-router-dom'; import { Badge } from '@shared/components/ui/badge'; +import { PageContainer } from '@shared/components/layout'; import { Search, Ship, AlertTriangle, Radar, MapPin, Printer, @@ -14,6 +15,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 +51,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,13 +148,17 @@ 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 ( - + {/* ── 좌측: 선박 정보 패널 ── */} @@ -167,20 +167,20 @@ export function VesselDetail() { 선박 상세 조회 시작/종료 - setStartDate(e.target.value)} + setStartDate(e.target.value)} className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50" placeholder="YYYY-MM-DD HH:mm" /> ~ - setEndDate(e.target.value)} + setEndDate(e.target.value)} className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50" placeholder="YYYY-MM-DD HH:mm" /> MMSI - setSearchMmsi(e.target.value)} + setSearchMmsi(e.target.value)} placeholder="MMSI 입력" className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none" /> - + 검색 @@ -280,12 +280,12 @@ export function VesselDetail() { 위험도 - - {riskConfig.label} + + {getAlertLevelLabel(riskLevel, tc, lang)} - + {Math.round(riskScore * 100)} /100 @@ -341,15 +341,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 +377,8 @@ export function VesselDetail() { MMSI: {mmsiParam} {vessel && ( - - 위험도: {riskConfig.label} + + 위험도: {getAlertLevelLabel(riskLevel, tc, lang)} )} @@ -415,20 +414,20 @@ export function VesselDetail() { {/* ── 우측 도구바 ── */} {RIGHT_TOOLS.map((t) => ( - + {t.label} ))} - + + + - - + - - 범례 - 미니맵 - AI모드 + 범례 + 미니맵 + AI모드 - + ); } diff --git a/frontend/src/flow/components/FilterBar.tsx b/frontend/src/flow/components/FilterBar.tsx index 73a478e..a950951 100644 --- a/frontend/src/flow/components/FilterBar.tsx +++ b/frontend/src/flow/components/FilterBar.tsx @@ -74,7 +74,7 @@ export function FilterBar({ filter, onChange, groupBy, onGroupByChange, meta }: v{meta.version} · {meta.releaseDate} · 노드 {meta.nodeCount} · 엣지 {meta.edgeCount} - { + key: K; + titleKo: string; + titleEn: string; + description: string; +} + +// ── Badge intent 의미 ────────────────────────── +export const BADGE_INTENT_META: Record> = { + critical: { + key: 'critical', + titleKo: '심각', + titleEn: 'Critical', + description: '심각 · 긴급 · 위험 (빨강 계열)', + }, + high: { + key: 'high', + titleKo: '높음', + titleEn: 'High', + description: '높음 · 경고 (주황 계열)', + }, + warning: { + key: 'warning', + titleKo: '주의', + titleEn: 'Warning', + description: '주의 · 보류 (노랑 계열)', + }, + info: { + key: 'info', + titleKo: '정보', + titleEn: 'Info', + description: '일반 · 정보 (파랑 계열, 기본값)', + }, + success: { + key: 'success', + titleKo: '성공', + titleEn: 'Success', + description: '성공 · 완료 · 정상 (초록 계열)', + }, + muted: { + key: 'muted', + titleKo: '중립', + titleEn: 'Muted', + description: '비활성 · 중립 · 기타 (회색 계열)', + }, + purple: { + key: 'purple', + titleKo: '분석', + titleEn: 'Purple', + description: '분석 · AI · 특수 (보라 계열)', + }, + cyan: { + key: 'cyan', + titleKo: '모니터', + titleEn: 'Cyan', + description: '모니터링 · 스트림 (청록 계열)', + }, +}; + +export const BADGE_INTENT_ORDER: BadgeIntent[] = [ + 'critical', + 'high', + 'warning', + 'info', + 'success', + 'muted', + 'purple', + 'cyan', +]; + +export const BADGE_SIZE_ORDER: BadgeSize[] = ['xs', 'sm', 'md', 'lg']; + +// ── Button variant 의미 ──────────────────────── +export const BUTTON_VARIANT_META: Record> = { + primary: { + key: 'primary', + titleKo: '주요', + titleEn: 'Primary', + description: '주요 액션 · 기본 CTA (페이지당 1개 권장)', + }, + secondary: { + key: 'secondary', + titleKo: '보조', + titleEn: 'Secondary', + description: '보조 액션 · 툴바 버튼 (기본값)', + }, + ghost: { + key: 'ghost', + titleKo: '고요', + titleEn: 'Ghost', + description: '고요한 액션 · 리스트 행 내부', + }, + outline: { + key: 'outline', + titleKo: '윤곽', + titleEn: 'Outline', + description: '강조 보조 · 필터 활성화 상태', + }, + destructive: { + key: 'destructive', + titleKo: '위험', + titleEn: 'Destructive', + description: '삭제 · 비활성화 등 위험 액션', + }, +}; + +export const BUTTON_VARIANT_ORDER: ButtonVariant[] = [ + 'primary', + 'secondary', + 'ghost', + 'outline', + 'destructive', +]; + +export const BUTTON_SIZE_ORDER: ButtonSize[] = ['sm', 'md', 'lg']; diff --git a/frontend/src/lib/theme/variants.ts b/frontend/src/lib/theme/variants.ts index a9d08a8..e382698 100644 --- a/frontend/src/lib/theme/variants.ts +++ b/frontend/src/lib/theme/variants.ts @@ -17,29 +17,47 @@ export const cardVariants = cva('rounded-xl border border-border', { defaultVariants: { variant: 'default' }, }); -/** 뱃지 변형 — 위험도/상태별 150회+ 반복 패턴 통합 */ +/** 뱃지 변형 — 위험도/상태별 150회+ 반복 패턴 통합 + * + * 가독성 정책 (테마별 팔레트 분리 + classes 기반 카탈로그와 통일): + * - **다크 모드**: 반투명 배경(-500/20) + 밝은 글자(-400) + 반투명 보더(-500/30) + * → 어두운 페이지에서 은은하게 녹아들며 글자 강조 (classes 기반 4종과 동일 패턴) + * - **라이트 모드**: 파스텔 배경(-100) + 진한 글자(-900) + 부드러운 보더(-300) + * → 흰 배경에 어울리는 소프트 토큰, 글자와 배경 대비 충분 + * - 가운데 정렬 + * - 폰트 크기: rem 기반 — 화면 비율 대비 자동 조정 + * + * className override는 cn(tailwind-merge) 덕분에 같은 그룹 충돌 시 마지막 명시값 적용. + */ 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', { 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-100 text-red-900 border-red-300 dark:bg-red-500/20 dark:text-red-400 dark:border-red-500/30', + high: 'bg-orange-100 text-orange-900 border-orange-300 dark:bg-orange-500/20 dark:text-orange-400 dark:border-orange-500/30', + warning: + 'bg-yellow-100 text-yellow-900 border-yellow-300 dark:bg-yellow-500/20 dark:text-yellow-400 dark:border-yellow-500/30', + info: 'bg-blue-100 text-blue-900 border-blue-300 dark:bg-blue-500/20 dark:text-blue-400 dark:border-blue-500/30', + success: + 'bg-green-100 text-green-900 border-green-300 dark:bg-green-500/20 dark:text-green-400 dark:border-green-500/30', + muted: + 'bg-slate-100 text-slate-700 border-slate-300 dark:bg-slate-500/20 dark:text-slate-300 dark:border-slate-500/30', + purple: + 'bg-purple-100 text-purple-900 border-purple-300 dark:bg-purple-500/20 dark:text-purple-400 dark:border-purple-500/30', + cyan: 'bg-cyan-100 text-cyan-900 border-cyan-300 dark:bg-cyan-500/20 dark:text-cyan-400 dark:border-cyan-500/30', }, + // 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' }, }, ); @@ -65,3 +83,89 @@ export type CardVariant = 'default' | 'elevated' | 'inner' | 'transparent'; export type BadgeIntent = 'critical' | 'high' | 'warning' | 'info' | 'success' | 'muted' | 'purple' | 'cyan'; export type BadgeSize = 'xs' | 'sm' | 'md' | 'lg'; export type StatusDotStatus = 'online' | 'warning' | 'danger' | 'offline'; + +/** Button 변형 — 5 variant × 3 size = 15 + * + * 색상 정책: + * - primary/destructive: 솔리드 배경 + text-on-vivid (흰색, 테마 무관) + * - secondary/ghost/outline: 텍스트 토큰 (text-label / text-heading on hover) + */ +export const buttonVariants = cva( + 'inline-flex items-center justify-center gap-1.5 whitespace-nowrap font-medium rounded-md transition-colors ' + + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 ' + + 'disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + primary: 'bg-blue-600 hover:bg-blue-500 text-on-vivid border border-blue-700', + secondary: + 'bg-surface-overlay hover:bg-surface-raised text-label hover:text-heading border border-slate-600/40', + ghost: 'bg-transparent hover:bg-surface-overlay text-label hover:text-heading border border-transparent', + outline: + 'bg-transparent hover:bg-blue-500/10 text-blue-400 hover:text-blue-300 border border-blue-500/50', + destructive: 'bg-red-600 hover:bg-red-500 text-on-vivid border border-red-700', + }, + size: { + sm: 'h-7 px-2.5 text-xs', + md: 'h-8 px-3 text-sm', + lg: 'h-10 px-4 text-base', + }, + }, + defaultVariants: { + variant: 'secondary', + size: 'md', + }, + }, +); + +export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'outline' | 'destructive'; +export type ButtonSize = 'sm' | 'md' | 'lg'; + +/** Input / Select / 유사 폼 요소 공통 변형 */ +export const inputVariants = cva( + 'w-full bg-surface-overlay border rounded-md text-label placeholder:text-hint ' + + 'focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/30 ' + + 'transition-colors disabled:opacity-50 disabled:cursor-not-allowed', + { + variants: { + size: { + sm: 'h-7 px-2 text-xs', + md: 'h-8 px-3 text-sm', + lg: 'h-10 px-3.5 text-base', + }, + state: { + default: 'border-slate-600/40', + error: 'border-red-500/60 focus:border-red-500 focus:ring-red-500/30', + success: 'border-green-500/60 focus:border-green-500 focus:ring-green-500/30', + }, + }, + defaultVariants: { + size: 'md', + state: 'default', + }, + }, +); + +export type InputSize = 'sm' | 'md' | 'lg'; +export type InputState = 'default' | 'error' | 'success'; + +/** PageContainer 변형 — 표준 페이지 루트 padding/spacing */ +export const pageContainerVariants = cva('', { + variants: { + size: { + sm: 'p-4 space-y-3', + md: 'p-5 space-y-4', + lg: 'p-6 space-y-4', + }, + fullBleed: { + true: 'p-0 space-y-0', + false: '', + }, + }, + defaultVariants: { + size: 'md', + fullBleed: false, + }, +}); + +export type PageContainerSize = 'sm' | 'md' | 'lg'; 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 ( + { + onChange(color); + setCustomHex(color); + }} + className={`w-6 h-6 rounded-md border-2 transition-all flex items-center justify-center ${ + selected ? 'border-white scale-110' : 'border-transparent hover:border-white/40' + }`} + style={{ backgroundColor: color }} + title={color} + > + {selected && } + + ); + })} + + {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..74a5f23 100644 --- a/frontend/src/shared/components/common/ExcelExport.tsx +++ b/frontend/src/shared/components/common/ExcelExport.tsx @@ -56,12 +56,13 @@ export function ExcelExport({ return ( {exported ? : } diff --git a/frontend/src/shared/components/common/FileUpload.tsx b/frontend/src/shared/components/common/FileUpload.tsx index 7a7a28f..fcb1793 100644 --- a/frontend/src/shared/components/common/FileUpload.tsx +++ b/frontend/src/shared/components/common/FileUpload.tsx @@ -110,7 +110,7 @@ export function FileUpload({ {f.file.name} {formatSize(f.file.size)} {f.msg && {f.msg}} - removeFile(i)} className="text-hint hover:text-muted-foreground shrink-0"> + removeFile(i)} className="text-hint hover:text-muted-foreground shrink-0"> diff --git a/frontend/src/shared/components/common/NotificationBanner.tsx b/frontend/src/shared/components/common/NotificationBanner.tsx index daf434f..9fcf6f8 100644 --- a/frontend/src/shared/components/common/NotificationBanner.tsx +++ b/frontend/src/shared/components/common/NotificationBanner.tsx @@ -79,6 +79,8 @@ export function NotificationBanner({ notices, userRole }: NotificationBannerProp {notice.dismissible && ( dismiss(notice.id)} className="text-hint hover:text-muted-foreground shrink-0" > @@ -145,7 +147,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/PrintButton.tsx b/frontend/src/shared/components/common/PrintButton.tsx index ddf8664..afc259d 100644 --- a/frontend/src/shared/components/common/PrintButton.tsx +++ b/frontend/src/shared/components/common/PrintButton.tsx @@ -41,6 +41,7 @@ export function PrintButton({ targetRef, label = '출력', className = '' }: Pri return ( diff --git a/frontend/src/shared/components/common/SaveButton.tsx b/frontend/src/shared/components/common/SaveButton.tsx index 3b35518..fb5d3ad 100644 --- a/frontend/src/shared/components/common/SaveButton.tsx +++ b/frontend/src/shared/components/common/SaveButton.tsx @@ -29,12 +29,13 @@ export function SaveButton({ onClick, label = '저장', disabled = false, classN return ( {state === 'saving' ? diff --git a/frontend/src/shared/components/common/SearchInput.tsx b/frontend/src/shared/components/common/SearchInput.tsx index 9fa04f0..40547b6 100644 --- a/frontend/src/shared/components/common/SearchInput.tsx +++ b/frontend/src/shared/components/common/SearchInput.tsx @@ -17,6 +17,7 @@ export function SearchInput({ value, onChange, placeholder = '검색...', classN onChange(e.target.value)} placeholder={placeholder} @@ -24,6 +25,8 @@ export function SearchInput({ value, onChange, placeholder = '검색...', classN /> {value && ( onChange('')} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-hint hover:text-muted-foreground" > diff --git a/frontend/src/shared/components/layout/PageContainer.tsx b/frontend/src/shared/components/layout/PageContainer.tsx new file mode 100644 index 0000000..77d6a9b --- /dev/null +++ b/frontend/src/shared/components/layout/PageContainer.tsx @@ -0,0 +1,34 @@ +import { type HTMLAttributes, type ReactNode } from 'react'; +import { pageContainerVariants, type PageContainerSize } from '@lib/theme/variants'; +import { cn } from '@lib/utils/cn'; + +export interface PageContainerProps extends HTMLAttributes { + size?: PageContainerSize; + /** 지도/3단 패널 등 풀화면 페이지: padding 0, space-y 0 */ + fullBleed?: boolean; + children: ReactNode; +} + +/** + * PageContainer — 모든 feature 페이지의 표준 루트 컨테이너 + * + * 기본값: `p-5 space-y-4` (프로젝트 전역 기준) + * - size="sm" → p-4 space-y-3 + * - size="lg" → p-6 space-y-4 (admin 계열) + * - fullBleed → padding 없음 (LiveMapView, VesselDetail 패턴) + * + * ESLint rule로 feature 페이지 루트는 PageContainer 필수 강제 (예정). + */ +export function PageContainer({ + className, + size, + fullBleed, + children, + ...props +}: PageContainerProps) { + return ( + + {children} + + ); +} diff --git a/frontend/src/shared/components/layout/PageHeader.tsx b/frontend/src/shared/components/layout/PageHeader.tsx new file mode 100644 index 0000000..b1ea48a --- /dev/null +++ b/frontend/src/shared/components/layout/PageHeader.tsx @@ -0,0 +1,63 @@ +import { type ReactNode } from 'react'; +import { type LucideIcon } from 'lucide-react'; +import { cn } from '@lib/utils/cn'; +import { Badge } from '@shared/components/ui/badge'; + +/** + * PageHeader — 페이지 최상단 제목/설명/액션 헤더 + * + * 구성: + * [좌] 아이콘 + 제목 + 설명 + (선택) 데모 배지 + * [우] 액션 슬롯 (검색/필터/버튼 등) + * + * 사용: + * + * + * 저장} + * /> + */ +export interface PageHeaderProps { + title: string; + description?: string; + icon?: LucideIcon; + /** 아이콘 색상 클래스 (text-blue-400 등). 미지정 시 text-muted-foreground */ + iconColor?: string; + /** 데모 데이터 배지 노출 */ + demo?: boolean; + /** 우측 액션 슬롯 */ + actions?: ReactNode; + className?: string; +} + +export function PageHeader({ + title, + description, + icon: Icon, + iconColor = 'text-muted-foreground', + demo = false, + actions, + className, +}: PageHeaderProps) { + return ( + + + + {Icon && } + {title} + {demo && ( + + 데모 데이터 + + )} + + {description && {description}} + + {actions && {actions}} + + ); +} diff --git a/frontend/src/shared/components/layout/Section.tsx b/frontend/src/shared/components/layout/Section.tsx new file mode 100644 index 0000000..7d4d572 --- /dev/null +++ b/frontend/src/shared/components/layout/Section.tsx @@ -0,0 +1,51 @@ +import { type ReactNode } from 'react'; +import { type LucideIcon } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardContent } from '@shared/components/ui/card'; +import type { CardVariant } from '@lib/theme/variants'; +import { cn } from '@lib/utils/cn'; + +/** + * Section — Card + CardHeader + CardTitle + CardContent 조합을 한 번에 + * + * 사용: + * + * {...} + * + */ +export interface SectionProps { + title?: string; + icon?: LucideIcon; + iconColor?: string; + variant?: CardVariant; + /** 우측 액션 슬롯 (제목 옆 버튼 등) */ + actions?: ReactNode; + children: ReactNode; + className?: string; + contentClassName?: string; +} + +export function Section({ + title, + icon: Icon, + iconColor = 'text-muted-foreground', + variant = 'default', + actions, + children, + className, + contentClassName, +}: SectionProps) { + return ( + + {title && ( + + + {Icon && } + {title} + + {actions && {actions}} + + )} + {children} + + ); +} diff --git a/frontend/src/shared/components/layout/index.ts b/frontend/src/shared/components/layout/index.ts new file mode 100644 index 0000000..7d7160e --- /dev/null +++ b/frontend/src/shared/components/layout/index.ts @@ -0,0 +1,3 @@ +export { PageContainer, type PageContainerProps } from './PageContainer'; +export { PageHeader, type PageHeaderProps } from './PageHeader'; +export { Section, type SectionProps } from './Section'; 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/components/ui/button.tsx b/frontend/src/shared/components/ui/button.tsx new file mode 100644 index 0000000..6edb909 --- /dev/null +++ b/frontend/src/shared/components/ui/button.tsx @@ -0,0 +1,35 @@ +import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'; +import { buttonVariants, type ButtonVariant, type ButtonSize } from '@lib/theme/variants'; +import { cn } from '@lib/utils/cn'; + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; + icon?: ReactNode; + trailingIcon?: ReactNode; +} + +/** + * Button — 프로젝트 표준 버튼 + * variant: primary / secondary / ghost / outline / destructive + * size: sm / md / lg + * className override는 cn()으로 안전하게 허용됨. + * + * **접근성 정책**: + * - 텍스트 children이 있는 버튼: 그 텍스트가 접근 이름 + * - 아이콘 전용 버튼 (children 없이 icon만): 반드시 aria-label 또는 title 필수 + * 예) } /> + * - 위반 시 스크린 리더가 용도를 인지할 수 없어 WCAG 2.1 Level A 위반 + */ +export const Button = forwardRef( + ({ className, variant, size, icon, trailingIcon, children, ...props }, ref) => { + return ( + + {icon} + {children} + {trailingIcon} + + ); + }, +); +Button.displayName = 'Button'; diff --git a/frontend/src/shared/components/ui/checkbox.tsx b/frontend/src/shared/components/ui/checkbox.tsx new file mode 100644 index 0000000..e11ebed --- /dev/null +++ b/frontend/src/shared/components/ui/checkbox.tsx @@ -0,0 +1,30 @@ +import { forwardRef, type InputHTMLAttributes } from 'react'; +import { cn } from '@lib/utils/cn'; + +export interface CheckboxProps extends Omit, 'type'> { + label?: string; +} + +/** Checkbox — native input에 라벨/스타일만 씌운 가벼운 래퍼 */ +export const Checkbox = forwardRef( + ({ className, label, id, ...props }, ref) => { + const inputId = id ?? `cb-${Math.random().toString(36).slice(2, 8)}`; + return ( + + + {label && {label}} + + ); + }, +); +Checkbox.displayName = 'Checkbox'; diff --git a/frontend/src/shared/components/ui/input.tsx b/frontend/src/shared/components/ui/input.tsx new file mode 100644 index 0000000..f5904aa --- /dev/null +++ b/frontend/src/shared/components/ui/input.tsx @@ -0,0 +1,23 @@ +import { forwardRef, type InputHTMLAttributes } from 'react'; +import { inputVariants, type InputSize, type InputState } from '@lib/theme/variants'; +import { cn } from '@lib/utils/cn'; + +export interface InputProps extends Omit, 'size'> { + size?: InputSize; + state?: InputState; +} + +/** Input — 프로젝트 표준 입력 필드 */ +export const Input = forwardRef( + ({ className, size, state, type = 'text', ...props }, ref) => { + return ( + + ); + }, +); +Input.displayName = 'Input'; diff --git a/frontend/src/shared/components/ui/radio.tsx b/frontend/src/shared/components/ui/radio.tsx new file mode 100644 index 0000000..26efb9d --- /dev/null +++ b/frontend/src/shared/components/ui/radio.tsx @@ -0,0 +1,29 @@ +import { forwardRef, type InputHTMLAttributes } from 'react'; +import { cn } from '@lib/utils/cn'; + +export interface RadioProps extends Omit, 'type'> { + label?: string; +} + +export const Radio = forwardRef( + ({ className, label, id, ...props }, ref) => { + const inputId = id ?? `rd-${Math.random().toString(36).slice(2, 8)}`; + return ( + + + {label && {label}} + + ); + }, +); +Radio.displayName = 'Radio'; diff --git a/frontend/src/shared/components/ui/select.tsx b/frontend/src/shared/components/ui/select.tsx new file mode 100644 index 0000000..6021516 --- /dev/null +++ b/frontend/src/shared/components/ui/select.tsx @@ -0,0 +1,44 @@ +import { forwardRef, type SelectHTMLAttributes } from 'react'; +import { inputVariants, type InputSize, type InputState } from '@lib/theme/variants'; +import { cn } from '@lib/utils/cn'; + +/** + * Select — 네이티브 select 래퍼. + * + * **접근성 정책 (타입으로 강제)**: + * 스크린 리더가 select의 용도를 읽을 수 있도록 아래 3개 중 하나는 필수: + * - `aria-label`: 짧은 텍스트 라벨 (예: "등급 필터") + * - `aria-labelledby`: 다른 요소의 ID 참조 + * - `title`: 툴팁 (접근 이름 폴백) + * + * 사용 예: + * ... + * ... + */ + +type BaseSelectProps = Omit, 'size'> & { + size?: InputSize; + state?: InputState; +}; + +type SelectWithAccessibleName = + | (BaseSelectProps & { 'aria-label': string }) + | (BaseSelectProps & { 'aria-labelledby': string }) + | (BaseSelectProps & { title: string }); + +export type SelectProps = SelectWithAccessibleName; + +export const Select = forwardRef( + ({ className, size, state, children, ...props }, ref) => { + return ( + + {children} + + ); + }, +); +Select.displayName = 'Select'; diff --git a/frontend/src/shared/components/ui/tabs.tsx b/frontend/src/shared/components/ui/tabs.tsx new file mode 100644 index 0000000..f54c874 --- /dev/null +++ b/frontend/src/shared/components/ui/tabs.tsx @@ -0,0 +1,78 @@ +import { type ButtonHTMLAttributes, type ReactNode } from 'react'; +import { cn } from '@lib/utils/cn'; + +/** + * TabBar — 탭 버튼 그룹 공통 컴포넌트 + * + * 3가지 스타일: + * - 'underline' (기본): border-b 밑줄 탭 (MLOps, RiskMap 스타일) + * - 'pill': 라운드 pill 탭 (ChinaFishing 스타일) + * - 'segmented': 배경 그룹 segmented control 스타일 + * + * 사용: + * + * setTab('a')}>탭 A + * setTab('b')}>탭 B + * + */ + +export interface TabBarProps { + variant?: 'underline' | 'pill' | 'segmented'; + children: ReactNode; + className?: string; +} + +export function TabBar({ variant = 'underline', children, className }: TabBarProps) { + const variantClass = { + underline: 'flex gap-0 border-b border-border', + pill: 'flex items-center gap-1', + segmented: 'flex items-center gap-1 bg-surface-raised rounded-lg p-1 border border-border w-fit', + }[variant]; + return {children}; +} + +export interface TabButtonProps extends ButtonHTMLAttributes { + active?: boolean; + variant?: 'underline' | 'pill' | 'segmented'; + icon?: ReactNode; + children: ReactNode; +} + +/** + * TabButton — 단일 탭 버튼. active 상태에 따라 스타일 변화. + */ +export function TabButton({ + active = false, + variant = 'underline', + icon, + children, + className, + type = 'button', + ...props +}: TabButtonProps) { + const variantClass = { + underline: cn( + 'flex items-center gap-1.5 px-4 py-2 text-[11px] font-medium border-b-2 transition-colors', + active ? 'text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label', + ), + pill: cn( + 'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-medium transition-colors', + active + ? 'bg-blue-600 text-on-vivid' + : 'text-muted-foreground hover:bg-secondary hover:text-foreground', + ), + segmented: cn( + 'flex items-center gap-1.5 px-4 py-2 rounded-md text-[11px] font-medium transition-colors', + active + ? 'bg-blue-600 text-on-vivid' + : 'text-muted-foreground hover:text-foreground hover:bg-surface-overlay', + ), + }[variant]; + + return ( + + {icon} + {children} + + ); +} diff --git a/frontend/src/shared/components/ui/textarea.tsx b/frontend/src/shared/components/ui/textarea.tsx new file mode 100644 index 0000000..5f6c453 --- /dev/null +++ b/frontend/src/shared/components/ui/textarea.tsx @@ -0,0 +1,31 @@ +import { forwardRef, type TextareaHTMLAttributes } from 'react'; +import { cn } from '@lib/utils/cn'; + +export interface TextareaProps extends TextareaHTMLAttributes { + state?: 'default' | 'error' | 'success'; +} + +export const Textarea = forwardRef( + ({ className, state = 'default', ...props }, ref) => { + const stateClass = { + default: 'border-slate-600/40', + error: 'border-red-500/60 focus:border-red-500 focus:ring-red-500/30', + success: 'border-green-500/60 focus:border-green-500 focus:ring-green-500/30', + }[state]; + return ( + + ); + }, +); +Textarea.displayName = 'Textarea'; 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/catalogRegistry.ts b/frontend/src/shared/constants/catalogRegistry.ts new file mode 100644 index 0000000..9b4aabd --- /dev/null +++ b/frontend/src/shared/constants/catalogRegistry.ts @@ -0,0 +1,267 @@ +/** + * 분류 카탈로그 중앙 레지스트리 (Single Source of Truth) + * + * 디자인 쇼케이스의 카탈로그 섹션과 실제 프론트가 **모두 이 레지스트리를 참조**한다. + * 따라서: + * - 특정 분류의 라벨을 바꾸면 (예: '심각' → '매우 심각') 쇼케이스 + 실 페이지 동시 반영 + * - 특정 분류의 intent/색상을 바꾸면 역시 자동 반영 + * - 새 카탈로그를 추가하면 쇼케이스에 자동 노출 (등록만 하면 됨) + * + * 각 카탈로그는 `items: Record` 형태를 가진다 (Meta는 개별 파일의 타입 사용). + * 쇼케이스는 heterogeneous 타입을 허용하므로 CatalogEntry의 items는 Record 로 처리. + */ + +import { ALERT_LEVELS } from './alertLevels'; +import { VIOLATION_TYPES } from './violationTypes'; +import { EVENT_STATUSES } from './eventStatuses'; +import { ENFORCEMENT_ACTIONS } from './enforcementActions'; +import { ENFORCEMENT_RESULTS } from './enforcementResults'; +import { PATROL_STATUSES } from './patrolStatuses'; +import { ENGINE_SEVERITIES } from './engineSeverities'; +import { DEVICE_STATUSES } from './deviceStatuses'; +import { + PARENT_RESOLUTION_STATUSES, + LABEL_SESSION_STATUSES, +} from './parentResolutionStatuses'; +import { + MODEL_STATUSES, + QUALITY_GATE_STATUSES, + EXPERIMENT_STATUSES, +} from './modelDeploymentStatuses'; +import { GEAR_GROUP_TYPES } from './gearGroupTypes'; +import { DARK_VESSEL_PATTERNS } from './darkVesselPatterns'; +import { USER_ACCOUNT_STATUSES } from './userAccountStatuses'; +import { LOGIN_RESULTS } from './loginResultStatuses'; +import { PERMIT_STATUSES, GEAR_JUDGMENTS } from './permissionStatuses'; +import { + VESSEL_SURVEILLANCE_STATUSES, + VESSEL_RISK_RINGS, +} from './vesselAnalysisStatuses'; +import { CONNECTION_STATUSES } from './connectionStatuses'; +import { TRAINING_ZONE_TYPES } from './trainingZoneTypes'; + +/** + * 카탈로그 공통 메타 — 쇼케이스 렌더와 UI 일관성을 위한 최소 스키마 + */ +export interface CatalogEntry { + /** 안정적 ID (쇼케이스 추적 ID의 슬러그 부분) */ + id: string; + /** 쇼케이스 추적 ID (TRK-CAT-*) */ + showcaseId: string; + /** 쇼케이스 섹션 제목 (한글) */ + titleKo: string; + /** 쇼케이스 섹션 제목 (영문) */ + titleEn: string; + /** 1줄 설명 */ + description: string; + /** 출처 (백엔드 enum / code_master 등) */ + source?: string; + /** 카탈로그 데이터 — items의 각 meta는 { code, fallback?, intent?, classes?, ... } 구조 */ + items: Record; +} + +/** + * 전체 카탈로그 레지스트리 + * + * ⚠️ 새 카탈로그 추가 시: + * 1. shared/constants/에 파일 생성 (items record + 헬퍼 함수) + * 2. 이 레지스트리에 CatalogEntry 추가 + * 3. 쇼케이스에 자동 노출 + 실 페이지에서 헬퍼 함수로 참조 + */ +export const CATALOG_REGISTRY: CatalogEntry[] = [ + { + id: 'alert-level', + showcaseId: 'TRK-CAT-alert-level', + titleKo: '위험도', + titleEn: 'Alert Level', + description: 'CRITICAL / HIGH / MEDIUM / LOW — 모든 이벤트/알림 배지', + source: 'backend code_master EVENT_LEVEL', + items: ALERT_LEVELS, + }, + { + id: 'violation-type', + showcaseId: 'TRK-CAT-violation-type', + titleKo: '위반 유형', + titleEn: 'Violation Type', + description: '중국불법조업 / 환적의심 / EEZ침범 등', + source: 'backend ViolationType enum', + items: VIOLATION_TYPES, + }, + { + id: 'event-status', + showcaseId: 'TRK-CAT-event-status', + titleKo: '이벤트 상태', + titleEn: 'Event Status', + description: 'NEW / ACK / IN_PROGRESS / RESOLVED / FALSE_POSITIVE', + source: 'backend code_master EVENT_STATUS', + items: EVENT_STATUSES, + }, + { + id: 'enforcement-action', + showcaseId: 'TRK-CAT-enforcement-action', + titleKo: '단속 조치', + titleEn: 'Enforcement Action', + description: 'CAPTURE / INSPECT / WARN / DISPERSE / TRACK / EVIDENCE', + source: 'backend code_master ENFORCEMENT_ACTION', + items: ENFORCEMENT_ACTIONS, + }, + { + id: 'enforcement-result', + showcaseId: 'TRK-CAT-enforcement-result', + titleKo: '단속 결과', + titleEn: 'Enforcement Result', + description: 'PUNISHED / REFERRED / WARNED / RELEASED / FALSE_POSITIVE', + source: 'backend code_master ENFORCEMENT_RESULT', + items: ENFORCEMENT_RESULTS, + }, + { + id: 'patrol-status', + showcaseId: 'TRK-CAT-patrol-status', + titleKo: '함정 상태', + titleEn: 'Patrol Status', + description: '출동 / 순찰 / 복귀 / 정박 / 정비 / 대기', + source: 'backend code_master PATROL_STATUS', + items: PATROL_STATUSES, + }, + { + id: 'engine-severity', + showcaseId: 'TRK-CAT-engine-severity', + titleKo: '엔진 심각도', + titleEn: 'Engine Severity', + description: 'AI 모델/분석엔진 오류 심각도', + items: ENGINE_SEVERITIES, + }, + { + id: 'device-status', + showcaseId: 'TRK-CAT-device-status', + titleKo: '함정 Agent 장치 상태', + titleEn: 'Device Status', + description: 'ONLINE / OFFLINE / SYNCING / NOT_DEPLOYED', + items: DEVICE_STATUSES, + }, + { + id: 'parent-resolution', + showcaseId: 'TRK-CAT-parent-resolution', + titleKo: '모선 확정 상태', + titleEn: 'Parent Resolution Status', + description: 'PENDING / CONFIRMED / REJECTED / REVIEWING', + items: PARENT_RESOLUTION_STATUSES, + }, + { + id: 'label-session', + showcaseId: 'TRK-CAT-label-session', + titleKo: '라벨 세션', + titleEn: 'Label Session Status', + description: '모선 학습 세션 상태', + items: LABEL_SESSION_STATUSES, + }, + { + id: 'model-status', + showcaseId: 'TRK-CAT-model-status', + titleKo: 'AI 모델 상태', + titleEn: 'Model Status', + description: 'DEV / STAGING / CANARY / PROD / ARCHIVED', + items: MODEL_STATUSES, + }, + { + id: 'quality-gate', + showcaseId: 'TRK-CAT-quality-gate', + titleKo: '품질 게이트', + titleEn: 'Quality Gate Status', + description: '모델 배포 전 품질 검증', + items: QUALITY_GATE_STATUSES, + }, + { + id: 'experiment', + showcaseId: 'TRK-CAT-experiment', + titleKo: 'ML 실험', + titleEn: 'Experiment Status', + description: 'MLOps 실험 상태', + items: EXPERIMENT_STATUSES, + }, + { + id: 'gear-group', + showcaseId: 'TRK-CAT-gear-group', + titleKo: '어구 그룹 유형', + titleEn: 'Gear Group Type', + description: 'FLEET / GEAR_IN_ZONE / GEAR_OUT_ZONE', + items: GEAR_GROUP_TYPES, + }, + { + id: 'dark-vessel', + showcaseId: 'TRK-CAT-dark-vessel', + titleKo: '다크베셀 패턴', + titleEn: 'Dark Vessel Pattern', + description: 'AIS 끊김/스푸핑 패턴 5종', + items: DARK_VESSEL_PATTERNS, + }, + { + id: 'user-account', + showcaseId: 'TRK-CAT-user-account', + titleKo: '사용자 계정 상태', + titleEn: 'User Account Status', + description: 'ACTIVE / LOCKED / INACTIVE / PENDING', + items: USER_ACCOUNT_STATUSES, + }, + { + id: 'login-result', + showcaseId: 'TRK-CAT-login-result', + titleKo: '로그인 결과', + titleEn: 'Login Result', + description: 'SUCCESS / FAILED / LOCKED', + items: LOGIN_RESULTS, + }, + { + id: 'permit-status', + showcaseId: 'TRK-CAT-permit-status', + titleKo: '허가 상태', + titleEn: 'Permit Status', + description: '선박 허가 유효 / 만료 / 정지', + items: PERMIT_STATUSES, + }, + { + id: 'gear-judgment', + showcaseId: 'TRK-CAT-gear-judgment', + titleKo: '어구 판정', + titleEn: 'Gear Judgment', + description: '합법 / 의심 / 불법', + items: GEAR_JUDGMENTS, + }, + { + id: 'vessel-surveillance', + showcaseId: 'TRK-CAT-vessel-surveillance', + titleKo: '선박 감시 상태', + titleEn: 'Vessel Surveillance Status', + description: '관심선박 추적 상태', + items: VESSEL_SURVEILLANCE_STATUSES, + }, + { + id: 'vessel-risk-ring', + showcaseId: 'TRK-CAT-vessel-risk-ring', + titleKo: '선박 위험 링', + titleEn: 'Vessel Risk Ring', + description: '지도 마커 위험도 링', + items: VESSEL_RISK_RINGS, + }, + { + id: 'connection', + showcaseId: 'TRK-CAT-connection', + titleKo: '연결 상태', + titleEn: 'Connection Status', + description: 'OK / WARNING / ERROR', + items: CONNECTION_STATUSES, + }, + { + id: 'training-zone', + showcaseId: 'TRK-CAT-training-zone', + titleKo: '훈련 수역', + titleEn: 'Training Zone Type', + description: 'NAVY / AIRFORCE / ARMY / ADD / KCG', + items: TRAINING_ZONE_TYPES, + }, +]; + +/** ID로 특정 카탈로그 조회 */ +export function getCatalogById(id: string): CatalogEntry | undefined { + return CATALOG_REGISTRY.find((c) => c.id === id); +} 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..f791e67 --- /dev/null +++ b/frontend/src/shared/constants/enforcementActions.ts @@ -0,0 +1,107 @@ +/** + * 단속 조치 코드 공통 카탈로그 + * + * SSOT: backend `code_master` 그룹 ENFORCEMENT_ACTION (V008 시드). + * 백엔드 EnforcementRecord.action enum. + * + * 사용처: EnforcementHistory(조치 컬럼), 단속 등록 폼 + */ + +import type { BadgeIntent } from '@lib/theme/variants'; + +export type EnforcementAction = + | 'CAPTURE' + | 'INSPECT' + | 'WARN' + | 'DISPERSE' + | 'TRACK' + | 'EVIDENCE'; + +export interface EnforcementActionMeta { + code: EnforcementAction; + i18nKey: string; + fallback: { ko: string; en: string }; + intent: BadgeIntent; + classes: string; + hex: string; + order: number; +} + +export const ENFORCEMENT_ACTIONS: Record = { + CAPTURE: { + code: 'CAPTURE', + i18nKey: 'enforcementAction.CAPTURE', + fallback: { ko: '나포', en: 'Capture' }, + intent: 'critical', + classes: 'bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-400', + hex: '#ef4444', + order: 1, + }, + INSPECT: { + code: 'INSPECT', + i18nKey: 'enforcementAction.INSPECT', + fallback: { ko: '검문', en: 'Inspect' }, + intent: 'warning', + classes: 'bg-amber-100 text-amber-800 dark:bg-amber-500/20 dark:text-amber-400', + hex: '#f59e0b', + order: 2, + }, + WARN: { + code: 'WARN', + i18nKey: 'enforcementAction.WARN', + fallback: { ko: '경고', en: 'Warn' }, + intent: 'info', + classes: 'bg-blue-100 text-blue-800 dark:bg-blue-500/20 dark:text-blue-400', + hex: '#3b82f6', + order: 3, + }, + DISPERSE: { + code: 'DISPERSE', + i18nKey: 'enforcementAction.DISPERSE', + fallback: { ko: '퇴거', en: 'Disperse' }, + intent: 'purple', + classes: 'bg-violet-100 text-violet-800 dark:bg-violet-500/20 dark:text-violet-400', + hex: '#8b5cf6', + order: 4, + }, + TRACK: { + code: 'TRACK', + i18nKey: 'enforcementAction.TRACK', + fallback: { ko: '추적', en: 'Track' }, + intent: 'cyan', + classes: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-500/20 dark:text-cyan-400', + hex: '#06b6d4', + order: 5, + }, + EVIDENCE: { + code: 'EVIDENCE', + i18nKey: 'enforcementAction.EVIDENCE', + fallback: { ko: '증거수집', en: 'Evidence' }, + intent: 'muted', + classes: 'bg-slate-100 text-slate-700 dark:bg-slate-500/20 dark:text-slate-300', + hex: '#64748b', + order: 6, + }, +}; + +export function getEnforcementActionClasses(action: string): string { + return getEnforcementActionMeta(action)?.classes ?? 'bg-muted text-muted-foreground'; +} + +export function getEnforcementActionIntent(action: string): BadgeIntent { + return getEnforcementActionMeta(action)?.intent ?? 'muted'; +} + +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..284e731 --- /dev/null +++ b/frontend/src/shared/constants/enforcementResults.ts @@ -0,0 +1,91 @@ +/** + * 단속 결과 코드 공통 카탈로그 + * + * SSOT: backend `code_master` 그룹 ENFORCEMENT_RESULT (V008 시드). + * 백엔드 EnforcementRecord.result enum. + * + * 사용처: EnforcementHistory(결과 컬럼), 단속 통계 + */ + +import type { BadgeIntent } from '@lib/theme/variants'; + +export type EnforcementResult = + | 'PUNISHED' + | 'WARNED' + | 'RELEASED' + | 'REFERRED' + | 'FALSE_POSITIVE'; + +export interface EnforcementResultMeta { + code: EnforcementResult; + i18nKey: string; + fallback: { ko: string; en: string }; + intent: BadgeIntent; + classes: string; + order: number; +} + +export const ENFORCEMENT_RESULTS: Record = { + PUNISHED: { + code: 'PUNISHED', + i18nKey: 'enforcementResult.PUNISHED', + fallback: { ko: '처벌', en: 'Punished' }, + intent: 'critical', + classes: 'bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-400', + order: 1, + }, + REFERRED: { + code: 'REFERRED', + i18nKey: 'enforcementResult.REFERRED', + fallback: { ko: '수사의뢰', en: 'Referred' }, + intent: 'purple', + classes: 'bg-purple-100 text-purple-800 dark:bg-purple-500/20 dark:text-purple-400', + order: 2, + }, + WARNED: { + code: 'WARNED', + i18nKey: 'enforcementResult.WARNED', + fallback: { ko: '경고', en: 'Warned' }, + intent: 'warning', + classes: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-500/20 dark:text-yellow-400', + order: 3, + }, + RELEASED: { + code: 'RELEASED', + i18nKey: 'enforcementResult.RELEASED', + fallback: { ko: '훈방', en: 'Released' }, + intent: 'success', + classes: 'bg-green-100 text-green-800 dark:bg-green-500/20 dark:text-green-400', + order: 4, + }, + FALSE_POSITIVE: { + code: 'FALSE_POSITIVE', + i18nKey: 'enforcementResult.FALSE_POSITIVE', + fallback: { ko: '오탐(정상)', en: 'False Positive' }, + intent: 'muted', + classes: 'bg-muted text-muted-foreground', + order: 5, + }, +}; + +export function getEnforcementResultIntent(result: string): BadgeIntent { + return getEnforcementResultMeta(result)?.intent ?? 'muted'; +} + +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..31e015d --- /dev/null +++ b/frontend/src/shared/constants/eventStatuses.ts @@ -0,0 +1,91 @@ +/** + * 이벤트 처리 상태 공통 카탈로그 + * + * SSOT: backend `code_master` 그룹 EVENT_STATUS (V008 시드). + * 향후 `GET /api/code-master?groupCode=EVENT_STATUS`로 fetch 예정. + * + * 사용처: EventList(처리상태 컬럼), 알림 처리, 단속 등록 액션 + */ + +import type { BadgeIntent } from '@lib/theme/variants'; + +export type EventStatus = + | 'NEW' + | 'ACK' + | 'IN_PROGRESS' + | 'RESOLVED' + | 'FALSE_POSITIVE'; + +export interface EventStatusMeta { + code: EventStatus; + i18nKey: string; + fallback: { ko: string; en: string }; + intent: BadgeIntent; + classes: string; // bg + text 묶음 (legacy - use intent 권장) + order: number; +} + +export const EVENT_STATUSES: Record = { + NEW: { + code: 'NEW', + i18nKey: 'eventStatus.NEW', + fallback: { ko: '신규', en: 'New' }, + intent: 'critical', + classes: 'bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-400', + order: 1, + }, + ACK: { + code: 'ACK', + i18nKey: 'eventStatus.ACK', + fallback: { ko: '확인', en: 'Acknowledged' }, + intent: 'high', + classes: 'bg-orange-100 text-orange-800 dark:bg-orange-500/20 dark:text-orange-400', + order: 2, + }, + IN_PROGRESS: { + code: 'IN_PROGRESS', + i18nKey: 'eventStatus.IN_PROGRESS', + fallback: { ko: '처리중', en: 'In Progress' }, + intent: 'info', + classes: 'bg-blue-100 text-blue-800 dark:bg-blue-500/20 dark:text-blue-400', + order: 3, + }, + RESOLVED: { + code: 'RESOLVED', + i18nKey: 'eventStatus.RESOLVED', + fallback: { ko: '완료', en: 'Resolved' }, + intent: 'success', + classes: 'bg-green-100 text-green-800 dark:bg-green-500/20 dark:text-green-400', + order: 4, + }, + FALSE_POSITIVE: { + code: 'FALSE_POSITIVE', + i18nKey: 'eventStatus.FALSE_POSITIVE', + fallback: { ko: '오탐', en: 'False Positive' }, + intent: 'muted', + classes: 'bg-muted text-muted-foreground', + order: 5, + }, +}; + +export function getEventStatusIntent(status: string): BadgeIntent { + return getEventStatusMeta(status)?.intent ?? 'muted'; +} + +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..112a84f --- /dev/null +++ b/frontend/src/shared/constants/index.ts @@ -0,0 +1,32 @@ +/** + * 분류/코드 카탈로그 통합 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'; +export * from './statusIntent'; 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..d1bd898 --- /dev/null +++ b/frontend/src/shared/constants/patrolStatuses.ts @@ -0,0 +1,138 @@ +/** + * 함정 상태 공통 카탈로그 + * + * SSOT: backend `code_master` 그룹 PATROL_STATUS (V008 시드). + * 향후 patrol_ship_master.status 컬럼 enum. + * + * 사용처: Dashboard PatrolStatusBadge, ShipAgent + */ + +import type { BadgeIntent } from '@lib/theme/variants'; + +export type PatrolStatus = + | 'AVAILABLE' + | 'ON_PATROL' + | 'IN_PURSUIT' + | 'INSPECTING' + | 'RETURNING' + | 'STANDBY' + | 'MAINTENANCE'; + +export interface PatrolStatusMeta { + code: PatrolStatus; + i18nKey: string; + fallback: { ko: string; en: string }; + intent: BadgeIntent; + classes: string; + order: number; +} + +export const PATROL_STATUSES: Record = { + IN_PURSUIT: { + code: 'IN_PURSUIT', + i18nKey: 'patrolStatus.IN_PURSUIT', + fallback: { ko: '추적중', en: 'In Pursuit' }, + intent: 'critical', + classes: + 'bg-red-100 text-red-800 border-red-300 dark:bg-red-500/20 dark:text-red-400 dark:border-red-500/30', + order: 1, + }, + INSPECTING: { + code: 'INSPECTING', + i18nKey: 'patrolStatus.INSPECTING', + fallback: { ko: '검문중', en: 'Inspecting' }, + intent: 'high', + classes: + 'bg-orange-100 text-orange-800 border-orange-300 dark:bg-orange-500/20 dark:text-orange-400 dark:border-orange-500/30', + order: 2, + }, + ON_PATROL: { + code: 'ON_PATROL', + i18nKey: 'patrolStatus.ON_PATROL', + fallback: { ko: '초계중', en: 'On Patrol' }, + intent: 'info', + classes: + 'bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-500/20 dark:text-blue-400 dark:border-blue-500/30', + order: 3, + }, + RETURNING: { + code: 'RETURNING', + i18nKey: 'patrolStatus.RETURNING', + fallback: { ko: '귀항중', en: 'Returning' }, + intent: 'purple', + classes: + 'bg-purple-100 text-purple-800 border-purple-300 dark:bg-purple-500/20 dark:text-purple-400 dark:border-purple-500/30', + order: 4, + }, + AVAILABLE: { + code: 'AVAILABLE', + i18nKey: 'patrolStatus.AVAILABLE', + fallback: { ko: '가용', en: 'Available' }, + intent: 'success', + classes: + 'bg-green-100 text-green-800 border-green-300 dark:bg-green-500/20 dark:text-green-400 dark:border-green-500/30', + order: 5, + }, + STANDBY: { + code: 'STANDBY', + i18nKey: 'patrolStatus.STANDBY', + fallback: { ko: '대기', en: 'Standby' }, + intent: 'muted', + classes: + 'bg-slate-100 text-slate-700 border-slate-300 dark:bg-slate-500/20 dark:text-slate-400 dark:border-slate-500/30', + order: 6, + }, + MAINTENANCE: { + code: 'MAINTENANCE', + i18nKey: 'patrolStatus.MAINTENANCE', + fallback: { ko: '정비중', en: 'Maintenance' }, + intent: 'warning', + classes: + 'bg-yellow-100 text-yellow-800 border-yellow-300 dark:bg-yellow-500/20 dark:text-yellow-400 dark:border-yellow-500/30', + order: 7, + }, +}; + +export function getPatrolStatusIntent(status: string): BadgeIntent { + return getPatrolStatusMeta(status)?.intent ?? 'muted'; +} + +/** 한글 라벨도 키로 받아주는 호환성 매핑 (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/statusIntent.ts b/frontend/src/shared/constants/statusIntent.ts new file mode 100644 index 0000000..0ec87f6 --- /dev/null +++ b/frontend/src/shared/constants/statusIntent.ts @@ -0,0 +1,163 @@ +/** + * 일반 상태 문자열 → BadgeIntent 매핑 유틸 + * + * 정식 카탈로그에 없는 ad-hoc 상태 문자열(한글/영문 섞여 있는 mock 데이터 등)을 + * 임시로 intent에 매핑. 프로젝트 전역에서 재사용 가능. + * + * 원칙: + * - 가능하면 전용 카탈로그를 만들어 사용하는 것이 우선 + * - 이 유틸은 정형화되지 않은 데모/mock 데이터 대응 임시 매핑용 + * + * 사용: + * {s} + */ + +import type { BadgeIntent } from '@lib/theme/variants'; + +const STATUS_INTENT_MAP: Record = { + // 정상/긍정 + '정상': 'success', + '운영': 'success', + '운영중': 'success', + '활성': 'success', + '완료': 'success', + '확정': 'success', + '가용': 'success', + '승인': 'success', + '성공': 'success', + '통과': 'success', + '배포': 'success', + active: 'success', + running: 'info', + online: 'success', + healthy: 'success', + success: 'success', + ok: 'success', + passed: 'success', + deployed: 'success', + confirmed: 'success', + CONFIRMED: 'success', + ACTIVE: 'success', + PASSED: 'success', + RUNNING: 'info', + DEPLOYED: 'success', + + // 정보/대기 + '대기': 'info', + '계획': 'info', + '계획중': 'info', + '진행': 'info', + '진행중': 'info', + '처리': 'info', + '처리중': 'info', + '생성': 'info', + '예약': 'info', + '예정': 'info', + '테스트': 'info', + pending: 'info', + PENDING: 'info', + planning: 'info', + PLANNING: 'info', + PLANNED: 'info', + scheduled: 'info', + testing: 'info', + TESTING: 'info', + STAGING: 'info', + CANARY: 'info', + + // 주의 + '주의': 'warning', + '경고': 'warning', + '검토': 'warning', + '검토필요': 'warning', + '수정': 'warning', + '변경': 'warning', + '점검': 'warning', + warning: 'warning', + WARNING: 'warning', + review: 'warning', + reviewing: 'warning', + maintenance: 'warning', + + // 심각/에러 + '긴급': 'critical', + '오류': 'critical', + '실패': 'critical', + '에러': 'critical', + '차단': 'critical', + '정지': 'critical', + '거부': 'critical', + '폐기': 'critical', + '만료': 'critical', + critical: 'critical', + CRITICAL: 'critical', + error: 'critical', + ERROR: 'critical', + failed: 'critical', + FAILED: 'critical', + blocked: 'critical', + rejected: 'critical', + REJECTED: 'critical', + expired: 'critical', + EXPIRED: 'critical', + + // 높음 + '높음': 'high', + '높은': 'high', + high: 'high', + HIGH: 'high', + + // 보라/특수 + '분석': 'purple', + '학습': 'purple', + '추론': 'purple', + '배포중': 'purple', + analyzing: 'purple', + training: 'purple', + + // 청록/모니터링 + '모니터': 'cyan', + '모니터링': 'cyan', + '감시': 'cyan', + '추적': 'cyan', + '추적중': 'cyan', + monitoring: 'cyan', + tracking: 'cyan', + + // 비활성/중립 + '비활성': 'muted', + '미배포': 'muted', + '없음': 'muted', + '-': 'muted', + '기타': 'muted', + '오탐': 'muted', + inactive: 'muted', + INACTIVE: 'muted', + disabled: 'muted', + DISABLED: 'muted', + none: 'muted', + unknown: 'muted', + UNKNOWN: 'muted', + ARCHIVED: 'muted', + DEV: 'muted', +}; + +/** + * 상태 문자열을 BadgeIntent로 변환. + * 매핑 없으면 'muted' 반환. + */ +export function getStatusIntent(status: string | null | undefined): BadgeIntent { + if (!status) return 'muted'; + return STATUS_INTENT_MAP[status] ?? STATUS_INTENT_MAP[status.toLowerCase()] ?? 'muted'; +} + +/** + * 숫자 위험도(0-100)를 intent로 매핑. + * 80 이상 critical, 60 이상 high, 40 이상 warning, 아니면 info + */ +export function getRiskIntent(score: number): BadgeIntent { + if (score >= 80) return 'critical'; + if (score >= 60) return 'high'; + if (score >= 40) return 'warning'; + return 'info'; +} 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 { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index dde1356..ca2179d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -33,6 +33,7 @@ export default defineConfig({ input: { main: path.resolve(__dirname, 'index.html'), systemFlow: path.resolve(__dirname, 'system-flow.html'), + designSystem: path.resolve(__dirname, 'design-system.html'), }, }, },
v0.1.0 · 쇼케이스
TRK-<카테고리>-<슬러그>
#trk=ID
{id}
{description}
+ {`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..0987990 --- /dev/null +++ b/frontend/src/design-system/sections/ButtonSection.tsx @@ -0,0 +1,129 @@ +import { TrkSectionHeader, Trk } from '../lib/Trk'; +import { Button } from '@shared/components/ui/button'; +import { + BUTTON_VARIANT_META, + BUTTON_VARIANT_ORDER, + BUTTON_SIZE_ORDER, +} from '@lib/theme/variantMeta'; +import { Plus, Download, Trash2, Search, Save } from 'lucide-react'; + +export function ButtonSection() { + return ( + <> + + + {/* 매트릭스 */} + 변형 매트릭스 + + + + + + + variant ↓ / size → + + {BUTTON_SIZE_ORDER.map((size) => ( + + {size} + + ))} + + + + {BUTTON_VARIANT_ORDER.map((variant) => ( + + {variant} + {BUTTON_SIZE_ORDER.map((size) => ( + + + + {variant} + + + + ))} + + ))} + + + + + + {/* 아이콘 버튼 */} + 아이콘 포함 패턴 + + + }> + 새 항목 추가 + + }> + 다운로드 + + }> + 검색 + + }> + 저장 + + }> + 삭제 + + + + + {/* 상태 */} + 상태 + + + Normal + + Disabled + + + + + {/* variant 의미 가이드 (variantMeta에서 자동 열거) */} + Variant 의미 가이드 + + {BUTTON_VARIANT_ORDER.map((variant) => { + const meta = BUTTON_VARIANT_META[variant]; + return ( + + + + {meta.titleKo} + + {meta.description} + + + ); + })} + + + {/* 사용 예시 */} + 사용 예시 + + + {`import { Button } from '@shared/components/ui/button'; +import { Plus } from 'lucide-react'; + +}> + 새 보고서 + + + + 삭제 + + +// 금지 +// ❌ +// ❌ → variant="destructive" 사용`} + + + > + ); +} diff --git a/frontend/src/design-system/sections/CardSection.tsx b/frontend/src/design-system/sections/CardSection.tsx new file mode 100644 index 0000000..c526482 --- /dev/null +++ b/frontend/src/design-system/sections/CardSection.tsx @@ -0,0 +1,111 @@ +import { TrkSectionHeader, Trk } from '../lib/Trk'; +import { Card, CardHeader, CardTitle, CardContent } from '@shared/components/ui/card'; +import { Database, Activity, Bell } from 'lucide-react'; + +export function CardSectionShowcase() { + return ( + <> + + + {/* 4 variant */} + Variant + + + variant=default + + + + + 데이터베이스 + + + + + PostgreSQL + v15.4 운영중 + + + 연결 + 8 / 20 + + + + + + + variant=elevated (기본값) + + + + + 시스템 상태 + + + + + API + 정상 + + + Prediction + 5분 주기 + + + + + + + variant=inner + + + + + 알림 + + + + 중첩 카드 내부에 사용. 외부 카드보다 한 단계 낮은 depth. + + + + + + variant=transparent + + + 투명 카드 + + + 배경/보더 없이 구조만 활용 (그룹핑 목적). + + + + + + {/* 사용 예시 */} + 사용 예시 + + + {`import { Card, CardHeader, CardTitle, CardContent } from '@shared/components/ui/card'; +import { Database } from 'lucide-react'; + + + + + + 데이터베이스 + + + + {/* 콘텐츠 */} + +`} + + + > + ); +} diff --git a/frontend/src/design-system/sections/CatalogSection.tsx b/frontend/src/design-system/sections/CatalogSection.tsx new file mode 100644 index 0000000..e18a475 --- /dev/null +++ b/frontend/src/design-system/sections/CatalogSection.tsx @@ -0,0 +1,130 @@ +import { type ReactNode } from 'react'; +import { TrkSectionHeader, Trk } from '../lib/Trk'; +import { Badge } from '@shared/components/ui/badge'; +import type { BadgeIntent } from '@lib/theme/variants'; +import { CATALOG_REGISTRY, type CatalogEntry } from '@shared/constants/catalogRegistry'; + +/** + * 카탈로그 섹션 — `CATALOG_REGISTRY`를 자동 열거. + * 새 카탈로그 추가는 `catalogRegistry.ts`에 한 줄 추가하면 끝. + * 여기는 렌더링 로직만 담당. + */ + +interface AnyMeta { + code: string; + intent?: BadgeIntent; + fallback?: { ko: string; en: string }; + classes?: string | { bg?: string; text?: string; border?: string }; + label?: string; +} + +function getKoLabel(meta: AnyMeta): string { + return meta.fallback?.ko ?? meta.label ?? meta.code; +} + +function getEnLabel(meta: AnyMeta): string | undefined { + return meta.fallback?.en; +} + +function getFallbackClasses(meta: AnyMeta): string | undefined { + if (typeof meta.classes === 'string') return meta.classes; + if (typeof meta.classes === 'object' && meta.classes) { + return [meta.classes.bg, meta.classes.text, meta.classes.border].filter(Boolean).join(' '); + } + return undefined; +} + +function renderBadge(meta: AnyMeta, label: string): ReactNode { + if (meta.intent) { + return ( + + {label} + + ); + } + const classes = + getFallbackClasses(meta) ?? + 'bg-slate-100 text-slate-700 border-slate-300 dark:bg-slate-500/20 dark:text-slate-300 dark:border-slate-500/30'; + return ( + + {label} + + ); +} + +function CatalogBadges({ entry }: { entry: CatalogEntry }) { + const items = Object.values(entry.items) as AnyMeta[]; + return ( + + {items.map((meta) => { + const koLabel = getKoLabel(meta); + const enLabel = getEnLabel(meta); + const trkId = `${entry.showcaseId}-${meta.code}`; + return ( + + + {meta.code} + + {renderBadge(meta, koLabel)} + + {enLabel ? ( + renderBadge(meta, enLabel) + ) : ( + (no en) + )} + + + ); + })} + + ); +} + +export function CatalogSection() { + return ( + <> + + + + + 페이지에서 배지를 렌더할 때는 반드시 카탈로그 API를 사용한다 (라벨/intent/색상 단일 관리): + + + {`import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels'; + + + {getAlertLevelLabel(event.level, t, lang)} +`} + + + 새 카탈로그 추가: shared/constants/catalogRegistry.ts에 + 항목을 추가하면 이 쇼케이스에 자동 노출됩니다. + + + + {CATALOG_REGISTRY.map((entry) => ( + + + + + {entry.titleKo} · {entry.titleEn} + + {entry.showcaseId} + + {entry.description} + {entry.source && ( + 출처: {entry.source} + )} + + + + ))} + > + ); +} diff --git a/frontend/src/design-system/sections/FormSection.tsx b/frontend/src/design-system/sections/FormSection.tsx new file mode 100644 index 0000000..655f1d4 --- /dev/null +++ b/frontend/src/design-system/sections/FormSection.tsx @@ -0,0 +1,124 @@ +import { useState } from 'react'; +import { TrkSectionHeader, Trk } from '../lib/Trk'; +import { Input } from '@shared/components/ui/input'; +import { Select } from '@shared/components/ui/select'; +import { Textarea } from '@shared/components/ui/textarea'; +import { Checkbox } from '@shared/components/ui/checkbox'; +import { Radio } from '@shared/components/ui/radio'; + +export function FormSection() { + const [radio, setRadio] = useState('a'); + + return ( + <> + + + {/* Input 사이즈 */} + Input · 사이즈 + + {(['sm', 'md', 'lg'] as const).map((size) => ( + + size={size} + + + ))} + + + {/* Input 상태 */} + Input · 상태 + + + state=default + + + + state=error + + + + state=success + + + + disabled + + + + readOnly + + + + type=number + + + + + {/* Select */} + Select + + {(['sm', 'md', 'lg'] as const).map((size) => ( + + size={size} + + 전체 선택 + CRITICAL + HIGH + MEDIUM + + + ))} + + + {/* Textarea */} + Textarea + + + + + {/* Checkbox */} + Checkbox + + + + + + + + + + {/* Radio */} + Radio + + + setRadio('a')} /> + setRadio('b')} /> + setRadio('c')} /> + + + + + {/* 사용 예시 */} + 사용 예시 + + + {`import { Input } from '@shared/components/ui/input'; +import { Select } from '@shared/components/ui/select'; +import { Checkbox } from '@shared/components/ui/checkbox'; + + setQ(e.target.value)} /> + + setLevel(e.target.value)}> + 전체 등급 + CRITICAL + + + setActive(e.target.checked)} />`} + + + > + ); +} diff --git a/frontend/src/design-system/sections/GuideSection.tsx b/frontend/src/design-system/sections/GuideSection.tsx new file mode 100644 index 0000000..53376f7 --- /dev/null +++ b/frontend/src/design-system/sections/GuideSection.tsx @@ -0,0 +1,130 @@ +import { TrkSectionHeader, Trk } from '../lib/Trk'; + +export function GuideSection() { + return ( + <> + + + + 언제 fullBleed를 쓰는가? + + + 지도 중심 페이지 — 가장자리까지 지도가 확장되어야 할 때 (LiveMapView, + VesselDetail) + + + 3단 패널 레이아웃 — 좌측 리스트 + 중앙 콘텐츠 + 우측 상세 구조로 + padding이 상위에서 관리되지 않는 경우 + + + 높이 100% 요구 — 브라우저 뷰포트 전체를 차지해야 할 때 + + + + 예외: <PageContainer fullBleed>로 명시.{' '} + -m-4, -mx-4 같은 negative margin 해킹 금지. + + + + + 언제 className override를 허용하는가? + + + 레이아웃 보정 — 부모 컨테이너 특성상 width/margin 조정이 필요할 때 ( + w-48, flex-1 등) + + + 반응형 조정 — sm/md/lg 브레이크포인트별 조정 + + + + 허용:{' '} + + <Badge intent="info" className="w-full justify-center"> + + + + 금지:{' '} + <Badge className="bg-red-500 text-white">{' '} + — intent prop을 대체하려 하지 말 것 + + + + + 동적 hex 색상이 필요한 경우 + + + DB에서 사용자가 정의한 색상 (예: Role.colorHex) → style={`{{ background: role.colorHex }}`}{' '} + 인라인 허용 + + + 차트 팔레트 → getAlertLevelHex(level) 같은 카탈로그 API에서 hex 조회 + + + 지도 마커 deck.gl → RGB 튜플로 변환 필요, 카탈로그 hex 기반 + + + + + + 금지 패턴 체크리스트 + + ❌ !important prefix (!bg-red-500) + ❌ className="bg-X text-Y"로 Badge 스타일을 재정의 + ❌ <button className="bg-blue-600 ..."> — Button 컴포넌트 사용 필수 + ❌ <input className="bg-surface ..."> — Input 컴포넌트 사용 필수 + ❌ p-4 space-y-5 같은 제각각 padding — PageContainer size 사용 + ❌ -m-4, -mx-4 negative margin 해킹 — fullBleed 사용 + ❌ 페이지에서 const STATUS_COLORS = {`{...}`} 로컬 상수 정의 — shared/constants 카탈로그 사용 + ❌ date.toLocaleString('ko-KR', ...) 직접 호출 — formatDateTime 사용 + + + + + 새 페이지 작성 템플릿 + + {`import { PageContainer, PageHeader, Section } from '@shared/components/layout'; +import { Button } from '@shared/components/ui/button'; +import { Input } from '@shared/components/ui/input'; +import { Badge } from '@shared/components/ui/badge'; +import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels'; +import { formatDateTime } from '@shared/utils/dateFormat'; +import { Shield, Plus } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +export function MyNewPage() { + const { t, i18n } = useTranslation('common'); + const lang = i18n.language as 'ko' | 'en'; + + return ( + + }> + 추가 + + } + /> + + + + {getAlertLevelLabel('HIGH', t, lang)} + + {formatDateTime(row.createdAt)} + + + ); +}`} + + + > + ); +} diff --git a/frontend/src/design-system/sections/IntroSection.tsx b/frontend/src/design-system/sections/IntroSection.tsx new file mode 100644 index 0000000..cde784f --- /dev/null +++ b/frontend/src/design-system/sections/IntroSection.tsx @@ -0,0 +1,68 @@ +import { TrkSectionHeader, Trk } from '../lib/Trk'; + +export function IntroSection() { + return ( + <> + + + + 이 페이지의 목적 + + + 모든 스타일의 뼈대 — 페이지 파일은 이 쇼케이스에 정의된 컴포넌트와 토큰만 + 사용한다. 임의의 `className="bg-red-600"` 같은 직접 스타일은 금지. + + + 미세조정의 단일 지점 — 색상 · 여백 · 텍스트 크기 등 모든 변경은 이 페이지에서 + 먼저 시각적으로 검증한 후 실제 페이지에 적용한다. + + + ID 기반 참조 — 각 쇼케이스 항목에 TRK-* 추적 ID가 부여되어 있어, + 특정 변형을 정확히 가리키며 논의 · 수정이 가능하다. + + + + + + 사용 방법 + + + 상단 "ID 복사 모드" 체크박스를 켜면 쇼케이스 항목 클릭 시 ID가 클립보드에 + 복사된다. + + + URL 해시 #trk=TRK-BADGE-critical-sm 으로 특정 항목 딥링크 — 스크롤 + + 하이라이트. + + + 상단 Dark / Light 토글로 두 테마에서 동시에 검증. + + + 좌측 네비게이션으로 섹션 이동. 각 섹션 제목 옆에 섹션의 TRK-SEC-* ID가 노출된다. + + + + + + 추적 ID 명명 규칙 + + {`TRK-<카테고리>-<슬러그>[-<변형>] + +예시: + TRK-TOKEN-text-heading → 시맨틱 토큰 --text-heading + TRK-BADGE-critical-sm → Badge intent=critical size=sm + TRK-BUTTON-primary-md → Button variant=primary size=md + TRK-LAYOUT-page-container → PageContainer 루트 + TRK-CAT-alert-level-HIGH → alertLevels 카탈로그의 HIGH 배지 + TRK-SEC-badge → Badge 섹션 자체 + +카테고리: SEC, INTRO, TOKEN, TYPO, BADGE, BUTTON, FORM, CARD, LAYOUT, CAT, GUIDE`} + + + > + ); +} diff --git a/frontend/src/design-system/sections/LayoutSection.tsx b/frontend/src/design-system/sections/LayoutSection.tsx new file mode 100644 index 0000000..c810e50 --- /dev/null +++ b/frontend/src/design-system/sections/LayoutSection.tsx @@ -0,0 +1,177 @@ +import { TrkSectionHeader, Trk } from '../lib/Trk'; +import { PageContainer, PageHeader, Section } from '@shared/components/layout'; +import { Button } from '@shared/components/ui/button'; +import { Input } from '@shared/components/ui/input'; +import { Shield, BarChart3, Plus, Search } from 'lucide-react'; + +export function LayoutSection() { + return ( + <> + + + {/* PageContainer */} + PageContainer + + + size=sm (p-4 space-y-3) + + + + + + + + + size=md · 기본값 (p-5 space-y-4) + + + + + + + + + size=lg (p-6 space-y-4) + + + + + + + + + + + + fullBleed — 지도/풀화면 페이지 (padding 0, space-y 0) + + + + + fullBleed: 가장자리까지 콘텐츠 (LiveMapView / VesselDetail 패턴) + + + + + + {/* PageHeader */} + PageHeader + + + 단순형 (title + description) + + + + + 아이콘 포함 + + + + + 데모 배지 + + + + + 우측 액션 슬롯 + + + }> + 검색 + + }> + 새 보고서 + + > + } + /> + + + + {/* Section */} + Section (Card 단축) + + + 기본 Section + + + + 동해 + 23건 + + + 서해 + 12건 + + + + + + + 우측 액션 + + 전체 보기 + + } + > + 이벤트 3건 + + + + + {/* 전체 조합 예시 */} + 전체 조합 예시 + + + {`import { PageContainer, PageHeader, Section } from '@shared/components/layout'; +import { Button } from '@shared/components/ui/button'; +import { Shield, Plus } from 'lucide-react'; + +export function AccessControlPage() { + return ( + + }> + 역할 추가 + + } + /> + + + + + ); +}`} + + + > + ); +} diff --git a/frontend/src/design-system/sections/TokenSection.tsx b/frontend/src/design-system/sections/TokenSection.tsx new file mode 100644 index 0000000..908edda --- /dev/null +++ b/frontend/src/design-system/sections/TokenSection.tsx @@ -0,0 +1,160 @@ +import { TrkSectionHeader, Trk } from '../lib/Trk'; + +/** 시맨틱 색상 토큰 목록. theme.css :root 정의 기준 */ +const SURFACE_TOKENS = [ + { id: 'background', label: '--background', desc: '페이지 최하위 배경' }, + { id: 'card', label: '--card', desc: 'Card 컴포넌트 배경' }, + { id: 'surface-raised', label: '--surface-raised', desc: '카드 내부 섹션' }, + { id: 'surface-overlay', label: '--surface-overlay', desc: '모달/오버레이' }, + { id: 'muted', label: '--muted', desc: '약한 배경 (inactive/disabled)' }, + { id: 'popover', label: '--popover', desc: '드롭다운/툴팁 배경' }, +]; + +const TEXT_TOKENS = [ + { id: 'text-heading', label: '--text-heading', example: '제목 Heading', desc: 'h1~h4, 강조 텍스트' }, + { id: 'text-label', label: '--text-label', example: '본문 Label', desc: '일반 본문, 라벨' }, + { id: 'text-hint', label: '--text-hint', example: '보조 Hint', desc: '설명, 힌트, placeholder' }, + { id: 'text-on-vivid', label: '--text-on-vivid', example: '컬러풀 위 텍스트', desc: '-600/-700 진한 배경 위 (버튼)', bg: '#2563eb' }, + { id: 'text-on-bright', label: '--text-on-bright', example: '밝은 위 텍스트', desc: '-300/-400 밝은 배경 위 (배지)', bg: '#60a5fa' }, +]; + +const BRAND_COLORS = [ + { id: 'primary', label: '--primary', desc: '기본 강조색 (파랑)' }, + { id: 'destructive', label: '--destructive', desc: '위험/삭제 (빨강)' }, + { id: 'ring', label: '--ring', desc: 'focus ring' }, + { id: 'border', label: '--border', desc: '경계선' }, +]; + +const CHART_COLORS = [ + { id: 'chart-1', label: '--chart-1' }, + { id: 'chart-2', label: '--chart-2' }, + { id: 'chart-3', label: '--chart-3' }, + { id: 'chart-4', label: '--chart-4' }, + { id: 'chart-5', label: '--chart-5' }, +]; + +const RADIUS_SCALE = [ + { id: 'sm', label: 'radius-sm', cls: 'rounded-sm', px: 'calc(radius - 4px)' }, + { id: 'md', label: 'radius-md', cls: 'rounded-md', px: 'calc(radius - 2px)' }, + { id: 'lg', label: 'radius-lg', cls: 'rounded-lg', px: '0.5rem (default)' }, + { id: 'xl', label: 'radius-xl', cls: 'rounded-xl', px: 'calc(radius + 4px)' }, + { id: 'full', label: 'rounded-full', cls: 'rounded-full', px: '9999px' }, +]; + +const SPACING_SCALE = [ + { id: '1', size: 4 }, { id: '2', size: 8 }, { id: '3', size: 12 }, + { id: '4', size: 16 }, { id: '5', size: 20 }, { id: '6', size: 24 }, + { id: '8', size: 32 }, { id: '10', size: 40 }, +]; + +export function TokenSection() { + return ( + <> + + + {/* Surface 토큰 */} + Surface / 배경 + + {SURFACE_TOKENS.map((t) => ( + + + {t.label} + {t.desc} + TRK-TOKEN-{t.id} + + ))} + + + {/* 텍스트 토큰 */} + Text / 텍스트 + + {TEXT_TOKENS.map((t) => ( + + + {t.example} + + {t.label} + {t.desc} + TRK-TOKEN-{t.id} + + ))} + + + {/* 브랜드 색 */} + Brand / 기능색 + + {BRAND_COLORS.map((t) => ( + + + {t.label} + {t.desc} + + ))} + + + {/* Chart 색 */} + Chart / 차트 팔레트 + + + {CHART_COLORS.map((t) => ( + + + {t.label} + + ))} + + TRK-TOKEN-chart-palette + + + {/* Radius 스케일 */} + Radius / 모서리 반경 + + + {RADIUS_SCALE.map((r) => ( + + + {r.label} + {r.px} + + ))} + + + + {/* Spacing 스케일 */} + Spacing / 간격 스케일 + + + {SPACING_SCALE.map((s) => ( + + p-{s.id} + + {s.size}px + + ))} + + + > + ); +} diff --git a/frontend/src/design-system/sections/TypographySection.tsx b/frontend/src/design-system/sections/TypographySection.tsx new file mode 100644 index 0000000..964888c --- /dev/null +++ b/frontend/src/design-system/sections/TypographySection.tsx @@ -0,0 +1,35 @@ +import { TrkSectionHeader, Trk } from '../lib/Trk'; + +const TYPE_SCALE = [ + { id: 'h1', tag: 'h1' as const, cls: 'text-2xl font-bold text-heading', example: '주요 타이틀 (H1 · text-2xl)' }, + { id: 'h2', tag: 'h2' as const, cls: 'text-xl font-bold text-heading', example: '페이지 제목 (H2 · text-xl)' }, + { id: 'h3', tag: 'h3' as const, cls: 'text-lg font-semibold text-heading', example: '섹션 제목 (H3 · text-lg)' }, + { id: 'h4', tag: 'h4' as const, cls: 'text-base font-semibold text-heading', example: '카드 제목 (H4 · text-base)' }, + { id: 'body', tag: 'p' as const, cls: 'text-sm text-label', example: '본문 텍스트 (body · text-sm)' }, + { id: 'body-sm', tag: 'p' as const, cls: 'text-xs text-label', example: '작은 본문 (body-sm · text-xs)' }, + { id: 'label', tag: 'span' as const, cls: 'text-[11px] text-label font-medium', example: '라벨 (label · 11px)' }, + { id: 'hint', tag: 'span' as const, cls: 'text-[10px] text-hint', example: '힌트/캡션 (hint · 10px)' }, +]; + +export function TypographySection() { + return ( + <> + + + + {TYPE_SCALE.map(({ id, tag: Tag, cls, example }) => ( + + + {example} + {cls} + + + ))} + + > + ); +} diff --git a/frontend/src/designSystemMain.tsx b/frontend/src/designSystemMain.tsx new file mode 100644 index 0000000..3a71b9d --- /dev/null +++ b/frontend/src/designSystemMain.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import './styles/index.css'; +import { DesignSystemApp } from './design-system/DesignSystemApp'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/frontend/src/features/admin/AccessControl.tsx b/frontend/src/features/admin/AccessControl.tsx index bfa4716..67e5013 100644 --- a/frontend/src/features/admin/AccessControl.tsx +++ b/frontend/src/features/admin/AccessControl.tsx @@ -2,6 +2,8 @@ import { useEffect, useState, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { Button } from '@shared/components/ui/button'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { Shield, Users, UserCheck, Key, Lock, FileText, Loader2, RefreshCw, UserCog, @@ -18,6 +20,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 +36,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 +120,7 @@ export function AccessControl() { return ( {list.map((r) => ( - {r} + {r} ))} ); @@ -144,7 +129,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', @@ -191,8 +176,7 @@ export function AccessControl() { { key: 'result', label: '결과', width: '70px', sortable: true, render: (v) => { const r = v as string; - const c = r === 'SUCCESS' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'; - return {r || '-'}; + return {r || '-'}; }, }, { key: 'failReason', label: '실패 사유', @@ -200,33 +184,36 @@ export function AccessControl() { ], []); return ( - - - - - - {t('accessControl.title')} - - {t('accessControl.desc')} - - - {userStats && ( - - - 활성 {userStats.active}명 - | - 잠금 {userStats.locked} - | - 총 {userStats.total} - - )} - { if (tab === 'users') loadUsers(); else if (tab === 'audit') loadAudit(); }} - className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침"> - - - - + + + {userStats && ( + + + 활성 {userStats.active}명 + | + 잠금 {userStats.locked} + | + 총 {userStats.total} + + )} + { if (tab === 'users') loadUsers(); else if (tab === 'audit') loadAudit(); }} + title="새로고침" + icon={} + > + 새로고침 + + > + } + /> {/* 탭 */} @@ -241,7 +228,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' }`} > @@ -371,7 +358,7 @@ export function AccessControl() { ]} /> )} - + ); } diff --git a/frontend/src/features/admin/AccessLogs.tsx b/frontend/src/features/admin/AccessLogs.tsx index e660abe..6e367c3 100644 --- a/frontend/src/features/admin/AccessLogs.tsx +++ b/frontend/src/features/admin/AccessLogs.tsx @@ -1,9 +1,12 @@ import { useEffect, useState, useCallback } from 'react'; -import { Loader2, RefreshCw } from 'lucide-react'; +import { Loader2, RefreshCw, Activity } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { Button } from '@shared/components/ui/button'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { fetchAccessLogs, fetchAccessStats, type AccessLog, type AccessStats } from '@/services/adminApi'; import { formatDateTime } from '@shared/utils/dateFormat'; +import { getHttpStatusIntent } from '@shared/constants/httpStatusCodes'; /** * 접근 이력 조회 + 메트릭 카드. @@ -30,22 +33,19 @@ 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 ( - - - - 접근 이력 - AccessLogFilter가 모든 HTTP 요청 비동기 기록 - - - 새로고침 - - + + }> + 새로고침 + + } + /> {stats && ( @@ -113,7 +113,7 @@ export function AccessLogs() { {it.httpMethod} {it.requestPath} - {it.statusCode} + {it.statusCode} {it.durationMs} {it.ipAddress || '-'} @@ -124,7 +124,7 @@ export function AccessLogs() { )} - + ); } diff --git a/frontend/src/features/admin/AdminPanel.tsx b/frontend/src/features/admin/AdminPanel.tsx index 6a66e06..e83874c 100644 --- a/frontend/src/features/admin/AdminPanel.tsx +++ b/frontend/src/features/admin/AdminPanel.tsx @@ -1,6 +1,9 @@ import { useTranslation } from 'react-i18next'; import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; -import { Settings, Server, HardDrive, Shield, Clock, Database } from 'lucide-react'; +import { Badge } from '@shared/components/ui/badge'; +import { PageContainer, PageHeader } from '@shared/components/layout'; +import { getStatusIntent } from '@shared/constants/statusIntent'; +import { Settings, Server, Shield, Database } from 'lucide-react'; /* * 시스템 관리 — 서버 상태, 디스크, 보안 설정 등 인프라 관리 @@ -30,17 +33,13 @@ function UsageBar({ value }: { value: number }) { export function AdminPanel() { const { t } = useTranslation('admin'); return ( - - - - - {t('adminPanel.title')} - - ⚠데모 데이터 (백엔드 API 미구현) - - - {t('adminPanel.desc')} - + + {/* 서버 상태 */} @@ -52,9 +51,7 @@ export function AdminPanel() { {s.name} - {s.status} + {s.status} CPU @@ -89,6 +86,6 @@ export function AdminPanel() { - + ); } diff --git a/frontend/src/features/admin/AuditLogs.tsx b/frontend/src/features/admin/AuditLogs.tsx index c427527..08d9625 100644 --- a/frontend/src/features/admin/AuditLogs.tsx +++ b/frontend/src/features/admin/AuditLogs.tsx @@ -1,7 +1,9 @@ import { useEffect, useState, useCallback } from 'react'; -import { Loader2, RefreshCw } from 'lucide-react'; +import { Loader2, RefreshCw, FileSearch } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { Button } from '@shared/components/ui/button'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { fetchAuditLogs, fetchAuditStats, type AuditLog, type AuditStats } from '@/services/adminApi'; import { formatDateTime } from '@shared/utils/dateFormat'; @@ -31,16 +33,18 @@ export function AuditLogs() { useEffect(() => { load(); }, [load]); return ( - - - - 감사 로그 - @Auditable AOP가 모든 운영자 의사결정 자동 기록 - - - 새로고침 - - + + }> + 새로고침 + + } + /> {/* 통계 카드 */} {stats && ( @@ -99,7 +103,7 @@ export function AuditLogs() { {it.actionCd} {it.resourceType ?? '-'} {it.resourceId ? `(${it.resourceId})` : ''} - + {it.result || '-'} @@ -115,7 +119,7 @@ export function AuditLogs() { )} - + ); } diff --git a/frontend/src/features/admin/DataHub.tsx b/frontend/src/features/admin/DataHub.tsx index de4376f..7c1b6cf 100644 --- a/frontend/src/features/admin/DataHub.tsx +++ b/frontend/src/features/admin/DataHub.tsx @@ -2,8 +2,21 @@ import { useState, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; +import { Button } from '@shared/components/ui/button'; +import { TabBar, TabButton } from '@shared/components/ui/tabs'; +import { PageContainer, PageHeader } from '@shared/components/layout'; import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; import { SaveButton } from '@shared/components/common/SaveButton'; +import { getConnectionStatusHex } from '@shared/constants/connectionStatuses'; +import type { BadgeIntent } from '@lib/theme/variants'; + +/** 수집/적재 작업 상태 → BadgeIntent 매핑 (DataHub 로컬 전용) */ +function jobStatusIntent(s: string): BadgeIntent { + if (s === '수행중') return 'success'; + if (s === '대기중') return 'warning'; + if (s === '장애발생') return 'critical'; + return 'muted'; +} import { Database, RefreshCw, Calendar, Wifi, WifiOff, Radio, Activity, Server, ArrowDownToLine, Clock, AlertTriangle, @@ -43,11 +56,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 +120,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 +138,7 @@ const channelColumns: DataColumn[] = [ const on = v === 'ON'; return ( - + {v as string} {row.lastUpdate && ( @@ -163,7 +172,7 @@ function SignalTimeline({ source }: { source: SignalSource }) { ))} @@ -209,18 +218,14 @@ const collectColumns: DataColumn[] = [ { key: 'serverType', label: '타입', width: '60px', align: 'center', sortable: true, render: (v) => { const t = v as string; - const c = t === 'SQL' ? 'bg-blue-500/20 text-blue-400' : t === 'FILE' ? 'bg-green-500/20 text-green-400' : 'bg-purple-500/20 text-purple-400'; - return {t}; + const intent: BadgeIntent = t === 'SQL' ? 'info' : t === 'FILE' ? 'success' : 'purple'; + return {t}; }, }, { key: 'serverName', label: '서버명', width: '120px', render: (v) => {v as string} }, { key: 'serverIp', label: 'IP', width: '120px', render: (v) => {v as string} }, { key: 'status', label: '상태', width: '80px', align: 'center', sortable: true, - render: (v) => { - const s = v as JobStatus; - const c = s === '수행중' ? 'bg-green-500/20 text-green-400' : s === '대기중' ? 'bg-yellow-500/20 text-yellow-400' : s === '장애발생' ? 'bg-red-500/20 text-red-400' : 'bg-muted text-muted-foreground'; - return {s}; - }, + render: (v) => {v as string}, }, { key: 'schedule', label: '스케줄', width: '80px' }, { key: 'lastRun', label: '최종 수행', width: '140px', sortable: true, render: (v) => {v as string} }, @@ -235,12 +240,12 @@ const collectColumns: DataColumn[] = [ render: (_v, row) => ( {row.status === '정지' ? ( - + ) : row.status !== '장애발생' ? ( - + ) : null} - - + + ), }, @@ -274,15 +279,11 @@ 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, - render: (v) => { - const s = v as JobStatus; - const c = s === '수행중' ? 'bg-green-500/20 text-green-400' : s === '대기중' ? 'bg-yellow-500/20 text-yellow-400' : s === '장애발생' ? 'bg-red-500/20 text-red-400' : 'bg-muted text-muted-foreground'; - return {s}; - }, + render: (v) => {v as string}, }, { key: 'schedule', label: '스케줄', width: '80px' }, { key: 'lastRun', label: '최종 적재', width: '140px', sortable: true, render: (v) => {v as string} }, @@ -290,9 +291,9 @@ const loadColumns: DataColumn[] = [ { key: 'id', label: '', width: '70px', align: 'center', sortable: false, render: () => ( - - - + + + ), }, @@ -383,28 +384,19 @@ export function DataHub() { ); return ( - - {/* 헤더 */} - - - - - {t('dataHub.title')} - - ⚠데모 데이터 (백엔드 API 미구현) - - - - {t('dataHub.desc')} - - - - - + + }> 새로고침 - - - + + } + /> {/* KPI */} @@ -427,7 +419,7 @@ export function DataHub() { {/* 탭 */} - + {[ { key: 'signal' as Tab, icon: Activity, label: '선박신호 수신 현황' }, { key: 'monitor' as Tab, icon: Server, label: '선박위치정보 모니터링' }, @@ -435,18 +427,17 @@ export function DataHub() { { key: 'load' as Tab, icon: HardDrive, label: '적재 작업 관리' }, { key: 'agents' as Tab, icon: Network, label: '연계서버 모니터링' }, ].map((t) => ( - 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' - }`} + icon={} > - {t.label} - + ))} - + {/* ── ① 선박신호 수신 현황 ── */} {tab === 'signal' && ( @@ -458,16 +449,16 @@ export function DataHub() { setSelectedDate(e.target.value)} className="bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-1.5 text-[11px] text-heading focus:outline-none focus:border-cyan-500/50" /> - - + }> 새로고침 - + @@ -536,17 +527,15 @@ export function DataHub() { {/* 상태 필터 */} {(['', 'ON', 'OFF'] as const).map((f) => ( - setStatusFilter(f)} - className={`px-2.5 py-1 rounded text-[10px] transition-colors ${ - statusFilter === f - ? 'bg-cyan-600 text-heading font-bold' - : 'text-hint hover:bg-surface-overlay hover:text-label' - }`} + className="px-2.5 py-1 text-[10px]" > {f || '전체'} - + ))} @@ -569,19 +558,21 @@ export function DataHub() { 서버 타입: {(['', 'SQL', 'FILE', 'FTP'] as const).map((f) => ( - setCollectTypeFilter(f)} - className={`px-2.5 py-1 rounded text-[10px] transition-colors ${collectTypeFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`} - >{f || '전체'} + setCollectTypeFilter(f)} className="px-2.5 py-1 text-[10px]"> + {f || '전체'} + ))} 상태: {(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => ( - setCollectStatusFilter(f)} - className={`px-2.5 py-1 rounded text-[10px] transition-colors ${collectStatusFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`} - >{f || '전체'} + setCollectStatusFilter(f)} className="px-2.5 py-1 text-[10px]"> + {f || '전체'} + ))} - - 작업 등록 - + + }> + 작업 등록 + + @@ -594,17 +585,17 @@ export function DataHub() { 상태: {(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => ( - setLoadStatusFilter(f)} - className={`px-2.5 py-1 rounded text-[10px] transition-colors ${loadStatusFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`} - >{f || '전체'} + setLoadStatusFilter(f)} className="px-2.5 py-1 text-[10px]"> + {f || '전체'} + ))} - - 스토리지 관리 - - - 작업 등록 - + }> + 스토리지 관리 + + }> + 작업 등록 + 종류: {(['', '수집', '적재'] as const).map((f) => ( - setAgentRoleFilter(f)} - className={`px-2.5 py-1 rounded text-[10px] transition-colors ${agentRoleFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`} - >{f || '전체'} + setAgentRoleFilter(f)} className="px-2.5 py-1 text-[10px]"> + {f || '전체'} + ))} 상태: {(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => ( - setAgentStatusFilter(f)} - className={`px-2.5 py-1 rounded text-[10px] transition-colors ${agentStatusFilter === f ? 'bg-cyan-600 text-heading font-bold' : 'text-hint hover:bg-surface-overlay'}`} - >{f || '전체'} + setAgentStatusFilter(f)} className="px-2.5 py-1 text-[10px]"> + {f || '전체'} + ))} - - 새로고침 - + + }> + 새로고침 + + {/* 연계서버 카드 그리드 */} {filteredAgents.map((agent) => { - const stColor = agent.status === '수행중' ? 'text-green-400 bg-green-500/15' : agent.status === '대기중' ? 'text-yellow-400 bg-yellow-500/15' : agent.status === '장애발생' ? 'text-red-400 bg-red-500/15' : 'text-muted-foreground bg-muted'; return ( @@ -645,7 +637,7 @@ export function DataHub() { {agent.name} {agent.id} · {agent.role}에이전트 - {agent.status} + {agent.status}
+ {`import { Button } from '@shared/components/ui/button'; +import { Plus } from 'lucide-react'; + +}> + 새 보고서 + + + + 삭제 + + +// 금지 +// ❌ +// ❌ → variant="destructive" 사용`} +
중첩 카드 내부에 사용. 외부 카드보다 한 단계 낮은 depth.
배경/보더 없이 구조만 활용 (그룹핑 목적).
+ {`import { Card, CardHeader, CardTitle, CardContent } from '@shared/components/ui/card'; +import { Database } from 'lucide-react'; + + + + + + 데이터베이스 + + + + {/* 콘텐츠 */} + +`} +
+ {meta.code} +
+ 페이지에서 배지를 렌더할 때는 반드시 카탈로그 API를 사용한다 (라벨/intent/색상 단일 관리): +
+ {`import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels'; + + + {getAlertLevelLabel(event.level, t, lang)} +`} +
+ 새 카탈로그 추가: shared/constants/catalogRegistry.ts에 + 항목을 추가하면 이 쇼케이스에 자동 노출됩니다. +
shared/constants/catalogRegistry.ts
{entry.showcaseId}
{entry.description}
출처: {entry.source}
+ {`import { Input } from '@shared/components/ui/input'; +import { Select } from '@shared/components/ui/select'; +import { Checkbox } from '@shared/components/ui/checkbox'; + + setQ(e.target.value)} /> + + setLevel(e.target.value)}> + 전체 등급 + CRITICAL + + + setActive(e.target.checked)} />`} +
+ 예외: <PageContainer fullBleed>로 명시.{' '} + -m-4, -mx-4 같은 negative margin 해킹 금지. +
<PageContainer fullBleed>
-m-4
-mx-4
w-48
flex-1
+ 허용:{' '} + + <Badge intent="info" className="w-full justify-center"> + +
+ <Badge intent="info" className="w-full justify-center"> +
+ 금지:{' '} + <Badge className="bg-red-500 text-white">{' '} + — intent prop을 대체하려 하지 말 것 +
<Badge className="bg-red-500 text-white">
style={`{{ background: role.colorHex }}`}
getAlertLevelHex(level)
!important
!bg-red-500
className="bg-X text-Y"
<button className="bg-blue-600 ...">
<input className="bg-surface ...">
p-4 space-y-5
const STATUS_COLORS = {`{...}`}
date.toLocaleString('ko-KR', ...)
formatDateTime
+ {`import { PageContainer, PageHeader, Section } from '@shared/components/layout'; +import { Button } from '@shared/components/ui/button'; +import { Input } from '@shared/components/ui/input'; +import { Badge } from '@shared/components/ui/badge'; +import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels'; +import { formatDateTime } from '@shared/utils/dateFormat'; +import { Shield, Plus } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +export function MyNewPage() { + const { t, i18n } = useTranslation('common'); + const lang = i18n.language as 'ko' | 'en'; + + return ( + + }> + 추가 + + } + /> + + + + {getAlertLevelLabel('HIGH', t, lang)} + + {formatDateTime(row.createdAt)} + + + ); +}`} +
TRK-*
#trk=TRK-BADGE-critical-sm
TRK-SEC-*
+ {`TRK-<카테고리>-<슬러그>[-<변형>] + +예시: + TRK-TOKEN-text-heading → 시맨틱 토큰 --text-heading + TRK-BADGE-critical-sm → Badge intent=critical size=sm + TRK-BUTTON-primary-md → Button variant=primary size=md + TRK-LAYOUT-page-container → PageContainer 루트 + TRK-CAT-alert-level-HIGH → alertLevels 카탈로그의 HIGH 배지 + TRK-SEC-badge → Badge 섹션 자체 + +카테고리: SEC, INTRO, TOKEN, TYPO, BADGE, BUTTON, FORM, CARD, LAYOUT, CAT, GUIDE`} +
+ {`import { PageContainer, PageHeader, Section } from '@shared/components/layout'; +import { Button } from '@shared/components/ui/button'; +import { Shield, Plus } from 'lucide-react'; + +export function AccessControlPage() { + return ( + + }> + 역할 추가 + + } + /> + + + + + ); +}`} +
p-{s.id}
{cls}
{t('accessControl.desc')}
AccessLogFilter가 모든 HTTP 요청 비동기 기록
{t('adminPanel.desc')}
@Auditable AOP가 모든 운영자 의사결정 자동 기록
- {t('dataHub.desc')} -
성공/실패 로그인 시도 기록 (5회 실패 시 자동 잠금)
- {t('notices.desc')} -
- {t('systemConfig.desc')} -
{t('assistant.desc')}
- {t('modelManagement.desc')} -
{`GET /api/v1/predictions/grid @@ -948,7 +946,7 @@ export function AIModelManagement() { ].map((s) => ( - {s.sfr} + {s.sfr} {s.name} {s.desc} @@ -989,6 +987,6 @@ export function AIModelManagement() { )} -
{t('mlops.desc')}
2. **최종 위치**: EEZ/NLL 경계 5NM 이내 여부
3. **과거 이력**: MMSI 변조, 이전 단속 기록 확인
{event.detail}
{t('darkVessel.desc')}
{t('gearDetection.desc')}
{t('history.desc')}
- {t('eventList.desc')} -
{t('aiAlert.desc')}
{t('mobileService.desc')}
{t('shipAgent.desc')}
{t('monitoring.desc')}
정답 라벨링 → prediction 모델 학습 데이터로 활용
GROUP/GLOBAL 스코프로 잘못된 후보를 차단합니다.
- 추론된 모선 후보를 확정/거부합니다. 권한: parent-inference-workflow:parent-review (UPDATE) -
{t('fleetOptimization.desc')}
{t('patrolRoute.desc')}
{t('enforcementPlan.desc')}
SFR-05 | 위험도 히트맵 (수집 중) + MTIS 해양사고 통계 (중앙해양안전심판원)
{t('externalService.desc')}
{t('reports.desc')}
- {t('statistics.desc')} -
한국연안 해상사격 훈련구역도 No.462 | Chart of Firing and Bombing Exercise Areas | WGS-84 | 출처: 국립해양조사원
선박 간 근접 접촉 및 환적 의심 행위 분석
` 형태를 가진다 (Meta는 개별 파일의 타입 사용). + * 쇼케이스는 heterogeneous 타입을 허용하므로 CatalogEntry의 items는 Record 로 처리. + */ + +import { ALERT_LEVELS } from './alertLevels'; +import { VIOLATION_TYPES } from './violationTypes'; +import { EVENT_STATUSES } from './eventStatuses'; +import { ENFORCEMENT_ACTIONS } from './enforcementActions'; +import { ENFORCEMENT_RESULTS } from './enforcementResults'; +import { PATROL_STATUSES } from './patrolStatuses'; +import { ENGINE_SEVERITIES } from './engineSeverities'; +import { DEVICE_STATUSES } from './deviceStatuses'; +import { + PARENT_RESOLUTION_STATUSES, + LABEL_SESSION_STATUSES, +} from './parentResolutionStatuses'; +import { + MODEL_STATUSES, + QUALITY_GATE_STATUSES, + EXPERIMENT_STATUSES, +} from './modelDeploymentStatuses'; +import { GEAR_GROUP_TYPES } from './gearGroupTypes'; +import { DARK_VESSEL_PATTERNS } from './darkVesselPatterns'; +import { USER_ACCOUNT_STATUSES } from './userAccountStatuses'; +import { LOGIN_RESULTS } from './loginResultStatuses'; +import { PERMIT_STATUSES, GEAR_JUDGMENTS } from './permissionStatuses'; +import { + VESSEL_SURVEILLANCE_STATUSES, + VESSEL_RISK_RINGS, +} from './vesselAnalysisStatuses'; +import { CONNECTION_STATUSES } from './connectionStatuses'; +import { TRAINING_ZONE_TYPES } from './trainingZoneTypes'; + +/** + * 카탈로그 공통 메타 — 쇼케이스 렌더와 UI 일관성을 위한 최소 스키마 + */ +export interface CatalogEntry { + /** 안정적 ID (쇼케이스 추적 ID의 슬러그 부분) */ + id: string; + /** 쇼케이스 추적 ID (TRK-CAT-*) */ + showcaseId: string; + /** 쇼케이스 섹션 제목 (한글) */ + titleKo: string; + /** 쇼케이스 섹션 제목 (영문) */ + titleEn: string; + /** 1줄 설명 */ + description: string; + /** 출처 (백엔드 enum / code_master 등) */ + source?: string; + /** 카탈로그 데이터 — items의 각 meta는 { code, fallback?, intent?, classes?, ... } 구조 */ + items: Record; +} + +/** + * 전체 카탈로그 레지스트리 + * + * ⚠️ 새 카탈로그 추가 시: + * 1. shared/constants/에 파일 생성 (items record + 헬퍼 함수) + * 2. 이 레지스트리에 CatalogEntry 추가 + * 3. 쇼케이스에 자동 노출 + 실 페이지에서 헬퍼 함수로 참조 + */ +export const CATALOG_REGISTRY: CatalogEntry[] = [ + { + id: 'alert-level', + showcaseId: 'TRK-CAT-alert-level', + titleKo: '위험도', + titleEn: 'Alert Level', + description: 'CRITICAL / HIGH / MEDIUM / LOW — 모든 이벤트/알림 배지', + source: 'backend code_master EVENT_LEVEL', + items: ALERT_LEVELS, + }, + { + id: 'violation-type', + showcaseId: 'TRK-CAT-violation-type', + titleKo: '위반 유형', + titleEn: 'Violation Type', + description: '중국불법조업 / 환적의심 / EEZ침범 등', + source: 'backend ViolationType enum', + items: VIOLATION_TYPES, + }, + { + id: 'event-status', + showcaseId: 'TRK-CAT-event-status', + titleKo: '이벤트 상태', + titleEn: 'Event Status', + description: 'NEW / ACK / IN_PROGRESS / RESOLVED / FALSE_POSITIVE', + source: 'backend code_master EVENT_STATUS', + items: EVENT_STATUSES, + }, + { + id: 'enforcement-action', + showcaseId: 'TRK-CAT-enforcement-action', + titleKo: '단속 조치', + titleEn: 'Enforcement Action', + description: 'CAPTURE / INSPECT / WARN / DISPERSE / TRACK / EVIDENCE', + source: 'backend code_master ENFORCEMENT_ACTION', + items: ENFORCEMENT_ACTIONS, + }, + { + id: 'enforcement-result', + showcaseId: 'TRK-CAT-enforcement-result', + titleKo: '단속 결과', + titleEn: 'Enforcement Result', + description: 'PUNISHED / REFERRED / WARNED / RELEASED / FALSE_POSITIVE', + source: 'backend code_master ENFORCEMENT_RESULT', + items: ENFORCEMENT_RESULTS, + }, + { + id: 'patrol-status', + showcaseId: 'TRK-CAT-patrol-status', + titleKo: '함정 상태', + titleEn: 'Patrol Status', + description: '출동 / 순찰 / 복귀 / 정박 / 정비 / 대기', + source: 'backend code_master PATROL_STATUS', + items: PATROL_STATUSES, + }, + { + id: 'engine-severity', + showcaseId: 'TRK-CAT-engine-severity', + titleKo: '엔진 심각도', + titleEn: 'Engine Severity', + description: 'AI 모델/분석엔진 오류 심각도', + items: ENGINE_SEVERITIES, + }, + { + id: 'device-status', + showcaseId: 'TRK-CAT-device-status', + titleKo: '함정 Agent 장치 상태', + titleEn: 'Device Status', + description: 'ONLINE / OFFLINE / SYNCING / NOT_DEPLOYED', + items: DEVICE_STATUSES, + }, + { + id: 'parent-resolution', + showcaseId: 'TRK-CAT-parent-resolution', + titleKo: '모선 확정 상태', + titleEn: 'Parent Resolution Status', + description: 'PENDING / CONFIRMED / REJECTED / REVIEWING', + items: PARENT_RESOLUTION_STATUSES, + }, + { + id: 'label-session', + showcaseId: 'TRK-CAT-label-session', + titleKo: '라벨 세션', + titleEn: 'Label Session Status', + description: '모선 학습 세션 상태', + items: LABEL_SESSION_STATUSES, + }, + { + id: 'model-status', + showcaseId: 'TRK-CAT-model-status', + titleKo: 'AI 모델 상태', + titleEn: 'Model Status', + description: 'DEV / STAGING / CANARY / PROD / ARCHIVED', + items: MODEL_STATUSES, + }, + { + id: 'quality-gate', + showcaseId: 'TRK-CAT-quality-gate', + titleKo: '품질 게이트', + titleEn: 'Quality Gate Status', + description: '모델 배포 전 품질 검증', + items: QUALITY_GATE_STATUSES, + }, + { + id: 'experiment', + showcaseId: 'TRK-CAT-experiment', + titleKo: 'ML 실험', + titleEn: 'Experiment Status', + description: 'MLOps 실험 상태', + items: EXPERIMENT_STATUSES, + }, + { + id: 'gear-group', + showcaseId: 'TRK-CAT-gear-group', + titleKo: '어구 그룹 유형', + titleEn: 'Gear Group Type', + description: 'FLEET / GEAR_IN_ZONE / GEAR_OUT_ZONE', + items: GEAR_GROUP_TYPES, + }, + { + id: 'dark-vessel', + showcaseId: 'TRK-CAT-dark-vessel', + titleKo: '다크베셀 패턴', + titleEn: 'Dark Vessel Pattern', + description: 'AIS 끊김/스푸핑 패턴 5종', + items: DARK_VESSEL_PATTERNS, + }, + { + id: 'user-account', + showcaseId: 'TRK-CAT-user-account', + titleKo: '사용자 계정 상태', + titleEn: 'User Account Status', + description: 'ACTIVE / LOCKED / INACTIVE / PENDING', + items: USER_ACCOUNT_STATUSES, + }, + { + id: 'login-result', + showcaseId: 'TRK-CAT-login-result', + titleKo: '로그인 결과', + titleEn: 'Login Result', + description: 'SUCCESS / FAILED / LOCKED', + items: LOGIN_RESULTS, + }, + { + id: 'permit-status', + showcaseId: 'TRK-CAT-permit-status', + titleKo: '허가 상태', + titleEn: 'Permit Status', + description: '선박 허가 유효 / 만료 / 정지', + items: PERMIT_STATUSES, + }, + { + id: 'gear-judgment', + showcaseId: 'TRK-CAT-gear-judgment', + titleKo: '어구 판정', + titleEn: 'Gear Judgment', + description: '합법 / 의심 / 불법', + items: GEAR_JUDGMENTS, + }, + { + id: 'vessel-surveillance', + showcaseId: 'TRK-CAT-vessel-surveillance', + titleKo: '선박 감시 상태', + titleEn: 'Vessel Surveillance Status', + description: '관심선박 추적 상태', + items: VESSEL_SURVEILLANCE_STATUSES, + }, + { + id: 'vessel-risk-ring', + showcaseId: 'TRK-CAT-vessel-risk-ring', + titleKo: '선박 위험 링', + titleEn: 'Vessel Risk Ring', + description: '지도 마커 위험도 링', + items: VESSEL_RISK_RINGS, + }, + { + id: 'connection', + showcaseId: 'TRK-CAT-connection', + titleKo: '연결 상태', + titleEn: 'Connection Status', + description: 'OK / WARNING / ERROR', + items: CONNECTION_STATUSES, + }, + { + id: 'training-zone', + showcaseId: 'TRK-CAT-training-zone', + titleKo: '훈련 수역', + titleEn: 'Training Zone Type', + description: 'NAVY / AIRFORCE / ARMY / ADD / KCG', + items: TRAINING_ZONE_TYPES, + }, +]; + +/** ID로 특정 카탈로그 조회 */ +export function getCatalogById(id: string): CatalogEntry | undefined { + return CATALOG_REGISTRY.find((c) => c.id === id); +} 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..f791e67 --- /dev/null +++ b/frontend/src/shared/constants/enforcementActions.ts @@ -0,0 +1,107 @@ +/** + * 단속 조치 코드 공통 카탈로그 + * + * SSOT: backend `code_master` 그룹 ENFORCEMENT_ACTION (V008 시드). + * 백엔드 EnforcementRecord.action enum. + * + * 사용처: EnforcementHistory(조치 컬럼), 단속 등록 폼 + */ + +import type { BadgeIntent } from '@lib/theme/variants'; + +export type EnforcementAction = + | 'CAPTURE' + | 'INSPECT' + | 'WARN' + | 'DISPERSE' + | 'TRACK' + | 'EVIDENCE'; + +export interface EnforcementActionMeta { + code: EnforcementAction; + i18nKey: string; + fallback: { ko: string; en: string }; + intent: BadgeIntent; + classes: string; + hex: string; + order: number; +} + +export const ENFORCEMENT_ACTIONS: Record = { + CAPTURE: { + code: 'CAPTURE', + i18nKey: 'enforcementAction.CAPTURE', + fallback: { ko: '나포', en: 'Capture' }, + intent: 'critical', + classes: 'bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-400', + hex: '#ef4444', + order: 1, + }, + INSPECT: { + code: 'INSPECT', + i18nKey: 'enforcementAction.INSPECT', + fallback: { ko: '검문', en: 'Inspect' }, + intent: 'warning', + classes: 'bg-amber-100 text-amber-800 dark:bg-amber-500/20 dark:text-amber-400', + hex: '#f59e0b', + order: 2, + }, + WARN: { + code: 'WARN', + i18nKey: 'enforcementAction.WARN', + fallback: { ko: '경고', en: 'Warn' }, + intent: 'info', + classes: 'bg-blue-100 text-blue-800 dark:bg-blue-500/20 dark:text-blue-400', + hex: '#3b82f6', + order: 3, + }, + DISPERSE: { + code: 'DISPERSE', + i18nKey: 'enforcementAction.DISPERSE', + fallback: { ko: '퇴거', en: 'Disperse' }, + intent: 'purple', + classes: 'bg-violet-100 text-violet-800 dark:bg-violet-500/20 dark:text-violet-400', + hex: '#8b5cf6', + order: 4, + }, + TRACK: { + code: 'TRACK', + i18nKey: 'enforcementAction.TRACK', + fallback: { ko: '추적', en: 'Track' }, + intent: 'cyan', + classes: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-500/20 dark:text-cyan-400', + hex: '#06b6d4', + order: 5, + }, + EVIDENCE: { + code: 'EVIDENCE', + i18nKey: 'enforcementAction.EVIDENCE', + fallback: { ko: '증거수집', en: 'Evidence' }, + intent: 'muted', + classes: 'bg-slate-100 text-slate-700 dark:bg-slate-500/20 dark:text-slate-300', + hex: '#64748b', + order: 6, + }, +}; + +export function getEnforcementActionClasses(action: string): string { + return getEnforcementActionMeta(action)?.classes ?? 'bg-muted text-muted-foreground'; +} + +export function getEnforcementActionIntent(action: string): BadgeIntent { + return getEnforcementActionMeta(action)?.intent ?? 'muted'; +} + +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..284e731 --- /dev/null +++ b/frontend/src/shared/constants/enforcementResults.ts @@ -0,0 +1,91 @@ +/** + * 단속 결과 코드 공통 카탈로그 + * + * SSOT: backend `code_master` 그룹 ENFORCEMENT_RESULT (V008 시드). + * 백엔드 EnforcementRecord.result enum. + * + * 사용처: EnforcementHistory(결과 컬럼), 단속 통계 + */ + +import type { BadgeIntent } from '@lib/theme/variants'; + +export type EnforcementResult = + | 'PUNISHED' + | 'WARNED' + | 'RELEASED' + | 'REFERRED' + | 'FALSE_POSITIVE'; + +export interface EnforcementResultMeta { + code: EnforcementResult; + i18nKey: string; + fallback: { ko: string; en: string }; + intent: BadgeIntent; + classes: string; + order: number; +} + +export const ENFORCEMENT_RESULTS: Record = { + PUNISHED: { + code: 'PUNISHED', + i18nKey: 'enforcementResult.PUNISHED', + fallback: { ko: '처벌', en: 'Punished' }, + intent: 'critical', + classes: 'bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-400', + order: 1, + }, + REFERRED: { + code: 'REFERRED', + i18nKey: 'enforcementResult.REFERRED', + fallback: { ko: '수사의뢰', en: 'Referred' }, + intent: 'purple', + classes: 'bg-purple-100 text-purple-800 dark:bg-purple-500/20 dark:text-purple-400', + order: 2, + }, + WARNED: { + code: 'WARNED', + i18nKey: 'enforcementResult.WARNED', + fallback: { ko: '경고', en: 'Warned' }, + intent: 'warning', + classes: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-500/20 dark:text-yellow-400', + order: 3, + }, + RELEASED: { + code: 'RELEASED', + i18nKey: 'enforcementResult.RELEASED', + fallback: { ko: '훈방', en: 'Released' }, + intent: 'success', + classes: 'bg-green-100 text-green-800 dark:bg-green-500/20 dark:text-green-400', + order: 4, + }, + FALSE_POSITIVE: { + code: 'FALSE_POSITIVE', + i18nKey: 'enforcementResult.FALSE_POSITIVE', + fallback: { ko: '오탐(정상)', en: 'False Positive' }, + intent: 'muted', + classes: 'bg-muted text-muted-foreground', + order: 5, + }, +}; + +export function getEnforcementResultIntent(result: string): BadgeIntent { + return getEnforcementResultMeta(result)?.intent ?? 'muted'; +} + +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..31e015d --- /dev/null +++ b/frontend/src/shared/constants/eventStatuses.ts @@ -0,0 +1,91 @@ +/** + * 이벤트 처리 상태 공통 카탈로그 + * + * SSOT: backend `code_master` 그룹 EVENT_STATUS (V008 시드). + * 향후 `GET /api/code-master?groupCode=EVENT_STATUS`로 fetch 예정. + * + * 사용처: EventList(처리상태 컬럼), 알림 처리, 단속 등록 액션 + */ + +import type { BadgeIntent } from '@lib/theme/variants'; + +export type EventStatus = + | 'NEW' + | 'ACK' + | 'IN_PROGRESS' + | 'RESOLVED' + | 'FALSE_POSITIVE'; + +export interface EventStatusMeta { + code: EventStatus; + i18nKey: string; + fallback: { ko: string; en: string }; + intent: BadgeIntent; + classes: string; // bg + text 묶음 (legacy - use intent 권장) + order: number; +} + +export const EVENT_STATUSES: Record = { + NEW: { + code: 'NEW', + i18nKey: 'eventStatus.NEW', + fallback: { ko: '신규', en: 'New' }, + intent: 'critical', + classes: 'bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-400', + order: 1, + }, + ACK: { + code: 'ACK', + i18nKey: 'eventStatus.ACK', + fallback: { ko: '확인', en: 'Acknowledged' }, + intent: 'high', + classes: 'bg-orange-100 text-orange-800 dark:bg-orange-500/20 dark:text-orange-400', + order: 2, + }, + IN_PROGRESS: { + code: 'IN_PROGRESS', + i18nKey: 'eventStatus.IN_PROGRESS', + fallback: { ko: '처리중', en: 'In Progress' }, + intent: 'info', + classes: 'bg-blue-100 text-blue-800 dark:bg-blue-500/20 dark:text-blue-400', + order: 3, + }, + RESOLVED: { + code: 'RESOLVED', + i18nKey: 'eventStatus.RESOLVED', + fallback: { ko: '완료', en: 'Resolved' }, + intent: 'success', + classes: 'bg-green-100 text-green-800 dark:bg-green-500/20 dark:text-green-400', + order: 4, + }, + FALSE_POSITIVE: { + code: 'FALSE_POSITIVE', + i18nKey: 'eventStatus.FALSE_POSITIVE', + fallback: { ko: '오탐', en: 'False Positive' }, + intent: 'muted', + classes: 'bg-muted text-muted-foreground', + order: 5, + }, +}; + +export function getEventStatusIntent(status: string): BadgeIntent { + return getEventStatusMeta(status)?.intent ?? 'muted'; +} + +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..112a84f --- /dev/null +++ b/frontend/src/shared/constants/index.ts @@ -0,0 +1,32 @@ +/** + * 분류/코드 카탈로그 통합 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'; +export * from './statusIntent'; 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..d1bd898 --- /dev/null +++ b/frontend/src/shared/constants/patrolStatuses.ts @@ -0,0 +1,138 @@ +/** + * 함정 상태 공통 카탈로그 + * + * SSOT: backend `code_master` 그룹 PATROL_STATUS (V008 시드). + * 향후 patrol_ship_master.status 컬럼 enum. + * + * 사용처: Dashboard PatrolStatusBadge, ShipAgent + */ + +import type { BadgeIntent } from '@lib/theme/variants'; + +export type PatrolStatus = + | 'AVAILABLE' + | 'ON_PATROL' + | 'IN_PURSUIT' + | 'INSPECTING' + | 'RETURNING' + | 'STANDBY' + | 'MAINTENANCE'; + +export interface PatrolStatusMeta { + code: PatrolStatus; + i18nKey: string; + fallback: { ko: string; en: string }; + intent: BadgeIntent; + classes: string; + order: number; +} + +export const PATROL_STATUSES: Record = { + IN_PURSUIT: { + code: 'IN_PURSUIT', + i18nKey: 'patrolStatus.IN_PURSUIT', + fallback: { ko: '추적중', en: 'In Pursuit' }, + intent: 'critical', + classes: + 'bg-red-100 text-red-800 border-red-300 dark:bg-red-500/20 dark:text-red-400 dark:border-red-500/30', + order: 1, + }, + INSPECTING: { + code: 'INSPECTING', + i18nKey: 'patrolStatus.INSPECTING', + fallback: { ko: '검문중', en: 'Inspecting' }, + intent: 'high', + classes: + 'bg-orange-100 text-orange-800 border-orange-300 dark:bg-orange-500/20 dark:text-orange-400 dark:border-orange-500/30', + order: 2, + }, + ON_PATROL: { + code: 'ON_PATROL', + i18nKey: 'patrolStatus.ON_PATROL', + fallback: { ko: '초계중', en: 'On Patrol' }, + intent: 'info', + classes: + 'bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-500/20 dark:text-blue-400 dark:border-blue-500/30', + order: 3, + }, + RETURNING: { + code: 'RETURNING', + i18nKey: 'patrolStatus.RETURNING', + fallback: { ko: '귀항중', en: 'Returning' }, + intent: 'purple', + classes: + 'bg-purple-100 text-purple-800 border-purple-300 dark:bg-purple-500/20 dark:text-purple-400 dark:border-purple-500/30', + order: 4, + }, + AVAILABLE: { + code: 'AVAILABLE', + i18nKey: 'patrolStatus.AVAILABLE', + fallback: { ko: '가용', en: 'Available' }, + intent: 'success', + classes: + 'bg-green-100 text-green-800 border-green-300 dark:bg-green-500/20 dark:text-green-400 dark:border-green-500/30', + order: 5, + }, + STANDBY: { + code: 'STANDBY', + i18nKey: 'patrolStatus.STANDBY', + fallback: { ko: '대기', en: 'Standby' }, + intent: 'muted', + classes: + 'bg-slate-100 text-slate-700 border-slate-300 dark:bg-slate-500/20 dark:text-slate-400 dark:border-slate-500/30', + order: 6, + }, + MAINTENANCE: { + code: 'MAINTENANCE', + i18nKey: 'patrolStatus.MAINTENANCE', + fallback: { ko: '정비중', en: 'Maintenance' }, + intent: 'warning', + classes: + 'bg-yellow-100 text-yellow-800 border-yellow-300 dark:bg-yellow-500/20 dark:text-yellow-400 dark:border-yellow-500/30', + order: 7, + }, +}; + +export function getPatrolStatusIntent(status: string): BadgeIntent { + return getPatrolStatusMeta(status)?.intent ?? 'muted'; +} + +/** 한글 라벨도 키로 받아주는 호환성 매핑 (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/statusIntent.ts b/frontend/src/shared/constants/statusIntent.ts new file mode 100644 index 0000000..0ec87f6 --- /dev/null +++ b/frontend/src/shared/constants/statusIntent.ts @@ -0,0 +1,163 @@ +/** + * 일반 상태 문자열 → BadgeIntent 매핑 유틸 + * + * 정식 카탈로그에 없는 ad-hoc 상태 문자열(한글/영문 섞여 있는 mock 데이터 등)을 + * 임시로 intent에 매핑. 프로젝트 전역에서 재사용 가능. + * + * 원칙: + * - 가능하면 전용 카탈로그를 만들어 사용하는 것이 우선 + * - 이 유틸은 정형화되지 않은 데모/mock 데이터 대응 임시 매핑용 + * + * 사용: + * {s} + */ + +import type { BadgeIntent } from '@lib/theme/variants'; + +const STATUS_INTENT_MAP: Record = { + // 정상/긍정 + '정상': 'success', + '운영': 'success', + '운영중': 'success', + '활성': 'success', + '완료': 'success', + '확정': 'success', + '가용': 'success', + '승인': 'success', + '성공': 'success', + '통과': 'success', + '배포': 'success', + active: 'success', + running: 'info', + online: 'success', + healthy: 'success', + success: 'success', + ok: 'success', + passed: 'success', + deployed: 'success', + confirmed: 'success', + CONFIRMED: 'success', + ACTIVE: 'success', + PASSED: 'success', + RUNNING: 'info', + DEPLOYED: 'success', + + // 정보/대기 + '대기': 'info', + '계획': 'info', + '계획중': 'info', + '진행': 'info', + '진행중': 'info', + '처리': 'info', + '처리중': 'info', + '생성': 'info', + '예약': 'info', + '예정': 'info', + '테스트': 'info', + pending: 'info', + PENDING: 'info', + planning: 'info', + PLANNING: 'info', + PLANNED: 'info', + scheduled: 'info', + testing: 'info', + TESTING: 'info', + STAGING: 'info', + CANARY: 'info', + + // 주의 + '주의': 'warning', + '경고': 'warning', + '검토': 'warning', + '검토필요': 'warning', + '수정': 'warning', + '변경': 'warning', + '점검': 'warning', + warning: 'warning', + WARNING: 'warning', + review: 'warning', + reviewing: 'warning', + maintenance: 'warning', + + // 심각/에러 + '긴급': 'critical', + '오류': 'critical', + '실패': 'critical', + '에러': 'critical', + '차단': 'critical', + '정지': 'critical', + '거부': 'critical', + '폐기': 'critical', + '만료': 'critical', + critical: 'critical', + CRITICAL: 'critical', + error: 'critical', + ERROR: 'critical', + failed: 'critical', + FAILED: 'critical', + blocked: 'critical', + rejected: 'critical', + REJECTED: 'critical', + expired: 'critical', + EXPIRED: 'critical', + + // 높음 + '높음': 'high', + '높은': 'high', + high: 'high', + HIGH: 'high', + + // 보라/특수 + '분석': 'purple', + '학습': 'purple', + '추론': 'purple', + '배포중': 'purple', + analyzing: 'purple', + training: 'purple', + + // 청록/모니터링 + '모니터': 'cyan', + '모니터링': 'cyan', + '감시': 'cyan', + '추적': 'cyan', + '추적중': 'cyan', + monitoring: 'cyan', + tracking: 'cyan', + + // 비활성/중립 + '비활성': 'muted', + '미배포': 'muted', + '없음': 'muted', + '-': 'muted', + '기타': 'muted', + '오탐': 'muted', + inactive: 'muted', + INACTIVE: 'muted', + disabled: 'muted', + DISABLED: 'muted', + none: 'muted', + unknown: 'muted', + UNKNOWN: 'muted', + ARCHIVED: 'muted', + DEV: 'muted', +}; + +/** + * 상태 문자열을 BadgeIntent로 변환. + * 매핑 없으면 'muted' 반환. + */ +export function getStatusIntent(status: string | null | undefined): BadgeIntent { + if (!status) return 'muted'; + return STATUS_INTENT_MAP[status] ?? STATUS_INTENT_MAP[status.toLowerCase()] ?? 'muted'; +} + +/** + * 숫자 위험도(0-100)를 intent로 매핑. + * 80 이상 critical, 60 이상 high, 40 이상 warning, 아니면 info + */ +export function getRiskIntent(score: number): BadgeIntent { + if (score >= 80) return 'critical'; + if (score >= 60) return 'high'; + if (score >= 40) return 'warning'; + return 'info'; +} 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 { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index dde1356..ca2179d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -33,6 +33,7 @@ export default defineConfig({ input: { main: path.resolve(__dirname, 'index.html'), systemFlow: path.resolve(__dirname, 'system-flow.html'), + designSystem: path.resolve(__dirname, 'design-system.html'), }, }, },