Merge pull request 'release: 2026-04-08 (35건 커밋)' (#19) from release/2026-04-08 into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 16s
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 16s
This commit is contained in:
커밋
30ef2cd593
122
CLAUDE.md
122
CLAUDE.md
@ -9,7 +9,7 @@ kcg-ai-monitoring/
|
||||
├── frontend/ # React 19 + TypeScript + Vite (UI)
|
||||
├── backend/ # Spring Boot 3.x + Java 21 (인증/권한/감사 + 분석 API)
|
||||
├── prediction/ # Python 3.9 + FastAPI (AIS 분석 엔진, 5분 주기)
|
||||
├── database/ # PostgreSQL 마이그레이션 (Flyway V001~V013)
|
||||
├── database/ # PostgreSQL 마이그레이션 (Flyway V001~V016, 48 테이블)
|
||||
│ └── migration/
|
||||
├── deploy/ # 배포 가이드 + 서버 설정 문서
|
||||
├── docs/ # 프로젝트 문서 (SFR, 아키텍처)
|
||||
@ -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';
|
||||
|
||||
<Badge intent={getAlertLevelIntent(event.level)} size="sm">
|
||||
{getAlertLevelLabel(event.level, t, lang)}
|
||||
</Badge>
|
||||
```
|
||||
|
||||
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 정책**
|
||||
- ✅ 레이아웃/위치 보정: `<Badge intent="info" className="w-full justify-center">`
|
||||
- ❌ 색상/글자 크기 override: `<Badge intent="info" className="bg-red-500 text-xs">`
|
||||
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 (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Shield}
|
||||
iconColor="text-blue-400"
|
||||
title="페이지 제목"
|
||||
description="페이지 설명"
|
||||
actions={
|
||||
<Button variant="primary" icon={<Plus className="w-4 h-4" />}>
|
||||
추가
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Section title="데이터 목록">
|
||||
<Badge intent={getAlertLevelIntent('HIGH')} size="sm">
|
||||
{getAlertLevelLabel('HIGH', t, lang)}
|
||||
</Badge>
|
||||
</Section>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 접근성 (a11y) 필수
|
||||
|
||||
- **`<button>`**: `type="button"` 명시 + 아이콘 전용은 `aria-label` 필수
|
||||
- **`<input>` / `<textarea>` / `<select>`**: `aria-label` 또는 `<label htmlFor>` 필수
|
||||
- **`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. 실 페이지는 **컴포넌트만 사용**, 변경 시 자동 반영
|
||||
|
||||
### 금지 패턴 체크리스트
|
||||
|
||||
- ❌ `<Badge className="bg-red-500/20 text-red-400">` → `<Badge intent="critical">`
|
||||
- ❌ `<button className="bg-blue-600 ...">` → `<Button variant="primary">`
|
||||
- ❌ `<input className="bg-surface ...">` → `<Input>`
|
||||
- ❌ `<div className="p-5 space-y-4">` 페이지 루트 → `<PageContainer>`
|
||||
- ❌ `-m-4` negative margin 해킹 → `<PageContainer fullBleed>`
|
||||
- ❌ `style={{ color: '#ef4444' }}` 정적 색상 → 시맨틱 토큰 또는 카탈로그
|
||||
- ❌ `!important` → `cn()` 활용
|
||||
- ❌ 페이지 내 `const STATUS_COLORS = {...}` 로컬 재정의 → shared/constants 카탈로그
|
||||
|
||||
## System Flow 뷰어 (개발 단계용)
|
||||
|
||||
- **URL**: https://kcg-ai-monitoring.gc-si.dev/system-flow.html (메인 SPA와 별개)
|
||||
|
||||
@ -47,15 +47,16 @@ public class PermTreeController {
|
||||
List<Role> roles = roleRepository.findAllByOrderByRoleSnAsc();
|
||||
return roles.stream().<Map<String, Object>>map(r -> {
|
||||
List<Perm> 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<String, Object> 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();
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -6,5 +6,6 @@ public record RoleCreateRequest(
|
||||
@NotBlank String roleCd,
|
||||
@NotBlank String roleNm,
|
||||
String roleDc,
|
||||
String colorHex,
|
||||
String dfltYn
|
||||
) {}
|
||||
|
||||
@ -3,5 +3,6 @@ package gc.mda.kcg.permission.dto;
|
||||
public record RoleUpdateRequest(
|
||||
String roleNm,
|
||||
String roleDc,
|
||||
String colorHex,
|
||||
String dfltYn
|
||||
) {}
|
||||
|
||||
@ -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';
|
||||
@ -4,13 +4,93 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-04-08]
|
||||
|
||||
### 추가
|
||||
- **디자인 시스템 쇼케이스** (`/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 두 가지 그룹화 토글, 검색/필터/딥링크 지원
|
||||
- 포커스 모드 (1-hop 연결 노드만 활성화, 나머지 dim)
|
||||
- 메인 SPA와 분리된 별도 entry, 산출문서 노드 ID 참조용
|
||||
- `/version` 스킬 사후 처리로 manifest version 자동 동기화
|
||||
- CI/CD에서 버전별 스냅샷을 서버 archive에 영구 보존
|
||||
- 백엔드 `GET /api/stats/hourly?hours=24` — 시간별 통계 조회 (PredictionStatsHourly)
|
||||
- V014 prediction 보조 테이블 12개 (fleet_vessels, gear_correlation_scores 등)
|
||||
- V015 NUMERIC precision 일괄 확대 (score→7,4, pct→12,2)
|
||||
- V016 parent workflow 누락 컬럼 일괄 추가 (17+ 컬럼, candidate_mmsi generated column)
|
||||
|
||||
### 변경
|
||||
- **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 해킹 → `<PageContainer fullBleed>`** 정리
|
||||
- **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 위반 전수 처리:
|
||||
- `<Select>` 컴포넌트 TypeScript union 타입으로 `aria-label`/`aria-labelledby`/`title` 컴파일 강제
|
||||
- 네이티브 `<select>` 5곳 aria-label
|
||||
- 아이콘 전용 `<button>` 16곳 aria-label
|
||||
- `<input>`/`<textarea>` 28곳 aria-label (placeholder 자동 복제 포함)
|
||||
- AIModelManagement 토글 → `role="switch"` + `aria-checked`
|
||||
- **Badge className 위반 37건 전수 제거** — `<Badge intent="...">` 패턴으로 통일
|
||||
- **하드코딩 `bg-X-500/20 text-X-400` 56곳 제거** — 카탈로그 API + intent 사용
|
||||
- **인라인 `<button>` 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 판정 로직
|
||||
- kpi_writer / stats_aggregator: UTC → KST 날짜 경계 통일
|
||||
- parent workflow 스키마 ↔ 코드 불일치 → V016로 일괄 해결
|
||||
- DemoQuickLogin hostname 기반 노출 (Gitea CI `.env` 차단 대응)
|
||||
- 프론트 전수 mock 정리: eventStore.alerts, enforcementStore.plans, transferStore 완전 제거
|
||||
- Dashboard/MonitoringDashboard/Statistics 하드코딩 → 실 API 전환
|
||||
- UTC → KST 시간 표시 통일 (`@shared/utils/dateFormat.ts` 공통 유틸)
|
||||
- i18n `group.parentInference` JSON 중복키 제거
|
||||
- RiskMap Math.random() 격자 제거, MTIS 라벨 + "AI 분석 데이터 수집 중" 안내
|
||||
- 12개 mock 화면에 "데모 데이터" 노란 배지 추가
|
||||
|
||||
## [2026-04-07]
|
||||
|
||||
|
||||
12
frontend/design-system.html
Normal file
12
frontend/design-system.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ko" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>KCG AI Monitoring — Design System</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/designSystemMain.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
14
frontend/package-lock.json
generated
14
frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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<UserRole, string> = {
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button onClick={() => onPageChange(0)} disabled={page === 0} className={btnCls}><ChevronsLeft className="w-3.5 h-3.5" /></button>
|
||||
<button onClick={() => onPageChange(page - 1)} disabled={page === 0} className={btnCls}><ChevronLeft className="w-3.5 h-3.5" /></button>
|
||||
{range.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => 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}</button>
|
||||
))}
|
||||
<button onClick={() => onPageChange(page + 1)} disabled={page >= totalPages - 1} className={btnCls}><ChevronRight className="w-3.5 h-3.5" /></button>
|
||||
<button onClick={() => onPageChange(totalPages - 1)} disabled={page >= totalPages - 1} className={btnCls}><ChevronsRight className="w-3.5 h-3.5" /></button>
|
||||
<span className="text-[9px] text-hint ml-2">{page + 1} / {totalPages}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
||||
<div className="mx-2 mt-2 px-3 py-2 rounded-lg bg-surface-overlay border border-border">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Lock className="w-3 h-3 text-hint" />
|
||||
<span className={`text-[9px] font-bold whitespace-nowrap overflow-hidden text-ellipsis ${roleColor}`}>{t(`role.${user.role}`)}</span>
|
||||
<span className="text-[9px] font-bold whitespace-nowrap overflow-hidden text-ellipsis" style={{ color: roleColor ?? undefined }}>{t(`role.${user.role}`)}</span>
|
||||
</div>
|
||||
<div className="text-[8px] text-hint mt-0.5">
|
||||
{t('layout.auth')} {AUTH_METHOD_LABELS[user.authMethod] || user.authMethod}
|
||||
@ -429,7 +361,7 @@ export function MainLayout() {
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
|
||||
<input
|
||||
<input aria-label={t('layout.searchPlaceholder')}
|
||||
type="text"
|
||||
placeholder={t('layout.searchPlaceholder')}
|
||||
className="w-56 bg-surface-overlay border border-border rounded-lg pl-8 pr-3 py-1.5 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/40"
|
||||
@ -442,7 +374,7 @@ export function MainLayout() {
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse" />
|
||||
<span className="text-[10px] text-red-400 font-bold whitespace-nowrap">{t('layout.alertCount', { count: 3 })}</span>
|
||||
</div>
|
||||
<button className="relative p-1.5 rounded-lg hover:bg-surface-overlay text-muted-foreground hover:text-heading transition-colors">
|
||||
<button type="button" aria-label={t('layout.notifications', { defaultValue: '알림' })} className="relative p-1.5 rounded-lg hover:bg-surface-overlay text-muted-foreground hover:text-heading transition-colors">
|
||||
<Bell className="w-4 h-4" />
|
||||
<span className="absolute top-0.5 right-0.5 w-2 h-2 bg-red-500 rounded-full" />
|
||||
</button>
|
||||
@ -485,7 +417,7 @@ export function MainLayout() {
|
||||
<div className="text-[8px] text-hint">{user.org}</div>
|
||||
</div>
|
||||
{roleColor && (
|
||||
<span className={`text-[8px] font-bold px-1.5 py-0.5 rounded whitespace-nowrap ${roleColor} bg-white/[0.04]`}>
|
||||
<span className="text-[8px] font-bold px-1.5 py-0.5 rounded whitespace-nowrap bg-white/[0.04]" style={{ color: roleColor }}>
|
||||
{user.role}
|
||||
</span>
|
||||
)}
|
||||
@ -506,6 +438,7 @@ export function MainLayout() {
|
||||
<div className="relative flex items-center">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-hint pointer-events-none" />
|
||||
<input
|
||||
aria-label="페이지 내 검색"
|
||||
value={pageSearch}
|
||||
onChange={(e) => 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"
|
||||
>
|
||||
<Search className="w-3 h-3" />
|
||||
{t('action.search')}
|
||||
@ -559,19 +492,9 @@ export function MainLayout() {
|
||||
<main
|
||||
ref={contentRef}
|
||||
className="flex-1 overflow-auto"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* SFR-02: 공통 페이지네이션 (하단) */}
|
||||
<div className="shrink-0 border-t border-border bg-background/60 px-4 py-1">
|
||||
<PagePagination
|
||||
page={scrollPage}
|
||||
totalPages={getTotalScrollPages()}
|
||||
onPageChange={handleScrollPageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SFR-02: 공통알림 팝업 */}
|
||||
|
||||
140
frontend/src/design-system/DesignSystemApp.css
Normal file
140
frontend/src/design-system/DesignSystemApp.css
Normal file
@ -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;
|
||||
}
|
||||
192
frontend/src/design-system/DesignSystemApp.tsx
Normal file
192
frontend/src/design-system/DesignSystemApp.tsx
Normal file
@ -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<string>('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 (
|
||||
<div className="ds-shell">
|
||||
{/* 고정 헤더 */}
|
||||
<header className="ds-header">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-base font-bold text-heading">KCG Design System</h1>
|
||||
<code className="text-[10px] text-hint font-mono">v0.1.0 · 쇼케이스</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs text-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={copyMode}
|
||||
onChange={(e) => setCopyMode(e.target.checked)}
|
||||
className="accent-blue-500"
|
||||
/>
|
||||
ID 복사 모드
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<span>{theme === 'dark' ? '☾ Dark' : '☀ Light'}</span>
|
||||
<kbd className="text-[9px] font-mono text-hint border border-slate-600/40 rounded px-1 py-0">A</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="ds-body">
|
||||
{/* 좌측 네비 */}
|
||||
<nav className="ds-nav">
|
||||
<div className="text-[10px] text-hint uppercase mb-2 tracking-wider">Sections</div>
|
||||
<ul className="space-y-0.5">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-slate-700/40 text-[10px] text-hint space-y-1">
|
||||
<div>
|
||||
<strong className="text-label">추적 ID 체계</strong>
|
||||
</div>
|
||||
<code className="block font-mono">TRK-<카테고리>-<슬러그></code>
|
||||
<div className="mt-2">
|
||||
딥링크: <code className="font-mono">#trk=ID</code>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* 우측 컨텐츠 */}
|
||||
<main className="ds-main">
|
||||
<section data-section-id="intro">
|
||||
<IntroSection />
|
||||
</section>
|
||||
<section data-section-id="token">
|
||||
<TokenSection />
|
||||
</section>
|
||||
<section data-section-id="typography">
|
||||
<TypographySection />
|
||||
</section>
|
||||
<section data-section-id="badge">
|
||||
<BadgeSection />
|
||||
</section>
|
||||
<section data-section-id="button">
|
||||
<ButtonSection />
|
||||
</section>
|
||||
<section data-section-id="form">
|
||||
<FormSection />
|
||||
</section>
|
||||
<section data-section-id="card">
|
||||
<CardSectionShowcase />
|
||||
</section>
|
||||
<section data-section-id="layout">
|
||||
<LayoutSection />
|
||||
</section>
|
||||
<section data-section-id="catalog">
|
||||
<CatalogSection />
|
||||
</section>
|
||||
<section data-section-id="guide">
|
||||
<GuideSection />
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DesignSystemApp() {
|
||||
return (
|
||||
<TrkProvider>
|
||||
<DesignSystemShell />
|
||||
</TrkProvider>
|
||||
);
|
||||
}
|
||||
71
frontend/src/design-system/lib/Trk.tsx
Normal file
71
frontend/src/design-system/lib/Trk.tsx
Normal file
@ -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 (
|
||||
<Wrapper
|
||||
data-trk={id}
|
||||
className={`trk-item ${isActive ? 'trk-active' : ''} ${copyMode ? 'trk-copyable' : ''} ${className}`}
|
||||
style={style}
|
||||
title={id}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{children}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrkSectionHeader({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<div data-trk={id} className="mb-4">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h2 className="text-xl font-bold text-heading">{title}</h2>
|
||||
<code className="text-[10px] text-hint font-mono">{id}</code>
|
||||
</div>
|
||||
{description && <p className="text-xs text-hint mt-1">{description}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
frontend/src/design-system/lib/TrkContext.tsx
Normal file
50
frontend/src/design-system/lib/TrkContext.tsx
Normal file
@ -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<TrkContextValue | null>(null);
|
||||
|
||||
export function TrkProvider({ children }: { children: ReactNode }) {
|
||||
const [copyMode, setCopyMode] = useState(false);
|
||||
const [activeId, setActiveId] = useState<string | null>(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 (
|
||||
<TrkContext.Provider value={{ copyMode, setCopyMode, activeId, setActiveId }}>
|
||||
{children}
|
||||
</TrkContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
109
frontend/src/design-system/sections/BadgeSection.tsx
Normal file
109
frontend/src/design-system/sections/BadgeSection.tsx
Normal file
@ -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 (
|
||||
<>
|
||||
<TrkSectionHeader
|
||||
id="TRK-SEC-badge"
|
||||
title="Badge"
|
||||
description={`${BADGE_INTENT_ORDER.length} intent × ${BADGE_SIZE_ORDER.length} size = ${BADGE_INTENT_ORDER.length * BADGE_SIZE_ORDER.length} 변형. CVA + cn()로 className override 허용, !important 없음.`}
|
||||
/>
|
||||
|
||||
{/* 변형 그리드 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-2">변형 매트릭스</h3>
|
||||
<Trk id="TRK-BADGE-matrix" className="ds-sample">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left text-[10px] text-hint font-mono pb-2 pr-3">
|
||||
intent ↓ / size →
|
||||
</th>
|
||||
{BADGE_SIZE_ORDER.map((size) => (
|
||||
<th key={size} className="text-left text-[10px] text-hint font-mono pb-2 px-2">
|
||||
{size}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{BADGE_INTENT_ORDER.map((intent) => (
|
||||
<tr key={intent}>
|
||||
<td className="text-[11px] text-label font-mono pr-3 py-1.5">{intent}</td>
|
||||
{BADGE_SIZE_ORDER.map((size) => (
|
||||
<td key={size} className="px-2 py-1.5">
|
||||
<Trk id={`TRK-BADGE-${intent}-${size}`} inline>
|
||||
<Badge intent={intent} size={size}>
|
||||
{intent.toUpperCase()}
|
||||
</Badge>
|
||||
</Trk>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Trk>
|
||||
|
||||
{/* intent 의미 가이드 (variantMeta에서 자동 열거) */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Intent 의미 가이드</h3>
|
||||
<div className="ds-grid ds-grid-2">
|
||||
{BADGE_INTENT_ORDER.map((intent) => {
|
||||
const meta = BADGE_INTENT_META[intent];
|
||||
return (
|
||||
<Trk key={intent} id={`TRK-BADGE-usage-${intent}`} className="ds-sample">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge intent={intent} size="md">
|
||||
{meta.titleKo}
|
||||
</Badge>
|
||||
<span className="text-xs text-label flex-1">{meta.description}</span>
|
||||
</div>
|
||||
</Trk>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 사용 예시 코드 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">사용 예시</h3>
|
||||
<Trk id="TRK-BADGE-usage-code" className="ds-sample">
|
||||
<code className="ds-code">
|
||||
{`import { Badge } from '@shared/components/ui/badge';
|
||||
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
|
||||
|
||||
// 카탈로그 API와 결합 — 라벨/색상 변경은 카탈로그 파일에서만
|
||||
<Badge intent={getAlertLevelIntent('CRITICAL')} size="sm">
|
||||
{getAlertLevelLabel('CRITICAL', t, lang)}
|
||||
</Badge>
|
||||
|
||||
// className override (tailwind-merge가 같은 그룹 충돌 감지)
|
||||
<Badge intent="info" size="md" className="rounded-full px-3">
|
||||
커스텀 둥근 배지
|
||||
</Badge>`}
|
||||
</code>
|
||||
</Trk>
|
||||
|
||||
{/* 금지 패턴 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">금지 패턴</h3>
|
||||
<Trk id="TRK-BADGE-antipattern" className="ds-sample border-red-500/30">
|
||||
<div className="text-xs text-red-400 mb-2">❌ 아래 패턴은 사용하지 마세요</div>
|
||||
<code className="ds-code">
|
||||
{`// ❌ className 직접 작성 (intent prop 무시)
|
||||
<Badge className="bg-red-400 text-white text-[11px]">...</Badge>
|
||||
|
||||
// ❌ !important 사용
|
||||
<Badge className="!bg-red-400 !text-slate-900">...</Badge>
|
||||
|
||||
// ❌ <div className="bg-red-400 ..."> → Badge 컴포넌트 사용 필수
|
||||
<div className="inline-flex bg-red-400 text-white rounded px-2">위험</div>`}
|
||||
</code>
|
||||
</Trk>
|
||||
</>
|
||||
);
|
||||
}
|
||||
129
frontend/src/design-system/sections/ButtonSection.tsx
Normal file
129
frontend/src/design-system/sections/ButtonSection.tsx
Normal file
@ -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 (
|
||||
<>
|
||||
<TrkSectionHeader
|
||||
id="TRK-SEC-button"
|
||||
title="Button"
|
||||
description={`${BUTTON_VARIANT_ORDER.length} variant × ${BUTTON_SIZE_ORDER.length} size = ${BUTTON_VARIANT_ORDER.length * BUTTON_SIZE_ORDER.length} 변형. CVA 기반, 직접 className 작성 금지.`}
|
||||
/>
|
||||
|
||||
{/* 매트릭스 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-2">변형 매트릭스</h3>
|
||||
<Trk id="TRK-BUTTON-matrix" className="ds-sample">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left text-[10px] text-hint font-mono pb-2 pr-3">
|
||||
variant ↓ / size →
|
||||
</th>
|
||||
{BUTTON_SIZE_ORDER.map((size) => (
|
||||
<th key={size} className="text-left text-[10px] text-hint font-mono pb-2 px-3">
|
||||
{size}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{BUTTON_VARIANT_ORDER.map((variant) => (
|
||||
<tr key={variant}>
|
||||
<td className="text-[11px] text-label font-mono pr-3 py-2">{variant}</td>
|
||||
{BUTTON_SIZE_ORDER.map((size) => (
|
||||
<td key={size} className="px-3 py-2">
|
||||
<Trk id={`TRK-BUTTON-${variant}-${size}`} inline>
|
||||
<Button variant={variant} size={size}>
|
||||
{variant}
|
||||
</Button>
|
||||
</Trk>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Trk>
|
||||
|
||||
{/* 아이콘 버튼 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">아이콘 포함 패턴</h3>
|
||||
<Trk id="TRK-BUTTON-with-icon" className="ds-sample">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="primary" icon={<Plus className="w-4 h-4" />}>
|
||||
새 항목 추가
|
||||
</Button>
|
||||
<Button variant="secondary" icon={<Download className="w-4 h-4" />}>
|
||||
다운로드
|
||||
</Button>
|
||||
<Button variant="outline" icon={<Search className="w-4 h-4" />}>
|
||||
검색
|
||||
</Button>
|
||||
<Button variant="ghost" icon={<Save className="w-4 h-4" />}>
|
||||
저장
|
||||
</Button>
|
||||
<Button variant="destructive" icon={<Trash2 className="w-4 h-4" />}>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</Trk>
|
||||
|
||||
{/* 상태 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">상태</h3>
|
||||
<Trk id="TRK-BUTTON-states" className="ds-sample">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="primary">Normal</Button>
|
||||
<Button variant="primary" disabled>
|
||||
Disabled
|
||||
</Button>
|
||||
</div>
|
||||
</Trk>
|
||||
|
||||
{/* variant 의미 가이드 (variantMeta에서 자동 열거) */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Variant 의미 가이드</h3>
|
||||
<div className="ds-grid ds-grid-2">
|
||||
{BUTTON_VARIANT_ORDER.map((variant) => {
|
||||
const meta = BUTTON_VARIANT_META[variant];
|
||||
return (
|
||||
<Trk key={variant} id={`TRK-BUTTON-usage-${variant}`} className="ds-sample">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant={variant} size="sm">
|
||||
{meta.titleKo}
|
||||
</Button>
|
||||
<span className="text-xs text-label flex-1">{meta.description}</span>
|
||||
</div>
|
||||
</Trk>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 사용 예시 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">사용 예시</h3>
|
||||
<Trk id="TRK-BUTTON-usage-code" className="ds-sample">
|
||||
<code className="ds-code">
|
||||
{`import { Button } from '@shared/components/ui/button';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
<Button variant="primary" size="md" icon={<Plus className="w-4 h-4" />}>
|
||||
새 보고서
|
||||
</Button>
|
||||
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={loading}>
|
||||
삭제
|
||||
</Button>
|
||||
|
||||
// 금지
|
||||
// ❌ <button className="bg-blue-600 text-white px-4 py-2 rounded-lg">
|
||||
// ❌ <Button className="bg-red-500"> → variant="destructive" 사용`}
|
||||
</code>
|
||||
</Trk>
|
||||
</>
|
||||
);
|
||||
}
|
||||
111
frontend/src/design-system/sections/CardSection.tsx
Normal file
111
frontend/src/design-system/sections/CardSection.tsx
Normal file
@ -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 (
|
||||
<>
|
||||
<TrkSectionHeader
|
||||
id="TRK-SEC-card"
|
||||
title="Card · Section"
|
||||
description="4 variant (default/elevated/inner/transparent). CardHeader+CardTitle+CardContent 조합."
|
||||
/>
|
||||
|
||||
{/* 4 variant */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-2">Variant</h3>
|
||||
<div className="ds-grid ds-grid-2">
|
||||
<Trk id="TRK-CARD-default" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-2 block">variant=default</label>
|
||||
<Card variant="default">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-1.5">
|
||||
<Database className="w-3.5 h-3.5 text-blue-400" />
|
||||
데이터베이스
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1.5">
|
||||
<div className="flex justify-between text-[11px]">
|
||||
<span className="text-hint">PostgreSQL</span>
|
||||
<span className="text-label">v15.4 운영중</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px]">
|
||||
<span className="text-hint">연결</span>
|
||||
<span className="text-label">8 / 20</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Trk>
|
||||
|
||||
<Trk id="TRK-CARD-elevated" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-2 block">variant=elevated (기본값)</label>
|
||||
<Card variant="elevated">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-1.5">
|
||||
<Activity className="w-3.5 h-3.5 text-green-400" />
|
||||
시스템 상태
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1.5">
|
||||
<div className="flex justify-between text-[11px]">
|
||||
<span className="text-hint">API</span>
|
||||
<span className="text-green-400">정상</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px]">
|
||||
<span className="text-hint">Prediction</span>
|
||||
<span className="text-green-400">5분 주기</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Trk>
|
||||
|
||||
<Trk id="TRK-CARD-inner" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-2 block">variant=inner</label>
|
||||
<Card variant="inner">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-1.5">
|
||||
<Bell className="w-3.5 h-3.5 text-yellow-400" />
|
||||
알림
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-[11px] text-label">중첩 카드 내부에 사용. 외부 카드보다 한 단계 낮은 depth.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Trk>
|
||||
|
||||
<Trk id="TRK-CARD-transparent" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-2 block">variant=transparent</label>
|
||||
<Card variant="transparent">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle>투명 카드</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-[11px] text-label">배경/보더 없이 구조만 활용 (그룹핑 목적).</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Trk>
|
||||
</div>
|
||||
|
||||
{/* 사용 예시 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">사용 예시</h3>
|
||||
<Trk id="TRK-CARD-usage-code" className="ds-sample">
|
||||
<code className="ds-code">
|
||||
{`import { Card, CardHeader, CardTitle, CardContent } from '@shared/components/ui/card';
|
||||
import { Database } from 'lucide-react';
|
||||
|
||||
<Card variant="elevated">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-1.5">
|
||||
<Database className="w-3.5 h-3.5 text-blue-400" />
|
||||
데이터베이스
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{/* 콘텐츠 */}
|
||||
</CardContent>
|
||||
</Card>`}
|
||||
</code>
|
||||
</Trk>
|
||||
</>
|
||||
);
|
||||
}
|
||||
130
frontend/src/design-system/sections/CatalogSection.tsx
Normal file
130
frontend/src/design-system/sections/CatalogSection.tsx
Normal file
@ -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 (
|
||||
<Badge intent={meta.intent} size="sm">
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-md border text-[12px] font-semibold ${classes}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogBadges({ entry }: { entry: CatalogEntry }) {
|
||||
const items = Object.values(entry.items) as AnyMeta[];
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{items.map((meta) => {
|
||||
const koLabel = getKoLabel(meta);
|
||||
const enLabel = getEnLabel(meta);
|
||||
const trkId = `${entry.showcaseId}-${meta.code}`;
|
||||
return (
|
||||
<Trk key={meta.code} id={trkId} className="flex items-center gap-3 rounded-sm">
|
||||
<code className="text-[10px] text-hint font-mono whitespace-nowrap w-32 shrink-0 truncate">
|
||||
{meta.code}
|
||||
</code>
|
||||
<div className="flex-1">{renderBadge(meta, koLabel)}</div>
|
||||
<div className="flex-1">
|
||||
{enLabel ? (
|
||||
renderBadge(meta, enLabel)
|
||||
) : (
|
||||
<span className="text-[10px] text-hint italic">(no en)</span>
|
||||
)}
|
||||
</div>
|
||||
</Trk>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CatalogSection() {
|
||||
return (
|
||||
<>
|
||||
<TrkSectionHeader
|
||||
id="TRK-SEC-catalog"
|
||||
title={`분류 카탈로그 (${CATALOG_REGISTRY.length}+)`}
|
||||
description="백엔드 enum/code_master 기반 SSOT. 쇼케이스와 실 페이지가 동일 레지스트리 참조."
|
||||
/>
|
||||
|
||||
<Trk id="TRK-CAT-intro" className="ds-sample mb-4">
|
||||
<p className="text-xs text-label leading-relaxed">
|
||||
페이지에서 배지를 렌더할 때는 반드시 카탈로그 API를 사용한다 (라벨/intent/색상 단일 관리):
|
||||
</p>
|
||||
<code className="ds-code mt-2">
|
||||
{`import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
|
||||
|
||||
<Badge intent={getAlertLevelIntent(event.level)} size="sm">
|
||||
{getAlertLevelLabel(event.level, t, lang)}
|
||||
</Badge>`}
|
||||
</code>
|
||||
<p className="text-[11px] text-hint mt-2">
|
||||
<strong>새 카탈로그 추가</strong>: <code className="font-mono">shared/constants/catalogRegistry.ts</code>에
|
||||
항목을 추가하면 이 쇼케이스에 자동 노출됩니다.
|
||||
</p>
|
||||
</Trk>
|
||||
|
||||
{CATALOG_REGISTRY.map((entry) => (
|
||||
<Trk key={entry.id} id={entry.showcaseId} className="ds-sample mb-3">
|
||||
<div className="mb-2">
|
||||
<div className="flex items-baseline gap-2 flex-wrap">
|
||||
<h3 className="text-sm font-semibold text-heading">
|
||||
{entry.titleKo} · {entry.titleEn}
|
||||
</h3>
|
||||
<code className="text-[10px] text-hint font-mono">{entry.showcaseId}</code>
|
||||
</div>
|
||||
<p className="text-[11px] text-hint">{entry.description}</p>
|
||||
{entry.source && (
|
||||
<p className="text-[10px] text-hint italic mt-0.5">출처: {entry.source}</p>
|
||||
)}
|
||||
</div>
|
||||
<CatalogBadges entry={entry} />
|
||||
</Trk>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
124
frontend/src/design-system/sections/FormSection.tsx
Normal file
124
frontend/src/design-system/sections/FormSection.tsx
Normal file
@ -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 (
|
||||
<>
|
||||
<TrkSectionHeader
|
||||
id="TRK-SEC-form"
|
||||
title="Form 요소"
|
||||
description="Input · Select · Textarea · Checkbox · Radio. 공통 스타일 토큰 공유."
|
||||
/>
|
||||
|
||||
{/* Input 사이즈 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-2">Input · 사이즈</h3>
|
||||
<div className="ds-grid ds-grid-3">
|
||||
{(['sm', 'md', 'lg'] as const).map((size) => (
|
||||
<Trk key={size} id={`TRK-FORM-input-${size}`} className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-1 block">size={size}</label>
|
||||
<Input size={size} placeholder={`Input size=${size}`} />
|
||||
</Trk>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Input 상태 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Input · 상태</h3>
|
||||
<div className="ds-grid ds-grid-3">
|
||||
<Trk id="TRK-FORM-input-default" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-1 block">state=default</label>
|
||||
<Input placeholder="기본 상태" />
|
||||
</Trk>
|
||||
<Trk id="TRK-FORM-input-error" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-1 block">state=error</label>
|
||||
<Input state="error" defaultValue="잘못된 값" />
|
||||
</Trk>
|
||||
<Trk id="TRK-FORM-input-success" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-1 block">state=success</label>
|
||||
<Input state="success" defaultValue="유효한 값" />
|
||||
</Trk>
|
||||
<Trk id="TRK-FORM-input-disabled" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-1 block">disabled</label>
|
||||
<Input disabled defaultValue="비활성" />
|
||||
</Trk>
|
||||
<Trk id="TRK-FORM-input-readonly" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-1 block">readOnly</label>
|
||||
<Input readOnly defaultValue="읽기 전용" />
|
||||
</Trk>
|
||||
<Trk id="TRK-FORM-input-type-number" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-1 block">type=number</label>
|
||||
<Input type="number" defaultValue={42} />
|
||||
</Trk>
|
||||
</div>
|
||||
|
||||
{/* Select */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Select</h3>
|
||||
<div className="ds-grid ds-grid-3">
|
||||
{(['sm', 'md', 'lg'] as const).map((size) => (
|
||||
<Trk key={size} id={`TRK-FORM-select-${size}`} className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-1 block">size={size}</label>
|
||||
<Select aria-label={`쇼케이스 샘플 Select ${size}`} size={size} defaultValue="">
|
||||
<option value="">전체 선택</option>
|
||||
<option value="CRITICAL">CRITICAL</option>
|
||||
<option value="HIGH">HIGH</option>
|
||||
<option value="MEDIUM">MEDIUM</option>
|
||||
</Select>
|
||||
</Trk>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Textarea */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Textarea</h3>
|
||||
<Trk id="TRK-FORM-textarea" className="ds-sample">
|
||||
<Textarea placeholder="여러 줄 입력..." rows={3} />
|
||||
</Trk>
|
||||
|
||||
{/* Checkbox */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Checkbox</h3>
|
||||
<Trk id="TRK-FORM-checkbox" className="ds-sample">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Checkbox label="옵션 1" defaultChecked />
|
||||
<Checkbox label="옵션 2" />
|
||||
<Checkbox label="비활성" disabled />
|
||||
<Checkbox label="체크됨 비활성" disabled defaultChecked />
|
||||
</div>
|
||||
</Trk>
|
||||
|
||||
{/* Radio */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Radio</h3>
|
||||
<Trk id="TRK-FORM-radio" className="ds-sample">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Radio name="grp" label="선택 A" checked={radio === 'a'} onChange={() => setRadio('a')} />
|
||||
<Radio name="grp" label="선택 B" checked={radio === 'b'} onChange={() => setRadio('b')} />
|
||||
<Radio name="grp" label="선택 C" checked={radio === 'c'} onChange={() => setRadio('c')} />
|
||||
<Radio name="grp" label="비활성" disabled />
|
||||
</div>
|
||||
</Trk>
|
||||
|
||||
{/* 사용 예시 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">사용 예시</h3>
|
||||
<Trk id="TRK-FORM-usage-code" className="ds-sample">
|
||||
<code className="ds-code">
|
||||
{`import { Input } from '@shared/components/ui/input';
|
||||
import { Select } from '@shared/components/ui/select';
|
||||
import { Checkbox } from '@shared/components/ui/checkbox';
|
||||
|
||||
<Input size="md" placeholder="검색어 입력" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
|
||||
<Select size="sm" value={level} onChange={(e) => setLevel(e.target.value)}>
|
||||
<option value="">전체 등급</option>
|
||||
<option value="CRITICAL">CRITICAL</option>
|
||||
</Select>
|
||||
|
||||
<Checkbox label="활성화" checked={active} onChange={(e) => setActive(e.target.checked)} />`}
|
||||
</code>
|
||||
</Trk>
|
||||
</>
|
||||
);
|
||||
}
|
||||
130
frontend/src/design-system/sections/GuideSection.tsx
Normal file
130
frontend/src/design-system/sections/GuideSection.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { TrkSectionHeader, Trk } from '../lib/Trk';
|
||||
|
||||
export function GuideSection() {
|
||||
return (
|
||||
<>
|
||||
<TrkSectionHeader
|
||||
id="TRK-SEC-guide"
|
||||
title="예외 처리 · 가이드"
|
||||
description="공통 패턴에서 벗어날 때의 명시적 규칙"
|
||||
/>
|
||||
|
||||
<Trk id="TRK-GUIDE-fullbleed" className="ds-sample">
|
||||
<h3 className="text-sm font-semibold text-heading mb-2">언제 fullBleed를 쓰는가?</h3>
|
||||
<ul className="text-xs text-label space-y-1.5 list-disc list-inside leading-relaxed">
|
||||
<li>
|
||||
<strong className="text-heading">지도 중심 페이지</strong> — 가장자리까지 지도가 확장되어야 할 때 (LiveMapView,
|
||||
VesselDetail)
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-heading">3단 패널 레이아웃</strong> — 좌측 리스트 + 중앙 콘텐츠 + 우측 상세 구조로
|
||||
padding이 상위에서 관리되지 않는 경우
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-heading">높이 100% 요구</strong> — 브라우저 뷰포트 전체를 차지해야 할 때
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-xs text-hint mt-2">
|
||||
예외: <code className="font-mono"><PageContainer fullBleed></code>로 명시.{' '}
|
||||
<code className="font-mono">-m-4</code>, <code className="font-mono">-mx-4</code> 같은 negative margin 해킹 금지.
|
||||
</p>
|
||||
</Trk>
|
||||
|
||||
<Trk id="TRK-GUIDE-classname-override" className="ds-sample mt-3">
|
||||
<h3 className="text-sm font-semibold text-heading mb-2">언제 className override를 허용하는가?</h3>
|
||||
<ul className="text-xs text-label space-y-1.5 list-disc list-inside leading-relaxed">
|
||||
<li>
|
||||
<strong className="text-heading">레이아웃 보정</strong> — 부모 컨테이너 특성상 width/margin 조정이 필요할 때 (
|
||||
<code className="font-mono">w-48</code>, <code className="font-mono">flex-1</code> 등)
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-heading">반응형 조정</strong> — sm/md/lg 브레이크포인트별 조정
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-xs text-label mt-2">
|
||||
<strong className="text-green-400">허용:</strong>{' '}
|
||||
<code className="font-mono">
|
||||
<Badge intent="info" className="w-full justify-center">
|
||||
</code>
|
||||
</p>
|
||||
<p className="text-xs text-label">
|
||||
<strong className="text-red-400">금지:</strong>{' '}
|
||||
<code className="font-mono"><Badge className="bg-red-500 text-white"></code>{' '}
|
||||
— <span className="text-hint">intent prop을 대체하려 하지 말 것</span>
|
||||
</p>
|
||||
</Trk>
|
||||
|
||||
<Trk id="TRK-GUIDE-dynamic-color" className="ds-sample mt-3">
|
||||
<h3 className="text-sm font-semibold text-heading mb-2">동적 hex 색상이 필요한 경우</h3>
|
||||
<ul className="text-xs text-label space-y-1.5 list-disc list-inside leading-relaxed">
|
||||
<li>
|
||||
DB에서 사용자가 정의한 색상 (예: Role.colorHex) → <code className="font-mono">style={`{{ background: role.colorHex }}`}</code>{' '}
|
||||
인라인 허용
|
||||
</li>
|
||||
<li>
|
||||
차트 팔레트 → <code className="font-mono">getAlertLevelHex(level)</code> 같은 카탈로그 API에서 hex 조회
|
||||
</li>
|
||||
<li>
|
||||
지도 마커 deck.gl → RGB 튜플로 변환 필요, 카탈로그 hex 기반
|
||||
</li>
|
||||
</ul>
|
||||
</Trk>
|
||||
|
||||
<Trk id="TRK-GUIDE-anti-patterns" className="ds-sample mt-3 border border-red-500/30">
|
||||
<h3 className="text-sm font-semibold text-red-400 mb-2">금지 패턴 체크리스트</h3>
|
||||
<ul className="text-xs text-label space-y-1.5 leading-relaxed">
|
||||
<li>❌ <code className="font-mono">!important</code> prefix (<code className="font-mono">!bg-red-500</code>)</li>
|
||||
<li>❌ <code className="font-mono">className="bg-X text-Y"</code>로 Badge 스타일을 재정의</li>
|
||||
<li>❌ <code className="font-mono"><button className="bg-blue-600 ..."></code> — Button 컴포넌트 사용 필수</li>
|
||||
<li>❌ <code className="font-mono"><input className="bg-surface ..."></code> — Input 컴포넌트 사용 필수</li>
|
||||
<li>❌ <code className="font-mono">p-4 space-y-5</code> 같은 제각각 padding — PageContainer size 사용</li>
|
||||
<li>❌ <code className="font-mono">-m-4</code>, <code className="font-mono">-mx-4</code> negative margin 해킹 — fullBleed 사용</li>
|
||||
<li>❌ 페이지에서 <code className="font-mono">const STATUS_COLORS = {`{...}`}</code> 로컬 상수 정의 — shared/constants 카탈로그 사용</li>
|
||||
<li>❌ <code className="font-mono">date.toLocaleString('ko-KR', ...)</code> 직접 호출 — <code className="font-mono">formatDateTime</code> 사용</li>
|
||||
</ul>
|
||||
</Trk>
|
||||
|
||||
<Trk id="TRK-GUIDE-new-page" className="ds-sample mt-3">
|
||||
<h3 className="text-sm font-semibold text-heading mb-2">새 페이지 작성 템플릿</h3>
|
||||
<code className="ds-code">
|
||||
{`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 (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Shield}
|
||||
iconColor="text-blue-400"
|
||||
title="새 페이지"
|
||||
description="페이지 설명"
|
||||
actions={
|
||||
<Button variant="primary" icon={<Plus className="w-4 h-4" />}>
|
||||
추가
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Section title="데이터 목록">
|
||||
<Badge intent={getAlertLevelIntent('HIGH')} size="sm">
|
||||
{getAlertLevelLabel('HIGH', t, lang)}
|
||||
</Badge>
|
||||
<span className="text-xs text-hint">{formatDateTime(row.createdAt)}</span>
|
||||
</Section>
|
||||
</PageContainer>
|
||||
);
|
||||
}`}
|
||||
</code>
|
||||
</Trk>
|
||||
</>
|
||||
);
|
||||
}
|
||||
68
frontend/src/design-system/sections/IntroSection.tsx
Normal file
68
frontend/src/design-system/sections/IntroSection.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { TrkSectionHeader, Trk } from '../lib/Trk';
|
||||
|
||||
export function IntroSection() {
|
||||
return (
|
||||
<>
|
||||
<TrkSectionHeader
|
||||
id="TRK-SEC-intro"
|
||||
title="KCG Design System"
|
||||
description="kcg-ai-monitoring 프론트엔드의 시각 언어 · 컴포넌트 · 레이아웃 규칙 단일 참조 페이지"
|
||||
/>
|
||||
|
||||
<Trk id="TRK-INTRO-overview" className="ds-sample">
|
||||
<h3 className="text-sm font-semibold text-heading mb-2">이 페이지의 목적</h3>
|
||||
<ul className="text-xs text-label space-y-1.5 list-disc list-inside leading-relaxed">
|
||||
<li>
|
||||
<strong className="text-heading">모든 스타일의 뼈대</strong> — 페이지 파일은 이 쇼케이스에 정의된 컴포넌트와 토큰만
|
||||
사용한다. 임의의 `className="bg-red-600"` 같은 직접 스타일은 금지.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-heading">미세조정의 단일 지점</strong> — 색상 · 여백 · 텍스트 크기 등 모든 변경은 이 페이지에서
|
||||
먼저 시각적으로 검증한 후 실제 페이지에 적용한다.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-heading">ID 기반 참조</strong> — 각 쇼케이스 항목에 <code>TRK-*</code> 추적 ID가 부여되어 있어,
|
||||
특정 변형을 정확히 가리키며 논의 · 수정이 가능하다.
|
||||
</li>
|
||||
</ul>
|
||||
</Trk>
|
||||
|
||||
<Trk id="TRK-INTRO-howto" className="ds-sample mt-3">
|
||||
<h3 className="text-sm font-semibold text-heading mb-2">사용 방법</h3>
|
||||
<ol className="text-xs text-label space-y-1.5 list-decimal list-inside leading-relaxed">
|
||||
<li>
|
||||
상단 <strong className="text-heading">"ID 복사 모드"</strong> 체크박스를 켜면 쇼케이스 항목 클릭 시 ID가 클립보드에
|
||||
복사된다.
|
||||
</li>
|
||||
<li>
|
||||
URL 해시 <code className="font-mono text-blue-400">#trk=TRK-BADGE-critical-sm</code> 으로 특정 항목 딥링크 — 스크롤
|
||||
+ 하이라이트.
|
||||
</li>
|
||||
<li>
|
||||
상단 <strong className="text-heading">Dark / Light</strong> 토글로 두 테마에서 동시에 검증.
|
||||
</li>
|
||||
<li>
|
||||
좌측 네비게이션으로 섹션 이동. 각 섹션 제목 옆에 섹션의 <code>TRK-SEC-*</code> ID가 노출된다.
|
||||
</li>
|
||||
</ol>
|
||||
</Trk>
|
||||
|
||||
<Trk id="TRK-INTRO-naming" className="ds-sample mt-3">
|
||||
<h3 className="text-sm font-semibold text-heading mb-2">추적 ID 명명 규칙</h3>
|
||||
<code className="ds-code">
|
||||
{`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`}
|
||||
</code>
|
||||
</Trk>
|
||||
</>
|
||||
);
|
||||
}
|
||||
177
frontend/src/design-system/sections/LayoutSection.tsx
Normal file
177
frontend/src/design-system/sections/LayoutSection.tsx
Normal file
@ -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 (
|
||||
<>
|
||||
<TrkSectionHeader
|
||||
id="TRK-SEC-layout"
|
||||
title="Layout 컴포넌트"
|
||||
description="PageContainer · PageHeader · Section. 40+ 페이지 레이아웃 표준."
|
||||
/>
|
||||
|
||||
{/* PageContainer */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-2">PageContainer</h3>
|
||||
<div className="ds-grid ds-grid-3">
|
||||
<Trk id="TRK-LAYOUT-page-container-sm" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-1 block">size=sm (p-4 space-y-3)</label>
|
||||
<div className="border border-dashed border-slate-600/40 rounded">
|
||||
<PageContainer size="sm">
|
||||
<div className="h-4 bg-blue-500/30 rounded" />
|
||||
<div className="h-4 bg-blue-500/30 rounded" />
|
||||
</PageContainer>
|
||||
</div>
|
||||
</Trk>
|
||||
<Trk id="TRK-LAYOUT-page-container-md" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-1 block">size=md · 기본값 (p-5 space-y-4)</label>
|
||||
<div className="border border-dashed border-slate-600/40 rounded">
|
||||
<PageContainer size="md">
|
||||
<div className="h-4 bg-blue-500/30 rounded" />
|
||||
<div className="h-4 bg-blue-500/30 rounded" />
|
||||
</PageContainer>
|
||||
</div>
|
||||
</Trk>
|
||||
<Trk id="TRK-LAYOUT-page-container-lg" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-1 block">size=lg (p-6 space-y-4)</label>
|
||||
<div className="border border-dashed border-slate-600/40 rounded">
|
||||
<PageContainer size="lg">
|
||||
<div className="h-4 bg-blue-500/30 rounded" />
|
||||
<div className="h-4 bg-blue-500/30 rounded" />
|
||||
</PageContainer>
|
||||
</div>
|
||||
</Trk>
|
||||
</div>
|
||||
|
||||
<Trk id="TRK-LAYOUT-page-container-fullbleed" className="ds-sample mt-3">
|
||||
<label className="text-[10px] text-hint font-mono mb-1 block">
|
||||
fullBleed — 지도/풀화면 페이지 (padding 0, space-y 0)
|
||||
</label>
|
||||
<div className="border border-dashed border-slate-600/40 rounded h-16 relative">
|
||||
<PageContainer fullBleed className="h-full">
|
||||
<div className="h-full bg-blue-500/30 rounded flex items-center justify-center text-xs text-heading">
|
||||
fullBleed: 가장자리까지 콘텐츠 (LiveMapView / VesselDetail 패턴)
|
||||
</div>
|
||||
</PageContainer>
|
||||
</div>
|
||||
</Trk>
|
||||
|
||||
{/* PageHeader */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">PageHeader</h3>
|
||||
<div className="space-y-3">
|
||||
<Trk id="TRK-LAYOUT-page-header-simple" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-2 block">단순형 (title + description)</label>
|
||||
<PageHeader title="대시보드" description="실시간 종합 상황판" />
|
||||
</Trk>
|
||||
|
||||
<Trk id="TRK-LAYOUT-page-header-icon" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-2 block">아이콘 포함</label>
|
||||
<PageHeader
|
||||
icon={Shield}
|
||||
iconColor="text-blue-400"
|
||||
title="권한 관리"
|
||||
description="사용자/역할/권한 설정"
|
||||
/>
|
||||
</Trk>
|
||||
|
||||
<Trk id="TRK-LAYOUT-page-header-demo" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-2 block">데모 배지</label>
|
||||
<PageHeader
|
||||
icon={BarChart3}
|
||||
iconColor="text-purple-400"
|
||||
title="AI 모델 관리"
|
||||
description="ML 모델 배포 상태"
|
||||
demo
|
||||
/>
|
||||
</Trk>
|
||||
|
||||
<Trk id="TRK-LAYOUT-page-header-actions" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-2 block">우측 액션 슬롯</label>
|
||||
<PageHeader
|
||||
title="보고서 관리"
|
||||
description="단속 증거 보고서 목록"
|
||||
actions={
|
||||
<>
|
||||
<Input size="sm" placeholder="검색..." className="w-40" />
|
||||
<Button variant="secondary" size="sm" icon={<Search className="w-3.5 h-3.5" />}>
|
||||
검색
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" icon={<Plus className="w-3.5 h-3.5" />}>
|
||||
새 보고서
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Trk>
|
||||
</div>
|
||||
|
||||
{/* Section */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Section (Card 단축)</h3>
|
||||
<div className="ds-grid ds-grid-2">
|
||||
<Trk id="TRK-LAYOUT-section-basic" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-2 block">기본 Section</label>
|
||||
<Section title="해역별 위험도" icon={BarChart3} iconColor="text-orange-400">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-[11px]">
|
||||
<span className="text-hint">동해</span>
|
||||
<span className="text-label">23건</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px]">
|
||||
<span className="text-hint">서해</span>
|
||||
<span className="text-label">12건</span>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</Trk>
|
||||
|
||||
<Trk id="TRK-LAYOUT-section-actions" className="ds-sample">
|
||||
<label className="text-[10px] text-hint font-mono mb-2 block">우측 액션</label>
|
||||
<Section
|
||||
title="최근 이벤트"
|
||||
icon={BarChart3}
|
||||
actions={
|
||||
<Button variant="ghost" size="sm">
|
||||
전체 보기
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="text-[11px] text-hint">이벤트 3건</div>
|
||||
</Section>
|
||||
</Trk>
|
||||
</div>
|
||||
|
||||
{/* 전체 조합 예시 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">전체 조합 예시</h3>
|
||||
<Trk id="TRK-LAYOUT-full-example" className="ds-sample">
|
||||
<code className="ds-code">
|
||||
{`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 (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Shield}
|
||||
iconColor="text-blue-400"
|
||||
title="권한 관리"
|
||||
description="사용자별 역할 및 권한 매핑"
|
||||
actions={
|
||||
<Button variant="primary" icon={<Plus className="w-4 h-4" />}>
|
||||
역할 추가
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Section title="사용자 목록">
|
||||
<DataTable ... />
|
||||
</Section>
|
||||
</PageContainer>
|
||||
);
|
||||
}`}
|
||||
</code>
|
||||
</Trk>
|
||||
</>
|
||||
);
|
||||
}
|
||||
160
frontend/src/design-system/sections/TokenSection.tsx
Normal file
160
frontend/src/design-system/sections/TokenSection.tsx
Normal file
@ -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 (
|
||||
<>
|
||||
<TrkSectionHeader
|
||||
id="TRK-SEC-token"
|
||||
title="테마 · 시맨틱 토큰"
|
||||
description="모든 색상/여백은 CSS 변수로 관리. 다크/라이트 모드는 값만 바뀌고 이름은 동일."
|
||||
/>
|
||||
|
||||
{/* Surface 토큰 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-4">Surface / 배경</h3>
|
||||
<div className="ds-grid ds-grid-3">
|
||||
{SURFACE_TOKENS.map((t) => (
|
||||
<Trk key={t.id} id={`TRK-TOKEN-${t.id}`} className="ds-sample">
|
||||
<div
|
||||
className={`h-16 rounded-md border border-slate-700/40 mb-2`}
|
||||
style={{ background: `var(--${t.id})` }}
|
||||
/>
|
||||
<div className="text-xs text-heading font-semibold">{t.label}</div>
|
||||
<div className="text-[10px] text-hint">{t.desc}</div>
|
||||
<div className="ds-sample-label">TRK-TOKEN-{t.id}</div>
|
||||
</Trk>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 텍스트 토큰 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Text / 텍스트</h3>
|
||||
<div className="ds-grid ds-grid-3">
|
||||
{TEXT_TOKENS.map((t) => (
|
||||
<Trk key={t.id} id={`TRK-TOKEN-${t.id}`} className="ds-sample">
|
||||
<div
|
||||
className="h-16 rounded-md flex items-center justify-center text-sm font-semibold mb-2"
|
||||
style={{
|
||||
background: t.bg ?? 'var(--surface-overlay)',
|
||||
color: `var(--${t.id})`,
|
||||
}}
|
||||
>
|
||||
{t.example}
|
||||
</div>
|
||||
<div className="text-xs text-heading font-semibold">{t.label}</div>
|
||||
<div className="text-[10px] text-hint">{t.desc}</div>
|
||||
<div className="ds-sample-label">TRK-TOKEN-{t.id}</div>
|
||||
</Trk>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 브랜드 색 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Brand / 기능색</h3>
|
||||
<div className="ds-grid ds-grid-4">
|
||||
{BRAND_COLORS.map((t) => (
|
||||
<Trk key={t.id} id={`TRK-TOKEN-${t.id}`} className="ds-sample">
|
||||
<div
|
||||
className="h-12 rounded-md border border-slate-700/40 mb-2"
|
||||
style={{ background: `var(--${t.id})` }}
|
||||
/>
|
||||
<div className="text-xs text-heading font-semibold">{t.label}</div>
|
||||
<div className="text-[10px] text-hint">{t.desc}</div>
|
||||
</Trk>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Chart 색 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Chart / 차트 팔레트</h3>
|
||||
<Trk id="TRK-TOKEN-chart-palette" className="ds-sample">
|
||||
<div className="flex gap-2">
|
||||
{CHART_COLORS.map((t) => (
|
||||
<div key={t.id} className="flex-1 text-center">
|
||||
<div
|
||||
className="h-12 rounded-md border border-slate-700/40 mb-1"
|
||||
style={{ background: `var(--${t.id})` }}
|
||||
/>
|
||||
<div className="text-[10px] text-hint font-mono">{t.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="ds-sample-label">TRK-TOKEN-chart-palette</div>
|
||||
</Trk>
|
||||
|
||||
{/* Radius 스케일 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Radius / 모서리 반경</h3>
|
||||
<Trk id="TRK-TOKEN-radius-scale" className="ds-sample">
|
||||
<div className="flex gap-4 items-end">
|
||||
{RADIUS_SCALE.map((r) => (
|
||||
<div key={r.id} className="flex-1 text-center">
|
||||
<div className={`h-16 bg-blue-500 ${r.cls} mb-1`} />
|
||||
<div className="text-[11px] text-heading">{r.label}</div>
|
||||
<div className="text-[9px] text-hint font-mono">{r.px}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Trk>
|
||||
|
||||
{/* Spacing 스케일 */}
|
||||
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Spacing / 간격 스케일</h3>
|
||||
<Trk id="TRK-TOKEN-spacing-scale" className="ds-sample">
|
||||
<div className="space-y-1.5">
|
||||
{SPACING_SCALE.map((s) => (
|
||||
<div key={s.id} className="flex items-center gap-3">
|
||||
<code className="text-[10px] text-hint font-mono w-12">p-{s.id}</code>
|
||||
<div
|
||||
className="h-4 bg-blue-500 rounded-sm"
|
||||
style={{ width: `${s.size}px` }}
|
||||
/>
|
||||
<span className="text-[10px] text-hint font-mono">{s.size}px</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Trk>
|
||||
</>
|
||||
);
|
||||
}
|
||||
35
frontend/src/design-system/sections/TypographySection.tsx
Normal file
35
frontend/src/design-system/sections/TypographySection.tsx
Normal file
@ -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 (
|
||||
<>
|
||||
<TrkSectionHeader
|
||||
id="TRK-SEC-typography"
|
||||
title="타이포그래피"
|
||||
description="8단계 텍스트 스케일 + 시맨틱 색상 토큰 조합"
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
{TYPE_SCALE.map(({ id, tag: Tag, cls, example }) => (
|
||||
<Trk key={id} id={`TRK-TYPO-${id}`} className="ds-sample">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Tag className={cls}>{example}</Tag>
|
||||
<code className="text-[10px] text-hint font-mono whitespace-nowrap">{cls}</code>
|
||||
</div>
|
||||
</Trk>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
frontend/src/designSystemMain.tsx
Normal file
10
frontend/src/designSystemMain.tsx
Normal file
@ -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(
|
||||
<StrictMode>
|
||||
<DesignSystemApp />
|
||||
</StrictMode>,
|
||||
);
|
||||
@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<Tab>('roles');
|
||||
|
||||
// 공통 상태
|
||||
@ -135,7 +120,7 @@ export function AccessControl() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{list.map((r) => (
|
||||
<Badge key={r} className={`${ROLE_COLORS[r] || ''} border-0 text-[9px]`}>{r}</Badge>
|
||||
<Badge key={r} size="sm" style={getRoleBadgeStyle(r)}>{r}</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@ -144,7 +129,7 @@ export function AccessControl() {
|
||||
{ key: 'userSttsCd', label: '상태', width: '70px', sortable: true,
|
||||
render: (v) => {
|
||||
const s = v as string;
|
||||
return <Badge className={`border-0 text-[9px] ${STATUS_COLORS[s] || ''}`}>{STATUS_LABELS[s] || s}</Badge>;
|
||||
return <Badge intent={getUserAccountStatusIntent(s)} size="sm">{getUserAccountStatusLabel(s, tc, lang)}</Badge>;
|
||||
},
|
||||
},
|
||||
{ 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 <Badge className={`border-0 text-[9px] ${c}`}>{r || '-'}</Badge>;
|
||||
return <Badge intent={r === 'SUCCESS' ? 'success' : 'critical'} size="xs">{r || '-'}</Badge>;
|
||||
},
|
||||
},
|
||||
{ key: 'failReason', label: '실패 사유',
|
||||
@ -200,33 +184,36 @@ export function AccessControl() {
|
||||
], []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-blue-400" />
|
||||
{t('accessControl.title')}
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('accessControl.desc')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{userStats && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-hint">
|
||||
<UserCheck className="w-3.5 h-3.5 text-green-500" />
|
||||
활성 <span className="text-green-400 font-bold">{userStats.active}</span>명
|
||||
<span className="mx-1">|</span>
|
||||
잠금 <span className="text-red-400 font-bold">{userStats.locked}</span>
|
||||
<span className="mx-1">|</span>
|
||||
총 <span className="text-heading font-bold">{userStats.total}</span>
|
||||
</div>
|
||||
)}
|
||||
<button type="button"
|
||||
onClick={() => { 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="새로고침">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<PageContainer size="lg">
|
||||
<PageHeader
|
||||
icon={Shield}
|
||||
iconColor="text-blue-400"
|
||||
title={t('accessControl.title')}
|
||||
description={t('accessControl.desc')}
|
||||
actions={
|
||||
<>
|
||||
{userStats && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-hint">
|
||||
<UserCheck className="w-3.5 h-3.5 text-green-500" />
|
||||
활성 <span className="text-green-400 font-bold">{userStats.active}</span>명
|
||||
<span className="mx-1">|</span>
|
||||
잠금 <span className="text-red-400 font-bold">{userStats.locked}</span>
|
||||
<span className="mx-1">|</span>
|
||||
총 <span className="text-heading font-bold">{userStats.total}</span>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => { if (tab === 'users') loadUsers(); else if (tab === 'audit') loadAudit(); }}
|
||||
title="새로고침"
|
||||
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<span className="sr-only">새로고침</span>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 탭 */}
|
||||
<div className="flex gap-1">
|
||||
@ -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'
|
||||
}`}
|
||||
>
|
||||
<tt.icon className="w-3.5 h-3.5" />
|
||||
@ -371,7 +358,7 @@ export function AccessControl() {
|
||||
]} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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 (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-heading">접근 이력</h1>
|
||||
<p className="text-xs text-hint mt-1">AccessLogFilter가 모든 HTTP 요청 비동기 기록</p>
|
||||
</div>
|
||||
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded flex items-center gap-1">
|
||||
<RefreshCw className="w-3.5 h-3.5" /> 새로고침
|
||||
</button>
|
||||
</div>
|
||||
<PageContainer size="lg">
|
||||
<PageHeader
|
||||
icon={Activity}
|
||||
iconColor="text-cyan-400"
|
||||
title="접근 이력"
|
||||
description="AccessLogFilter가 모든 HTTP 요청 비동기 기록"
|
||||
actions={
|
||||
<Button variant="primary" size="sm" onClick={load} icon={<RefreshCw className="w-3.5 h-3.5" />}>
|
||||
새로고침
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{stats && (
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
@ -113,7 +113,7 @@ export function AccessLogs() {
|
||||
<td className="px-3 py-2 text-purple-400 font-mono">{it.httpMethod}</td>
|
||||
<td className="px-3 py-2 text-heading font-mono text-[10px] max-w-md truncate">{it.requestPath}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<Badge className={`border-0 text-[9px] ${statusColor(it.statusCode)}`}>{it.statusCode}</Badge>
|
||||
<Badge intent={getHttpStatusIntent(it.statusCode)} size="sm">{it.statusCode}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-muted-foreground">{it.durationMs}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{it.ipAddress || '-'}</td>
|
||||
@ -124,7 +124,7 @@ export function AccessLogs() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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 (
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<Settings className="w-5 h-5 text-muted-foreground" />
|
||||
{t('adminPanel.title')}
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
|
||||
<span>⚠</span><span>데모 데이터 (백엔드 API 미구현)</span>
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('adminPanel.desc')}</p>
|
||||
</div>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Settings}
|
||||
title={t('adminPanel.title')}
|
||||
description={t('adminPanel.desc')}
|
||||
demo
|
||||
/>
|
||||
|
||||
{/* 서버 상태 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
@ -52,9 +51,7 @@ export function AdminPanel() {
|
||||
<Server className="w-4 h-4 text-hint" />
|
||||
<span className="text-[11px] font-bold text-heading">{s.name}</span>
|
||||
</div>
|
||||
<span className={`text-[9px] font-bold px-2 py-0.5 rounded ${
|
||||
s.status === '정상' ? 'bg-green-500/20 text-green-400' : s.status === '주의' ? 'bg-yellow-500/20 text-yellow-400' : 'bg-red-500/20 text-red-400'
|
||||
}`}>{s.status}</span>
|
||||
<Badge intent={getStatusIntent(s.status)} size="xs">{s.status}</Badge>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between"><span className="text-[9px] text-hint">CPU</span><UsageBar value={s.cpu} /></div>
|
||||
@ -89,6 +86,6 @@ export function AdminPanel() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-heading">감사 로그</h1>
|
||||
<p className="text-xs text-hint mt-1">@Auditable AOP가 모든 운영자 의사결정 자동 기록</p>
|
||||
</div>
|
||||
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded flex items-center gap-1">
|
||||
<RefreshCw className="w-3.5 h-3.5" /> 새로고침
|
||||
</button>
|
||||
</div>
|
||||
<PageContainer size="lg">
|
||||
<PageHeader
|
||||
icon={FileSearch}
|
||||
iconColor="text-blue-400"
|
||||
title="감사 로그"
|
||||
description="@Auditable AOP가 모든 운영자 의사결정 자동 기록"
|
||||
actions={
|
||||
<Button variant="primary" size="sm" onClick={load} icon={<RefreshCw className="w-3.5 h-3.5" />}>
|
||||
새로고침
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
{stats && (
|
||||
@ -99,7 +103,7 @@ export function AuditLogs() {
|
||||
<td className="px-3 py-2 text-heading font-medium">{it.actionCd}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{it.resourceType ?? '-'} {it.resourceId ? `(${it.resourceId})` : ''}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={`border-0 text-[9px] ${it.result === 'SUCCESS' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}`}>
|
||||
<Badge intent={it.result === 'SUCCESS' ? 'success' : 'critical'} size="xs">
|
||||
{it.result || '-'}
|
||||
</Badge>
|
||||
</td>
|
||||
@ -115,7 +119,7 @@ export function AuditLogs() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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<SignalStatus, string> = {
|
||||
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<ChannelRecord>[] = [
|
||||
{ key: 'linkInfo', label: '연계정보', width: '65px' },
|
||||
{ key: 'storage', label: '저장장소', render: (v) => <span className="text-hint font-mono text-[9px]">{v as string}</span> },
|
||||
{ key: 'linkMethod', label: '연계방식', width: '70px', align: 'center',
|
||||
render: (v) => <Badge className="bg-purple-500/20 text-purple-400 border-0 text-[9px]">{v as string}</Badge>,
|
||||
render: (v) => <Badge intent="purple" size="sm">{v as string}</Badge>,
|
||||
},
|
||||
{ key: 'cycle', label: '수집주기', width: '80px', align: 'center',
|
||||
render: (v) => {
|
||||
@ -129,7 +138,7 @@ const channelColumns: DataColumn<ChannelRecord>[] = [
|
||||
const on = v === 'ON';
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<Badge className={`border-0 text-[9px] font-bold px-3 ${on ? 'bg-blue-600 text-heading' : 'bg-red-500 text-heading'}`}>
|
||||
<Badge intent={on ? 'info' : 'critical'} size="xs">
|
||||
{v as string}
|
||||
</Badge>
|
||||
{row.lastUpdate && (
|
||||
@ -163,7 +172,7 @@ function SignalTimeline({ source }: { source: SignalSource }) {
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 h-5 rounded-[1px]"
|
||||
style={{ backgroundColor: SIGNAL_COLORS[status], minWidth: '2px' }}
|
||||
style={{ backgroundColor: getConnectionStatusHex(status), minWidth: '2px' }}
|
||||
title={`${String(Math.floor(i / 6)).padStart(2, '0')}:${String((i % 6) * 10).padStart(2, '0')} — ${status === 'ok' ? '정상' : status === 'warn' ? '지연' : '장애'}`}
|
||||
/>
|
||||
))}
|
||||
@ -209,18 +218,14 @@ const collectColumns: DataColumn<CollectJob>[] = [
|
||||
{ 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 <Badge className={`border-0 text-[9px] ${c}`}>{t}</Badge>;
|
||||
const intent: BadgeIntent = t === 'SQL' ? 'info' : t === 'FILE' ? 'success' : 'purple';
|
||||
return <Badge intent={intent} size="xs">{t}</Badge>;
|
||||
},
|
||||
},
|
||||
{ key: 'serverName', label: '서버명', width: '120px', render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'serverIp', label: 'IP', width: '120px', render: (v) => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ 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 <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>;
|
||||
},
|
||||
render: (v) => <Badge intent={jobStatusIntent(v as string)} size="xs">{v as string}</Badge>,
|
||||
},
|
||||
{ key: 'schedule', label: '스케줄', width: '80px' },
|
||||
{ key: 'lastRun', label: '최종 수행', width: '140px', sortable: true, render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
|
||||
@ -235,12 +240,12 @@ const collectColumns: DataColumn<CollectJob>[] = [
|
||||
render: (_v, row) => (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{row.status === '정지' ? (
|
||||
<button className="p-1 text-hint hover:text-green-400" title="시작"><Play className="w-3 h-3" /></button>
|
||||
<button type="button" className="p-1 text-hint hover:text-green-400" title="시작"><Play className="w-3 h-3" /></button>
|
||||
) : row.status !== '장애발생' ? (
|
||||
<button className="p-1 text-hint hover:text-orange-400" title="정지"><Square className="w-3 h-3" /></button>
|
||||
<button type="button" className="p-1 text-hint hover:text-orange-400" title="정지"><Square className="w-3 h-3" /></button>
|
||||
) : null}
|
||||
<button className="p-1 text-hint hover:text-blue-400" title="편집"><Edit2 className="w-3 h-3" /></button>
|
||||
<button className="p-1 text-hint hover:text-cyan-400" title="이력"><FileText className="w-3 h-3" /></button>
|
||||
<button type="button" className="p-1 text-hint hover:text-blue-400" title="편집"><Edit2 className="w-3 h-3" /></button>
|
||||
<button type="button" className="p-1 text-hint hover:text-cyan-400" title="이력"><FileText className="w-3 h-3" /></button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@ -274,15 +279,11 @@ const LOAD_JOBS: LoadJob[] = [
|
||||
const loadColumns: DataColumn<LoadJob>[] = [
|
||||
{ key: 'id', label: 'ID', width: '80px', render: (v) => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'name', label: '작업명', sortable: true, render: (v) => <span className="text-heading font-medium">{v as string}</span> },
|
||||
{ key: 'sourceJob', label: '수집원', width: '80px', render: (v) => <Badge className="bg-cyan-500/15 text-cyan-400 border-0 text-[9px]">{v as string}</Badge> },
|
||||
{ key: 'sourceJob', label: '수집원', width: '80px', render: (v) => <Badge intent="cyan" size="sm">{v as string}</Badge> },
|
||||
{ key: 'targetTable', label: '대상 테이블', width: '140px', render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
|
||||
{ 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 <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>;
|
||||
},
|
||||
render: (v) => <Badge intent={jobStatusIntent(v as string)} size="xs">{v as string}</Badge>,
|
||||
},
|
||||
{ key: 'schedule', label: '스케줄', width: '80px' },
|
||||
{ key: 'lastRun', label: '최종 적재', width: '140px', sortable: true, render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
|
||||
@ -290,9 +291,9 @@ const loadColumns: DataColumn<LoadJob>[] = [
|
||||
{ key: 'id', label: '', width: '70px', align: 'center', sortable: false,
|
||||
render: () => (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button className="p-1 text-hint hover:text-blue-400" title="편집"><Edit2 className="w-3 h-3" /></button>
|
||||
<button className="p-1 text-hint hover:text-cyan-400" title="이력"><FileText className="w-3 h-3" /></button>
|
||||
<button className="p-1 text-hint hover:text-orange-400" title="스토리지"><HardDrive className="w-3 h-3" /></button>
|
||||
<button type="button" className="p-1 text-hint hover:text-blue-400" title="편집"><Edit2 className="w-3 h-3" /></button>
|
||||
<button type="button" className="p-1 text-hint hover:text-cyan-400" title="이력"><FileText className="w-3 h-3" /></button>
|
||||
<button type="button" className="p-1 text-hint hover:text-orange-400" title="스토리지"><HardDrive className="w-3 h-3" /></button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@ -383,28 +384,19 @@ export function DataHub() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-5 space-y-4">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-cyan-400" />
|
||||
{t('dataHub.title')}
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
|
||||
<span>⚠</span><span>데모 데이터 (백엔드 API 미구현)</span>
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">
|
||||
{t('dataHub.desc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="flex items-center gap-1.5 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading transition-colors">
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Database}
|
||||
iconColor="text-cyan-400"
|
||||
title={t('dataHub.title')}
|
||||
description={t('dataHub.desc')}
|
||||
demo
|
||||
actions={
|
||||
<Button variant="secondary" size="sm" icon={<RefreshCw className="w-3 h-3" />}>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* KPI */}
|
||||
<div className="flex gap-2">
|
||||
@ -427,7 +419,7 @@ export function DataHub() {
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<div className="flex gap-1">
|
||||
<TabBar variant="pill">
|
||||
{[
|
||||
{ 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) => (
|
||||
<button
|
||||
<TabButton
|
||||
key={t.key}
|
||||
variant="pill"
|
||||
active={tab === t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs transition-colors ${
|
||||
tab === t.key ? 'bg-cyan-600 text-heading' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'
|
||||
}`}
|
||||
icon={<t.icon className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<t.icon className="w-3.5 h-3.5" />
|
||||
{t.label}
|
||||
</button>
|
||||
</TabButton>
|
||||
))}
|
||||
</div>
|
||||
</TabBar>
|
||||
|
||||
{/* ── ① 선박신호 수신 현황 ── */}
|
||||
{tab === 'signal' && (
|
||||
@ -458,16 +449,16 @@ export function DataHub() {
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<input
|
||||
aria-label="수신 현황 기준일"
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<button className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-slate-700/50 rounded-lg text-[10px] text-muted-foreground hover:text-heading transition-colors">
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
<Button variant="secondary" size="sm" icon={<RefreshCw className="w-3 h-3" />}>
|
||||
새로고침
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -536,17 +527,15 @@ export function DataHub() {
|
||||
{/* 상태 필터 */}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{(['', 'ON', 'OFF'] as const).map((f) => (
|
||||
<button
|
||||
<TabButton
|
||||
key={f}
|
||||
variant="pill"
|
||||
active={statusFilter === f}
|
||||
onClick={() => 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 || '전체'}
|
||||
</button>
|
||||
</TabButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@ -569,19 +558,21 @@ export function DataHub() {
|
||||
<div className="flex items-center gap-3 px-4 py-2 rounded-xl border border-border bg-card">
|
||||
<span className="text-[10px] text-hint">서버 타입:</span>
|
||||
{(['', 'SQL', 'FILE', 'FTP'] as const).map((f) => (
|
||||
<button key={f} onClick={() => 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 || '전체'}</button>
|
||||
<TabButton key={f} variant="pill" active={collectTypeFilter === f} onClick={() => setCollectTypeFilter(f)} className="px-2.5 py-1 text-[10px]">
|
||||
{f || '전체'}
|
||||
</TabButton>
|
||||
))}
|
||||
<span className="text-[10px] text-hint ml-3">상태:</span>
|
||||
{(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => (
|
||||
<button key={f} onClick={() => 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 || '전체'}</button>
|
||||
<TabButton key={f} variant="pill" active={collectStatusFilter === f} onClick={() => setCollectStatusFilter(f)} className="px-2.5 py-1 text-[10px]">
|
||||
{f || '전체'}
|
||||
</TabButton>
|
||||
))}
|
||||
<button className="ml-auto flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[10px] font-bold rounded-lg transition-colors">
|
||||
<Plus className="w-3 h-3" />작업 등록
|
||||
</button>
|
||||
<div className="ml-auto">
|
||||
<Button variant="primary" size="sm" icon={<Plus className="w-3 h-3" />}>
|
||||
작업 등록
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable data={filteredCollectJobs} columns={collectColumns} pageSize={10}
|
||||
searchPlaceholder="작업명, 서버명, IP 검색..." searchKeys={['name', 'serverName', 'serverIp']} exportFilename="수집작업목록" />
|
||||
@ -594,17 +585,17 @@ export function DataHub() {
|
||||
<div className="flex items-center gap-3 px-4 py-2 rounded-xl border border-border bg-card">
|
||||
<span className="text-[10px] text-hint">상태:</span>
|
||||
{(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => (
|
||||
<button key={f} onClick={() => 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 || '전체'}</button>
|
||||
<TabButton key={f} variant="pill" active={loadStatusFilter === f} onClick={() => setLoadStatusFilter(f)} className="px-2.5 py-1 text-[10px]">
|
||||
{f || '전체'}
|
||||
</TabButton>
|
||||
))}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button 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">
|
||||
<FolderOpen className="w-3 h-3" />스토리지 관리
|
||||
</button>
|
||||
<button className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[10px] font-bold rounded-lg transition-colors">
|
||||
<Plus className="w-3 h-3" />작업 등록
|
||||
</button>
|
||||
<Button variant="secondary" size="sm" icon={<FolderOpen className="w-3 h-3" />}>
|
||||
스토리지 관리
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" icon={<Plus className="w-3 h-3" />}>
|
||||
작업 등록
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable data={filteredLoadJobs} columns={loadColumns} pageSize={10}
|
||||
@ -618,25 +609,26 @@ export function DataHub() {
|
||||
<div className="flex items-center gap-3 px-4 py-2 rounded-xl border border-border bg-card">
|
||||
<span className="text-[10px] text-hint">종류:</span>
|
||||
{(['', '수집', '적재'] as const).map((f) => (
|
||||
<button key={f} onClick={() => 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 || '전체'}</button>
|
||||
<TabButton key={f} variant="pill" active={agentRoleFilter === f} onClick={() => setAgentRoleFilter(f)} className="px-2.5 py-1 text-[10px]">
|
||||
{f || '전체'}
|
||||
</TabButton>
|
||||
))}
|
||||
<span className="text-[10px] text-hint ml-3">상태:</span>
|
||||
{(['', '수행중', '대기중', '장애발생', '정지'] as const).map((f) => (
|
||||
<button key={f} onClick={() => 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 || '전체'}</button>
|
||||
<TabButton key={f} variant="pill" active={agentStatusFilter === f} onClick={() => setAgentStatusFilter(f)} className="px-2.5 py-1 text-[10px]">
|
||||
{f || '전체'}
|
||||
</TabButton>
|
||||
))}
|
||||
<button className="ml-auto 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">
|
||||
<RefreshCw className="w-3 h-3" />새로고침
|
||||
</button>
|
||||
<div className="ml-auto">
|
||||
<Button variant="secondary" size="sm" icon={<RefreshCw className="w-3 h-3" />}>
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 연계서버 카드 그리드 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{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 (
|
||||
<Card key={agent.id} className="bg-surface-raised border-border">
|
||||
<CardContent className="p-4">
|
||||
@ -645,7 +637,7 @@ export function DataHub() {
|
||||
<div className="text-[12px] font-bold text-heading">{agent.name}</div>
|
||||
<div className="text-[10px] text-hint">{agent.id} · {agent.role}에이전트</div>
|
||||
</div>
|
||||
<Badge className={`border-0 text-[9px] ${stColor}`}>{agent.status}</Badge>
|
||||
<Badge intent={jobStatusIntent(agent.status)} size="xs">{agent.status}</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-[10px] mb-3">
|
||||
<div className="flex justify-between"><span className="text-hint">Hostname</span><span className="text-label font-mono">{agent.hostname}</span></div>
|
||||
@ -661,9 +653,9 @@ export function DataHub() {
|
||||
<div className="flex items-center justify-between mt-3 pt-2 border-t border-border">
|
||||
<span className="text-[9px] text-hint">작업 {agent.taskCount}건 · heartbeat {agent.lastHeartbeat.slice(11)}</span>
|
||||
<div className="flex gap-0.5">
|
||||
<button className="p-1 text-hint hover:text-blue-400" title="상태 상세"><Eye className="w-3 h-3" /></button>
|
||||
<button className="p-1 text-hint hover:text-yellow-400" title="이름 변경"><Edit2 className="w-3 h-3" /></button>
|
||||
<button className="p-1 text-hint hover:text-red-400" title="삭제"><Trash2 className="w-3 h-3" /></button>
|
||||
<button type="button" className="p-1 text-hint hover:text-blue-400" title="상태 상세"><Eye className="w-3 h-3" /></button>
|
||||
<button type="button" className="p-1 text-hint hover:text-yellow-400" title="이름 변경"><Edit2 className="w-3 h-3" /></button>
|
||||
<button type="button" className="p-1 text-hint hover:text-red-400" title="삭제"><Trash2 className="w-3 h-3" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@ -673,6 +665,6 @@ export function DataHub() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<LoginHistory[]>([]);
|
||||
const [stats, setStats] = useState<LoginStats | null>(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 (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-heading">로그인 이력</h1>
|
||||
<p className="text-xs text-hint mt-1">성공/실패 로그인 시도 기록 (5회 실패 시 자동 잠금)</p>
|
||||
</div>
|
||||
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded flex items-center gap-1">
|
||||
<RefreshCw className="w-3.5 h-3.5" /> 새로고침
|
||||
</button>
|
||||
</div>
|
||||
<PageContainer size="lg">
|
||||
<PageHeader
|
||||
icon={LogIn}
|
||||
iconColor="text-green-400"
|
||||
title="로그인 이력"
|
||||
description="성공/실패 로그인 시도 기록 (5회 실패 시 자동 잠금)"
|
||||
actions={
|
||||
<Button variant="primary" size="sm" onClick={load} icon={<RefreshCw className="w-3.5 h-3.5" />}>
|
||||
새로고침
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
{stats && (
|
||||
@ -122,7 +125,7 @@ export function LoginHistoryView() {
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.loginDtm)}</td>
|
||||
<td className="px-3 py-2 text-cyan-400">{it.userAcnt}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={`border-0 text-[9px] ${resultColor(it.result)}`}>{it.result}</Badge>
|
||||
<Badge intent={getLoginResultIntent(it.result)} size="sm">{getLoginResultLabel(it.result, tc, lang)}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-red-400 text-[10px]">{it.failReason || '-'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{it.authProvider || '-'}</td>
|
||||
@ -134,7 +137,7 @@ export function LoginHistoryView() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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 (
|
||||
<div className="p-5 space-y-4">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<Bell className="w-5 h-5 text-yellow-400" />
|
||||
{t('notices.title')}
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
|
||||
<span>⚠</span><span>데모 데이터 (백엔드 API 미구현)</span>
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">
|
||||
{t('notices.desc')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openNew}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-heading text-[11px] font-bold rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
새 알림 등록
|
||||
</button>
|
||||
</div>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Bell}
|
||||
iconColor="text-yellow-400"
|
||||
title={t('notices.title')}
|
||||
description={t('notices.desc')}
|
||||
demo
|
||||
actions={
|
||||
<Button variant="primary" size="md" onClick={openNew} icon={<Plus className="w-3.5 h-3.5" />}>
|
||||
새 알림 등록
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* KPI — 가로 한 줄 */}
|
||||
<div className="flex gap-2">
|
||||
@ -211,7 +204,7 @@ export function NoticeManagement() {
|
||||
return (
|
||||
<tr key={n.id} className="border-b border-border hover:bg-surface-overlay">
|
||||
<td className="px-2 py-1.5">
|
||||
<Badge className={`border-0 text-[9px] ${status.color}`}>{status.label}</Badge>
|
||||
<Badge intent={status.intent} size="xs">{status.label}</Badge>
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<span className={`inline-flex items-center gap-1 text-[10px] ${typeOpt.color}`}>
|
||||
@ -244,10 +237,10 @@ export function NoticeManagement() {
|
||||
</td>
|
||||
<td className="px-1 py-1.5">
|
||||
<div className="flex items-center justify-center gap-0.5">
|
||||
<button onClick={() => openEdit(n)} className="p-1 text-hint hover:text-blue-400" title="수정">
|
||||
<button type="button" onClick={() => openEdit(n)} className="p-1 text-hint hover:text-blue-400" title="수정">
|
||||
<Edit2 className="w-3 h-3" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(n.id)} className="p-1 text-hint hover:text-red-400" title="삭제">
|
||||
<button type="button" onClick={() => handleDelete(n.id)} className="p-1 text-hint hover:text-red-400" title="삭제">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
@ -268,7 +261,7 @@ export function NoticeManagement() {
|
||||
<span className="text-sm font-bold text-heading">
|
||||
{editingId ? '알림 수정' : '새 알림 등록'}
|
||||
</span>
|
||||
<button onClick={() => setShowForm(false)} className="text-hint hover:text-heading">
|
||||
<button type="button" aria-label="닫기" onClick={() => setShowForm(false)} className="text-hint hover:text-heading">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
@ -278,6 +271,7 @@ export function NoticeManagement() {
|
||||
<div>
|
||||
<label className="text-[10px] text-muted-foreground font-medium mb-1 block">제목</label>
|
||||
<input
|
||||
aria-label="알림 제목"
|
||||
value={form.title}
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
className="w-full bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/50"
|
||||
@ -289,6 +283,7 @@ export function NoticeManagement() {
|
||||
<div>
|
||||
<label className="text-[10px] text-muted-foreground font-medium mb-1 block">내용</label>
|
||||
<textarea
|
||||
aria-label="알림 내용"
|
||||
value={form.message}
|
||||
onChange={(e) => setForm({ ...form, message: e.target.value })}
|
||||
rows={3}
|
||||
@ -303,7 +298,7 @@ export function NoticeManagement() {
|
||||
<label className="text-[10px] text-muted-foreground font-medium mb-1 block">알림 유형</label>
|
||||
<div className="flex gap-1">
|
||||
{TYPE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
<button type="button"
|
||||
key={opt.key}
|
||||
onClick={() => 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() {
|
||||
<label className="text-[10px] text-muted-foreground font-medium mb-1 block">표시 방식</label>
|
||||
<div className="flex gap-1">
|
||||
{DISPLAY_OPTIONS.map((opt) => (
|
||||
<button
|
||||
<button type="button"
|
||||
key={opt.key}
|
||||
onClick={() => 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() {
|
||||
<div>
|
||||
<label className="text-[10px] text-muted-foreground font-medium mb-1 block">시작일</label>
|
||||
<input
|
||||
aria-label="시작일"
|
||||
type="date"
|
||||
value={form.startDate}
|
||||
onChange={(e) => setForm({ ...form, startDate: e.target.value })}
|
||||
@ -353,6 +349,7 @@ export function NoticeManagement() {
|
||||
<div>
|
||||
<label className="text-[10px] text-muted-foreground font-medium mb-1 block">종료일</label>
|
||||
<input
|
||||
aria-label="종료일"
|
||||
type="date"
|
||||
value={form.endDate}
|
||||
onChange={(e) => setForm({ ...form, endDate: e.target.value })}
|
||||
@ -368,7 +365,7 @@ export function NoticeManagement() {
|
||||
</label>
|
||||
<div className="flex gap-1.5">
|
||||
{ROLE_OPTIONS.map((role) => (
|
||||
<button
|
||||
<button type="button"
|
||||
key={role}
|
||||
onClick={() => toggleRole(role)}
|
||||
className={`px-3 py-1.5 rounded-lg text-[10px] transition-colors ${
|
||||
@ -408,7 +405,7 @@ export function NoticeManagement() {
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="px-5 py-3 border-t border-border flex items-center justify-end gap-2">
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => 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() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<string, string> = {
|
||||
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<string, 'Y' | 'N' | null>; // 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<string>(ROLE_DEFAULT_PALETTE[0]);
|
||||
const [editingColor, setEditingColor] = useState<string | null>(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() {
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="mb-2 p-2 bg-surface-overlay rounded space-y-1">
|
||||
<input value={newRoleCd} onChange={(e) => setNewRoleCd(e.target.value.toUpperCase())}
|
||||
<div className="mb-2 p-2 bg-surface-overlay rounded space-y-1.5">
|
||||
<input aria-label="역할 코드" value={newRoleCd} onChange={(e) => setNewRoleCd(e.target.value.toUpperCase())}
|
||||
placeholder="ROLE_CD (대문자)"
|
||||
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
|
||||
<input value={newRoleNm} onChange={(e) => setNewRoleNm(e.target.value)}
|
||||
<input aria-label="역할 이름" value={newRoleNm} onChange={(e) => setNewRoleNm(e.target.value)}
|
||||
placeholder="역할 이름"
|
||||
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
|
||||
<div className="flex gap-1">
|
||||
<button type="button" onClick={handleCreateRole} disabled={!newRoleCd || !newRoleNm}
|
||||
className="flex-1 py-1 bg-green-600 hover:bg-green-500 disabled:bg-green-600/40 text-white text-[10px] rounded">생성</button>
|
||||
<button type="button" onClick={() => setShowCreate(false)}
|
||||
className="flex-1 py-1 bg-gray-600 hover:bg-gray-500 text-white text-[10px] rounded">취소</button>
|
||||
<ColorPicker label="배지 색상" value={newRoleColor} onChange={setNewRoleColor} />
|
||||
<div className="flex gap-1 pt-1">
|
||||
<Button variant="primary" size="sm" onClick={handleCreateRole} disabled={!newRoleCd || !newRoleNm} className="flex-1">
|
||||
생성
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowCreate(false)} className="flex-1">
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -383,26 +395,55 @@ export function PermissionsPanel() {
|
||||
<div className="space-y-1">
|
||||
{roles.map((r) => {
|
||||
const selected = r.roleSn === selectedRoleSn;
|
||||
const isEditingColor = editingColor === String(r.roleSn);
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={r.roleSn}
|
||||
type="button"
|
||||
onClick={() => 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'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge className={`${ROLE_COLORS[r.roleCd] || 'bg-gray-500/20 text-gray-400'} border text-[9px]`}>
|
||||
{r.roleCd}
|
||||
</Badge>
|
||||
{r.builtinYn === 'Y' && <span className="text-[8px] text-hint">BUILT-IN</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedRoleSn(r.roleSn)}
|
||||
className="flex items-center gap-1.5 cursor-pointer"
|
||||
title="역할 선택"
|
||||
>
|
||||
<Badge size="sm" style={getRoleBadgeStyle(r.roleCd)}>
|
||||
{r.roleCd}
|
||||
</Badge>
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
{r.builtinYn === 'Y' && <span className="text-[8px] text-hint">BUILT-IN</span>}
|
||||
{canUpdatePerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingColor(isEditingColor ? null : String(r.roleSn))}
|
||||
className="text-[8px] text-hint hover:text-blue-400"
|
||||
title="색상 변경"
|
||||
>
|
||||
●
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[10px] mt-0.5">{r.roleNm}</div>
|
||||
<div className="text-[9px] text-hint mt-0.5">권한 {r.permissions.length}건</div>
|
||||
</button>
|
||||
<button type="button" onClick={() => setSelectedRoleSn(r.roleSn)} className="w-full text-left">
|
||||
<div className="text-[10px] mt-0.5">{r.roleNm}</div>
|
||||
<div className="text-[9px] text-hint mt-0.5">권한 {r.permissions.length}건</div>
|
||||
</button>
|
||||
{isEditingColor && (
|
||||
<div className="mt-2 p-2 bg-background rounded border border-border">
|
||||
<ColorPicker
|
||||
label="배지 색상"
|
||||
value={r.colorHex}
|
||||
onChange={(hex) => handleUpdateColor(r.roleSn, hex)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@ -426,11 +467,15 @@ export function PermissionsPanel() {
|
||||
</div>
|
||||
</div>
|
||||
{canUpdatePerm && selectedRole && (
|
||||
<button type="button" onClick={handleSave} disabled={!isDirty || saving}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/40 text-white text-xs rounded flex items-center gap-1">
|
||||
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty || saving}
|
||||
icon={saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||
>
|
||||
저장 {isDirty && <span className="text-yellow-300">●</span>}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@ -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 (
|
||||
<div className="p-5 space-y-4">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-cyan-400" />
|
||||
{t('systemConfig.title')}
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
|
||||
<span>⚠</span><span>데모 데이터 (백엔드 API 미구현)</span>
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">
|
||||
{t('systemConfig.desc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button 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">
|
||||
<Download className="w-3 h-3" />
|
||||
내보내기
|
||||
</button>
|
||||
<button 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">
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
코드 동기화
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Database}
|
||||
iconColor="text-cyan-400"
|
||||
title={t('systemConfig.title')}
|
||||
description={t('systemConfig.desc')}
|
||||
demo
|
||||
actions={
|
||||
<>
|
||||
<Button variant="secondary" size="sm" icon={<Download className="w-3 h-3" />}>
|
||||
내보내기
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" icon={<RefreshCw className="w-3 h-3" />}>
|
||||
코드 동기화
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* KPI 카드 */}
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
@ -199,11 +194,11 @@ export function SystemConfig() {
|
||||
{/* 탭 */}
|
||||
<div className="flex gap-1">
|
||||
{TAB_ITEMS.map((t) => (
|
||||
<button
|
||||
<button type="button"
|
||||
key={t.key}
|
||||
onClick={() => changeTab(t.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs transition-colors ${
|
||||
tab === t.key ? 'bg-cyan-600 text-heading' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'
|
||||
tab === t.key ? 'bg-cyan-600 text-on-vivid' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<t.icon className="w-3.5 h-3.5" />
|
||||
@ -223,6 +218,7 @@ export function SystemConfig() {
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
|
||||
<input
|
||||
aria-label="코드 검색"
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); setPage(0); }}
|
||||
placeholder={
|
||||
@ -237,6 +233,7 @@ export function SystemConfig() {
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Filter className="w-3.5 h-3.5 text-hint" />
|
||||
<select
|
||||
aria-label="대분류 필터"
|
||||
value={majorFilter}
|
||||
onChange={(e) => { setMajorFilter(e.target.value); setPage(0); }}
|
||||
className="bg-surface-overlay border border-slate-700/50 rounded-lg px-3 py-2 text-[11px] text-label focus:outline-none focus:border-cyan-500/50"
|
||||
@ -275,13 +272,10 @@ export function SystemConfig() {
|
||||
<tr key={a.code} className="border-b border-border hover:bg-surface-overlay">
|
||||
<td className="px-4 py-2 text-cyan-400 font-mono font-medium">{a.code}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Badge className={`border-0 text-[9px] ${
|
||||
a.major === '서해' ? 'bg-blue-500/20 text-blue-400'
|
||||
: a.major === '남해' ? 'bg-green-500/20 text-green-400'
|
||||
: a.major === '동해' ? 'bg-purple-500/20 text-purple-400'
|
||||
: a.major === '제주' ? 'bg-orange-500/20 text-orange-400'
|
||||
: 'bg-cyan-500/20 text-cyan-400'
|
||||
}`}>{a.major}</Badge>
|
||||
{(() => {
|
||||
const intent: BadgeIntent = a.major === '서해' ? 'info' : a.major === '남해' ? 'success' : a.major === '동해' ? 'purple' : a.major === '제주' ? 'high' : 'cyan';
|
||||
return <Badge intent={intent} size="xs">{a.major}</Badge>;
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-label">{a.mid}</td>
|
||||
<td className="px-4 py-2 text-heading font-medium">{a.name}</td>
|
||||
@ -321,7 +315,7 @@ export function SystemConfig() {
|
||||
>
|
||||
<td className="px-4 py-2 text-cyan-400 font-mono font-medium">{s.code}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Badge className="bg-switch-background/50 text-label border-0 text-[9px]">{s.major}</Badge>
|
||||
<Badge intent="muted" size="sm">{s.major}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{s.mid}</td>
|
||||
<td className="px-4 py-2 text-heading font-medium">{s.name}</td>
|
||||
@ -365,25 +359,16 @@ export function SystemConfig() {
|
||||
<tr key={f.code} className="border-b border-border hover:bg-surface-overlay">
|
||||
<td className="px-4 py-2 text-cyan-400 font-mono font-medium">{f.code}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Badge className={`border-0 text-[9px] ${
|
||||
f.major === '근해어업' ? 'bg-blue-500/20 text-blue-400'
|
||||
: f.major === '연안어업' ? 'bg-green-500/20 text-green-400'
|
||||
: f.major === '양식어업' ? 'bg-cyan-500/20 text-cyan-400'
|
||||
: f.major === '원양어업' ? 'bg-purple-500/20 text-purple-400'
|
||||
: f.major === '구획어업' ? 'bg-orange-500/20 text-orange-400'
|
||||
: f.major === '마을어업' ? 'bg-yellow-500/20 text-yellow-400'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>{f.major}</Badge>
|
||||
{(() => {
|
||||
const intent: BadgeIntent = f.major === '근해어업' ? 'info' : f.major === '연안어업' ? 'success' : f.major === '양식어업' ? 'cyan' : f.major === '원양어업' ? 'purple' : f.major === '구획어업' ? 'high' : f.major === '마을어업' ? 'warning' : 'muted';
|
||||
return <Badge intent={intent} size="xs">{f.major}</Badge>;
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-label">{f.mid}</td>
|
||||
<td className="px-4 py-2 text-heading font-medium">{f.name}</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{f.target}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Badge className={`border-0 text-[9px] ${
|
||||
f.permit === '허가' ? 'bg-blue-500/20 text-blue-400'
|
||||
: f.permit === '면허' ? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>{f.permit}</Badge>
|
||||
<Badge intent={f.permit === '허가' ? 'info' : f.permit === '면허' ? 'success' : 'muted'} size="xs">{f.permit}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-hint text-[10px]">{f.law}</td>
|
||||
</tr>
|
||||
@ -416,15 +401,10 @@ export function SystemConfig() {
|
||||
<tr key={v.code} className="border-b border-border hover:bg-surface-overlay">
|
||||
<td className="px-3 py-2 text-cyan-400 font-mono font-medium">{v.code}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={`border-0 text-[9px] ${
|
||||
v.major === '어선' ? 'bg-blue-500/20 text-blue-400'
|
||||
: v.major === '여객선' ? 'bg-green-500/20 text-green-400'
|
||||
: v.major === '화물선' ? 'bg-orange-500/20 text-orange-400'
|
||||
: v.major === '유조선' ? 'bg-red-500/20 text-red-400'
|
||||
: v.major === '관공선' ? 'bg-purple-500/20 text-purple-400'
|
||||
: v.major === '함정' ? 'bg-cyan-500/20 text-cyan-400'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>{v.major}</Badge>
|
||||
{(() => {
|
||||
const intent: BadgeIntent = v.major === '어선' ? 'info' : v.major === '여객선' ? 'success' : v.major === '화물선' ? 'high' : v.major === '유조선' ? 'critical' : v.major === '관공선' ? 'purple' : v.major === '함정' ? 'cyan' : 'muted';
|
||||
return <Badge intent={intent} size="xs">{v.major}</Badge>;
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-label text-[10px]">{v.mid}</td>
|
||||
<td className="px-3 py-2 text-heading font-medium">{v.name}</td>
|
||||
@ -451,7 +431,7 @@ export function SystemConfig() {
|
||||
{/* 페이지네이션 (코드 탭에서만) */}
|
||||
{tab !== 'settings' && totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => 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() {
|
||||
<span className="text-[11px] text-hint">
|
||||
{page + 1} / {totalPages} 페이지 ({totalItems.toLocaleString()}건)
|
||||
</span>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => 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() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<string, string> = {
|
||||
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 합집합)
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="text-hint hover:text-heading">
|
||||
<button type="button" aria-label="닫기" onClick={onClose} className="text-hint hover:text-heading">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
@ -91,7 +84,7 @@ export function UserRoleAssignDialog({ user, onClose, onSaved }: Props) {
|
||||
}`}>
|
||||
{isSelected && <Check className="w-3.5 h-3.5 text-white" />}
|
||||
</div>
|
||||
<Badge className={`${ROLE_COLORS[r.roleCd] || 'bg-gray-500/20 text-gray-400'} border-0 text-[10px]`}>
|
||||
<Badge size="md" style={getRoleBadgeStyle(r.roleCd)}>
|
||||
{r.roleCd}
|
||||
</Badge>
|
||||
<div className="text-left">
|
||||
|
||||
@ -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 (
|
||||
<div className="p-5 h-full flex flex-col">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><MessageSquare className="w-5 h-5 text-green-400" />{t('assistant.title')}</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('assistant.desc')}</p>
|
||||
</div>
|
||||
<PageContainer className="h-full flex flex-col">
|
||||
<PageHeader
|
||||
icon={MessageSquare}
|
||||
iconColor="text-green-400"
|
||||
title={t('assistant.title')}
|
||||
description={t('assistant.desc')}
|
||||
/>
|
||||
<div className="flex-1 flex gap-3 min-h-0">
|
||||
{/* 대화 이력 사이드바 */}
|
||||
<Card className="w-56 shrink-0 bg-surface-raised border-border">
|
||||
@ -138,18 +141,19 @@ export function AIAssistant() {
|
||||
{/* 입력창 */}
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<input
|
||||
aria-label="AI 어시스턴트 질의"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSend()}
|
||||
placeholder="질의를 입력하세요... (법령, 단속 절차, AI 분석 결과 등)"
|
||||
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded-xl px-4 py-2.5 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-green-500/50"
|
||||
/>
|
||||
<button onClick={handleSend} className="px-4 py-2.5 bg-green-600 hover:bg-green-500 text-heading rounded-xl transition-colors">
|
||||
<button type="button" aria-label="전송" onClick={handleSend} className="px-4 py-2.5 bg-green-600 hover:bg-green-500 text-on-vivid rounded-xl transition-colors">
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<ModelVersion>[] = [
|
||||
{ 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 <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>;
|
||||
return <Badge intent={getStatusIntent(s)} size="xs">{s}</Badge>;
|
||||
},
|
||||
},
|
||||
{ key: 'accuracy', label: 'Accuracy', width: '80px', align: 'right', sortable: true, render: (v) => <span className="text-heading font-bold">{v as number}%</span> },
|
||||
@ -176,8 +180,8 @@ const gearColumns: DataColumn<GearCode>[] = [
|
||||
{ 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 <Badge className={`border-0 text-[9px] ${c}`}>{r}</Badge>;
|
||||
const intent: BadgeIntent = r === '고위험' ? 'critical' : r === '중위험' ? 'warning' : 'success';
|
||||
return <Badge intent={intent} size="xs">{r}</Badge>;
|
||||
},
|
||||
},
|
||||
{ key: 'speed', label: '탐지 속도', width: '90px', align: 'center', render: (v) => <span className="text-label font-mono">{v as string}</span> },
|
||||
@ -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<Tab>('registry');
|
||||
const [rules, setRules] = useState(defaultRules);
|
||||
|
||||
@ -244,29 +250,21 @@ export function AIModelManagement() {
|
||||
const currentModel = MODELS.find((m) => m.status === '운영중')!;
|
||||
|
||||
return (
|
||||
<div className="p-5 space-y-4">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<Brain className="w-5 h-5 text-purple-400" />
|
||||
{t('modelManagement.title')}
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
|
||||
<span>⚠</span><span>데모 데이터 (백엔드 API 미구현)</span>
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">
|
||||
{t('modelManagement.desc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Brain}
|
||||
iconColor="text-purple-400"
|
||||
title={t('modelManagement.title')}
|
||||
description={t('modelManagement.desc')}
|
||||
demo
|
||||
actions={
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-green-500/10 border border-green-500/20 rounded-lg">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||
<span className="text-[10px] text-green-400 font-bold">운영 모델: {currentModel.version}</span>
|
||||
<span className="text-[10px] text-hint">Accuracy {currentModel.accuracy}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* KPI */}
|
||||
<div className="flex gap-2">
|
||||
@ -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) => (
|
||||
<button key={t.key} onClick={() => 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'}`}>
|
||||
<button type="button" key={t.key} onClick={() => 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.icon className="w-3.5 h-3.5" />{t.label}
|
||||
</button>
|
||||
))}
|
||||
@ -319,7 +317,7 @@ export function AIModelManagement() {
|
||||
<div className="text-[10px] text-muted-foreground">정확도 93.2% (+3.1%) · 오탐률 7.8% (-2.1%) · 다크베셀 탐지 강화</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="bg-blue-600 hover:bg-blue-500 text-heading text-[11px] font-bold px-4 py-2 rounded-lg transition-colors shrink-0">운영 배포</button>
|
||||
<button type="button" className="bg-blue-600 hover:bg-blue-500 text-on-vivid text-[11px] font-bold px-4 py-2 rounded-lg transition-colors shrink-0">운영 배포</button>
|
||||
</div>
|
||||
<DataTable data={MODELS} columns={modelColumns} pageSize={10} searchPlaceholder="버전, 비고 검색..." searchKeys={['version', 'note']} exportFilename="AI모델_버전이력" />
|
||||
</div>
|
||||
@ -333,14 +331,14 @@ export function AIModelManagement() {
|
||||
{rules.map((rule, i) => (
|
||||
<Card key={i} className="bg-surface-raised border-border">
|
||||
<CardContent className="p-3 flex items-center gap-4">
|
||||
<button onClick={() => toggleRule(i)}
|
||||
<button type="button" role="switch" aria-checked={rule.enabled ? 'true' : 'false'} aria-label={rule.name} onClick={() => toggleRule(i)}
|
||||
className={`w-10 h-5 rounded-full transition-colors relative shrink-0 ${rule.enabled ? 'bg-blue-600' : 'bg-switch-background'}`}>
|
||||
<div className="w-4 h-4 bg-white rounded-full absolute top-0.5 transition-all shadow-sm" style={{ left: rule.enabled ? '22px' : '2px' }} />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] font-bold text-heading">{rule.name}</span>
|
||||
<Badge className="bg-purple-500/20 text-purple-400 border-0 text-[8px]">{rule.model}</Badge>
|
||||
<Badge intent="purple" size="xs">{rule.model}</Badge>
|
||||
</div>
|
||||
<div className="text-[10px] text-hint mt-0.5">{rule.desc}</div>
|
||||
</div>
|
||||
@ -628,8 +626,6 @@ export function AIModelManagement() {
|
||||
{/* 7대 엔진 카드 */}
|
||||
<div className="space-y-2">
|
||||
{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 (
|
||||
<Card key={eng.id} className="bg-surface-raised border-border">
|
||||
<CardContent className="p-3 flex items-start gap-4">
|
||||
@ -641,7 +637,7 @@ export function AIModelManagement() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[12px] font-bold text-heading">{eng.name}</span>
|
||||
<Badge className={`border-0 text-[8px] ${stColor}`}>{eng.status}</Badge>
|
||||
<Badge intent={getStatusIntent(eng.status)} size="xs">{eng.status}</Badge>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground mb-1.5">{eng.purpose}</div>
|
||||
<div className="text-[10px] text-hint leading-relaxed">{eng.detail}</div>
|
||||
@ -654,7 +650,9 @@ export function AIModelManagement() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[9px] text-hint">심각도</div>
|
||||
<Badge className={`border-0 text-[9px] ${sevColor}`}>{eng.severity}</Badge>
|
||||
<Badge intent={getEngineSeverityIntent(eng.severity)} size="sm">
|
||||
{getEngineSeverityLabel(eng.severity, tcCommon, lang)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[9px] text-hint">쿨다운</div>
|
||||
@ -852,14 +850,14 @@ export function AIModelManagement() {
|
||||
].map((api, i) => (
|
||||
<tr key={i} className="border-b border-border hover:bg-surface-overlay">
|
||||
<td className="py-1.5">
|
||||
<Badge className={`border-0 text-[8px] font-bold ${api.method === 'GET' ? 'bg-green-500/20 text-green-400' : 'bg-blue-500/20 text-blue-400'}`}>{api.method}</Badge>
|
||||
<Badge intent={api.method === 'GET' ? 'success' : 'info'} size="xs">{api.method}</Badge>
|
||||
</td>
|
||||
<td className="py-1.5 font-mono text-cyan-400">{api.endpoint}</td>
|
||||
<td className="py-1.5 text-hint">{api.unit}</td>
|
||||
<td className="py-1.5 text-label">{api.desc}</td>
|
||||
<td className="py-1.5 text-muted-foreground">{api.sfr}</td>
|
||||
<td className="py-1.5 text-center">
|
||||
<Badge className={`border-0 text-[8px] ${api.status === '운영' ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'}`}>{api.status}</Badge>
|
||||
<Badge intent={getStatusIntent(api.status)} size="xs">{api.status}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@ -882,7 +880,7 @@ export function AIModelManagement() {
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] text-muted-foreground">격자별 위험도 조회 (파라미터: 좌표 범위, 시간)</span>
|
||||
<button onClick={() => navigator.clipboard.writeText('GET /api/v1/predictions/grid?lat_min=36.0&lat_max=38.0&lon_min=124.0&lon_max=126.0&time=2026-04-03T09:00Z')} className="text-hint hover:text-muted-foreground"><Copy className="w-3 h-3" /></button>
|
||||
<button type="button" aria-label="예시 URL 복사" onClick={() => navigator.clipboard.writeText('GET /api/v1/predictions/grid?lat_min=36.0&lat_max=38.0&lon_min=124.0&lon_max=126.0&time=2026-04-03T09:00Z')} className="text-hint hover:text-muted-foreground"><Copy className="w-3 h-3" /></button>
|
||||
</div>
|
||||
<pre className="bg-background border border-border rounded-lg p-3 text-[9px] font-mono text-muted-foreground overflow-x-auto">
|
||||
{`GET /api/v1/predictions/grid
|
||||
@ -948,7 +946,7 @@ export function AIModelManagement() {
|
||||
].map((s) => (
|
||||
<div key={s.sfr} className="px-3 py-2.5 rounded-lg bg-surface-overlay border border-border">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge className="border-0 text-[9px] font-bold" style={{ backgroundColor: `${s.color}20`, color: s.color }}>{s.sfr}</Badge>
|
||||
<Badge size="sm" className="font-bold" style={{ backgroundColor: s.color, borderColor: s.color }}>{s.sfr}</Badge>
|
||||
<span className="text-[11px] font-bold text-heading">{s.name}</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-hint mb-1.5">{s.desc}</div>
|
||||
@ -989,6 +987,6 @@ export function AIModelManagement() {
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<Cpu className="w-5 h-5 text-purple-400" />{t('mlops.title')}
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
|
||||
<span>⚠</span><span>데모 데이터 (백엔드 API 미구현)</span>
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('mlops.desc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Cpu}
|
||||
iconColor="text-purple-400"
|
||||
title={t('mlops.title')}
|
||||
description={t('mlops.desc')}
|
||||
demo
|
||||
/>
|
||||
|
||||
{/* 탭 */}
|
||||
<div className="flex gap-0 border-b border-border">
|
||||
@ -138,7 +133,7 @@ export function MLOpsPage() {
|
||||
{ key: 'llmops' as Tab, icon: Brain, label: 'LLMOps' },
|
||||
{ key: 'platform' as Tab, icon: Settings, label: '플랫폼 관리' },
|
||||
]).map(t => (
|
||||
<button key={t.key} onClick={() => setTab(t.key)}
|
||||
<button type="button" key={t.key} onClick={() => setTab(t.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2.5 text-[11px] font-medium border-b-2 transition-colors ${tab === t.key ? 'text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}>
|
||||
<t.icon className="w-3.5 h-3.5" />{t.label}
|
||||
</button>
|
||||
@ -161,7 +156,7 @@ export function MLOpsPage() {
|
||||
<div className="text-[12px] font-bold text-label mb-3">배포 모델 현황</div>
|
||||
<div className="space-y-2">{MODELS.filter(m => m.status === 'DEPLOYED').map(m => (
|
||||
<div key={m.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0 text-[9px]">DEPLOYED</Badge>
|
||||
<Badge intent="success" size="sm">DEPLOYED</Badge>
|
||||
<span className="text-[11px] text-heading font-medium flex-1">{m.name}</span>
|
||||
<span className="text-[10px] text-hint">{m.ver}</span>
|
||||
<span className="text-[10px] text-green-400 font-bold">F1 {m.f1}%</span>
|
||||
@ -172,7 +167,7 @@ export function MLOpsPage() {
|
||||
<div className="text-[12px] font-bold text-label mb-3">진행 중 실험</div>
|
||||
<div className="space-y-2">{EXPERIMENTS.filter(e => e.status === 'running').map(e => (
|
||||
<div key={e.id} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
||||
<Badge className="bg-blue-500/20 text-blue-400 border-0 text-[9px] animate-pulse">실행중</Badge>
|
||||
<Badge intent="info" size="sm" className="animate-pulse">실행중</Badge>
|
||||
<span className="text-[11px] text-heading font-medium flex-1">{e.name}</span>
|
||||
<div className="w-20 h-1.5 bg-switch-background rounded-full overflow-hidden"><div className="h-full bg-blue-500 rounded-full" style={{ width: `${e.progress}%` }} /></div>
|
||||
<span className="text-[10px] text-muted-foreground">{e.progress}%</span>
|
||||
@ -202,14 +197,14 @@ export function MLOpsPage() {
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-[12px] font-bold text-heading">실험 목록</div>
|
||||
<button className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[10px] font-bold rounded-lg"><Play className="w-3 h-3" />새 실험</button>
|
||||
<button type="button" className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg"><Play className="w-3 h-3" />새 실험</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{EXPERIMENTS.map(e => (
|
||||
<div key={e.id} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||
<span className="text-[10px] text-hint font-mono w-16">{e.id}</span>
|
||||
<span className="text-[11px] text-heading font-medium w-40 truncate">{e.name}</span>
|
||||
<Badge className={`border-0 text-[9px] w-14 text-center ${expColor(e.status)}`}>{e.status}</Badge>
|
||||
<Badge intent={getExperimentIntent(e.status)} size="sm" className={`w-14 ${EXPERIMENT_STATUSES[e.status as keyof typeof EXPERIMENT_STATUSES]?.pulse ? 'animate-pulse' : ''}`}>{e.status}</Badge>
|
||||
<div className="w-24 h-1.5 bg-switch-background rounded-full overflow-hidden"><div className={`h-full rounded-full ${e.status === 'done' ? 'bg-green-500' : e.status === 'fail' ? 'bg-red-500' : 'bg-blue-500'}`} style={{ width: `${e.progress}%` }} /></div>
|
||||
<span className="text-[10px] text-muted-foreground w-12">{e.epoch}</span>
|
||||
<span className="text-[10px] text-muted-foreground w-16">{e.time}</span>
|
||||
@ -228,7 +223,7 @@ export function MLOpsPage() {
|
||||
<Card key={m.name + m.ver} className="bg-surface-raised border-border"><CardContent className="p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div><div className="text-[13px] font-bold text-heading">{m.name}</div><div className="text-[9px] text-hint mt-0.5">{m.ver}</div></div>
|
||||
<Badge className={`border text-[10px] font-bold ${stColor(m.status)}`}>{m.status}</Badge>
|
||||
<Badge intent={getModelStatusIntent(m.status)} size="md" className="font-bold">{MODEL_STATUSES[m.status as keyof typeof MODEL_STATUSES]?.fallback.ko ?? m.status}</Badge>
|
||||
</div>
|
||||
{/* 성능 지표 */}
|
||||
{m.accuracy > 0 && (
|
||||
@ -245,7 +240,7 @@ export function MLOpsPage() {
|
||||
<div className="text-[9px] text-hint mb-1.5 font-bold">Quality Gates</div>
|
||||
<div className="flex gap-1">
|
||||
{m.gates.map((g, i) => (
|
||||
<Badge key={g} className={`border-0 text-[8px] ${gateColor(m.gateStatus[i])}`}>{g}</Badge>
|
||||
<Badge key={g} intent={getQualityGateIntent(m.gateStatus[i])} size="xs" className={QUALITY_GATE_STATUSES[m.gateStatus[i] as keyof typeof QUALITY_GATE_STATUSES]?.pulse ? 'animate-pulse' : ''}>{g}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
@ -272,7 +267,7 @@ export function MLOpsPage() {
|
||||
<td className="px-3 py-2 text-label">{d.latency}</td>
|
||||
<td className="px-3 py-2 text-label">{d.falseAlarm}</td>
|
||||
<td className="px-3 py-2 text-heading font-bold">{d.rps}</td>
|
||||
<td className="px-3 py-2"><Badge className={`border-0 text-[9px] ${d.status === '정상' ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'}`}>{d.status}</Badge></td>
|
||||
<td className="px-3 py-2"><Badge intent={getStatusIntent(d.status)} size="xs">{d.status}</Badge></td>
|
||||
<td className="px-3 py-2 text-hint">{d.date}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
@ -283,8 +278,8 @@ export function MLOpsPage() {
|
||||
<div className="text-[12px] font-bold text-heading mb-2">카나리 / A·B 테스트</div>
|
||||
<div className="text-[10px] text-muted-foreground mb-3">위험도 v2.1.0 (80%) ↔ v2.0.3 (20%)</div>
|
||||
<div className="h-5 bg-background rounded-lg overflow-hidden flex">
|
||||
<div className="bg-blue-600 flex items-center justify-center text-[9px] text-heading font-bold" style={{ width: '80%' }}>v2.1.0 80%</div>
|
||||
<div className="bg-yellow-600 flex items-center justify-center text-[9px] text-heading font-bold" style={{ width: '20%' }}>v2.0.3 20%</div>
|
||||
<div className="bg-blue-600 flex items-center justify-center text-[9px] text-on-vivid font-bold" style={{ width: '80%' }}>v2.1.0 80%</div>
|
||||
<div className="bg-yellow-600 flex items-center justify-center text-[9px] text-on-vivid font-bold" style={{ width: '20%' }}>v2.0.3 20%</div>
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
<Card><CardContent className="p-4">
|
||||
@ -293,7 +288,7 @@ export function MLOpsPage() {
|
||||
{MODELS.filter(m => m.status === 'APPROVED').map(m => (
|
||||
<div key={m.name} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
||||
<span className="text-[11px] text-heading font-medium flex-1">{m.name} {m.ver}</span>
|
||||
<button className="flex items-center gap-1 px-2.5 py-1 bg-green-600 hover:bg-green-500 text-heading text-[9px] font-bold rounded"><Rocket className="w-3 h-3" />배포</button>
|
||||
<button type="button" className="flex items-center gap-1 px-2.5 py-1 bg-green-600 hover:bg-green-500 text-on-vivid text-[9px] font-bold rounded"><Rocket className="w-3 h-3" />배포</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -307,7 +302,7 @@ export function MLOpsPage() {
|
||||
<div className="grid grid-cols-2 gap-3" style={{ height: 'calc(100vh - 240px)' }}>
|
||||
<Card className="bg-surface-raised border-border flex flex-col"><CardContent className="p-4 flex-1 flex flex-col">
|
||||
<div className="text-[9px] font-bold text-hint mb-2">REQUEST BODY (JSON)</div>
|
||||
<textarea className="flex-1 bg-background border border-border rounded-lg p-3 text-[10px] text-cyan-300 font-mono resize-none focus:outline-none focus:border-blue-500/40" defaultValue={`{
|
||||
<textarea aria-label="API 요청 본문 JSON" className="flex-1 bg-background border border-border rounded-lg p-3 text-[10px] text-cyan-300 font-mono resize-none focus:outline-none focus:border-blue-500/40" defaultValue={`{
|
||||
"mmsi": "412345678",
|
||||
"lat": 37.12,
|
||||
"lon": 124.63,
|
||||
@ -318,8 +313,8 @@ export function MLOpsPage() {
|
||||
"version": "v2.1.0"
|
||||
}`} />
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button className="flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[10px] font-bold rounded-lg"><Zap className="w-3 h-3" />실행</button>
|
||||
<button className="px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground">초기화</button>
|
||||
<button type="button" className="flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg"><Zap className="w-3 h-3" />실행</button>
|
||||
<button type="button" className="px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground">초기화</button>
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
<Card className="bg-surface-raised border-border flex flex-col"><CardContent className="p-4 flex-1 flex flex-col">
|
||||
@ -357,7 +352,7 @@ export function MLOpsPage() {
|
||||
{ key: 'worker' as LLMSubTab, label: '배포 워커' },
|
||||
{ key: 'llmtest' as LLMSubTab, label: 'LLM 테스트' },
|
||||
]).map(t => (
|
||||
<button key={t.key} onClick={() => setLlmSub(t.key)}
|
||||
<button type="button" key={t.key} onClick={() => setLlmSub(t.key)}
|
||||
className={`px-4 py-2 text-[11px] font-medium border-b-2 ${llmSub === t.key ? 'text-blue-400 border-blue-400' : 'text-hint border-transparent hover:text-label'}`}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
@ -386,7 +381,7 @@ export function MLOpsPage() {
|
||||
<div key={k} className="flex flex-col gap-1"><span className="text-[9px] text-hint">{k}</span><div className="bg-background border border-border rounded px-2.5 py-1.5 text-label">{v}</div></div>
|
||||
))}
|
||||
</div>
|
||||
<button className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[10px] font-bold rounded-lg w-full justify-center"><Play className="w-3 h-3" />학습 시작</button>
|
||||
<button type="button" className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg w-full justify-center"><Play className="w-3 h-3" />학습 시작</button>
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
<Card><CardContent className="p-4">
|
||||
@ -396,7 +391,7 @@ export function MLOpsPage() {
|
||||
<div key={j.id} className="flex items-center gap-3 px-3 py-2.5 bg-surface-overlay rounded-lg">
|
||||
<span className="text-[10px] text-hint font-mono w-16">{j.id}</span>
|
||||
<span className="text-[11px] text-heading w-24">{j.model}</span>
|
||||
<Badge className={`border-0 text-[9px] w-14 text-center ${j.status === 'running' ? 'bg-blue-500/20 text-blue-400 animate-pulse' : j.status === 'done' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}`}>{j.status}</Badge>
|
||||
<Badge intent={j.status === 'running' ? 'info' : j.status === 'done' ? 'success' : 'critical'} size="xs" className="w-14 text-center">{j.status}</Badge>
|
||||
<div className="flex-1 h-1.5 bg-switch-background rounded-full overflow-hidden"><div className={`h-full rounded-full ${j.status === 'done' ? 'bg-green-500' : j.status === 'fail' ? 'bg-red-500' : 'bg-blue-500'}`} style={{ width: `${j.progress}%` }} /></div>
|
||||
<span className="text-[10px] text-muted-foreground w-16">{j.elapsed}</span>
|
||||
</div>
|
||||
@ -422,7 +417,7 @@ export function MLOpsPage() {
|
||||
<div key={k} className="flex justify-between px-2 py-1 bg-surface-overlay rounded"><span className="text-hint font-mono">{k}</span><span className="text-label">{v}</span></div>
|
||||
))}
|
||||
</div>
|
||||
<button className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[10px] font-bold rounded-lg w-full justify-center"><Search className="w-3 h-3" />검색 시작</button>
|
||||
<button type="button" className="mt-3 flex items-center gap-1 px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[10px] font-bold rounded-lg w-full justify-center"><Search className="w-3 h-3" />검색 시작</button>
|
||||
</CardContent></Card>
|
||||
<Card className="col-span-2 bg-surface-raised border-border"><CardContent className="p-4">
|
||||
<div className="flex justify-between mb-3"><div className="text-[12px] font-bold text-heading">HPS 시도 결과</div><span className="text-[10px] text-green-400 font-bold">Best: Trial #3 (F1=0.912)</span></div>
|
||||
@ -436,7 +431,7 @@ export function MLOpsPage() {
|
||||
<td className="py-2 px-2 text-muted-foreground">{t.dropout}</td>
|
||||
<td className="py-2 px-2 text-muted-foreground">{t.hidden}</td>
|
||||
<td className="py-2 px-2 text-heading font-bold">{t.f1.toFixed(3)}</td>
|
||||
<td className="py-2 px-2">{t.best && <Badge className="bg-green-500/20 text-green-400 border-0 text-[8px]">BEST</Badge>}</td>
|
||||
<td className="py-2 px-2">{t.best && <Badge intent="success" size="xs">BEST</Badge>}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
@ -503,14 +498,14 @@ export function MLOpsPage() {
|
||||
<p className="text-[10px] text-muted-foreground">2. **최종 위치**: EEZ/NLL 경계 5NM 이내 여부</p>
|
||||
<p className="text-[10px] text-muted-foreground">3. **과거 이력**: MMSI 변조, 이전 단속 기록 확인</p>
|
||||
<div className="mt-2 pt-2 border-t border-border flex gap-1">
|
||||
<Badge className="bg-green-500/10 text-green-400 border-0 text-[8px]">배타적경제수역법 §5</Badge>
|
||||
<Badge className="bg-green-500/10 text-green-400 border-0 text-[8px]">한중어업협정 §6</Badge>
|
||||
<Badge intent="success" size="xs">배타적경제수역법 §5</Badge>
|
||||
<Badge intent="success" size="xs">한중어업협정 §6</Badge>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<input className="flex-1 bg-background border border-border rounded-xl px-4 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/40" placeholder="질의를 입력하세요..." />
|
||||
<button className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-heading rounded-xl"><Send className="w-4 h-4" /></button>
|
||||
<input aria-label="LLM 질의 입력" className="flex-1 bg-background border border-border rounded-xl px-4 py-2 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/40" placeholder="질의를 입력하세요..." />
|
||||
<button type="button" aria-label="전송" className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-on-vivid rounded-xl"><Send className="w-4 h-4" /></button>
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
@ -565,6 +560,6 @@ export function MLOpsPage() {
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -142,6 +142,7 @@ export function LoginPage() {
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-hint" />
|
||||
<input
|
||||
aria-label={t('form.userId')}
|
||||
type="text"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
@ -157,6 +158,7 @@ export function LoginPage() {
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-hint" />
|
||||
<input
|
||||
aria-label={t('form.password')}
|
||||
type={showPw ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@ -195,7 +197,7 @@ export function LoginPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/50 text-heading text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2 whitespace-nowrap"
|
||||
className="w-full py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/50 text-on-vivid text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2 whitespace-nowrap"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
|
||||
@ -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<AlertLevel, { bg: string; text: string; border: string; dot: string }> = {
|
||||
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<string, { icon: LucideIcon; color: string }> = {
|
||||
'실시간 탐지': { 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<string, string> = {
|
||||
'EEZ 침범': '#ef4444',
|
||||
'다크베셀': '#f97316',
|
||||
'불법환적': '#a855f7',
|
||||
'MMSI변조': '#eab308',
|
||||
'고속도주': '#06b6d4',
|
||||
'어구 불법': '#6b7280',
|
||||
};
|
||||
const DEFAULT_PIE_COLORS = ['#ef4444', '#f97316', '#a855f7', '#eab308', '#06b6d4', '#3b82f6', '#10b981', '#6b7280'];
|
||||
|
||||
// TODO: /api/weather 연동 예정
|
||||
const WEATHER_DATA = {
|
||||
@ -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 (
|
||||
<div className="flex items-center gap-2">
|
||||
@ -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 (
|
||||
<div className={`flex gap-3 p-2.5 rounded-lg ${c.bg} border ${c.border} hover:brightness-110 transition-all cursor-pointer group`}>
|
||||
<div className="flex flex-col items-center gap-1 pt-0.5 shrink-0">
|
||||
<div className="flex flex-col items-center gap-0.5 pt-0.5 shrink-0 min-w-[68px]">
|
||||
<PulsingDot color={c.dot} />
|
||||
<span className="text-[9px] text-hint tabular-nums">{event.time}</span>
|
||||
<span className="text-[9px] text-hint tabular-nums whitespace-nowrap">{formatDate(event.time)}</span>
|
||||
<span className="text-[9px] text-hint tabular-nums whitespace-nowrap">{formatTime(event.time)}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className={`text-xs font-bold ${c.text}`}>{event.title}</span>
|
||||
<Badge className={`${c.bg} ${c.text} text-[8px] px-1 py-0 border-0`}>{event.level}</Badge>
|
||||
<Badge intent={getAlertLevelIntent(event.level)} size="xs">
|
||||
{getAlertLevelLabel(event.level, tc, lang)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed truncate">{event.detail}</p>
|
||||
<p className="text-[0.75rem] text-label leading-relaxed truncate">{event.detail}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-[9px] text-hint flex items-center gap-0.5"><Ship className="w-2.5 h-2.5" />{event.vessel}</span>
|
||||
<span className="text-[9px] text-hint flex items-center gap-0.5"><MapPin className="w-2.5 h-2.5" />{event.area}</span>
|
||||
<span className="text-[0.6875rem] text-muted-foreground flex items-center gap-0.5"><Ship className="w-2.5 h-2.5" />{event.vessel}</span>
|
||||
<span className="text-[0.6875rem] text-muted-foreground flex items-center gap-0.5"><MapPin className="w-2.5 h-2.5" />{event.area}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-3.5 h-3.5 text-hint group-hover:text-muted-foreground transition-colors shrink-0 mt-1" />
|
||||
@ -146,14 +121,13 @@ function TimelineItem({ event }: { event: TimelineEvent }) {
|
||||
}
|
||||
|
||||
function PatrolStatusBadge({ status }: { status: string }) {
|
||||
const styles: Record<string, string> = {
|
||||
'추적 중': '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 <Badge className={`${styles[status] || 'bg-muted text-muted-foreground'} text-[9px] border px-1.5 py-0`}>{status}</Badge>;
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
return (
|
||||
<Badge intent={getPatrolStatusIntent(status)} size="xs">
|
||||
{getPatrolStatusLabel(status, tc, lang)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, number> = {};
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{/* ── 상단 헤더 바 ── */}
|
||||
<PageContainer>
|
||||
{/* ── 상단 헤더 바 (DEFCON + 라이브 상태) — 커스텀 구조 유지 ── */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
@ -511,10 +488,10 @@ export function Dashboard() {
|
||||
실시간 상황 타임라인
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge className="bg-red-500/15 text-red-400 text-[8px] border-0 px-1.5 py-0">
|
||||
<Badge intent="critical" size="xs" className="px-1.5 py-0">
|
||||
긴급 {TIMELINE_EVENTS.filter(e => e.level === 'CRITICAL').length}
|
||||
</Badge>
|
||||
<Badge className="bg-orange-500/15 text-orange-400 text-[8px] border-0 px-1.5 py-0">
|
||||
<Badge intent="high" size="xs" className="px-1.5 py-0">
|
||||
경고 {TIMELINE_EVENTS.filter(e => e.level === 'HIGH').length}
|
||||
</Badge>
|
||||
</div>
|
||||
@ -538,7 +515,7 @@ export function Dashboard() {
|
||||
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
||||
<Navigation className="w-3.5 h-3.5 text-cyan-500" />
|
||||
함정 배치 현황
|
||||
<Badge className="bg-cyan-500/15 text-cyan-400 text-[8px] border-0 ml-auto px-1.5 py-0">
|
||||
<Badge intent="cyan" size="xs" className="ml-auto px-1.5 py-0">
|
||||
{PATROL_SHIPS.length}척 운용 중
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
@ -654,12 +631,12 @@ export function Dashboard() {
|
||||
<Crosshair className="w-3.5 h-3.5 text-red-500" />
|
||||
고위험 선박 추적 현황 (AI 우선순위)
|
||||
</CardTitle>
|
||||
<Badge className="bg-red-500/15 text-red-400 text-[9px] border-0">{TOP_RISK_VESSELS.length}척 감시 중</Badge>
|
||||
<Badge intent="critical" size="sm">{TOP_RISK_VESSELS.length}척 감시 중</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 pt-2">
|
||||
{/* 테이블 헤더 */}
|
||||
<div className="grid grid-cols-[32px_1fr_80px_80px_80px_80px_100px] gap-2 px-3 py-2 text-[9px] text-hint border-b border-slate-700/50 font-medium">
|
||||
<div className="grid grid-cols-[32px_minmax(120px,160px)_minmax(70px,1fr)_minmax(80px,1fr)_minmax(80px,1fr)_minmax(120px,2fr)_120px] gap-2 px-3 py-2 text-[9px] text-hint border-b border-slate-700/50 font-medium">
|
||||
<span>#</span>
|
||||
<span>MMSI</span>
|
||||
<span>선종</span>
|
||||
@ -672,20 +649,20 @@ export function Dashboard() {
|
||||
{TOP_RISK_VESSELS.map((vessel, index) => (
|
||||
<div
|
||||
key={vessel.id}
|
||||
className="grid grid-cols-[32px_1fr_80px_80px_80px_80px_100px] gap-2 px-3 py-2.5 rounded-lg hover:bg-surface-overlay transition-colors cursor-pointer group items-center"
|
||||
className="grid grid-cols-[32px_minmax(120px,160px)_minmax(70px,1fr)_minmax(80px,1fr)_minmax(80px,1fr)_minmax(120px,2fr)_120px] gap-2 px-3 py-2.5 rounded-lg hover:bg-surface-overlay transition-colors cursor-pointer group items-center"
|
||||
>
|
||||
<span className="text-hint text-xs font-bold">#{index + 1}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<PulsingDot color={vessel.risk > 0.9 ? 'bg-red-500' : vessel.risk > 0.7 ? 'bg-orange-500' : 'bg-yellow-500'} />
|
||||
<PulsingDot color={vessel.risk > 70 ? 'bg-red-500' : vessel.risk > 50 ? 'bg-orange-500' : 'bg-yellow-500'} />
|
||||
<span className="text-heading text-[11px] font-bold tabular-nums">{vessel.name}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">{vessel.type}</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate">{vessel.zone}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{vessel.activity}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{vessel.isDark && <Badge className="bg-orange-500/15 text-orange-400 text-[8px] px-1 py-0 border-0">다크</Badge>}
|
||||
{vessel.isSpoofing && <Badge className="bg-yellow-500/15 text-yellow-400 text-[8px] px-1 py-0 border-0">GPS변조</Badge>}
|
||||
{vessel.isTransship && <Badge className="bg-purple-500/15 text-purple-400 text-[8px] px-1 py-0 border-0">전재</Badge>}
|
||||
{vessel.isDark && <Badge intent="high" size="xs" className="px-1 py-0">다크</Badge>}
|
||||
{vessel.isSpoofing && <Badge intent="warning" size="xs" className="px-1 py-0">GPS변조</Badge>}
|
||||
{vessel.isTransship && <Badge intent="purple" size="xs" className="px-1 py-0">전재</Badge>}
|
||||
{!vessel.isDark && !vessel.isSpoofing && !vessel.isTransship && <span className="text-[9px] text-hint">-</span>}
|
||||
</div>
|
||||
<RiskBar value={vessel.risk} />
|
||||
@ -694,6 +671,6 @@ export function Dashboard() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<VesselStatus, { ring: string; bg: string; text: string }> = {
|
||||
'의심': { 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 (
|
||||
<div className="space-y-3">
|
||||
<PageContainer size="sm">
|
||||
{/* ── 모드 탭 (AI 대시보드 / 환적 탐지) ── */}
|
||||
<div className="flex items-center gap-1 bg-surface-raised rounded-lg p-1 border border-slate-700/30 w-fit">
|
||||
{modeTabs.map((tab) => (
|
||||
<button
|
||||
<button type="button"
|
||||
key={tab.key}
|
||||
onClick={() => setMode(tab.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-md text-[11px] font-medium transition-colors ${
|
||||
mode === tab.key
|
||||
? 'bg-blue-600 text-heading'
|
||||
? 'bg-blue-600 text-on-vivid'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-surface-overlay'
|
||||
}`}
|
||||
>
|
||||
@ -342,7 +345,7 @@ export function ChinaFishing() {
|
||||
</button>
|
||||
<div className="flex-1 flex items-center bg-surface-overlay border border-slate-700/40 rounded-lg px-3 py-1.5">
|
||||
<Search className="w-3.5 h-3.5 text-hint mr-2" />
|
||||
<input
|
||||
<input aria-label="해역 또는 해구 번호 검색"
|
||||
placeholder="해역 또는 해구 번호 검색"
|
||||
className="bg-transparent text-[11px] text-label placeholder:text-hint flex-1 focus:outline-none"
|
||||
/>
|
||||
@ -425,7 +428,7 @@ export function ChinaFishing() {
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-heading">관심영역 안전도</span>
|
||||
<select className="bg-secondary border border-slate-700/50 rounded px-2 py-0.5 text-[10px] text-label focus:outline-none">
|
||||
<select aria-label="관심영역 선택" className="bg-secondary border border-slate-700/50 rounded px-2 py-0.5 text-[10px] text-label focus:outline-none">
|
||||
<option>영역 A</option>
|
||||
<option>영역 B</option>
|
||||
</select>
|
||||
@ -467,7 +470,7 @@ export function ChinaFishing() {
|
||||
{/* 탭 헤더 */}
|
||||
<div className="flex border-b border-slate-700/30">
|
||||
{vesselTabs.map((tab) => (
|
||||
<button
|
||||
<button type="button"
|
||||
key={tab}
|
||||
onClick={() => setVesselTab(tab)}
|
||||
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${
|
||||
@ -501,7 +504,7 @@ export function ChinaFishing() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12px] font-bold text-heading">{v.name}</span>
|
||||
<Badge className="bg-blue-500/20 text-blue-400 border-0 text-[8px] px-1.5 py-0">{v.type}</Badge>
|
||||
<Badge intent="info" size="xs" className="px-1.5 py-0">{v.type}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-0.5 text-[9px] text-hint">
|
||||
<span>{v.country}</span>
|
||||
@ -524,7 +527,7 @@ export function ChinaFishing() {
|
||||
{/* 탭 */}
|
||||
<div className="flex border-b border-slate-700/30">
|
||||
{statsTabs.map((tab) => (
|
||||
<button
|
||||
<button type="button"
|
||||
key={tab}
|
||||
onClick={() => setStatsTab(tab)}
|
||||
className={`flex-1 py-2.5 text-[11px] font-medium transition-colors ${
|
||||
@ -567,10 +570,14 @@ export function ChinaFishing() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-0.5 text-[8px]">
|
||||
<div className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-red-500" /><span className="text-hint">CRITICAL {riskDistribution.critical}</span></div>
|
||||
<div className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-orange-500" /><span className="text-hint">HIGH {riskDistribution.high}</span></div>
|
||||
<div className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-yellow-500" /><span className="text-hint">MEDIUM {riskDistribution.medium}</span></div>
|
||||
<div className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-blue-500" /><span className="text-hint">LOW {riskDistribution.low}</span></div>
|
||||
{(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as AlertLevel[]).map((lv) => (
|
||||
<div key={lv} className="flex items-center gap-1">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${ALERT_LEVELS[lv].classes.dot}`} />
|
||||
<span className="text-hint">
|
||||
{getAlertLevelLabel(lv, tcCommon, lang)} {riskDistribution[lv.toLowerCase() as 'critical' | 'high' | 'medium' | 'low']}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -592,7 +599,7 @@ export function ChinaFishing() {
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] font-bold text-heading">최근 위성영상 분석</span>
|
||||
<button className="text-[9px] text-blue-400 hover:underline">자세히 보기</button>
|
||||
<button type="button" className="text-[9px] text-blue-400 hover:underline">자세히 보기</button>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-[10px]">
|
||||
<div className="flex gap-2">
|
||||
@ -616,7 +623,7 @@ export function ChinaFishing() {
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] font-bold text-heading">기상 예보</span>
|
||||
<button className="text-[9px] text-blue-400 hover:underline">자세히 보기</button>
|
||||
<button type="button" className="text-[9px] text-blue-400 hover:underline">자세히 보기</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-center">
|
||||
@ -639,7 +646,7 @@ export function ChinaFishing() {
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] font-bold text-heading">VTS연계 현황</span>
|
||||
<button className="text-[9px] text-blue-400 hover:underline">자세히 보기</button>
|
||||
<button type="button" className="text-[9px] text-blue-400 hover:underline">자세히 보기</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{VTS_ITEMS.map((vts) => (
|
||||
@ -657,10 +664,10 @@ export function ChinaFishing() {
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between mt-2">
|
||||
<button className="text-hint hover:text-heading transition-colors">
|
||||
<button type="button" aria-label="이전" className="text-hint hover:text-heading transition-colors">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="text-hint hover:text-heading transition-colors">
|
||||
<button type="button" aria-label="다음" className="text-hint hover:text-heading transition-colors">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
@ -671,6 +678,6 @@ export function ChinaFishing() {
|
||||
</div>
|
||||
|
||||
</>}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<string, string> = {
|
||||
'AIS 완전차단': '#ef4444',
|
||||
'MMSI 변조 의심': '#f97316',
|
||||
'장기소실': '#eab308',
|
||||
'신호 간헐송출': '#a855f7',
|
||||
};
|
||||
|
||||
const cols: DataColumn<Suspect>[] = [
|
||||
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'pattern', label: '탐지 패턴', width: '120px', sortable: true, render: v => <Badge className="bg-red-500/15 text-red-400 border-0 text-[9px]">{v as string}</Badge> },
|
||||
{ key: 'name', label: '선박 유형', sortable: true, render: v => <span className="text-cyan-400 font-medium">{v as string}</span> },
|
||||
{ key: 'mmsi', label: 'MMSI', width: '100px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'flag', label: '국적', width: '50px' },
|
||||
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
|
||||
render: v => { const n = v as number; return <span className={`font-bold ${n > 80 ? 'text-red-400' : n > 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}</span>; } },
|
||||
{ key: 'lastAIS', label: '최종 AIS', width: '90px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||
{ 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 <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>; } },
|
||||
{ key: 'label', label: '라벨', width: '60px', align: 'center',
|
||||
render: v => { const l = v as string; return l === '-' ? <button className="text-[9px] text-hint hover:text-blue-400"><Tag className="w-3 h-3 inline" /> 분류</button> : <Badge className={`border-0 text-[8px] ${l === '불법' ? 'bg-red-500/20 text-red-400' : 'bg-green-500/20 text-green-400'}`}>{l}</Badge>; } },
|
||||
];
|
||||
|
||||
export function DarkVesselDetection() {
|
||||
const { t } = useTranslation('detection');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
|
||||
const cols: DataColumn<Suspect>[] = useMemo(() => [
|
||||
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'pattern', label: '탐지 패턴', width: '120px', sortable: true,
|
||||
render: v => <Badge intent={getDarkVesselPatternIntent(v as string)} size="sm">{getDarkVesselPatternLabel(v as string, tc, lang)}</Badge> },
|
||||
{ key: 'name', label: '선박 유형', sortable: true, render: v => <span className="text-cyan-400 font-medium">{v as string}</span> },
|
||||
{ key: 'mmsi', label: 'MMSI', width: '100px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'flag', label: '국적', width: '50px' },
|
||||
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
|
||||
render: v => { const n = v as number; return <span className={`font-bold ${n > 80 ? 'text-red-400' : n > 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}</span>; } },
|
||||
{ key: 'lastAIS', label: '최종 AIS', width: '90px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
|
||||
render: v => <Badge intent={getVesselSurveillanceIntent(v as string)} size="sm">{getVesselSurveillanceLabel(v as string, tc, lang)}</Badge> },
|
||||
{ key: 'label', label: '라벨', width: '60px', align: 'center',
|
||||
render: v => { const l = v as string; return l === '-' ? <button type="button" className="text-[9px] text-hint hover:text-blue-400"><Tag className="w-3 h-3 inline" /> 분류</button> : <Badge intent={l === '불법' ? 'critical' : 'success'} size="xs">{l}</Badge>; } },
|
||||
], [tc, lang]);
|
||||
|
||||
const [darkItems, setDarkItems] = useState<VesselAnalysisItem[]>([]);
|
||||
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 (
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><EyeOff className="w-5 h-5 text-red-400" />{t('darkVessel.title')}</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('darkVessel.desc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={EyeOff}
|
||||
iconColor="text-red-400"
|
||||
title={t('darkVessel.title')}
|
||||
description={t('darkVessel.desc')}
|
||||
/>
|
||||
|
||||
{!serviceAvailable && (
|
||||
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs">
|
||||
@ -193,12 +194,16 @@ export function DarkVesselDetection() {
|
||||
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
|
||||
<div className="text-[9px] text-muted-foreground font-bold mb-1.5">탐지 패턴</div>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(PATTERN_COLORS).map(([p, c]) => (
|
||||
<div key={p} className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: c }} />
|
||||
<span className="text-[8px] text-muted-foreground">{p}</span>
|
||||
</div>
|
||||
))}
|
||||
{(['AIS_FULL_BLOCK', 'MMSI_SPOOFING', 'LONG_LOSS', 'INTERMITTENT'] as const).map((p) => {
|
||||
const meta = getDarkVesselPatternMeta(p);
|
||||
if (!meta) return null;
|
||||
return (
|
||||
<div key={p} className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: meta.hex }} />
|
||||
<span className="text-[8px] text-muted-foreground">{meta.fallback.ko}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border">
|
||||
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-red-500/50" /><span className="text-[7px] text-hint">EEZ</span></div>
|
||||
@ -212,6 +217,6 @@ export function DarkVesselDetection() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<string, string> = {
|
||||
'고위험': '#ef4444',
|
||||
'중위험': '#eab308',
|
||||
// 한글 위험도 → AlertLevel hex 매핑
|
||||
const RISK_HEX: Record<string, string> = {
|
||||
'고위험': getAlertLevelHex('CRITICAL'),
|
||||
'중위험': getAlertLevelHex('MEDIUM'),
|
||||
'안전': '#22c55e',
|
||||
};
|
||||
|
||||
@ -50,22 +55,25 @@ function mapGroupToGear(g: GearGroupItem, idx: number): Gear {
|
||||
};
|
||||
}
|
||||
|
||||
const cols: DataColumn<Gear>[] = [
|
||||
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'type', label: '어구 유형', width: '100px', sortable: true, render: v => <span className="text-heading font-medium">{v as string}</span> },
|
||||
{ key: 'owner', label: '소유 선박', sortable: true, render: v => <span className="text-cyan-400">{v as string}</span> },
|
||||
{ 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 <Badge className={`border-0 text-[9px] ${c}`}>{p}</Badge>; } },
|
||||
{ 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 <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>; } },
|
||||
{ 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 <span className={`text-[10px] font-bold ${c}`}>{r}</span>; } },
|
||||
{ key: 'lastSignal', label: '최종 신호', width: '80px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||
];
|
||||
|
||||
export function GearDetection() {
|
||||
const { t } = useTranslation('detection');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
|
||||
const cols: DataColumn<Gear>[] = useMemo(() => [
|
||||
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'type', label: '어구 유형', width: '100px', sortable: true, render: v => <span className="text-heading font-medium">{v as string}</span> },
|
||||
{ key: 'owner', label: '소유 선박', sortable: true, render: v => <span className="text-cyan-400">{v as string}</span> },
|
||||
{ key: 'zone', label: '설치 해역', width: '90px', sortable: true },
|
||||
{ key: 'permit', label: '허가 상태', width: '80px', align: 'center',
|
||||
render: v => <Badge intent={getPermitStatusIntent(v as string)} size="sm">{getPermitStatusLabel(v as string, tc, lang)}</Badge> },
|
||||
{ key: 'status', label: '판정', width: '80px', align: 'center', sortable: true,
|
||||
render: v => <Badge intent={getGearJudgmentIntent(v as string)} size="sm">{v as string}</Badge> },
|
||||
{ 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 <span className={`text-[10px] font-bold ${c}`}>{r}</span>; } },
|
||||
{ key: 'lastSignal', label: '최종 신호', width: '80px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||
], [tc, lang]);
|
||||
|
||||
const [groups, setGroups] = useState<GearGroupItem[]>([]);
|
||||
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 (
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Anchor className="w-5 h-5 text-orange-400" />{t('gearDetection.title')}</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('gearDetection.desc')}</p>
|
||||
</div>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Anchor}
|
||||
iconColor="text-orange-400"
|
||||
title={t('gearDetection.title')}
|
||||
description={t('gearDetection.desc')}
|
||||
/>
|
||||
|
||||
{!serviceAvailable && (
|
||||
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs">
|
||||
@ -186,6 +196,6 @@ export function GearDetection() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<input
|
||||
aria-label={label ?? placeholder}
|
||||
type={type}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => 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 (
|
||||
<select
|
||||
aria-label={label}
|
||||
value={value}
|
||||
onChange={(e) => 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<Origin, string> = {
|
||||
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<Origin, import('@lib/theme/variants').BadgeIntent> = {
|
||||
china: 'critical',
|
||||
korea: 'info',
|
||||
uncertain: 'warning',
|
||||
};
|
||||
const labels: Record<Origin, string> = { china: '중국어선 어구', korea: '한국어선 어구', uncertain: '판별 불가' };
|
||||
const confLabels: Record<Confidence, string> = { high: '높음', medium: '보통', low: '낮음' };
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={`${colors[origin]} border text-sm px-3 py-1`}>{labels[origin]}</Badge>
|
||||
<Badge intent={intents[origin]} size="md">{labels[origin]}</Badge>
|
||||
<span className="text-[10px] text-hint">신뢰도: {confLabels[confidence]}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertBadge({ level }: { level: string }) {
|
||||
const styles: Record<string, string> = {
|
||||
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<string, import('@lib/theme/variants').BadgeIntent> = {
|
||||
CRITICAL: 'critical',
|
||||
HIGH: 'high',
|
||||
MEDIUM: 'warning',
|
||||
LOW: 'info',
|
||||
};
|
||||
return <Badge className={`${styles[level]} border text-[10px] px-2 py-0.5`}>{level}</Badge>;
|
||||
return <Badge intent={intents[level] ?? 'muted'} size="xs">{level}</Badge>;
|
||||
}
|
||||
|
||||
// ─── 어구 비교 레퍼런스 테이블 ──────────
|
||||
@ -648,7 +652,7 @@ export function GearIdentification() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => setShowReference(!showReference)}
|
||||
className="px-3 py-1.5 bg-secondary border border-slate-700/50 rounded-md text-[11px] text-label hover:bg-switch-background transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
@ -692,7 +696,7 @@ export function GearIdentification() {
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<SectionHeader icon={Anchor} title="어구 물리적 특성" color="#a855f7" />
|
||||
<FormField label="어구 유형 (추정)">
|
||||
<SelectField value={input.gearCategory} onChange={(v) => update('gearCategory', v as GearType)} options={[
|
||||
<SelectField label="어구 유형 (추정)" value={input.gearCategory} onChange={(v) => update('gearCategory', v as GearType)} options={[
|
||||
{ value: 'unknown', label: '미확인 / 모름' },
|
||||
{ value: 'trawl', label: '트롤 (저인망) — 끌고 다니는 어망' },
|
||||
{ value: 'gillnet', label: '자망 (유자망) — 세워놓는 어망' },
|
||||
@ -746,7 +750,7 @@ export function GearIdentification() {
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="궤적 패턴">
|
||||
<SelectField value={input.trajectoryPattern} onChange={(v) => update('trajectoryPattern', v as GearInput['trajectoryPattern'])} options={[
|
||||
<SelectField label="궤적 패턴" value={input.trajectoryPattern} onChange={(v) => 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() {
|
||||
|
||||
{/* 판별 버튼 */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={runIdentification}
|
||||
className="flex-1 py-2.5 bg-blue-600 hover:bg-blue-500 text-heading text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
className="flex-1 py-2.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-sm font-bold rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
어구 국적 판별 실행
|
||||
</button>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={resetForm}
|
||||
className="px-4 py-2.5 bg-secondary hover:bg-switch-background text-label text-sm rounded-lg transition-colors border border-slate-700/50"
|
||||
>
|
||||
|
||||
@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<GearGroupItem[]>([]);
|
||||
const [available, setAvailable] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -61,14 +55,14 @@ export function RealGearGroups() {
|
||||
<div>
|
||||
<div className="text-sm font-bold text-heading flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-orange-400" /> 실시간 어구/선단 그룹 (iran 백엔드)
|
||||
{!available && <Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]">미연결</Badge>}
|
||||
{!available && <Badge intent="critical" size="sm">미연결</Badge>}
|
||||
</div>
|
||||
<div className="text-[10px] text-hint mt-0.5">
|
||||
GET /api/vessel-analysis/groups · 자체 DB의 운영자 결정(resolution) 합성됨
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select value={filterType} onChange={(e) => setFilterType(e.target.value)}
|
||||
<select aria-label="그룹 유형 필터" value={filterType} onChange={(e) => setFilterType(e.target.value)}
|
||||
className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading">
|
||||
<option value="">전체</option>
|
||||
<option value="FLEET">FLEET</option>
|
||||
@ -115,7 +109,7 @@ export function RealGearGroups() {
|
||||
{filtered.slice(0, 100).map((g) => (
|
||||
<tr key={`${g.groupKey}-${g.subClusterId}`} className="border-t border-border hover:bg-surface-overlay/50">
|
||||
<td className="px-2 py-1.5">
|
||||
<Badge className={`${TYPE_COLORS[g.groupType]} border-0 text-[9px]`}>{g.groupType}</Badge>
|
||||
<Badge intent={getGearGroupTypeIntent(g.groupType)} size="sm">{getGearGroupTypeLabel(g.groupType, tc, lang)}</Badge>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-heading font-medium font-mono text-[10px]">{g.groupKey}</td>
|
||||
<td className="px-2 py-1.5 text-center text-muted-foreground">{g.subClusterId}</td>
|
||||
@ -126,8 +120,8 @@ export function RealGearGroups() {
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
{g.resolution ? (
|
||||
<Badge className={`${STATUS_COLORS[g.resolution.status] || ''} border-0 text-[9px]`}>
|
||||
{g.resolution.status}
|
||||
<Badge intent={getParentResolutionIntent(g.resolution.status)} size="sm">
|
||||
{getParentResolutionLabel(g.resolution.status, tc, lang)}
|
||||
</Badge>
|
||||
) : <span className="text-hint text-[10px]">-</span>}
|
||||
</td>
|
||||
|
||||
@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
TERRITORIAL_SEA: '영해',
|
||||
@ -82,14 +78,14 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
||||
<div>
|
||||
<div className="text-sm font-bold text-heading flex items-center gap-2">
|
||||
{icon} {title}
|
||||
{!available && <Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]">미연결</Badge>}
|
||||
{!available && <Badge intent="critical" size="sm">미연결</Badge>}
|
||||
</div>
|
||||
<div className="text-[10px] text-hint mt-0.5">
|
||||
GET /api/vessel-analysis · iran 백엔드 실시간 분석 결과
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select value={zoneFilter} onChange={(e) => setZoneFilter(e.target.value)}
|
||||
<select aria-label="해역 필터" value={zoneFilter} onChange={(e) => setZoneFilter(e.target.value)}
|
||||
className="bg-surface-overlay border border-border rounded px-2 py-1 text-[10px] text-heading">
|
||||
<option value="">전체 해역</option>
|
||||
<option value="TERRITORIAL_SEA">영해</option>
|
||||
@ -147,7 +143,7 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
||||
<span className="text-hint ml-1 text-[9px]">({(v.classification.confidence * 100).toFixed(0)}%)</span>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<Badge className={`${RISK_COLORS[v.algorithms.riskScore.level] || ''} border-0 text-[9px]`}>
|
||||
<Badge intent={getAlertLevelIntent(v.algorithms.riskScore.level)} size="sm">
|
||||
{v.algorithms.riskScore.level}
|
||||
</Badge>
|
||||
</td>
|
||||
@ -159,7 +155,7 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
||||
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">{v.algorithms.activity.state}</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
{v.algorithms.darkVessel.isDark ? (
|
||||
<Badge className="bg-purple-500/20 text-purple-400 border-0 text-[9px]">{v.algorithms.darkVessel.gapDurationMin}분</Badge>
|
||||
<Badge intent="purple" size="sm">{v.algorithms.darkVessel.gapDurationMin}분</Badge>
|
||||
) : <span className="text-hint">-</span>}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
@ -169,7 +165,7 @@ export function RealVesselAnalysis({ mode, title, icon }: Props) {
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
{v.algorithms.transship.isSuspect ? (
|
||||
<Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]">{v.algorithms.transship.durationMin}분</Badge>
|
||||
<Badge intent="critical" size="sm">{v.algorithms.transship.durationMin}분</Badge>
|
||||
) : <span className="text-hint">-</span>}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-muted-foreground text-[10px]">
|
||||
|
||||
@ -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<Record>[] = [
|
||||
{
|
||||
key: 'id',
|
||||
label: 'ID',
|
||||
width: '80px',
|
||||
render: (v) => (
|
||||
<span className="text-hint font-mono text-[10px]">{v as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
label: '일시',
|
||||
width: '130px',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<span className="text-muted-foreground font-mono text-[10px]">
|
||||
{v as string}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: 'zone', label: '해역', width: '90px', sortable: true },
|
||||
{
|
||||
key: 'vessel',
|
||||
label: '대상 선박',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<span className="text-cyan-400 font-medium">{v as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'violation',
|
||||
label: '위반 내용',
|
||||
width: '100px',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<Badge className="bg-red-500/15 text-red-400 border-0 text-[9px]">
|
||||
{v as string}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{ key: 'action', label: '조치', width: '90px' },
|
||||
{
|
||||
key: 'aiMatch',
|
||||
label: 'AI 매칭',
|
||||
width: '70px',
|
||||
align: 'center',
|
||||
render: (v) => {
|
||||
const m = v as string;
|
||||
return m === '일치' ? (
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-400 inline" />
|
||||
) : (
|
||||
<XCircle className="w-3.5 h-3.5 text-red-400 inline" />
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<Badge className={`border-0 text-[9px] ${c}`}>{r}</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
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<Record>[] = useMemo(() => [
|
||||
{
|
||||
key: 'id',
|
||||
label: 'ID',
|
||||
width: '80px',
|
||||
render: (v) => (
|
||||
<span className="text-hint font-mono text-[10px]">{v as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
label: '일시',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<span className="text-muted-foreground font-mono text-[10px]">
|
||||
{formatDateTime(v as string)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: 'zone', label: '해역', width: '90px', sortable: true },
|
||||
{
|
||||
key: 'vessel',
|
||||
label: '대상 선박',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<span className="text-cyan-400 font-medium">{v as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'violation',
|
||||
label: '위반 내용',
|
||||
minWidth: '90px',
|
||||
maxWidth: '160px',
|
||||
sortable: true,
|
||||
render: (v) => {
|
||||
const code = v as string;
|
||||
return (
|
||||
<Badge intent={getViolationIntent(code)} size="sm">
|
||||
{getViolationLabel(code, tc, lang)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
label: '조치',
|
||||
minWidth: '70px',
|
||||
maxWidth: '110px',
|
||||
render: (v) => (
|
||||
<span className="text-label">{getEnforcementActionLabel(v as string, tc, lang)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'aiMatch',
|
||||
label: 'AI 매칭',
|
||||
width: '70px',
|
||||
align: 'center',
|
||||
render: (v) => {
|
||||
const m = v as string;
|
||||
return m === '일치' || m === 'MATCH' ? (
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-400 inline" />
|
||||
) : (
|
||||
<XCircle className="w-3.5 h-3.5 text-red-400 inline" />
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'result',
|
||||
label: '결과',
|
||||
minWidth: '80px',
|
||||
maxWidth: '120px',
|
||||
align: 'center',
|
||||
sortable: true,
|
||||
render: (v) => {
|
||||
const code = v as string;
|
||||
return (
|
||||
<Badge intent={getEnforcementResultIntent(code)} size="xs">
|
||||
{getEnforcementResultLabel(code, tc, lang)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
], [tc, lang]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
@ -106,32 +122,31 @@ export function EnforcementHistory() {
|
||||
const DATA: Record[] = records as Record[];
|
||||
|
||||
return (
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-blue-400" />
|
||||
{t('history.title')}
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('history.desc')}</p>
|
||||
</div>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={FileText}
|
||||
iconColor="text-blue-400"
|
||||
title={t('history.title')}
|
||||
description={t('history.desc')}
|
||||
/>
|
||||
|
||||
{/* KPI 카드 */}
|
||||
{/* KPI 카드 — backend enum 코드(PUNISHED/REFERRED/FALSE_POSITIVE) 기반 비교 */}
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ 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="단속이력"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<AlertLevel, { bg: string; text: string }> = {
|
||||
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<string, string> = {
|
||||
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<EventRow>[] = [
|
||||
{
|
||||
key: 'level', label: '등급', width: '70px', sortable: true,
|
||||
render: (val) => {
|
||||
const lv = val as AlertLevel;
|
||||
const s = LEVEL_STYLES[lv];
|
||||
return <Badge className={`border-0 text-[9px] ${s?.bg ?? ''} ${s?.text ?? ''}`}>{lv}</Badge>;
|
||||
},
|
||||
},
|
||||
{ key: 'time', label: '발생시간', width: '140px', sortable: true,
|
||||
render: (val) => <span className="text-muted-foreground font-mono text-[10px]">{val as string}</span>,
|
||||
},
|
||||
{ key: 'type', label: '유형', width: '90px', sortable: true,
|
||||
render: (val) => <span className="text-heading font-medium">{val as string}</span>,
|
||||
},
|
||||
{ key: 'vesselName', label: '선박명', sortable: true,
|
||||
render: (val) => <span className="text-cyan-400 font-medium">{val as string}</span>,
|
||||
},
|
||||
{ key: 'mmsi', label: 'MMSI', width: '100px',
|
||||
render: (val) => <span className="text-hint font-mono text-[10px]">{val as string}</span>,
|
||||
},
|
||||
{ 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 <Badge className={`border-0 text-[9px] ${statusColor(s)}`}>{s}</Badge>;
|
||||
},
|
||||
},
|
||||
{ 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<EventRow>[] = useMemo(() => [
|
||||
{
|
||||
key: 'level', label: '등급', minWidth: '64px', maxWidth: '110px', sortable: true,
|
||||
render: (val) => {
|
||||
const lv = val as AlertLevel;
|
||||
return (
|
||||
<Badge intent={getAlertLevelIntent(lv)} size="sm">
|
||||
{getAlertLevelLabel(lv, tc, lang)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: 'time', label: '발생시간', minWidth: '140px', maxWidth: '170px', sortable: true,
|
||||
render: (val) => <span className="text-muted-foreground font-mono text-[10px]">{formatDateTime(val as string)}</span>,
|
||||
},
|
||||
{ key: 'type', label: '유형', minWidth: '90px', maxWidth: '160px', sortable: true,
|
||||
render: (val) => {
|
||||
const code = val as string;
|
||||
return (
|
||||
<Badge intent={getViolationIntent(code)} size="sm">
|
||||
{getViolationLabel(code, tc, lang)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: 'vesselName', label: '선박명', minWidth: '100px', maxWidth: '220px', sortable: true,
|
||||
render: (val) => <span className="text-cyan-400 font-medium">{val as string}</span>,
|
||||
},
|
||||
{ key: 'mmsi', label: 'MMSI', minWidth: '90px', maxWidth: '120px',
|
||||
render: (val) => <span className="text-hint font-mono text-[10px]">{val as string}</span>,
|
||||
},
|
||||
{ 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 (
|
||||
<Badge intent={getEventStatusIntent(s)} size="xs">
|
||||
{getEventStatusLabel(s, tc, lang)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: 'assignee', label: '담당', minWidth: '60px', maxWidth: '100px' },
|
||||
], [tc, lang]);
|
||||
|
||||
const [levelFilter, setLevelFilter] = useState<string>('');
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
|
||||
@ -137,45 +138,41 @@ export function EventList() {
|
||||
const kpiTotal = (stats['TOTAL'] as number | undefined) ?? EVENTS.length;
|
||||
|
||||
return (
|
||||
<div className="p-5 space-y-4">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<Radar className="w-5 h-5 text-blue-400" />
|
||||
{t('eventList.title')}
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">
|
||||
{t('eventList.desc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 등급 필터 */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Filter className="w-3.5 h-3.5 text-hint" />
|
||||
<select
|
||||
value={levelFilter}
|
||||
onChange={(e) => 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"
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Radar}
|
||||
iconColor="text-blue-400"
|
||||
title={t('eventList.title')}
|
||||
description={t('eventList.desc')}
|
||||
actions={
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<Filter className="w-3.5 h-3.5 text-hint" />
|
||||
<Select
|
||||
size="sm"
|
||||
value={levelFilter}
|
||||
onChange={(e) => setLevelFilter(e.target.value)}
|
||||
title="등급 필터"
|
||||
className="w-32"
|
||||
>
|
||||
<option value="">전체 등급</option>
|
||||
<option value="CRITICAL">CRITICAL</option>
|
||||
<option value="HIGH">HIGH</option>
|
||||
<option value="MEDIUM">MEDIUM</option>
|
||||
<option value="LOW">LOW</option>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowUpload(!showUpload)}
|
||||
icon={<Upload className="w-3 h-3" />}
|
||||
>
|
||||
<option value="">전체 등급</option>
|
||||
<option value="CRITICAL">CRITICAL</option>
|
||||
<option value="HIGH">HIGH</option>
|
||||
<option value="MEDIUM">MEDIUM</option>
|
||||
<option value="LOW">LOW</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<Upload className="w-3 h-3" />
|
||||
파일 업로드
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
파일 업로드
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* KPI 요약 */}
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
@ -250,6 +247,6 @@ export function EventList() {
|
||||
exportFilename="이벤트목록"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<AlertRow>[] = [
|
||||
width: '80px',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<Badge className="bg-blue-500/15 text-blue-400 border-0 text-[9px]">{v as string}</Badge>
|
||||
<Badge intent="info" size="sm">{v as string}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
@ -81,14 +82,10 @@ const cols: DataColumn<AlertRow>[] = [
|
||||
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 (
|
||||
<Badge className={`border-0 text-[9px] ${c}`}>{STATUS_LABEL[s] ?? s}</Badge>
|
||||
<Badge intent={intent} size="xs">{STATUS_LABEL[s] ?? s}</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
@ -140,34 +137,37 @@ export function AIAlert() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-5 flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span>알림 데이터 로딩 중...</span>
|
||||
</div>
|
||||
<PageContainer>
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span>알림 데이터 로딩 중...</span>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-5 flex items-center justify-center gap-2 text-red-400">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
<span>알림 조회 실패: {error}</span>
|
||||
<button type="button" onClick={fetchAlerts} className="ml-2 text-xs underline text-cyan-400">
|
||||
재시도
|
||||
</button>
|
||||
</div>
|
||||
<PageContainer>
|
||||
<div className="flex items-center justify-center gap-2 text-red-400 py-8">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
<span>알림 조회 실패: {error}</span>
|
||||
<button type="button" onClick={fetchAlerts} className="ml-2 text-xs underline text-cyan-400">
|
||||
재시도
|
||||
</button>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<Send className="w-5 h-5 text-yellow-400" />
|
||||
{t('aiAlert.title')}
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('aiAlert.desc')}</p>
|
||||
</div>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Send}
|
||||
iconColor="text-yellow-400"
|
||||
title={t('aiAlert.title')}
|
||||
description={t('aiAlert.desc')}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ l: '총 발송', v: totalElements, c: 'text-heading' },
|
||||
@ -191,6 +191,6 @@ export function AIAlert() {
|
||||
searchKeys={['channel', 'recipient']}
|
||||
exportFilename="AI알림이력"
|
||||
/>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Smartphone className="w-5 h-5 text-blue-400" />{t('mobileService.title')}</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('mobileService.desc')}</p>
|
||||
</div>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Smartphone}
|
||||
iconColor="text-blue-400"
|
||||
title={t('mobileService.title')}
|
||||
description={t('mobileService.desc')}
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{/* 모바일 프리뷰 */}
|
||||
<Card>
|
||||
@ -98,7 +102,7 @@ export function MobileService() {
|
||||
<div className="space-y-1">
|
||||
{ALERTS.slice(0, 2).map((a, i) => (
|
||||
<div key={i} className="bg-surface-overlay rounded p-1.5 flex items-center gap-1.5">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${a.level === 'CRITICAL' ? 'bg-red-500' : 'bg-orange-500'}`} />
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${ALERT_LEVELS[a.level as AlertLevel]?.classes.dot ?? 'bg-slate-500'}`} />
|
||||
<span className="text-[8px] text-label truncate">{a.title}</span>
|
||||
</div>
|
||||
))}
|
||||
@ -142,6 +146,6 @@ export function MobileService() {
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<Agent>[] = [
|
||||
{ key: 'id', label: 'Agent ID', width: '80px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'ship', label: '함정', sortable: true, render: v => <span className="text-cyan-400 font-medium">{v as string}</span> },
|
||||
{ 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 <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>; } },
|
||||
{ key: 'sync', label: '동기화', width: '90px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||
{ key: 'lastSync', label: '최종 동기화', width: '90px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'tasks', label: '작업 수', width: '60px', align: 'right', render: v => <span className="text-heading font-bold">{v as number}</span> },
|
||||
];
|
||||
|
||||
export function ShipAgent() {
|
||||
const { t } = useTranslation('fieldOps');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
|
||||
const cols: DataColumn<Agent>[] = useMemo(() => [
|
||||
{ key: 'id', label: 'Agent ID', width: '80px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'ship', label: '함정', sortable: true, render: v => <span className="text-cyan-400 font-medium">{v as string}</span> },
|
||||
{ key: 'version', label: '버전', width: '70px' },
|
||||
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
|
||||
render: v => {
|
||||
const s = v as string;
|
||||
return (
|
||||
<Badge intent={getDeviceStatusIntent(s)} size="sm">
|
||||
{getDeviceStatusLabel(s, tc, lang)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: 'sync', label: '동기화', width: '90px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||
{ key: 'lastSync', label: '최종 동기화', width: '90px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'tasks', label: '작업 수', width: '60px', align: 'right', render: v => <span className="text-heading font-bold">{v as number}</span> },
|
||||
], [tc, lang]);
|
||||
|
||||
return (
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<Monitor className="w-5 h-5 text-cyan-400" />{t('shipAgent.title')}
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
|
||||
<span>⚠</span><span>데모 데이터 (백엔드 API 미구현)</span>
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('shipAgent.desc')}</p>
|
||||
</div>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Monitor}
|
||||
iconColor="text-cyan-400"
|
||||
title={t('shipAgent.title')}
|
||||
description={t('shipAgent.desc')}
|
||||
demo
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
{[{ 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 => (
|
||||
<div key={k.l} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
||||
@ -47,6 +60,6 @@ export function ShipAgent() {
|
||||
))}
|
||||
</div>
|
||||
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="함정명, Agent ID 검색..." searchKeys={['ship', 'id']} exportFilename="함정Agent" />
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<string, { icon: LucideIcon; color: string }> = {
|
||||
'실시간 탐지': { 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<string, string> = {
|
||||
'EEZ 침범': '#ef4444', '다크베셀': '#f97316', 'MMSI 변조': '#eab308',
|
||||
'불법환적': '#a855f7', '어구 불법': '#6b7280',
|
||||
};
|
||||
const LV: Record<string, string> = { 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 (
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Activity className="w-5 h-5 text-green-400" />{t('monitoring.title')}</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('monitoring.desc')}</p>
|
||||
</div>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Activity}
|
||||
iconColor="text-green-400"
|
||||
title={t('monitoring.title')}
|
||||
description={t('monitoring.desc')}
|
||||
/>
|
||||
{/* iran 백엔드 + Prediction 시스템 상태 (실시간) */}
|
||||
<SystemStatusPanel />
|
||||
|
||||
@ -122,14 +118,16 @@ export function MonitoringDashboard() {
|
||||
<div className="space-y-2">
|
||||
{EVENTS.map((e, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
|
||||
<span className="text-[10px] text-hint font-mono w-10">{e.time}</span>
|
||||
<Badge className={`border-0 text-[9px] w-16 text-center ${LV[e.level]}`}>{e.level}</Badge>
|
||||
<span className="text-[10px] text-hint font-mono whitespace-nowrap shrink-0">{e.time}</span>
|
||||
<Badge intent={getAlertLevelIntent(e.level)} size="sm" className="min-w-[52px]">
|
||||
{getAlertLevelLabel(e.level, tc, lang)}
|
||||
</Badge>
|
||||
<span className="text-[11px] text-heading font-medium flex-1">{e.title}</span>
|
||||
<span className="text-[10px] text-hint">{e.detail}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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={<Database className="w-4 h-4" />}
|
||||
title="KCG AI Backend"
|
||||
status="UP"
|
||||
statusColor="text-green-400"
|
||||
statusIntent="success"
|
||||
details={[
|
||||
['포트', ':8080'],
|
||||
['프로파일', 'local'],
|
||||
@ -98,7 +99,7 @@ export function SystemStatusPanel() {
|
||||
icon={<Wifi className="w-4 h-4" />}
|
||||
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={<Activity className="w-4 h-4" />}
|
||||
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 }: {
|
||||
<span className="text-cyan-400">{icon}</span>
|
||||
{title}
|
||||
</div>
|
||||
<Badge className={`bg-transparent border ${statusColor.replace('text-', 'border-')} ${statusColor} text-[9px]`}>
|
||||
<Badge intent={statusIntent} size="xs">
|
||||
{status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@ -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<string, string> = {
|
||||
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 (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-heading">학습 세션</h1>
|
||||
<p className="text-xs text-hint mt-1">정답 라벨링 → prediction 모델 학습 데이터로 활용</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select value={filter} onChange={(e) => setFilter(e.target.value)}
|
||||
className="bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs text-heading">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="ACTIVE">ACTIVE</option>
|
||||
<option value="CANCELLED">CANCELLED</option>
|
||||
<option value="COMPLETED">COMPLETED</option>
|
||||
</select>
|
||||
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded">새로고침</button>
|
||||
</div>
|
||||
</div>
|
||||
<PageContainer size="lg">
|
||||
<PageHeader
|
||||
icon={Tag}
|
||||
iconColor="text-cyan-400"
|
||||
title="학습 세션"
|
||||
description="정답 라벨링 → prediction 모델 학습 데이터로 활용"
|
||||
actions={
|
||||
<>
|
||||
<Select
|
||||
size="sm"
|
||||
aria-label="상태 필터"
|
||||
title="상태 필터"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
>
|
||||
<option value="">전체 상태</option>
|
||||
<option value="ACTIVE">ACTIVE</option>
|
||||
<option value="CANCELLED">CANCELLED</option>
|
||||
<option value="COMPLETED">COMPLETED</option>
|
||||
</Select>
|
||||
<Button variant="primary" size="sm" onClick={load}>
|
||||
새로고침
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
@ -111,11 +122,11 @@ export function LabelSession() {
|
||||
{!canCreate && <span className="text-yellow-400 text-[10px]">권한 없음</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input value={groupKey} onChange={(e) => setGroupKey(e.target.value)} placeholder="group_key"
|
||||
<input aria-label="group_key" value={groupKey} onChange={(e) => setGroupKey(e.target.value)} placeholder="group_key"
|
||||
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} />
|
||||
<input type="number" value={subCluster} onChange={(e) => setSubCluster(e.target.value)} placeholder="sub"
|
||||
<input aria-label="sub_cluster_id" type="number" value={subCluster} onChange={(e) => setSubCluster(e.target.value)} placeholder="sub"
|
||||
className="w-24 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} />
|
||||
<input value={labelMmsi} onChange={(e) => setLabelMmsi(e.target.value)} placeholder="정답 parent MMSI"
|
||||
<input aria-label="정답 parent MMSI" value={labelMmsi} onChange={(e) => setLabelMmsi(e.target.value)} placeholder="정답 parent MMSI"
|
||||
className="w-48 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreate} />
|
||||
<button type="button" onClick={handleCreate}
|
||||
disabled={!canCreate || !groupKey || !labelMmsi || busy === -1}
|
||||
@ -162,7 +173,7 @@ export function LabelSession() {
|
||||
<td className="px-3 py-2 text-center text-muted-foreground">{it.subClusterId}</td>
|
||||
<td className="px-3 py-2 text-cyan-400 font-mono">{it.labelParentMmsi}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={`border-0 text-[9px] ${STATUS_COLORS[it.status] || ''}`}>{it.status}</Badge>
|
||||
<Badge intent={getLabelSessionIntent(it.status)} size="sm">{getLabelSessionLabel(it.status, tc, lang)}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{it.createdByAcnt || '-'}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-[10px]">{formatDateTime(it.activeFrom)}</td>
|
||||
@ -181,6 +192,6 @@ export function LabelSession() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-heading">모선 후보 제외</h1>
|
||||
<p className="text-xs text-hint mt-1">GROUP/GLOBAL 스코프로 잘못된 후보를 차단합니다.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as '' | 'GROUP' | 'GLOBAL')}
|
||||
className="bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs text-heading"
|
||||
>
|
||||
<option value="">전체 스코프</option>
|
||||
<option value="GROUP">GROUP</option>
|
||||
<option value="GLOBAL">GLOBAL</option>
|
||||
</select>
|
||||
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded">새로고침</button>
|
||||
</div>
|
||||
</div>
|
||||
<PageContainer size="lg">
|
||||
<PageHeader
|
||||
icon={Ban}
|
||||
iconColor="text-red-400"
|
||||
title="모선 후보 제외"
|
||||
description="GROUP/GLOBAL 스코프로 잘못된 후보를 차단합니다."
|
||||
actions={
|
||||
<>
|
||||
<Select
|
||||
size="sm"
|
||||
aria-label="스코프 필터"
|
||||
title="스코프 필터"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as '' | 'GROUP' | 'GLOBAL')}
|
||||
>
|
||||
<option value="">전체 스코프</option>
|
||||
<option value="GROUP">GROUP</option>
|
||||
<option value="GLOBAL">GLOBAL</option>
|
||||
</Select>
|
||||
<Button variant="primary" size="sm" onClick={load}>
|
||||
새로고침
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 신규 등록: GROUP */}
|
||||
<Card>
|
||||
@ -130,13 +139,13 @@ export function ParentExclusion() {
|
||||
{!canCreateGroup && <span className="text-yellow-400 text-[10px]">권한 없음</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input value={grpKey} onChange={(e) => setGrpKey(e.target.value)} placeholder="group_key"
|
||||
<input aria-label="group_key" value={grpKey} onChange={(e) => setGrpKey(e.target.value)} placeholder="group_key"
|
||||
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
|
||||
<input type="number" value={grpSub} onChange={(e) => setGrpSub(e.target.value)} placeholder="sub"
|
||||
<input aria-label="sub_cluster_id" type="number" value={grpSub} onChange={(e) => setGrpSub(e.target.value)} placeholder="sub"
|
||||
className="w-24 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
|
||||
<input value={grpMmsi} onChange={(e) => setGrpMmsi(e.target.value)} placeholder="excluded MMSI"
|
||||
<input aria-label="excluded MMSI" value={grpMmsi} onChange={(e) => setGrpMmsi(e.target.value)} placeholder="excluded MMSI"
|
||||
className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
|
||||
<input value={grpReason} onChange={(e) => setGrpReason(e.target.value)} placeholder="사유"
|
||||
<input aria-label="제외 사유" value={grpReason} onChange={(e) => setGrpReason(e.target.value)} placeholder="사유"
|
||||
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGroup} />
|
||||
<button type="button" onClick={handleAddGroup}
|
||||
disabled={!canCreateGroup || !grpKey || !grpMmsi || busy === -1}
|
||||
@ -156,9 +165,9 @@ export function ParentExclusion() {
|
||||
{!canCreateGlobal && <span className="text-yellow-400 text-[10px]">권한 없음</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input value={glbMmsi} onChange={(e) => setGlbMmsi(e.target.value)} placeholder="excluded MMSI"
|
||||
<input aria-label="excluded MMSI (전역)" value={glbMmsi} onChange={(e) => setGlbMmsi(e.target.value)} placeholder="excluded MMSI"
|
||||
className="w-40 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGlobal} />
|
||||
<input value={glbReason} onChange={(e) => setGlbReason(e.target.value)} placeholder="사유"
|
||||
<input aria-label="전역 제외 사유" value={glbReason} onChange={(e) => setGlbReason(e.target.value)} placeholder="사유"
|
||||
className="flex-1 bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs" disabled={!canCreateGlobal} />
|
||||
<button type="button" onClick={handleAddGlobal}
|
||||
disabled={!canCreateGlobal || !glbMmsi || busy === -2}
|
||||
@ -203,7 +212,7 @@ export function ParentExclusion() {
|
||||
<tr key={it.id} className="border-t border-border hover:bg-surface-overlay/50">
|
||||
<td className="px-3 py-2 text-hint font-mono">{it.id}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={`border-0 text-[9px] ${it.scopeType === 'GLOBAL' ? 'bg-red-500/20 text-red-400' : 'bg-orange-500/20 text-orange-400'}`}>
|
||||
<Badge intent={it.scopeType === 'GLOBAL' ? 'critical' : 'high'} size="xs">
|
||||
{it.scopeType}
|
||||
</Badge>
|
||||
</td>
|
||||
@ -226,6 +235,6 @@ export function ParentExclusion() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<ParentResolution[]>([]);
|
||||
@ -104,34 +100,26 @@ export function ParentReview() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-heading">모선 확정/거부</h1>
|
||||
<p className="text-xs text-hint mt-1">
|
||||
추론된 모선 후보를 확정/거부합니다. 권한: parent-inference-workflow:parent-review (UPDATE)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs text-heading"
|
||||
>
|
||||
<option value="">전체 상태</option>
|
||||
<option value="UNRESOLVED">미해결</option>
|
||||
<option value="MANUAL_CONFIRMED">확정됨</option>
|
||||
<option value="REVIEW_REQUIRED">검토필요</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={load}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded"
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<PageContainer size="lg">
|
||||
<PageHeader
|
||||
icon={GitMerge}
|
||||
iconColor="text-purple-400"
|
||||
title="모선 확정/거부"
|
||||
description="추론된 모선 후보를 확정/거부합니다. 권한: parent-inference-workflow:parent-review (UPDATE)"
|
||||
actions={
|
||||
<>
|
||||
<Select size="sm" title="상태 필터" value={filter} onChange={(e) => setFilter(e.target.value)}>
|
||||
<option value="">전체 상태</option>
|
||||
<option value="UNRESOLVED">미해결</option>
|
||||
<option value="MANUAL_CONFIRMED">확정됨</option>
|
||||
<option value="REVIEW_REQUIRED">검토필요</option>
|
||||
</Select>
|
||||
<Button variant="primary" size="sm" onClick={load}>
|
||||
새로고침
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 신규 등록 폼 (테스트용) */}
|
||||
{canUpdate && (
|
||||
@ -140,6 +128,7 @@ export function ParentReview() {
|
||||
<div className="text-xs text-muted-foreground mb-2">신규 모선 확정 등록 (테스트)</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
aria-label="group_key"
|
||||
type="text"
|
||||
value={newGroupKey}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<input
|
||||
aria-label="sub_cluster_id"
|
||||
type="number"
|
||||
value={newSubCluster}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<input
|
||||
aria-label="parent MMSI"
|
||||
type="text"
|
||||
value={newMmsi}
|
||||
onChange={(e) => setNewMmsi(e.target.value)}
|
||||
@ -228,8 +219,8 @@ export function ParentReview() {
|
||||
<td className="px-3 py-2 text-heading font-medium">{it.groupKey}</td>
|
||||
<td className="px-3 py-2 text-center text-muted-foreground">{it.subClusterId}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={`border-0 text-[9px] ${STATUS_COLORS[it.status] || ''}`}>
|
||||
{STATUS_LABELS[it.status] || it.status}
|
||||
<Badge intent={getParentResolutionIntent(it.status)} size="sm">
|
||||
{getParentResolutionLabel(it.status, tc, lang)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-cyan-400 font-mono">{it.selectedParentMmsi || '-'}</td>
|
||||
@ -274,6 +265,6 @@ export function ParentReview() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-purple-400" />{t('fleetOptimization.title')}
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
|
||||
<span>⚠</span>
|
||||
<span>데모 데이터 (백엔드 API 미구현)</span>
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('fleetOptimization.desc')}</p>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button onClick={() => 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"><Play className="w-3 h-3" />시뮬레이션</button>
|
||||
<button onClick={() => 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"><CheckCircle className="w-3 h-3" />최종 승인</button>
|
||||
</div>
|
||||
</div>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Users}
|
||||
iconColor="text-purple-400"
|
||||
title={t('fleetOptimization.title')}
|
||||
description={t('fleetOptimization.desc')}
|
||||
demo
|
||||
actions={
|
||||
<>
|
||||
<Button variant="primary" size="sm" onClick={() => setSimRunning(true)} icon={<Play className="w-3 h-3" />}>
|
||||
시뮬레이션
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setApproved(true)}
|
||||
disabled={!simRunning}
|
||||
icon={<CheckCircle className="w-3 h-3" />}
|
||||
className="bg-green-600 hover:bg-green-500 border-green-700"
|
||||
>
|
||||
최종 승인
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* KPI */}
|
||||
<div className="flex gap-2">
|
||||
@ -141,7 +151,7 @@ export function FleetOptimization() {
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: f.color }} />
|
||||
<span className="text-[11px] font-bold text-heading">{f.name}</span>
|
||||
</div>
|
||||
<Badge className={`border-0 text-[8px] ${f.status === '가용' ? 'bg-green-500/20 text-green-400' : f.status === '출동중' ? 'bg-yellow-500/20 text-yellow-400' : 'bg-muted text-muted-foreground'}`}>{f.status}</Badge>
|
||||
<Badge intent={f.status === '출동중' ? 'warning' : getStatusIntent(f.status)} size="xs">{f.status}</Badge>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-1 text-[9px] text-hint">
|
||||
<span>구역: {f.zone}</span><span>속력: {f.speed}</span><span>연료: {f.fuel}%</span>
|
||||
@ -218,6 +228,6 @@ export function FleetOptimization() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<Navigation className="w-5 h-5 text-cyan-400" />{t('patrolRoute.title')}
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
|
||||
<span>⚠</span>
|
||||
<span>데모 데이터 (백엔드 API 미구현)</span>
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('patrolRoute.desc')}</p>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button className="flex items-center gap-1 px-3 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-heading text-[10px] font-bold rounded-lg"><Play className="w-3 h-3" />경로 생성</button>
|
||||
<button 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"><Share2 className="w-3 h-3" />공유</button>
|
||||
</div>
|
||||
</div>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Navigation}
|
||||
iconColor="text-cyan-400"
|
||||
title={t('patrolRoute.title')}
|
||||
description={t('patrolRoute.desc')}
|
||||
demo
|
||||
actions={
|
||||
<>
|
||||
<Button variant="primary" size="sm" icon={<Play className="w-3 h-3" />}>
|
||||
경로 생성
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" icon={<Share2 className="w-3 h-3" />}>
|
||||
공유
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{/* 함정 선택 */}
|
||||
@ -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' : ''}`}>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[11px] font-bold text-heading">{s.name}</span>
|
||||
<Badge className={`border-0 text-[8px] ${s.status === '가용' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}`}>{s.status}</Badge>
|
||||
<Badge intent={s.status === '가용' ? 'success' : 'critical'} size="xs">{s.status}</Badge>
|
||||
</div>
|
||||
<div className="text-[9px] text-hint mt-0.5">{s.class} · {s.speed} · {s.range}</div>
|
||||
</div>
|
||||
@ -215,6 +218,6 @@ export function PatrolRoute() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<Plan>[] = [
|
||||
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'zone', label: '단속 구역', sortable: true, render: v => <span className="text-heading font-medium">{v as string}</span> },
|
||||
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
|
||||
render: v => { const n = v as number; return <Badge className={`border-0 text-[9px] ${n > 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}점</Badge>; } },
|
||||
render: v => { const n = v as number; return <Badge intent={getRiskIntent(n)} size="xs">{n}점</Badge>; } },
|
||||
{ key: 'period', label: '단속 시간', width: '160px', render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'ships', label: '참여 함정', render: v => <span className="text-cyan-400">{v as string}</span> },
|
||||
{ key: 'crew', label: '인력', width: '50px', align: 'right', render: v => <span className="text-heading font-bold">{v as number || '-'}</span> },
|
||||
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
|
||||
render: v => { const s = v as string; return <Badge className={`border-0 text-[9px] ${s === '확정' || s === 'CONFIRMED' ? 'bg-green-500/20 text-green-400' : s === '계획중' || s === 'PLANNED' ? 'bg-blue-500/20 text-blue-400' : 'bg-muted text-muted-foreground'}`}>{s}</Badge>; } },
|
||||
render: v => { const s = v as string; return <Badge intent={getStatusIntent(s)} size="xs">{s}</Badge>; } },
|
||||
{ key: 'alert', label: '경보', width: '80px', align: 'center',
|
||||
render: v => { const a = v as string; return a === '경보 발령' || a === 'ALERT' ? <Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]">{a}</Badge> : <span className="text-hint text-[10px]">{a}</span>; } },
|
||||
render: v => { const a = v as string; return a === '경보 발령' || a === 'ALERT' ? <Badge intent="critical" size="sm">{a}</Badge> : <span className="text-hint text-[10px]">{a}</span>; } },
|
||||
];
|
||||
|
||||
export function EnforcementPlan() {
|
||||
@ -118,14 +121,18 @@ export function EnforcementPlan() {
|
||||
const totalCrew = PLANS.reduce((sum, p) => sum + p.crew, 0);
|
||||
|
||||
return (
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Shield className="w-5 h-5 text-orange-400" />{t('enforcementPlan.title')}</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('enforcementPlan.desc')}</p>
|
||||
</div>
|
||||
<button className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-heading text-[11px] font-bold rounded-lg"><Plus className="w-3.5 h-3.5" />단속 계획 수립</button>
|
||||
</div>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Shield}
|
||||
iconColor="text-orange-400"
|
||||
title={t('enforcementPlan.title')}
|
||||
description={t('enforcementPlan.desc')}
|
||||
actions={
|
||||
<Button variant="primary" size="md" icon={<Plus className="w-3.5 h-3.5" />}>
|
||||
단속 계획 수립
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 로딩/에러 상태 */}
|
||||
{loading && (
|
||||
@ -153,7 +160,7 @@ export function EnforcementPlan() {
|
||||
<div className="flex gap-4 text-[10px]">
|
||||
{[['위험도 ≥ 80', '상황실 즉시 경보 (알림+SMS)'], ['위험도 ≥ 60', '관련 부서 주의 알림'], ['위험도 ≥ 40', '참고 로그 기록']].map(([k, v]) => (
|
||||
<div key={k} className="flex items-center gap-2 px-3 py-2 bg-surface-overlay rounded-lg flex-1">
|
||||
<Badge className="bg-red-500/20 text-red-400 border-0 text-[9px]">{k}</Badge>
|
||||
<Badge intent="critical" size="sm">{k}</Badge>
|
||||
<span className="text-muted-foreground">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
@ -185,6 +192,6 @@ export function EnforcementPlan() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading flex items-center gap-2">
|
||||
<Map className="w-5 h-5 text-red-400" />격자 기반 불법조업 위험도 지도
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Map}
|
||||
iconColor="text-red-400"
|
||||
title="격자 기반 불법조업 위험도 지도"
|
||||
description="SFR-05 | 위험도 히트맵 (수집 중) + MTIS 해양사고 통계 (중앙해양안전심판원)"
|
||||
actions={
|
||||
<>
|
||||
{COLLECTING_BADGE}
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">SFR-05 | 위험도 히트맵 (수집 중) + MTIS 해양사고 통계 (중앙해양안전심판원)</p>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button className="flex items-center gap-1 px-2.5 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading"><Printer className="w-3 h-3" />인쇄</button>
|
||||
<button className="flex items-center gap-1 px-2.5 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading"><Download className="w-3 h-3" />이미지</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" icon={<Printer className="w-3 h-3" />}>
|
||||
인쇄
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" icon={<Download className="w-3 h-3" />}>
|
||||
이미지
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 탭 */}
|
||||
<div className="flex gap-0 border-b border-border">
|
||||
@ -200,7 +206,7 @@ export function RiskMap() {
|
||||
{ key: 'timeStat' as Tab, icon: Clock, label: '시간적 특성별' },
|
||||
{ key: 'accRate' as Tab, icon: BarChart3, label: '사고율' },
|
||||
]).map(t => (
|
||||
<button key={t.key} onClick={() => setTab(t.key)}
|
||||
<button type="button" key={t.key} onClick={() => setTab(t.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-[11px] font-medium border-b-2 ${tab === t.key ? 'text-red-400 border-red-400' : 'text-hint border-transparent hover:text-label'}`}>
|
||||
<t.icon className="w-3.5 h-3.5" />{t.label}
|
||||
</button>
|
||||
@ -489,6 +495,6 @@ export function RiskMap() {
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<Service>[] = [
|
||||
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'name', label: '서비스명', sortable: true, render: v => <span className="text-heading font-medium">{v as string}</span> },
|
||||
{ key: 'target', label: '제공 대상', width: '80px', sortable: true },
|
||||
{ key: 'type', label: '방식', width: '50px', align: 'center', render: v => <Badge className="bg-cyan-500/20 text-cyan-400 border-0 text-[9px]">{v as string}</Badge> },
|
||||
{ key: 'type', label: '방식', width: '50px', align: 'center', render: v => <Badge intent="cyan" size="sm">{v as string}</Badge> },
|
||||
{ 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 <Badge className={`border-0 text-[9px] ${c}`}>{p}</Badge>; } },
|
||||
render: v => {
|
||||
const p = v as string;
|
||||
const intent: BadgeIntent = p === '비공개' ? 'critical' : p === '비식별' ? 'warning' : p === '익명화' ? 'info' : 'success';
|
||||
return <Badge intent={intent} size="xs">{p}</Badge>;
|
||||
} },
|
||||
{ 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 <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>; } },
|
||||
render: v => { const s = v as string; return <Badge intent={getStatusIntent(s)} size="xs">{s}</Badge>; } },
|
||||
{ key: 'calls', label: '호출 수', width: '70px', align: 'right', render: v => <span className="text-heading font-bold">{v as string}</span> },
|
||||
];
|
||||
|
||||
export function ExternalService() {
|
||||
const { t } = useTranslation('statistics');
|
||||
return (
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<Globe className="w-5 h-5 text-green-400" />{t('externalService.title')}
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
|
||||
<span>⚠</span><span>데모 데이터 (백엔드 API 미구현)</span>
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">{t('externalService.desc')}</p>
|
||||
</div>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Globe}
|
||||
iconColor="text-green-400"
|
||||
title={t('externalService.title')}
|
||||
description={t('externalService.desc')}
|
||||
demo
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
{[{ 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 => (
|
||||
<div key={k.l} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
||||
@ -49,6 +53,6 @@ export function ExternalService() {
|
||||
))}
|
||||
</div>
|
||||
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="서비스명, 대상기관 검색..." searchKeys={['name', 'target']} exportFilename="외부서비스연계" />
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
{t('reports.title')}
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
|
||||
<span>⚠</span><span>데모 데이터 (백엔드 API 미구현)</span>
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-xs text-hint mt-0.5">{t('reports.desc')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SearchInput value={search} onChange={setSearch} placeholder="보고서 검색..." className="w-48" />
|
||||
<ExcelExport
|
||||
data={reports as unknown as Record<string, unknown>[]}
|
||||
columns={[
|
||||
{ key: 'id', label: '보고서번호' }, { key: 'name', label: '선박명' },
|
||||
{ key: 'type', label: '유형' }, { key: 'status', label: '상태' },
|
||||
{ key: 'date', label: '일시' }, { key: 'evidence', label: '증거수' },
|
||||
]}
|
||||
filename="보고서목록"
|
||||
/>
|
||||
<PrintButton />
|
||||
<button
|
||||
onClick={() => 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"
|
||||
>
|
||||
<Upload className="w-3 h-3" />증거 업로드
|
||||
</button>
|
||||
<button className="flex items-center gap-2 bg-red-600 hover:bg-red-500 text-heading px-4 py-2 rounded-lg text-sm transition-colors">
|
||||
<Plus className="w-4 h-4" />새 보고서
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={FileText}
|
||||
iconColor="text-muted-foreground"
|
||||
title={t('reports.title')}
|
||||
description={t('reports.desc')}
|
||||
demo
|
||||
actions={
|
||||
<>
|
||||
<SearchInput value={search} onChange={setSearch} placeholder="보고서 검색..." className="w-48" />
|
||||
<ExcelExport
|
||||
data={reports as unknown as Record<string, unknown>[]}
|
||||
columns={[
|
||||
{ key: 'id', label: '보고서번호' }, { key: 'name', label: '선박명' },
|
||||
{ key: 'type', label: '유형' }, { key: 'status', label: '상태' },
|
||||
{ key: 'date', label: '일시' }, { key: 'evidence', label: '증거수' },
|
||||
]}
|
||||
filename="보고서목록"
|
||||
/>
|
||||
<PrintButton />
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowUpload(!showUpload)}
|
||||
icon={<Upload className="w-3 h-3" />}
|
||||
>
|
||||
증거 업로드
|
||||
</Button>
|
||||
<Button variant="destructive" size="md" icon={<Plus className="w-4 h-4" />}>
|
||||
새 보고서
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 증거 파일 업로드 */}
|
||||
{showUpload && (
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] text-label font-bold">증거 파일 업로드 (사진·영상·문서)</span>
|
||||
<button onClick={() => setShowUpload(false)} className="text-hint hover:text-muted-foreground"><X className="w-4 h-4" /></button>
|
||||
<button type="button" aria-label="업로드 패널 닫기" onClick={() => setShowUpload(false)} className="text-hint hover:text-muted-foreground"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<FileUpload accept=".jpg,.jpeg,.png,.mp4,.pdf,.hwp,.docx" multiple maxSizeMB={100} />
|
||||
<div className="flex justify-end mt-3">
|
||||
@ -104,7 +108,7 @@ export function ReportManagement() {
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-heading font-medium text-sm">{r.name}</span>
|
||||
<Badge className={`${r.statusColor} text-heading text-[10px]`}>{r.status}</Badge>
|
||||
<Badge intent={r.statusIntent} size="xs">{r.status}</Badge>
|
||||
</div>
|
||||
<div className="text-[11px] text-hint">{r.id}</div>
|
||||
<div className="flex items-center gap-2 text-[11px] text-hint mt-0.5">
|
||||
@ -116,8 +120,8 @@ export function ReportManagement() {
|
||||
</div>
|
||||
<div className="text-[11px] text-hint mt-1">증거 {r.evidence}건</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button className="bg-blue-600 text-heading text-[11px] px-3 py-1 rounded hover:bg-blue-500 transition-colors">PDF</button>
|
||||
<button className="bg-muted text-heading text-[11px] px-3 py-1 rounded hover:bg-muted transition-colors">한글</button>
|
||||
<button type="button" className="bg-blue-600 text-on-vivid text-[11px] px-3 py-1 rounded hover:bg-blue-500 transition-colors">PDF</button>
|
||||
<button type="button" className="bg-muted text-heading text-[11px] px-3 py-1 rounded hover:bg-muted transition-colors">한글</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -131,7 +135,7 @@ export function ReportManagement() {
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-sm text-label">보고서 미리보기</div>
|
||||
<button className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 text-heading px-3 py-1.5 rounded-lg text-xs transition-colors">
|
||||
<button type="button" className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid px-3 py-1.5 rounded-lg text-xs transition-colors">
|
||||
<Download className="w-3.5 h-3.5" />다운로드
|
||||
</button>
|
||||
</div>
|
||||
@ -186,6 +190,6 @@ export function ReportManagement() {
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<KpiRow>[] = [
|
||||
width: '60px',
|
||||
align: 'center',
|
||||
render: (v) => (
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0 text-[9px]">
|
||||
<Badge intent="success" size="sm">
|
||||
{v as string}
|
||||
</Badge>
|
||||
),
|
||||
@ -69,6 +73,8 @@ const kpiCols: DataColumn<KpiRow>[] = [
|
||||
|
||||
export function Statistics() {
|
||||
const { t } = useTranslation('statistics');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
|
||||
const [monthly, setMonthly] = useState<MonthlyTrend[]>([]);
|
||||
const [violationTypes, setViolationTypes] = useState<ViolationType[]>([]);
|
||||
@ -136,22 +142,18 @@ export function Statistics() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-purple-400" />
|
||||
{t('statistics.title')}
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">
|
||||
{t('statistics.desc')}
|
||||
</p>
|
||||
</div>
|
||||
<button 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">
|
||||
<Download className="w-3 h-3" />
|
||||
보고서 생성
|
||||
</button>
|
||||
</div>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={BarChart3}
|
||||
iconColor="text-purple-400"
|
||||
title={t('statistics.title')}
|
||||
description={t('statistics.desc')}
|
||||
actions={
|
||||
<Button variant="secondary" size="sm" icon={<Download className="w-3 h-3" />}>
|
||||
보고서 생성
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{loading && (
|
||||
<div className="text-center py-10 text-muted-foreground text-sm">
|
||||
@ -205,20 +207,25 @@ export function Statistics() {
|
||||
위반 유형별 분포
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{BY_TYPE.map((item) => (
|
||||
<div
|
||||
key={item.type}
|
||||
className="flex-1 text-center px-3 py-3 bg-surface-overlay rounded-lg"
|
||||
>
|
||||
<div className="text-lg font-bold text-heading">
|
||||
{item.count}
|
||||
{BY_TYPE.map((item) => {
|
||||
const color = getViolationColor(item.type);
|
||||
const label = getViolationLabel(item.type, tc, lang);
|
||||
return (
|
||||
<div
|
||||
key={item.type}
|
||||
className="flex-1 text-center px-3 py-3 bg-surface-overlay rounded-lg border-l-4"
|
||||
style={{ borderLeftColor: color }}
|
||||
>
|
||||
<div className="text-lg font-bold tabular-nums" style={{ color }}>
|
||||
{item.count}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-[9px] text-hint">{item.pct}%</div>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{item.type}
|
||||
</div>
|
||||
<div className="text-[9px] text-hint">{item.pct}%</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -233,6 +240,6 @@ export function Statistics() {
|
||||
searchPlaceholder="지표명 검색..."
|
||||
exportFilename="성과지표"
|
||||
/>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<string, string> = {
|
||||
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 (
|
||||
<div className="flex gap-5 h-[calc(100vh-7rem)]">
|
||||
<PageContainer fullBleed className="flex gap-5 h-[calc(100vh-7rem)]">
|
||||
{/* 좌측: 이벤트 목록 + 지도 */}
|
||||
<div className="flex-1 flex gap-4 min-w-0">
|
||||
{/* 이벤트 카드 목록 */}
|
||||
@ -294,7 +289,7 @@ export function LiveMapView() {
|
||||
|
||||
{/* 지도 영역 */}
|
||||
<div className="flex-1 relative rounded-xl overflow-hidden">
|
||||
<BaseMap ref={mapRef} center={[36.8, 125.3]} zoom={8} height="100%" style={{ minHeight: 400 }} onClick={handleMapClick} onMapReady={handleMapReady} />
|
||||
<BaseMap ref={mapRef} center={[36.8, 125.3]} zoom={8} height="100%" className="min-h-[400px]" onClick={handleMapClick} onMapReady={handleMapReady} />
|
||||
{/* 범례 */}
|
||||
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
|
||||
<div className="text-[9px] text-muted-foreground font-bold mb-1">선박 범례</div>
|
||||
@ -367,7 +362,7 @@ export function LiveMapView() {
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Zap className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-sm text-heading font-medium">AI 판단 근거</span>
|
||||
<Badge className="bg-red-500/20 text-red-400 text-[10px]">신뢰도: High</Badge>
|
||||
<Badge intent="critical" size="md">신뢰도: High</Badge>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="border-l-2 border-red-500 pl-3">
|
||||
@ -397,6 +392,6 @@ export function LiveMapView() {
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<NtmRecord>[] = [
|
||||
{ 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 <Badge className={`border-0 text-[9px] ${color}`}>{c}</Badge>;
|
||||
const intent: BadgeIntent = c.includes('사격') || c.includes('군사') ? 'critical'
|
||||
: c.includes('기뢰') ? 'high'
|
||||
: c.includes('오염') ? 'warning'
|
||||
: 'info';
|
||||
return <Badge intent={intent} size="xs">{c}</Badge>;
|
||||
},
|
||||
},
|
||||
{ key: 'sea', label: '해역', width: '50px', sortable: true },
|
||||
{ key: 'title', label: '제목', sortable: true, render: v => <span className="text-heading font-medium">{v as string}</span> },
|
||||
{ key: 'position', label: '위치', width: '120px', render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
|
||||
render: v => <Badge className={`border-0 text-[9px] ${v === '발령중' ? 'bg-red-500/20 text-red-400' : 'bg-muted text-muted-foreground'}`}>{v as string}</Badge> },
|
||||
render: v => <Badge intent={v === '발령중' ? 'critical' : 'muted'} size="xs">{v as string}</Badge> },
|
||||
];
|
||||
|
||||
// ─── 범례 색상 ──────────────────────────
|
||||
|
||||
const TYPE_COLORS: Record<string, { bg: string; text: string; label: string; mapColor: string }> = {
|
||||
'해군': { 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<TrainingZone>[] = [
|
||||
{ key: 'id', label: '구역번호', width: '80px', sortable: true, render: v => <span className="text-cyan-400 font-mono font-bold">{v as string}</span> },
|
||||
{ key: 'type', label: '구분', width: '60px', align: 'center', sortable: true,
|
||||
render: v => { const t = TYPE_COLORS[v as string]; return <Badge className={`border-0 text-[9px] ${t?.bg} ${t?.text}`}>{v as string}</Badge>; } },
|
||||
render: v => <Badge intent={getTrainingZoneIntent(v as string)} size="sm">{v as string}</Badge> },
|
||||
{ key: 'sea', label: '해역', width: '60px', sortable: true },
|
||||
{ key: 'lat', label: '위도', width: '110px', render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'lng', label: '경도', width: '110px', render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'radius', label: '반경', width: '60px', align: 'center' },
|
||||
{ key: 'status', label: '상태', width: '60px', align: 'center', sortable: true,
|
||||
render: v => <Badge className={`border-0 text-[9px] ${v === '활성' ? 'bg-green-500/20 text-green-400' : 'bg-muted text-muted-foreground'}`}>{v as string}</Badge> },
|
||||
render: v => <Badge intent={v === '활성' ? 'success' : 'muted'} size="xs">{v as string}</Badge> },
|
||||
{ key: 'schedule', label: '운용', width: '60px', align: 'center' },
|
||||
{ key: 'note', label: '비고', render: v => <span className="text-hint">{v as string}</span> },
|
||||
];
|
||||
@ -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 (
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-heading flex items-center gap-2">
|
||||
<Map className="w-5 h-5 text-cyan-400" />해역 통제
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
|
||||
<span>⚠</span><span>데모 데이터 (백엔드 API 미구현)</span>
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-[10px] text-hint mt-0.5">한국연안 해상사격 훈련구역도 No.462 | Chart of Firing and Bombing Exercise Areas | WGS-84 | 출처: 국립해양조사원</p>
|
||||
</div>
|
||||
</div>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Map}
|
||||
iconColor="text-cyan-400"
|
||||
title="해역 통제"
|
||||
description="한국연안 해상사격 훈련구역도 No.462 | Chart of Firing and Bombing Exercise Areas | WGS-84 | 출처: 국립해양조사원"
|
||||
demo
|
||||
/>
|
||||
|
||||
{/* KPI */}
|
||||
<div className="flex gap-2">
|
||||
@ -285,12 +276,16 @@ export function MapControl() {
|
||||
{/* 범례 */}
|
||||
<div className="flex items-center gap-4 px-4 py-2 rounded-xl border border-border bg-card">
|
||||
<span className="text-[10px] text-hint font-bold">범례:</span>
|
||||
{Object.entries(TYPE_COLORS).map(([type, c]) => (
|
||||
<div key={type} className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-3 rounded-sm" style={{ backgroundColor: c.mapColor, opacity: 0.6 }} />
|
||||
<span className="text-[10px] text-muted-foreground">{c.label}</span>
|
||||
</div>
|
||||
))}
|
||||
{(['해군', '공군', '육군', '국과연', '해경'] as const).map((type) => {
|
||||
const meta = getTrainingZoneMeta(type);
|
||||
if (!meta) return null;
|
||||
return (
|
||||
<div key={type} className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-3 rounded-sm" style={{ backgroundColor: meta.hex, opacity: 0.6 }} />
|
||||
<span className="text-[10px] text-muted-foreground">{meta.fallback.ko}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 탭 + 해역 필터 */}
|
||||
@ -305,7 +300,7 @@ export function MapControl() {
|
||||
{ key: 'kcg' as Tab, label: '해경', icon: Anchor },
|
||||
{ key: 'ntm' as Tab, label: '항행통보', icon: Bell },
|
||||
]).map(t => (
|
||||
<button key={t.key} onClick={() => setTab(t.key)}
|
||||
<button type="button" key={t.key} onClick={() => setTab(t.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-[11px] font-medium border-b-2 ${tab === t.key ? 'text-cyan-400 border-cyan-400' : 'text-hint border-transparent hover:text-label'}`}>
|
||||
<t.icon className="w-3.5 h-3.5" />{t.label}
|
||||
</button>
|
||||
@ -314,8 +309,8 @@ export function MapControl() {
|
||||
<div className="flex items-center gap-1.5 ml-auto">
|
||||
<Filter className="w-3.5 h-3.5 text-hint" />
|
||||
{['', '서해', '남해', '동해', '제주'].map(s => (
|
||||
<button key={s} onClick={() => 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'}`}>
|
||||
<button type="button" key={s} onClick={() => setSeaFilter(s)}
|
||||
className={`px-2.5 py-1 rounded text-[10px] ${seaFilter === s ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>
|
||||
{s || '전체'}
|
||||
</button>
|
||||
))}
|
||||
@ -345,8 +340,8 @@ export function MapControl() {
|
||||
<Filter className="w-3.5 h-3.5 text-hint" />
|
||||
<span className="text-[10px] text-hint">구분:</span>
|
||||
{NTM_CATEGORIES.map(c => (
|
||||
<button key={c} onClick={() => 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}</button>
|
||||
<button type="button" key={c} onClick={() => setNtmCatFilter(c === '전체' ? '' : c)}
|
||||
className={`px-2.5 py-1 rounded text-[10px] ${(c === '전체' && !ntmCatFilter) || ntmCatFilter === c ? 'bg-cyan-600 text-on-vivid font-bold' : 'text-hint hover:bg-surface-overlay'}`}>{c}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -358,7 +353,7 @@ export function MapControl() {
|
||||
<div className="space-y-2">
|
||||
{NTM_DATA.filter(n => n.status === '발령중').map(n => (
|
||||
<div key={n.no} className="flex items-start gap-3 px-3 py-2.5 bg-red-500/5 border border-red-500/10 rounded-lg">
|
||||
<Badge className="bg-red-500/20 text-red-400 border-0 text-[9px] shrink-0 mt-0.5">{n.category}</Badge>
|
||||
<Badge intent="critical" size="sm" className="shrink-0 mt-0.5">{n.category}</Badge>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[11px] text-heading font-medium">{n.title}</div>
|
||||
<div className="text-[10px] text-hint mt-0.5">{n.detail}</div>
|
||||
@ -397,12 +392,16 @@ export function MapControl() {
|
||||
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
|
||||
<div className="text-[9px] text-muted-foreground font-bold mb-1.5">훈련구역 범례</div>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(TYPE_COLORS).map(([type, c]) => (
|
||||
<div key={type} className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full border" style={{ backgroundColor: c.mapColor, borderColor: c.mapColor, opacity: 0.7 }} />
|
||||
<span className="text-[9px] text-muted-foreground">{c.label}</span>
|
||||
</div>
|
||||
))}
|
||||
{(['해군', '공군', '육군', '국과연', '해경'] as const).map((type) => {
|
||||
const meta = getTrainingZoneMeta(type);
|
||||
if (!meta) return null;
|
||||
return (
|
||||
<div key={type} className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full border" style={{ backgroundColor: meta.hex, borderColor: meta.hex, opacity: 0.7 }} />
|
||||
<span className="text-[9px] text-muted-foreground">{meta.fallback.ko}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border">
|
||||
<div className="flex items-center gap-1"><div className="w-4 h-0 border-t-2 border-dashed border-red-500/50" /><span className="text-[8px] text-hint">EEZ</span></div>
|
||||
@ -427,6 +426,6 @@ export function MapControl() {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-heading">환적·접촉 탐지</h2>
|
||||
<p className="text-xs text-hint mt-0.5">선박 간 근접 접촉 및 환적 의심 행위 분석</p>
|
||||
</div>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={RefreshCw}
|
||||
iconColor="text-cyan-400"
|
||||
title="환적·접촉 탐지"
|
||||
description="선박 간 근접 접촉 및 환적 의심 행위 분석"
|
||||
/>
|
||||
|
||||
{/* prediction 분석 결과 기반 실시간 환적 의심 선박 */}
|
||||
<RealTransshipSuspects />
|
||||
@ -32,6 +36,6 @@ export function TransferDetection() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<VesselPermitData | null>
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 위험도 레벨 → 색상 매핑 ──────────────
|
||||
const RISK_LEVEL_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
|
||||
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 (
|
||||
<div className="flex h-[calc(100vh-7.5rem)] gap-0 -m-4">
|
||||
<PageContainer fullBleed className="flex h-[calc(100vh-7.5rem)] gap-0">
|
||||
|
||||
{/* ── 좌측: 선박 정보 패널 ── */}
|
||||
<div className="w-[370px] shrink-0 bg-card border-r border-border flex flex-col overflow-hidden">
|
||||
@ -167,20 +167,20 @@ export function VesselDetail() {
|
||||
<h2 className="text-sm font-bold text-heading">선박 상세 조회</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[9px] text-hint w-14 shrink-0">시작/종료</span>
|
||||
<input value={startDate} onChange={(e) => setStartDate(e.target.value)}
|
||||
<input aria-label="조회 시작 시각" value={startDate} onChange={(e) => setStartDate(e.target.value)}
|
||||
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50"
|
||||
placeholder="YYYY-MM-DD HH:mm" />
|
||||
<span className="text-hint text-[10px]">~</span>
|
||||
<input value={endDate} onChange={(e) => setEndDate(e.target.value)}
|
||||
<input aria-label="조회 종료 시각" value={endDate} onChange={(e) => setEndDate(e.target.value)}
|
||||
className="flex-1 bg-surface-overlay border border-slate-700/50 rounded px-2 py-1 text-[10px] text-label focus:outline-none focus:border-blue-500/50"
|
||||
placeholder="YYYY-MM-DD HH:mm" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[9px] text-hint w-14 shrink-0">MMSI</span>
|
||||
<input value={searchMmsi} onChange={(e) => setSearchMmsi(e.target.value)}
|
||||
<input aria-label="MMSI" value={searchMmsi} onChange={(e) => 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" />
|
||||
<button className="flex items-center gap-1.5 bg-secondary border border-slate-700/50 rounded px-3 py-1 text-[10px] text-label hover:bg-switch-background transition-colors">
|
||||
<button type="button" className="flex items-center gap-1.5 bg-secondary border border-slate-700/50 rounded px-3 py-1 text-[10px] text-label hover:bg-switch-background transition-colors">
|
||||
검색 <Search className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
@ -280,12 +280,12 @@ export function VesselDetail() {
|
||||
<div className="mb-3 p-2 bg-surface-overlay rounded border border-slate-700/20">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] text-hint">위험도</span>
|
||||
<Badge className={`border-0 text-[9px] ${riskConfig.bg} ${riskConfig.color}`}>
|
||||
{riskConfig.label}
|
||||
<Badge intent={riskMeta.intent} size="sm">
|
||||
{getAlertLevelLabel(riskLevel, tc, lang)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1 mb-1">
|
||||
<span className={`text-xl font-bold ${riskConfig.color}`}>
|
||||
<span className={`text-xl font-bold ${riskMeta.classes.text}`}>
|
||||
{Math.round(riskScore * 100)}
|
||||
</span>
|
||||
<span className="text-[10px] text-hint">/100</span>
|
||||
@ -341,15 +341,14 @@ export function VesselDetail() {
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{events.map((evt) => {
|
||||
const lvl = RISK_LEVEL_CONFIG[evt.level] ?? RISK_LEVEL_CONFIG.LOW;
|
||||
return (
|
||||
<div key={evt.id} className="bg-surface-overlay rounded border border-slate-700/20 px-2.5 py-2">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<Badge className={`border-0 text-[8px] px-1.5 py-0 ${lvl.bg} ${lvl.color}`}>
|
||||
{evt.level}
|
||||
<Badge intent={getAlertLevelIntent(evt.level)} size="xs">
|
||||
{getAlertLevelLabel(evt.level, tc, lang)}
|
||||
</Badge>
|
||||
<span className="text-[10px] text-heading font-medium flex-1 truncate">{evt.title}</span>
|
||||
<Badge className="border-0 text-[8px] bg-muted text-muted-foreground px-1.5 py-0">
|
||||
<Badge intent="muted" size="xs" className="px-1.5 py-0">
|
||||
{evt.status}
|
||||
</Badge>
|
||||
</div>
|
||||
@ -378,8 +377,8 @@ export function VesselDetail() {
|
||||
<Ship className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-[11px] font-bold text-heading">MMSI: {mmsiParam}</span>
|
||||
{vessel && (
|
||||
<Badge className={`border-0 text-[9px] ${riskConfig.bg} ${riskConfig.color}`}>
|
||||
위험도: {riskConfig.label}
|
||||
<Badge intent={riskMeta.intent} size="sm">
|
||||
위험도: {getAlertLevelLabel(riskLevel, tc, lang)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@ -415,20 +414,20 @@ export function VesselDetail() {
|
||||
{/* ── 우측 도구바 ── */}
|
||||
<div className="w-10 bg-background border-l border-border flex flex-col items-center py-2 gap-0.5 shrink-0">
|
||||
{RIGHT_TOOLS.map((t) => (
|
||||
<button key={t.label} className="flex flex-col items-center gap-0.5 py-1.5 px-1 rounded hover:bg-surface-overlay text-hint hover:text-label transition-colors" title={t.label}>
|
||||
<button type="button" key={t.label} className="flex flex-col items-center gap-0.5 py-1.5 px-1 rounded hover:bg-surface-overlay text-hint hover:text-label transition-colors" title={t.label}>
|
||||
<t.icon className="w-3.5 h-3.5" /><span className="text-[6px]">{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<div className="flex flex-col border border-border rounded-lg overflow-hidden">
|
||||
<button className="p-1 hover:bg-secondary text-hint hover:text-heading text-sm font-bold">+</button>
|
||||
<button type="button" className="p-1 hover:bg-secondary text-hint hover:text-heading text-sm font-bold">+</button>
|
||||
<div className="h-px bg-white/[0.06]" />
|
||||
<button className="p-1 hover:bg-secondary text-hint hover:text-heading text-sm font-bold">-</button>
|
||||
<button type="button" className="p-1 hover:bg-secondary text-hint hover:text-heading text-sm font-bold">-</button>
|
||||
</div>
|
||||
<button className="mt-1 flex flex-col items-center py-1 text-hint hover:text-label"><LayoutGrid className="w-3.5 h-3.5" /><span className="text-[6px]">범례</span></button>
|
||||
<button className="flex flex-col items-center py-1 text-hint hover:text-label"><div className="w-3.5 h-3.5 border border-slate-500 rounded-sm" /><span className="text-[6px]">미니맵</span></button>
|
||||
<button className="flex flex-col items-center py-1 bg-blue-600/20 text-blue-400 rounded"><Radar className="w-3.5 h-3.5" /><span className="text-[6px]">AI모드</span></button>
|
||||
<button type="button" className="mt-1 flex flex-col items-center py-1 text-hint hover:text-label"><LayoutGrid className="w-3.5 h-3.5" /><span className="text-[6px]">범례</span></button>
|
||||
<button type="button" className="flex flex-col items-center py-1 text-hint hover:text-label"><div className="w-3.5 h-3.5 border border-slate-500 rounded-sm" /><span className="text-[6px]">미니맵</span></button>
|
||||
<button type="button" className="flex flex-col items-center py-1 bg-blue-600/20 text-blue-400 rounded"><Radar className="w-3.5 h-3.5" /><span className="text-[6px]">AI모드</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@ -74,7 +74,7 @@ export function FilterBar({ filter, onChange, groupBy, onGroupByChange, meta }:
|
||||
v{meta.version} · {meta.releaseDate} · 노드 {meta.nodeCount} · 엣지 {meta.edgeCount}
|
||||
</div>
|
||||
<div className="sf-spacer" />
|
||||
<input
|
||||
<input aria-label="검색 (label, file, symbol, tag)"
|
||||
type="text"
|
||||
placeholder="검색 (label, file, symbol, tag)"
|
||||
value={filter.search}
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
"patrolRoute": "Patrol Route",
|
||||
"fleetOptimization": "Fleet Optimize",
|
||||
"enforcementHistory": "History",
|
||||
"realtimeEvent": "Realtime Events",
|
||||
"eventList": "Event List",
|
||||
"mobileService": "Mobile",
|
||||
"shipAgent": "Ship Agent",
|
||||
@ -48,6 +49,133 @@
|
||||
"medium": "Medium",
|
||||
"low": "Low"
|
||||
},
|
||||
"violation": {
|
||||
"eezViolation": "EEZ Violation",
|
||||
"darkVessel": "Dark Vessel",
|
||||
"mmsiTampering": "MMSI Tampering",
|
||||
"illegalTransship": "Illegal Transship",
|
||||
"illegalGear": "Illegal Gear",
|
||||
"riskBehavior": "Risk Behavior"
|
||||
},
|
||||
"eventStatus": {
|
||||
"NEW": "New",
|
||||
"ACK": "Acknowledged",
|
||||
"IN_PROGRESS": "In Progress",
|
||||
"RESOLVED": "Resolved",
|
||||
"FALSE_POSITIVE": "False Positive"
|
||||
},
|
||||
"enforcementAction": {
|
||||
"CAPTURE": "Capture",
|
||||
"INSPECT": "Inspect",
|
||||
"WARN": "Warn",
|
||||
"DISPERSE": "Disperse",
|
||||
"TRACK": "Track",
|
||||
"EVIDENCE": "Evidence"
|
||||
},
|
||||
"enforcementResult": {
|
||||
"PUNISHED": "Punished",
|
||||
"REFERRED": "Referred",
|
||||
"WARNED": "Warned",
|
||||
"RELEASED": "Released",
|
||||
"FALSE_POSITIVE": "False Positive"
|
||||
},
|
||||
"patrolStatus": {
|
||||
"AVAILABLE": "Available",
|
||||
"ON_PATROL": "On Patrol",
|
||||
"IN_PURSUIT": "In Pursuit",
|
||||
"INSPECTING": "Inspecting",
|
||||
"RETURNING": "Returning",
|
||||
"STANDBY": "Standby",
|
||||
"MAINTENANCE": "Maintenance"
|
||||
},
|
||||
"engineSeverity": {
|
||||
"CRITICAL": "Critical",
|
||||
"HIGH_CRITICAL": "High~Critical",
|
||||
"HIGH": "High",
|
||||
"MEDIUM_CRITICAL": "Medium~Critical",
|
||||
"MEDIUM": "Medium",
|
||||
"LOW": "Low",
|
||||
"NONE": "-"
|
||||
},
|
||||
"deviceStatus": {
|
||||
"ONLINE": "Online",
|
||||
"OFFLINE": "Offline",
|
||||
"SYNCING": "Syncing",
|
||||
"NOT_DEPLOYED": "Not Deployed"
|
||||
},
|
||||
"parentResolution": {
|
||||
"UNRESOLVED": "Unresolved",
|
||||
"REVIEW_REQUIRED": "Review Required",
|
||||
"MANUAL_CONFIRMED": "Manually Confirmed"
|
||||
},
|
||||
"labelSession": {
|
||||
"ACTIVE": "Active",
|
||||
"COMPLETED": "Completed",
|
||||
"CANCELLED": "Cancelled"
|
||||
},
|
||||
"modelStatus": {
|
||||
"DEPLOYED": "Deployed",
|
||||
"APPROVED": "Approved",
|
||||
"TESTING": "Testing",
|
||||
"DRAFT": "Draft"
|
||||
},
|
||||
"gearGroupType": {
|
||||
"FLEET": "Fleet",
|
||||
"GEAR_IN_ZONE": "In-Zone Gear",
|
||||
"GEAR_OUT_ZONE": "Out-Zone Gear"
|
||||
},
|
||||
"darkPattern": {
|
||||
"AIS_FULL_BLOCK": "AIS Full Block",
|
||||
"MMSI_SPOOFING": "MMSI Spoofing",
|
||||
"LONG_LOSS": "Long Loss",
|
||||
"INTERMITTENT": "Intermittent",
|
||||
"SPEED_ANOMALY": "Speed Anomaly"
|
||||
},
|
||||
"userAccountStatus": {
|
||||
"ACTIVE": "Active",
|
||||
"PENDING": "Pending",
|
||||
"LOCKED": "Locked",
|
||||
"INACTIVE": "Inactive"
|
||||
},
|
||||
"loginResult": {
|
||||
"SUCCESS": "Success",
|
||||
"FAILED": "Failed",
|
||||
"LOCKED": "Locked"
|
||||
},
|
||||
"permitStatus": {
|
||||
"VALID": "Valid",
|
||||
"PENDING": "Pending",
|
||||
"EXPIRED": "Expired",
|
||||
"UNLICENSED": "Unlicensed"
|
||||
},
|
||||
"gearJudgment": {
|
||||
"NORMAL": "Normal",
|
||||
"CHECKING": "Checking",
|
||||
"SUSPECT_ILLEGAL": "Suspect Illegal"
|
||||
},
|
||||
"vesselSurveillance": {
|
||||
"TRACKING": "Tracking",
|
||||
"WATCHING": "Watching",
|
||||
"CHECKING": "Checking",
|
||||
"NORMAL": "Normal"
|
||||
},
|
||||
"vesselRing": {
|
||||
"SAFE": "Safe",
|
||||
"SUSPECT": "Suspect",
|
||||
"WARNING": "Warning"
|
||||
},
|
||||
"connectionStatus": {
|
||||
"OK": "OK",
|
||||
"WARNING": "Warning",
|
||||
"ERROR": "Error"
|
||||
},
|
||||
"trainingZone": {
|
||||
"NAVY": "Navy Zone",
|
||||
"AIRFORCE": "Airforce Zone",
|
||||
"ARMY": "Army Zone",
|
||||
"ADD": "ADD",
|
||||
"KCG": "KCG"
|
||||
},
|
||||
"action": {
|
||||
"search": "Search",
|
||||
"save": "Save",
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
"patrolRoute": "순찰경로 추천",
|
||||
"fleetOptimization": "다함정 최적화",
|
||||
"enforcementHistory": "단속 이력",
|
||||
"realtimeEvent": "실시간 이벤트",
|
||||
"eventList": "이벤트 목록",
|
||||
"mobileService": "모바일 서비스",
|
||||
"shipAgent": "함정 Agent",
|
||||
@ -48,6 +49,133 @@
|
||||
"medium": "보통",
|
||||
"low": "낮음"
|
||||
},
|
||||
"violation": {
|
||||
"eezViolation": "EEZ 침범",
|
||||
"darkVessel": "다크베셀",
|
||||
"mmsiTampering": "MMSI 변조",
|
||||
"illegalTransship": "불법 환적",
|
||||
"illegalGear": "불법 어구",
|
||||
"riskBehavior": "위험 행동"
|
||||
},
|
||||
"eventStatus": {
|
||||
"NEW": "신규",
|
||||
"ACK": "확인",
|
||||
"IN_PROGRESS": "처리중",
|
||||
"RESOLVED": "완료",
|
||||
"FALSE_POSITIVE": "오탐"
|
||||
},
|
||||
"enforcementAction": {
|
||||
"CAPTURE": "나포",
|
||||
"INSPECT": "검문",
|
||||
"WARN": "경고",
|
||||
"DISPERSE": "퇴거",
|
||||
"TRACK": "추적",
|
||||
"EVIDENCE": "증거수집"
|
||||
},
|
||||
"enforcementResult": {
|
||||
"PUNISHED": "처벌",
|
||||
"REFERRED": "수사의뢰",
|
||||
"WARNED": "경고",
|
||||
"RELEASED": "훈방",
|
||||
"FALSE_POSITIVE": "오탐(정상)"
|
||||
},
|
||||
"patrolStatus": {
|
||||
"AVAILABLE": "가용",
|
||||
"ON_PATROL": "초계중",
|
||||
"IN_PURSUIT": "추적중",
|
||||
"INSPECTING": "검문중",
|
||||
"RETURNING": "귀항중",
|
||||
"STANDBY": "대기",
|
||||
"MAINTENANCE": "정비중"
|
||||
},
|
||||
"engineSeverity": {
|
||||
"CRITICAL": "심각",
|
||||
"HIGH_CRITICAL": "높음~심각",
|
||||
"HIGH": "높음",
|
||||
"MEDIUM_CRITICAL": "보통~심각",
|
||||
"MEDIUM": "보통",
|
||||
"LOW": "낮음",
|
||||
"NONE": "-"
|
||||
},
|
||||
"deviceStatus": {
|
||||
"ONLINE": "온라인",
|
||||
"OFFLINE": "오프라인",
|
||||
"SYNCING": "동기화중",
|
||||
"NOT_DEPLOYED": "미배포"
|
||||
},
|
||||
"parentResolution": {
|
||||
"UNRESOLVED": "미해결",
|
||||
"REVIEW_REQUIRED": "검토 필요",
|
||||
"MANUAL_CONFIRMED": "수동 확정"
|
||||
},
|
||||
"labelSession": {
|
||||
"ACTIVE": "진행중",
|
||||
"COMPLETED": "완료",
|
||||
"CANCELLED": "취소"
|
||||
},
|
||||
"modelStatus": {
|
||||
"DEPLOYED": "배포됨",
|
||||
"APPROVED": "승인",
|
||||
"TESTING": "테스트",
|
||||
"DRAFT": "초안"
|
||||
},
|
||||
"gearGroupType": {
|
||||
"FLEET": "선단",
|
||||
"GEAR_IN_ZONE": "구역 내 어구",
|
||||
"GEAR_OUT_ZONE": "구역 외 어구"
|
||||
},
|
||||
"darkPattern": {
|
||||
"AIS_FULL_BLOCK": "AIS 완전차단",
|
||||
"MMSI_SPOOFING": "MMSI 변조 의심",
|
||||
"LONG_LOSS": "장기 소실",
|
||||
"INTERMITTENT": "신호 간헐송출",
|
||||
"SPEED_ANOMALY": "속도 이상"
|
||||
},
|
||||
"userAccountStatus": {
|
||||
"ACTIVE": "활성",
|
||||
"PENDING": "승인 대기",
|
||||
"LOCKED": "잠금",
|
||||
"INACTIVE": "비활성"
|
||||
},
|
||||
"loginResult": {
|
||||
"SUCCESS": "성공",
|
||||
"FAILED": "실패",
|
||||
"LOCKED": "계정 잠금"
|
||||
},
|
||||
"permitStatus": {
|
||||
"VALID": "유효",
|
||||
"PENDING": "확인 중",
|
||||
"EXPIRED": "만료",
|
||||
"UNLICENSED": "무허가"
|
||||
},
|
||||
"gearJudgment": {
|
||||
"NORMAL": "정상",
|
||||
"CHECKING": "확인 중",
|
||||
"SUSPECT_ILLEGAL": "불법 의심"
|
||||
},
|
||||
"vesselSurveillance": {
|
||||
"TRACKING": "추적중",
|
||||
"WATCHING": "감시중",
|
||||
"CHECKING": "확인중",
|
||||
"NORMAL": "정상"
|
||||
},
|
||||
"vesselRing": {
|
||||
"SAFE": "양호",
|
||||
"SUSPECT": "의심",
|
||||
"WARNING": "경고"
|
||||
},
|
||||
"connectionStatus": {
|
||||
"OK": "정상",
|
||||
"WARNING": "경고",
|
||||
"ERROR": "오류"
|
||||
},
|
||||
"trainingZone": {
|
||||
"NAVY": "해군 훈련 구역",
|
||||
"AIRFORCE": "공군 훈련 구역",
|
||||
"ARMY": "육군 훈련 구역",
|
||||
"ADD": "국방과학연구소",
|
||||
"KCG": "해양경찰청"
|
||||
},
|
||||
"action": {
|
||||
"search": "검색",
|
||||
"save": "저장",
|
||||
|
||||
128
frontend/src/lib/theme/variantMeta.ts
Normal file
128
frontend/src/lib/theme/variantMeta.ts
Normal file
@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 컴포넌트 variant 메타데이터 (Single Source of Truth)
|
||||
*
|
||||
* variants.ts는 CVA 클래스 문자열만 정의.
|
||||
* 여기서는 각 variant의 **의미/사용처/설명**을 중앙 관리하여
|
||||
* 쇼케이스와 실 페이지가 일관되게 참조 가능.
|
||||
*
|
||||
* 예) 쇼케이스 BadgeSection의 "intent 의미 가이드"가 이 파일을 참조.
|
||||
* 코드 리뷰 시 variant 선택 기준이 이 파일에서 단일하게 유지됨.
|
||||
*/
|
||||
|
||||
import type { BadgeIntent, BadgeSize, ButtonVariant, ButtonSize } from './variants';
|
||||
|
||||
export interface VariantMeta<K extends string> {
|
||||
key: K;
|
||||
titleKo: string;
|
||||
titleEn: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// ── Badge intent 의미 ──────────────────────────
|
||||
export const BADGE_INTENT_META: Record<BadgeIntent, VariantMeta<BadgeIntent>> = {
|
||||
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<ButtonVariant, VariantMeta<ButtonVariant>> = {
|
||||
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'];
|
||||
@ -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';
|
||||
|
||||
35
frontend/src/lib/utils/cn.ts
Normal file
35
frontend/src/lib/utils/cn.ts
Normal file
@ -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));
|
||||
}
|
||||
@ -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<PermTreeNode[]>('/perm-tree');
|
||||
}
|
||||
|
||||
export function fetchRoles() {
|
||||
return apiGet<RoleWithPermissions[]>('/roles');
|
||||
import { updateRoleColorCache } from '@shared/constants/userRoles';
|
||||
|
||||
export async function fetchRoles(): Promise<RoleWithPermissions[]> {
|
||||
const roles = await apiGet<RoleWithPermissions[]>('/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;
|
||||
}
|
||||
|
||||
|
||||
90
frontend/src/shared/components/common/ColorPicker.tsx
Normal file
90
frontend/src/shared/components/common/ColorPicker.tsx
Normal file
@ -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<string>(value ?? '');
|
||||
|
||||
const handleCustomChange = (hex: string) => {
|
||||
setCustomHex(hex);
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(hex)) {
|
||||
onChange(hex);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
{label && <div className="text-[10px] text-hint">{label}</div>}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{palette.map((color) => {
|
||||
const selected = value?.toLowerCase() === color.toLowerCase();
|
||||
return (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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 && <Check className="w-3 h-3 text-slate-900" strokeWidth={3} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{allowCustom && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={value ?? '#808080'}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
setCustomHex(e.target.value);
|
||||
}}
|
||||
className="w-7 h-7 rounded border border-border bg-transparent cursor-pointer"
|
||||
title="색상 직접 선택"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={customHex}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -14,11 +14,11 @@ import { useAuth } from '@/app/auth/AuthContext';
|
||||
export interface DataColumn<T> {
|
||||
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<T extends Record<string, unknown>>({
|
||||
<tr className="border-b border-border">
|
||||
{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 (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`px-4 py-2.5 font-medium text-hint whitespace-nowrap ${
|
||||
className={`px-4 py-2.5 font-medium text-hint whitespace-nowrap overflow-hidden text-ellipsis ${
|
||||
col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : 'text-left'
|
||||
} ${col.sortable !== false ? 'cursor-pointer hover:text-label select-none' : ''}`}
|
||||
style={cellStyle}
|
||||
@ -188,19 +190,23 @@ export function DataTable<T extends Record<string, unknown>>({
|
||||
>
|
||||
{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 (
|
||||
<td
|
||||
key={col.key}
|
||||
className={`px-4 py-2 whitespace-nowrap ${
|
||||
className={`px-4 py-2 whitespace-nowrap overflow-hidden text-ellipsis ${
|
||||
col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : 'text-left'
|
||||
}`}
|
||||
style={cellStyle}
|
||||
title={titleText}
|
||||
>
|
||||
{col.render
|
||||
? col.render(row[col.key], row, page * pageSize + i)
|
||||
: <span className="text-label">{row[col.key] != null ? String(row[col.key]) : ''}</span>
|
||||
? col.render(rawValue, row, page * pageSize + i)
|
||||
: <span className="text-label">{titleText}</span>
|
||||
}
|
||||
</td>
|
||||
);
|
||||
|
||||
@ -56,12 +56,13 @@ export function ExcelExport({
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
disabled={!data.length}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-medium transition-colors disabled:opacity-30 ${
|
||||
exported
|
||||
? 'bg-green-600/20 text-green-400 border border-green-500/30'
|
||||
: 'bg-surface-overlay border border-border text-muted-foreground hover:text-heading hover:border-border'
|
||||
: 'bg-surface-overlay border border-border text-muted-foreground hover:text-on-vivid hover:border-border'
|
||||
} ${className}`}
|
||||
>
|
||||
{exported ? <Check className="w-3 h-3" /> : <FileSpreadsheet className="w-3 h-3" />}
|
||||
|
||||
@ -110,7 +110,7 @@ export function FileUpload({
|
||||
<span className="text-label truncate flex-1">{f.file.name}</span>
|
||||
<span className="text-hint text-[10px] shrink-0">{formatSize(f.file.size)}</span>
|
||||
{f.msg && <span className="text-red-400 text-[10px] shrink-0">{f.msg}</span>}
|
||||
<button onClick={() => removeFile(i)} className="text-hint hover:text-muted-foreground shrink-0">
|
||||
<button type="button" aria-label={`${f.file.name} 제거`} onClick={() => removeFile(i)} className="text-hint hover:text-muted-foreground shrink-0">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -79,6 +79,8 @@ export function NotificationBanner({ notices, userRole }: NotificationBannerProp
|
||||
</span>
|
||||
{notice.dismissible && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="알림 닫기"
|
||||
onClick={() => dismiss(notice.id)}
|
||||
className="text-hint hover:text-muted-foreground shrink-0"
|
||||
>
|
||||
@ -145,7 +147,7 @@ export function NotificationPopup({ notices, userRole }: NotificationPopupProps)
|
||||
<div className="px-5 py-3 border-t border-border flex justify-end">
|
||||
<button
|
||||
onClick={close}
|
||||
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-heading text-[11px] font-bold rounded-lg transition-colors"
|
||||
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid text-[11px] font-bold rounded-lg transition-colors"
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
|
||||
@ -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'
|
||||
}`}
|
||||
>
|
||||
|
||||
@ -41,6 +41,7 @@ export function PrintButton({ targetRef, label = '출력', className = '' }: Pri
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrint}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-medium bg-surface-overlay border border-border text-muted-foreground hover:text-heading hover:border-border transition-colors ${className}`}
|
||||
>
|
||||
|
||||
@ -29,12 +29,13 @@ export function SaveButton({ onClick, label = '저장', disabled = false, classN
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={disabled || state !== 'idle'}
|
||||
className={`flex items-center gap-1.5 px-4 py-1.5 rounded-lg text-[11px] font-bold transition-colors disabled:opacity-40 ${
|
||||
state === 'done'
|
||||
? 'bg-green-600 text-heading'
|
||||
: 'bg-blue-600 hover:bg-blue-500 text-heading'
|
||||
? 'bg-green-600 text-on-vivid'
|
||||
: 'bg-blue-600 hover:bg-blue-500 text-on-vivid'
|
||||
} ${className}`}
|
||||
>
|
||||
{state === 'saving' ? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
|
||||
@ -17,6 +17,7 @@ export function SearchInput({ value, onChange, placeholder = '검색...', classN
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
|
||||
<input
|
||||
type="text"
|
||||
aria-label={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
@ -24,6 +25,8 @@ export function SearchInput({ value, onChange, placeholder = '검색...', classN
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="검색어 지우기"
|
||||
onClick={() => onChange('')}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-hint hover:text-muted-foreground"
|
||||
>
|
||||
|
||||
34
frontend/src/shared/components/layout/PageContainer.tsx
Normal file
34
frontend/src/shared/components/layout/PageContainer.tsx
Normal file
@ -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<HTMLDivElement> {
|
||||
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 (
|
||||
<div className={cn(pageContainerVariants({ size, fullBleed }), className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
frontend/src/shared/components/layout/PageHeader.tsx
Normal file
63
frontend/src/shared/components/layout/PageHeader.tsx
Normal file
@ -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 — 페이지 최상단 제목/설명/액션 헤더
|
||||
*
|
||||
* 구성:
|
||||
* [좌] 아이콘 + 제목 + 설명 + (선택) 데모 배지
|
||||
* [우] 액션 슬롯 (검색/필터/버튼 등)
|
||||
*
|
||||
* 사용:
|
||||
* <PageHeader title="대시보드" description="실시간 종합 상황판" />
|
||||
*
|
||||
* <PageHeader
|
||||
* icon={Shield}
|
||||
* title="권한 관리"
|
||||
* description="사용자/역할/권한 설정"
|
||||
* demo
|
||||
* actions={<Button variant="primary">저장</Button>}
|
||||
* />
|
||||
*/
|
||||
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 (
|
||||
<div className={cn('flex items-start justify-between gap-4', className)}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
|
||||
{Icon && <Icon className={cn('w-5 h-5', iconColor)} />}
|
||||
{title}
|
||||
{demo && (
|
||||
<Badge intent="warning" size="xs" className="font-normal">
|
||||
데모 데이터
|
||||
</Badge>
|
||||
)}
|
||||
</h2>
|
||||
{description && <p className="text-[11px] text-hint mt-0.5">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
frontend/src/shared/components/layout/Section.tsx
Normal file
51
frontend/src/shared/components/layout/Section.tsx
Normal file
@ -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 조합을 한 번에
|
||||
*
|
||||
* 사용:
|
||||
* <Section title="해역별 위험도" icon={BarChart3} iconColor="text-orange-400">
|
||||
* {...}
|
||||
* </Section>
|
||||
*/
|
||||
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 (
|
||||
<Card variant={variant} className={className}>
|
||||
{title && (
|
||||
<CardHeader className="pb-2 flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-xs text-label flex items-center gap-1.5">
|
||||
{Icon && <Icon className={cn('w-3.5 h-3.5', iconColor)} />}
|
||||
{title}
|
||||
</CardTitle>
|
||||
{actions && <div className="flex items-center gap-1.5">{actions}</div>}
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent className={cn(title ? 'pt-0' : 'pt-4', contentClassName)}>{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
3
frontend/src/shared/components/layout/index.ts
Normal file
3
frontend/src/shared/components/layout/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { PageContainer, type PageContainerProps } from './PageContainer';
|
||||
export { PageHeader, type PageHeaderProps } from './PageHeader';
|
||||
export { Section, type SectionProps } from './Section';
|
||||
@ -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<HTMLDivElement> {
|
||||
/** 의미 기반 색상 (기존 variant 대체) */
|
||||
@ -17,11 +18,13 @@ const LEGACY_MAP: Record<string, BadgeIntent> = {
|
||||
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 (
|
||||
<div
|
||||
className={`${badgeVariants({ intent: resolvedIntent, size })} ${className}`}
|
||||
className={cn(badgeVariants({ intent: resolvedIntent, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
35
frontend/src/shared/components/ui/button.tsx
Normal file
35
frontend/src/shared/components/ui/button.tsx
Normal file
@ -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<HTMLButtonElement> {
|
||||
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 필수
|
||||
* 예) <Button variant="ghost" aria-label="닫기" icon={<X/>} />
|
||||
* - 위반 시 스크린 리더가 용도를 인지할 수 없어 WCAG 2.1 Level A 위반
|
||||
*/
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, icon, trailingIcon, children, ...props }, ref) => {
|
||||
return (
|
||||
<button ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props}>
|
||||
{icon}
|
||||
{children}
|
||||
{trailingIcon}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
30
frontend/src/shared/components/ui/checkbox.tsx
Normal file
30
frontend/src/shared/components/ui/checkbox.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { forwardRef, type InputHTMLAttributes } from 'react';
|
||||
import { cn } from '@lib/utils/cn';
|
||||
|
||||
export interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** Checkbox — native input에 라벨/스타일만 씌운 가벼운 래퍼 */
|
||||
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
({ className, label, id, ...props }, ref) => {
|
||||
const inputId = id ?? `cb-${Math.random().toString(36).slice(2, 8)}`;
|
||||
return (
|
||||
<label htmlFor={inputId} className="inline-flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
type="checkbox"
|
||||
className={cn(
|
||||
'w-3.5 h-3.5 rounded border-slate-600/60 bg-surface-overlay',
|
||||
'accent-blue-500 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{label && <span className="text-xs text-label">{label}</span>}
|
||||
</label>
|
||||
);
|
||||
},
|
||||
);
|
||||
Checkbox.displayName = 'Checkbox';
|
||||
23
frontend/src/shared/components/ui/input.tsx
Normal file
23
frontend/src/shared/components/ui/input.tsx
Normal file
@ -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<InputHTMLAttributes<HTMLInputElement>, 'size'> {
|
||||
size?: InputSize;
|
||||
state?: InputState;
|
||||
}
|
||||
|
||||
/** Input — 프로젝트 표준 입력 필드 */
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, size, state, type = 'text', ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(inputVariants({ size, state }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
29
frontend/src/shared/components/ui/radio.tsx
Normal file
29
frontend/src/shared/components/ui/radio.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { forwardRef, type InputHTMLAttributes } from 'react';
|
||||
import { cn } from '@lib/utils/cn';
|
||||
|
||||
export interface RadioProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const Radio = forwardRef<HTMLInputElement, RadioProps>(
|
||||
({ className, label, id, ...props }, ref) => {
|
||||
const inputId = id ?? `rd-${Math.random().toString(36).slice(2, 8)}`;
|
||||
return (
|
||||
<label htmlFor={inputId} className="inline-flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
type="radio"
|
||||
className={cn(
|
||||
'w-3.5 h-3.5 border-slate-600/60 bg-surface-overlay',
|
||||
'accent-blue-500 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{label && <span className="text-xs text-label">{label}</span>}
|
||||
</label>
|
||||
);
|
||||
},
|
||||
);
|
||||
Radio.displayName = 'Radio';
|
||||
44
frontend/src/shared/components/ui/select.tsx
Normal file
44
frontend/src/shared/components/ui/select.tsx
Normal file
@ -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`: 툴팁 (접근 이름 폴백)
|
||||
*
|
||||
* 사용 예:
|
||||
* <Select aria-label="상태 필터" value={v} onChange={...}>...</Select>
|
||||
* <Select title="등급 필터" value={v} onChange={...}>...</Select>
|
||||
*/
|
||||
|
||||
type BaseSelectProps = Omit<SelectHTMLAttributes<HTMLSelectElement>, '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<HTMLSelectElement, SelectProps>(
|
||||
({ className, size, state, children, ...props }, ref) => {
|
||||
return (
|
||||
<select
|
||||
ref={ref}
|
||||
className={cn(inputVariants({ size, state }), 'cursor-pointer', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
},
|
||||
);
|
||||
Select.displayName = 'Select';
|
||||
78
frontend/src/shared/components/ui/tabs.tsx
Normal file
78
frontend/src/shared/components/ui/tabs.tsx
Normal file
@ -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 스타일
|
||||
*
|
||||
* 사용:
|
||||
* <TabBar>
|
||||
* <TabButton active={tab === 'a'} onClick={() => setTab('a')}>탭 A</TabButton>
|
||||
* <TabButton active={tab === 'b'} onClick={() => setTab('b')}>탭 B</TabButton>
|
||||
* </TabBar>
|
||||
*/
|
||||
|
||||
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 <div className={cn(variantClass, className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export interface TabButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
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 (
|
||||
<button type={type} className={cn(variantClass, className)} {...props}>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
31
frontend/src/shared/components/ui/textarea.tsx
Normal file
31
frontend/src/shared/components/ui/textarea.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { forwardRef, type TextareaHTMLAttributes } from 'react';
|
||||
import { cn } from '@lib/utils/cn';
|
||||
|
||||
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
state?: 'default' | 'error' | 'success';
|
||||
}
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ 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
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'w-full bg-surface-overlay border rounded-md px-3 py-2 text-sm text-label placeholder:text-hint',
|
||||
'focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/30',
|
||||
'transition-colors resize-y min-h-[4rem]',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
stateClass,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Textarea.displayName = 'Textarea';
|
||||
125
frontend/src/shared/constants/alertLevels.ts
Normal file
125
frontend/src/shared/constants/alertLevels.ts
Normal file
@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 위험도/알림 등급 공통 카탈로그
|
||||
*
|
||||
* SSOT: backend `code_master` 그룹 EVENT_LEVEL (V008 시드).
|
||||
* 향후 `GET /api/code-master?groupCode=EVENT_LEVEL`로 fetch 예정.
|
||||
*
|
||||
* **공통 Badge 컴포넌트와 연동**: 가능한 한 `<Badge intent={getAlertLevelIntent(level)}>` 사용.
|
||||
* 카드 컨테이너 등 분리된 클래스가 필요한 경우만 `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 클래스 묶음 — 카드/컨테이너 전용 (배지가 아님)
|
||||
* 배지에는 `<Badge intent={meta.intent}>` 사용 권장.
|
||||
* 여기서 bg/border는 약한 알파 — 카드 배경에 텍스트가 묻히지 않도록.
|
||||
*/
|
||||
classes: {
|
||||
bg: string;
|
||||
text: string;
|
||||
border: string;
|
||||
dot: string;
|
||||
/** 진한 배경 (액션 버튼 등) */
|
||||
bgSolid: string;
|
||||
};
|
||||
/** hex 색상 (지도 마커, 차트, 인라인 style 용) */
|
||||
hex: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export const ALERT_LEVELS: Record<AlertLevel, AlertLevelMeta> = {
|
||||
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';
|
||||
}
|
||||
267
frontend/src/shared/constants/catalogRegistry.ts
Normal file
267
frontend/src/shared/constants/catalogRegistry.ts
Normal file
@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 분류 카탈로그 중앙 레지스트리 (Single Source of Truth)
|
||||
*
|
||||
* 디자인 쇼케이스의 카탈로그 섹션과 실제 프론트가 **모두 이 레지스트리를 참조**한다.
|
||||
* 따라서:
|
||||
* - 특정 분류의 라벨을 바꾸면 (예: '심각' → '매우 심각') 쇼케이스 + 실 페이지 동시 반영
|
||||
* - 특정 분류의 intent/색상을 바꾸면 역시 자동 반영
|
||||
* - 새 카탈로그를 추가하면 쇼케이스에 자동 노출 (등록만 하면 됨)
|
||||
*
|
||||
* 각 카탈로그는 `items: Record<Code, Meta>` 형태를 가진다 (Meta는 개별 파일의 타입 사용).
|
||||
* 쇼케이스는 heterogeneous 타입을 허용하므로 CatalogEntry의 items는 Record<string, AnyMeta> 로 처리.
|
||||
*/
|
||||
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 카탈로그 레지스트리
|
||||
*
|
||||
* ⚠️ 새 카탈로그 추가 시:
|
||||
* 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);
|
||||
}
|
||||
52
frontend/src/shared/constants/connectionStatuses.ts
Normal file
52
frontend/src/shared/constants/connectionStatuses.ts
Normal file
@ -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<ConnectionStatus, { i18nKey: string; fallback: { ko: string; en: string }; intent: BadgeIntent; hex: string }> = {
|
||||
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<string, ConnectionStatus> = {
|
||||
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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
불러오는 중...
Reference in New Issue
Block a user