release: 2026-04-16 (294건 커밋) #180

병합
dnlee develop 에서 main 로 14 commits 를 머지했습니다 2026-04-16 18:37:59 +09:00
340개의 변경된 파일37252개의 추가작업 그리고 37339개의 파일을 삭제
Showing only changes of commit 7a5028226b - Show all commits

파일 보기

@ -84,5 +84,8 @@
]
}
]
},
"enabledPlugins": {
"frontend-design@claude-plugins-official": true
}
}
}

파일 보기

@ -1,7 +1,7 @@
{
"applied_global_version": "1.6.1",
"applied_date": "2026-04-14",
"applied_date": "2026-04-16",
"project_type": "react-ts",
"gitea_url": "https://gitea.gc-si.dev",
"custom_pre_commit": true
}
}

5
.gitignore vendored
파일 보기

@ -102,4 +102,7 @@ frontend/public/hns-manual/images/
# mcp
.mcp.json
.mcp.json
# python
.venv

파일 보기

@ -54,7 +54,7 @@ wing/
│ │ ├── types/ backtrack, boomLine, hns, navigation
│ │ ├── utils/ coordinates, geo, sanitize, cn.ts
│ │ └── data/ layerData.ts (UI 레이어 트리)
│ └── tabs/ 탭 단위 패키지 (@tabs/ alias)
│ └── components/ 탭 단위 패키지 (@components/ alias)
│ ├── prediction/ 확산 예측 (OilSpillView, 역추적, 오일붐)
│ ├── hns/ HNS 분석 (시나리오, 물질 DB, 재계산)
│ ├── rescue/ 구조 시나리오
@ -96,7 +96,7 @@ wing/
### Path Alias
- `@common/*` -> `src/common/*` (공통 모듈)
- `@tabs/*` -> `src/tabs/*` (탭 패키지)
- `@components/*` -> `src/components/*` (탭 패키지)
## 팀 컨벤션
@ -107,6 +107,8 @@ wing/
- `naming.md` -- 네이밍 규칙
- `testing.md` -- 테스트 규칙
- `subagent-policy.md` -- 서브에이전트 활용 정책
- `design-system.md` -- AI 에이전트 UI 디자인 시스템 규칙 (영문, 실사용)
- `design-system-ko.md` -- 디자인 시스템 규칙 (한국어 참고용)
## 개발 문서 (docs/)

파일 보기

@ -77,7 +77,7 @@ cd backend && npm run db:seed # DB 초기 데이터
## 프로젝트 구조
Path Alias: `@common/*` -> `src/common/*`, `@tabs/*` -> `src/tabs/*`
Path Alias: `@common/*` -> `src/common/*`, `@components/*` -> `src/components/*`
```
wing/
@ -95,7 +95,7 @@ wing/
│ │ ├── types/ backtrack, boomLine, hns, navigation
│ │ ├── utils/ coordinates, geo, sanitize, cn.ts
│ │ └── data/ layerData.ts (UI 레이어 트리)
│ └── tabs/ 탭 단위 패키지 (@tabs/ alias)
│ └── tabs/ 탭 단위 패키지 (@components/ alias)
│ ├── prediction/ 확산 예측 (OilSpillView, 역추적, 오일붐)
│ ├── hns/ HNS 분석 (시나리오, 물질 DB, 재계산)
│ ├── rescue/ 구조 시나리오

파일 보기

@ -1,6 +1,8 @@
import express from 'express';
import { mkdirSync, existsSync } from 'fs';
import multer from 'multer';
import path from 'path';
import { randomUUID } from 'crypto';
import {
listMedia,
createMedia,
@ -25,6 +27,29 @@ import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
const router = express.Router();
const stitchUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } });
const mediaUpload = multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => {
const dir = path.resolve('uploads', 'aerial');
mkdirSync(dir, { recursive: true });
cb(null, dir);
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${randomUUID()}${ext}`);
},
}),
limits: { fileSize: 2 * 1024 * 1024 * 1024 }, // 2GB
fileFilter: (_req, file, cb) => {
const allowed = /\.(jpe?g|png|tiff?|geotiff|mp4|mov)$/i;
if (allowed.test(path.extname(file.originalname))) {
cb(null, true);
} else {
cb(new Error('허용되지 않는 파일 형식입니다.'));
}
},
});
// ============================================================
// AERIAL_MEDIA 라우트
// ============================================================
@ -73,6 +98,96 @@ router.post('/media', requireAuth, requirePermission('aerial', 'CREATE'), async
}
});
// POST /api/aerial/media/upload — 파일 업로드 + 메타 등록
router.post('/media/upload', requireAuth, requirePermission('aerial', 'CREATE'), mediaUpload.single('file'), async (req, res) => {
try {
const file = req.file;
if (!file) {
res.status(400).json({ error: '파일이 필요합니다.' });
return;
}
const { equipTpCd, equipNm, mediaTpCd, acdntSn, memo } = req.body as {
equipTpCd?: string;
equipNm?: string;
mediaTpCd?: string;
acdntSn?: string;
memo?: string;
};
const isVideo = file.mimetype.startsWith('video/');
const detectedMediaType = mediaTpCd ?? (isVideo ? '영상' : '사진');
const fileSzMb = (file.size / (1024 * 1024)).toFixed(2) + ' MB';
const result = await createMedia({
fileNm: file.filename,
orgnlNm: file.originalname,
filePath: file.path,
equipTpCd: equipTpCd ?? 'drone',
equipNm: equipNm ?? '기타',
mediaTpCd: detectedMediaType,
fileSz: fileSzMb,
acdntSn: acdntSn ? parseInt(acdntSn, 10) : undefined,
locDc: memo ?? undefined,
});
res.status(201).json(result);
} catch (err) {
console.error('[aerial] 미디어 업로드 오류:', err);
res.status(500).json({ error: '미디어 업로드 실패' });
}
});
// GET /api/aerial/media/:sn/view — 원본 이미지 뷰어용 (inline 표시)
router.get('/media/:sn/view', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
try {
const sn = parseInt(req.params['sn'] as string, 10);
if (!isValidNumber(sn, 1, 999999)) {
res.status(400).json({ error: '유효하지 않은 미디어 번호' });
return;
}
const media = await getMediaBySn(sn);
if (!media) {
res.status(404).json({ error: '미디어를 찾을 수 없습니다.' });
return;
}
// 로컬 업로드 파일이면 직접 서빙
if (media.filePath) {
const absPath = path.resolve(media.filePath);
if (existsSync(absPath)) {
const ext = path.extname(absPath).toLowerCase();
const mimeMap: Record<string, string> = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
'.tif': 'image/tiff', '.tiff': 'image/tiff',
'.mp4': 'video/mp4', '.mov': 'video/quicktime',
};
res.setHeader('Content-Type', mimeMap[ext] ?? 'application/octet-stream');
res.setHeader('Content-Disposition', 'inline');
res.setHeader('Cache-Control', 'private, max-age=300');
res.sendFile(absPath);
return;
}
}
const fileId = media.fileNm.substring(0, 36);
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID_PATTERN.test(fileId) || !media.equipNm) {
res.status(404).json({ error: '표시 가능한 이미지가 없습니다.' });
return;
}
const buffer = await fetchOriginalImage(media.equipNm, fileId);
res.setHeader('Content-Type', 'image/jpeg');
res.setHeader('Content-Disposition', 'inline');
res.setHeader('Cache-Control', 'private, max-age=300');
res.send(buffer);
} catch (err) {
console.error('[aerial] 이미지 뷰어 오류:', err);
res.status(502).json({ error: '이미지 조회 실패' });
}
});
// GET /api/aerial/media/:sn/download — 원본 이미지 다운로드
router.get('/media/:sn/download', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
try {

파일 보기

@ -378,7 +378,7 @@ PUT, DELETE, PATCH 등 기타 메서드는 사용하지 않는다.
각 탭은 `tabs/{탭명}/services/{탭명}Api.ts`에 API 함수를 정의한다.
```typescript
// frontend/src/tabs/board/services/boardApi.ts
// frontend/src/components/board/services/boardApi.ts
import { api } from '@common/services/api';
// 인터페이스 정의
@ -490,7 +490,7 @@ interface MenuConfigItem {
```typescript
// frontend/src/common/store/newStore.ts (공통) 또는
// frontend/src/tabs/{탭}/store/newStore.ts (탭 전용)
// frontend/src/components/{탭}/store/newStore.ts (탭 전용)
import { create } from 'zustand';
interface MyState {
@ -514,7 +514,7 @@ export const useMyStore = create<MyState>((set) => ({
```typescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchBoardPosts, createBoardPost } from '@tabs/board/services/boardApi';
import { fetchBoardPosts, createBoardPost } from '@components/board/services/boardApi';
// 조회 (캐싱 + 자동 리페치)
const { data, isLoading, error } = useQuery({
@ -1491,13 +1491,13 @@ const result = await authPool.query('SELECT * FROM AUTH_USER WHERE USER_ID = $1'
### 파일 위치
```
frontend/src/tabs/{탭명}/services/{탭명}Api.ts
frontend/src/components/{탭명}/services/{탭명}Api.ts
```
### 작성 패턴
```typescript
// frontend/src/tabs/{탭명}/services/{탭명}Api.ts
// frontend/src/components/{탭명}/services/{탭명}Api.ts
import { api } from '@common/services/api';
// ============================================================

파일 보기

@ -736,13 +736,13 @@ ON CONFLICT DO NOTHING;
### 파일 위치
```
frontend/src/tabs/{탭명}/services/{tabName}Api.ts
frontend/src/components/{탭명}/services/{tabName}Api.ts
```
### 기본 구조
```ts
// frontend/src/tabs/{탭명}/services/{tabName}Api.ts
// frontend/src/components/{탭명}/services/{tabName}Api.ts
import { api } from '@common/services/api';
@ -1376,7 +1376,7 @@ export default router;
### 4단계: 프론트엔드 API 서비스
```ts
// frontend/src/tabs/assets/services/equipmentApi.ts
// frontend/src/components/assets/services/equipmentApi.ts
import { api } from '@common/services/api';

파일 보기

@ -163,11 +163,11 @@ Frontend에서 두 가지 경로 별칭을 사용한다:
| Alias | 실제 경로 | 용도 |
|-------|----------|------|
| `@common/*` | `src/common/*` | 공통 모듈 (컴포넌트, 훅, 서비스, 스토어) |
| `@tabs/*` | `src/tabs/*` | 탭별 패키지 (11개 탭) |
| `@components/*` | `src/components/*` | 탭별 패키지 (11개 탭) |
```tsx
import { useAuth } from '@common/hooks/useAuth';
import OilSpillView from '@tabs/prediction/components/OilSpillView';
import OilSpillView from '@components/prediction/components/OilSpillView';
```
---
@ -495,7 +495,7 @@ pre-commit: [backend] 타입 체크 성공
git status
# 스테이징 (파일 지정)
git add frontend/src/tabs/incidents/components/IncidentDetailView.tsx
git add frontend/src/components/incidents/components/IncidentDetailView.tsx
git add backend/src/incidents/incidentService.ts
# 커밋 (pre-commit + commit-msg 검증 자동 실행)
@ -540,7 +540,7 @@ curl -X POST "https://gitea.gc-si.dev/api/v1/repos/gc/wing-ops/pulls" \
- 변경 내용을 1~3줄로 요약
## 변경 파일
- `frontend/src/tabs/incidents/components/IncidentDetailView.tsx` (신규)
- `frontend/src/components/incidents/components/IncidentDetailView.tsx` (신규)
- `backend/src/incidents/incidentService.ts` (수정)
## Test plan
@ -754,8 +754,8 @@ chmod +x .githooks/pre-commit .githooks/commit-msg
| `database/migration/017_incident_detail.sql` | DB 마이그레이션 (필요 시) |
| `backend/src/incidents/incidentService.ts` | 상세 조회 함수 추가 |
| `backend/src/incidents/incidentRouter.ts` | `GET /api/incidents/:id` 라우트 |
| `frontend/src/tabs/incidents/services/incidentsApi.ts` | API 호출 함수 |
| `frontend/src/tabs/incidents/components/IncidentDetailView.tsx` | 상세 뷰 컴포넌트 |
| `frontend/src/components/incidents/services/incidentsApi.ts` | API 호출 함수 |
| `frontend/src/components/incidents/components/IncidentDetailView.tsx` | 상세 뷰 컴포넌트 |
#### Step 2. 브랜치 생성
@ -797,7 +797,7 @@ router.get('/:id', requireAuth, async (req, res) => {
**Frontend - API:**
```typescript
// frontend/src/tabs/incidents/services/incidentsApi.ts
// frontend/src/components/incidents/services/incidentsApi.ts
export async function fetchIncidentById(id: number) {
const { data } = await api.get(`/incidents/${id}`);
return data;
@ -807,7 +807,7 @@ export async function fetchIncidentById(id: number) {
**Frontend - Component:**
```tsx
// frontend/src/tabs/incidents/components/IncidentDetailView.tsx
// frontend/src/components/incidents/components/IncidentDetailView.tsx
const IncidentDetailView = ({ incidentId }: IncidentDetailViewProps) => {
const { data, isLoading } = useQuery({
queryKey: ['incident', incidentId],
@ -829,7 +829,7 @@ cd ../backend && npx tsc --noEmit
#### Step 5. 커밋 & 푸시
```bash
git add backend/src/incidents/ frontend/src/tabs/incidents/
git add backend/src/incidents/ frontend/src/components/incidents/
git commit -m "feat(incidents): 사고 상세 조회 페이지 추가"
# pre-commit: TypeScript OK, ESLint OK
# commit-msg: Conventional Commits OK

파일 보기

@ -31,9 +31,9 @@ board 탭을 기준 템플릿으로 사용하며, 각 단계별 실제 코드
| 단계 | 파일 | 작업 |
|------|------|------|
| **Step 1** | `frontend/src/tabs/{탭명}/components/{TabName}View.tsx` | 뷰 컴포넌트 생성 |
| | `frontend/src/tabs/{탭명}/services/{tabName}Api.ts` | API 서비스 생성 |
| | `frontend/src/tabs/{탭명}/index.ts` | re-export |
| **Step 1** | `frontend/src/components/{탭명}/components/{TabName}View.tsx` | 뷰 컴포넌트 생성 |
| | `frontend/src/components/{탭명}/services/{tabName}Api.ts` | API 서비스 생성 |
| | `frontend/src/components/{탭명}/index.ts` | re-export |
| **Step 2** | `frontend/src/common/types/navigation.ts` | MainTab 타입 추가 |
| | `frontend/src/App.tsx` | import + renderView case 추가 |
| | `frontend/src/common/hooks/useSubMenu.ts` | 서브메뉴 설정 (서브탭이 있는 경우) |
@ -52,7 +52,7 @@ board 탭을 기준 템플릿으로 사용하며, 각 단계별 실제 코드
### 1-1. 디렉토리 구조
```
frontend/src/tabs/{탭명}/
frontend/src/components/{탭명}/
components/
{TabName}View.tsx # 메인 뷰 컴포넌트
services/
@ -65,7 +65,7 @@ frontend/src/tabs/{탭명}/
서브탭이 **없는** 간단한 탭:
```tsx
// frontend/src/tabs/monitoring/components/MonitoringView.tsx
// frontend/src/components/monitoring/components/MonitoringView.tsx
export function MonitoringView() {
return (
@ -91,7 +91,7 @@ export function MonitoringView() {
서브탭이 **있는** 탭 (board 패턴):
```tsx
// frontend/src/tabs/monitoring/components/MonitoringView.tsx
// frontend/src/components/monitoring/components/MonitoringView.tsx
import { useSubMenu } from '@common/hooks/useSubMenu';
@ -122,7 +122,7 @@ export function MonitoringView() {
### 1-3. API 서비스 (보일러플레이트)
```ts
// frontend/src/tabs/monitoring/services/monitoringApi.ts
// frontend/src/components/monitoring/services/monitoringApi.ts
import { api } from '@common/services/api';
@ -180,7 +180,7 @@ export async function createMonitoring(input: CreateMonitoringInput): Promise<{
### 1-4. index.ts (re-export)
```ts
// frontend/src/tabs/monitoring/index.ts
// frontend/src/components/monitoring/index.ts
export { MonitoringView } from './components/MonitoringView';
```
@ -209,7 +209,7 @@ export type MainTab = 'prediction' | 'hns' | 'rescue' | ... | 'monitoring' | 'ad
// frontend/src/App.tsx
// 1. import 추가
import { MonitoringView } from '@tabs/monitoring';
import { MonitoringView } from '@components/monitoring';
// 2. renderView switch에 case 추가
const renderView = () => {
@ -577,13 +577,13 @@ CREATE INDEX IF NOT EXISTS IDX_MONITORING_REG_DTM ON MONITORING(REG_DTM DESC);
### 1단계: 프론트엔드 파일 생성
```bash
mkdir -p frontend/src/tabs/monitoring/components
mkdir -p frontend/src/tabs/monitoring/services
mkdir -p frontend/src/components/monitoring/components
mkdir -p frontend/src/components/monitoring/services
```
- `frontend/src/tabs/monitoring/components/MonitoringView.tsx` 생성
- `frontend/src/tabs/monitoring/services/monitoringApi.ts` 생성
- `frontend/src/tabs/monitoring/index.ts` 생성
- `frontend/src/components/monitoring/components/MonitoringView.tsx` 생성
- `frontend/src/components/monitoring/services/monitoringApi.ts` 생성
- `frontend/src/components/monitoring/index.ts` 생성
### 2단계: 프론트엔드 기존 파일 수정
@ -592,7 +592,7 @@ mkdir -p frontend/src/tabs/monitoring/services
+ export type MainTab = '...' | 'monitoring' | 'admin';
--- frontend/src/App.tsx
+ import { MonitoringView } from '@tabs/monitoring';
+ import { MonitoringView } from '@components/monitoring';
// renderView switch 내:
+ case 'monitoring':
+ return <MonitoringView />;
@ -644,9 +644,9 @@ cd backend && npx tsc --noEmit # 백엔드 컴파일 검증
## 체크리스트
### 프론트엔드
- [ ] `frontend/src/tabs/{탭명}/components/{TabName}View.tsx` 생성
- [ ] `frontend/src/tabs/{탭명}/services/{tabName}Api.ts` 생성
- [ ] `frontend/src/tabs/{탭명}/index.ts` re-export 생성
- [ ] `frontend/src/components/{탭명}/components/{TabName}View.tsx` 생성
- [ ] `frontend/src/components/{탭명}/services/{tabName}Api.ts` 생성
- [ ] `frontend/src/components/{탭명}/index.ts` re-export 생성
- [ ] `navigation.ts` MainTab 타입에 새 ID 추가
- [ ] `App.tsx` import + renderView switch case 추가
- [ ] `useSubMenu.ts` subMenuConfigs + subMenuState 추가 (서브탭 있는 경우)

파일 보기

@ -49,7 +49,7 @@ git checkout -b feature/{탭명}-crud
```bash
# 탭 디렉토리 내 mock 데이터 검색
grep -rn "mock\|Mock\|MOCK\|sample\|initial\|hardcod\|localStorage" \
frontend/src/tabs/{탭명}/
frontend/src/components/{탭명}/
# 공통 디렉토리에서 해당 탭 관련 데이터 확인 (반드시!)
grep -rn "{탭명}\|{Tab}" frontend/src/common/mock/
@ -302,7 +302,7 @@ app.use('/api/{탭명}', newtabRouter);
**1) API 서비스 파일 생성:**
파일 위치: `frontend/src/tabs/{탭명}/services/{탭명}Api.ts`
파일 위치: `frontend/src/components/{탭명}/services/{탭명}Api.ts`
```typescript
import { api } from '@common/services/api';
@ -476,7 +476,7 @@ CRUD 전체 흐름(생성 -> 조회 -> 수정 -> 삭제)을 확인하고 테스
```bash
# 해당 탭 디렉토리에서 mock 잔여 검색
grep -rn "mock\|Mock\|MOCK\|localStorage" frontend/src/tabs/{탭명}/
grep -rn "mock\|Mock\|MOCK\|localStorage" frontend/src/components/{탭명}/
# 공통 mock/data 디렉토리에서 해당 탭 관련 검색
grep -rn "{탭명}" frontend/src/common/mock/
@ -497,7 +497,7 @@ git status
git add database/migration/017_{탭명}.sql
git add backend/src/{탭명}/
git add backend/src/server.ts
git add frontend/src/tabs/{탭명}/
git add frontend/src/components/{탭명}/
# 커밋 (Conventional Commits, 한국어)
git commit -m "feat({탭명}): mock 데이터를 PostgreSQL + REST API로 전환"
@ -602,7 +602,7 @@ AUTH_USER 주요 컬럼 참조:
```bash
# 불충분 -- 탭 디렉토리만 검색
grep -rn "mock" frontend/src/tabs/{탭명}/
grep -rn "mock" frontend/src/components/{탭명}/
# 반드시 공통 디렉토리도 검색
grep -rn "{탭명}\|{Tab}" frontend/src/common/mock/
@ -780,8 +780,8 @@ export async function fetchCategories(): Promise<Category[]> {
- [ ] 프론트 타입 체크 통과: `cd frontend && npx tsc --noEmit`
- [ ] ESLint 통과: `cd frontend && npx eslint .`
- [ ] CRUD 테스트: curl로 생성/조회/수정/삭제 정상 동작 확인
- [ ] Mock 잔여 0건: `grep -rn "mock\|Mock" frontend/src/tabs/{탭명}/` (UI 상수 제외)
- [ ] PUT/DELETE 사용 0건: `grep -rn "api\.put\|api\.delete" frontend/src/tabs/{탭명}/`
- [ ] Mock 잔여 0건: `grep -rn "mock\|Mock" frontend/src/components/{탭명}/` (UI 상수 제외)
- [ ] PUT/DELETE 사용 0건: `grep -rn "api\.put\|api\.delete" frontend/src/components/{탭명}/`
- [ ] 라우터 등록 확인: `server.ts``app.use('/api/{탭명}', ...)` 추가됨
- [ ] 마이그레이션 실행 확인: psql로 테이블 생성 및 검증 SELECT 통과
- [ ] 커밋 + 푸시 + MR 생성

파일 보기

@ -66,7 +66,7 @@ wing/
│ │ ├── utils/ cn, coordinates, geo, sanitize
│ │ ├── styles/ base.css, components.css, wing.css (@layer)
│ │ └── constants/ featureIds.ts (FEATURE_ID 상수 체계)
│ └── tabs/ @tabs/ alias (11개 탭)
│ └── tabs/ @components/ alias (11개 탭)
│ ├── prediction/ 유류 확산 예측
│ ├── hns/ HNS 분석
│ ├── rescue/ 구조 시나리오
@ -103,7 +103,7 @@ wing/
| Alias | 경로 |
|-------|------|
| `@common/*` | `src/common/*` |
| `@tabs/*` | `src/tabs/*` |
| `@components/*` | `src/components/*` |
---

파일 보기

@ -5,12 +5,17 @@
## [Unreleased]
### 추가
- HNS: AEGL 등농도선 표출 및 자동 줌·동적 도메인 기능 추가
- 사건사고: 통합 분석 패널 HNS/구난 연동 및 사고 목록을 wing.ACDNT 기반으로 전환
- 사건사고: 통합 분석 패널 분할 뷰 및 이전 분석 결과 비교 표출 + 분석 선택 모달 추가
- 확산예측: 유출유 확산 요약 API 신규 (`/analyses/:acdntSn/oil-summary`, primary + byModel)
- HNS: 분석 생성 시 `acdntSn` 연결 지원
- GSC: 사고 목록 응답에 `acdntSn` 노출 및 민감자원 누적 카테고리 관리 + HNS 확산 레이어 유틸 추가
### 변경
- 탭 디렉토리를 MPA 컴포넌트 구조로 재편 (src/tabs → src/components, src/interfaces, src/types)
- TimelineControl 분리 및 aerial/hns 컴포넌트 개선
## [2026-04-15]
### 추가

파일 보기

@ -1945,9 +1945,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
"integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
"cpu": [
"arm"
],
@ -1959,9 +1959,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
"integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
"cpu": [
"arm64"
],
@ -1973,9 +1973,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
"integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
"cpu": [
"arm64"
],
@ -1987,9 +1987,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
"integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
"cpu": [
"x64"
],
@ -2001,9 +2001,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
"integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
"cpu": [
"arm64"
],
@ -2015,9 +2015,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
"integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
"cpu": [
"x64"
],
@ -2029,9 +2029,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
"integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
"cpu": [
"arm"
],
@ -2043,9 +2043,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
"integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
"cpu": [
"arm"
],
@ -2057,9 +2057,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
"integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
"cpu": [
"arm64"
],
@ -2071,9 +2071,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
"integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
"cpu": [
"arm64"
],
@ -2085,9 +2085,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
"integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
"cpu": [
"loong64"
],
@ -2099,9 +2099,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
"integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
"cpu": [
"loong64"
],
@ -2113,9 +2113,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
"integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
"cpu": [
"ppc64"
],
@ -2127,9 +2127,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
"integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
"cpu": [
"ppc64"
],
@ -2141,9 +2141,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
"integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
"cpu": [
"riscv64"
],
@ -2155,9 +2155,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
"integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
"cpu": [
"riscv64"
],
@ -2169,9 +2169,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
"integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
"cpu": [
"s390x"
],
@ -2183,9 +2183,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
"integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
"cpu": [
"x64"
],
@ -2197,9 +2197,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
"integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
"cpu": [
"x64"
],
@ -2211,9 +2211,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
"integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
"cpu": [
"x64"
],
@ -2225,9 +2225,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
"integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
"cpu": [
"arm64"
],
@ -2239,9 +2239,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
"integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
"cpu": [
"arm64"
],
@ -2253,9 +2253,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
"integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
"cpu": [
"ia32"
],
@ -2267,9 +2267,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
"integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
"cpu": [
"x64"
],
@ -2281,9 +2281,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
"integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
"cpu": [
"x64"
],
@ -2711,9 +2711,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2721,13 +2721,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@ -2873,9 +2873,9 @@
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2927,9 +2927,9 @@
}
},
"node_modules/anymatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@ -3015,14 +3015,14 @@
}
},
"node_modules/axios": {
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
"proxy-from-env": "^2.1.0"
}
},
"node_modules/balanced-match": {
@ -3077,9 +3077,9 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3946,9 +3946,9 @@
"license": "MIT"
},
"node_modules/fast-xml-parser": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.4.tgz",
"integrity": "sha512-jE8ugADnYOBsu1uaoayVl1tVKAMNOXyjwvv2U6udEA2ORBhDooJDWoGxTkhd4Qn4yh59JVVt/pKXtjPwx9OguQ==",
"version": "4.5.6",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.6.tgz",
"integrity": "sha512-Yd4vkROfJf8AuJrDIVMVmYfULKmIJszVsMv7Vo71aocsKgFxpdlpSHXSaInvyYfgw2PRuObQSW2GFpVMUjxu9A==",
"funding": [
{
"type": "github",
@ -4055,16 +4055,16 @@
}
},
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
@ -4904,9 +4904,9 @@
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@ -4938,9 +4938,9 @@
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@ -5169,9 +5169,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@ -5409,10 +5409,13 @@
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/punycode": {
"version": "2.3.1",
@ -5569,9 +5572,9 @@
}
},
"node_modules/readdirp/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@ -5633,9 +5636,9 @@
}
},
"node_modules/rollup": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -5649,31 +5652,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.57.1",
"@rollup/rollup-android-arm64": "4.57.1",
"@rollup/rollup-darwin-arm64": "4.57.1",
"@rollup/rollup-darwin-x64": "4.57.1",
"@rollup/rollup-freebsd-arm64": "4.57.1",
"@rollup/rollup-freebsd-x64": "4.57.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
"@rollup/rollup-linux-arm64-musl": "4.57.1",
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
"@rollup/rollup-linux-loong64-musl": "4.57.1",
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
"@rollup/rollup-linux-x64-gnu": "4.57.1",
"@rollup/rollup-linux-x64-musl": "4.57.1",
"@rollup/rollup-openbsd-x64": "4.57.1",
"@rollup/rollup-openharmony-arm64": "4.57.1",
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
"@rollup/rollup-win32-x64-gnu": "4.57.1",
"@rollup/rollup-win32-x64-msvc": "4.57.1",
"@rollup/rollup-android-arm-eabi": "4.60.1",
"@rollup/rollup-android-arm64": "4.60.1",
"@rollup/rollup-darwin-arm64": "4.60.1",
"@rollup/rollup-darwin-x64": "4.60.1",
"@rollup/rollup-freebsd-arm64": "4.60.1",
"@rollup/rollup-freebsd-x64": "4.60.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
"@rollup/rollup-linux-arm-musleabihf": "4.60.1",
"@rollup/rollup-linux-arm64-gnu": "4.60.1",
"@rollup/rollup-linux-arm64-musl": "4.60.1",
"@rollup/rollup-linux-loong64-gnu": "4.60.1",
"@rollup/rollup-linux-loong64-musl": "4.60.1",
"@rollup/rollup-linux-ppc64-gnu": "4.60.1",
"@rollup/rollup-linux-ppc64-musl": "4.60.1",
"@rollup/rollup-linux-riscv64-gnu": "4.60.1",
"@rollup/rollup-linux-riscv64-musl": "4.60.1",
"@rollup/rollup-linux-s390x-gnu": "4.60.1",
"@rollup/rollup-linux-x64-gnu": "4.60.1",
"@rollup/rollup-linux-x64-musl": "4.60.1",
"@rollup/rollup-openbsd-x64": "4.60.1",
"@rollup/rollup-openharmony-arm64": "4.60.1",
"@rollup/rollup-win32-arm64-msvc": "4.60.1",
"@rollup/rollup-win32-ia32-msvc": "4.60.1",
"@rollup/rollup-win32-x64-gnu": "4.60.1",
"@rollup/rollup-win32-x64-msvc": "4.60.1",
"fsevents": "~2.3.2"
}
},
@ -5801,9 +5804,9 @@
}
},
"node_modules/socket.io-parser": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
@ -6285,9 +6288,9 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"dependencies": {

파일 보기

@ -1,25 +1,25 @@
import { useState, useEffect } from 'react';
import { Routes, Route } from 'react-router-dom';
import { GoogleOAuthProvider } from '@react-oauth/google';
import type { MainTab } from '@common/types/navigation';
import { MainLayout } from '@common/components/layout/MainLayout';
import { LoginPage } from '@common/components/auth/LoginPage';
import type { MainTab } from '@/types/navigation';
import { MainLayout } from '@components/common/layout/MainLayout';
import { LoginPage } from '@components/common/auth/LoginPage';
import { registerMainTabSwitcher } from '@common/hooks/useSubMenu';
import { useAuthStore } from '@common/store/authStore';
import { useMenuStore } from '@common/store/menuStore';
import { useMapStore } from '@common/store/mapStore';
import { API_BASE_URL } from '@common/services/api';
import { OilSpillView } from '@tabs/prediction';
import { ReportsView } from '@tabs/reports';
import { HNSView } from '@tabs/hns';
import { AerialView } from '@tabs/aerial';
import { AssetsView } from '@tabs/assets';
import { BoardView } from '@tabs/board';
import { WeatherView } from '@tabs/weather';
import { IncidentsView } from '@tabs/incidents';
import { AdminView } from '@tabs/admin';
import { ScatView } from '@tabs/scat';
import { RescueView } from '@tabs/rescue';
import { OilSpillView } from '@components/prediction';
import { ReportsView } from '@components/reports';
import { HNSView } from '@components/hns';
import { AerialView } from '@components/aerial';
import { AssetsView } from '@components/assets';
import { BoardView } from '@components/board';
import { WeatherView } from '@components/weather';
import { IncidentsView } from '@components/incidents';
import { AdminView } from '@components/admin';
import { ScatView } from '@components/scat';
import { RescueView } from '@components/rescue';
import { DesignPage } from '@/pages/design/DesignPage';
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || '';

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -0,0 +1,30 @@
import chaptersJson from './chapters.json';
export interface InputItem {
label: string;
type: string;
required: boolean;
desc: string;
}
export interface ScreenItem {
id: string;
name: string;
menuPath: string;
imageIndex: number;
overview: string;
description?: string;
procedure?: string[];
inputs?: InputItem[];
notes?: string[];
}
export interface Chapter {
id: string;
number: string;
title: string;
subtitle: string;
screens: ScreenItem[];
}
export const CHAPTERS = chaptersJson as Chapter[];

파일 보기

@ -1,6 +1,6 @@
import type { StyleSpecification } from 'maplibre-gl';
import { useMapStore } from '@common/store/mapStore';
import { LIGHT_STYLE, SATELLITE_3D_STYLE, ENC_EMPTY_STYLE } from '@common/components/map/mapStyles';
import { LIGHT_STYLE, SATELLITE_3D_STYLE, ENC_EMPTY_STYLE } from '@components/common/map/mapStyles';
export function useBaseMapStyle(): StyleSpecification {
const mapToggles = useMapStore((s) => s.mapToggles);

파일 보기

@ -1,5 +1,5 @@
import { useEffect, useSyncExternalStore } from 'react';
import type { MainTab } from '../types/navigation';
import type { MainTab } from '@/types/navigation';
import { useAuthStore } from '@common/store/authStore';
import { API_BASE_URL } from '@common/services/api';
@ -61,6 +61,7 @@ const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
{ id: 'manual', label: '해경매뉴얼', icon: '📘' },
],
weather: null,
monitor: null,
admin: null, // 관리자 화면은 자체 사이드바 사용 (AdminSidebar.tsx)
};
@ -76,6 +77,7 @@ const subMenuState: Record<MainTab, string> = {
incidents: '',
board: 'all',
weather: '',
monitor: '',
admin: 'users',
};

파일 보기

@ -7,7 +7,7 @@ import {
getInitialVesselSnapshot,
isVesselInitEnabled,
} from '@common/services/vesselApi';
import type { VesselPosition, MapBounds } from '@common/types/vessel';
import type { VesselPosition, MapBounds } from '@/types/vessel';
/**
*

파일 보기

@ -1,4 +1,4 @@
// Deprecated: Mock 선박 데이터는 제거되었습니다.
// 실제 선박 신호는 @common/hooks/useVesselSignals + @common/components/map/VesselLayer 를 사용합니다.
// 범례는 @common/components/map/VesselLayer 의 VESSEL_LEGEND 를 import 하세요.
// 실제 선박 신호는 @common/hooks/useVesselSignals + @components/common/map/VesselLayer 를 사용합니다.
// 범례는 @components/common/map/VesselLayer 의 VESSEL_LEGEND 를 import 하세요.
export {};

파일 보기

@ -1,5 +1,5 @@
import { api } from './api';
import type { VesselPosition, MapBounds } from '@common/types/vessel';
import type { VesselPosition, MapBounds } from '@/types/vessel';
export async function getVesselsInArea(bounds: MapBounds): Promise<VesselPosition[]> {
const res = await api.post<VesselPosition[]>('/vessels/in-area', { bounds });

파일 보기

@ -1,4 +1,4 @@
import type { VesselPosition, MapBounds } from '@common/types/vessel';
import type { VesselPosition, MapBounds } from '@/types/vessel';
import { getVesselsInArea } from './vesselApi';
export interface VesselSignalClient {

파일 보기

@ -1,44 +1,7 @@
import { create } from 'zustand';
import type { WeatherSnapshot } from '@interfaces/weather/WeatherInterface';
export interface WeatherSnapshot {
stationName: string;
capturedAt: string;
wind: {
speed: number;
direction: number;
directionLabel: string;
speed_1k: number;
speed_3k: number;
};
wave: {
height: number;
maxHeight: number;
period: number;
direction: string;
};
temperature: {
current: number;
feelsLike: number;
};
pressure: number;
visibility: number;
salinity: number;
astronomy?: {
sunrise: string;
sunset: string;
moonrise: string;
moonset: string;
moonPhase: string;
tidalRange: number;
};
alert?: string;
forecast?: Array<{
time: string;
icon: string;
temperature: number;
windSpeed: number;
}>;
}
export type { WeatherSnapshot };
interface WeatherSnapshotStore {
snapshot: WeatherSnapshot | null;

파일 보기

@ -325,7 +325,7 @@
gap: 4px;
padding: 5px 4px;
border-radius: 5px;
font-size: 0.6875rem;
font-size: 0.75rem;
font-weight: 600;
font-family: var(--font-korean);
cursor: pointer;
@ -360,7 +360,7 @@
width: 100%;
padding: 10px;
border-radius: 6px;
font-size: 12px;
font-size: 0.8125rem;
font-weight: 700;
cursor: pointer;
border: none;
@ -386,7 +386,7 @@
border: 1px solid rgba(6, 182, 212, 0.2);
border-radius: 6px;
color: var(--color-accent);
font-size: 0.6875rem;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
@ -411,7 +411,7 @@
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
font-size: 12px;
font-size: 0.8125rem;
font-weight: 600;
transition: all 0.15s;
background: rgba(15, 21, 36, 0.75);
@ -450,7 +450,7 @@
border-radius: 6px;
padding: 5px 14px;
font-family: var(--font-mono);
font-size: 0.6875rem;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.7);
font-weight: 400;
z-index: 20;
@ -491,7 +491,7 @@
}
.wii-value {
font-size: 0.6875rem;
font-size: 0.75rem;
font-weight: 400;
color: #ffffff;
font-family: var(--font-mono);
@ -538,7 +538,7 @@
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 14px;
font-size: 1rem;
transition: 0.2s;
}
@ -621,7 +621,7 @@
position: absolute;
top: -18px;
transform: translateX(-50%);
font-size: 12px;
font-size: 0.8125rem;
cursor: pointer;
filter: drop-shadow(0 0 4px rgba(245, 158, 11, 0.5));
}
@ -672,7 +672,7 @@
}
.tlct {
font-size: 14px;
font-size: 1rem;
font-weight: 600;
color: var(--color-accent);
font-family: var(--font-mono);
@ -841,7 +841,7 @@
}
.layer-icon {
font-size: 14px;
font-size: 1rem;
flex-shrink: 0;
}
@ -1115,7 +1115,7 @@
cursor: pointer;
border-radius: var(--radius-sm);
transition: background 0.15s;
font-size: 12px;
font-size: 0.8125rem;
font-weight: 700;
color: var(--fg-default);
font-family: var(--font-korean);
@ -1345,7 +1345,7 @@
}
.lyr-ccustom label {
font-size: 0.6875rem;
font-size: 0.75rem;
color: var(--fg-disabled);
font-family: var(--font-korean);
}
@ -1369,7 +1369,7 @@
border-radius: var(--radius-sm);
}
.lyr-style-label {
font-size: 0.6875rem;
font-size: 0.75rem;
font-weight: 700;
color: var(--fg-disabled);
font-family: var(--font-korean);
@ -1411,7 +1411,7 @@
cursor: pointer;
}
.lyr-style-val {
font-size: 0.6875rem;
font-size: 0.75rem;
color: var(--fg-disabled);
font-family: var(--font-mono);
min-width: 28px;

파일 보기

@ -1,67 +0,0 @@
/* HNS 물질 검색 데이터 타입 */
export interface HNSSearchSubstance {
id: number;
abbreviation: string; // 약자/제품명 (화물적부도 코드)
nameKr: string; // 국문명
nameEn: string; // 영문명
synonymsEn: string; // 영문 동의어
synonymsKr: string; // 국문 동의어/용도
unNumber: string; // UN번호
casNumber: string; // CAS번호
transportMethod: string; // 운송방법
sebc: string; // SEBC 거동분류
/* 물리·화학적 특성 */
usage: string;
state: string;
color: string;
odor: string;
flashPoint: string;
autoIgnition: string;
boilingPoint: string;
density: string; // 비중 (물=1)
solubility: string;
vaporPressure: string;
vaporDensity: string; // 증기밀도 (공기=1)
explosionRange: string; // 폭발범위
/* 위험등급·농도기준 */
nfpa: { health: number; fire: number; reactivity: number; special: string };
hazardClass: string;
ergNumber: string;
idlh: string;
aegl2: string;
erpg2: string;
/* 방제거리 */
responseDistanceFire: string;
responseDistanceSpillDay: string;
responseDistanceSpillNight: string;
marineResponse: string;
/* PPE */
ppeClose: string;
ppeFar: string;
/* MSDS 요약 */
msds: {
hazard: string;
firstAid: string;
fireFighting: string;
spillResponse: string;
exposure: string;
regulation: string;
};
/* IBC CODE */
ibcHazard: string;
ibcShipType: string;
ibcTankType: string;
ibcDetection: string;
ibcFireFighting: string;
ibcMinRequirement: string;
/* EmS */
emsCode: string;
emsFire: string;
emsSpill: string;
emsFirstAid: string;
/* 화물적부도 코드 */
cargoCodes: Array<{ code: string; name: string; company: string; source: string }>;
/* 항구별 반입 */
portFrequency: Array<{ port: string; portCode: string; lastImport: string; frequency: string }>;
}

파일 보기

@ -3,7 +3,7 @@ import type {
BoomLineCoord,
AlgorithmSettings,
ContainmentResult,
} from '../types/boomLine';
} from '@/types/boomLine';
const DEG2RAD = Math.PI / 180;
const RAD2DEG = 180 / Math.PI;

파일 보기

@ -1,4 +1,4 @@
import type { ImageAnalyzeResult } from '@tabs/prediction/services/predictionApi';
import type { ImageAnalyzeResult } from '@interfaces/prediction/PredictionInterface';
/**
* () .

파일 보기

@ -69,7 +69,9 @@ export function AdminView() {
return (
<div className="flex flex-1 overflow-hidden bg-bg-base">
<AdminSidebar activeMenu={activeMenu} onSelect={setActiveMenu} />
<div className="flex-1 flex flex-col overflow-hidden">{renderContent()}</div>
<div className="flex-1 flex flex-col overflow-hidden" key={activeMenu}>
{renderContent()}
</div>
</div>
);
}

파일 보기

@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { fetchUploadLogs } from '@tabs/assets/services/assetsApi';
import type { UploadLogItem } from '@tabs/assets/services/assetsApi';
import { fetchUploadLogs } from '@components/assets/services/assetsApi';
import type { UploadLogItem } from '@interfaces/assets/AssetsInterface';
const ASSET_CATEGORIES = [
'전체',
@ -20,29 +20,29 @@ const PERM_ITEMS = [
icon: '👑',
role: '시스템관리자',
desc: '전체 자산 업로드/삭제 가능',
bg: 'rgba(245,158,11,0.15)',
color: 'text-yellow-400',
bg: 'rgba(6,182,212,0.12)',
color: 'text-color-accent',
},
{
icon: '🔧',
role: '운영관리자',
desc: '관할청 내 자산 업로드 가능',
bg: 'rgba(6,182,212,0.15)',
bg: 'rgba(6,182,212,0.08)',
color: 'text-color-accent',
},
{
icon: '👁',
role: '조회자',
desc: '현황 조회만 가능',
bg: 'rgba(148,163,184,0.15)',
bg: 'rgba(6,182,212,0.08)',
color: 'text-fg-sub',
},
{
icon: '🚫',
role: '게스트',
desc: '접근 불가',
bg: 'rgba(239,68,68,0.1)',
color: 'text-red-400',
bg: 'rgba(6,182,212,0.08)',
color: 'text-fg-sub',
},
];
@ -102,7 +102,7 @@ function AssetUploadPanel() {
<div className="flex flex-col h-full">
{/* 헤더 */}
<div className="px-6 py-4 border-b border-stroke flex-shrink-0">
<h1 className="text-lg font-bold text-fg font-korean"> </h1>
<h1 className="text-title-1 font-bold text-fg font-korean"> </h1>
<p className="text-caption text-fg-disabled mt-1 font-korean">
</p>
@ -130,7 +130,7 @@ function AssetUploadPanel() {
className={`rounded-lg border-2 border-dashed py-8 text-center cursor-pointer transition-colors ${
dragging
? 'border-color-accent bg-[rgba(6,182,212,0.05)]'
: 'border-stroke hover:border-[rgba(6,182,212,0.5)] bg-bg-elevated'
: 'border-stroke hover:border-[rgba(6,182,212,0.3)] bg-bg-elevated'
}`}
>
<div className="text-3xl mb-2 opacity-40">📁</div>

파일 보기

@ -1,10 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import {
fetchBoardPosts,
adminDeleteBoardPost,
type BoardPostItem,
type BoardListResponse,
} from '@tabs/board/services/boardApi';
import { fetchBoardPosts, adminDeleteBoardPost } from '@components/board/services/boardApi';
import type { BoardPostItem, BoardListResponse } from '@interfaces/board/BoardInterface';
// ─── 상수 ──────────────────────────────────────────────────
const PAGE_SIZE = 20;
@ -118,13 +114,13 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
return (
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1">
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke">
<h2 className="text-body-2 font-semibold text-fg"> </h2>
<span className="text-caption text-fg-disabled"> {data?.totalCount ?? 0}</span>
</div>
{/* 카테고리 탭 + 검색 */}
<div className="flex items-center gap-3 px-5 py-2 border-b border-stroke-1">
<div className="flex items-center gap-3 px-5 py-2 border-b border-stroke">
<div className="flex gap-1">
{CATEGORY_TABS.map((tab) => (
<button
@ -132,7 +128,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
onClick={() => handleCategoryChange(tab.code)}
className={`px-3 py-1 text-caption rounded-full transition-colors ${
activeCategory === tab.code
? 'bg-blue-500/20 text-blue-400 font-medium'
? 'bg-[rgba(6,182,212,0.15)] text-color-accent font-medium'
: 'text-fg-disabled hover:text-fg-sub hover:bg-bg-elevated'
}`}
>
@ -146,11 +142,11 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="제목/작성자 검색"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-fg placeholder:text-text-4 w-48"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-fg placeholder:text-text-4 w-48"
/>
<button
type="submit"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-fg-sub hover:bg-bg-card"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-fg-sub hover:bg-bg-card"
>
</button>
@ -158,11 +154,11 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
</div>
{/* 액션 바 */}
<div className="flex items-center gap-2 px-5 py-2 border-b border-stroke-1">
<div className="flex items-center gap-2 px-5 py-2 border-b border-stroke">
<button
onClick={handleDelete}
disabled={selected.size === 0 || deleting}
className="px-3 py-1 text-caption rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-40 disabled:cursor-not-allowed"
className="px-3 py-1 text-caption rounded bg-[rgba(239,68,68,0.15)] text-color-danger hover:bg-[rgba(239,68,68,0.25)] disabled:opacity-40 disabled:cursor-not-allowed"
>
{deleting ? '삭제 중...' : `선택 삭제 (${selected.size})`}
</button>
@ -172,7 +168,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
<div className="flex-1 overflow-auto">
<table className="w-full text-caption">
<thead className="sticky top-0 bg-bg-surface z-10">
<tr className="border-b border-stroke-1 text-fg-disabled">
<tr className="border-b border-stroke text-fg-disabled">
<th className="w-8 py-2 text-center">
<input
type="checkbox"
@ -218,7 +214,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-1 py-2 border-t border-stroke-1">
<div className="flex items-center justify-center gap-1 py-2 border-t border-stroke">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
@ -236,7 +232,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
onClick={() => setPage(p)}
className={`w-7 h-7 text-caption rounded ${
p === page
? 'bg-blue-500/20 text-blue-400 font-medium'
? 'bg-[rgba(6,182,212,0.15)] text-color-accent font-medium'
: 'text-fg-disabled hover:bg-bg-elevated'
}`}
>
@ -266,7 +262,7 @@ interface PostRowProps {
function PostRow({ post, checked, onToggle }: PostRowProps) {
return (
<tr className="border-b border-stroke-1 hover:bg-bg-surface/50 transition-colors">
<tr className="border-b border-stroke hover:bg-bg-surface/50 transition-colors">
<td className="py-2 text-center">
<input type="checkbox" checked={checked} onChange={onToggle} className="accent-blue-500" />
</td>
@ -275,17 +271,17 @@ function PostRow({ post, checked, onToggle }: PostRowProps) {
<span
className={`inline-block px-2 py-0.5 rounded-full text-caption font-medium ${
post.categoryCd === 'NOTICE'
? 'bg-red-500/15 text-red-400'
? 'bg-[rgba(6,182,212,0.08)] text-fg-sub'
: post.categoryCd === 'QNA'
? 'bg-purple-500/15 text-purple-400'
: 'bg-blue-500/15 text-blue-400'
? 'bg-[rgba(6,182,212,0.08)] text-fg-sub'
: 'bg-[rgba(6,182,212,0.08)] text-fg-sub'
}`}
>
{CATEGORY_LABELS[post.categoryCd] ?? post.categoryCd}
</span>
</td>
<td className="py-2 pl-3 text-fg truncate max-w-[300px]">
{post.pinnedYn === 'Y' && <span className="text-caption text-orange-400 mr-1">[]</span>}
{post.pinnedYn === 'Y' && <span className="text-caption text-color-accent mr-1">[]</span>}
{post.title}
</td>
<td className="py-2 text-center text-fg-sub">{post.authorName}</td>

파일 보기

@ -1,7 +1,8 @@
import { useState, useEffect, useMemo } from 'react';
import { fetchOrganizations } from '@tabs/assets/services/assetsApi';
import type { AssetOrgCompat } from '@tabs/assets/services/assetsApi';
import { typeTagCls } from '@tabs/assets/components/assetTypes';
import { fetchOrganizations } from '@components/assets/services/assetsApi';
import type { AssetOrgCompat } from '@interfaces/assets/AssetsInterface';
import { typeTagCls } from '@components/assets/components/assetTypes';
/* eslint-disable react-refresh/only-export-components */
const PAGE_SIZE = 20;
@ -98,7 +99,7 @@ function CleanupEquipPanel() {
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
<div>
<h1 className="text-lg font-bold text-fg font-korean"> </h1>
<h1 className="text-title-1 font-bold text-fg font-korean"> </h1>
<p className="text-caption text-fg-disabled mt-1 font-korean">
{filtered.length}
</p>
@ -341,16 +342,11 @@ function CleanupEquipPanel() {
<button
key={p}
onClick={() => setCurrentPage(p)}
className="px-2.5 py-1 text-label-2 border rounded transition-colors"
style={
className={`px-2.5 py-1 text-label-2 border rounded transition-colors ${
p === safePage
? {
borderColor: 'var(--color-accent)',
color: 'var(--color-accent)',
background: 'rgba(6,182,212,0.1)',
}
: { borderColor: 'var(--border)', color: 'var(--text-2)' }
}
? 'border-color-accent text-color-accent bg-[rgba(6,182,212,0.08)]'
: 'border-stroke text-fg-sub'
}`}
>
{p}
</button>

파일 보기

@ -176,9 +176,9 @@ function getCollectStatus(item: HrCollectItem): { label: string; color: string }
return { label: '비활성', color: 'text-t3 bg-bg-elevated' };
}
if (item.etaClctList.length > 0) {
return { label: '완료', color: 'text-emerald-400 bg-emerald-500/10' };
return { label: '완료', color: 'text-color-success bg-[rgba(34,197,94,0.08)]' };
}
return { label: '대기', color: 'text-yellow-400 bg-yellow-500/10' };
return { label: '대기', color: 'text-color-caution bg-[rgba(234,179,8,0.08)]' };
}
// ─── cron 표현식 → 읽기 쉬운 형태 ─────────────────────────
@ -217,7 +217,7 @@ function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean })
{HEADERS.map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
>
{h}
</th>
@ -227,7 +227,7 @@ function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean })
<tbody>
{loading && rows.length === 0
? Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-stroke-1 animate-pulse">
<tr key={i} className="border-b border-stroke animate-pulse">
{HEADERS.map((_, j) => (
<td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-elevated rounded w-14" />
@ -240,7 +240,7 @@ function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean })
return (
<tr
key={`${row.seq}`}
className="border-b border-stroke-1 hover:bg-bg-surface/50"
className="border-b border-stroke hover:bg-bg-surface/50"
>
<td className="px-3 py-2 text-t2 text-center">{idx + 1}</td>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">
@ -258,7 +258,7 @@ function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean })
<span
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${
row.activeYn === 'Y'
? 'text-emerald-400 bg-emerald-500/10'
? 'text-color-success bg-[rgba(34,197,94,0.08)]'
: 'text-t3 bg-bg-elevated'
}`}
>
@ -316,7 +316,7 @@ export default function CollectHrPanel() {
return (
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke shrink-0">
<h2 className="text-body-2 font-semibold text-t1"> </h2>
<div className="flex items-center gap-3">
{lastUpdate && (
@ -353,9 +353,9 @@ export default function CollectHrPanel() {
</div>
{/* 상태 표시줄 */}
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-emerald-500/10 text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke bg-bg-base">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(34,197,94,0.08)] text-color-success">
<span className="w-1.5 h-1.5 rounded-full bg-color-success" />
{completedCount}
</span>
<span className="text-caption text-t3">

파일 보기

@ -0,0 +1,567 @@
import { useState, useEffect, useCallback } from 'react';
import { TaskTable } from './contents/TaskTable';
import { AuditLogModal } from './contents/AuditLogModal';
import { WizardModal } from './contents/WizardModal';
/* eslint-disable react-refresh/only-export-components */
// ─── 타입 ──────────────────────────────────────────────────
export type TaskStatus = '완료' | '진행중' | '대기' | '오류';
export interface AuditLogEntry {
id: string;
time: string;
operator: string;
operatorId: string;
action: string;
targetData: string;
result: string;
resultType: '성공' | '실패' | '거부' | '진행중';
ip: string;
browser: string;
detail: {
dataCount: number;
rulesApplied: string;
processedCount: number;
errorCount: number;
};
}
export interface DeidentifyTask {
id: string;
name: string;
target: string;
status: TaskStatus;
startTime: string;
progress: number;
createdBy: string;
}
export type SourceType = 'db' | 'file' | 'api';
export type ProcessMode = 'immediate' | 'scheduled' | 'oneshot';
export type RepeatType = 'daily' | 'weekly' | 'monthly';
export type DeidentifyTechnique = '마스킹' | '삭제' | '범주화' | '암호화' | '샘플링' | '가명처리' | '유지';
export interface FieldConfig {
name: string;
dataType: string;
technique: DeidentifyTechnique;
configValue: string;
selected: boolean;
}
export interface DbConfig {
host: string;
port: string;
database: string;
tableName: string;
}
export interface ApiConfig {
url: string;
method: 'GET' | 'POST';
}
export interface ScheduleConfig {
hour: string;
repeatType: RepeatType;
weekday: string;
startDate: string;
notifyOnComplete: boolean;
notifyOnError: boolean;
}
export interface OneshotConfig {
date: string;
hour: string;
}
export interface WizardState {
step: number;
taskName: string;
sourceType: SourceType;
dbConfig: DbConfig;
apiConfig: ApiConfig;
fields: FieldConfig[];
processMode: ProcessMode;
scheduleConfig: ScheduleConfig;
oneshotConfig: OneshotConfig;
saveAsTemplate: boolean;
applyTemplate: string;
confirmed: boolean;
}
// ─── Mock 데이터 ────────────────────────────────────────────
export const MOCK_TASKS: DeidentifyTask[] = [
{
id: '001',
name: 'customer_2024',
target: '선박/운항 - 선장·선원 성명',
status: '완료',
startTime: '2026-04-10 14:30',
progress: 100,
createdBy: '관리자',
},
{
id: '002',
name: 'transaction_04',
target: '사고 현장 - 현장사진, 영상내 인물',
status: '진행중',
startTime: '2026-04-10 14:15',
progress: 82,
createdBy: '김담당',
},
{
id: '003',
name: 'employee_info',
target: '인사정보 - 계정, 로그인 정보',
status: '대기',
startTime: '2026-04-10 22:00',
progress: 0,
createdBy: '이담당',
},
{
id: '004',
name: 'vendor_data',
target: 'HNS 대응 - 화학물질 취급자, 방제업체 연락처',
status: '오류',
startTime: '2026-04-09 13:45',
progress: 45,
createdBy: '관리자',
},
{
id: '005',
name: 'partner_contacts',
target: '시스템 운영 - 관리자, 운영자 접속로그',
status: '완료',
startTime: '2026-04-08 09:00',
progress: 100,
createdBy: '박담당',
},
];
export const DEFAULT_FIELDS: FieldConfig[] = [
{ name: '고객ID', dataType: '문자열', technique: '삭제', configValue: '-', selected: true },
{
name: '이름',
dataType: '문자열',
technique: '마스킹',
configValue: '*로 치환',
selected: true,
},
{
name: '휴대폰',
dataType: '문자열',
technique: '마스킹',
configValue: '010-****-****',
selected: true,
},
{
name: '주소',
dataType: '문자열',
technique: '범주화',
configValue: '시/도만 표시',
selected: true,
},
{
name: '이메일',
dataType: '문자열',
technique: '가명처리',
configValue: '키: random_001',
selected: true,
},
{
name: '생년월일',
dataType: '날짜',
technique: '범주화',
configValue: '연도만 표시',
selected: true,
},
{ name: '회사', dataType: '문자열', technique: '유지', configValue: '변경 없음', selected: true },
];
export const TECHNIQUES: DeidentifyTechnique[] = [
'마스킹',
'삭제',
'범주화',
'암호화',
'샘플링',
'가명처리',
'유지',
];
export const HOURS = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`);
export const WEEKDAYS = ['월', '화', '수', '목', '금', '토', '일'];
export const TEMPLATES = ['기본 개인정보', '금융데이터', '의료데이터'];
export const MOCK_AUDIT_LOGS: Record<string, AuditLogEntry[]> = {
'001': [
{
id: 'LOG_20260410_001',
time: '2026-04-10 14:30:45',
operator: '김철수',
operatorId: 'user_12345',
action: '처리완료',
targetData: 'customer_2024',
result: '성공 (100%)',
resultType: '성공',
ip: '192.168.1.100',
browser: 'Chrome 123.0',
detail: {
dataCount: 15240,
rulesApplied: '마스킹 3, 범주화 2, 삭제 2',
processedCount: 15240,
errorCount: 0,
},
},
{
id: 'LOG_20260410_002',
time: '2026-04-10 14:15:10',
operator: '김철수',
operatorId: 'user_12345',
action: '처리시작',
targetData: 'customer_2024',
result: '성공',
resultType: '성공',
ip: '192.168.1.100',
browser: 'Chrome 123.0',
detail: {
dataCount: 15240,
rulesApplied: '마스킹 3, 범주화 2, 삭제 2',
processedCount: 0,
errorCount: 0,
},
},
{
id: 'LOG_20260410_003',
time: '2026-04-10 14:10:30',
operator: '김철수',
operatorId: 'user_12345',
action: '규칙설정',
targetData: 'customer_2024',
result: '성공',
resultType: '성공',
ip: '192.168.1.100',
browser: 'Chrome 123.0',
detail: { dataCount: 15240, rulesApplied: '7개 규칙 적용', processedCount: 0, errorCount: 0 },
},
],
'002': [
{
id: 'LOG_20260410_004',
time: '2026-04-10 14:15:22',
operator: '이영희',
operatorId: 'user_23456',
action: '처리시작',
targetData: 'transaction_04',
result: '진행중 (82%)',
resultType: '진행중',
ip: '192.168.1.101',
browser: 'Firefox 124.0',
detail: {
dataCount: 8920,
rulesApplied: '마스킹 2, 암호화 1, 삭제 3',
processedCount: 7314,
errorCount: 0,
},
},
],
'003': [
{
id: 'LOG_20260410_005',
time: '2026-04-10 13:45:30',
operator: '박민준',
operatorId: 'user_34567',
action: '규칙수정',
targetData: 'employee_info',
result: '성공',
resultType: '성공',
ip: '192.168.1.102',
browser: 'Chrome 123.0',
detail: {
dataCount: 3200,
rulesApplied: '마스킹 4, 가명처리 1',
processedCount: 0,
errorCount: 0,
},
},
],
'004': [
{
id: 'LOG_20260409_001',
time: '2026-04-09 13:45:30',
operator: '관리자',
operatorId: 'user_admin',
action: '처리오류',
targetData: 'vendor_data',
result: '오류 (45%)',
resultType: '실패',
ip: '192.168.1.100',
browser: 'Chrome 123.0',
detail: {
dataCount: 5100,
rulesApplied: '마스킹 2, 범주화 1, 삭제 1',
processedCount: 2295,
errorCount: 12,
},
},
{
id: 'LOG_20260409_002',
time: '2026-04-09 13:40:15',
operator: '김철수',
operatorId: 'user_12345',
action: '규칙조회',
targetData: 'vendor_data',
result: '성공',
resultType: '성공',
ip: '192.168.1.100',
browser: 'Chrome 123.0',
detail: { dataCount: 5100, rulesApplied: '4개 규칙', processedCount: 0, errorCount: 0 },
},
{
id: 'LOG_20260409_003',
time: '2026-04-09 09:25:00',
operator: '이영희',
operatorId: 'user_23456',
action: '삭제시도',
targetData: 'vendor_data',
result: '거부 (권한부족)',
resultType: '거부',
ip: '192.168.1.101',
browser: 'Firefox 124.0',
detail: { dataCount: 5100, rulesApplied: '-', processedCount: 0, errorCount: 0 },
},
],
'005': [
{
id: 'LOG_20260408_001',
time: '2026-04-08 09:15:00',
operator: '박담당',
operatorId: 'user_45678',
action: '처리완료',
targetData: 'partner_contacts',
result: '성공 (100%)',
resultType: '성공',
ip: '192.168.1.103',
browser: 'Edge 122.0',
detail: {
dataCount: 1850,
rulesApplied: '마스킹 2, 유지 3',
processedCount: 1850,
errorCount: 0,
},
},
],
};
function fetchTasks(): Promise<DeidentifyTask[]> {
return new Promise((resolve) => {
setTimeout(() => resolve(MOCK_TASKS), 300);
});
}
// ─── 상태 뱃지 ─────────────────────────────────────────────
export function getStatusBadgeClass(status: TaskStatus): string {
switch (status) {
case '완료':
return 'text-color-success bg-[rgba(34,197,94,0.1)]';
case '진행중':
return 'text-color-accent bg-[rgba(6,182,212,0.1)]';
case '대기':
return 'text-color-caution bg-[rgba(234,179,8,0.1)]';
case '오류':
return 'text-color-danger bg-[rgba(239,68,68,0.1)]';
}
}
// ─── 진행률 바 ─────────────────────────────────────────────
export const TABLE_HEADERS = ['작업ID', '작업명', '대상', '상태', '시작시간', '진행률', '등록자', '액션'];
export const STEP_LABELS = ['소스선택', '데이터검증', '비식별화규칙', '처리방식', '최종확인'];
export const INITIAL_WIZARD: WizardState = {
step: 1,
taskName: '',
sourceType: 'db',
dbConfig: { host: '', port: '5432', database: '', tableName: '' },
apiConfig: { url: '', method: 'GET' },
fields: DEFAULT_FIELDS,
processMode: 'immediate',
scheduleConfig: {
hour: '02:00',
repeatType: 'daily',
weekday: '월',
startDate: '',
notifyOnComplete: true,
notifyOnError: true,
},
oneshotConfig: { date: '', hour: '02:00' },
saveAsTemplate: false,
applyTemplate: '',
confirmed: false,
};
// ─── 메인 패널 ──────────────────────────────────────────────
type FilterStatus = '모두' | TaskStatus;
export default function DeidentifyPanel() {
const [tasks, setTasks] = useState<DeidentifyTask[]>([]);
const [loading, setLoading] = useState(false);
const [showWizard, setShowWizard] = useState(false);
const [auditTask, setAuditTask] = useState<DeidentifyTask | null>(null);
const [searchName, setSearchName] = useState('');
const [filterStatus, setFilterStatus] = useState<FilterStatus>('모두');
const [filterPeriod, setFilterPeriod] = useState<'7' | '30' | '90'>('30');
const loadTasks = useCallback(async () => {
setLoading(true);
const data = await fetchTasks();
setTasks(data);
setLoading(false);
}, []);
useEffect(() => {
let isMounted = true;
if (tasks.length === 0) {
void Promise.resolve().then(() => {
if (isMounted) void loadTasks();
});
}
return () => {
isMounted = false;
};
}, [tasks.length, loadTasks]);
const handleAction = useCallback((action: string, task: DeidentifyTask) => {
// TODO: 실제 API 연동 시 각 액션에 맞는 API 호출로 교체
if (action === 'delete') {
setTasks((prev) => prev.filter((t) => t.id !== task.id));
} else if (action === 'audit') {
setAuditTask(task);
}
}, []);
const handleWizardSubmit = useCallback(
(wizard: WizardState) => {
const selectedFields = wizard.fields.filter((f) => f.selected).map((f) => f.name);
const newTask: DeidentifyTask = {
id: String(tasks.length + 1).padStart(3, '0'),
name: wizard.taskName,
target: selectedFields.join(', ') || '-',
status: wizard.processMode === 'immediate' ? '진행중' : '대기',
startTime: new Date()
.toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
.replace(/\. /g, '-')
.replace('.', ''),
progress: 0,
createdBy: '관리자',
};
setTasks((prev) => [newTask, ...prev]);
},
[tasks.length],
);
const filteredTasks = tasks.filter((t) => {
if (searchName && !t.name.includes(searchName)) return false;
if (filterStatus !== '모두' && t.status !== filterStatus) return false;
return true;
});
const completedCount = tasks.filter((t) => t.status === '완료').length;
const inProgressCount = tasks.filter((t) => t.status === '진행중').length;
const errorCount = tasks.filter((t) => t.status === '오류').length;
return (
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke shrink-0">
<h2 className="text-body-2 font-semibold text-t1"></h2>
<button
onClick={() => setShowWizard(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-caption rounded bg-color-accent hover:bg-color-accent-muted text-white transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
{/* 상태 요약 */}
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke bg-bg-base">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(34,197,94,0.1)] text-color-success">
<span className="w-1.5 h-1.5 rounded-full bg-color-success" />
{completedCount}
</span>
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(6,182,212,0.1)] text-color-accent">
<span className="w-1.5 h-1.5 rounded-full bg-color-accent" />
{inProgressCount}
</span>
{errorCount > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(239,68,68,0.1)] text-color-danger">
<span className="w-1.5 h-1.5 rounded-full bg-color-danger" />
{errorCount}
</span>
)}
<span className="text-caption text-t3"> {tasks.length}</span>
</div>
{/* 검색/필터 */}
<div className="flex items-center gap-2 px-5 py-2.5 shrink-0 border-b border-stroke">
<input
type="text"
value={searchName}
onChange={(e) => setSearchName(e.target.value)}
placeholder="작업명 검색"
className="px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 placeholder:text-t3 focus:outline-none focus:border-color-accent w-40"
/>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value as FilterStatus)}
className="px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
>
{(['모두', '완료', '진행중', '대기', '오류'] as FilterStatus[]).map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
<select
value={filterPeriod}
onChange={(e) => setFilterPeriod(e.target.value as '7' | '30' | '90')}
className="px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
>
<option value="7"> 7</option>
<option value="30"> 30</option>
<option value="90"> 90</option>
</select>
</div>
{/* 테이블 */}
<div className="flex-1 overflow-auto p-5">
<TaskTable rows={filteredTasks} loading={loading} onAction={handleAction} />
</div>
{/* 감사로그 모달 */}
{auditTask && <AuditLogModal task={auditTask} onClose={() => setAuditTask(null)} />}
{/* 마법사 모달 */}
{showWizard && (
<WizardModal onClose={() => setShowWizard(false)} onSubmit={handleWizardSubmit} />
)}
</div>
);
}

파일 보기

@ -5,7 +5,7 @@ import { GeoJsonLayer } from '@deck.gl/layers';
import type { Layer } from '@deck.gl/core';
import 'maplibre-gl/dist/maplibre-gl.css';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
import { S57EncOverlay } from '@components/common/map/S57EncOverlay';
import { useMapStore } from '@common/store/mapStore';
const MAP_CENTER: [number, number] = [127.5, 36.0];
@ -119,7 +119,7 @@ const DispersingZonePanel = () => {
const isConsider = zone === 'consider';
const showLayer = isConsider ? showConsider : showRestrict;
const setShowLayer = isConsider ? setShowConsider : setShowRestrict;
const swatchColor = isConsider ? 'bg-blue-500' : 'bg-red-500';
const swatchColor = isConsider ? 'bg-color-info' : 'bg-color-danger';
const isExpanded = expandedZone === zone;
return (
@ -197,11 +197,11 @@ const DispersingZonePanel = () => {
{/* 범례 */}
<div className="absolute bottom-4 left-4 bg-bg-surface border border-stroke rounded-lg px-3 py-2 flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-sm bg-blue-500 opacity-80" />
<span className="w-3 h-3 rounded-sm bg-color-info opacity-80" />
<span className="text-label-2 text-fg-sub font-korean"></span>
</div>
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-sm bg-red-500 opacity-80" />
<span className="w-3 h-3 rounded-sm bg-color-danger opacity-80" />
<span className="text-label-2 text-fg-sub font-korean"></span>
</div>
</div>

파일 보기

@ -229,7 +229,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
{/* 레이어코드 */}
<div>
<label className={labelCls}>
<span className="text-red-400">*</span>
<span className="text-color-danger">*</span>
</label>
<input
type="text"
@ -243,7 +243,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
{/* 레이어명 */}
<div>
<label className={labelCls}>
<span className="text-red-400">*</span>
<span className="text-color-danger">*</span>
</label>
<input
type="text"
@ -261,7 +261,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
{/* 레이어전체명 */}
<div>
<label className={labelCls}>
<span className="text-red-400">*</span>
<span className="text-color-danger">*</span>
</label>
<input
type="text"
@ -321,7 +321,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
{/* 에러 */}
{formError && (
<div className="px-6 pb-2">
<p className="text-label-2 text-red-400 font-korean">{formError}</p>
<p className="text-label-2 text-color-danger font-korean">{formError}</p>
</div>
)}
{/* 버튼 */}
@ -448,7 +448,7 @@ const LayerPanel = () => {
<div className="px-6 py-4 border-b border-stroke shrink-0">
<div className="flex items-center justify-between mb-3">
<div>
<h1 className="text-lg font-bold text-fg font-korean"> </h1>
<h1 className="text-title-1 font-bold text-fg font-korean"> </h1>
<p className="text-caption text-fg-disabled mt-1 font-korean"> {total}</p>
</div>
<button
@ -487,7 +487,7 @@ const LayerPanel = () => {
{/* 오류 메시지 */}
{error && (
<div className="px-6 py-2 text-caption text-red-400 bg-[rgba(239,68,68,0.05)] border-b border-stroke shrink-0 font-korean">
<div className="px-6 py-2 text-caption text-color-danger bg-[rgba(239,68,68,0.08)] border-b border-stroke shrink-0 font-korean">
{error}
</div>
)}
@ -598,7 +598,7 @@ const LayerPanel = () => {
item.useYn === 'Y' && item.parentUseYn !== 'N'
? 'bg-color-accent'
: item.useYn === 'Y' && item.parentUseYn === 'N'
? 'bg-[rgba(6,182,212,0.4)]'
? 'bg-[rgba(6,182,212,0.3)]'
: 'bg-[rgba(255,255,255,0.08)] border border-stroke'
}`}
>
@ -620,7 +620,7 @@ const LayerPanel = () => {
</button>
<button
onClick={() => handleDelete(item.layerCd)}
className="px-3 py-1 text-caption rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 font-korean whitespace-nowrap"
className="px-3 py-1 text-caption rounded bg-[rgba(239,68,68,0.15)] text-color-danger hover:bg-[rgba(239,68,68,0.25)] font-korean whitespace-nowrap"
>
</button>

파일 보기

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { api } from '@common/services/api';
import { useMapStore } from '@common/store/mapStore';
/* eslint-disable react-refresh/only-export-components */
// ─── 타입 ─────────────────────────────────────────────────
interface MapBaseItem {
@ -101,7 +102,7 @@ function MapBaseModal({
{/* 지도 이름 */}
<div>
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
<span className="text-red-400">*</span>
<span className="text-color-danger">*</span>
</label>
<input
type="text"
@ -115,7 +116,7 @@ function MapBaseModal({
{/* 지도 키 */}
<div>
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
<span className="text-red-400">*</span>
<span className="text-color-danger">*</span>
</label>
<input
type="text"
@ -184,7 +185,7 @@ function MapBaseModal({
type="button"
onClick={() => setField('useYn', form.useYn === 'Y' ? 'N' : 'Y')}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
form.useYn === 'Y' ? 'bg-color-accent' : 'bg-border'
form.useYn === 'Y' ? 'bg-color-accent' : 'bg-bg-elevated'
}`}
>
<span
@ -200,7 +201,7 @@ function MapBaseModal({
</div>
{/* 에러 */}
{modalError && <p className="text-label-2 text-red-400 font-korean">{modalError}</p>}
{modalError && <p className="text-label-2 text-color-danger font-korean">{modalError}</p>}
</div>
{/* 모달 푸터 */}
@ -349,7 +350,7 @@ function MapBasePanel() {
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
<div>
<h1 className="text-lg font-bold text-fg font-korean"> </h1>
<h1 className="text-title-1 font-bold text-fg font-korean"> </h1>
<p className="text-caption text-fg-disabled mt-1 font-korean"> {total}</p>
</div>
<button
@ -365,8 +366,8 @@ function MapBasePanel() {
<div
className={`mx-6 mt-2 px-3 py-2 text-label-2 rounded-md font-korean ${
message.type === 'success'
? 'text-green-400 bg-[rgba(74,222,128,0.08)] border border-[rgba(74,222,128,0.2)]'
: 'text-red-400 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)]'
? 'text-color-success bg-[rgba(34,197,94,0.08)] border border-[rgba(34,197,94,0.2)]'
: 'text-color-danger bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)]'
}`}
>
{message.text}
@ -419,7 +420,7 @@ function MapBasePanel() {
<button
type="button"
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
item.useYn === 'Y' ? 'bg-color-accent' : 'bg-border'
item.useYn === 'Y' ? 'bg-color-accent' : 'bg-bg-elevated'
}`}
>
<span
@ -441,7 +442,7 @@ function MapBasePanel() {
<td className="py-3 text-center">
<button
onClick={() => handleDelete(item)}
className="px-3 py-1 text-caption rounded bg-red-500/20 text-red-400 hover:bg-red-500/30"
className="px-3 py-1 text-caption rounded bg-[rgba(239,68,68,0.15)] text-color-danger hover:bg-[rgba(239,68,68,0.25)]"
>
</button>
@ -478,7 +479,7 @@ function MapBasePanel() {
onClick={() => setPage(p)}
className={`w-7 h-7 text-caption rounded ${
p === page
? 'bg-blue-500/20 text-blue-400 font-medium'
? 'bg-[rgba(6,182,212,0.15)] text-color-accent font-medium'
: 'text-fg-disabled hover:bg-bg-elevated'
}`}
>

파일 보기

@ -135,7 +135,7 @@ function MenusPanel() {
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
<div>
<h1 className="text-lg font-bold text-fg font-korean"> </h1>
<h1 className="text-title-1 font-bold text-fg font-korean"> </h1>
<p className="text-caption text-fg-disabled mt-1 font-korean">
, , ,
</p>

파일 보기

@ -45,19 +45,19 @@ function formatTime(iso: string | null): string {
function StatusCell({ row }: { row: NumericalDataStatus }) {
if (row.lastStatus === 'COMPLETED') {
return <span className="text-emerald-400 text-caption"></span>;
return <span className="text-color-success text-caption"></span>;
}
if (row.lastStatus === 'FAILED') {
return (
<span className="text-red-400 text-caption">
<span className="text-color-danger text-caption">
{row.consecutiveFailures > 0 ? ` (${row.consecutiveFailures}회 연속)` : ''}
</span>
);
}
if (row.lastStatus === 'STARTED') {
return (
<span className="inline-flex items-center gap-1 text-cyan-400 text-caption">
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
<span className="inline-flex items-center gap-1 text-color-accent text-caption">
<span className="w-1.5 h-1.5 rounded-full bg-color-accent animate-pulse" />
</span>
);
@ -77,30 +77,30 @@ function StatusBadge({
if (loading) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-bg-elevated text-t2">
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
<span className="w-1.5 h-1.5 rounded-full bg-color-accent animate-pulse" />
...
</span>
);
}
if (errorCount === total && total > 0) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-red-500/10 text-red-400">
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(239,68,68,0.08)] text-color-danger">
<span className="w-1.5 h-1.5 rounded-full bg-color-danger" />
</span>
);
}
if (errorCount > 0) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-yellow-500/10 text-yellow-400">
<span className="w-1.5 h-1.5 rounded-full bg-yellow-400" />
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(234,179,8,0.08)] text-color-caution">
<span className="w-1.5 h-1.5 rounded-full bg-color-caution" />
({errorCount}/{total})
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-emerald-500/10 text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(34,197,94,0.08)] text-color-success">
<span className="w-1.5 h-1.5 rounded-full bg-color-success" />
</span>
);
@ -124,7 +124,7 @@ function ForecastTable({ rows, loading }: { rows: NumericalDataStatus[]; loading
{TABLE_HEADERS.map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
>
{h}
</th>
@ -134,7 +134,7 @@ function ForecastTable({ rows, loading }: { rows: NumericalDataStatus[]; loading
<tbody>
{loading && rows.length === 0
? Array.from({ length: 6 }).map((_, i) => (
<tr key={i} className="border-b border-stroke-1 animate-pulse">
<tr key={i} className="border-b border-stroke animate-pulse">
{TABLE_HEADERS.map((_, j) => (
<td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-elevated rounded w-16" />
@ -143,7 +143,7 @@ function ForecastTable({ rows, loading }: { rows: NumericalDataStatus[]; loading
</tr>
))
: rows.map((row) => (
<tr key={row.jobName} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<tr key={row.jobName} className="border-b border-stroke hover:bg-bg-surface/50">
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">
{row.modelName}
</td>
@ -192,7 +192,7 @@ export default function MonitorForecastPanel() {
return (
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke shrink-0">
<h2 className="text-body-2 font-semibold text-t1"> </h2>
<div className="flex items-center gap-3">
{lastUpdate && (
@ -229,14 +229,14 @@ export default function MonitorForecastPanel() {
</div>
{/* 탭 */}
<div className="flex gap-0 border-b border-stroke-1 shrink-0 px-5">
<div className="flex gap-0 border-b border-stroke shrink-0 px-5">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2.5 text-caption font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-cyan-400 text-cyan-400'
? 'border-color-accent text-color-accent'
: 'border-transparent text-t3 hover:text-t2'
}`}
>
@ -246,7 +246,7 @@ export default function MonitorForecastPanel() {
</div>
{/* 상태 표시줄 */}
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base">
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke bg-bg-base">
<StatusBadge loading={loading} errorCount={errorCount} total={totalCount} />
{!loading && totalCount > 0 && (
<span className="text-caption text-t3"> {totalCount}</span>

파일 보기

@ -1,18 +1,17 @@
import { useState, useEffect, useCallback } from 'react';
import {
getRecentObservation,
OBS_STATION_CODES,
type RecentObservation,
} from '@tabs/weather/services/khoaApi';
import { getRecentObservation, OBS_STATION_CODES } from '@components/weather/services/khoaApi';
import {
getUltraShortForecast,
getMarineForecast,
convertToGridCoords,
getCurrentBaseDateTime,
MARINE_REGIONS,
type WeatherForecastData,
type MarineWeatherData,
} from '@tabs/weather/services/weatherApi';
} from '@components/weather/services/weatherApi';
import type {
RecentObservation,
WeatherForecastData,
MarineWeatherData,
} from '@interfaces/weather/WeatherInterface';
const KEY_TO_NAME: Record<string, string> = {
incheon: '인천',
@ -85,30 +84,30 @@ function StatusBadge({
if (loading) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-bg-elevated text-t2">
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
<span className="w-1.5 h-1.5 rounded-full bg-color-accent animate-pulse" />
...
</span>
);
}
if (errorCount === total) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-red-500/10 text-red-400">
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(239,68,68,0.08)] text-color-danger">
<span className="w-1.5 h-1.5 rounded-full bg-color-danger" />
</span>
);
}
if (errorCount > 0) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-yellow-500/10 text-yellow-400">
<span className="w-1.5 h-1.5 rounded-full bg-yellow-400" />
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(234,179,8,0.08)] text-color-caution">
<span className="w-1.5 h-1.5 rounded-full bg-color-caution" />
({errorCount}/{total})
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-emerald-500/10 text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(34,197,94,0.08)] text-color-success">
<span className="w-1.5 h-1.5 rounded-full bg-color-success" />
</span>
);
@ -136,7 +135,7 @@ function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) {
{headers.map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
>
{h}
</th>
@ -146,7 +145,7 @@ function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) {
<tbody>
{loading && rows.length === 0
? Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-stroke-1 animate-pulse">
<tr key={i} className="border-b border-stroke animate-pulse">
{headers.map((_, j) => (
<td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-elevated rounded w-12" />
@ -157,7 +156,7 @@ function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) {
: rows.map((row) => (
<tr
key={row.stationName}
className="border-b border-stroke-1 hover:bg-bg-surface/50"
className="border-b border-stroke hover:bg-bg-surface/50"
>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">
{row.stationName}
@ -172,9 +171,9 @@ function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) {
<td className="px-3 py-2 text-t2">{fmt(row.data?.tide_level, 0)}</td>
<td className="px-3 py-2">
{row.error ? (
<span className="text-red-400 text-caption"></span>
<span className="text-color-danger text-caption"></span>
) : row.data ? (
<span className="text-emerald-400 text-caption"></span>
<span className="text-color-success text-caption"></span>
) : (
<span className="text-t3 text-caption">-</span>
)}
@ -207,7 +206,7 @@ function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolea
{headers.map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
>
{h}
</th>
@ -217,7 +216,7 @@ function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolea
<tbody>
{loading && rows.length === 0
? Array.from({ length: 3 }).map((_, i) => (
<tr key={i} className="border-b border-stroke-1 animate-pulse">
<tr key={i} className="border-b border-stroke animate-pulse">
{headers.map((_, j) => (
<td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-elevated rounded w-12" />
@ -228,7 +227,7 @@ function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolea
: rows.map((row) => (
<tr
key={row.stationName}
className="border-b border-stroke-1 hover:bg-bg-surface/50"
className="border-b border-stroke hover:bg-bg-surface/50"
>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">
{row.stationName}
@ -241,9 +240,9 @@ function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolea
<td className="px-3 py-2 text-t2">{fmt(row.data?.humidity, 0)}</td>
<td className="px-3 py-2">
{row.error ? (
<span className="text-red-400 text-caption"></span>
<span className="text-color-danger text-caption"></span>
) : row.data ? (
<span className="text-emerald-400 text-caption"></span>
<span className="text-color-success text-caption"></span>
) : (
<span className="text-t3 text-caption">-</span>
)}
@ -267,7 +266,7 @@ function MarineTable({ rows, loading }: { rows: MarineRow[]; loading: boolean })
{headers.map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
>
{h}
</th>
@ -277,7 +276,7 @@ function MarineTable({ rows, loading }: { rows: MarineRow[]; loading: boolean })
<tbody>
{loading && rows.length === 0
? Array.from({ length: 4 }).map((_, i) => (
<tr key={i} className="border-b border-stroke-1 animate-pulse">
<tr key={i} className="border-b border-stroke animate-pulse">
{headers.map((_, j) => (
<td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-elevated rounded w-14" />
@ -286,7 +285,7 @@ function MarineTable({ rows, loading }: { rows: MarineRow[]; loading: boolean })
</tr>
))
: rows.map((row) => (
<tr key={row.regId} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<tr key={row.regId} className="border-b border-stroke hover:bg-bg-surface/50">
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.name}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.waveHeight)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.windSpeed)}</td>
@ -294,9 +293,9 @@ function MarineTable({ rows, loading }: { rows: MarineRow[]; loading: boolean })
<td className="px-3 py-2 text-t2">{fmt(row.data?.temperature)}</td>
<td className="px-3 py-2">
{row.error ? (
<span className="text-red-400 text-caption"></span>
<span className="text-color-danger text-caption"></span>
) : row.data ? (
<span className="text-emerald-400 text-caption"></span>
<span className="text-color-success text-caption"></span>
) : (
<span className="text-t3 text-caption">-</span>
)}
@ -440,7 +439,7 @@ export default function MonitorRealtimePanel() {
return (
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke shrink-0">
<h2 className="text-body-2 font-semibold text-t1"> </h2>
<div className="flex items-center gap-3">
{lastUpdate && (
@ -477,14 +476,14 @@ export default function MonitorRealtimePanel() {
</div>
{/* 탭 */}
<div className="flex gap-0 border-b border-stroke-1 shrink-0 px-5">
<div className="flex gap-0 border-b border-stroke shrink-0 px-5">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2.5 text-caption font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-cyan-400 text-cyan-400'
? 'border-color-accent text-color-accent'
: 'border-transparent text-t3 hover:text-t2'
}`}
>
@ -494,7 +493,7 @@ export default function MonitorRealtimePanel() {
</div>
{/* 상태 표시줄 */}
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base">
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke bg-bg-base">
<StatusBadge loading={isLoading} errorCount={errorCount} total={totalCount} />
<span className="text-caption text-t3">
{activeTab === 'khoa' && `관측소 ${totalCount}`}

파일 보기

@ -301,7 +301,7 @@ function StatusBadge({
if (loading) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-bg-elevated text-t2">
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
<span className="w-1.5 h-1.5 rounded-full bg-color-accent animate-pulse" />
...
</span>
);
@ -309,23 +309,23 @@ function StatusBadge({
const offCount = total - onCount;
if (offCount === total) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-red-500/10 text-red-400">
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(239,68,68,0.08)] text-color-danger">
<span className="w-1.5 h-1.5 rounded-full bg-color-danger" />
OFF
</span>
);
}
if (offCount > 0) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-yellow-500/10 text-yellow-400">
<span className="w-1.5 h-1.5 rounded-full bg-yellow-400" />
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(234,179,8,0.08)] text-color-caution">
<span className="w-1.5 h-1.5 rounded-full bg-color-caution" />
OFF ({offCount}/{total})
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-emerald-500/10 text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-[rgba(34,197,94,0.08)] text-color-success">
<span className="w-1.5 h-1.5 rounded-full bg-color-success" />
</span>
);
@ -342,7 +342,7 @@ function ConnectionBadge({
if (isNormal) {
return (
<div className="flex flex-col items-start gap-0.5">
<span className="inline-flex items-center px-2 py-0.5 rounded text-label-2 font-semibold bg-blue-600 text-white">
<span className="inline-flex items-center px-2 py-0.5 rounded text-label-2 font-semibold bg-color-accent text-white">
ON
</span>
{lastMessageTime && <span className="text-caption text-t3">{lastMessageTime}</span>}
@ -351,7 +351,7 @@ function ConnectionBadge({
}
return (
<div className="flex flex-col items-start gap-0.5">
<span className="inline-flex items-center px-2 py-0.5 rounded text-label-2 font-semibold bg-orange-500 text-white">
<span className="inline-flex items-center px-2 py-0.5 rounded text-label-2 font-semibold bg-color-warning text-white">
OFF
</span>
{lastMessageTime && <span className="text-caption text-t3">{lastMessageTime}</span>}
@ -382,7 +382,7 @@ function VesselTable({ rows, loading }: { rows: VesselMonitorRow[]; loading: boo
{HEADERS.map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
>
{h}
</th>
@ -392,7 +392,7 @@ function VesselTable({ rows, loading }: { rows: VesselMonitorRow[]; loading: boo
<tbody>
{loading && rows.length === 0
? Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b border-stroke-1 animate-pulse">
<tr key={i} className="border-b border-stroke animate-pulse">
{HEADERS.map((_, j) => (
<td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-elevated rounded w-14" />
@ -403,7 +403,7 @@ function VesselTable({ rows, loading }: { rows: VesselMonitorRow[]; loading: boo
: rows.map((row, idx) => (
<tr
key={`${row.institutionCode}-${row.systemName}`}
className="border-b border-stroke-1 hover:bg-bg-surface/50"
className="border-b border-stroke hover:bg-bg-surface/50"
>
<td className="px-3 py-2 text-t2 text-center">{idx + 1}</td>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">
@ -461,7 +461,7 @@ export default function MonitorVesselPanel() {
return (
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke shrink-0">
<h2 className="text-body-2 font-semibold text-t1"> </h2>
<div className="flex items-center gap-3">
{lastUpdate && (
@ -498,7 +498,7 @@ export default function MonitorVesselPanel() {
</div>
{/* 상태 표시줄 */}
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base">
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke bg-bg-base">
<StatusBadge loading={loading} onCount={onCount} total={rows.length} />
<span className="text-caption text-t3">
{rows.length} (ON: {onCount} / OFF: {rows.length - onCount})

파일 보기

@ -0,0 +1,417 @@
import { useState, useEffect, useCallback } from 'react';
import {
fetchRoles,
fetchPermTree,
type RoleWithPermissions,
type PermTreeNode,
} from '@common/services/authApi';
import { RolePermTab } from './contents/RolePermTab';
import { UserPermTab } from './contents/UserPermTab';
/* eslint-disable react-refresh/only-export-components */
// ─── 오퍼레이션 코드 ─────────────────────────────────
export const OPER_CODES = ['READ', 'CREATE', 'UPDATE', 'DELETE'] as const;
export type OperCode = (typeof OPER_CODES)[number];
export const OPER_LABELS: Record<OperCode, string> = { READ: 'R', CREATE: 'C', UPDATE: 'U', DELETE: 'D' };
export const OPER_FULL_LABELS: Record<OperCode, string> = {
READ: '조회',
CREATE: '생성',
UPDATE: '수정',
DELETE: '삭제',
};
// ─── 권한 상태 타입 ─────────────────────────────────────
export type PermState = 'explicit-granted' | 'inherited-granted' | 'explicit-denied' | 'forced-denied';
// ─── 키 유틸 ──────────────────────────────────────────
export function makeKey(rsrc: string, oper: string): string {
return `${rsrc}::${oper}`;
}
// ─── 유틸: 플랫 노드 목록 추출 (트리 DFS) ─────────────
export function flattenTree(nodes: PermTreeNode[]): PermTreeNode[] {
const result: PermTreeNode[] = [];
function walk(list: PermTreeNode[]) {
for (const n of list) {
result.push(n);
if (n.children.length > 0) walk(n.children);
}
}
walk(nodes);
return result;
}
// ─── 유틸: 권한 상태 계산 (오퍼레이션별) ──────────────
function resolvePermStateForOper(
code: string,
parentCode: string | null,
operCd: string,
explicitPerms: Map<string, boolean>,
cache: Map<string, PermState>,
): PermState {
const key = makeKey(code, operCd);
const cached = cache.get(key);
if (cached) return cached;
const explicit = explicitPerms.get(key);
if (parentCode === null) {
const state: PermState =
explicit === true
? 'explicit-granted'
: explicit === false
? 'explicit-denied'
: 'explicit-denied';
cache.set(key, state);
return state;
}
// 부모 READ 확인 (접근 게이트)
const parentReadKey = makeKey(parentCode, 'READ');
const parentReadState = cache.get(parentReadKey);
if (parentReadState === 'explicit-denied' || parentReadState === 'forced-denied') {
cache.set(key, 'forced-denied');
return 'forced-denied';
}
if (explicit === true) {
cache.set(key, 'explicit-granted');
return 'explicit-granted';
}
if (explicit === false) {
cache.set(key, 'explicit-denied');
return 'explicit-denied';
}
// 부모의 같은 오퍼레이션 상속
const parentOperKey = makeKey(parentCode, operCd);
const parentOperState = cache.get(parentOperKey);
if (parentOperState === 'explicit-granted' || parentOperState === 'inherited-granted') {
cache.set(key, 'inherited-granted');
return 'inherited-granted';
}
if (parentOperState === 'forced-denied') {
cache.set(key, 'forced-denied');
return 'forced-denied';
}
cache.set(key, 'explicit-denied');
return 'explicit-denied';
}
export function buildEffectiveStates(
flatNodes: PermTreeNode[],
explicitPerms: Map<string, boolean>,
): Map<string, PermState> {
const cache = new Map<string, PermState>();
for (const node of flatNodes) {
// READ 먼저 (CUD는 READ에 의존)
resolvePermStateForOper(node.code, node.parentCode, 'READ', explicitPerms, cache);
for (const oper of OPER_CODES) {
if (oper === 'READ') continue;
resolvePermStateForOper(node.code, node.parentCode, oper, explicitPerms, cache);
}
}
return cache;
}
type ActiveTab = 'role' | 'user';
function PermissionsPanel() {
const [activeTab, setActiveTab] = useState<ActiveTab>('role');
const [roles, setRoles] = useState<RoleWithPermissions[]>([]);
const [permTree, setPermTree] = useState<PermTreeNode[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
const [showCreateForm, setShowCreateForm] = useState(false);
const [newRoleCode, setNewRoleCode] = useState('');
const [newRoleName, setNewRoleName] = useState('');
const [newRoleDesc, setNewRoleDesc] = useState('');
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState('');
const [editingRoleSn, setEditingRoleSn] = useState<number | null>(null);
const [editRoleName, setEditRoleName] = useState('');
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [selectedRoleSn, setSelectedRoleSn] = useState<number | null>(null);
// 역할별 명시적 권한: Map<roleSn, Map<"rsrc::oper", boolean>>
const [rolePerms, setRolePerms] = useState<Map<number, Map<string, boolean>>>(new Map());
const loadData = useCallback(async () => {
setLoading(true);
try {
const [rolesData, treeData] = await Promise.all([fetchRoles(), fetchPermTree()]);
setRoles(rolesData);
setPermTree(treeData);
// 명시적 권한 맵 초기화 (rsrc::oper 키 형식)
const permsMap = new Map<number, Map<string, boolean>>();
for (const role of rolesData) {
const roleMap = new Map<string, boolean>();
for (const p of role.permissions) {
roleMap.set(makeKey(p.resourceCode, p.operationCode), p.granted);
}
permsMap.set(role.sn, roleMap);
}
setRolePerms(permsMap);
// 최상위 노드 기본 펼침
setExpanded(new Set(treeData.map((n) => n.code)));
// 첫 번째 역할 선택
if (rolesData.length > 0 && !selectedRoleSn) {
setSelectedRoleSn(rolesData[0].sn);
}
setDirty(false);
} catch (err) {
console.error('권한 데이터 조회 실패:', err);
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- 초기 마운트 시 1회만 실행
}, []);
useEffect(() => {
loadData();
}, [loadData]);
// 플랫 노드 목록
const flatNodes = flattenTree(permTree);
const handleToggleExpand = useCallback((code: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(code)) next.delete(code);
else next.add(code);
return next;
});
}, []);
const handleTogglePerm = useCallback(
(code: string, oper: OperCode, currentState: PermState) => {
if (!selectedRoleSn) return;
setRolePerms((prev) => {
const next = new Map(prev);
const roleMap = new Map(next.get(selectedRoleSn) ?? new Map());
const key = makeKey(code, oper);
const node = flatNodes.find((n) => n.code === code);
const isRoot = node ? node.parentCode === null : false;
switch (currentState) {
case 'explicit-granted':
roleMap.set(key, false);
break;
case 'inherited-granted':
roleMap.set(key, false);
break;
case 'explicit-denied':
if (isRoot) {
roleMap.set(key, true);
} else {
roleMap.delete(key);
}
break;
default:
return prev;
}
next.set(selectedRoleSn, roleMap);
return next;
});
setDirty(true);
},
[selectedRoleSn, flatNodes],
);
const handleSave = async () => {
setSaving(true);
setSaveError(null);
try {
for (const role of roles) {
const perms = rolePerms.get(role.sn);
if (!perms) continue;
const permsList: Array<{ resourceCode: string; operationCode: string; granted: boolean }> =
[];
for (const [key, granted] of perms) {
const sepIdx = key.indexOf('::');
permsList.push({
resourceCode: key.substring(0, sepIdx),
operationCode: key.substring(sepIdx + 2),
granted,
});
}
await updatePermissionsApi(role.sn, permsList);
}
setDirty(false);
} catch (err) {
console.error('권한 저장 실패:', err);
setSaveError('권한 저장에 실패했습니다. 다시 시도해주세요.');
} finally {
setSaving(false);
}
};
const handleCreateRole = async () => {
setCreating(true);
setCreateError('');
try {
await createRoleApi({
code: newRoleCode,
name: newRoleName,
description: newRoleDesc || undefined,
});
await loadData();
setShowCreateForm(false);
setNewRoleCode('');
setNewRoleName('');
setNewRoleDesc('');
} catch (err) {
const message = err instanceof Error ? err.message : '역할 생성에 실패했습니다.';
setCreateError(message);
} finally {
setCreating(false);
}
};
const handleDeleteRole = async (roleSn: number, roleName: string) => {
if (
!window.confirm(
`"${roleName}" 역할을 삭제하시겠습니까?\n이 역할을 가진 모든 사용자에서 해당 역할이 제거됩니다.`,
)
) {
return;
}
try {
await deleteRoleApi(roleSn);
if (selectedRoleSn === roleSn) setSelectedRoleSn(null);
await loadData();
} catch (err) {
console.error('역할 삭제 실패:', err);
}
};
const handleStartEditName = (role: RoleWithPermissions) => {
setEditingRoleSn(role.sn);
setEditRoleName(role.name);
};
const handleSaveRoleName = async (roleSn: number) => {
if (!editRoleName.trim()) return;
try {
await updateRoleApi(roleSn, { name: editRoleName.trim() });
setRoles((prev) =>
prev.map((r) => (r.sn === roleSn ? { ...r, name: editRoleName.trim() } : r)),
);
setEditingRoleSn(null);
} catch (err) {
console.error('역할 이름 수정 실패:', err);
}
};
const toggleDefault = async (roleSn: number) => {
const role = roles.find((r) => r.sn === roleSn);
if (!role) return;
const newValue = !role.isDefault;
try {
await updateRoleDefaultApi(roleSn, newValue);
setRoles((prev) => prev.map((r) => (r.sn === roleSn ? { ...r, isDefault: newValue } : r)));
} catch (err) {
console.error('기본 역할 변경 실패:', err);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-32 text-fg-disabled text-body-2 font-korean">
...
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* 헤더 */}
<div
className="flex items-center justify-between px-4 py-2.5 border-b border-stroke"
style={{ flexShrink: 0 }}
>
<div>
<h1 className="text-body-2 font-bold text-fg font-korean"> </h1>
<p className="text-caption text-fg-disabled mt-0.5 font-korean">
× CRUD
</p>
</div>
{/* 탭 전환 */}
<div className="flex items-center gap-1 p-1 bg-bg-elevated rounded-lg border border-stroke">
<button
onClick={() => setActiveTab('role')}
className={`px-4 py-1.5 text-caption font-semibold rounded-md transition-all font-korean ${
activeTab === 'role'
? 'bg-color-accent text-bg-0 shadow-[0_0_8px_rgba(6,182,212,0.25)]'
: 'text-fg-disabled hover:text-fg-sub'
}`}
>
</button>
<button
onClick={() => setActiveTab('user')}
className={`px-4 py-1.5 text-caption font-semibold rounded-md transition-all font-korean ${
activeTab === 'user'
? 'bg-color-accent text-bg-0 shadow-[0_0_8px_rgba(6,182,212,0.25)]'
: 'text-fg-disabled hover:text-fg-sub'
}`}
>
</button>
</div>
</div>
{activeTab === 'role' ? (
<RolePermTab
roles={roles}
permTree={permTree}
rolePerms={rolePerms}
setRolePerms={setRolePerms}
selectedRoleSn={selectedRoleSn}
setSelectedRoleSn={setSelectedRoleSn}
dirty={dirty}
saving={saving}
saveError={saveError}
handleSave={handleSave}
handleToggleExpand={handleToggleExpand}
handleTogglePerm={handleTogglePerm}
expanded={expanded}
flatNodes={flatNodes}
editingRoleSn={editingRoleSn}
editRoleName={editRoleName}
setEditRoleName={setEditRoleName}
handleStartEditName={handleStartEditName}
handleSaveRoleName={handleSaveRoleName}
setEditingRoleSn={setEditingRoleSn}
toggleDefault={toggleDefault}
handleDeleteRole={handleDeleteRole}
showCreateForm={showCreateForm}
setShowCreateForm={setShowCreateForm}
setCreateError={setCreateError}
newRoleCode={newRoleCode}
setNewRoleCode={setNewRoleCode}
newRoleName={newRoleName}
setNewRoleName={setNewRoleName}
newRoleDesc={newRoleDesc}
setNewRoleDesc={setNewRoleDesc}
creating={creating}
createError={createError}
handleCreateRole={handleCreateRole}
/>
) : (
<UserPermTab roles={roles} permTree={permTree} rolePerms={rolePerms} />
)}
</div>
);
}
export default PermissionsPanel;

파일 보기

@ -257,34 +257,34 @@ function fetchHnsAtmosData(): Promise<HnsAtmosData> {
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
function getPipelineStatusStyle(status: PipelineStatus): string {
if (status === '정상') return 'text-emerald-400 bg-emerald-500/10';
if (status === '지연') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
if (status === '정상') return 'text-color-success bg-[rgba(34,197,94,0.08)]';
if (status === '지연') return 'text-color-caution bg-[rgba(234,179,8,0.08)]';
return 'text-color-danger bg-[rgba(239,68,68,0.08)]';
}
function getPipelineBorderStyle(status: PipelineStatus): string {
if (status === '정상') return 'border-l-emerald-500';
if (status === '지연') return 'border-l-yellow-500';
return 'border-l-red-500';
if (status === '정상') return 'border-l-color-success';
if (status === '지연') return 'border-l-color-caution';
return 'border-l-color-danger';
}
function getReceiveStatusStyle(status: ReceiveStatus): string {
if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10';
if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
if (status === '수신완료') return 'text-color-success bg-[rgba(34,197,94,0.08)]';
if (status === '수신대기') return 'text-color-caution bg-[rgba(234,179,8,0.08)]';
return 'text-color-danger bg-[rgba(239,68,68,0.08)]';
}
function getProcessStatusStyle(status: ProcessStatus): string {
if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10';
if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10';
if (status === '대기') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
if (status === '처리완료') return 'text-color-success bg-[rgba(34,197,94,0.08)]';
if (status === '처리중') return 'text-color-accent bg-[rgba(6,182,212,0.08)]';
if (status === '대기') return 'text-color-caution bg-[rgba(234,179,8,0.08)]';
return 'text-color-danger bg-[rgba(239,68,68,0.08)]';
}
function getAlertStyle(level: AlertLevel): string {
if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30';
if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30';
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30';
if (level === '경고') return 'text-color-danger bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)]';
if (level === '주의') return 'text-color-caution bg-[rgba(234,179,8,0.08)] border-[rgba(234,179,8,0.3)]';
return 'text-color-accent bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)]';
}
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
@ -295,9 +295,9 @@ function PipelineCard({ node }: { node: PipelineNode }) {
return (
<div
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
>
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
<div className="text-caption font-medium text-t1 leading-snug">{node.name}</div>
<span
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
>
@ -316,7 +316,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-1">
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
{i < 4 && <span className="text-t3 text-sm px-0.5"></span>}
{i < 4 && <span className="text-t3 text-body-2 px-0.5"></span>}
</div>
))}
</div>
@ -328,7 +328,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
{nodes.map((node, idx) => (
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
<PipelineCard node={node} />
{idx < nodes.length - 1 && <span className="text-t3 text-sm shrink-0 px-0.5"></span>}
{idx < nodes.length - 1 && <span className="text-t3 text-body-2 shrink-0 px-0.5"></span>}
</div>
))}
</div>
@ -367,13 +367,13 @@ const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{LOG_HEADERS.map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
>
{h}
</th>
@ -383,7 +383,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
<tbody>
{loading && rows.length === 0
? Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b border-stroke-1 animate-pulse">
<tr key={i} className="border-b border-stroke animate-pulse">
{LOG_HEADERS.map((_, j) => (
<td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-elevated rounded w-16" />
@ -392,7 +392,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
</tr>
))
: rows.map((row) => (
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<tr key={row.id} className="border-b border-stroke hover:bg-bg-surface/50">
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.timestamp}</td>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
@ -440,7 +440,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean
}
if (alerts.length === 0) {
return <p className="text-xs text-t3 py-2"> .</p>;
return <p className="text-caption text-t3 py-2"> .</p>;
}
return (
@ -448,7 +448,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean
{alerts.map((alert) => (
<div
key={alert.id}
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
className={`flex items-start gap-2 px-3 py-2 rounded border text-caption ${getAlertStyle(alert.level)}`}
>
<span className="font-semibold shrink-0">[{alert.level}]</span>
<span className="flex-1">{alert.message}</span>
@ -507,12 +507,12 @@ export default function RndHnsAtmosPanel() {
return (
<div className="flex flex-col h-full overflow-hidden">
{/* ── 헤더 ── */}
<div className="shrink-0 border-b border-stroke-1">
<div className="shrink-0 border-b border-stroke">
<div className="flex items-center justify-between px-5 py-3">
<h2 className="text-sm font-semibold text-t1">HNS () </h2>
<h2 className="text-body-2 font-semibold text-t1">HNS () </h2>
<div className="flex items-center gap-3">
{lastUpdate && (
<span className="text-xs text-t3">
<span className="text-caption text-t3">
:{' '}
{lastUpdate.toLocaleTimeString('ko-KR', {
hour: '2-digit',
@ -524,7 +524,7 @@ export default function RndHnsAtmosPanel() {
<button
onClick={() => void fetchData()}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="flex items-center gap-1.5 px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
@ -544,21 +544,21 @@ export default function RndHnsAtmosPanel() {
</div>
</div>
{/* 요약 통계 바 */}
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-caption text-t3 border-t border-stroke">
<span>
: <span className="text-emerald-400 font-medium">{totalReceived}</span>
: <span className="text-color-success font-medium">{totalReceived}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-yellow-400 font-medium">{totalDelayed}</span>
: <span className="text-color-caution font-medium">{totalDelayed}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-red-400 font-medium">{totalFailed}</span>
: <span className="text-color-danger font-medium">{totalFailed}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-cyan-400 font-medium">2 / 4</span>
: <span className="text-color-accent font-medium">2 / 4</span>
</span>
</div>
</div>
@ -566,17 +566,17 @@ export default function RndHnsAtmosPanel() {
{/* ── 스크롤 영역 ── */}
<div className="flex-1 overflow-auto">
{/* 파이프라인 현황 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
<section className="px-5 pt-4 pb-3 border-b border-stroke">
<h3 className="text-caption font-semibold text-t2 mb-3 uppercase tracking-wide">
</h3>
<PipelineFlow nodes={pipeline} loading={loading} />
</section>
{/* 필터 바 + 수신 이력 테이블 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<section className="px-5 pt-4 pb-3 border-b border-stroke">
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
<h3 className="text-caption font-semibold text-t2 uppercase tracking-wide shrink-0">
</h3>
<div className="flex items-center gap-2 flex-wrap">
@ -584,7 +584,7 @@ export default function RndHnsAtmosPanel() {
<select
value={filterSource}
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors"
>
<option value="all"> ()</option>
<option value="HYCOM">HYCOM</option>
@ -595,7 +595,7 @@ export default function RndHnsAtmosPanel() {
<select
value={filterReceive}
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors"
>
<option value="all"> ()</option>
<option value="수신완료"></option>
@ -607,13 +607,13 @@ export default function RndHnsAtmosPanel() {
<select
value={filterPeriod}
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors"
>
<option value="6h"> 6</option>
<option value="12h"> 12</option>
<option value="24h"> 24</option>
</select>
<span className="text-xs text-t3">{filteredLogs.length}</span>
<span className="text-caption text-t3">{filteredLogs.length}</span>
</div>
</div>
<DataLogTable rows={filteredLogs} loading={loading} />
@ -621,7 +621,7 @@ export default function RndHnsAtmosPanel() {
{/* 알림 현황 */}
<section className="px-5 pt-4 pb-5">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3"> </h3>
<h3 className="text-caption font-semibold text-t2 uppercase tracking-wide mb-3"> </h3>
<AlertList alerts={alerts} loading={loading} />
</section>
</div>

파일 보기

@ -257,34 +257,34 @@ function fetchKospsData(): Promise<KospsData> {
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
function getPipelineStatusStyle(status: PipelineStatus): string {
if (status === '정상') return 'text-emerald-400 bg-emerald-500/10';
if (status === '지연') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
if (status === '정상') return 'text-color-success bg-[rgba(34,197,94,0.08)]';
if (status === '지연') return 'text-color-caution bg-[rgba(234,179,8,0.08)]';
return 'text-color-danger bg-[rgba(239,68,68,0.08)]';
}
function getPipelineBorderStyle(status: PipelineStatus): string {
if (status === '정상') return 'border-l-emerald-500';
if (status === '지연') return 'border-l-yellow-500';
return 'border-l-red-500';
if (status === '정상') return 'border-l-color-success';
if (status === '지연') return 'border-l-color-caution';
return 'border-l-color-danger';
}
function getReceiveStatusStyle(status: ReceiveStatus): string {
if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10';
if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
if (status === '수신완료') return 'text-color-success bg-[rgba(34,197,94,0.08)]';
if (status === '수신대기') return 'text-color-caution bg-[rgba(234,179,8,0.08)]';
return 'text-color-danger bg-[rgba(239,68,68,0.08)]';
}
function getProcessStatusStyle(status: ProcessStatus): string {
if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10';
if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10';
if (status === '대기') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
if (status === '처리완료') return 'text-color-success bg-[rgba(34,197,94,0.08)]';
if (status === '처리중') return 'text-color-accent bg-[rgba(6,182,212,0.08)]';
if (status === '대기') return 'text-color-caution bg-[rgba(234,179,8,0.08)]';
return 'text-color-danger bg-[rgba(239,68,68,0.08)]';
}
function getAlertStyle(level: AlertLevel): string {
if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30';
if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30';
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30';
if (level === '경고') return 'text-color-danger bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)]';
if (level === '주의') return 'text-color-caution bg-[rgba(234,179,8,0.08)] border-[rgba(234,179,8,0.3)]';
return 'text-color-accent bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)]';
}
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
@ -295,9 +295,9 @@ function PipelineCard({ node }: { node: PipelineNode }) {
return (
<div
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
>
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
<div className="text-caption font-medium text-t1 leading-snug">{node.name}</div>
<span
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
>
@ -316,7 +316,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-1">
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
{i < 4 && <span className="text-t3 text-sm px-0.5"></span>}
{i < 4 && <span className="text-t3 text-body-2 px-0.5"></span>}
</div>
))}
</div>
@ -328,7 +328,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
{nodes.map((node, idx) => (
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
<PipelineCard node={node} />
{idx < nodes.length - 1 && <span className="text-t3 text-sm shrink-0 px-0.5"></span>}
{idx < nodes.length - 1 && <span className="text-t3 text-body-2 shrink-0 px-0.5"></span>}
</div>
))}
</div>
@ -367,13 +367,13 @@ const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{LOG_HEADERS.map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
>
{h}
</th>
@ -383,7 +383,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
<tbody>
{loading && rows.length === 0
? Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b border-stroke-1 animate-pulse">
<tr key={i} className="border-b border-stroke animate-pulse">
{LOG_HEADERS.map((_, j) => (
<td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-elevated rounded w-16" />
@ -392,7 +392,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
</tr>
))
: rows.map((row) => (
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<tr key={row.id} className="border-b border-stroke hover:bg-bg-surface/50">
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.timestamp}</td>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
@ -440,7 +440,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean
}
if (alerts.length === 0) {
return <p className="text-xs text-t3 py-2"> .</p>;
return <p className="text-caption text-t3 py-2"> .</p>;
}
return (
@ -448,7 +448,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean
{alerts.map((alert) => (
<div
key={alert.id}
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
className={`flex items-start gap-2 px-3 py-2 rounded border text-caption ${getAlertStyle(alert.level)}`}
>
<span className="font-semibold shrink-0">[{alert.level}]</span>
<span className="flex-1">{alert.message}</span>
@ -507,12 +507,12 @@ export default function RndKospsPanel() {
return (
<div className="flex flex-col h-full overflow-hidden">
{/* ── 헤더 ── */}
<div className="shrink-0 border-b border-stroke-1">
<div className="shrink-0 border-b border-stroke">
<div className="flex items-center justify-between px-5 py-3">
<h2 className="text-sm font-semibold text-t1"> (KOSPS) </h2>
<h2 className="text-body-2 font-semibold text-t1"> (KOSPS) </h2>
<div className="flex items-center gap-3">
{lastUpdate && (
<span className="text-xs text-t3">
<span className="text-caption text-t3">
:{' '}
{lastUpdate.toLocaleTimeString('ko-KR', {
hour: '2-digit',
@ -524,7 +524,7 @@ export default function RndKospsPanel() {
<button
onClick={() => void fetchData()}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="flex items-center gap-1.5 px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
@ -544,21 +544,21 @@ export default function RndKospsPanel() {
</div>
</div>
{/* 요약 통계 바 */}
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-caption text-t3 border-t border-stroke">
<span>
: <span className="text-emerald-400 font-medium">{totalReceived}</span>
: <span className="text-color-success font-medium">{totalReceived}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-yellow-400 font-medium">{totalDelayed}</span>
: <span className="text-color-caution font-medium">{totalDelayed}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-red-400 font-medium">{totalFailed}</span>
: <span className="text-color-danger font-medium">{totalFailed}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-cyan-400 font-medium">3 / 6</span>
: <span className="text-color-accent font-medium">3 / 6</span>
</span>
</div>
</div>
@ -566,17 +566,17 @@ export default function RndKospsPanel() {
{/* ── 스크롤 영역 ── */}
<div className="flex-1 overflow-auto">
{/* 파이프라인 현황 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
<section className="px-5 pt-4 pb-3 border-b border-stroke">
<h3 className="text-caption font-semibold text-t2 mb-3 uppercase tracking-wide">
</h3>
<PipelineFlow nodes={pipeline} loading={loading} />
</section>
{/* 필터 바 + 수신 이력 테이블 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<section className="px-5 pt-4 pb-3 border-b border-stroke">
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
<h3 className="text-caption font-semibold text-t2 uppercase tracking-wide shrink-0">
</h3>
<div className="flex items-center gap-2 flex-wrap">
@ -584,7 +584,7 @@ export default function RndKospsPanel() {
<select
value={filterSource}
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors"
>
<option value="all"> ()</option>
<option value="HYCOM">HYCOM</option>
@ -595,7 +595,7 @@ export default function RndKospsPanel() {
<select
value={filterReceive}
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors"
>
<option value="all"> ()</option>
<option value="수신완료"></option>
@ -607,13 +607,13 @@ export default function RndKospsPanel() {
<select
value={filterPeriod}
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors"
>
<option value="6h"> 6</option>
<option value="12h"> 12</option>
<option value="24h"> 24</option>
</select>
<span className="text-xs text-t3">{filteredLogs.length}</span>
<span className="text-caption text-t3">{filteredLogs.length}</span>
</div>
</div>
<DataLogTable rows={filteredLogs} loading={loading} />
@ -621,7 +621,7 @@ export default function RndKospsPanel() {
{/* 알림 현황 */}
<section className="px-5 pt-4 pb-5">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3"> </h3>
<h3 className="text-caption font-semibold text-t2 uppercase tracking-wide mb-3"> </h3>
<AlertList alerts={alerts} loading={loading} />
</section>
</div>

파일 보기

@ -284,34 +284,34 @@ function fetchPoseidonData(): Promise<PoseidonData> {
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
function getPipelineStatusStyle(status: PipelineStatus): string {
if (status === '정상') return 'text-emerald-400 bg-emerald-500/10';
if (status === '지연') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
if (status === '정상') return 'text-color-success bg-[rgba(34,197,94,0.08)]';
if (status === '지연') return 'text-color-caution bg-[rgba(234,179,8,0.08)]';
return 'text-color-danger bg-[rgba(239,68,68,0.08)]';
}
function getPipelineBorderStyle(status: PipelineStatus): string {
if (status === '정상') return 'border-l-emerald-500';
if (status === '지연') return 'border-l-yellow-500';
return 'border-l-red-500';
if (status === '정상') return 'border-l-color-success';
if (status === '지연') return 'border-l-color-caution';
return 'border-l-color-danger';
}
function getReceiveStatusStyle(status: ReceiveStatus): string {
if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10';
if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
if (status === '수신완료') return 'text-color-success bg-[rgba(34,197,94,0.08)]';
if (status === '수신대기') return 'text-color-caution bg-[rgba(234,179,8,0.08)]';
return 'text-color-danger bg-[rgba(239,68,68,0.08)]';
}
function getProcessStatusStyle(status: ProcessStatus): string {
if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10';
if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10';
if (status === '대기') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
if (status === '처리완료') return 'text-color-success bg-[rgba(34,197,94,0.08)]';
if (status === '처리중') return 'text-color-accent bg-[rgba(6,182,212,0.08)]';
if (status === '대기') return 'text-color-caution bg-[rgba(234,179,8,0.08)]';
return 'text-color-danger bg-[rgba(239,68,68,0.08)]';
}
function getAlertStyle(level: AlertLevel): string {
if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30';
if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30';
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30';
if (level === '경고') return 'text-color-danger bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)]';
if (level === '주의') return 'text-color-caution bg-[rgba(234,179,8,0.08)] border-[rgba(234,179,8,0.3)]';
return 'text-color-accent bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)]';
}
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
@ -322,9 +322,9 @@ function PipelineCard({ node }: { node: PipelineNode }) {
return (
<div
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
>
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
<div className="text-caption font-medium text-t1 leading-snug">{node.name}</div>
<span
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
>
@ -343,7 +343,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-1">
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
{i < 4 && <span className="text-t3 text-sm px-0.5"></span>}
{i < 4 && <span className="text-t3 text-body-2 px-0.5"></span>}
</div>
))}
</div>
@ -355,7 +355,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
{nodes.map((node, idx) => (
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
<PipelineCard node={node} />
{idx < nodes.length - 1 && <span className="text-t3 text-sm shrink-0 px-0.5"></span>}
{idx < nodes.length - 1 && <span className="text-t3 text-body-2 shrink-0 px-0.5"></span>}
</div>
))}
</div>
@ -394,13 +394,13 @@ const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{LOG_HEADERS.map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
>
{h}
</th>
@ -410,7 +410,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
<tbody>
{loading && rows.length === 0
? Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b border-stroke-1 animate-pulse">
<tr key={i} className="border-b border-stroke animate-pulse">
{LOG_HEADERS.map((_, j) => (
<td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-elevated rounded w-16" />
@ -419,7 +419,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
</tr>
))
: rows.map((row) => (
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<tr key={row.id} className="border-b border-stroke hover:bg-bg-surface/50">
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.timestamp}</td>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
@ -467,7 +467,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean
}
if (alerts.length === 0) {
return <p className="text-xs text-t3 py-2"> .</p>;
return <p className="text-caption text-t3 py-2"> .</p>;
}
return (
@ -475,7 +475,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean
{alerts.map((alert) => (
<div
key={alert.id}
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
className={`flex items-start gap-2 px-3 py-2 rounded border text-caption ${getAlertStyle(alert.level)}`}
>
<span className="font-semibold shrink-0">[{alert.level}]</span>
<span className="flex-1">{alert.message}</span>
@ -534,12 +534,12 @@ export default function RndPoseidonPanel() {
return (
<div className="flex flex-col h-full overflow-hidden">
{/* ── 헤더 ── */}
<div className="shrink-0 border-b border-stroke-1">
<div className="shrink-0 border-b border-stroke">
<div className="flex items-center justify-between px-5 py-3">
<h2 className="text-sm font-semibold text-t1"> () </h2>
<h2 className="text-body-2 font-semibold text-t1"> () </h2>
<div className="flex items-center gap-3">
{lastUpdate && (
<span className="text-xs text-t3">
<span className="text-caption text-t3">
:{' '}
{lastUpdate.toLocaleTimeString('ko-KR', {
hour: '2-digit',
@ -551,7 +551,7 @@ export default function RndPoseidonPanel() {
<button
onClick={() => void fetchData()}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="flex items-center gap-1.5 px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
@ -571,21 +571,21 @@ export default function RndPoseidonPanel() {
</div>
</div>
{/* 요약 통계 바 */}
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-caption text-t3 border-t border-stroke">
<span>
: <span className="text-emerald-400 font-medium">{totalReceived}</span>
: <span className="text-color-success font-medium">{totalReceived}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-yellow-400 font-medium">{totalDelayed}</span>
: <span className="text-color-caution font-medium">{totalDelayed}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-red-400 font-medium">{totalFailed}</span>
: <span className="text-color-danger font-medium">{totalFailed}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-cyan-400 font-medium">4 / 8</span>
: <span className="text-color-accent font-medium">4 / 8</span>
</span>
</div>
</div>
@ -593,17 +593,17 @@ export default function RndPoseidonPanel() {
{/* ── 스크롤 영역 ── */}
<div className="flex-1 overflow-auto">
{/* 파이프라인 현황 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
<section className="px-5 pt-4 pb-3 border-b border-stroke">
<h3 className="text-caption font-semibold text-t2 mb-3 uppercase tracking-wide">
</h3>
<PipelineFlow nodes={pipeline} loading={loading} />
</section>
{/* 필터 바 + 수신 이력 테이블 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<section className="px-5 pt-4 pb-3 border-b border-stroke">
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
<h3 className="text-caption font-semibold text-t2 uppercase tracking-wide shrink-0">
</h3>
<div className="flex items-center gap-2 flex-wrap">
@ -611,7 +611,7 @@ export default function RndPoseidonPanel() {
<select
value={filterSource}
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors"
>
<option value="all"> ()</option>
<option value="HYCOM">HYCOM</option>
@ -622,7 +622,7 @@ export default function RndPoseidonPanel() {
<select
value={filterReceive}
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors"
>
<option value="all"> ()</option>
<option value="수신완료"></option>
@ -634,13 +634,13 @@ export default function RndPoseidonPanel() {
<select
value={filterPeriod}
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors"
>
<option value="6h"> 6</option>
<option value="12h"> 12</option>
<option value="24h"> 24</option>
</select>
<span className="text-xs text-t3">{filteredLogs.length}</span>
<span className="text-caption text-t3">{filteredLogs.length}</span>
</div>
</div>
<DataLogTable rows={filteredLogs} loading={loading} />
@ -648,7 +648,7 @@ export default function RndPoseidonPanel() {
{/* 알림 현황 */}
<section className="px-5 pt-4 pb-5">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3"> </h3>
<h3 className="text-caption font-semibold text-t2 uppercase tracking-wide mb-3"> </h3>
<AlertList alerts={alerts} loading={loading} />
</section>
</div>

파일 보기

@ -257,34 +257,34 @@ function fetchRescueData(): Promise<RescueData> {
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
function getPipelineStatusStyle(status: PipelineStatus): string {
if (status === '정상') return 'text-emerald-400 bg-emerald-500/10';
if (status === '지연') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
if (status === '정상') return 'text-color-success bg-[rgba(34,197,94,0.08)]';
if (status === '지연') return 'text-color-caution bg-[rgba(234,179,8,0.08)]';
return 'text-color-danger bg-[rgba(239,68,68,0.08)]';
}
function getPipelineBorderStyle(status: PipelineStatus): string {
if (status === '정상') return 'border-l-emerald-500';
if (status === '지연') return 'border-l-yellow-500';
return 'border-l-red-500';
if (status === '정상') return 'border-l-color-success';
if (status === '지연') return 'border-l-color-caution';
return 'border-l-color-danger';
}
function getReceiveStatusStyle(status: ReceiveStatus): string {
if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10';
if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
if (status === '수신완료') return 'text-color-success bg-[rgba(34,197,94,0.08)]';
if (status === '수신대기') return 'text-color-caution bg-[rgba(234,179,8,0.08)]';
return 'text-color-danger bg-[rgba(239,68,68,0.08)]';
}
function getProcessStatusStyle(status: ProcessStatus): string {
if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10';
if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10';
if (status === '대기') return 'text-yellow-400 bg-yellow-500/10';
return 'text-red-400 bg-red-500/10';
if (status === '처리완료') return 'text-color-success bg-[rgba(34,197,94,0.08)]';
if (status === '처리중') return 'text-color-accent bg-[rgba(6,182,212,0.08)]';
if (status === '대기') return 'text-color-caution bg-[rgba(234,179,8,0.08)]';
return 'text-color-danger bg-[rgba(239,68,68,0.08)]';
}
function getAlertStyle(level: AlertLevel): string {
if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30';
if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30';
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30';
if (level === '경고') return 'text-color-danger bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)]';
if (level === '주의') return 'text-color-caution bg-[rgba(234,179,8,0.08)] border-[rgba(234,179,8,0.3)]';
return 'text-color-accent bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)]';
}
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
@ -295,9 +295,9 @@ function PipelineCard({ node }: { node: PipelineNode }) {
return (
<div
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
>
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
<div className="text-caption font-medium text-t1 leading-snug">{node.name}</div>
<span
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
>
@ -316,7 +316,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-1">
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
{i < 4 && <span className="text-t3 text-sm px-0.5"></span>}
{i < 4 && <span className="text-t3 text-body-2 px-0.5"></span>}
</div>
))}
</div>
@ -328,7 +328,7 @@ function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: bool
{nodes.map((node, idx) => (
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
<PipelineCard node={node} />
{idx < nodes.length - 1 && <span className="text-t3 text-sm shrink-0 px-0.5"></span>}
{idx < nodes.length - 1 && <span className="text-t3 text-body-2 shrink-0 px-0.5"></span>}
</div>
))}
</div>
@ -367,13 +367,13 @@ const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
return (
<div className="overflow-auto">
<table className="w-full text-xs border-collapse">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{LOG_HEADERS.map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
>
{h}
</th>
@ -383,7 +383,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
<tbody>
{loading && rows.length === 0
? Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b border-stroke-1 animate-pulse">
<tr key={i} className="border-b border-stroke animate-pulse">
{LOG_HEADERS.map((_, j) => (
<td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-elevated rounded w-16" />
@ -392,7 +392,7 @@ function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean
</tr>
))
: rows.map((row) => (
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
<tr key={row.id} className="border-b border-stroke hover:bg-bg-surface/50">
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.timestamp}</td>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
@ -440,7 +440,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean
}
if (alerts.length === 0) {
return <p className="text-xs text-t3 py-2"> .</p>;
return <p className="text-caption text-t3 py-2"> .</p>;
}
return (
@ -448,7 +448,7 @@ function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean
{alerts.map((alert) => (
<div
key={alert.id}
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
className={`flex items-start gap-2 px-3 py-2 rounded border text-caption ${getAlertStyle(alert.level)}`}
>
<span className="font-semibold shrink-0">[{alert.level}]</span>
<span className="flex-1">{alert.message}</span>
@ -507,12 +507,12 @@ export default function RndRescuePanel() {
return (
<div className="flex flex-col h-full overflow-hidden">
{/* ── 헤더 ── */}
<div className="shrink-0 border-b border-stroke-1">
<div className="shrink-0 border-b border-stroke">
<div className="flex items-center justify-between px-5 py-3">
<h2 className="text-sm font-semibold text-t1"> </h2>
<h2 className="text-body-2 font-semibold text-t1"> </h2>
<div className="flex items-center gap-3">
{lastUpdate && (
<span className="text-xs text-t3">
<span className="text-caption text-t3">
:{' '}
{lastUpdate.toLocaleTimeString('ko-KR', {
hour: '2-digit',
@ -524,7 +524,7 @@ export default function RndRescuePanel() {
<button
onClick={() => void fetchData()}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="flex items-center gap-1.5 px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
@ -544,21 +544,21 @@ export default function RndRescuePanel() {
</div>
</div>
{/* 요약 통계 바 */}
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-caption text-t3 border-t border-stroke">
<span>
: <span className="text-emerald-400 font-medium">{totalReceived}</span>
: <span className="text-color-success font-medium">{totalReceived}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-yellow-400 font-medium">{totalDelayed}</span>
: <span className="text-color-caution font-medium">{totalDelayed}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-red-400 font-medium">{totalFailed}</span>
: <span className="text-color-danger font-medium">{totalFailed}</span>
</span>
<span className="text-stroke-1">|</span>
<span>
: <span className="text-cyan-400 font-medium">5 / 6</span>
: <span className="text-color-accent font-medium">5 / 6</span>
</span>
</div>
</div>
@ -566,17 +566,17 @@ export default function RndRescuePanel() {
{/* ── 스크롤 영역 ── */}
<div className="flex-1 overflow-auto">
{/* 파이프라인 현황 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
<section className="px-5 pt-4 pb-3 border-b border-stroke">
<h3 className="text-caption font-semibold text-t2 mb-3 uppercase tracking-wide">
</h3>
<PipelineFlow nodes={pipeline} loading={loading} />
</section>
{/* 필터 바 + 수신 이력 테이블 */}
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
<section className="px-5 pt-4 pb-3 border-b border-stroke">
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
<h3 className="text-caption font-semibold text-t2 uppercase tracking-wide shrink-0">
</h3>
<div className="flex items-center gap-2 flex-wrap">
@ -584,7 +584,7 @@ export default function RndRescuePanel() {
<select
value={filterSource}
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors"
>
<option value="all"> ()</option>
<option value="HYCOM">HYCOM</option>
@ -595,7 +595,7 @@ export default function RndRescuePanel() {
<select
value={filterReceive}
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors"
>
<option value="all"> ()</option>
<option value="수신완료"></option>
@ -607,13 +607,13 @@ export default function RndRescuePanel() {
<select
value={filterPeriod}
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t2 focus:outline-none focus:border-color-accent transition-colors"
>
<option value="6h"> 6</option>
<option value="12h"> 12</option>
<option value="24h"> 24</option>
</select>
<span className="text-xs text-t3">{filteredLogs.length}</span>
<span className="text-caption text-t3">{filteredLogs.length}</span>
</div>
</div>
<DataLogTable rows={filteredLogs} loading={loading} />
@ -621,7 +621,7 @@ export default function RndRescuePanel() {
{/* 알림 현황 */}
<section className="px-5 pt-4 pb-5">
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3"> </h3>
<h3 className="text-caption font-semibold text-t2 uppercase tracking-wide mb-3"> </h3>
<AlertList alerts={alerts} loading={loading} />
</section>
</div>

파일 보기

@ -135,7 +135,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
<div className="px-6 py-4 border-b border-stroke shrink-0">
<div className="flex items-center justify-between mb-3">
<div>
<h1 className="text-lg font-bold text-fg font-korean">{title}</h1>
<h1 className="text-title-1 font-bold text-fg font-korean">{title}</h1>
<p className="text-caption text-fg-disabled mt-1 font-korean"> {total}</p>
</div>
</div>
@ -168,7 +168,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
{/* 오류 메시지 */}
{error && (
<div className="px-6 py-2 text-caption text-red-400 bg-[rgba(239,68,68,0.05)] border-b border-stroke shrink-0 font-korean">
<div className="px-6 py-2 text-caption text-color-danger bg-[rgba(239,68,68,0.08)] border-b border-stroke shrink-0 font-korean">
{error}
</div>
)}

파일 보기

@ -63,7 +63,7 @@ function SettingsPanel() {
return (
<div className="flex flex-col h-full">
<div className="px-6 py-4 border-b border-stroke">
<h1 className="text-lg font-bold text-fg font-korean"> </h1>
<h1 className="text-title-1 font-bold text-fg font-korean"> </h1>
<p className="text-caption text-fg-disabled mt-1 font-korean">
</p>
@ -87,9 +87,9 @@ function SettingsPanel() {
<div className="text-title-4 font-semibold text-fg font-korean"> </div>
<p className="text-label-2 text-fg-disabled mt-1 font-korean leading-relaxed">
{' '}
<span className="text-green-400 font-semibold">ACTIVE</span> .
<span className="text-color-success font-semibold">ACTIVE</span> .
{' '}
<span className="text-yellow-400 font-semibold">PENDING</span>
<span className="text-color-caution font-semibold">PENDING</span>
.
</p>
</div>
@ -152,8 +152,8 @@ function SettingsPanel() {
</div>
<p className="text-label-2 text-fg-disabled font-korean leading-relaxed mb-3">
Google {' '}
<span className="text-green-400 font-semibold">ACTIVE</span> .
<span className="text-yellow-400 font-semibold">PENDING</span>
<span className="text-color-success font-semibold">ACTIVE</span> .
<span className="text-color-caution font-semibold">PENDING</span>
. (,) .
</p>
<div className="flex gap-2">
@ -226,25 +226,25 @@ function SettingsPanel() {
<div className="flex flex-col gap-3 text-label-1 font-korean">
<div className="flex items-center gap-2">
<span
className={`w-2 h-2 rounded-full ${settings?.autoApprove ? 'bg-green-400' : 'bg-yellow-400'}`}
className={`w-2 h-2 rounded-full ${settings?.autoApprove ? 'bg-color-success' : 'bg-color-caution'}`}
/>
<span className="text-fg-sub">
{' '}
{settings?.autoApprove ? (
<span className="text-green-400 font-semibold"> </span>
<span className="text-color-success font-semibold"> </span>
) : (
<span className="text-yellow-400 font-semibold"> </span>
<span className="text-color-caution font-semibold"> </span>
)}
</span>
</div>
<div className="flex items-center gap-2">
<span
className={`w-2 h-2 rounded-full ${settings?.defaultRole ? 'bg-green-400' : 'bg-fg-disabled'}`}
className={`w-2 h-2 rounded-full ${settings?.defaultRole ? 'bg-color-success' : 'bg-fg-disabled'}`}
/>
<span className="text-fg-sub">
{' '}
{settings?.defaultRole ? (
<span className="text-green-400 font-semibold"></span>
<span className="text-color-success font-semibold"></span>
) : (
<span className="text-fg-disabled font-semibold"></span>
)}
@ -252,12 +252,12 @@ function SettingsPanel() {
</div>
<div className="flex items-center gap-2">
<span
className={`w-2 h-2 rounded-full ${oauthSettings?.autoApproveDomains ? 'bg-blue-400' : 'bg-fg-disabled'}`}
className={`w-2 h-2 rounded-full ${oauthSettings?.autoApproveDomains ? 'bg-color-info' : 'bg-fg-disabled'}`}
/>
<span className="text-fg-sub">
Google OAuth {' '}
{oauthSettings?.autoApproveDomains ? (
<span className="text-blue-400 font-semibold font-mono">
<span className="text-color-info font-semibold font-mono">
{oauthSettings.autoApproveDomains}
</span>
) : (

파일 보기

@ -0,0 +1,57 @@
import { useState } from 'react';
import { FrameworkTab } from './contents/FrameworkTab';
import { TargetArchTab } from './contents/TargetArchTab';
import { InterfaceTab } from './contents/InterfaceTab';
import { HeterogeneousTab } from './contents/HeterogeneousTab';
import { CommonFeaturesTab } from './contents/CommonFeaturesTab';
type TabId = 'framework' | 'target' | 'interface' | 'heterogeneous' | 'common-features';
const TABS: { id: TabId; label: string }[] = [
{ id: 'framework', label: '표준 프레임워크' },
{ id: 'target', label: '목표시스템 아키텍쳐' },
{ id: 'interface', label: '시스템 인터페이스 연계' },
{ id: 'heterogeneous', label: '이기종시스템연계' },
{ id: 'common-features', label: '공통기능' },
];
// ─── 기술 스택 테이블 데이터 ──────────────────────────────────────────────────────
export default function SystemArchPanel() {
const [activeTab, setActiveTab] = useState<TabId>('framework');
return (
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke shrink-0">
<h2 className="text-body-2 font-semibold text-t1"></h2>
</div>
{/* 탭 버튼 */}
<div className="flex gap-1.5 px-5 py-2.5 border-b border-stroke shrink-0 bg-bg-base">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-3 py-1.5 text-caption font-medium rounded transition-colors ${
activeTab === tab.id
? 'bg-color-accent text-white'
: 'bg-bg-elevated text-t2 hover:bg-bg-card'
}`}
>
{tab.label}
</button>
))}
</div>
{/* 탭 콘텐츠 */}
<div className="flex-1 overflow-auto">
{activeTab === 'framework' && <FrameworkTab />}
{activeTab === 'target' && <TargetArchTab />}
{activeTab === 'interface' && <InterfaceTab />}
{activeTab === 'heterogeneous' && <HeterogeneousTab />}
{activeTab === 'common-features' && <CommonFeaturesTab />}
</div>
</div>
);
}

파일 보기

@ -0,0 +1,563 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import {
fetchUsers,
fetchRoles,
fetchOrgs,
updateUserApi,
approveUserApi,
rejectUserApi,
assignRolesApi,
type UserListItem,
type RoleWithPermissions,
type OrgItem,
} from '@common/services/authApi';
import { getRoleColor, statusLabels } from './adminConstants';
import { RegisterModal } from './contents/RegisterModal';
import { UserDetailModal } from './contents/UserDetailModal';
/* eslint-disable react-refresh/only-export-components */
const PAGE_SIZE = 15;
// ─── 포맷 헬퍼 ─────────────────────────────────────────────────
export function formatDate(dateStr: string | null) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function UsersPanel() {
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [orgFilter, setOrgFilter] = useState<string>('');
const [users, setUsers] = useState<UserListItem[]>([]);
const [loading, setLoading] = useState(true);
const [allRoles, setAllRoles] = useState<RoleWithPermissions[]>([]);
const [allOrgs, setAllOrgs] = useState<OrgItem[]>([]);
const [roleEditUserId, setRoleEditUserId] = useState<string | null>(null);
const [selectedRoleSns, setSelectedRoleSns] = useState<number[]>([]);
const [showRegisterModal, setShowRegisterModal] = useState(false);
const [detailUser, setDetailUser] = useState<UserListItem | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const roleDropdownRef = useRef<HTMLDivElement>(null);
const loadUsers = useCallback(async () => {
setLoading(true);
try {
const data = await fetchUsers(searchTerm || undefined, statusFilter || undefined);
setUsers(data);
setCurrentPage(1);
} catch (err) {
console.error('사용자 목록 조회 실패:', err);
} finally {
setLoading(false);
}
}, [searchTerm, statusFilter]);
useEffect(() => {
loadUsers();
}, [loadUsers]);
useEffect(() => {
fetchRoles().then(setAllRoles).catch(console.error);
fetchOrgs().then(setAllOrgs).catch(console.error);
}, []);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (roleDropdownRef.current && !roleDropdownRef.current.contains(e.target as Node)) {
setRoleEditUserId(null);
}
};
if (roleEditUserId) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [roleEditUserId]);
// ─── 필터링 (org 클라이언트 사이드) ───────────────────────────
const filteredUsers = orgFilter ? users.filter((u) => String(u.orgSn) === orgFilter) : users;
// ─── 페이지네이션 ──────────────────────────────────────────────
const totalCount = filteredUsers.length;
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
const pagedUsers = filteredUsers.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
// ─── 액션 핸들러 ──────────────────────────────────────────────
const handleUnlock = async (userId: string) => {
try {
await updateUserApi(userId, { status: 'ACTIVE' });
await loadUsers();
} catch (err) {
console.error('계정 잠금 해제 실패:', err);
}
};
const handleApprove = async (userId: string) => {
try {
await approveUserApi(userId);
await loadUsers();
} catch (err) {
console.error('사용자 승인 실패:', err);
}
};
const handleReject = async (userId: string) => {
try {
await rejectUserApi(userId);
await loadUsers();
} catch (err) {
console.error('사용자 거절 실패:', err);
}
};
const handleDeactivate = async (userId: string) => {
try {
await updateUserApi(userId, { status: 'INACTIVE' });
await loadUsers();
} catch (err) {
console.error('사용자 비활성화 실패:', err);
}
};
const handleActivate = async (userId: string) => {
try {
await updateUserApi(userId, { status: 'ACTIVE' });
await loadUsers();
} catch (err) {
console.error('사용자 활성화 실패:', err);
}
};
const handleOpenRoleEdit = (user: UserListItem) => {
setRoleEditUserId(user.id);
setSelectedRoleSns(user.roleSns || []);
};
const toggleRoleSelection = (roleSn: number) => {
setSelectedRoleSns((prev) =>
prev.includes(roleSn) ? prev.filter((s) => s !== roleSn) : [...prev, roleSn],
);
};
const handleSaveRoles = async (userId: string) => {
try {
await assignRolesApi(userId, selectedRoleSns);
await loadUsers();
setRoleEditUserId(null);
} catch (err) {
console.error('역할 할당 실패:', err);
}
};
const pendingCount = users.filter((u) => u.status === 'PENDING').length;
return (
<>
<div className="flex flex-col h-full">
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
<div className="flex items-center gap-3">
<div>
<h1 className="text-title-1 font-bold text-fg font-korean"> </h1>
<p className="text-caption text-fg-disabled mt-1 font-korean">
{filteredUsers.length}
</p>
</div>
{pendingCount > 0 && (
<span className="px-2.5 py-1 text-caption font-bold rounded-full bg-[rgba(234,179,8,0.15)] text-color-caution border border-[rgba(234,179,8,0.3)] animate-pulse font-korean">
{pendingCount}
</span>
)}
</div>
<div className="flex items-center gap-3">
{/* 소속 필터 */}
<select
value={orgFilter}
onChange={(e) => {
setOrgFilter(e.target.value);
setCurrentPage(1);
}}
className="px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
>
<option value=""> </option>
{allOrgs.map((org) => (
<option key={org.orgSn} value={String(org.orgSn)}>
{org.orgAbbrNm || org.orgNm}
</option>
))}
</select>
{/* 상태 필터 */}
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
>
<option value=""> </option>
<option value="PENDING"></option>
<option value="ACTIVE"></option>
<option value="LOCKED"></option>
<option value="INACTIVE"></option>
<option value="REJECTED"></option>
</select>
{/* 텍스트 검색 */}
<input
type="text"
placeholder="이름, 계정 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-56 px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
/>
<button
onClick={() => setShowRegisterModal(true)}
className="px-4 py-2 text-caption font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
>
+
</button>
</div>
</div>
{/* 테이블 */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-32 text-fg-disabled text-body-2 font-korean">
...
</div>
) : (
<table className="w-full">
<thead>
<tr className="border-b border-stroke bg-bg-surface">
<th className="px-4 py-3 text-left text-caption font-semibold text-fg-disabled font-korean w-10">
</th>
<th className="px-4 py-3 text-left text-caption font-semibold text-fg-disabled font-mono">
ID
</th>
<th className="px-4 py-3 text-left text-caption font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-left text-caption font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-left text-caption font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-left text-caption font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-left text-caption font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-left text-caption font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-right text-caption font-semibold text-fg-disabled font-korean">
</th>
</tr>
</thead>
<tbody>
{pagedUsers.length === 0 ? (
<tr>
<td
colSpan={9}
className="px-6 py-10 text-center text-caption text-fg-disabled font-korean"
>
.
</td>
</tr>
) : (
pagedUsers.map((user, idx) => {
const statusInfo = statusLabels[user.status] || statusLabels.INACTIVE;
const rowNum = (currentPage - 1) * PAGE_SIZE + idx + 1;
return (
<tr
key={user.id}
className="border-b border-stroke hover:bg-[rgba(6,182,212,0.04)] transition-colors"
>
{/* 번호 */}
<td className="px-4 py-3 text-caption text-fg-disabled font-mono text-center">
{rowNum}
</td>
{/* ID(account) */}
<td className="px-4 py-3 text-caption text-fg-sub font-mono">
{user.account}
</td>
{/* 사용자명 */}
<td className="px-4 py-3">
<button
onClick={() => setDetailUser(user)}
className="text-caption text-color-accent font-semibold font-korean hover:underline"
>
{user.name}
</button>
</td>
{/* 직급 */}
<td className="px-4 py-3 text-caption text-fg-sub font-korean">
{user.rank || '-'}
</td>
{/* 소속 */}
<td className="px-4 py-3 text-caption text-fg-sub font-korean">
{user.orgAbbr || user.orgName || '-'}
</td>
{/* 이메일 */}
<td className="px-4 py-3 text-caption text-fg-disabled font-mono">
{user.email || '-'}
</td>
{/* 역할 (인라인 편집) */}
<td className="px-4 py-3">
<div className="relative">
<div
className="flex flex-wrap gap-1 cursor-pointer"
onClick={() => handleOpenRoleEdit(user)}
title="클릭하여 역할 변경"
>
{user.roles.length > 0 ? (
user.roles.map((roleCode) => {
const roleName =
allRoles.find((r) => r.code === roleCode)?.name || roleCode;
return (
<span
key={roleCode}
className="px-2 py-0.5 text-caption font-semibold rounded-md font-korean text-fg-sub bg-bg-elevated border border-stroke-light"
>
{roleName}
</span>
);
})
) : (
<span className="text-caption text-fg-disabled font-korean">
</span>
)}
<span className="text-caption text-fg-disabled ml-0.5">
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</span>
</div>
{roleEditUserId === user.id && (
<div
ref={roleDropdownRef}
className="absolute z-20 top-full left-0 mt-1 p-2 bg-bg-surface border border-stroke rounded-lg shadow-lg min-w-[200px]"
>
<div className="text-caption text-fg-disabled font-korean font-semibold mb-1.5 px-1">
</div>
{allRoles.map((role, roleIdx) => {
const color = getRoleColor(role.code, roleIdx);
return (
<label
key={role.sn}
className="flex items-center gap-2 px-2 py-1.5 hover:bg-[rgba(6,182,212,0.08)] rounded cursor-pointer"
>
<input
type="checkbox"
checked={selectedRoleSns.includes(role.sn)}
onChange={() => toggleRoleSelection(role.sn)}
style={{ accentColor: color }}
/>
<span className="text-caption font-korean" style={{ color }}>
{role.name}
</span>
<span className="text-caption text-fg-disabled font-mono">
{role.code}
</span>
</label>
);
})}
<div className="flex justify-end gap-2 mt-2 pt-2 border-t border-stroke">
<button
onClick={() => setRoleEditUserId(null)}
className="px-3 py-1 text-caption text-fg-disabled border border-stroke rounded hover:bg-bg-surface-hover font-korean"
>
</button>
<button
onClick={() => handleSaveRoles(user.id)}
disabled={selectedRoleSns.length === 0}
className="px-3 py-1 text-caption font-semibold rounded bg-color-accent text-bg-0 hover:shadow-[0_0_8px_rgba(6,182,212,0.3)] disabled:opacity-50 font-korean"
>
</button>
</div>
</div>
)}
</div>
</td>
{/* 승인상태 */}
<td className="px-4 py-3">
<span
className={`inline-flex items-center gap-1.5 text-caption font-semibold font-korean ${statusInfo.color}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot}`} />
{statusInfo.label}
</span>
</td>
{/* 관리 */}
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
{user.status === 'PENDING' && (
<>
<button
onClick={() => handleApprove(user.id)}
className="px-2 py-1 text-caption font-semibold text-color-success border border-color-success rounded hover:bg-[rgba(34,197,94,0.12)] transition-all font-korean"
>
</button>
<button
onClick={() => handleReject(user.id)}
className="px-2 py-1 text-caption font-semibold text-color-danger border border-color-danger rounded hover:bg-[rgba(239,68,68,0.12)] transition-all font-korean"
>
</button>
</>
)}
{user.status === 'LOCKED' && (
<button
onClick={() => handleUnlock(user.id)}
className="px-2 py-1 text-caption font-semibold text-color-caution border border-color-caution rounded hover:bg-[rgba(234,179,8,0.12)] transition-all font-korean"
>
</button>
)}
{user.status === 'ACTIVE' && (
<button
onClick={() => handleDeactivate(user.id)}
className="px-2 py-1 text-caption font-semibold text-fg-disabled border border-stroke rounded hover:bg-[rgba(6,182,212,0.08)] transition-all font-korean"
>
</button>
)}
{(user.status === 'INACTIVE' || user.status === 'REJECTED') && (
<button
onClick={() => handleActivate(user.id)}
className="px-2 py-1 text-caption font-semibold text-color-success border border-color-success rounded hover:bg-[rgba(34,197,94,0.12)] transition-all font-korean"
>
</button>
)}
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
)}
</div>
{/* 페이지네이션 */}
{!loading && totalPages > 1 && (
<div className="flex items-center justify-between px-6 py-3 border-t border-stroke bg-bg-surface">
<span className="text-label-2 text-fg-disabled font-korean">
{(currentPage - 1) * PAGE_SIZE + 1}{Math.min(currentPage * PAGE_SIZE, totalCount)} /{' '}
{totalCount}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-2.5 py-1 text-label-2 border border-stroke text-fg-disabled rounded hover:bg-[rgba(6,182,212,0.08)] disabled:opacity-40 transition-all font-korean"
>
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter((p) => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
.reduce<(number | '...')[]>((acc, p, i, arr) => {
if (
i > 0 &&
typeof arr[i - 1] === 'number' &&
(p as number) - (arr[i - 1] as number) > 1
) {
acc.push('...');
}
acc.push(p);
return acc;
}, [])
.map((item, i) =>
item === '...' ? (
<span key={`ellipsis-${i}`} className="px-2 text-label-2 text-fg-disabled">
</span>
) : (
<button
key={item}
onClick={() => setCurrentPage(item as number)}
className="px-2.5 py-1 text-label-2 border rounded transition-all font-mono"
style={
currentPage === item
? {
borderColor: 'var(--color-accent)',
color: 'var(--color-accent)',
background: 'rgba(6,182,212,0.1)',
}
: { borderColor: 'var(--border)', color: 'var(--fg-disabled)' }
}
>
{item}
</button>
),
)}
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-2.5 py-1 text-label-2 border border-stroke text-fg-disabled rounded hover:bg-[rgba(6,182,212,0.08)] disabled:opacity-40 transition-all font-korean"
>
</button>
</div>
</div>
)}
</div>
{/* 사용자 등록 모달 */}
{showRegisterModal && (
<RegisterModal
allRoles={allRoles}
allOrgs={allOrgs}
onClose={() => setShowRegisterModal(false)}
onSuccess={loadUsers}
/>
)}
{/* 사용자 상세/수정 모달 */}
{detailUser && (
<UserDetailModal
user={detailUser}
allRoles={allRoles}
allOrgs={allOrgs}
onClose={() => setDetailUser(null)}
onUpdated={() => {
loadUsers();
// 최신 정보로 모달 갱신을 위해 닫지 않음
}}
/>
)}
</>
);
}
export default UsersPanel;

파일 보기

@ -1,7 +1,7 @@
import { useState, useEffect, useMemo } from 'react';
import { fetchOrganizations } from '@tabs/assets/services/assetsApi';
import type { AssetOrgCompat } from '@tabs/assets/services/assetsApi';
import { typeTagCls } from '@tabs/assets/components/assetTypes';
import { fetchOrganizations } from '@components/assets/services/assetsApi';
import type { AssetOrgCompat } from '@interfaces/assets/AssetsInterface';
import { typeTagCls } from '@components/assets/components/assetTypes';
const PAGE_SIZE = 20;
@ -89,7 +89,7 @@ function VesselMaterialsPanel() {
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
<div>
<h1 className="text-lg font-bold text-fg font-korean"> </h1>
<h1 className="text-title-1 font-bold text-fg font-korean"> </h1>
<p className="text-caption text-fg-disabled mt-1 font-korean">
{filtered.length} ( )
</p>
@ -327,16 +327,11 @@ function VesselMaterialsPanel() {
<button
key={p}
onClick={() => setCurrentPage(p)}
className="px-2.5 py-1 text-label-2 border rounded transition-colors"
style={
className={`px-2.5 py-1 text-label-2 border rounded transition-colors ${
p === safePage
? {
borderColor: 'var(--color-accent)',
color: 'var(--color-accent)',
background: 'rgba(6,182,212,0.1)',
}
: { borderColor: 'var(--border)', color: 'var(--text-2)' }
}
? 'border-color-accent text-color-accent bg-[rgba(6,182,212,0.08)]'
: 'border-stroke text-fg-sub'
}`}
>
{p}
</button>

파일 보기

@ -10,18 +10,10 @@ interface SignalSlot {
}
// ─── 상수 ──────────────────────────────────────────────────
const SOURCE_COLORS: Record<SignalSource, string> = {
VTS: '#3b82f6',
'VTS-AIS': '#a855f7',
'V-PASS': '#22c55e',
'E-NAVI': '#f97316',
'S&P AIS': '#ec4899',
};
const STATUS_COLOR: Record<string, string> = {
ok: '#22c55e',
warn: '#eab308',
error: '#ef4444',
ok: 'var(--color-success)',
warn: 'var(--color-caution)',
error: 'var(--color-danger)',
none: 'rgba(255,255,255,0.06)',
};
@ -141,18 +133,18 @@ export default function VesselSignalPanel() {
return (
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-3 border-b border-stroke-1">
<div className="flex items-center justify-between px-6 py-3 border-b border-stroke">
<h2 className="text-body-2 font-semibold text-fg"> </h2>
<div className="flex items-center gap-3">
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-fg"
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-fg"
/>
<button
onClick={load}
className="px-3 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-fg-sub hover:bg-bg-card"
className="px-3 py-1 text-caption rounded bg-bg-elevated border border-stroke text-fg-sub hover:bg-bg-card"
>
</button>
@ -172,7 +164,6 @@ export default function VesselSignalPanel() {
{/* 시간축 높이 맞춤 빈칸 */}
<div className="h-5 mb-3" />
{SIGNAL_SOURCES.map((src) => {
const c = SOURCE_COLORS[src];
const st = stats.find((s) => s.src === src)!;
return (
<div
@ -180,10 +171,10 @@ export default function VesselSignalPanel() {
className="flex flex-col justify-center mb-4"
style={{ height: 20 }}
>
<span className="text-label-1 font-semibold leading-tight" style={{ color: c }}>
<span className="text-label-1 font-semibold leading-tight text-fg">
{src}
</span>
<span className="text-caption font-mono text-text-4 mt-0.5">{st.rate}%</span>
<span className="text-caption font-mono text-fg-sub mt-0.5">{st.rate}%</span>
</div>
);
})}

파일 보기

@ -0,0 +1,212 @@
import { useState } from 'react';
import type { AuditLogEntry, DeidentifyTask } from '../DeidentifyPanel';
import { MOCK_AUDIT_LOGS } from '../DeidentifyPanel';
function getAuditResultClass(type: AuditLogEntry['resultType']): string {
switch (type) {
case '성공':
return 'text-emerald-400 bg-emerald-500/10';
case '진행중':
return 'text-cyan-400 bg-cyan-500/10';
case '실패':
return 'text-red-400 bg-red-500/10';
case '거부':
return 'text-yellow-400 bg-yellow-500/10';
}
}
interface AuditLogModalProps {
task: DeidentifyTask;
onClose: () => void;
}
export function AuditLogModal({ task, onClose }: AuditLogModalProps) {
const logs = MOCK_AUDIT_LOGS[task.id] ?? [];
const [selectedLog, setSelectedLog] = useState<AuditLogEntry | null>(null);
const [filterOperator, setFilterOperator] = useState('모두');
const [startDate, setStartDate] = useState('2026-04-01');
const [endDate, setEndDate] = useState('2026-04-11');
const operators = ['모두', ...Array.from(new Set(logs.map((l) => l.operator)))];
const filteredLogs = logs.filter((l) => {
if (filterOperator !== '모두' && l.operator !== filterOperator) return false;
return true;
});
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div className="bg-bg-card border border-stroke rounded-lg shadow-2xl w-full max-w-5xl max-h-[85vh] flex flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke shrink-0">
<h3 className="text-body-2 font-semibold text-t1"> () {task.name}</h3>
<button
onClick={onClose}
className="text-t3 hover:text-t1 transition-colors text-lg leading-none"
>
</button>
</div>
{/* 필터 바 */}
<div className="flex items-center gap-3 px-5 py-2.5 border-b border-stroke shrink-0 bg-bg-base">
<span className="text-caption text-t3">:</span>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
/>
<span className="text-caption text-t3">~</span>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
/>
<span className="text-caption text-t3 ml-2">:</span>
<select
value={filterOperator}
onChange={(e) => setFilterOperator(e.target.value)}
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
>
{operators.map((op) => (
<option key={op} value={op}>
{op}
</option>
))}
</select>
</div>
{/* 로그 테이블 */}
<div className="flex-1 overflow-auto px-5 py-3">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{['시간', '작업자', '작업', '대상 데이터', '결과', '상세'].map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{filteredLogs.length === 0 ? (
<tr>
<td colSpan={6} className="px-3 py-8 text-center text-t3">
.
</td>
</tr>
) : (
filteredLogs.map((log) => (
<tr
key={log.id}
className={`border-b border-stroke hover:bg-bg-surface/50 cursor-pointer ${selectedLog?.id === log.id ? 'bg-bg-surface/70' : ''}`}
onClick={() => setSelectedLog(log)}
>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
{log.time.split(' ')[1]}
</td>
<td className="px-3 py-2 text-t1 whitespace-nowrap">{log.operator}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap">{log.action}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
{log.targetData}
</td>
<td className="px-3 py-2">
<span
className={`inline-block px-2 py-0.5 rounded text-label-2 font-medium ${getAuditResultClass(log.resultType)}`}
>
{log.result}
</span>
</td>
<td className="px-3 py-2">
<button
onClick={(e) => {
e.stopPropagation();
setSelectedLog(log);
}}
className="px-2 py-0.5 text-label-2 rounded bg-bg-elevated hover:bg-bg-card text-color-accent transition-colors whitespace-nowrap"
>
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* 로그 상세 정보 */}
{selectedLog && (
<div className="px-5 py-3 border-t border-stroke shrink-0 bg-bg-base">
<h4 className="text-caption font-semibold text-t1 mb-2"> </h4>
<div className="bg-bg-elevated border border-stroke rounded p-3 text-caption grid grid-cols-2 gap-x-6 gap-y-1.5">
<div>
<span className="text-t3">ID:</span>{' '}
<span className="text-t1 font-mono">{selectedLog.id}</span>
</div>
<div>
<span className="text-t3">:</span>{' '}
<span className="text-t1 font-mono">{selectedLog.time}</span>
</div>
<div>
<span className="text-t3">:</span>{' '}
<span className="text-t1">
{selectedLog.operator} ({selectedLog.operatorId})
</span>
</div>
<div>
<span className="text-t3"> :</span>{' '}
<span className="text-t1">{selectedLog.action}</span>
</div>
<div>
<span className="text-t3">:</span>{' '}
<span className="text-t1">
{selectedLog.targetData} ({selectedLog.detail.dataCount.toLocaleString()})
</span>
</div>
<div>
<span className="text-t3"> :</span>{' '}
<span className="text-t1">{selectedLog.detail.rulesApplied}</span>
</div>
<div>
<span className="text-t3">:</span>{' '}
<span className="text-t1">
{selectedLog.result} (: {selectedLog.detail.processedCount.toLocaleString()},
: {selectedLog.detail.errorCount})
</span>
</div>
<div>
<span className="text-t3">IP :</span>{' '}
<span className="text-t1 font-mono">{selectedLog.ip}</span>
</div>
<div>
<span className="text-t3">:</span>{' '}
<span className="text-t1">{selectedLog.browser}</span>
</div>
</div>
</div>
)}
{/* 하단 버튼 */}
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-stroke shrink-0">
<button className="px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 transition-colors">
()
</button>
<button className="px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 transition-colors">
</button>
<button
onClick={onClose}
className="px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 transition-colors"
>
</button>
</div>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,765 @@
interface CommonFeatureItem {
title: string;
description: string;
details: string[];
}
const COMMON_FEATURES: CommonFeatureItem[] = [
{
title: '인증 시스템',
description: 'JWT 기반 세션 인증 + Google OAuth 소셜 로그인',
details: [
'HttpOnly 쿠키(WING_SESSION) 기반 토큰 관리 — XSS 방어',
'Access Token(15분) + Refresh Token(7일) 이중 토큰 구조',
'Google OAuth 2.0 소셜 로그인 지원',
'Zustand authStore 기반 프론트엔드 인증 상태 통합 관리',
],
},
{
title: 'RBAC 2차원 권한',
description: 'AUTH_PERM 기반 기능별·역할별 2차원 권한 엔진',
details: [
'OPER_CD (R: 조회, C: 생성, U: 수정, D: 삭제) 4단계 조작 권한',
'역할(Role) × 기능(Feature) 매트릭스 기반 권한 매핑',
'permResolver 엔진으로 백엔드·프론트엔드 동시 권한 검증',
'메뉴 접근, 버튼 노출, API 호출 3중 권한 통제',
],
},
{
title: 'API 통신 패턴',
description: 'Axios 기반 공통 API 클라이언트 + 자동 인증·에러 처리',
details: [
'GET/POST만 사용 (PUT/DELETE/PATCH 금지 — 보안취약점 점검 가이드 준수)',
'요청 인터셉터: 쿠키 자동 첨부 (withCredentials)',
'응답 인터셉터: 401 시 자동 토큰 갱신, 실패 시 로그아웃',
'TanStack Query 기반 서버 상태 캐싱 및 자동 재검증',
],
},
{
title: '상태 관리',
description: 'Zustand(클라이언트) + TanStack Query(서버) 이중 상태 관리',
details: [
'Zustand: authStore(인증), menuStore(메뉴) 등 클라이언트 전역 상태',
'TanStack Query: API 응답 캐싱, 자동 재요청, 낙관적 업데이트',
'컴포넌트 로컬 상태: useState 활용',
],
},
{
title: '메뉴 시스템',
description: 'DB 기반 동적 메뉴 + 권한 연동 자동 필터링',
details: [
'DB에서 메뉴 트리 구조를 동적으로 로드',
'사용자 권한에 따라 메뉴 항목 자동 필터링 (접근 불가 메뉴 미노출)',
'관리자 화면에서 메뉴 순서·표시 여부·아이콘 실시간 편집',
'menuStore(Zustand)로 현재 활성 메뉴 상태 전역 관리',
],
},
{
title: '지도 엔진',
description: 'MapLibre GL JS 5.x + deck.gl 9.x 기반 GIS 시각화',
details: [
'MapLibre GL JS: 오픈소스 벡터 타일 기반 지도 렌더링',
'deck.gl: 대규모 공간 데이터(파티클, 히트맵, 궤적) 고성능 시각화',
'PostGIS 공간 쿼리 → GeoJSON → deck.gl 레이어 파이프라인',
'레이어 트리 UI로 사용자별 레이어 표시·숨김 제어',
],
},
{
title: '스타일링',
description: 'Tailwind CSS @layer 아키텍처 + CSS 변수 디자인 시스템',
details: [
'@layer base → components → wing 3단계 CSS 계층 구조',
'CSS 변수 기반 시맨틱 컬러 (bg-bg-base, text-t1, border-stroke 등)',
'다크 모드 기본 적용 — CSS 변수 전환으로 테마 일괄 변경',
'인라인 스타일 지양, Tailwind 유틸리티 클래스 우선',
],
},
{
title: '감사 로그',
description: '사용자 행위 자동 기록 — 접속·조회·변경 이력 추적',
details: [
'로그인/로그아웃, 메뉴 접근, 데이터 변경 자동 기록',
'App.tsx에서 탭 전환 시 감사 로그 자동 전송',
'관리자 화면에서 사용자별·기간별 감사 로그 조회 가능',
'IP 주소, User-Agent, 요청 경로 등 부가 정보 기록',
],
},
{
title: '보안',
description: '입력 살균·CORS·CSP·Rate Limiting 다층 보안 정책',
details: [
'입력 살균(sanitize): XSS·SQL Injection 방어 미들웨어 적용',
'Helmet: CSP, X-Frame-Options, HSTS 등 보안 헤더 자동 설정',
'CORS: 허용 오리진 화이트리스트 제한',
'Rate Limiting: API 요청 빈도 제한으로 DoS 방어',
],
},
];
// ─── 방제대응 프로세스 데이터 ─────────────────────────────────────────────────────
interface ProcessStep {
phase: string;
description: string;
modules: string[];
}
const RESPONSE_PROCESS: ProcessStep[] = [
{
phase: '사고 접수',
description: '해양오염 사고 신고 접수 및 초동 상황 등록',
modules: ['사건/사고'],
},
{
phase: '상황 파악',
description: '사고 현장 기상·해상 조건 확인, 유출원·유출량 파악',
modules: ['해양기상', '사건/사고'],
},
{
phase: '확산 예측',
description: '유출유/HNS 확산 시뮬레이션 및 역추적 분석 수행',
modules: ['확산예측', 'HNS분석'],
},
{
phase: '방제 계획',
description: '오일붐 배치, 유처리제 살포 구역, 방제선 투입 계획 수립',
modules: ['확산예측', '자산관리'],
},
{
phase: '구조 작전',
description: '인명 구조 시나리오 수립, 표류 예측 기반 수색 구역 결정',
modules: ['구조시나리오'],
},
{
phase: '항공 감시',
description: '위성·드론 영상으로 유막 면적 모니터링 및 방제 효과 확인',
modules: ['항공방제'],
},
{
phase: '해안 조사',
description: 'Pre-SCAT 해안 오염 조사, 피해 범위 기록',
modules: ['SCAT조사'],
},
{
phase: '상황 종료',
description: '방제 완료 보고, 감사 이력 정리, 사후 분석',
modules: ['사건/사고', '관리자'],
},
];
// ─── 시스템별 기능 유무 매트릭스 데이터 ────────────────────────────────────────────
const SYSTEM_MODULES = [
'확산예측',
'HNS분석',
'구조시나리오',
'항공방제',
'해양기상',
'사건/사고',
'자산관리',
'SCAT조사',
'게시판',
'관리자',
] as const;
interface FeatureMatrixRow {
feature: string;
category: '공통기능' | '기본정보관리' | '업무기능';
integrated: boolean;
systems: Record<string, boolean>;
}
const FEATURE_MATRIX: FeatureMatrixRow[] = [
{
feature: '사용자 인증 (JWT)',
category: '공통기능',
integrated: true,
systems: {
확산예측: true,
HNS분석: true,
구조시나리오: true,
항공방제: true,
해양기상: true,
'사건/사고': true,
자산관리: true,
SCAT조사: true,
게시판: true,
관리자: true,
},
},
{
feature: 'RBAC 권한 제어',
category: '공통기능',
integrated: true,
systems: {
확산예측: true,
HNS분석: true,
구조시나리오: true,
항공방제: true,
해양기상: true,
'사건/사고': true,
자산관리: true,
SCAT조사: true,
게시판: true,
관리자: true,
},
},
{
feature: '감사 로그',
category: '공통기능',
integrated: true,
systems: {
확산예측: true,
HNS분석: true,
구조시나리오: true,
항공방제: true,
해양기상: true,
'사건/사고': true,
자산관리: true,
SCAT조사: true,
게시판: true,
관리자: true,
},
},
{
feature: 'API 통신 (Axios)',
category: '공통기능',
integrated: true,
systems: {
확산예측: true,
HNS분석: true,
구조시나리오: true,
항공방제: true,
해양기상: true,
'사건/사고': true,
자산관리: true,
SCAT조사: true,
게시판: true,
관리자: true,
},
},
{
feature: '입력 살균/보안',
category: '공통기능',
integrated: true,
systems: {
확산예측: true,
HNS분석: true,
구조시나리오: true,
항공방제: true,
해양기상: true,
'사건/사고': true,
자산관리: true,
SCAT조사: true,
게시판: true,
관리자: true,
},
},
{
feature: '사용자 관리',
category: '기본정보관리',
integrated: true,
systems: {
확산예측: false,
HNS분석: false,
구조시나리오: false,
항공방제: false,
해양기상: false,
'사건/사고': false,
자산관리: false,
SCAT조사: false,
게시판: false,
관리자: true,
},
},
{
feature: '지도 엔진 (MapLibre)',
category: '기본정보관리',
integrated: true,
systems: {
확산예측: true,
HNS분석: true,
구조시나리오: true,
항공방제: true,
해양기상: true,
'사건/사고': true,
자산관리: false,
SCAT조사: true,
게시판: false,
관리자: false,
},
},
{
feature: '레이어 관리',
category: '기본정보관리',
integrated: true,
systems: {
확산예측: true,
HNS분석: true,
구조시나리오: true,
항공방제: true,
해양기상: true,
'사건/사고': true,
자산관리: false,
SCAT조사: true,
게시판: false,
관리자: true,
},
},
{
feature: '메뉴 관리',
category: '기본정보관리',
integrated: true,
systems: {
확산예측: false,
HNS분석: false,
구조시나리오: false,
항공방제: false,
해양기상: false,
'사건/사고': false,
자산관리: false,
SCAT조사: false,
게시판: false,
관리자: true,
},
},
{
feature: '시스템 설정',
category: '기본정보관리',
integrated: true,
systems: {
확산예측: false,
HNS분석: false,
구조시나리오: false,
항공방제: false,
해양기상: false,
'사건/사고': false,
자산관리: false,
SCAT조사: false,
게시판: false,
관리자: true,
},
},
{
feature: '확산 시뮬레이션',
category: '업무기능',
integrated: false,
systems: {
확산예측: true,
HNS분석: false,
구조시나리오: false,
항공방제: false,
해양기상: false,
'사건/사고': false,
자산관리: false,
SCAT조사: false,
게시판: false,
관리자: false,
},
},
{
feature: 'HNS 대기확산',
category: '업무기능',
integrated: false,
systems: {
확산예측: false,
HNS분석: true,
구조시나리오: false,
항공방제: false,
해양기상: false,
'사건/사고': false,
자산관리: false,
SCAT조사: false,
게시판: false,
관리자: false,
},
},
{
feature: '표류 예측',
category: '업무기능',
integrated: false,
systems: {
확산예측: false,
HNS분석: false,
구조시나리오: true,
항공방제: false,
해양기상: false,
'사건/사고': false,
자산관리: false,
SCAT조사: false,
게시판: false,
관리자: false,
},
},
{
feature: '위성/드론 영상',
category: '업무기능',
integrated: false,
systems: {
확산예측: false,
HNS분석: false,
구조시나리오: false,
항공방제: true,
해양기상: false,
'사건/사고': false,
자산관리: false,
SCAT조사: false,
게시판: false,
관리자: false,
},
},
{
feature: '기상/해상 정보',
category: '업무기능',
integrated: false,
systems: {
확산예측: true,
HNS분석: true,
구조시나리오: true,
항공방제: false,
해양기상: true,
'사건/사고': false,
자산관리: false,
SCAT조사: false,
게시판: false,
관리자: false,
},
},
{
feature: '역추적 분석',
category: '업무기능',
integrated: false,
systems: {
확산예측: true,
HNS분석: false,
구조시나리오: false,
항공방제: false,
해양기상: false,
'사건/사고': false,
자산관리: false,
SCAT조사: false,
게시판: false,
관리자: false,
},
},
{
feature: '사고 등록/이력',
category: '업무기능',
integrated: false,
systems: {
확산예측: false,
HNS분석: false,
구조시나리오: false,
항공방제: false,
해양기상: false,
'사건/사고': true,
자산관리: false,
SCAT조사: false,
게시판: false,
관리자: false,
},
},
{
feature: '장비/선박 관리',
category: '업무기능',
integrated: false,
systems: {
확산예측: false,
HNS분석: false,
구조시나리오: false,
항공방제: false,
해양기상: false,
'사건/사고': false,
자산관리: true,
SCAT조사: false,
게시판: false,
관리자: false,
},
},
{
feature: '해안 조사',
category: '업무기능',
integrated: false,
systems: {
확산예측: false,
HNS분석: false,
구조시나리오: false,
항공방제: false,
해양기상: false,
'사건/사고': false,
자산관리: false,
SCAT조사: true,
게시판: false,
관리자: false,
},
},
{
feature: '게시판 CRUD',
category: '업무기능',
integrated: false,
systems: {
확산예측: false,
HNS분석: false,
구조시나리오: false,
항공방제: false,
해양기상: false,
'사건/사고': false,
자산관리: false,
SCAT조사: false,
게시판: true,
관리자: false,
},
},
];
const CATEGORY_STYLES: Record<string, string> = {
: 'bg-[rgba(6,182,212,0.2)] text-color-accent',
: 'bg-[rgba(34,197,94,0.2)] text-color-success',
: 'bg-bg-elevated text-t3',
};
export function CommonFeaturesTab() {
return (
<div className="flex flex-col gap-6 p-5">
{/* 1. 방제대응 프로세스 */}
<section>
<h3 className="text-body-2 font-semibold text-t1 mb-3">1. </h3>
<p className="text-caption text-t2 leading-relaxed mb-4">
,
.
</p>
{/* 프로세스 흐름도 */}
<div className="flex items-start gap-1 flex-wrap mb-4">
{RESPONSE_PROCESS.map((step, idx) => (
<div key={step.phase} className="flex items-start gap-1">
<div className="bg-bg-elevated border border-stroke rounded px-3 py-2 text-center min-w-20">
<p className="text-caption font-semibold text-t1 mb-1">{step.phase}</p>
<div className="flex flex-col gap-0.5">
{step.modules.map((mod) => (
<span key={mod} className="text-[10px] text-color-accent">
{mod}
</span>
))}
</div>
</div>
{idx < RESPONSE_PROCESS.length - 1 && (
<span className="text-t3 text-lg shrink-0 mt-2.5"></span>
)}
</div>
))}
</div>
{/* 프로세스 상세 */}
<div className="flex flex-col gap-2">
{RESPONSE_PROCESS.map((step, idx) => (
<div
key={step.phase}
className="bg-bg-card border border-stroke rounded p-3 flex items-start gap-3"
>
<span className="inline-flex items-center justify-center w-5 h-5 rounded bg-color-accent text-white text-caption font-semibold shrink-0 mt-0.5">
{idx + 1}
</span>
<div className="flex-1">
<p className="text-caption font-semibold text-t1 mb-0.5">{step.phase}</p>
<p className="text-caption text-t2 leading-relaxed">{step.description}</p>
</div>
<div className="flex gap-1 shrink-0">
{step.modules.map((mod) => (
<span
key={mod}
className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-[rgba(6,182,212,0.2)] text-color-accent"
>
{mod}
</span>
))}
</div>
</div>
))}
</div>
</section>
{/* 2. 시스템별 기능 유무 매트릭스 */}
<section>
<h3 className="text-body-2 font-semibold text-t1 mb-3">2. </h3>
<p className="text-caption text-t2 leading-relaxed mb-4">
( ) , (, )
.{' '}
<span className="text-color-accent font-medium"> </span>
.
</p>
<div className="overflow-auto">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 tracking-wide">
<th className="px-2 py-2 text-left font-medium border-b border-stroke whitespace-nowrap sticky left-0 bg-bg-elevated z-10">
</th>
<th className="px-2 py-2 text-center font-medium border-b border-stroke whitespace-nowrap">
</th>
<th className="px-2 py-2 text-center font-medium border-b border-stroke whitespace-nowrap">
</th>
{SYSTEM_MODULES.map((mod) => (
<th
key={mod}
className="px-1.5 py-2 text-center font-medium border-b border-stroke whitespace-nowrap"
>
<span className="writing-mode-vertical text-[10px]">{mod}</span>
</th>
))}
</tr>
</thead>
<tbody>
{FEATURE_MATRIX.map((row) => (
<tr key={row.feature} className="border-b border-stroke hover:bg-bg-surface/50">
<td className="px-2 py-1.5 font-medium text-t1 whitespace-nowrap sticky left-0 bg-bg-base z-10">
{row.feature}
</td>
<td className="px-2 py-1.5 text-center">
<span
className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${CATEGORY_STYLES[row.category]}`}
>
{row.category}
</span>
</td>
<td className="px-2 py-1.5 text-center">
{row.integrated ? (
<span className="text-color-accent font-semibold"></span>
) : (
<span className="text-t3"></span>
)}
</td>
{SYSTEM_MODULES.map((mod) => (
<td key={mod} className="px-1.5 py-1.5 text-center">
{row.systems[mod] ? (
<span className="text-color-success font-bold">O</span>
) : (
<span className="text-t3/30">-</span>
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* 범례 */}
<div className="flex gap-4 mt-3">
<div className="flex items-center gap-1.5">
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-[rgba(6,182,212,0.2)] text-color-accent">
</span>
<span className="text-caption text-t3"> </span>
</div>
<div className="flex items-center gap-1.5">
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-[rgba(34,197,94,0.2)] text-color-success">
</span>
<span className="text-caption text-t3">··· </span>
</div>
<div className="flex items-center gap-1.5">
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-bg-elevated text-t3">
</span>
<span className="text-caption text-t3"> </span>
</div>
</div>
</section>
{/* 3. 공통기능 상세 */}
<section>
<h3 className="text-body-2 font-semibold text-t1 mb-3">3. </h3>
<div className="flex flex-col gap-3">
{COMMON_FEATURES.map((feature, idx) => (
<div key={feature.title} className="bg-bg-card border border-stroke rounded p-3">
<div className="flex items-center gap-2 mb-1.5">
<span className="inline-flex items-center justify-center w-5 h-5 rounded bg-color-accent text-white text-caption font-semibold shrink-0">
{idx + 1}
</span>
<p className="text-caption font-semibold text-t1">{feature.title}</p>
</div>
<p className="text-caption text-t2 leading-relaxed mb-2 pl-7">{feature.description}</p>
<ul className="flex flex-col gap-1 pl-7">
{feature.details.map((detail) => (
<li key={detail} className="text-caption text-t3 leading-relaxed list-disc">
{detail}
</li>
))}
</ul>
</div>
))}
</div>
</section>
{/* 4. 공통 모듈 구조 */}
<section>
<h3 className="text-body-2 font-semibold text-t1 mb-3">4. </h3>
<div className="overflow-auto">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{['디렉토리', '역할', '주요 파일'].map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{[
{
dir: 'common/components/',
role: '공통 UI 컴포넌트',
files: 'auth/, layout/, map/, ui/, layer/',
},
{
dir: 'common/hooks/',
role: '공통 커스텀 훅',
files: 'useLayers, useSubMenu, useFeatureTracking',
},
{
dir: 'common/services/',
role: 'API 통신 모듈',
files: 'api.ts, authApi.ts, layerService.ts',
},
{
dir: 'common/store/',
role: '전역 상태 스토어',
files: 'authStore.ts, menuStore.ts',
},
{
dir: 'common/styles/',
role: 'CSS @layer 스타일',
files: 'base.css, components.css, wing.css',
},
{
dir: 'common/types/',
role: '공통 타입 정의',
files: 'backtrack, hns, navigation 등',
},
{
dir: 'common/utils/',
role: '유틸리티 함수',
files: 'coordinates, geo, sanitize, cn.ts',
},
{ dir: 'common/constants/', role: '상수 정의', files: 'featureIds.ts' },
{ dir: 'common/data/', role: 'UI 데이터', files: 'layerData.ts (레이어 트리)' },
].map((row) => (
<tr key={row.dir} className="border-b border-stroke hover:bg-bg-surface/50">
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap font-mono">
{row.dir}
</td>
<td className="px-3 py-2 text-t2">{row.role}</td>
<td className="px-3 py-2 text-t3 font-mono">{row.files}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
);
}

파일 보기

@ -0,0 +1,171 @@
interface TechStackRow {
category: string;
tech: string;
version: string;
description: string;
}
const TECH_STACK: TechStackRow[] = [
{ category: 'Frontend', tech: 'React', version: '19.x', description: '컴포넌트 기반 SPA' },
{ category: 'Frontend', tech: 'TypeScript', version: '5.9', description: '정적 타입 시스템' },
{ category: 'Frontend', tech: 'Vite', version: '7.x', description: '빌드 도구 (HMR)' },
{ category: 'Frontend', tech: 'Tailwind CSS', version: '3.x', description: '유틸리티 기반 CSS' },
{ category: 'Frontend', tech: 'MapLibre GL', version: '5.x', description: '오픈소스 GIS 엔진' },
{ category: 'Frontend', tech: 'deck.gl', version: '9.x', description: '대규모 데이터 시각화' },
{ category: 'Frontend', tech: 'Zustand', version: '-', description: '클라이언트 상태관리' },
{ category: 'Frontend', tech: 'TanStack Query', version: '-', description: '서버 상태관리/캐싱' },
{ category: 'Backend', tech: 'Express', version: '4.x', description: 'REST API 서버' },
{ category: 'Backend', tech: 'Socket.IO', version: '-', description: '실시간 양방향 통신' },
{ category: 'DB', tech: 'PostgreSQL', version: '16', description: '관계형 데이터베이스' },
{ category: 'DB', tech: 'PostGIS', version: '-', description: '공간정보 확장' },
{
category: '인증',
tech: 'JWT',
version: '-',
description: '토큰 기반 인증 (HttpOnly Cookie)',
},
{ category: '인증', tech: 'Google OAuth', version: '2.0', description: 'SSO 연동' },
{ category: '보안', tech: 'Helmet', version: '-', description: 'HTTP 헤더 보안' },
{ category: '보안', tech: 'Rate Limiting', version: '-', description: 'API 호출 제한' },
{ category: 'CI/CD', tech: 'Gitea Actions', version: '-', description: '자동 빌드/배포' },
];
// ─── 탭 모듈 데이터 ───────────────────────────────────────────────────────────────
export function FrameworkTab() {
return (
<div className="flex flex-col gap-6 p-5">
{/* 1. 개발 프레임워크 구성 */}
<section>
<h3 className="text-body-2 font-semibold text-t1 mb-3">1. </h3>
<div className="border border-stroke rounded overflow-hidden">
{/* 프레젠테이션 계층 */}
<div className="border-b border-stroke p-4 bg-bg-card">
<p className="text-caption font-semibold text-t2 mb-1"> </p>
<p className="text-caption text-t3 mb-3">React 19 + TypeScript 5.9 + Tailwind CSS 3</p>
<div className="grid grid-cols-4 gap-2">
{[
{ name: 'MapLibre', sub: 'GL JS 5' },
{ name: 'deck.gl', sub: '9.x' },
{ name: 'Zustand', sub: '상태관리' },
{ name: 'TanStack', sub: 'Query' },
].map((item) => (
<div
key={item.name}
className="bg-bg-elevated border border-stroke rounded p-2 text-center"
>
<p className="text-caption font-medium text-t1">{item.name}</p>
<p className="text-caption text-t3 mt-0.5">{item.sub}</p>
</div>
))}
</div>
</div>
{/* 비즈니스 로직 계층 */}
<div className="border-b border-stroke p-4 bg-bg-surface">
<p className="text-caption font-semibold text-t2 mb-1"> </p>
<p className="text-caption text-t3 mb-3">Express 4 + TypeScript</p>
<div className="grid grid-cols-4 gap-2">
{[
{ name: 'JWT 인증', sub: 'OAuth2.0' },
{ name: 'RBAC', sub: '권한엔진' },
{ name: 'Socket.IO', sub: '실시간' },
{ name: 'Helmet', sub: '보안' },
].map((item) => (
<div
key={item.name}
className="bg-bg-elevated border border-stroke rounded p-2 text-center"
>
<p className="text-caption font-medium text-t1">{item.name}</p>
<p className="text-caption text-t3 mt-0.5">{item.sub}</p>
</div>
))}
</div>
</div>
{/* 데이터 접근 계층 */}
<div className="p-4 bg-bg-card">
<p className="text-caption font-semibold text-t2 mb-1"> </p>
<p className="text-caption text-t3 mb-3">PostgreSQL 16 + PostGIS</p>
<div className="grid grid-cols-3 gap-2 max-w-xs">
{[
{ name: 'wing DB', sub: '운영 DB' },
{ name: 'wing_auth', sub: '인증 DB' },
{ name: 'PostGIS', sub: '공간정보' },
].map((item) => (
<div
key={item.name}
className="bg-bg-elevated border border-stroke rounded p-2 text-center"
>
<p className="text-caption font-medium text-t1">{item.name}</p>
<p className="text-caption text-t3 mt-0.5">{item.sub}</p>
</div>
))}
</div>
</div>
</div>
</section>
{/* 2. 기술 스택 상세 */}
<section>
<h3 className="text-body-2 font-semibold text-t1 mb-3">2. </h3>
<div className="overflow-auto">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{['구분', '기술', '버전', '설명'].map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{TECH_STACK.map((row, idx) => (
<tr key={idx} className="border-b border-stroke hover:bg-bg-surface/50">
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">
{row.category}
</td>
<td className="px-3 py-2 text-t1 whitespace-nowrap">{row.tech}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.version}</td>
<td className="px-3 py-2 text-t2">{row.description}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{/* 3. 개발 표준 및 규칙 */}
<section>
<h3 className="text-body-2 font-semibold text-t1 mb-3">3. </h3>
<div className="grid grid-cols-1 gap-3">
{[
{
title: 'HTTP 정책',
content: 'GET/POST만 사용 (PUT/DELETE/PATCH 금지) — 한국 보안취약점 점검 가이드 준수',
},
{
title: '코드 표준',
content: 'ESLint + Prettier 적용, TypeScript strict 모드 필수',
},
{
title: '모듈 구조',
content: '@common/ (공통 모듈) + @components/ (업무별 탭) Path Alias 기반 분리',
},
{
title: '보안',
content: '입력 살균(sanitize), XSS/SQL Injection 방지, CORS 정책, Rate Limiting',
},
].map((item) => (
<div key={item.title} className="bg-bg-card border border-stroke rounded p-3">
<p className="text-caption font-semibold text-t2 mb-1">{item.title}</p>
<p className="text-caption text-t2 leading-relaxed">{item.content}</p>
</div>
))}
</div>
</section>
</div>
);
}

파일 보기

@ -0,0 +1,367 @@
interface HeterogeneousSystemRow {
system: string;
lang: string;
os: string;
location: string;
protocol: string;
description: string;
}
const HETEROGENEOUS_SYSTEMS: HeterogeneousSystemRow[] = [
{
system: 'KOSPS',
lang: 'Fortran',
os: 'Linux',
location: '광주',
protocol: 'HTTPS (REST 래퍼)',
description: '유출유 확산 예측 — Fortran DLL을 REST API로 래핑하여 연계',
},
{
system: '충북대 HNS',
lang: 'Python / C++',
os: 'Linux',
location: '충북대',
protocol: 'HTTPS',
description: 'HNS 대기확산 예측 — Python/C++ 모델을 REST API로 호출',
},
{
system: '긴급구난',
lang: 'Python',
os: 'Linux',
location: '해경 내부',
protocol: '내부망 API',
description: '구난 표류 분석 — Python 모델을 내부망 REST API로 연계',
},
{
system: 'HYCOM',
lang: 'Fortran / NetCDF',
os: 'Linux HPC',
location: '미 해군 공개',
protocol: 'HTTPS / FTP',
description: '전지구 해류·수온 예측 — NetCDF 파일 수신 후 ETL 전처리',
},
{
system: '기상청',
lang: '-',
os: '-',
location: '기상청 API Hub',
protocol: 'HTTPS',
description: '풍향·풍속·기온·강수 등 기상 데이터 REST API 수집',
},
{
system: 'KHOA',
lang: '-',
os: '-',
location: '해양조사원',
protocol: 'HTTPS',
description: '조위·해류·수온 등 해양관측 데이터 REST API 수집',
},
{
system: '해경 KBP',
lang: 'Java 전자정부',
os: 'Linux',
location: '해경 내부망',
protocol: '내부망 API',
description: '사용자·조직·직위 인사 데이터 배치 수집 (비식별화 적용)',
},
{
system: 'AIS',
lang: '-',
os: '-',
location: '해경 AIS 서버',
protocol: 'Socket / API',
description: '선박 위치·속도·방향 실시간 수신',
},
];
interface HeterogeneousStrategyCard {
challenge: string;
solution: string;
description: string;
}
interface IntegrationPlanItem {
title: string;
description: string;
details?: string[];
}
const INTEGRATION_PLANS: IntegrationPlanItem[] = [
{
title: '사용자 정보 연계',
description:
'해양경찰청의 인사관리플랫폼과 연계 또는 사용자 정보를 제공받아 구성할 수 있어야 함',
},
{
title: '해양공간 데이터 연계',
description:
"해경 해양공간 데이터 구축사업(해양공간정보 활용체계 구축 및 빅데이터 관련 사업)의 '데이터통합저장소' 시스템과 연계하여 현장탐색 전자지도 자동표출 기술 등에 영상 및 사진자료를 연계하여 구축",
},
{
title: 'DB 통합설계 기반 맞춤형 인터페이스',
description:
'플랫폼 변경 및 신규 통합설계 되는 데이터베이스(DB) 구조 설계를 기반으로 사용자 맞춤형 화면 인터페이스를 구현해야 함',
details: [
'DBMS는 분리되어 있는 시스템들을 통합설계를 통하여 공통, 분야별 등으로 설계하여야 함',
],
},
{
title: '유출유 확산예측 정확성 향상 (KOSPS 연계)',
description:
'유출유 확산예측 정확성 향상을 위해, 해양오염방제지원시스템(KOSPS)를 연계·탑재하여야 함',
details: [
'다양한 유출유 확산 예측 결과를 사용자가 한눈에 확인 가능하여야 함',
'확산예측 기반으로 역추적, 최초 유출유 발생지점을 예측할 수 있어야 함',
'그 밖에 유출유 확산예측 정확성 향상을 위한 대책을 마련하여야 함',
],
},
{
title: '기타 시스템 연계',
description: '그 밖에 시스템 구축 중 효율적인 사고대응을 위한 타 시스템 연계할 수 있음',
},
];
const HETEROGENEOUS_STRATEGIES: HeterogeneousStrategyCard[] = [
{
challenge: '언어 이질성',
solution: 'REST API 래퍼 계층',
description:
'Fortran, Python, C++, Java 등 각 언어로 작성된 모델을 REST API 래퍼로 감싸 언어·플랫폼 독립적인 표준 인터페이스 제공',
},
{
challenge: '데이터 형식 차이',
solution: 'ETL 전처리 파이프라인',
description:
'NetCDF, CSV, Binary, JSON 등 이기종 포맷을 ETL 파이프라인으로 표준 JSON/GeoJSON 형식으로 변환 후 DB 적재',
},
{
challenge: '네트워크 분리',
solution: '이중 네트워크 연계',
description:
'외부망(인터넷) 연계와 내부망(해경 내부) 연계를 분리 운영하여 보안 정책 준수 및 데이터 안전성 확보',
},
{
challenge: '가용성·장애 대응',
solution: '연계 모니터링 + 알림',
description:
'연계 상태를 실시간 모니터링하고 수신 지연·실패 발생 시 운영자에게 즉시 알림 발송하여 신속 대응',
},
{
challenge: '인증·보안 차이',
solution: 'API Gateway 패턴',
description:
'시스템별 상이한 인증 방식(API Key, JWT, IP 제한 등)을 API Gateway 계층에서 통합 관리하여 단일 보안 정책 적용',
},
{
challenge: '프로토콜 차이',
solution: '어댑터 패턴 적용',
description:
'HTTP REST, FTP, Socket, 배치 파일 등 다양한 프로토콜을 어댑터 패턴으로 추상화하여 표준 인터페이스로 통일',
},
];
const HETEROGENEOUS_FLOW_STEPS = [
'원본 데이터',
'수집 어댑터',
'ETL 전처리',
'표준 변환',
'DB 적재',
'API 제공',
];
interface SecurityPolicyCard {
title: string;
items: string[];
}
const HETEROGENEOUS_SECURITY: SecurityPolicyCard[] = [
{
title: '외부망 연계',
items: [
'TLS 1.2+ 암호화 통신',
'API Key / OAuth 인증',
'IP 화이트리스트 제한',
'Rate Limiting 적용',
],
},
{
title: '내부망 연계',
items: [
'전용 내부망 구간 분리',
'상호 인증서 검증',
'비식별화 자동 처리',
'접근 이력 감사로그',
],
},
{
title: '데이터 보호',
items: [
'개인정보 수집 최소화',
'ETL 단계 비식별화',
'전송 구간 암호화',
'저장 데이터 접근 제어',
],
},
];
// ─── 탭 4: 이기종시스템연계 ───────────────────────────────────────────────────────
export function HeterogeneousTab() {
return (
<div className="p-5 space-y-6">
{/* 1. 이기종시스템 연계 개요 */}
<section>
<h3 className="text-body-2 font-semibold text-t1 mb-3">1. </h3>
<p className="text-caption text-t2 leading-relaxed mb-4">
Fortran, Python, C++, Java
. REST API , ETL , ·
, · .
</p>
<div className="flex items-stretch gap-2">
<div className="flex-1 bg-bg-elevated border border-stroke rounded p-3 text-center">
<p className="text-caption font-semibold text-t2 mb-2"> </p>
{['Fortran KOSPS', 'Python/C++ 충북대', 'Java 해경KBP', 'NetCDF HYCOM'].map((item) => (
<p key={item} className="text-caption text-t3 leading-relaxed">
{item}
</p>
))}
</div>
<div className="flex flex-col items-center justify-center shrink-0 gap-0.5">
<span className="text-t3 text-lg"></span>
<span className="text-t3 text-lg"></span>
</div>
<div className="flex-1 bg-[rgba(6,182,212,0.1)] border border-stroke rounded p-3 text-center">
<p className="text-caption font-semibold text-t2 mb-2"> </p>
{['REST API 래퍼', 'ETL 전처리', '프로토콜 변환', '인증 통합'].map((item) => (
<p key={item} className="text-caption text-t3 leading-relaxed">
{item}
</p>
))}
</div>
<div className="flex flex-col items-center justify-center shrink-0 gap-0.5">
<span className="text-t3 text-lg"></span>
<span className="text-t3 text-lg"></span>
</div>
<div className="flex-1 bg-bg-elevated border border-stroke rounded p-3 text-center">
<p className="text-caption font-semibold text-t2 mb-2"></p>
{['Express REST API', 'PostgreSQL+PostGIS', 'React SPA', '표준 JSON'].map((item) => (
<p key={item} className="text-caption text-t3 leading-relaxed">
{item}
</p>
))}
</div>
</div>
</section>
{/* 2. 이기종 시스템 간의 연계 방안 */}
<section>
<h3 className="text-body-2 font-semibold text-t1 mb-3">2. </h3>
<div className="flex flex-col gap-2">
{INTEGRATION_PLANS.map((item, idx) => (
<div key={item.title} className="bg-bg-card border border-stroke rounded p-3">
<p className="text-caption font-semibold text-t2 mb-1">
{idx + 1}. {item.title}
</p>
<p className="text-caption text-t2 leading-relaxed">{item.description}</p>
{item.details && (
<ul className="mt-1.5 flex flex-col gap-1 pl-3">
{item.details.map((detail) => (
<li key={detail} className="text-caption text-t3 leading-relaxed list-disc">
{detail}
</li>
))}
</ul>
)}
</div>
))}
</div>
</section>
{/* 3. 연계 대상 이기종 시스템 목록 */}
<section>
<h3 className="text-body-2 font-semibold text-t1 mb-3">3. </h3>
<div className="overflow-auto">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{['시스템', '구현 언어', 'OS', '위치', '연계 프로토콜', '연계 설명'].map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{HETEROGENEOUS_SYSTEMS.map((row) => (
<tr key={row.system} className="border-b border-stroke hover:bg-bg-surface/50">
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.system}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.lang}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.os}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.location}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.protocol}</td>
<td className="px-3 py-2 text-t2">{row.description}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{/* 4. 이기종 연계 전략 */}
<section>
<h3 className="text-body-2 font-semibold text-t1 mb-3">4. </h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{HETEROGENEOUS_STRATEGIES.map((card) => (
<div key={card.challenge} className="bg-bg-card border border-stroke rounded p-3">
<div className="flex items-center gap-1.5 mb-1.5">
<span className="text-caption font-semibold text-color-danger">{card.challenge}</span>
<span className="text-t3 text-caption"></span>
<span className="text-caption font-semibold text-color-accent">{card.solution}</span>
</div>
<p className="text-caption text-t2 leading-relaxed">{card.description}</p>
</div>
))}
</div>
</section>
{/* 5. 이기종 데이터 변환 흐름 */}
<section>
<h3 className="text-body-2 font-semibold text-t1 mb-3">5. </h3>
<div className="flex items-center gap-1 flex-wrap">
{HETEROGENEOUS_FLOW_STEPS.map((step, idx) => (
<div key={step} className="flex items-center gap-1">
<div className="bg-bg-elevated border border-stroke rounded px-3 py-2 text-center min-w-16">
<p className="text-caption font-medium text-t1">{step}</p>
</div>
{idx < HETEROGENEOUS_FLOW_STEPS.length - 1 && (
<span className="text-t3 text-lg shrink-0"></span>
)}
</div>
))}
</div>
</section>
{/* 6. 이기종 연계 보안 정책 */}
<section>
<h3 className="text-body-2 font-semibold text-t1 mb-3">6. </h3>
<div className="grid grid-cols-3 gap-3">
{HETEROGENEOUS_SECURITY.map((card) => (
<div key={card.title} className="bg-bg-card border border-stroke rounded p-3">
<p className="text-caption font-semibold text-t2 mb-2">{card.title}</p>
<ul className="flex flex-col gap-1">
{card.items.map((item) => (
<li key={item} className="text-caption text-t2 leading-relaxed">
· {item}
</li>
))}
</ul>
</div>
))}
</div>
</section>
</div>
);
}

파일 보기

@ -0,0 +1,236 @@
interface InterfaceRow {
system: string;
method: string;
data: string;
cycle: string;
protocol: string;
}
const INTERFACES: InterfaceRow[] = [
{
system: 'KHOA (해양조사원)',
method: 'REST API',
data: '조위, 해류, 수온',
cycle: '실시간/1시간',
protocol: 'HTTPS',
},
{
system: '기상청',
method: 'REST API',
data: '풍향/풍속, 기압, 기온, 강수',
cycle: '3시간',
protocol: 'HTTPS',
},
{
system: 'HYCOM',
method: '파일 수신',
data: 'SST, 해류(U/V), SSH',
cycle: '6시간',
protocol: 'HTTPS/FTP',
},
{
system: '해경 KBP (인사)',
method: '배치 수집',
data: '사용자, 부서, 직위, 조직',
cycle: '1일 1회',
protocol: '내부망 API',
},
{
system: 'AIS 선박위치',
method: '실시간 수집',
data: '선박 위치, 속도, 방향',
cycle: '실시간',
protocol: 'Socket/API',
},
{
system: '포세이돈 R&D',
method: 'API 연계',
data: '유출유 확산 예측 결과',
cycle: '요청 시',
protocol: 'HTTPS',
},
{
system: 'KOSPS (광주)',
method: 'DLL 호출',
data: '유출유 확산 예측 결과',
cycle: '요청 시',
protocol: 'HTTPS (Fortran DLL)',
},
{
system: '충북대 HNS',
method: 'API 호출',
data: 'HNS 대기확산 결과',
cycle: '요청 시',
protocol: 'HTTPS',
},
{
system: '긴급구난 R&D',
method: '내부 연계',
data: '구난 분석 결과',
cycle: '요청 시',
protocol: '내부망 API',
},
];
// ─── 탭 1: 표준 프레임워크 ────────────────────────────────────────────────────────
export function InterfaceTab() {
const dataFlowSteps = ['수집', '전처리', '저장', '분석/예측', '시각화', '의사결정지원'];
return (
<div className="flex flex-col gap-6 p-5">
{/* 1. 외부 시스템 연계 구성도 */}
<section>
<h3 className="text-body-2 font-semibold text-t1 mb-3">1. </h3>
<div className="flex items-stretch gap-2">
{/* 외부 시스템 */}
<div className="flex-1 bg-bg-card border border-stroke rounded p-3 flex flex-col gap-1.5">
<p className="text-caption font-semibold text-t2 mb-1 text-center"> </p>
{['KHOA API', '기상청 API', '해경 KBP', 'AIS 선박'].map((item) => (
<div
key={item}
className="bg-bg-elevated border border-stroke rounded px-2 py-1 text-center"
>
<p className="text-caption text-t2">{item}</p>
</div>
))}
</div>
{/* 화살표 */}
<div className="flex flex-col items-center justify-center gap-1 shrink-0">
<span className="text-t3 text-lg"></span>
<span className="text-t3 text-lg"></span>
</div>
{/* 통합지원시스템 */}
<div className="flex-[2] bg-bg-surface border-2 border-stroke rounded p-3 flex flex-col gap-2">
<p className="text-caption font-semibold text-t1 text-center">
<br />
</p>
<div className="border border-stroke rounded p-2 bg-bg-elevated">
<p className="text-caption font-medium text-t2 mb-1 text-center"> </p>
<div className="flex flex-col gap-1">
{['수집자료 관리', '연계 모니터링', '비식별화 조치'].map((item) => (
<p key={item} className="text-caption text-t3 text-center">
- {item}
</p>
))}
</div>
</div>
</div>
{/* 화살표 */}
<div className="flex flex-col items-center justify-center gap-1 shrink-0">
<span className="text-t3 text-lg"></span>
<span className="text-t3 text-lg"></span>
</div>
{/* R&D 시스템 */}
<div className="flex-1 bg-bg-card border border-stroke rounded p-3 flex flex-col gap-1.5">
<p className="text-caption font-semibold text-t2 mb-1 text-center">R&D </p>
{['포세이돈', 'KOSPS', '충북대 HNS', '긴급구난'].map((item) => (
<div
key={item}
className="bg-bg-elevated border border-stroke rounded px-2 py-1 text-center"
>
<p className="text-caption text-t2">{item}</p>
</div>
))}
</div>
</div>
</section>
{/* 2. 연계 인터페이스 목록 */}
<section>
<h3 className="text-body-2 font-semibold text-t1 mb-3">2. </h3>
<div className="overflow-auto">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{['연계 시스템', '연계 방식', '데이터', '주기', '프로토콜'].map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{INTERFACES.map((row) => (
<tr key={row.system} className="border-b border-stroke hover:bg-bg-surface/50">
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.system}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.method}</td>
<td className="px-3 py-2 text-t2">{row.data}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.cycle}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.protocol}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{/* 3. 데이터 흐름도 */}
<section>
<h3 className="text-body-2 font-semibold text-t1 mb-3">3. </h3>
<div className="flex items-center gap-1 flex-wrap">
{dataFlowSteps.map((step, idx) => (
<div key={step} className="flex items-center gap-1">
<div className="bg-bg-elevated border border-stroke rounded px-3 py-2 text-center min-w-16">
<p className="text-caption font-medium text-t1">{step}</p>
</div>
{idx < dataFlowSteps.length - 1 && (
<span className="text-t3 text-lg shrink-0"></span>
)}
</div>
))}
</div>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{[
{ step: '수집', desc: 'KHOA, 기상청, HYCOM, AIS 등 외부 원천 데이터 수신' },
{ step: '전처리', desc: '포맷 변환, 좌표계 통일, 비식별화, 품질 검사' },
{ step: '저장', desc: 'PostgreSQL 16 + PostGIS 공간정보 DB 적재' },
{ step: '분석/예측', desc: 'R&D 모델 연계 (포세이돈, KOSPS, 충북대, 긴급구난)' },
{ step: '시각화', desc: 'MapLibre GL + deck.gl 기반 지도 레이어 렌더링' },
{ step: '의사결정지원', desc: '방제작전 시나리오, 구조분석, 경보 발령 지원' },
].map((item) => (
<div key={item.step} className="bg-bg-card border border-stroke rounded p-2.5">
<p className="text-caption font-semibold text-t2 mb-1">{item.step}</p>
<p className="text-caption text-t2 leading-relaxed">{item.desc}</p>
</div>
))}
</div>
</section>
{/* 4. 연계 장애 대응 */}
<section>
<h3 className="text-body-2 font-semibold text-t1 mb-3">4. </h3>
<div className="flex flex-col gap-2">
{[
{
title: '연계 모니터링',
content: '관리자 > 연계관리 > 연계모니터링에서 실시간 연계 상태 확인',
},
{
title: 'R&D 파이프라인 모니터링',
content: '관리자 > 연계관리 > R&D과제에서 과제별 데이터 수신 이력 및 처리 현황 확인',
},
{
title: '장애 알림',
content: '데이터 수신 지연/실패 발생 시 알림 발생 — 운영자 즉시 인지 가능',
},
{
title: '비식별화 조치',
content: '개인정보 포함 데이터(해경 KBP 인사 등) 수집 시 자동 비식별화 처리 적용',
},
].map((item) => (
<div key={item.title} className="bg-bg-card border border-stroke rounded p-3">
<p className="text-caption font-semibold text-t2 mb-1">{item.title}</p>
<p className="text-caption text-t2 leading-relaxed">{item.content}</p>
</div>
))}
</div>
</section>
</div>
);
}

파일 보기

@ -0,0 +1,70 @@
import type { PermState } from '../PermissionsPanel';
interface PermCellProps {
state: PermState;
onToggle: () => void;
label?: string;
readOnly?: boolean;
}
export function PermCell({ state, onToggle, label, readOnly = false }: PermCellProps) {
const isDisabled = state === 'forced-denied' || readOnly;
const baseClasses =
'w-5 h-5 rounded border text-caption font-bold transition-all flex items-center justify-center';
let classes: string;
let icon: string;
switch (state) {
case 'explicit-granted':
classes = readOnly
? `${baseClasses} bg-[rgba(6,182,212,0.2)] border-color-accent text-color-accent cursor-default`
: `${baseClasses} bg-[rgba(6,182,212,0.2)] border-color-accent text-color-accent cursor-pointer hover:bg-[rgba(6,182,212,0.3)]`;
icon = '✓';
break;
case 'inherited-granted':
classes = readOnly
? `${baseClasses} bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-[rgba(6,182,212,0.5)] cursor-default`
: `${baseClasses} bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-[rgba(6,182,212,0.5)] cursor-pointer hover:border-color-accent`;
icon = '✓';
break;
case 'explicit-denied':
classes = readOnly
? `${baseClasses} bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-color-danger cursor-default`
: `${baseClasses} bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-color-danger cursor-pointer hover:border-color-danger`;
icon = '—';
break;
case 'forced-denied':
classes = `${baseClasses} bg-bg-elevated border-stroke text-fg-disabled opacity-40 cursor-not-allowed`;
icon = '—';
break;
}
return (
<button
onClick={isDisabled ? undefined : onToggle}
disabled={isDisabled}
className={classes}
title={
readOnly
? state === 'explicit-granted'
? `${label ?? ''} 허용`
: state === 'inherited-granted'
? `${label ?? ''} 상속 허용`
: state === 'explicit-denied'
? `${label ?? ''} 거부`
: `${label ?? ''} 비활성`
: state === 'explicit-granted'
? `${label ?? ''} 명시적 허용 (클릭: 거부로 전환)`
: state === 'inherited-granted'
? `${label ?? ''} 부모 상속 허용 (클릭: 명시적 거부)`
: state === 'explicit-denied'
? `${label ?? ''} 명시적 거부 (클릭: 허용으로 전환)`
: `${label ?? ''} 부모 거부로 비활성`
}
>
{icon}
</button>
);
}

파일 보기

@ -0,0 +1,36 @@
export function PermLegend() {
return (
<div
className="flex items-center gap-3 px-4 py-1.5 border-b border-stroke bg-bg-surface text-caption text-fg-disabled font-korean"
style={{ flexShrink: 0 }}
>
<span className="flex items-center gap-1">
<span className="inline-block w-3 h-3 rounded border bg-[rgba(6,182,212,0.2)] border-color-accent text-color-accent text-center text-caption leading-3">
</span>
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-3 h-3 rounded border bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-[rgba(6,182,212,0.5)] text-center text-caption leading-3">
</span>
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-3 h-3 rounded border bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-color-danger text-center text-caption leading-3">
</span>
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-3 h-3 rounded border bg-bg-elevated border-stroke text-fg-disabled opacity-40 text-center text-caption leading-3">
</span>
</span>
<span className="ml-2 border-l border-stroke pl-2 text-fg-disabled">
R= C= U= D=
</span>
</div>
);
}

파일 보기

@ -0,0 +1,15 @@
export function ProgressBar({ value }: { value: number }) {
const colorClass =
value === 100 ? 'bg-color-success' : value > 0 ? 'bg-color-accent' : 'bg-bg-elevated';
return (
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-bg-elevated rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${colorClass}`}
style={{ width: `${value}%` }}
/>
</div>
<span className="text-t3 w-8 text-right">{value}%</span>
</div>
);
}

파일 보기

@ -0,0 +1,228 @@
import { useState } from 'react';
import { createUserApi, type RoleWithPermissions, type OrgItem } from '@common/services/authApi';
import { getRoleColor } from '../adminConstants';
interface RegisterModalProps {
allRoles: RoleWithPermissions[];
allOrgs: OrgItem[];
onClose: () => void;
onSuccess: () => void;
}
export function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalProps) {
const [account, setAccount] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const [rank, setRank] = useState('');
const [orgSn, setOrgSn] = useState<number | ''>(() => {
const defaultOrg = allOrgs.find((o) => o.orgNm === '기동방제과');
return defaultOrg ? defaultOrg.orgSn : '';
});
const [email, setEmail] = useState('');
const [roleSns, setRoleSns] = useState<number[]>([]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const toggleRole = (sn: number) => {
setRoleSns((prev) => (prev.includes(sn) ? prev.filter((s) => s !== sn) : [...prev, sn]));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!account.trim() || !password.trim() || !name.trim()) {
setError('계정, 비밀번호, 사용자명은 필수 항목입니다.');
return;
}
setSubmitting(true);
setError(null);
try {
await createUserApi({
account: account.trim(),
password,
name: name.trim(),
rank: rank.trim() || undefined,
orgSn: orgSn !== '' ? orgSn : undefined,
roleSns: roleSns.length > 0 ? roleSns : undefined,
});
onSuccess();
onClose();
} catch (err) {
setError('사용자 등록에 실패했습니다.');
console.error('사용자 등록 실패:', err);
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-bg-surface border border-stroke rounded-lg shadow-lg w-[480px] max-h-[90vh] flex flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
<h2 className="text-body-2 font-bold text-fg font-korean"> </h2>
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
{/* 폼 */}
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
<div className="px-6 py-4 space-y-4">
{/* 계정 */}
<div>
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
<span className="text-color-danger">*</span>
</label>
<input
type="text"
value={account}
onChange={(e) => setAccount(e.target.value)}
placeholder="로그인 계정 ID"
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
/>
</div>
{/* 비밀번호 */}
<div>
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
<span className="text-color-danger">*</span>
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="초기 비밀번호"
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
/>
</div>
{/* 사용자명 */}
<div>
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
<span className="text-color-danger">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="실명"
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
/>
</div>
{/* 직급 */}
<div>
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
</label>
<input
type="text"
value={rank}
onChange={(e) => setRank(e.target.value)}
placeholder="예: 팀장, 주임 등"
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
/>
</div>
{/* 소속 */}
<div>
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
</label>
<select
value={orgSn}
onChange={(e) => setOrgSn(e.target.value !== '' ? Number(e.target.value) : '')}
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
>
<option value=""> </option>
{allOrgs.map((org) => (
<option key={org.orgSn} value={org.orgSn}>
{org.orgNm}
{org.orgAbbrNm ? ` (${org.orgAbbrNm})` : ''}
</option>
))}
</select>
</div>
{/* 이메일 */}
<div>
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="이메일 주소"
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
/>
</div>
{/* 역할 */}
<div>
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
</label>
<div className="bg-bg-elevated border border-stroke rounded-md p-2 space-y-1 max-h-[120px] overflow-y-auto">
{allRoles.length === 0 ? (
<p className="text-caption text-fg-disabled font-korean px-1 py-1"> </p>
) : (
allRoles.map((role, idx) => {
const color = getRoleColor(role.code, idx);
return (
<label
key={role.sn}
className="flex items-center gap-2 px-2 py-1.5 hover:bg-[rgba(6,182,212,0.08)] rounded cursor-pointer"
>
<input
type="checkbox"
checked={roleSns.includes(role.sn)}
onChange={() => toggleRole(role.sn)}
style={{ accentColor: color }}
/>
<span className="text-caption font-korean" style={{ color }}>
{role.name}
</span>
<span className="text-caption text-fg-disabled font-mono">{role.code}</span>
</label>
);
})
)}
</div>
</div>
{/* 에러 메시지 */}
{error && <p className="text-label-2 text-color-danger font-korean">{error}</p>}
</div>
{/* 푸터 */}
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-stroke">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-caption border border-stroke text-fg-sub rounded-md hover:bg-[rgba(6,182,212,0.08)] transition-all font-korean"
>
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 text-caption font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all disabled:opacity-50 font-korean"
>
{submitting ? '등록 중...' : '등록'}
</button>
</div>
</form>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,312 @@
import type { PermTreeNode, RoleWithPermissions } from '@common/services/authApi';
import type { PermState, OperCode } from '../PermissionsPanel';
import { OPER_CODES, OPER_FULL_LABELS, OPER_LABELS, buildEffectiveStates } from '../PermissionsPanel';
import { TreeRow } from './TreeRow';
import { PermLegend } from './PermLegend';
interface RolePermTabProps {
roles: RoleWithPermissions[];
permTree: PermTreeNode[];
rolePerms: Map<number, Map<string, boolean>>;
setRolePerms: React.Dispatch<React.SetStateAction<Map<number, Map<string, boolean>>>>;
selectedRoleSn: number | null;
setSelectedRoleSn: (sn: number | null) => void;
dirty: boolean;
saving: boolean;
saveError: string | null;
handleSave: () => Promise<void>;
handleToggleExpand: (code: string) => void;
handleTogglePerm: (code: string, oper: OperCode, currentState: PermState) => void;
expanded: Set<string>;
flatNodes: PermTreeNode[];
editingRoleSn: number | null;
editRoleName: string;
setEditRoleName: (name: string) => void;
handleStartEditName: (role: RoleWithPermissions) => void;
handleSaveRoleName: (roleSn: number) => Promise<void>;
setEditingRoleSn: (sn: number | null) => void;
toggleDefault: (roleSn: number) => Promise<void>;
handleDeleteRole: (roleSn: number, roleName: string) => Promise<void>;
showCreateForm: boolean;
setShowCreateForm: (show: boolean) => void;
setCreateError: (err: string) => void;
newRoleCode: string;
setNewRoleCode: (code: string) => void;
newRoleName: string;
setNewRoleName: (name: string) => void;
newRoleDesc: string;
setNewRoleDesc: (desc: string) => void;
creating: boolean;
createError: string;
handleCreateRole: () => Promise<void>;
}
export function RolePermTab({
roles,
permTree,
selectedRoleSn,
setSelectedRoleSn,
dirty,
saving,
saveError,
handleSave,
handleToggleExpand,
handleTogglePerm,
expanded,
flatNodes,
rolePerms,
editingRoleSn,
editRoleName,
setEditRoleName,
handleStartEditName,
handleSaveRoleName,
setEditingRoleSn,
toggleDefault,
handleDeleteRole,
showCreateForm,
setShowCreateForm,
setCreateError,
newRoleCode,
setNewRoleCode,
newRoleName,
setNewRoleName,
newRoleDesc,
setNewRoleDesc,
creating,
createError,
handleCreateRole,
}: RolePermTabProps) {
const currentStateMap = selectedRoleSn
? buildEffectiveStates(flatNodes, rolePerms.get(selectedRoleSn) ?? new Map())
: new Map<string, PermState>();
return (
<>
{/* 헤더 액션 버튼 */}
<div
className="flex items-center gap-2 px-4 py-2 border-b border-stroke"
style={{ flexShrink: 0 }}
>
<button
onClick={() => {
setShowCreateForm(true);
setCreateError('');
}}
className="px-3 py-1.5 text-label-2 font-semibold rounded-md border border-color-accent text-color-accent hover:bg-[rgba(6,182,212,0.08)] transition-all font-korean"
>
+
</button>
<button
onClick={handleSave}
disabled={!dirty || saving}
className={`px-3 py-1.5 text-label-2 font-semibold rounded-md transition-all font-korean ${
dirty
? 'bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
: 'bg-bg-card text-fg-disabled cursor-not-allowed'
}`}
>
{saving ? '저장 중...' : '변경사항 저장'}
</button>
{saveError && (
<span className="text-label-2 text-color-danger font-korean">{saveError}</span>
)}
</div>
{/* 역할 탭 바 */}
<div
className="flex items-center gap-1.5 px-4 py-2 border-b border-stroke bg-bg-surface overflow-x-auto"
style={{ flexShrink: 0 }}
>
{roles.map((role) => {
const isSelected = selectedRoleSn === role.sn;
return (
<div key={role.sn} className="flex items-center gap-0.5 flex-shrink-0">
<button
onClick={() => setSelectedRoleSn(role.sn)}
className={`px-2.5 py-1 text-label-2 font-semibold rounded-md transition-all font-korean ${
isSelected
? 'border-2 border-color-accent text-color-accent shadow-[0_0_8px_rgba(6,182,212,0.2)]'
: 'border border-stroke text-fg-disabled hover:border-stroke-light hover:text-fg-sub'
}`}
>
{editingRoleSn === role.sn ? (
<input
type="text"
value={editRoleName}
onChange={(e) => setEditRoleName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveRoleName(role.sn);
if (e.key === 'Escape') setEditingRoleSn(null);
}}
onBlur={() => handleSaveRoleName(role.sn)}
onClick={(e) => e.stopPropagation()}
autoFocus
className="w-20 px-1 py-0 text-label-2 font-semibold bg-bg-elevated border border-color-accent rounded text-center text-fg focus:outline-none font-korean"
/>
) : (
<span onDoubleClick={() => handleStartEditName(role)}>{role.name}</span>
)}
<span className="ml-1 text-caption font-mono opacity-50">{role.code}</span>
{role.isDefault && (
<span className="ml-1 text-caption text-color-accent"></span>
)}
</button>
{isSelected && (
<div className="flex items-center gap-0.5">
<button
onClick={() => toggleDefault(role.sn)}
className={`px-1.5 py-0.5 text-caption rounded transition-all font-korean ${
role.isDefault
? 'bg-[rgba(6,182,212,0.15)] text-color-accent'
: 'text-fg-disabled hover:text-fg-sub'
}`}
title="신규 사용자 기본 역할 설정"
>
{role.isDefault ? '기본역할' : '기본설정'}
</button>
{role.code !== 'ADMIN' && (
<button
onClick={() => handleDeleteRole(role.sn, role.name)}
className="w-5 h-5 flex items-center justify-center text-fg-disabled hover:text-color-danger transition-colors"
title="역할 삭제"
>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
)}
</div>
);
})}
</div>
{/* 범례 */}
<PermLegend />
{/* CRUD 매트릭스 테이블 */}
{selectedRoleSn ? (
<div className="flex-1 overflow-auto">
<table className="w-full">
<thead>
<tr className="border-b border-stroke bg-bg-surface sticky top-0 z-10">
<th className="px-3 py-1.5 text-left text-caption font-semibold text-fg-disabled font-korean min-w-[200px]">
</th>
{OPER_CODES.map((oper) => (
<th key={oper} className="px-1 py-1.5 text-center w-12">
<div className="text-caption font-semibold text-fg-sub">
{OPER_LABELS[oper]}
</div>
<div className="text-caption text-fg-disabled font-korean">
{OPER_FULL_LABELS[oper]}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{permTree.map((rootNode) => (
<TreeRow
key={rootNode.code}
node={rootNode}
stateMap={currentStateMap}
expanded={expanded}
onToggleExpand={handleToggleExpand}
onTogglePerm={handleTogglePerm}
/>
))}
</tbody>
</table>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-fg-disabled text-body-2 font-korean">
</div>
)}
{/* 역할 생성 모달 */}
{showCreateForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-[400px] bg-bg-surface rounded-lg border border-stroke shadow-2xl">
<div className="px-5 py-4 border-b border-stroke">
<h3 className="text-body-2 font-bold text-fg font-korean"> </h3>
</div>
<div className="px-5 py-4 flex flex-col gap-3">
<div>
<label className="text-label-2 text-fg-disabled font-korean block mb-1">
</label>
<input
type="text"
value={newRoleCode}
onChange={(e) =>
setNewRoleCode(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, ''))
}
placeholder="CUSTOM_ROLE"
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
/>
<p className="text-caption text-fg-disabled mt-1 font-korean">
, , ( )
</p>
</div>
<div>
<label className="text-label-2 text-fg-disabled font-korean block mb-1">
</label>
<input
type="text"
value={newRoleName}
onChange={(e) => setNewRoleName(e.target.value)}
placeholder="사용자 정의 역할"
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
/>
</div>
<div>
<label className="text-label-2 text-fg-disabled font-korean block mb-1">
()
</label>
<input
type="text"
value={newRoleDesc}
onChange={(e) => setNewRoleDesc(e.target.value)}
placeholder="역할에 대한 설명"
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
/>
</div>
{createError && (
<div className="px-3 py-2 text-label-2 text-color-danger bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)] rounded-md font-korean">
{createError}
</div>
)}
</div>
<div className="px-5 py-3 border-t border-stroke flex justify-end gap-2">
<button
onClick={() => setShowCreateForm(false)}
className="px-4 py-2 text-caption text-fg-disabled border border-stroke rounded-md hover:bg-bg-surface-hover font-korean"
>
</button>
<button
onClick={handleCreateRole}
disabled={!newRoleCode || !newRoleName || creating}
className="px-4 py-2 text-caption font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean disabled:opacity-50"
>
{creating ? '생성 중...' : '생성'}
</button>
</div>
</div>
</div>
)}
</>
);
}

파일 보기

@ -0,0 +1,120 @@
import type { ApiConfig, DbConfig, SourceType, WizardState } from '../DeidentifyPanel';
interface Step1Props {
wizard: WizardState;
onChange: (patch: Partial<WizardState>) => void;
}
export function Step1({ wizard, onChange }: Step1Props) {
const handleDbChange = (key: keyof DbConfig, value: string) => {
onChange({ dbConfig: { ...wizard.dbConfig, [key]: value } });
};
const handleApiChange = (key: keyof ApiConfig, value: string) => {
onChange({ apiConfig: { ...wizard.apiConfig, [key]: value } });
};
return (
<div className="space-y-5">
<div>
<label className="block text-caption font-medium text-t2 mb-1"> *</label>
<input
type="text"
value={wizard.taskName}
onChange={(e) => onChange({ taskName: e.target.value })}
placeholder="작업 이름을 입력하세요"
className="w-full px-3 py-2 text-caption rounded bg-bg-elevated border border-stroke text-t1 placeholder:text-t3 focus:outline-none focus:border-color-accent"
/>
</div>
<div>
<label className="block text-caption font-medium text-t2 mb-2"> *</label>
<div className="flex flex-col gap-2">
{(
[
['db', '데이터베이스 연결'],
['file', '파일 업로드'],
['api', 'API 호출'],
] as [SourceType, string][]
).map(([val, label]) => (
<label key={val} className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="sourceType"
value={val}
checked={wizard.sourceType === val}
onChange={() => onChange({ sourceType: val })}
className="accent-cyan-500"
/>
<span className="text-caption text-t1">{label}</span>
</label>
))}
</div>
</div>
{wizard.sourceType === 'db' && (
<div className="grid grid-cols-2 gap-3 p-4 rounded bg-bg-surface border border-stroke">
{(
[
['host', '호스트', 'localhost'],
['port', '포트', '5432'],
['database', '데이터베이스', 'wing'],
['tableName', '테이블명', 'public.customers'],
] as [keyof DbConfig, string, string][]
).map(([key, labelText, placeholder]) => (
<div key={key}>
<label className="block text-label-2 text-t3 mb-1">{labelText}</label>
<input
type="text"
value={wizard.dbConfig[key]}
onChange={(e) => handleDbChange(key, e.target.value)}
placeholder={placeholder}
className="w-full px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 placeholder:text-t3 focus:outline-none focus:border-color-accent"
/>
</div>
))}
</div>
)}
{wizard.sourceType === 'file' && (
<div className="p-8 rounded border-2 border-dashed border-stroke bg-bg-surface flex flex-col items-center gap-2 text-center">
<svg className="w-8 h-8 text-t3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p className="text-caption text-t2"> </p>
<p className="text-label-2 text-t3">CSV, XLSX, JSON ( 500MB)</p>
</div>
)}
{wizard.sourceType === 'api' && (
<div className="p-4 rounded bg-bg-surface border border-stroke space-y-3">
<div>
<label className="block text-label-2 text-t3 mb-1">API URL</label>
<input
type="text"
value={wizard.apiConfig.url}
onChange={(e) => handleApiChange('url', e.target.value)}
placeholder="https://api.example.com/data"
className="w-full px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 placeholder:text-t3 focus:outline-none focus:border-color-accent"
/>
</div>
<div>
<label className="block text-label-2 text-t3 mb-1"></label>
<select
value={wizard.apiConfig.method}
onChange={(e) => handleApiChange('method', e.target.value)}
className="px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
>
<option value="GET">GET</option>
<option value="POST">POST</option>
</select>
</div>
</div>
)}
</div>
);
}

파일 보기

@ -0,0 +1,77 @@
import type { WizardState } from '../DeidentifyPanel';
interface Step2Props {
wizard: WizardState;
onChange: (patch: Partial<WizardState>) => void;
}
export function Step2({ wizard, onChange }: Step2Props) {
const toggleField = (idx: number) => {
const updated = wizard.fields.map((f, i) => (i === idx ? { ...f, selected: !f.selected } : f));
onChange({ fields: updated });
};
return (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-3">
{[
{ label: '총 데이터 건수', value: '15,240건', color: 'text-t1' },
{ label: '중복', value: '0건', color: 'text-color-success' },
{ label: '누락값', value: '23건', color: 'text-color-caution' },
].map((stat) => (
<div key={stat.label} className="p-3 rounded bg-bg-surface border border-stroke">
<p className="text-label-2 text-t3 mb-1">{stat.label}</p>
<p className={`text-body-2 font-semibold ${stat.color}`}>{stat.value}</p>
</div>
))}
</div>
<div>
<h4 className="text-caption font-medium text-t2 mb-2"> </h4>
<div className="rounded border border-stroke overflow-hidden">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3">
<th className="px-3 py-2 text-left font-medium border-b border-stroke w-8">
<input
type="checkbox"
checked={wizard.fields.every((f) => f.selected)}
onChange={(e) =>
onChange({
fields: wizard.fields.map((f) => ({ ...f, selected: e.target.checked })),
})
}
className="accent-cyan-500"
/>
</th>
<th className="px-3 py-2 text-left font-medium border-b border-stroke"></th>
<th className="px-3 py-2 text-left font-medium border-b border-stroke">
</th>
</tr>
</thead>
<tbody>
{wizard.fields.map((field, idx) => (
<tr key={field.name} className="border-b border-stroke hover:bg-bg-surface/50">
<td className="px-3 py-2">
<input
type="checkbox"
checked={field.selected}
onChange={() => toggleField(idx)}
className="accent-cyan-500"
/>
</td>
<td className="px-3 py-2 font-medium text-t1">{field.name}</td>
<td className="px-3 py-2 text-t3">{field.dataType}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-1.5 text-label-2 text-t3">
{wizard.fields.filter((f) => f.selected).length} ( {wizard.fields.length})
</p>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,97 @@
import type { FieldConfig, WizardState } from '../DeidentifyPanel';
import { TECHNIQUES, TEMPLATES } from '../DeidentifyPanel';
interface Step3Props {
wizard: WizardState;
onChange: (patch: Partial<WizardState>) => void;
}
export function Step3({ wizard, onChange }: Step3Props) {
const updateField = (idx: number, key: keyof FieldConfig, value: string | boolean) => {
const updated = wizard.fields.map((f, i) => (i === idx ? { ...f, [key]: value } : f));
onChange({ fields: updated });
};
const selectedFields = wizard.fields.filter((f) => f.selected);
return (
<div className="space-y-4">
<div className="rounded border border-stroke overflow-hidden">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3">
<th className="px-3 py-2 text-left font-medium border-b border-stroke"></th>
<th className="px-3 py-2 text-left font-medium border-b border-stroke">
</th>
<th className="px-3 py-2 text-left font-medium border-b border-stroke">
</th>
<th className="px-3 py-2 text-left font-medium border-b border-stroke"></th>
</tr>
</thead>
<tbody>
{selectedFields.map((field) => {
const globalIdx = wizard.fields.findIndex((f) => f.name === field.name);
return (
<tr key={field.name} className="border-b border-stroke hover:bg-bg-surface/50">
<td className="px-3 py-2 font-medium text-t1">{field.name}</td>
<td className="px-3 py-2 text-t3">{field.dataType}</td>
<td className="px-3 py-2">
<select
value={field.technique}
onChange={(e) => updateField(globalIdx, 'technique', e.target.value)}
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
>
{TECHNIQUES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</td>
<td className="px-3 py-2">
<input
type="text"
value={field.configValue}
onChange={(e) => updateField(globalIdx, 'configValue', e.target.value)}
className="w-full px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="flex flex-wrap items-center gap-4 pt-1">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={wizard.saveAsTemplate}
onChange={(e) => onChange({ saveAsTemplate: e.target.checked })}
className="accent-cyan-500"
/>
<span className="text-caption text-t2">릿 </span>
</label>
<div className="flex items-center gap-2">
<span className="text-caption text-t3"> 릿 :</span>
<select
value={wizard.applyTemplate}
onChange={(e) => onChange({ applyTemplate: e.target.value })}
className="px-2.5 py-1 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
>
<option value=""> </option>
{TEMPLATES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,160 @@
import type { OneshotConfig, ProcessMode, RepeatType, ScheduleConfig, WizardState } from '../DeidentifyPanel';
import { HOURS, WEEKDAYS } from '../DeidentifyPanel';
interface Step4Props {
wizard: WizardState;
onChange: (patch: Partial<WizardState>) => void;
}
export function Step4({ wizard, onChange }: Step4Props) {
const handleScheduleChange = (key: keyof ScheduleConfig, value: string | boolean) => {
onChange({ scheduleConfig: { ...wizard.scheduleConfig, [key]: value } });
};
const handleOneshotChange = (key: keyof OneshotConfig, value: string) => {
onChange({ oneshotConfig: { ...wizard.oneshotConfig, [key]: value } });
};
return (
<div className="space-y-4">
<div className="space-y-3">
{(
[
['immediate', '즉시 처리', '지금 바로 데이터를 비식별화합니다.'],
['scheduled', '배치 처리 - 정기 스케줄링', '반복 일정에 따라 자동으로 처리합니다.'],
['oneshot', '배치 처리 - 일회성', '지정한 날짜/시간에 한 번 처리합니다.'],
] as [ProcessMode, string, string][]
).map(([val, label, desc]) => (
<div key={val}>
<label className="flex items-start gap-2.5 cursor-pointer">
<input
type="radio"
name="processMode"
value={val}
checked={wizard.processMode === val}
onChange={() => onChange({ processMode: val })}
className="mt-0.5 accent-cyan-500"
/>
<div>
<span className="text-caption font-medium text-t1">{label}</span>
<p className="text-label-2 text-t3 mt-0.5">{desc}</p>
</div>
</label>
{val === 'scheduled' && wizard.processMode === 'scheduled' && (
<div className="mt-3 ml-5 p-4 rounded bg-bg-surface border border-stroke space-y-3">
<div className="flex items-center gap-3">
<label className="text-caption text-t3 w-16 shrink-0"> </label>
<select
value={wizard.scheduleConfig.hour}
onChange={(e) => handleScheduleChange('hour', e.target.value)}
className="px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
>
{HOURS.map((h) => (
<option key={h} value={h}>
{h}
</option>
))}
</select>
</div>
<div className="flex items-start gap-3">
<label className="text-caption text-t3 w-16 shrink-0 mt-1"></label>
<div className="flex flex-col gap-2">
{(
[
['daily', '매일'],
['weekly', '주 1회'],
['monthly', '월 1회'],
] as [RepeatType, string][]
).map(([rt, rl]) => (
<div key={rt} className="flex items-center gap-2">
<input
type="radio"
name="repeatType"
value={rt}
checked={wizard.scheduleConfig.repeatType === rt}
onChange={() => handleScheduleChange('repeatType', rt)}
className="accent-cyan-500"
/>
<span className="text-caption text-t1">{rl}</span>
{rt === 'weekly' && wizard.scheduleConfig.repeatType === 'weekly' && (
<select
value={wizard.scheduleConfig.weekday}
onChange={(e) => handleScheduleChange('weekday', e.target.value)}
className="ml-1 px-2 py-0.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
>
{WEEKDAYS.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
)}
</div>
))}
</div>
</div>
<div className="flex items-center gap-3">
<label className="text-caption text-t3 w-16 shrink-0"></label>
<input
type="date"
value={wizard.scheduleConfig.startDate}
onChange={(e) => handleScheduleChange('startDate', e.target.value)}
className="px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
/>
</div>
<div className="flex flex-col gap-1.5 mt-1">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={wizard.scheduleConfig.notifyOnComplete}
onChange={(e) => handleScheduleChange('notifyOnComplete', e.target.checked)}
className="accent-cyan-500"
/>
<span className="text-caption text-t2"> </span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={wizard.scheduleConfig.notifyOnError}
onChange={(e) => handleScheduleChange('notifyOnError', e.target.checked)}
className="accent-cyan-500"
/>
<span className="text-caption text-t2"> </span>
</label>
</div>
</div>
)}
{val === 'oneshot' && wizard.processMode === 'oneshot' && (
<div className="mt-3 ml-5 p-4 rounded bg-bg-surface border border-stroke space-y-3">
<div className="flex items-center gap-3">
<label className="text-caption text-t3 w-16 shrink-0"> </label>
<input
type="date"
value={wizard.oneshotConfig.date}
onChange={(e) => handleOneshotChange('date', e.target.value)}
className="px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
/>
</div>
<div className="flex items-center gap-3">
<label className="text-caption text-t3 w-16 shrink-0"> </label>
<select
value={wizard.oneshotConfig.hour}
onChange={(e) => handleOneshotChange('hour', e.target.value)}
className="px-2.5 py-1.5 text-caption rounded bg-bg-elevated border border-stroke text-t1 focus:outline-none focus:border-color-accent"
>
{HOURS.map((h) => (
<option key={h} value={h}>
{h}
</option>
))}
</select>
</div>
</div>
)}
</div>
))}
</div>
</div>
);
}

파일 보기

@ -0,0 +1,62 @@
import type { ProcessMode, WizardState } from '../DeidentifyPanel';
interface Step5Props {
wizard: WizardState;
onChange: (patch: Partial<WizardState>) => void;
}
export function Step5({ wizard, onChange }: Step5Props) {
const selectedCount = wizard.fields.filter((f) => f.selected).length;
const ruleCount = wizard.fields.filter((f) => f.selected && f.technique !== '유지').length;
const processModeLabel: Record<ProcessMode, string> = {
immediate: '즉시 처리',
scheduled: `배치 - 정기 (${wizard.scheduleConfig.hour} / ${wizard.scheduleConfig.repeatType === 'daily' ? '매일' : wizard.scheduleConfig.repeatType === 'weekly' ? `주1회 ${wizard.scheduleConfig.weekday}요일` : '월1회'})`,
oneshot: `배치 - 일회성 (${wizard.oneshotConfig.date} ${wizard.oneshotConfig.hour})`,
};
const summaryRows = [
{ label: '작업명', value: wizard.taskName || '(미입력)' },
{
label: '소스',
value:
wizard.sourceType === 'db'
? `DB: ${wizard.dbConfig.database}.${wizard.dbConfig.tableName}`
: wizard.sourceType === 'file'
? '파일 업로드'
: `API: ${wizard.apiConfig.url}`,
},
{ label: '데이터 건수', value: '15,240건' },
{ label: '선택 필드 수', value: `${selectedCount}` },
{ label: '비식별화 규칙 수', value: `${ruleCount}` },
{ label: '처리 방식', value: processModeLabel[wizard.processMode] },
{ label: '예상 처리시간', value: '약 3~5분' },
];
return (
<div className="space-y-4">
<div className="rounded border border-stroke overflow-hidden">
<table className="w-full text-caption border-collapse">
<tbody>
{summaryRows.map(({ label, value }) => (
<tr key={label} className="border-b border-stroke last:border-b-0">
<td className="px-4 py-2.5 text-t3 bg-bg-elevated w-36 font-medium">{label}</td>
<td className="px-4 py-2.5 text-t1">{value}</td>
</tr>
))}
</tbody>
</table>
</div>
<label className="flex items-center gap-2 cursor-pointer p-3 rounded border border-stroke bg-bg-surface">
<input
type="checkbox"
checked={wizard.confirmed}
onChange={(e) => onChange({ confirmed: e.target.checked })}
className="accent-cyan-500"
/>
<span className="text-caption text-t1 font-medium"> .</span>
</label>
</div>
);
}

파일 보기

@ -0,0 +1,53 @@
import { STEP_LABELS } from '../DeidentifyPanel';
export function StepIndicator({ current }: { current: number }) {
return (
<div className="flex items-center justify-center gap-0 px-6 py-4 border-b border-stroke">
{STEP_LABELS.map((label, i) => {
const stepNum = i + 1;
const isDone = stepNum < current;
const isActive = stepNum === current;
return (
<div key={label} className="flex items-center">
<div className="flex flex-col items-center">
<div
className={`w-7 h-7 rounded-full flex items-center justify-center text-caption font-semibold transition-colors ${
isDone
? 'bg-color-success text-white'
: isActive
? 'bg-color-accent text-white'
: 'bg-bg-elevated text-t3'
}`}
>
{isDone ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.5}
d="M5 13l4 4L19 7"
/>
</svg>
) : (
stepNum
)}
</div>
<span
className={`mt-1 text-label-2 whitespace-nowrap ${
isActive ? 'text-color-accent' : isDone ? 'text-color-success' : 'text-t3'
}`}
>
{stepNum}.{label}
</span>
</div>
{i < STEP_LABELS.length - 1 && (
<div
className={`w-10 h-px mx-1 mb-4 ${i + 1 < current ? 'bg-color-success' : 'bg-stroke-1'}`}
/>
)}
</div>
);
})}
</div>
);
}

파일 보기

@ -0,0 +1,196 @@
interface TabModuleRow {
module: string;
name: string;
feature: string;
integration: string;
}
const TAB_MODULES: TabModuleRow[] = [
{
module: '확산예측',
name: 'prediction',
feature: '유출유 확산 시뮬레이션, 역추적 분석, 오일붐 배치',
integration: 'KOSPS, 포세이돈 R&D',
},
{
module: 'HNS 분석',
name: 'hns',
feature: '화학물질 확산 예측, 물질 DB, 위험도 평가',
integration: '충북대 R&D, 물질 DB',
},
{
module: '구조 시나리오',
name: 'rescue',
feature: '긴급구난 분석, 표류 예측',
integration: '긴급구난 R&D',
},
{
module: '항공 방제',
name: 'aerial',
feature: '위성영상 분석, 드론 영상, 유막 면적 분석',
integration: '위성/드론 데이터',
},
{
module: '해양 기상',
name: 'weather',
feature: '기상·해상 정보, 조위·해류 관측',
integration: 'KHOA API, 기상청 API',
},
{
module: '사건/사고',
name: 'incidents',
feature: '해양오염 사고 등록·관리·이력',
integration: '해경 사고 DB',
},
{
module: '자산 관리',
name: 'assets',
feature: '기관·장비·선박 보험 관리',
integration: '해경 자산 DB',
},
{
module: 'SCAT 조사',
name: 'scat',
feature: 'Pre-SCAT 해안 조사 기록',
integration: '현장 조사 데이터',
},
{
module: '관리자',
name: 'admin',
feature: '사용자/권한/메뉴/설정/연계 관리',
integration: '전체 시스템',
},
];
// ─── 연계 인터페이스 데이터 ───────────────────────────────────────────────────────
export function TargetArchTab() {
return (
<div className="flex flex-col gap-6 p-5">
{/* 1. 시스템 전체 구성도 */}
<section>
<h3 className="text-body-2 font-semibold text-t1 mb-3">1. </h3>
<div className="flex flex-col items-stretch gap-0 border border-stroke rounded overflow-hidden">
{/* 사용자 접근 계층 */}
<div className="p-4 bg-bg-card">
<p className="text-caption font-semibold text-t2 mb-1"> </p>
<p className="text-caption text-t1 font-medium mb-1"> (React SPA)</p>
<p className="text-caption text-t3 leading-relaxed">
| HNS분석 | | | | | SCAT조사 |
|
</p>
</div>
{/* 화살표 + 프로토콜 */}
<div className="flex flex-col items-center py-2 bg-bg-base border-y border-stroke">
<span className="text-t3 text-lg"></span>
<span className="text-caption text-t3">HTTPS (TLS 1.2+)</span>
</div>
{/* API 서버 계층 */}
<div className="p-4 bg-bg-surface border-b border-stroke">
<p className="text-caption font-semibold text-t2 mb-1">API </p>
<p className="text-caption text-t1 font-medium mb-2">Express 4 REST API (Port 3001)</p>
<div className="grid grid-cols-2 gap-1.5 sm:grid-cols-4">
{[
'JWT 인증 미들웨어',
'RBAC 권한 엔진 (permResolver)',
'감사로그 자동 기록',
'입력 살균 / Rate Limiting / Helmet',
].map((item) => (
<div
key={item}
className="bg-bg-elevated border border-stroke rounded px-2 py-1.5 text-center"
>
<p className="text-caption text-t2 leading-snug">{item}</p>
</div>
))}
</div>
</div>
{/* 화살표 + 프로토콜 */}
<div className="flex flex-col items-center py-2 bg-bg-base border-b border-stroke">
<span className="text-t3 text-lg"></span>
<span className="text-caption text-t3">pg connection pool</span>
</div>
{/* 데이터 계층 */}
<div className="p-4 bg-bg-card">
<p className="text-caption font-semibold text-t2 mb-1"> </p>
<p className="text-caption text-t1 font-medium mb-2">PostgreSQL 16 + PostGIS</p>
<div className="flex gap-2">
{[
{ name: 'wing DB', sub: '운영' },
{ name: 'wing_auth', sub: '인증' },
].map((item) => (
<div
key={item.name}
className="bg-bg-elevated border border-stroke rounded p-2 text-center min-w-24"
>
<p className="text-caption font-medium text-t1">{item.name}</p>
<p className="text-caption text-t3 mt-0.5">({item.sub})</p>
</div>
))}
</div>
</div>
</div>
</section>
{/* 2. 탭 기반 업무 모듈 구조 */}
<section>
<h3 className="text-body-2 font-semibold text-t1 mb-3">2. </h3>
<div className="overflow-auto">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{['모듈', '패키지명', '기능', '주요 연계'].map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{TAB_MODULES.map((row) => (
<tr key={row.name} className="border-b border-stroke hover:bg-bg-surface/50">
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.module}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.name}</td>
<td className="px-3 py-2 text-t2">{row.feature}</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.integration}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{/* 3. RBAC 권한 체계 */}
<section>
<h3 className="text-body-2 font-semibold text-t1 mb-3">3. RBAC </h3>
<div className="flex flex-col gap-3">
{[
{
title: '2차원 권한 엔진',
content:
'AUTH_PERM OPER_CD 기반: R(조회), C(생성), U(수정), D(삭제) — 역할별 메뉴·기능 접근 제어',
},
{
title: 'permResolver',
content:
'역할(Role)과 권한(Permission)의 2차원 매핑으로 메뉴 표시 여부 및 기능 사용 가능 여부를 동적으로 판단',
},
{
title: '감사로그 자동 기록',
content:
'누가(사용자) / 언제(타임스탬프) / 무엇을(기능) / 어디서(IP, 메뉴) — 모든 주요 작업 자동 기록',
},
].map((item) => (
<div key={item.title} className="bg-bg-card border border-stroke rounded p-3">
<p className="text-caption font-semibold text-t2 mb-1">{item.title}</p>
<p className="text-caption text-t2 leading-relaxed">{item.content}</p>
</div>
))}
</div>
</section>
</div>
);
}

파일 보기

@ -0,0 +1,100 @@
import type { DeidentifyTask } from '../DeidentifyPanel';
import { TABLE_HEADERS, getStatusBadgeClass } from '../DeidentifyPanel';
import { ProgressBar } from './ProgressBar';
interface TaskTableProps {
rows: DeidentifyTask[];
loading: boolean;
onAction: (action: string, task: DeidentifyTask) => void;
}
export function TaskTable({ rows, loading, onAction }: TaskTableProps) {
return (
<div className="overflow-auto">
<table className="w-full text-caption border-collapse">
<thead>
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
{TABLE_HEADERS.map((h) => (
<th
key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{loading && rows.length === 0
? Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-stroke animate-pulse">
{TABLE_HEADERS.map((_, j) => (
<td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-elevated rounded w-14" />
</td>
))}
</tr>
))
: rows.map((row) => (
<tr key={row.id} className="border-b border-stroke hover:bg-bg-surface/50">
<td className="px-3 py-2 text-t3 font-mono">{row.id}</td>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.name}</td>
<td
className="px-3 py-2 text-t2 whitespace-nowrap max-w-[240px] truncate"
title={row.target}
>
{row.target}
</td>
<td className="px-3 py-2">
<span
className={`inline-block px-2 py-0.5 rounded text-label-2 font-medium ${getStatusBadgeClass(row.status)}`}
>
{row.status}
</span>
</td>
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.startTime}</td>
<td className="px-3 py-2 min-w-[120px]">
<ProgressBar value={row.progress} />
</td>
<td className="px-3 py-2 text-t2">{row.createdBy}</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1 flex-wrap">
<button
onClick={() => onAction('detail', row)}
className="px-2 py-0.5 text-label-2 rounded bg-bg-elevated hover:bg-bg-card text-t2 transition-colors whitespace-nowrap"
>
</button>
<button
onClick={() => onAction('download', row)}
className="px-2 py-0.5 text-label-2 rounded bg-bg-elevated hover:bg-bg-card text-t2 transition-colors whitespace-nowrap"
>
</button>
<button
onClick={() => onAction('retry', row)}
className="px-2 py-0.5 text-label-2 rounded bg-bg-elevated hover:bg-bg-card text-t2 transition-colors whitespace-nowrap"
>
</button>
<button
onClick={() => onAction('delete', row)}
className="px-2 py-0.5 text-label-2 rounded bg-bg-elevated hover:bg-bg-card text-color-danger transition-colors whitespace-nowrap"
>
</button>
<button
onClick={() => onAction('audit', row)}
className="px-2 py-0.5 text-label-2 rounded bg-bg-elevated hover:bg-bg-card text-t2 transition-colors whitespace-nowrap"
>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

파일 보기

@ -0,0 +1,103 @@
import type { PermTreeNode } from '@common/services/authApi';
import type { PermState, OperCode } from '../PermissionsPanel';
import { OPER_CODES, OPER_FULL_LABELS, makeKey } from '../PermissionsPanel';
import { PermCell } from './PermCell';
interface TreeRowProps {
node: PermTreeNode;
stateMap: Map<string, PermState>;
expanded: Set<string>;
onToggleExpand: (code: string) => void;
onTogglePerm: (code: string, oper: OperCode, currentState: PermState) => void;
readOnly?: boolean;
}
export function TreeRow({
node,
stateMap,
expanded,
onToggleExpand,
onTogglePerm,
readOnly = false,
}: TreeRowProps) {
const hasChildren = node.children.length > 0;
const isExpanded = expanded.has(node.code);
const indent = node.level * 16;
// 이 노드의 READ 상태 (CUD 비활성 판단용)
const readState = stateMap.get(makeKey(node.code, 'READ')) ?? 'forced-denied';
const readDenied = readState === 'explicit-denied' || readState === 'forced-denied';
return (
<>
<tr className="border-b border-stroke hover:bg-[rgba(6,182,212,0.04)] transition-colors">
<td className="px-3 py-1">
<div className="flex items-center" style={{ paddingLeft: indent }}>
{hasChildren ? (
<button
onClick={() => onToggleExpand(node.code)}
className="w-4 h-4 flex items-center justify-center text-fg-disabled hover:text-fg transition-colors mr-1 flex-shrink-0"
>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
className={`transition-transform ${isExpanded ? 'rotate-90' : ''}`}
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
) : (
<span className="w-4 mr-1 flex-shrink-0 text-center text-fg-disabled text-caption">
{node.level > 0 ? '├' : ''}
</span>
)}
{node.icon && <span className="mr-1 flex-shrink-0 text-label-2">{node.icon}</span>}
<div className="min-w-0">
<div
className={`text-label-2 font-korean truncate ${node.level === 0 ? 'font-bold text-fg' : 'font-medium text-fg-sub'}`}
>
{node.name}
</div>
</div>
</div>
</td>
{OPER_CODES.map((oper) => {
const key = makeKey(node.code, oper);
const state = stateMap.get(key) ?? 'forced-denied';
// READ 거부 시 CUD도 강제 거부
const effectiveState =
oper !== 'READ' && readDenied ? ('forced-denied' as PermState) : state;
return (
<td key={oper} className="px-1 py-1 text-center">
<div className="flex justify-center">
<PermCell
state={effectiveState}
label={OPER_FULL_LABELS[oper]}
onToggle={() => onTogglePerm(node.code, oper, effectiveState)}
readOnly={readOnly}
/>
</div>
</td>
);
})}
</tr>
{hasChildren &&
isExpanded &&
node.children.map((child: PermTreeNode) => (
<TreeRow
key={child.code}
node={child}
stateMap={stateMap}
expanded={expanded}
onToggleExpand={onToggleExpand}
onTogglePerm={onTogglePerm}
readOnly={readOnly}
/>
))}
</>
);
}

파일 보기

@ -0,0 +1,293 @@
import { useState } from 'react';
import { changePasswordApi, updateUserApi, type UserListItem, type OrgItem, type RoleWithPermissions } from '@common/services/authApi';
import { statusLabels } from '../adminConstants';
import { formatDate } from '../UsersPanel';
interface UserDetailModalProps {
user: UserListItem;
allRoles: RoleWithPermissions[];
allOrgs: OrgItem[];
onClose: () => void;
onUpdated: () => void;
}
export function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalProps) {
const [name, setName] = useState(user.name);
const [rank, setRank] = useState(user.rank || '');
const [orgSn, setOrgSn] = useState<number | ''>(user.orgSn ?? '');
const [saving, setSaving] = useState(false);
const [newPassword, setNewPassword] = useState('');
const [resetPwLoading, setResetPwLoading] = useState(false);
const [resetPwDone, setResetPwDone] = useState(false);
const [unlockLoading, setUnlockLoading] = useState(false);
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
const handleSaveInfo = async () => {
setSaving(true);
setMessage(null);
try {
await updateUserApi(user.id, {
name: name.trim(),
rank: rank.trim() || undefined,
orgSn: orgSn !== '' ? orgSn : null,
});
setMessage({ text: '사용자 정보가 수정되었습니다.', type: 'success' });
onUpdated();
} catch {
setMessage({ text: '사용자 정보 수정에 실패했습니다.', type: 'error' });
} finally {
setSaving(false);
}
};
const handleResetPassword = async () => {
if (!newPassword.trim()) {
setMessage({ text: '새 비밀번호를 입력하세요.', type: 'error' });
return;
}
setResetPwLoading(true);
setMessage(null);
try {
await changePasswordApi(user.id, newPassword);
setMessage({ text: '비밀번호가 초기화되었습니다.', type: 'success' });
setResetPwDone(true);
setNewPassword('');
} catch {
setMessage({ text: '비밀번호 초기화에 실패했습니다.', type: 'error' });
} finally {
setResetPwLoading(false);
}
};
const handleUnlock = async () => {
setUnlockLoading(true);
setMessage(null);
try {
await updateUserApi(user.id, { status: 'ACTIVE' });
setMessage({ text: '계정 잠금이 해제되었습니다.', type: 'success' });
onUpdated();
} catch {
setMessage({ text: '잠금 해제에 실패했습니다.', type: 'error' });
} finally {
setUnlockLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-bg-surface border border-stroke rounded-lg shadow-lg w-[480px] max-h-[90vh] flex flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
<div>
<h2 className="text-body-2 font-bold text-fg font-korean"> </h2>
<p className="text-caption text-fg-disabled font-mono mt-0.5">{user.account}</p>
</div>
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-5">
{/* 기본 정보 수정 */}
<div>
<h3 className="text-label-2 font-semibold text-fg-sub font-korean mb-3">
</h3>
<div className="space-y-3">
<div>
<label className="block text-caption text-fg-disabled font-korean mb-1">
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-caption text-fg-disabled font-korean mb-1">
</label>
<input
type="text"
value={rank}
onChange={(e) => setRank(e.target.value)}
placeholder="예: 팀장"
className="w-full px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
/>
</div>
<div>
<label className="block text-caption text-fg-disabled font-korean mb-1">
</label>
<select
value={orgSn}
onChange={(e) => setOrgSn(e.target.value !== '' ? Number(e.target.value) : '')}
className="w-full px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
>
<option value=""> </option>
{allOrgs.map((org) => (
<option key={org.orgSn} value={org.orgSn}>
{org.orgNm}
{org.orgAbbrNm ? ` (${org.orgAbbrNm})` : ''}
</option>
))}
</select>
</div>
</div>
<button
onClick={handleSaveInfo}
disabled={saving || !name.trim()}
className="px-4 py-1.5 text-label-2 font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all disabled:opacity-50 font-korean"
>
{saving ? '저장 중...' : '정보 저장'}
</button>
</div>
</div>
{/* 구분선 */}
<div className="border-t border-stroke" />
{/* 비밀번호 초기화 */}
<div>
<h3 className="text-label-2 font-semibold text-fg-sub font-korean mb-3">
</h3>
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="block text-caption text-fg-disabled font-korean mb-1">
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="새 비밀번호 입력"
className="w-full px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
/>
</div>
<button
onClick={handleResetPassword}
disabled={resetPwLoading || !newPassword.trim()}
className="px-4 py-1.5 text-label-2 font-semibold rounded-md border border-color-caution text-color-caution hover:bg-[rgba(234,179,8,0.12)] transition-all disabled:opacity-50 font-korean flex-shrink-0"
>
{resetPwLoading ? '초기화 중...' : resetPwDone ? '초기화 완료' : '비밀번호 초기화'}
</button>
<button
onClick={handleUnlock}
disabled={unlockLoading || user.status !== 'LOCKED'}
className="px-4 py-1.5 text-label-2 font-semibold rounded-md border border-color-success text-color-success hover:bg-[rgba(34,197,94,0.12)] transition-all disabled:opacity-50 font-korean flex-shrink-0"
title={user.status !== 'LOCKED' ? '잠금 상태가 아닙니다' : ''}
>
{unlockLoading ? '해제 중...' : '패스워드잠금해제'}
</button>
</div>
<p className="text-caption text-fg-disabled font-korean mt-1.5">
.
</p>
</div>
{/* 구분선 */}
<div className="border-t border-stroke" />
{/* 계정 잠금 해제 */}
<div>
<h3 className="text-label-2 font-semibold text-fg-sub font-korean mb-2"> </h3>
<div className="flex items-center justify-between bg-bg-elevated border border-stroke rounded-md px-4 py-3">
<div>
<div className="flex items-center gap-2">
<span
className={`inline-flex items-center gap-1.5 text-label-2 font-semibold font-korean ${(statusLabels[user.status] || statusLabels.INACTIVE).color}`}
>
<span
className={`w-1.5 h-1.5 rounded-full ${(statusLabels[user.status] || statusLabels.INACTIVE).dot}`}
/>
{(statusLabels[user.status] || statusLabels.INACTIVE).label}
</span>
{user.failCount > 0 && (
<span className="text-caption text-color-danger font-korean">
( {user.failCount})
</span>
)}
</div>
{user.status === 'LOCKED' && (
<p className="text-caption text-fg-disabled font-korean mt-1">
5
</p>
)}
</div>
{user.status === 'LOCKED' && (
<button
onClick={handleUnlock}
disabled={unlockLoading}
className="px-4 py-1.5 text-label-2 font-semibold rounded-md border border-color-success text-color-success hover:bg-[rgba(34,197,94,0.12)] transition-all disabled:opacity-50 font-korean flex-shrink-0"
>
{unlockLoading ? '해제 중...' : '잠금 해제'}
</button>
)}
</div>
</div>
{/* 기타 정보 (읽기 전용) */}
<div>
<h3 className="text-label-2 font-semibold text-fg-sub font-korean mb-2"> </h3>
<div className="grid grid-cols-2 gap-2 text-caption font-korean">
<div className="bg-bg-elevated border border-stroke rounded px-3 py-2">
<span className="text-fg-disabled">: </span>
<span className="text-fg-sub font-mono">{user.email || '-'}</span>
</div>
<div className="bg-bg-elevated border border-stroke rounded px-3 py-2">
<span className="text-fg-disabled">OAuth: </span>
<span className="text-fg-sub">{user.oauthProvider || '-'}</span>
</div>
<div className="bg-bg-elevated border border-stroke rounded px-3 py-2">
<span className="text-fg-disabled"> : </span>
<span className="text-fg-sub">
{user.lastLogin ? formatDate(user.lastLogin) : '-'}
</span>
</div>
<div className="bg-bg-elevated border border-stroke rounded px-3 py-2">
<span className="text-fg-disabled">: </span>
<span className="text-fg-sub">{formatDate(user.regDtm)}</span>
</div>
</div>
</div>
{/* 메시지 */}
{message && (
<div
className={`px-3 py-2 text-label-2 rounded-md font-korean ${
message.type === 'success'
? 'text-color-success bg-[rgba(34,197,94,0.08)] border border-[rgba(34,197,94,0.3)]'
: 'text-color-danger bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)]'
}`}
>
{message.text}
</div>
)}
</div>
{/* 푸터 */}
<div className="flex items-center justify-end px-6 py-3 border-t border-stroke">
<button
onClick={onClose}
className="px-4 py-2 text-caption border border-stroke text-fg-sub rounded-md hover:bg-[rgba(6,182,212,0.08)] transition-all font-korean"
>
</button>
</div>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,336 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import type { UserListItem, PermTreeNode, RoleWithPermissions } from '@common/services/authApi';
import { fetchUsers, assignRolesApi } from '@common/services/authApi';
import { getRoleColor } from '../adminConstants';
import type { OperCode, PermState } from '../PermissionsPanel';
import { OPER_CODES, OPER_FULL_LABELS, OPER_LABELS, flattenTree, buildEffectiveStates } from '../PermissionsPanel';
import { TreeRow } from './TreeRow';
import { PermLegend } from './PermLegend';
interface UserPermTabProps {
roles: RoleWithPermissions[];
permTree: PermTreeNode[];
rolePerms: Map<number, Map<string, boolean>>;
}
export function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
const [users, setUsers] = useState<UserListItem[]>([]);
const [loadingUsers, setLoadingUsers] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [showDropdown, setShowDropdown] = useState(false);
const [selectedUser, setSelectedUser] = useState<UserListItem | null>(null);
const [assignedRoleSns, setAssignedRoleSns] = useState<number[]>([]);
const [savingRoles, setSavingRoles] = useState(false);
const [rolesDirty, setRolesDirty] = useState(false);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const dropdownRef = useRef<HTMLDivElement>(null);
const flatNodes = flattenTree(permTree);
useEffect(() => {
const loadUsers = async () => {
setLoadingUsers(true);
try {
const data = await fetchUsers();
setUsers(data);
} catch (err) {
console.error('사용자 목록 조회 실패:', err);
} finally {
setLoadingUsers(false);
}
};
loadUsers();
}, []);
// 최상위 노드 기본 펼침
useEffect(() => {
if (permTree.length > 0) {
setExpanded(new Set(permTree.map((n) => n.code)));
}
}, [permTree]);
// 드롭다운 외부 클릭 시 닫기
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setShowDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const filteredUsers = users.filter((u) => {
if (!searchQuery) return true;
const q = searchQuery.toLowerCase();
return (
u.name.toLowerCase().includes(q) ||
u.account.toLowerCase().includes(q) ||
(u.orgName?.toLowerCase().includes(q) ?? false)
);
});
const handleSelectUser = (user: UserListItem) => {
setSelectedUser(user);
setSearchQuery(user.name);
setShowDropdown(false);
setAssignedRoleSns(user.roleSns ?? []);
setRolesDirty(false);
};
const handleToggleRole = (roleSn: number) => {
setAssignedRoleSns((prev) => {
const next = prev.includes(roleSn) ? prev.filter((sn) => sn !== roleSn) : [...prev, roleSn];
return next;
});
setRolesDirty(true);
};
const handleSaveRoles = async () => {
if (!selectedUser) return;
setSavingRoles(true);
try {
await assignRolesApi(selectedUser.id, assignedRoleSns);
setRolesDirty(false);
// 로컬 users 상태 갱신
setUsers((prev) =>
prev.map((u) =>
u.id === selectedUser.id
? {
...u,
roleSns: assignedRoleSns,
roles: roles.filter((r) => assignedRoleSns.includes(r.sn)).map((r) => r.name),
}
: u,
),
);
setSelectedUser((prev) =>
prev
? {
...prev,
roleSns: assignedRoleSns,
roles: roles.filter((r) => assignedRoleSns.includes(r.sn)).map((r) => r.name),
}
: null,
);
} catch (err) {
console.error('역할 저장 실패:', err);
} finally {
setSavingRoles(false);
}
};
const handleToggleExpand = useCallback((code: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(code)) next.delete(code);
else next.add(code);
return next;
});
}, []);
// 사용자의 유효 권한: 할당된 역할들의 권한 병합 (OR 결합)
const effectiveStateMap = (() => {
if (!selectedUser || assignedRoleSns.length === 0) {
return new Map<string, PermState>();
}
// 각 역할의 명시적 권한 병합: 어느 역할이든 granted=true면 허용
const mergedPerms = new Map<string, boolean>();
for (const roleSn of assignedRoleSns) {
const perms = rolePerms.get(roleSn);
if (!perms) continue;
for (const [key, granted] of perms) {
if (granted) {
mergedPerms.set(key, true);
} else if (!mergedPerms.has(key)) {
mergedPerms.set(key, false);
}
}
}
return buildEffectiveStates(flatNodes, mergedPerms);
})();
const noOpToggle = useCallback((_code: string, _oper: OperCode, _state: PermState): void => {
void _code;
void _oper;
void _state;
// 읽기 전용 — 토글 없음
}, []);
return (
<div className="flex flex-col flex-1 min-h-0">
{/* 사용자 검색/선택 */}
<div className="px-4 py-2.5 border-b border-stroke" style={{ flexShrink: 0 }}>
<label className="text-caption text-fg-disabled font-korean block mb-1.5">
</label>
<div className="relative" ref={dropdownRef}>
<input
type="text"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setShowDropdown(true);
if (selectedUser && e.target.value !== selectedUser.name) {
setSelectedUser(null);
setAssignedRoleSns([]);
setRolesDirty(false);
}
}}
onFocus={() => setShowDropdown(true)}
placeholder={loadingUsers ? '불러오는 중...' : '이름, 계정, 조직으로 검색...'}
disabled={loadingUsers}
className="w-full max-w-sm px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean disabled:opacity-50"
/>
{showDropdown && filteredUsers.length > 0 && (
<div className="absolute left-0 top-full mt-1 w-full max-w-sm bg-bg-surface border border-stroke rounded-md shadow-xl z-20 overflow-auto max-h-52">
{filteredUsers.map((user) => (
<button
key={user.id}
onClick={() => handleSelectUser(user)}
className="w-full px-3 py-2 text-left hover:bg-bg-surface-hover transition-colors flex items-center gap-2"
>
<div className="min-w-0 flex-1">
<div className="text-caption font-semibold text-fg font-korean truncate">
{user.name}
{user.rank && (
<span className="ml-1 text-caption text-fg-disabled font-korean">
{user.rank}
</span>
)}
</div>
<div className="text-caption text-fg-disabled font-mono truncate">
{user.account}
</div>
</div>
{user.orgName && (
<span className="text-caption text-fg-disabled font-korean flex-shrink-0 truncate max-w-[100px]">
{user.orgName}
</span>
)}
</button>
))}
</div>
)}
{showDropdown && !loadingUsers && filteredUsers.length === 0 && searchQuery && (
<div className="absolute left-0 top-full mt-1 w-full max-w-sm bg-bg-surface border border-stroke rounded-md shadow-xl z-20 px-3 py-2 text-caption text-fg-disabled font-korean">
</div>
)}
</div>
</div>
{selectedUser ? (
<>
{/* 역할 할당 섹션 */}
<div
className="px-4 py-2.5 border-b border-stroke bg-bg-surface"
style={{ flexShrink: 0 }}
>
<div className="flex items-center justify-between mb-2">
<span className="text-caption font-semibold text-fg-sub font-korean"> </span>
<button
onClick={handleSaveRoles}
disabled={!rolesDirty || savingRoles}
className={`px-3 py-1.5 text-label-2 font-semibold rounded-md transition-all font-korean ${
rolesDirty
? 'bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
: 'bg-bg-card text-fg-disabled cursor-not-allowed'
}`}
>
{savingRoles ? '저장 중...' : '역할 저장'}
</button>
</div>
<div className="flex flex-wrap gap-1.5">
{roles.map((role, idx) => {
const color = getRoleColor(role.code, idx);
const isChecked = assignedRoleSns.includes(role.sn);
return (
<label
key={role.sn}
className={[
'flex items-center gap-1 px-2 py-1 rounded-md border cursor-pointer transition-all font-korean text-label-2 select-none',
isChecked ? '' : 'border-stroke text-fg-disabled hover:border-text-2',
].join(' ')}
style={
isChecked
? { borderColor: color, color, backgroundColor: `${color}18` }
: undefined
}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => handleToggleRole(role.sn)}
className="w-3 h-3 accent-primary-cyan"
/>
<span>{role.name}</span>
<span className="text-caption font-mono opacity-60">{role.code}</span>
</label>
);
})}
</div>
</div>
{/* 유효 권한 매트릭스 (읽기 전용) */}
<div
className="px-4 py-1.5 border-b border-stroke bg-bg-surface text-caption text-fg-disabled font-korean"
style={{ flexShrink: 0 }}
>
<span className="font-semibold text-fg-sub"> ( )</span>
<span className="ml-2"> </span>
</div>
<PermLegend />
{assignedRoleSns.length > 0 ? (
<div className="flex-1 overflow-auto">
<table className="w-full">
<thead>
<tr className="border-b border-stroke bg-bg-surface sticky top-0 z-10">
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean min-w-[240px]">
</th>
{OPER_CODES.map((oper) => (
<th key={oper} className="px-2 py-3 text-center w-16">
<div className="text-label-2 font-semibold text-fg-sub">
{OPER_LABELS[oper]}
</div>
<div className="text-caption text-fg-disabled font-korean">
{OPER_FULL_LABELS[oper]}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{permTree.map((rootNode) => (
<TreeRow
key={rootNode.code}
node={rootNode}
stateMap={effectiveStateMap}
expanded={expanded}
onToggleExpand={handleToggleExpand}
onTogglePerm={noOpToggle}
readOnly={true}
/>
))}
</tbody>
</table>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-fg-disabled text-body-2 font-korean">
</div>
)}
</>
) : (
<div className="flex-1 flex items-center justify-center text-fg-disabled text-body-2 font-korean">
</div>
)}
</div>
);
}

파일 보기

@ -0,0 +1,112 @@
import { useState, useCallback } from 'react';
import type { WizardState } from '../DeidentifyPanel';
import { INITIAL_WIZARD } from '../DeidentifyPanel';
import { StepIndicator } from './StepIndicator';
import { Step1 } from './Step1';
import { Step2 } from './Step2';
import { Step3 } from './Step3';
import { Step4 } from './Step4';
import { Step5 } from './Step5';
interface WizardModalProps {
onClose: () => void;
onSubmit: (wizard: WizardState) => void;
}
export function WizardModal({ onClose, onSubmit }: WizardModalProps) {
const [wizard, setWizard] = useState<WizardState>(INITIAL_WIZARD);
const patch = useCallback((update: Partial<WizardState>) => {
setWizard((prev) => ({ ...prev, ...update }));
}, []);
const handleNext = () => {
if (wizard.step < 5) patch({ step: wizard.step + 1 });
};
const handlePrev = () => {
if (wizard.step > 1) patch({ step: wizard.step - 1 });
};
const handleSubmit = () => {
onSubmit(wizard);
onClose();
};
const canProceed = () => {
if (wizard.step === 1) return wizard.taskName.trim().length > 0;
if (wizard.step === 2) return wizard.fields.some((f) => f.selected);
if (wizard.step === 5) return wizard.confirmed;
return true;
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div className="bg-bg-card border border-stroke rounded-lg shadow-2xl w-full max-w-4xl mx-4 flex flex-col max-h-[90vh]">
{/* 모달 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke shrink-0">
<h3 className="text-body-2 font-semibold text-t1"> </h3>
<button
onClick={onClose}
className="p-1 rounded text-t3 hover:text-t1 hover:bg-bg-elevated transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* 단계 표시기 */}
<StepIndicator current={wizard.step} />
{/* 단계 내용 */}
<div className="flex-1 overflow-auto px-6 py-5">
{wizard.step === 1 && <Step1 wizard={wizard} onChange={patch} />}
{wizard.step === 2 && <Step2 wizard={wizard} onChange={patch} />}
{wizard.step === 3 && <Step3 wizard={wizard} onChange={patch} />}
{wizard.step === 4 && <Step4 wizard={wizard} onChange={patch} />}
{wizard.step === 5 && <Step5 wizard={wizard} onChange={patch} />}
</div>
{/* 푸터 버튼 */}
<div className="flex items-center justify-between px-6 py-4 border-t border-stroke shrink-0">
<button
onClick={handlePrev}
disabled={wizard.step === 1}
className="px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
</button>
<div className="flex items-center gap-2">
<button
onClick={onClose}
className="px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 transition-colors"
>
</button>
{wizard.step < 5 ? (
<button
onClick={handleNext}
disabled={!canProceed()}
className="px-3 py-1.5 text-caption rounded bg-color-accent hover:bg-color-accent-muted text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
</button>
) : (
<button
onClick={handleSubmit}
disabled={!canProceed()}
className="px-3 py-1.5 text-caption rounded bg-color-accent hover:bg-color-accent-muted text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
</button>
)}
</div>
</div>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,73 @@
import { useState } from 'react';
import { PanelOverview } from './contents/PanelOverview';
import { PanelDetection } from './contents/PanelDetection';
import { PanelRemoteSensing } from './contents/PanelRemoteSensing';
import { PanelESIMap } from './contents/PanelESIMap';
import { PanelAreaCalc } from './contents/PanelAreaCalc';
import { PanelSpreadModel } from './contents/PanelSpreadModel';
import { PanelReferences } from './contents/PanelReferences';
const panels = [
{ id: 0, icon: '🌐', label: '개요' },
{ id: 1, icon: '🛸', label: '탐지 장비' },
{ id: 2, icon: '🛰', label: '원격탐사' },
{ id: 3, icon: '🗺️', label: 'ESI 방제지도' },
{ id: 4, icon: '📏', label: '면적 산정' },
{ id: 5, icon: '🔗', label: '확산예측 연계' },
{ id: 6, icon: '📚', label: '논문·특허' },
];
export function AerialTheoryView() {
const [activePanel, setActivePanel] = useState(0);
return (
<div className="flex flex-col h-full w-full flex-1 overflow-hidden bg-bg-base">
<div
className="flex-1 overflow-y-auto scrollbar-thin px-6 py-5"
style={{ scrollbarGutter: 'stable' }}
>
{/* 헤더 */}
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="w-[42px] h-[42px] rounded-[10px] bg-bg-elevated border border-stroke flex items-center justify-center text-heading-3">
📐
</div>
<div>
<div className="text-title-2 font-bold text-fg"> · </div>
<div className="text-label-2 text-fg-disabled mt-0.5">
· · ESI · 10-1567431
</div>
</div>
</div>
</div>
{/* 내부 네비게이션 */}
<div className="flex gap-[3px] bg-bg-card rounded-lg p-1 mb-5 border border-stroke">
{panels.map((p) => (
<button
key={p.id}
onClick={() => setActivePanel(p.id)}
className={`flex-1 py-2 px-1 text-label-1 font-medium rounded-md transition-all duration-150 cursor-pointer border ${
activePanel === p.id
? 'border-stroke-light bg-bg-elevated text-fg'
: 'border-transparent bg-bg-card text-fg-disabled hover:text-fg-sub'
}`}
>
{p.icon} {p.label}
</button>
))}
</div>
{/* 패널 */}
{activePanel === 0 && <PanelOverview />}
{activePanel === 1 && <PanelDetection />}
{activePanel === 2 && <PanelRemoteSensing />}
{activePanel === 3 && <PanelESIMap />}
{activePanel === 4 && <PanelAreaCalc />}
{activePanel === 5 && <PanelSpreadModel />}
{activePanel === 6 && <PanelReferences />}
</div>
</div>
);
}

파일 보기

@ -3,10 +3,10 @@ import { Map, Marker, Popup } from '@vis.gl/react-maplibre';
import 'maplibre-gl/dist/maplibre-gl.css';
import { fetchCctvCameras } from '../services/aerialApi';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
import { S57EncOverlay } from '@components/common/map/S57EncOverlay';
import { useMapStore } from '@common/store/mapStore';
import { BaseMap } from '@common/components/map/BaseMap';
import type { CctvCameraItem } from '../services/aerialApi';
import { BaseMap } from '@components/common/map/BaseMap';
import type { CctvCameraItem } from '@interfaces/aerial/AerialInterface';
import { CCTVPlayer } from './CCTVPlayer';
import type { CCTVPlayerHandle } from './CCTVPlayer';

파일 보기

@ -1,6 +1,11 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { fetchAerialMedia, downloadAerialMedia } from '../services/aerialApi';
import type { AerialMediaItem } from '../services/aerialApi';
import {
fetchAerialMedia,
downloadAerialMedia,
getAerialMediaViewUrl,
uploadAerialMedia,
} from '../services/aerialApi';
import type { AerialMediaItem } from '@interfaces/aerial/AerialInterface';
import { navigateToTab } from '@common/hooks/useSubMenu';
// ── Helpers ──
@ -54,7 +59,16 @@ export function MediaManagement() {
const [downloadResult, setDownloadResult] = useState<{ total: number; success: number } | null>(
null,
);
const [previewItem, setPreviewItem] = useState<AerialMediaItem | null>(null);
const modalRef = useRef<HTMLDivElement>(null);
const previewRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
// const [uploadEquip, setUploadEquip] = useState('drone');
// const [uploadEquipNm, setUploadEquipNm] = useState('드론 (DJI M300 RTK)');
// const [uploadMemo, setUploadMemo] = useState('');
const loadData = useCallback(async () => {
setLoading(true);
@ -82,6 +96,15 @@ export function MediaManagement() {
return () => document.removeEventListener('mousedown', handler);
}, [showUpload]);
useEffect(() => {
if (!previewItem) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setPreviewItem(null);
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [previewItem]);
const filtered = mediaItems.filter((f) => {
if (equipFilter !== 'all' && f.equipTpCd !== equipFilter) return false;
if (typeFilter.size > 0) {
@ -164,6 +187,38 @@ export function MediaManagement() {
}
};
const handleUploadSubmit = async () => {
if (!uploadFile || uploading) return;
setUploading(true);
try {
await uploadAerialMedia(uploadFile, {
// equipTpCd: uploadEquip,
// equipNm: uploadEquipNm,
// memo: uploadMemo,
});
setShowUpload(false);
setUploadFile(null);
// setUploadMemo('');
await loadData();
} catch {
alert('업로드 실패: 다시 시도해주세요.');
} finally {
setUploading(false);
}
};
const handleFileDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file) setUploadFile(file);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) setUploadFile(file);
};
const droneCount = mediaItems.filter((f) => f.equipTpCd === 'drone').length;
const planeCount = mediaItems.filter((f) => f.equipTpCd === 'plane').length;
const satCount = mediaItems.filter((f) => f.equipTpCd === 'satellite').length;
@ -224,6 +279,12 @@ export function MediaManagement() {
<option value="name"></option>
<option value="size"></option>
</select>
<button
onClick={() => setShowUpload(true)}
className="px-3 py-1.5 text-label-2 font-semibold rounded bg-[rgba(6,182,212,0.08)] text-color-accent border border-[rgba(6,182,212,0.3)] hover:bg-[rgba(6,182,212,0.15)] transition-colors font-korean"
>
+
</button>
</div>
</div>
@ -266,7 +327,7 @@ export function MediaManagement() {
{/* File Table */}
<div className="flex-1 border border-stroke rounded-md overflow-hidden flex flex-col">
<div className="overflow-auto flex-1">
<div className="overflow-auto flex-1 scrollbar-thin">
<table className="w-full text-left" style={{ tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: 36 }} />
@ -332,56 +393,74 @@ export function MediaManagement() {
</td>
</tr>
) : (
sorted.map((f) => (
<tr
key={f.aerialMediaSn}
onClick={() => toggleId(f.aerialMediaSn)}
className={`border-b border-stroke cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.02)] ${
selectedIds.has(f.aerialMediaSn) ? 'bg-[rgba(6,182,212,0.06)]' : ''
}`}
>
<td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(f.aerialMediaSn)}
onChange={() => toggleId(f.aerialMediaSn)}
className="accent-primary-blue"
/>
</td>
<td className="px-1 py-2 text-base">{equipIcon(f.equipTpCd)}</td>
<td className="px-2 py-2 text-caption font-semibold text-fg font-korean truncate">
{f.acdntSn != null ? String(f.acdntSn) : '—'}
</td>
<td className="px-2 py-2 text-caption text-color-accent font-mono truncate">
{f.locDc ?? '—'}
</td>
<td className="px-2 py-2 text-label-2 font-semibold text-fg font-korean truncate">
{f.fileNm}
</td>
<td className="px-2 py-2">
<span className={`text-caption font-semibold font-korean ${equipTagCls()}`}>
{f.equipNm}
</span>
</td>
<td className="px-2 py-2">
<span className={`text-caption font-semibold font-korean ${mediaTagCls()}`}>
{f.mediaTpCd === '영상' ? '🎬' : '📷'} {f.mediaTpCd}
</span>
</td>
<td className="px-2 py-2 text-label-2 font-mono">{formatDtm(f.takngDtm)}</td>
<td className="px-2 py-2 text-label-2 font-mono">{f.fileSz ?? '—'}</td>
<td className="px-2 py-2 text-label-2 font-mono">{f.resolution ?? '—'}</td>
<td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}>
<button
onClick={(e) => handleDownload(e, f)}
disabled={downloadingId === f.aerialMediaSn}
className="px-2 py-1 text-caption rounded bg-[rgba(6,182,212,0.08)] text-color-accent border border-[rgba(6,182,212,0.3)] hover:bg-[rgba(6,182,212,0.15)] transition-colors disabled:opacity-50"
sorted.map((f) => {
const isPhoto = f.mediaTpCd !== '영상';
return (
<tr
key={f.aerialMediaSn}
className={`border-b border-stroke transition-colors hover:bg-[rgba(255,255,255,0.02)] ${
selectedIds.has(f.aerialMediaSn) ? 'bg-[rgba(6,182,212,0.06)]' : ''
}`}
>
<td
className="px-2 py-2 text-center cursor-pointer"
onClick={() => toggleId(f.aerialMediaSn)}
>
{downloadingId === f.aerialMediaSn ? '⏳' : '📥'}
</button>
</td>
</tr>
))
<input
type="checkbox"
checked={selectedIds.has(f.aerialMediaSn)}
onChange={() => toggleId(f.aerialMediaSn)}
onClick={(e) => e.stopPropagation()}
className="accent-primary-blue"
/>
</td>
<td className="px-1 py-2 text-base">{equipIcon(f.equipTpCd)}</td>
<td className="px-2 py-2 text-caption font-semibold text-fg font-korean truncate">
{f.acdntSn != null ? String(f.acdntSn) : '—'}
</td>
<td className="px-2 py-2 text-caption text-fg font-mono truncate">
{f.locDc ?? '—'}
</td>
<td className="px-2 py-2 text-label-2 text-fg font-korean truncate">
{f.fileNm}
</td>
<td className="px-2 py-2">
<span className={`text-caption font-korean ${equipTagCls()}`}>
{f.equipNm}
</span>
</td>
<td className="px-2 py-2">
{isPhoto ? (
<button
type="button"
onClick={() => setPreviewItem(f)}
className={`text-caption font-semibold font-korean hover:underline cursor-pointer ${mediaTagCls()}`}
>
📷 {f.mediaTpCd}
</button>
) : (
<span
className={`text-caption font-semibold font-korean ${mediaTagCls()}`}
>
🎬 {f.mediaTpCd}
</span>
)}
</td>
<td className="px-2 py-2 text-label-2 font-mono">{formatDtm(f.takngDtm)}</td>
<td className="px-2 py-2 text-label-2 font-mono">{f.fileSz ?? '—'}</td>
<td className="px-2 py-2 text-label-2 font-mono">{f.resolution ?? '—'}</td>
<td className="px-2 py-2 text-center">
<button
onClick={(e) => handleDownload(e, f)}
disabled={downloadingId === f.aerialMediaSn}
className="px-2 py-1 text-caption disabled:opacity-50"
>
{downloadingId === f.aerialMediaSn ? '⏳' : '📥'}
</button>
</td>
</tr>
);
})
)}
</tbody>
</table>
@ -398,20 +477,20 @@ export function MediaManagement() {
onClick={toggleAll}
className="px-3 py-1.5 text-label-2 font-semibold rounded bg-bg-card border border-stroke text-fg-sub hover:bg-bg-surface-hover transition-colors font-korean"
>
</button>
<button
onClick={handleBulkDownload}
disabled={bulkDownloading || selectedIds.size === 0}
className="px-3 py-1.5 text-label-2 font-semibold rounded bg-[rgba(6,182,212,0.08)] text-color-accent border border-[rgba(6,182,212,0.3)] hover:bg-[rgba(6,182,212,0.15)] transition-colors font-korean disabled:opacity-50"
>
{bulkDownloading ? '⏳ 다운로드 중...' : '📥 선택 다운로드'}
{bulkDownloading ? '⏳ 다운로드 중...' : '선택 다운로드'}
</button>
<button
onClick={() => navigateToTab('prediction', 'analysis')}
className="px-3 py-1.5 text-label-2 font-semibold rounded bg-[rgba(6,182,212,0.08)] text-color-accent border border-[rgba(6,182,212,0.3)] hover:bg-[rgba(6,182,212,0.15)] transition-colors font-korean"
>
🔬
</button>
</div>
</div>
@ -457,28 +536,78 @@ export function MediaManagement() {
className="bg-bg-surface border border-stroke rounded-md w-[480px] max-h-[80vh] overflow-y-auto p-6"
>
<div className="flex justify-between items-center mb-4">
<span className="text-base font-bold font-korean">📤 · </span>
<span className="text-base font-bold font-korean">· </span>
<button
onClick={() => setShowUpload(false)}
onClick={() => {
setShowUpload(false);
setUploadFile(null);
}}
className="text-fg-disabled text-lg hover:text-fg"
>
</button>
</div>
<div className="border-2 border-dashed border-stroke-light rounded-md py-8 px-4 text-center mb-4 cursor-pointer hover:border-[rgba(6,182,212,0.4)] transition-colors">
<div className="text-3xl mb-2 opacity-50">📁</div>
<div className="text-title-4 font-semibold mb-1 font-korean">
</div>
<div className="text-label-2 text-fg-disabled font-korean">
JPG, TIFF, GeoTIFF, MP4, MOV · 2GB
</div>
<div
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={handleFileDrop}
onClick={() => fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-md py-8 px-4 text-center mb-4 cursor-pointer transition-colors ${
dragOver
? 'border-color-accent bg-[rgba(6,182,212,0.06)]'
: uploadFile
? 'border-[rgba(6,182,212,0.3)] bg-[rgba(6,182,212,0.04)]'
: 'border-stroke-light hover:border-[rgba(6,182,212,0.4)]'
}`}
>
<input
ref={fileInputRef}
type="file"
accept=".jpg,.jpeg,.png,.tif,.tiff,.mp4,.mov"
onChange={handleFileSelect}
className="hidden"
/>
{uploadFile ? (
<>
<div className="text-3xl mb-2"></div>
<div className="text-title-4 font-semibold mb-1 font-korean truncate px-4">
{uploadFile.name}
</div>
<div className="text-label-2 text-fg-disabled font-korean">
{(uploadFile.size / (1024 * 1024)).toFixed(2)} MB ·
</div>
</>
) : (
<>
<div className="text-3xl mb-2 opacity-50">📁</div>
<div className="text-title-4 font-semibold mb-1 font-korean">
</div>
<div className="text-label-2 text-fg-disabled font-korean">
JPG, TIFF, GeoTIFF, MP4, MOV · 2GB
</div>
</>
)}
</div>
<div className="mb-3">
{/* <div className="mb-3">
<label className="block text-caption font-semibold mb-1.5 text-fg-sub font-korean">
</label>
<select className="prd-i w-full">
<select
className="prd-i w-full"
value={uploadEquipNm}
onChange={(e) => {
setUploadEquipNm(e.target.value);
const v = e.target.value;
if (v.startsWith('드론')) setUploadEquip('drone');
else if (v.startsWith('유인')) setUploadEquip('plane');
else if (v.startsWith('위성')) setUploadEquip('satellite');
else setUploadEquip('drone');
}}
>
<option> (DJI M300 RTK)</option>
<option> (DJI Mavic 3E)</option>
<option> (CN-235)</option>
@ -505,21 +634,88 @@ export function MediaManagement() {
<textarea
className="prd-i w-full h-[60px] resize-y"
placeholder="촬영 조건, 비고 등..."
value={uploadMemo}
onChange={(e) => setUploadMemo(e.target.value)}
/>
</div>
</div> */}
<button
className="w-full py-3 rounded-sm text-body-2 font-bold font-korean cursor-pointer hover:brightness-125 transition-all"
onClick={handleUploadSubmit}
disabled={!uploadFile || uploading}
className="w-full py-3 rounded-sm text-body-2 font-bold font-korean cursor-pointer hover:brightness-125 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
style={{
background: 'rgba(6,182,212,0.15)',
border: '1px solid rgba(6,182,212,0.3)',
color: 'var(--color-accent)',
}}
>
📤
{uploading ? '업로드 중...' : '업로드 실행'}
</button>
</div>
</div>
)}
{/* Image Preview Modal */}
{previewItem && (
<div
className="fixed inset-0 z-[250] bg-black/70 backdrop-blur-sm flex items-center justify-center"
onClick={() => setPreviewItem(null)}
>
<div
ref={previewRef}
onClick={(e) => e.stopPropagation()}
className="bg-bg-surface border border-stroke rounded-md w-[720px] max-w-[90vw] max-h-[85vh] flex flex-col overflow-hidden"
>
<div className="flex justify-between items-center px-4 py-2.5 border-b border-stroke">
<div className="flex flex-col min-w-0">
<span className="text-label-1 font-bold font-korean text-fg truncate">
{previewItem.fileNm}
</span>
<span className="text-caption text-fg-disabled font-mono">
{formatDtm(previewItem.takngDtm)} · {previewItem.equipNm}
</span>
</div>
<button
onClick={() => setPreviewItem(null)}
className="text-fg-disabled text-lg hover:text-fg ml-3"
>
</button>
</div>
<div className="flex-1 flex items-center justify-center overflow-hidden bg-black/30 relative min-h-[320px]">
<img
src={getAerialMediaViewUrl(previewItem.aerialMediaSn)}
alt={previewItem.orgnlNm ?? previewItem.fileNm}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
}}
/>
<div className="hidden flex-col items-center gap-2">
<div className="text-[48px] text-fg-disabled">📷</div>
<div className="text-label-1 text-fg-disabled font-korean">
</div>
</div>
</div>
<div className="flex justify-end gap-2 px-4 py-2.5 border-t border-stroke">
<button
onClick={(e) => handleDownload(e, previewItem)}
disabled={downloadingId === previewItem.aerialMediaSn}
className="px-3 py-1.5 text-label-2 font-semibold rounded bg-[rgba(6,182,212,0.08)] text-color-accent border border-[rgba(6,182,212,0.3)] hover:bg-[rgba(6,182,212,0.15)] transition-colors font-korean disabled:opacity-50"
>
{downloadingId === previewItem.aerialMediaSn ? '⏳ 다운로드 중...' : '📥 다운로드'}
</button>
<button
onClick={() => setPreviewItem(null)}
className="px-3 py-1.5 text-label-2 font-semibold rounded bg-bg-card border border-stroke text-fg-sub hover:bg-bg-surface-hover transition-colors font-korean"
>
</button>
</div>
</div>
</div>
)}
</div>
);
}

파일 보기

@ -1,7 +1,7 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import * as exifr from 'exifr';
import { stitchImages } from '../services/aerialApi';
import { analyzeImage } from '@tabs/prediction/services/predictionApi';
import { analyzeImage } from '@components/prediction/services/predictionApi';
import { setPendingImageAnalysis } from '@common/utils/imageAnalysisSignal';
import { navigateToTab } from '@common/hooks/useSubMenu';
import { decimalToDMS } from '@common/utils/coordinates';
@ -239,7 +239,7 @@ export function OilAreaAnalysis() {
<div className="flex gap-5 h-full overflow-hidden">
{/* ── Left Panel ── */}
<div className="w-[280px] min-w-[280px] flex flex-col overflow-y-auto scrollbar-thin">
<div className="text-body-2 font-bold mb-1 font-korean">🧩 </div>
<div className="text-body-2 font-bold mb-1 font-korean"></div>
<div className="text-label-2 text-fg-disabled mb-4 font-korean">
.
</div>
@ -396,12 +396,12 @@ export function OilAreaAnalysis() {
: { background: 'var(--bg-3)' }
}
>
{isAnalyzing ? '⏳ 분석 중...' : '🧩 분석 시작'}
{isAnalyzing ? '⏳ 분석 중...' : '분석 시작'}
</button>
</div>
{/* ── Right Panel ── */}
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 flex flex-col overflow-y-auto scrollbar-thin min-w-0">
{/* 3×2 이미지 그리드 */}
<div className="text-label-2 font-bold mb-2 font-korean"> </div>
<div className="grid grid-cols-3 gap-1.5 mb-3">
@ -458,8 +458,8 @@ export function OilAreaAnalysis() {
{/* 합성 결과 */}
<div className="text-label-2 font-bold mb-2 font-korean"> </div>
<div
className="relative bg-bg-base border border-stroke rounded-sm overflow-hidden flex items-center justify-center"
style={{ minHeight: '160px', flex: '1 1 0' }}
className="relative bg-bg-base border border-stroke rounded-sm overflow-hidden flex items-center justify-center shrink-0"
style={{ minHeight: '400px' }}
>
{stitchedPreviewUrl ? (
<img

파일 보기

@ -1,5 +1,5 @@
import { useRef, useEffect, memo } from 'react';
import type { OilDetectionResult } from '../utils/oilDetection';
import type { OilDetectionResult } from '@interfaces/aerial/AerialInterface';
import { OIL_CLASSES, OIL_CLASS_NAMES } from '../utils/oilDetection';
export interface OilDetectionOverlayProps {

파일 보기

@ -2,11 +2,11 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import { Map, Marker, Popup } from '@vis.gl/react-maplibre';
import 'maplibre-gl/dist/maplibre-gl.css';
import { fetchDroneStreams, startDroneStreamApi, stopDroneStreamApi } from '../services/aerialApi';
import type { DroneStreamItem } from '../services/aerialApi';
import type { DroneStreamItem } from '@interfaces/aerial/AerialInterface';
import { CCTVPlayer } from './CCTVPlayer';
import type { CCTVPlayerHandle } from './CCTVPlayer';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
import { S57EncOverlay } from '@components/common/map/S57EncOverlay';
import { useMapStore } from '@common/store/mapStore';
/** 함정 위치 + 드론 비행 위치 */

파일 보기

@ -3,12 +3,12 @@ import { Map, Source, Layer } from '@vis.gl/react-maplibre';
import 'maplibre-gl/dist/maplibre-gl.css';
import { Marker } from '@vis.gl/react-maplibre';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
import { S57EncOverlay } from '@components/common/map/S57EncOverlay';
import { useMapStore } from '@common/store/mapStore';
import { fetchSatellitePasses } from '../services/aerialApi';
const VWORLD_API_KEY = import.meta.env.VITE_VWORLD_API_KEY || '';
import type { SatellitePass } from '../services/aerialApi';
import type { SatellitePass } from '@interfaces/aerial/AerialInterface';
interface SatRequest {
id: string;

파일 보기

@ -0,0 +1,101 @@
import { useState } from 'react';
import { useControl } from '@vis.gl/react-maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
import 'maplibre-gl/dist/maplibre-gl.css';
import { DetectPanel } from './contents/DetectPanel';
import { ChangeDetectPanel } from './contents/ChangeDetectPanel';
import { AoiPanel } from './contents/AoiPanel';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function DeckGLOverlay({ layers }: { layers: any[] }) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
overlay.setProps({ layers });
return null;
}
type WingAITab = 'detect' | 'change' | 'aoi';
const tabItems: { id: WingAITab; label: string; icon: string; desc: string }[] = [
{
id: 'detect',
label: '객체 탐지',
icon: '🎯',
desc: '위성/드론 영상에서 선박·차량·시설물 자동 탐지 및 분류',
},
{
id: 'change',
label: '변화 감지',
icon: '🔄',
desc: '동일 지역 다시점 영상 비교 분석 (Before/After)',
},
{
id: 'aoi',
label: '연안자동감지',
icon: '📍',
desc: '연안 관심지역 등록 → 변화 자동 감지 및 알림',
},
];
export function WingAI() {
const [activeTab, setActiveTab] = useState<WingAITab>('detect');
return (
<div className="overflow-y-auto px-6 pt-1 pb-2">
{/* 헤더 */}
<div className="flex items-center gap-3 mb-4 h-9">
<div className="flex items-center gap-2 shrink-0">
<div
className="w-7 h-7 rounded-md flex items-center justify-center text-body-2"
style={{
background: 'rgba(6,182,212,0.15)',
border: '1px solid rgba(6,182,212,0.3)',
}}
>
🤖
</div>
<div className="text-label-1 font-bold font-korean text-fg">AI /</div>
{/* <span
className="text-caption px-1.5 py-0.5 rounded font-bold"
style={{
background: 'color-mix(in srgb, var(--color-tertiary) 12%, transparent)',
color: 'var(--color-tertiary)',
border: '1px solid rgba(168,85,247,.25)',
}}
>
WingAI
</span> */}
</div>
<div className="flex gap-1 h-7">
{tabItems.map((t) => (
<button
key={t.id}
onClick={() => setActiveTab(t.id)}
className="px-2.5 h-full rounded text-caption font-bold font-korean cursor-pointer border transition-colors"
style={
activeTab === t.id
? {
background: 'rgba(6,182,212,.08)',
borderColor: 'rgba(6,182,212,.3)',
color: 'var(--color-accent)',
}
: {
background: 'var(--bg-card)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-disabled)',
}
}
>
{/* {t.icon} */}
{t.label}
</button>
))}
</div>
</div>
{/* 탭 콘텐츠 */}
{activeTab === 'detect' && <DetectPanel />}
{activeTab === 'change' && <ChangeDetectPanel />}
{activeTab === 'aoi' && <AoiPanel />}
</div>
);
}

파일 보기

@ -0,0 +1,800 @@
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { Map } from '@vis.gl/react-maplibre';
import { PolygonLayer, ScatterplotLayer } from '@deck.gl/layers';
import type { MapMouseEvent } from 'maplibre-gl';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
import { useMapStore } from '@common/store/mapStore';
import { S57EncOverlay } from '@components/common/map/S57EncOverlay';
import { DeckGLOverlay } from '../WingAI';
type ZoneStatus = '정상' | '경보' | '주의';
type MonitorSource = 'satellite' | 'cctv' | 'drone' | 'ais';
interface MonitorSourceConfig {
id: MonitorSource;
label: string;
icon: string;
color: string;
desc: string;
}
const MONITOR_SOURCES: MonitorSourceConfig[] = [
{
id: 'satellite',
label: '위성영상',
icon: '🛰',
color: 'var(--color-accent)',
desc: 'KOMPSAT/Sentinel 주기 촬영',
},
{
id: 'cctv',
label: 'CCTV',
icon: '📹',
color: 'var(--color-info)',
desc: 'KHOA/KBS 해안 CCTV 실시간',
},
{
id: 'drone',
label: '드론',
icon: '🛸',
color: 'var(--color-success)',
desc: '드론 정밀 촬영 / 열화상',
},
{
id: 'ais',
label: 'AIS',
icon: '🚢',
color: 'var(--color-warning)',
desc: '선박 위치·항적 실시간 수신',
},
];
interface MonitorZone {
id: string;
name: string;
interval: string;
lastCheck: string;
status: ZoneStatus;
alerts: number;
polygon: [number, number][];
color: string;
monitoring: boolean;
/** 활성 모니터링 소스 */
sources: MonitorSource[];
}
const INTERVAL_OPTIONS = ['1h', '3h', '6h', '12h', '24h'];
const ZONE_COLORS = [
'var(--color-accent)',
'var(--color-info)',
'var(--color-success)',
'var(--color-warning)',
'var(--color-danger)',
'var(--color-danger)',
'var(--color-accent)',
];
const INITIAL_ZONES: MonitorZone[] = [
{
id: 'AOI-001',
name: '여수항 반경',
interval: '6h',
lastCheck: '03-16 14:00',
status: '정상',
alerts: 0,
monitoring: true,
color: 'var(--color-info)',
polygon: [
[127.68, 34.78],
[127.78, 34.78],
[127.78, 34.7],
[127.68, 34.7],
],
sources: ['satellite', 'cctv', 'ais'],
},
{
id: 'AOI-002',
name: '제주 서귀포 해상',
interval: '3h',
lastCheck: '03-16 13:30',
status: '경보',
alerts: 2,
monitoring: true,
color: 'var(--color-danger)',
polygon: [
[126.45, 33.28],
[126.58, 33.28],
[126.58, 33.2],
[126.45, 33.2],
],
sources: ['satellite', 'drone', 'cctv', 'ais'],
},
{
id: 'AOI-003',
name: '부산항 외항',
interval: '12h',
lastCheck: '03-16 08:00',
status: '정상',
alerts: 0,
monitoring: true,
color: 'var(--color-success)',
polygon: [
[129.05, 35.12],
[129.2, 35.12],
[129.2, 35.05],
[129.05, 35.05],
],
sources: ['cctv', 'ais'],
},
{
id: 'AOI-004',
name: '통영 ~ 거제 해역',
interval: '24h',
lastCheck: '03-15 20:00',
status: '주의',
alerts: 1,
monitoring: true,
color: 'var(--color-warning)',
polygon: [
[128.3, 34.9],
[128.65, 34.9],
[128.65, 34.8],
[128.3, 34.8],
],
sources: ['satellite', 'drone'],
},
];
export function AoiPanel() {
const [zones, setZones] = useState<MonitorZone[]>(INITIAL_ZONES);
const [selectedZone, setSelectedZone] = useState<string | null>(null);
const currentMapStyle = useBaseMapStyle();
const mapToggles = useMapStore((s) => s.mapToggles);
// 드로잉 상태
const [isDrawing, setIsDrawing] = useState(false);
const [drawingPoints, setDrawingPoints] = useState<[number, number][]>([]);
// 등록 폼
const [showForm, setShowForm] = useState(false);
const [formName, setFormName] = useState('');
// 실시간 시뮬
const [now, setNow] = useState(() => new Date());
// 시계 갱신 (1분마다)
useEffect(() => {
const t = setInterval(() => setNow(new Date()), 60_000);
return () => clearInterval(t);
}, []);
const nextId = useRef(zones.length + 1);
// 지도 클릭 → 폴리곤 포인트 수집
const handleMapClick = useCallback(
(e: MapMouseEvent) => {
if (!isDrawing) return;
setDrawingPoints((prev) => [...prev, [e.lngLat.lng, e.lngLat.lat]]);
},
[isDrawing],
);
// 드로잉 시작
const startDrawing = () => {
setDrawingPoints([]);
setIsDrawing(true);
setShowForm(false);
setSelectedZone(null);
};
// 드로잉 완료 → 폼 표시
const finishDrawing = () => {
if (drawingPoints.length < 3) return;
setIsDrawing(false);
setShowForm(true);
setFormName('');
};
// 드로잉 취소
const cancelDrawing = () => {
setIsDrawing(false);
setDrawingPoints([]);
setShowForm(false);
};
// 구역 등록 (이름만 → 등록 후 설정)
const registerZone = () => {
if (!formName.trim() || drawingPoints.length < 3) return;
const newId = `AOI-${String(nextId.current++).padStart(3, '0')}`;
const newZone: MonitorZone = {
id: newId,
name: formName.trim(),
interval: '6h',
lastCheck: `${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`,
status: '정상',
alerts: 0,
polygon: [...drawingPoints],
color: ZONE_COLORS[zones.length % ZONE_COLORS.length],
monitoring: true,
sources: ['satellite', 'cctv'],
};
setZones((prev) => [...prev, newZone]);
setDrawingPoints([]);
setShowForm(false);
setSelectedZone(newId);
};
// 구역 설정 변경
const updateZone = (id: string, patch: Partial<MonitorZone>) => {
setZones((prev) => prev.map((z) => (z.id === id ? { ...z, ...patch } : z)));
};
// 모니터링 소스 토글
const toggleSource = (id: string, src: MonitorSource) => {
setZones((prev) =>
prev.map((z) => {
if (z.id !== id) return z;
const has = z.sources.includes(src);
return { ...z, sources: has ? z.sources.filter((s) => s !== src) : [...z.sources, src] };
}),
);
};
// 모니터링 토글
const toggleMonitoring = (id: string) => {
setZones((prev) => prev.map((z) => (z.id === id ? { ...z, monitoring: !z.monitoring } : z)));
};
// 구역 삭제
const removeZone = (id: string) => {
setZones((prev) => prev.filter((z) => z.id !== id));
if (selectedZone === id) setSelectedZone(null);
};
const statusStyle = (s: ZoneStatus) => {
if (s === '경보')
return {
background: 'color-mix(in srgb, var(--color-danger) 12%, transparent)',
color: 'var(--color-danger)',
border: '1px solid color-mix(in srgb, var(--color-danger) 25%, transparent)',
};
if (s === '주의')
return {
background: 'color-mix(in srgb, var(--color-caution) 12%, transparent)',
color: 'var(--color-caution)',
border: '1px solid color-mix(in srgb, var(--color-caution) 25%, transparent)',
};
return {
background: 'color-mix(in srgb, var(--color-success) 12%, transparent)',
color: 'var(--color-success)',
border: '1px solid color-mix(in srgb, var(--color-success) 25%, transparent)',
};
};
// deck.gl 레이어: 등록된 폴리곤 + 드로잉 중 포인트
const deckLayers = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: any[] = [];
// 등록된 구역 폴리곤
const visibleZones = zones.filter((z) => z.monitoring);
if (visibleZones.length > 0) {
result.push(
new PolygonLayer({
id: 'aoi-zones',
data: visibleZones,
getPolygon: (d: MonitorZone) => [...d.polygon, d.polygon[0]],
getFillColor: (d: MonitorZone) => {
const hex = d.color;
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const alpha = selectedZone === d.id ? 60 : 30;
return [r, g, b, alpha];
},
getLineColor: (d: MonitorZone) => {
const hex = d.color;
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return [r, g, b, selectedZone === d.id ? 220 : 140];
},
getLineWidth: (d: MonitorZone) => (selectedZone === d.id ? 3 : 1.5),
lineWidthUnits: 'pixels',
pickable: true,
onClick: ({ object }: { object: MonitorZone }) => {
if (object && !isDrawing)
setSelectedZone(object.id === selectedZone ? null : object.id);
},
updateTriggers: {
getFillColor: [selectedZone],
getLineColor: [selectedZone],
getLineWidth: [selectedZone],
},
}),
);
// 경보/주의 구역 중심점 펄스
const alertZones = visibleZones.filter((z) => z.status !== '정상');
if (alertZones.length > 0) {
result.push(
new ScatterplotLayer({
id: 'aoi-alert-pulse',
data: alertZones,
getPosition: (d: MonitorZone) => {
const lngs = d.polygon.map((p) => p[0]);
const lats = d.polygon.map((p) => p[1]);
return [
(Math.min(...lngs) + Math.max(...lngs)) / 2,
(Math.min(...lats) + Math.max(...lats)) / 2,
];
},
getRadius: 8000,
radiusUnits: 'meters' as const,
getFillColor: (d: MonitorZone) =>
d.status === '경보' ? [239, 68, 68, 80] : [234, 179, 8, 60],
getLineColor: (d: MonitorZone) =>
d.status === '경보' ? [239, 68, 68, 180] : [234, 179, 8, 150],
lineWidthMinPixels: 2,
stroked: true,
}),
);
}
}
// 드로잉 중 포인트
if (drawingPoints.length > 0) {
result.push(
new ScatterplotLayer({
id: 'drawing-points',
data: drawingPoints,
getPosition: (d: [number, number]) => d,
getRadius: 5,
radiusUnits: 'pixels' as const,
getFillColor: [6, 182, 212, 220],
getLineColor: [255, 255, 255, 255],
lineWidthMinPixels: 2,
stroked: true,
}),
);
// 드로잉 폴리곤 미리보기
if (drawingPoints.length >= 3) {
result.push(
new PolygonLayer({
id: 'drawing-preview',
data: [{ polygon: [...drawingPoints, drawingPoints[0]] }],
getPolygon: (d: { polygon: [number, number][] }) => d.polygon,
getFillColor: [6, 182, 212, 25],
getLineColor: [6, 182, 212, 200],
getLineWidth: 2,
lineWidthUnits: 'pixels',
getDashArray: [4, 4],
}),
);
}
}
return result;
}, [zones, drawingPoints, selectedZone, isDrawing]);
const activeMonitoring = zones.filter((z) => z.monitoring).length;
const alertCount = zones.filter((z) => z.status === '경보').length;
const warningCount = zones.filter((z) => z.status === '주의').length;
const totalAlerts = zones.reduce((s, a) => s + a.alerts, 0);
const inputCls = 'w-full px-2.5 py-1.5 rounded text-caption font-korean outline-none border';
const inputStyle = {
background: 'var(--bg-elevated)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-default)',
};
return (
<div>
{/* 통계 */}
<div className="grid grid-cols-4 gap-3 mb-4">
{[
{ value: String(activeMonitoring), label: '감시 구역', color: 'var(--color-info)' },
{ value: String(alertCount), label: '경보', color: 'var(--color-danger)' },
{ value: String(warningCount), label: '주의', color: 'var(--color-caution)' },
{ value: String(totalAlerts), label: '미확인 알림', color: 'var(--color-tertiary)' },
].map((s, i) => (
<div key={i} className="bg-bg-elevated border border-stroke rounded-md p-3 text-center">
<div className="text-[20px] font-bold font-mono text-fg">{s.value}</div>
<div className="text-caption text-fg-disabled mt-0.5 font-korean">{s.label}</div>
</div>
))}
</div>
<div className="grid grid-cols-[1fr_360px] gap-4">
{/* 지도 영역 */}
<div
className="bg-bg-elevated border border-stroke rounded-md overflow-hidden"
style={{ minHeight: 520 }}
>
{/* 지도 헤더 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-stroke">
<div className="flex items-center gap-2">
<div className="text-label-2 font-bold font-korean text-fg">📍 </div>
{isDrawing && (
<span
className="text-caption px-1.5 py-0.5 rounded font-bold animate-pulse"
style={{
background: 'rgba(6,182,212,.12)',
color: 'var(--color-accent)',
border: '1px solid rgba(6,182,212,.3)',
}}
>
· ({drawingPoints.length})
</span>
)}
</div>
<div className="flex gap-1.5">
{isDrawing ? (
<>
<button
onClick={finishDrawing}
disabled={drawingPoints.length < 3}
className="px-2.5 h-6 text-color-accent rounded-sm text-caption font-semibold cursor-pointer font-korean disabled:opacity-40 disabled:cursor-not-allowed"
style={{
background: drawingPoints.length >= 3 ? 'rgba(6,182,212,0.08)' : '#333',
}}
>
({drawingPoints.length >= 3 ? `${drawingPoints.length}` : '3점 이상'})
</button>
<button
onClick={() => setDrawingPoints((p) => p.slice(0, -1))}
disabled={drawingPoints.length === 0}
className="px-2 h-6 rounded-sm text-caption font-semibold cursor-pointer font-korean border disabled:opacity-40"
style={{
background: 'var(--bg-card)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-sub)',
}}
>
</button>
<button
onClick={cancelDrawing}
className="px-2 h-6 rounded-sm text-caption font-semibold cursor-pointer font-korean border"
style={{
background: 'var(--bg-card)',
borderColor: 'var(--stroke-default)',
color: 'var(--color-danger)',
}}
>
</button>
</>
) : (
<button
onClick={startDrawing}
className="px-2.5 h-6 text-color-accent rounded-sm text-caption font-semibold cursor-pointer font-korean flex items-center gap-1"
style={{ background: 'rgba(6,182,212,0.08)' }}
>
+
</button>
)}
</div>
</div>
{/* MapLibre 지도 */}
<div style={{ height: 480, cursor: isDrawing ? 'crosshair' : undefined }}>
<Map
initialViewState={{ longitude: 127.8, latitude: 34.5, zoom: 6.5 }}
style={{ width: '100%', height: '100%' }}
mapStyle={currentMapStyle}
attributionControl={false}
onClick={handleMapClick}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
<DeckGLOverlay layers={deckLayers} />
</Map>
</div>
</div>
{/* 우측 패널 */}
<div
className="flex flex-col gap-3"
style={{
maxHeight: 520,
overflowY: 'scroll',
scrollbarWidth: 'thin',
scrollbarColor: 'var(--stroke-default) transparent',
}}
>
{/* 등록 폼: 이름만 입력 → 바로 등록 */}
{showForm && drawingPoints.length >= 3 && (
<div
className="bg-bg-elevated border rounded-md overflow-hidden"
style={{ borderColor: 'rgba(6,182,212,.3)' }}
>
<div
className="px-4 py-2.5 border-b font-korean"
style={{ borderColor: 'rgba(6,182,212,.15)', background: 'rgba(6,182,212,.05)' }}
>
<div className="text-label-2 font-bold" style={{ color: 'var(--color-accent)' }}>
</div>
<div className="text-caption text-fg-disabled mt-0.5">
{drawingPoints.length}
</div>
</div>
<div className="px-4 py-3">
<label className="text-caption font-bold text-fg-disabled font-korean block mb-1.5">
</label>
<input
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="예: 여수항 북측 해안"
className={inputCls}
style={inputStyle}
onKeyDown={(e) => {
if (e.key === 'Enter') registerZone();
}}
autoFocus
/>
<div className="flex gap-2 mt-3">
<button
onClick={registerZone}
disabled={!formName.trim()}
className="flex-1 py-2 text-color-accent rounded text-caption font-bold cursor-pointer font-korean disabled:opacity-40 disabled:cursor-not-allowed"
style={{
background: formName.trim() ? 'rgba(6,182,212,0.08)' : '#333',
}}
>
</button>
<button
onClick={cancelDrawing}
className="px-4 py-2 rounded text-caption font-bold cursor-pointer font-korean border"
style={{
background: 'var(--bg-card)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-sub)',
}}
>
</button>
</div>
</div>
</div>
)}
{/* 감시 구역 목록 */}
<div className="bg-bg-elevated border border-stroke rounded-md flex-1">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-stroke">
<div className="text-label-2 font-bold font-korean text-fg">📋 </div>
<div className="text-caption text-fg-disabled font-mono">{zones.length}</div>
</div>
<div className="divide-y divide-stroke">
{zones.map((z) => {
const isOpen = selectedZone === z.id;
return (
<div key={z.id} className="border-b border-stroke last:border-b-0">
{/* 목록 행 */}
<div
onClick={() => setSelectedZone(isOpen ? null : z.id)}
className="px-4 py-2.5 hover:bg-bg-surface-hover/30 transition-colors cursor-pointer"
style={{ background: isOpen ? 'rgba(6,182,212,.04)' : undefined }}
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span className="text-caption font-mono text-fg-disabled">{z.id}</span>
<span className="text-label-2 font-semibold text-fg font-korean">
{z.name}
</span>
</div>
<div className="flex items-center gap-1.5">
<span
className="px-1.5 py-0.5 rounded text-caption font-bold font-korean"
style={statusStyle(z.status)}
>
{z.status}
</span>
<span className="text-caption text-fg-disabled">
{isOpen ? '▲' : '▼'}
</span>
</div>
</div>
{/* 활성 소스 배지 + 주기 */}
<div className="flex items-center gap-1.5 mt-1">
{z.sources.map((sid) => {
const cfg = MONITOR_SOURCES.find((s) => s.id === sid)!;
return (
<span
key={sid}
className="px-1.5 py-0.5 rounded text-caption font-bold"
style={{
background: 'transparent',
color: cfg.color,
border: `1px solid color-mix(in srgb, ${cfg.color} 40%, transparent)`,
}}
>
{cfg.label}
</span>
);
})}
<span className="text-caption text-fg-disabled font-mono ml-auto">
{z.interval} · {z.lastCheck}
</span>
</div>
{z.alerts > 0 && (
<div
className="mt-1.5 px-2 py-1 rounded text-caption font-bold font-korean"
style={{
background: 'color-mix(in srgb, var(--color-danger) 8%, transparent)',
color: 'var(--color-danger)',
}}
>
{z.alerts}
</div>
)}
</div>
{/* 인라인 설정 패널 (아코디언) */}
{isOpen && (
<div
className="px-4 py-3 space-y-3 border-t"
style={{
borderColor: `color-mix(in srgb, ${z.color} 12%, transparent)`,
background: `color-mix(in srgb, ${z.color} 3%, transparent)`,
}}
>
{/* 감시 주기 */}
<div>
<label className="text-caption font-bold text-fg-disabled font-korean block mb-1.5">
</label>
<div className="flex gap-1">
{INTERVAL_OPTIONS.map((iv) => (
<button
key={iv}
onClick={(e) => {
e.stopPropagation();
updateZone(z.id, { interval: iv });
}}
className="px-2.5 py-1 rounded text-caption font-bold font-mono cursor-pointer border transition-colors"
style={
z.interval === iv
? {
background: 'rgba(6,182,212,.08)',
borderColor: 'rgba(6,182,212,.3)',
color: 'var(--color-accent)',
}
: {
background: 'var(--bg-card)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-disabled)',
}
}
>
{iv}
</button>
))}
</div>
</div>
{/* 모니터링 방법 */}
<div>
<label className="text-caption font-bold text-fg-disabled font-korean block mb-1.5">
</label>
<div className="space-y-1.5">
{MONITOR_SOURCES.map((src) => {
const active = z.sources.includes(src.id);
return (
<button
key={src.id}
onClick={(e) => {
e.stopPropagation();
toggleSource(z.id, src.id);
}}
className="w-full flex items-center gap-2.5 px-3 py-2 rounded border cursor-pointer transition-all text-left"
style={
active
? {
background: 'transparent',
borderColor: `color-mix(in srgb, ${src.color} 40%, transparent)`,
color: 'var(--fg-default)',
}
: {
background: 'var(--bg-card)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-disabled)',
opacity: 0.6,
}
}
>
<span className="text-body-2 shrink-0">{active ? '◉' : '○'}</span>
<span className="text-body-2 shrink-0">{src.icon}</span>
<div className="flex-1 min-w-0">
<div className="text-caption font-bold font-korean">
{src.label}
</div>
<div className="text-caption font-korean text-fg-disabled">
{src.desc}
</div>
</div>
</button>
);
})}
</div>
{z.sources.length === 0 && (
<div className="text-caption text-fg-disabled font-korean mt-1.5">
1
</div>
)}
</div>
{/* 하단 컨트롤 */}
<div
className="flex items-center gap-2 pt-1 border-t"
style={{ borderColor: 'var(--stroke-default)' }}
>
<button
onClick={(e) => {
e.stopPropagation();
toggleMonitoring(z.id);
}}
className="px-3 py-1.5 rounded text-caption font-bold font-korean cursor-pointer border transition-colors"
style={
z.monitoring
? {
background:
'color-mix(in srgb, var(--color-success) 12%, transparent)',
borderColor:
'color-mix(in srgb, var(--color-success) 25%, transparent)',
color: 'var(--color-success)',
}
: {
background: 'var(--bg-card)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-disabled)',
}
}
>
{z.monitoring ? '◉ 감시 중' : '○ 일시정지'}
</button>
<span className="text-caption text-fg-disabled font-mono">
{z.polygon.length} · {z.sources.length} · {z.interval}
</span>
<button
onClick={(e) => {
e.stopPropagation();
removeZone(z.id);
}}
className="ml-auto px-2 py-1.5 rounded text-caption font-bold font-korean cursor-pointer border"
style={{
background: 'color-mix(in srgb, var(--color-danger) 8%, transparent)',
borderColor:
'color-mix(in srgb, var(--color-danger) 20%, transparent)',
color: 'var(--color-danger)',
}}
>
</button>
</div>
</div>
)}
</div>
);
})}
{zones.length === 0 && (
<div className="px-4 py-8 text-center">
<div className="text-2xl opacity-30 mb-2">📍</div>
<div className="text-caption text-fg-disabled font-korean">
</div>
<div className="text-caption text-fg-disabled font-korean mt-1">
"+ 감시 구역 등록"
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,601 @@
import { useState } from 'react';
type SourceType = 'satellite' | 'cctv' | 'drone' | 'ais';
interface SourceConfig {
id: SourceType;
label: string;
icon: string;
color: string;
desc: string;
}
const SOURCES: SourceConfig[] = [
{
id: 'satellite',
label: '위성영상',
icon: '🛰',
color: 'var(--color-accent)',
desc: 'KOMPSAT-3A / Sentinel SAR 수신 영상',
},
{
id: 'cctv',
label: 'CCTV',
icon: '📹',
color: 'var(--color-info)',
desc: 'KHOA / KBS 해안 CCTV 스냅샷',
},
{
id: 'drone',
label: '드론',
icon: '🛸',
color: 'var(--color-success)',
desc: '정밀 촬영 / 열화상 이미지',
},
{
id: 'ais',
label: 'AIS',
icon: '🚢',
color: 'var(--color-warning)',
desc: '선박 위치·항적·MMSI 궤적',
},
];
interface ChangeRecord {
id: string;
area: string;
type: string;
date1: string;
time1: string;
date2: string;
time2: string;
severity: '심각' | '보통' | '낮음';
detail: string;
sources: SourceType[];
crossRef?: string;
/** 각 정보원별 AS-IS 시점 요약 */
asIsDetail: Partial<Record<SourceType, string>>;
/** 각 정보원별 현재 시점 요약 */
nowDetail: Partial<Record<SourceType, string>>;
}
export function ChangeDetectPanel() {
const [layers, setLayers] = useState<Record<SourceType, boolean>>({
satellite: true,
cctv: true,
drone: true,
ais: true,
});
const [sourceFilter, setSourceFilter] = useState<SourceType | 'all'>('all');
const [selectedChange, setSelectedChange] = useState<string | null>(null);
const toggleLayer = (id: SourceType) => setLayers((prev) => ({ ...prev, [id]: !prev[id] }));
const activeCount = Object.values(layers).filter(Boolean).length;
const changes: ChangeRecord[] = [
{
id: 'CHG-001',
area: '여수항 북측 해안',
type: '선박 이동',
date1: '03-14',
time1: '14:00',
date2: '03-16',
time2: '14:23',
severity: '보통',
detail: '정박 선박 3척 → 7척 (증가)',
sources: ['satellite', 'ais', 'cctv'],
crossRef: 'AIS MMSI 440123456 외 3척 신규 입항 — 위성+CCTV 동시 확인',
asIsDetail: {
satellite: '정박 선박 3척 식별',
ais: 'MMSI 3건 정박 상태',
cctv: '여수 오동도 CCTV 정상',
},
nowDetail: {
satellite: '선박 7척 식별 (4척 증가)',
ais: 'MMSI 7건 (신규 4건 입항)',
cctv: '여수 오동도 CCTV 선박 증가 확인',
},
},
{
id: 'CHG-002',
area: '제주 서귀포 해상',
type: '유막 확산',
date1: '03-15',
time1: '10:30',
date2: '03-16',
time2: '14:23',
severity: '심각',
detail: '유막 면적 2.1km² → 4.8km² (확산)',
sources: ['satellite', 'drone', 'cctv', 'ais'],
crossRef: '4개 정보원 교차확인 — 유막 남동 방향 확산 일치',
asIsDetail: {
satellite: 'SAR 유막 2.1km² 탐지',
drone: '열화상 유막 경계 포착',
cctv: '서귀포 카메라 해면 이상 없음',
ais: '인근 유조선 1척 정박',
},
nowDetail: {
satellite: 'SAR 유막 4.8km² 확산',
drone: '열화상 유막 남동 2.7km 확대',
cctv: '서귀포 카메라 해면 변색 감지',
ais: '유조선 이탈, 방제선 2척 진입',
},
},
{
id: 'CHG-003',
area: '부산항 외항',
type: '방제장비 배치',
date1: '03-10',
time1: '09:00',
date2: '03-16',
time2: '14:23',
severity: '낮음',
detail: '부유식 오일펜스 신규 배치 확인',
sources: ['drone', 'cctv'],
crossRef: 'CCTV 부산항 #204 + 드론 정밀 촬영 일치',
asIsDetail: { drone: '오일펜스 미배치', cctv: '부산 민락항 CCTV 해상 장비 없음' },
nowDetail: { drone: '오일펜스 300m 배치 확인', cctv: '부산 민락항 CCTV 오일펜스 포착' },
},
{
id: 'CHG-004',
area: '통영 해역 남측',
type: '미식별 선박',
date1: '03-13',
time1: '22:00',
date2: '03-16',
time2: '14:23',
severity: '보통',
detail: 'AIS 미송출 선박 2척 위성 포착',
sources: ['satellite', 'ais'],
crossRef: 'AIS 미등록 — 위성 SAR 반사 신호로 탐지, 불법 조업 의심',
asIsDetail: { satellite: '해역 내 선박 신호 없음', ais: '등록 선박 0척' },
nowDetail: { satellite: 'SAR 반사 2건 포착 (선박 추정)', ais: 'MMSI 미수신 — 미식별 선박' },
},
{
id: 'CHG-005',
area: '인천 연안부두',
type: '야간 이상징후',
date1: '03-15',
time1: '03:42',
date2: '03-16',
time2: '03:45',
severity: '심각',
detail: 'CCTV 야간 유출 의심 + AIS 정박선 이탈',
sources: ['cctv', 'ais'],
crossRef: 'KBS CCTV #9981 03:42 해면 반사 이상 → AIS 03:45 정박선 이탈 연계',
asIsDetail: { cctv: '인천 연안부두 CCTV 야간 정상', ais: '정박선 5척 정상 정박' },
nowDetail: {
cctv: '03:42 해면 반사광 이상 감지',
ais: '03:45 정박선 1척 이탈 (MMSI 441987654)',
},
},
{
id: 'CHG-006',
area: '마라도 주변 해역',
type: '해안선 변화',
date1: '03-12',
time1: '11:00',
date2: '03-16',
time2: '11:15',
severity: '낮음',
detail: '해안 퇴적물 분포 변경 감지',
sources: ['satellite', 'drone'],
crossRef: '위성 다분광 + 드론 정밀 촬영 퇴적 방향 확인',
asIsDetail: { satellite: '해안선 퇴적 분포 기준점', drone: '미촬영' },
nowDetail: { satellite: '퇴적 남서 방향 이동 감지', drone: '정밀 촬영으로 퇴적 경계 확인' },
},
];
const severityStyle = (s: '심각' | '보통' | '낮음') => {
if (s === '심각')
return {
background: 'color-mix(in srgb, var(--color-danger) 12%, transparent)',
color: 'var(--color-danger)',
border: '1px solid color-mix(in srgb, var(--color-danger) 25%, transparent)',
};
if (s === '보통')
return {
background: 'color-mix(in srgb, var(--color-caution) 12%, transparent)',
color: 'var(--color-caution)',
border: '1px solid color-mix(in srgb, var(--color-caution) 25%, transparent)',
};
return {
background: 'color-mix(in srgb, var(--color-success) 12%, transparent)',
color: 'var(--color-success)',
border: '1px solid color-mix(in srgb, var(--color-success) 25%, transparent)',
};
};
const sourceStyle = (src: SourceConfig, active = true) =>
active
? { background: `${src.color}18`, color: src.color, border: `1px solid ${src.color}40` }
: {
background: 'var(--bg-card)',
color: 'var(--fg-disabled)',
border: '1px solid var(--stroke-default)',
opacity: 0.5,
};
const filteredChanges =
sourceFilter === 'all' ? changes : changes.filter((c) => c.sources.includes(sourceFilter));
return (
<div>
{/* 레이어 토글 바 */}
<div className="flex items-center gap-3 mb-4">
<div className="text-caption font-bold font-korean text-fg-disabled shrink-0">
</div>
<div className="flex gap-1.5">
{SOURCES.map((s) => (
<button
key={s.id}
onClick={() => toggleLayer(s.id)}
className="px-2.5 py-1.5 rounded text-caption font-bold font-korean cursor-pointer border transition-all"
style={sourceStyle(s, layers[s.id])}
>
<span className="mr-1">{layers[s.id] ? '◉' : '○'}</span>
{/* {s.icon} */}
{s.label}
</button>
))}
</div>
<div className="text-caption text-fg-disabled font-mono ml-auto">
{activeCount}/4
</div>
</div>
{/* AS-IS / 현재 시점 비교 뷰 */}
<div className="grid grid-cols-2 gap-4 mb-4" style={{ minHeight: 400 }}>
{/* AS-IS 시점 */}
<div className="bg-bg-elevated border border-stroke rounded-md overflow-hidden">
<div
className="flex items-center justify-between px-4 py-2.5 border-b border-stroke"
style={{ background: 'rgba(6,182,212,.05)' }}
>
<div className="flex items-center gap-2">
<span
className="text-label-2 font-bold font-korean"
style={{ color: 'var(--color-accent)' }}
>
AS-IS
</span>
<span
className="text-caption px-1.5 py-0.5 rounded font-bold"
style={{
background: 'rgba(6,182,212,.12)',
color: 'var(--color-accent)',
border: '1px solid rgba(6,182,212,.25)',
}}
>
</span>
</div>
<div className="flex items-center gap-2">
<input
type="date"
defaultValue="2026-03-14"
className="text-caption font-mono px-2 py-1 rounded border outline-none"
style={{
background: 'var(--bg-card)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-sub)',
}}
/>
<input
type="time"
defaultValue="14:00"
className="text-caption font-mono px-2 py-1 rounded border outline-none"
style={{
background: 'var(--bg-card)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-sub)',
}}
/>
</div>
</div>
{/* 활성 레이어 표시 */}
{/* <div
className="flex gap-1 px-3 py-2 border-b"
style={{ borderColor: 'rgba(255,255,255,.04)' }}
>
{SOURCES.filter((s) => layers[s.id]).map((s) => (
<span
key={s.id}
className="px-1.5 py-0.5 rounded text-caption font-bold"
style={sourceStyle(s)}
>
{s.icon} {s.label}
</span>
))}
{activeCount === 0 && (
<span className="text-caption text-fg-disabled font-korean"> </span>
)}
</div> */}
{/* 지도 플레이스홀더 */}
<div
className="flex items-center justify-center text-fg-disabled"
style={{ height: 320 }}
>
<div className="text-center">
<div className="flex justify-center gap-2 text-2xl mb-3 opacity-30">
{SOURCES.filter((s) => layers[s.id]).map((s) => (
<span key={s.id}>{s.icon}</span>
))}
</div>
<div className="text-label-2 font-korean text-fg-disabled">
</div>
<div className="text-caption text-fg-disabled mt-1">
{SOURCES.filter((s) => layers[s.id])
.map((s) => s.label)
.join(' + ')}{' '}
</div>
</div>
</div>
</div>
{/* 현재 시점 */}
<div className="bg-bg-elevated border border-stroke rounded-md overflow-hidden">
<div
className="flex items-center justify-between px-4 py-2.5 border-b border-stroke"
style={{ background: 'rgba(6,182,212,.05)' }}
>
<div className="flex items-center gap-2">
<span
className="text-label-2 font-bold font-korean"
style={{ color: 'var(--color-accent)' }}
>
</span>
<span
className="text-caption px-1.5 py-0.5 rounded font-bold"
style={{
background: 'rgba(6,182,212,.12)',
color: 'var(--color-accent)',
border: '1px solid rgba(6,182,212,.25)',
}}
>
NOW
</span>
</div>
<span className="text-caption text-fg-disabled font-mono">2026-03-16 14:23</span>
</div>
{/* 활성 레이어 표시 */}
{/* <div
className="flex gap-1 px-3 py-2 border-b"
style={{ borderColor: 'rgba(255,255,255,.04)' }}
>
{SOURCES.filter((s) => layers[s.id]).map((s) => (
<span
key={s.id}
className="px-1.5 py-0.5 rounded text-caption font-bold"
style={sourceStyle(s)}
>
{s.icon} {s.label}
</span>
))}
{activeCount === 0 && (
<span className="text-caption text-fg-disabled font-korean"> </span>
)}
</div> */}
{/* 지도 플레이스홀더 */}
<div
className="flex items-center justify-center text-fg-disabled"
style={{ height: 320 }}
>
<div className="text-center">
<div className="flex justify-center gap-2 text-2xl mb-3 opacity-30">
{SOURCES.filter((s) => layers[s.id]).map((s) => (
<span key={s.id}>{s.icon}</span>
))}
</div>
<div className="text-label-2 font-korean text-fg-disabled">
</div>
<div className="text-caption text-fg-disabled mt-1">
{SOURCES.filter((s) => layers[s.id])
.map((s) => s.label)
.join(' + ')}{' '}
</div>
</div>
</div>
</div>
</div>
{/* 복합 변화 감지 목록 */}
<div className="bg-bg-elevated border border-stroke rounded-md overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-stroke">
<div className="text-label-2 font-bold font-korean text-fg">
🔄
</div>
<div className="flex items-center gap-2">
<div className="flex gap-1">
<button
onClick={() => setSourceFilter('all')}
className="px-2 py-0.5 rounded text-caption font-bold font-korean cursor-pointer border transition-colors"
style={
sourceFilter === 'all'
? {
background: 'rgba(6,182,212,.08)',
borderColor: 'rgba(6,182,212,.3)',
color: 'var(--color-accent)',
}
: {
background: 'var(--bg-card)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-disabled)',
}
}
>
</button>
{SOURCES.map((s) => (
<button
key={s.id}
onClick={() => setSourceFilter(s.id)}
className="px-2 py-0.5 rounded text-caption font-bold font-korean cursor-pointer border transition-colors"
style={
sourceFilter === s.id
? {
background: 'rgba(6,182,212,.08)',
borderColor: 'rgba(6,182,212,.3)',
color: 'var(--color-accent)',
}
: {
background: 'var(--bg-card)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-disabled)',
}
}
>
{s.label}
</button>
))}
</div>
<div className="text-caption text-fg-disabled font-mono">
{filteredChanges.length}
</div>
</div>
</div>
{/* 데이터 행 */}
{filteredChanges.map((c) => {
const isOpen = selectedChange === c.id;
return (
<div key={c.id} className="border-b" style={{ borderColor: 'rgba(255,255,255,.04)' }}>
{/* 요약 행 */}
<div
onClick={() => setSelectedChange(isOpen ? null : c.id)}
className="grid gap-0 px-4 py-3 items-center hover:bg-bg-surface-hover/30 transition-colors cursor-pointer"
style={{
gridTemplateColumns: '52px 1fr 200px 150px 52px',
background: isOpen ? 'rgba(6,182,212,.04)' : undefined,
}}
>
<div className="text-caption font-mono text-fg-disabled">{c.id}</div>
<div>
<div className="flex items-center gap-2">
<span className="text-label-2 font-semibold text-fg font-korean">{c.area}</span>
<span
className="text-caption font-korean"
style={{ color: 'var(--fg-disabled)' }}
>
{c.type}
</span>
</div>
<div className="text-caption text-fg-disabled font-korean mt-0.5">{c.detail}</div>
</div>
<div className="flex flex-wrap gap-1">
{c.sources.map((sid) => {
const cfg = SOURCES.find((s) => s.id === sid)!;
return (
<span
key={sid}
className="px-1.5 py-0.5 rounded text-caption text-fg"
style={{
background: 'transparent',
border: `1px solid color-mix(in srgb, ${cfg.color} 40%, transparent)`,
color: `${cfg.color}`,
}}
>
{cfg.label}
</span>
);
})}
</div>
<div className="text-caption font-mono">
<span className="text-fg">
{c.date1} {c.time1}
</span>
<span className="text-fg-disabled mx-1"></span>
<span className="text-fg">
{c.date2} {c.time2}
</span>
</div>
<div>
<span
className="px-1.5 py-0.5 rounded text-caption font-bold font-korean"
style={severityStyle(c.severity)}
>
{c.severity}
</span>
</div>
</div>
{/* 펼침: 정보원별 AS-IS → 현재 상세 */}
{isOpen && (
<div className="px-4 pb-3 pt-1" style={{ background: 'rgba(6,182,212,.02)' }}>
{/* 교차검증 */}
{c.crossRef && (
<div
className="mb-3 px-3 py-2 rounded text-caption font-korean"
style={{
background: 'rgba(6,182,212,.06)',
border: '1px solid rgba(6,182,212,.15)',
color: 'var(--fg-sub)',
}}
>
<span className="font-bold" style={{ color: 'var(--color-accent)' }}>
</span>{' '}
{c.crossRef}
</div>
)}
{/* 정보원별 비교 그리드 */}
<div className="grid gap-2">
<div
className="grid gap-0 px-3 py-1.5"
style={{ gridTemplateColumns: '90px 1fr 1fr' }}
>
<div className="text-caption font-bold text-fg-disabled font-korean">
</div>
<div className="text-caption font-bold font-korean text-fg">
AS-IS ({c.date1} {c.time1})
</div>
<div className="text-caption font-bold font-korean text-fg">
({c.date2} {c.time2})
</div>
</div>
{c.sources.map((sid) => {
const cfg = SOURCES.find((s) => s.id === sid)!;
return (
<div
key={sid}
className="grid gap-0 px-3 py-2 rounded"
style={{
gridTemplateColumns: '90px 1fr 1fr',
background: `${cfg.color}06`,
border: `1px solid ${cfg.color}15`,
}}
>
<div className="flex items-center gap-1.5">
<span
className="text-caption font-bold px-1.5 py-0.5 rounded"
style={sourceStyle(cfg)}
>
{cfg.icon} {cfg.label}
</span>
</div>
<div className="text-caption font-korean text-fg-disabled">
{c.asIsDetail[sid] || '-'}
</div>
<div className="text-caption font-korean text-fg">
{c.nowDetail[sid] || '-'}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}

파일 보기

@ -0,0 +1,360 @@
import { useState } from 'react';
import { Map } from '@vis.gl/react-maplibre';
import { ScatterplotLayer } from '@deck.gl/layers';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
import { useMapStore } from '@common/store/mapStore';
import { S57EncOverlay } from '@components/common/map/S57EncOverlay';
import { DeckGLOverlay } from '../WingAI';
type MismatchStatus = '불일치' | '의심' | '정상' | '확인중';
interface VesselDetection {
id: string;
mmsi: string;
vesselName: string;
/** AIS 등록 선종 */
aisType: string;
/** AI 영상 분석 선종 */
detectedType: string;
/** 불일치 여부 */
mismatch: boolean;
status: MismatchStatus;
confidence: string;
coord: string;
lon: number;
lat: number;
time: string;
detail: string;
}
export function DetectPanel() {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [filterStatus, setFilterStatus] = useState<MismatchStatus | '전체'>('전체');
const currentMapStyle = useBaseMapStyle();
const mapToggles = useMapStore((s) => s.mapToggles);
const detections: VesselDetection[] = [
{
id: 'VD-001',
mmsi: '440123456',
vesselName: 'OCEAN GLORY',
aisType: '화물선',
detectedType: '유조선',
mismatch: true,
status: '불일치',
confidence: '94.2%',
coord: '33.24°N 126.50°E',
lon: 126.5,
lat: 33.24,
time: '14:23',
detail: 'AIS 화물선 등록 → 영상 분석 결과 유조선 선형 + 탱크 구조 탐지',
},
{
id: 'VD-002',
mmsi: '441987654',
vesselName: 'SEA PHOENIX',
aisType: '유조선',
detectedType: '화물선',
mismatch: true,
status: '불일치',
confidence: '91.7%',
coord: '34.73°N 127.68°E',
lon: 127.68,
lat: 34.73,
time: '14:18',
detail: 'AIS 유조선 등록 → 영상 분석 결과 컨테이너 적재 확인, 화물선 판정',
},
{
id: 'VD-003',
mmsi: '440555123',
vesselName: 'DONGBANG 7',
aisType: '어선',
detectedType: '화물선',
mismatch: true,
status: '의심',
confidence: '78.3%',
coord: '35.15°N 129.13°E',
lon: 129.13,
lat: 35.15,
time: '14:10',
detail: 'AIS 어선 등록 → 선체 규모 및 구조가 어선 대비 과대, 화물선 의심',
},
{
id: 'VD-004',
mmsi: '440678901',
vesselName: 'KOREA STAR',
aisType: '화물선',
detectedType: '화물선',
mismatch: false,
status: '정상',
confidence: '97.8%',
coord: '34.80°N 126.37°E',
lon: 126.37,
lat: 34.8,
time: '14:05',
detail: 'AIS 등록 선종과 영상 분석 결과 일치',
},
{
id: 'VD-005',
mmsi: 'N/A',
vesselName: '미식별',
aisType: 'AIS 미수신',
detectedType: '유조선',
mismatch: true,
status: '확인중',
confidence: '85.6%',
coord: '33.11°N 126.27°E',
lon: 126.27,
lat: 33.11,
time: '14:01',
detail: 'AIS 신호 없음 → 위성 SAR로 유조선급 선형 탐지, 불법 운항 의심',
},
{
id: 'VD-006',
mmsi: '440234567',
vesselName: 'BUSAN EXPRESS',
aisType: '컨테이너선',
detectedType: '유조선',
mismatch: true,
status: '불일치',
confidence: '89.1%',
coord: '35.05°N 129.10°E',
lon: 129.1,
lat: 35.05,
time: '13:55',
detail: 'AIS 컨테이너선 → 갑판 컨테이너 미확인, 탱크 구조 감지',
},
{
id: 'VD-007',
mmsi: '440345678',
vesselName: 'JEJU BREEZE',
aisType: '여객선',
detectedType: '여객선',
mismatch: false,
status: '정상',
confidence: '98.1%',
coord: '33.49°N 126.52°E',
lon: 126.52,
lat: 33.49,
time: '13:50',
detail: 'AIS 등록 선종과 영상 분석 결과 일치',
},
];
const mismatchCount = detections.filter((d) => d.mismatch).length;
const confirmingCount = detections.filter((d) => d.status === '확인중').length;
const stats = [
{ value: String(detections.length), label: '분석 선박', color: 'var(--fg-default)' },
{ value: String(mismatchCount), label: '선종 불일치', color: 'var(--fg-default)' },
{ value: String(confirmingCount), label: '확인 중', color: 'var(--fg-default)' },
{
value: String(detections.filter((d) => !d.mismatch).length),
label: '정상',
color: 'var(--fg-default)',
},
];
const filtered =
filterStatus === '전체' ? detections : detections.filter((d) => d.status === filterStatus);
const statusStyle = (s: MismatchStatus) => {
if (s === '불일치')
return {
background: 'color-mix(in srgb, var(--color-danger) 12%, transparent)',
color: 'var(--color-danger)',
border: '1px solid color-mix(in srgb, var(--color-danger) 25%, transparent)',
};
if (s === '의심')
return {
background: 'color-mix(in srgb, var(--color-caution) 12%, transparent)',
color: 'var(--color-caution)',
border: '1px solid color-mix(in srgb, var(--color-caution) 25%, transparent)',
};
if (s === '확인중')
return {
background: 'rgba(6,182,212,.08)',
color: 'var(--color-accent)',
border: '1px solid rgba(6,182,212,.25)',
};
return {
background: 'color-mix(in srgb, var(--color-success) 12%, transparent)',
color: 'var(--color-success)',
border: '1px solid color-mix(in srgb, var(--color-success) 25%, transparent)',
};
};
const filters: (MismatchStatus | '전체')[] = ['전체', '불일치', '의심', '확인중', '정상'];
return (
<div>
{/* 통계 카드 */}
<div className="grid grid-cols-4 gap-3 mb-5">
{stats.map((s, i) => (
<div key={i} className="bg-bg-elevated border border-stroke rounded-md p-3.5 text-center">
<div className="text-[22px] font-bold font-mono" style={{ color: s.color }}>
{s.value}
</div>
<div className="text-caption text-fg-disabled mt-1 font-korean">{s.label}</div>
</div>
))}
</div>
<div className="grid grid-cols-[1fr_380px] gap-4">
{/* 탐지 결과 지도 */}
<div
className="bg-bg-elevated border border-stroke rounded-md overflow-hidden"
style={{ minHeight: 480 }}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-stroke">
<div className="text-label-2 font-bold font-korean text-fg">
🎯
</div>
<div className="text-caption text-fg-disabled font-mono">{filtered.length} </div>
</div>
<div className="relative" style={{ height: 440 }}>
<Map
initialViewState={{ longitude: 127.5, latitude: 34.0, zoom: 6.5 }}
style={{ width: '100%', height: '100%' }}
mapStyle={currentMapStyle}
attributionControl={false}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
<DeckGLOverlay
layers={[
new ScatterplotLayer({
id: 'vessel-detect-markers',
data: filtered,
getPosition: (d: VesselDetection) => [d.lon, d.lat],
getRadius: (d: VesselDetection) => (selectedId === d.id ? 10 : 7),
radiusUnits: 'pixels' as const,
getFillColor: (d: VesselDetection) => {
if (d.status === '불일치') return [239, 68, 68, 200];
if (d.status === '의심') return [234, 179, 8, 200];
if (d.status === '확인중') return [6, 182, 212, 200];
return [34, 197, 94, 160];
},
getLineColor: [255, 255, 255, 255],
lineWidthMinPixels: 2,
stroked: true,
pickable: true,
onClick: ({ object }: { object: VesselDetection }) => {
if (object) setSelectedId(object.id === selectedId ? null : object.id);
},
updateTriggers: { getRadius: [selectedId] },
}),
]}
/>
</Map>
{/* 범례 */}
<div className="absolute bottom-3 left-3 bg-bg-elevated/90 border border-stroke rounded px-3 py-2 backdrop-blur-sm">
<div className="flex items-center gap-3">
{[
{ color: 'var(--color-danger)', label: '불일치' },
{ color: 'var(--color-caution)', label: '의심' },
{ color: 'var(--color-accent)', label: '확인중' },
{ color: 'var(--color-success)', label: '정상' },
].map((l) => (
<div key={l.label} className="flex items-center gap-1">
<div className="w-2.5 h-2.5 rounded-full" style={{ background: l.color }} />
<span className="text-caption font-korean text-fg-disabled">{l.label}</span>
</div>
))}
</div>
</div>
</div>
</div>
{/* 탐지 목록 */}
<div
className="bg-bg-elevated border border-stroke rounded-md overflow-hidden flex flex-col"
style={{ maxHeight: 520 }}
>
<div className="px-4 py-3 border-b border-stroke shrink-0">
<div className="flex items-center justify-between mb-2">
<div className="text-label-2 font-bold font-korean text-fg">
📋 MMSI
</div>
<div className="text-caption text-fg-disabled font-mono">{filtered.length}</div>
</div>
<div className="flex gap-1">
{filters.map((f) => (
<button
key={f}
onClick={() => setFilterStatus(f)}
className="px-2 py-0.5 rounded text-caption font-bold font-korean cursor-pointer border transition-colors"
style={
filterStatus === f
? {
background: 'rgba(6,182,212,.08)',
borderColor: 'rgba(6,182,212,.3)',
color: 'var(--color-accent)',
}
: {
background: 'var(--bg-card)',
borderColor: 'var(--stroke-default)',
color: 'var(--fg-disabled)',
}
}
>
{f}
</button>
))}
</div>
</div>
<div className="divide-y divide-stroke overflow-y-auto flex-1">
{filtered.map((d) => (
<div
key={d.id}
onClick={() => setSelectedId(selectedId === d.id ? null : d.id)}
className="px-4 py-3 hover:bg-bg-surface-hover/30 transition-colors cursor-pointer"
style={{ background: selectedId === d.id ? 'rgba(6,182,212,.04)' : undefined }}
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span className="text-caption font-mono text-fg-disabled">{d.id}</span>
<span className="text-caption font-bold font-korean text-fg">
{d.vesselName}
</span>
</div>
<span
className="px-1.5 py-0.5 rounded text-caption font-bold font-korean"
style={statusStyle(d.status)}
>
{d.status}
</span>
</div>
{/* 선종 비교 */}
<div className="flex items-center gap-1.5 mt-1.5">
<span className="text-caption font-korean text-fg">AIS: {d.aisType}</span>
{d.mismatch && <span className="text-caption text-fg-disabled"></span>}
{!d.mismatch && <span className="text-caption text-fg-disabled">=</span>}
<span className="text-caption font-korean text-fg">AI: {d.detectedType}</span>
</div>
<div className="flex items-center gap-3 mt-1.5 text-caption text-fg-disabled font-mono">
<span>MMSI {d.mmsi}</span>
<span>{d.coord}</span>
<span>{d.time}</span>
<span> {d.confidence}</span>
</div>
{/* 펼침: 상세 */}
{selectedId === d.id && (
<div
className="mt-2 px-3 py-2 rounded text-caption font-korean"
style={{
background: 'rgba(6,182,212,.04)',
border: '1px solid rgba(6,182,212,.12)',
color: 'var(--fg-sub)',
}}
>
{d.detail}
</div>
)}
</div>
))}
</div>
</div>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,130 @@
export function PanelAreaCalc() {
return (
<div>
{/* 면적 산정 방법론 */}
<div className="bg-bg-card border border-stroke rounded-[10px] p-4 mb-[14px]">
<div className="text-label-2 font-bold text-fg font-korean mb-3">
📏
</div>
<div className="grid grid-cols-2 gap-3 mb-3">
<div className="bg-bg-base rounded-lg p-3 border border-stroke">
<div className="text-label-2 font-bold text-fg font-korean mb-2">
(Pixel Classification)
</div>
<div className="text-caption text-fg-sub font-korean leading-[1.7] mb-2">
<b style={{ color: 'var(--color-accent)' }}>··</b>
/ . × GSD² = .
</div>
<div className="bg-white/[0.03] rounded-[5px] p-2 font-mono text-caption text-fg leading-[1.8]">
A = N<sub>oil</sub> × (GSD)<sup>2</sup>
<br />
<span className="text-fg-disabled">N: 기름 , GSD: 지상 </span>
</div>
</div>
<div className="bg-bg-base rounded-lg p-3 border border-stroke">
<div className="text-label-2 font-bold text-fg font-korean mb-2">
(Spectral Index)
</div>
<div className="text-caption text-fg-sub font-korean leading-[1.7] mb-2">
{' '}
<b style={{ color: 'var(--color-accent)' }}> </b>
.
</div>
<div className="bg-white/[0.03] rounded-[5px] p-2 font-mono text-caption text-fg leading-[1.8]">
OSDI = (B<sub>NIR</sub>B<sub>Red</sub>) / (B<sub>NIR</sub>+B<sub>Red</sub>)
<br />
<span className="text-fg-disabled">Oil Spill Detection Index</span>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="bg-bg-base rounded-lg p-3 border border-stroke">
<div className="text-label-2 font-bold text-fg font-korean mb-2">SAR </div>
<div className="text-caption text-fg-sub font-korean leading-[1.7]">
SAR (σ°) . , 3m/s
, <b style={{ color: 'var(--color-accent)' }}>False Alarm</b>{' '}
.
</div>
<div className="mt-[6px] bg-white/[0.03] rounded-[5px] p-[7px] font-mono text-caption text-fg">
{'Oil = {(x,y) | σ°(x,y) < T'}
<sub>th</sub>
{'}'}
<br />
<span className="text-fg-disabled">
T<sub>th</sub>: ( )
</span>
</div>
</div>
<div className="bg-bg-base rounded-lg p-3 border border-stroke">
<div className="text-label-2 font-bold text-fg font-korean mb-2"> </div>
<div className="text-caption text-fg-sub font-korean leading-[1.7]">
(A) (d) (t) (V)
.
</div>
<div className="mt-[6px] bg-white/[0.03] rounded-[5px] p-[7px] font-mono text-caption text-fg leading-[1.8]">
V = A × d / (1 f<sub>e</sub>(t))
<br />
<span className="text-fg-disabled">
f<sub>e</sub>: (Stiver & Mackay 1984)
</span>
</div>
</div>
</div>
</div>
{/* Bonn Agreement 색상 코드 */}
<div className="bg-bg-card border border-stroke rounded-[10px] p-[14px]">
<div className="text-label-2 font-bold font-korean mb-[10px]">
🎨 (Bonn Agreement Color Code)
</div>
<div className="grid grid-cols-5 gap-[6px] text-caption font-korean">
<div className="px-2 py-2 bg-white/[0.04] border border-white/[0.1] rounded-[6px] text-center">
<div
className="w-5 h-5 rounded-[3px] mx-auto mb-1"
style={{ background: 'rgba(200,200,255,.3)' }}
/>
<div className="font-bold text-fg"></div>
<div className="text-fg-disabled">&lt; 0.1μm</div>
<div className="text-fg-disabled"></div>
</div>
<div className="px-2 py-2 bg-white/[0.04] border border-white/[0.1] rounded-[6px] text-center">
<div
className="w-5 h-5 rounded-[3px] mx-auto mb-1"
style={{ background: 'rgba(180,220,180,.4)' }}
/>
<div className="font-bold text-fg"></div>
<div className="text-fg-disabled">0.1~0.3μm</div>
<div className="text-fg-disabled"></div>
</div>
<div className="px-2 py-2 bg-white/[0.04] border border-white/[0.1] rounded-[6px] text-center">
<div
className="w-5 h-5 rounded-[3px] mx-auto mb-1"
style={{ background: 'rgba(200,160,80,.5)' }}
/>
<div className="font-bold text-fg"></div>
<div className="text-fg-disabled">0.3~5μm</div>
<div className="text-fg-disabled"></div>
</div>
<div className="px-2 py-2 bg-white/[0.04] border border-white/[0.1] rounded-[6px] text-center">
<div
className="w-5 h-5 rounded-[3px] mx-auto mb-1"
style={{ background: 'rgba(120,90,40,.7)' }}
/>
<div className="font-bold text-fg"></div>
<div className="text-fg-disabled">5~200μm</div>
<div className="text-fg-disabled"></div>
</div>
<div className="px-2 py-2 bg-white/[0.04] border border-white/[0.1] rounded-[6px] text-center">
<div
className="w-5 h-5 rounded-[3px] mx-auto mb-1"
style={{ background: 'rgba(40,40,40,.9)' }}
/>
<div className="font-bold text-fg"></div>
<div className="text-fg-disabled">&gt;200μm</div>
<div className="text-fg-disabled"></div>
</div>
</div>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,256 @@
export function PanelDetection() {
return (
<div>
{/* 센서 비교표 */}
<div className="bg-bg-card border border-stroke rounded-[10px] p-4 mb-[14px]">
<div className="text-label-2 font-bold text-fg font-korean mb-3">
🔬
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse font-korean text-caption">
<thead>
<tr className="bg-white/[0.03] border-b border-stroke-light">
<th className="px-[10px] py-[7px] text-left text-fg-disabled font-semibold">
</th>
<th className="px-[10px] py-[7px] text-center text-fg-disabled font-semibold">
</th>
<th className="px-[10px] py-[7px] text-center text-fg-disabled font-semibold">
</th>
<th className="px-[10px] py-[7px] text-center text-fg-disabled font-semibold">
</th>
<th className="px-[10px] py-[7px] text-center text-fg-disabled font-semibold">
</th>
<th className="px-[10px] py-[7px] text-center text-fg-disabled font-semibold">
</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-white/[0.04]">
<td className="px-[10px] py-[7px] font-bold text-fg-sub">(EO)</td>
<td className="px-[10px] py-[7px] text-center text-fg-sub">0.4~0.7μm</td>
<td className="px-[10px] py-[7px] text-center text-fg-sub">
<br />
·
</td>
<td className="px-[10px] py-[7px] text-center text-fg-sub">
<br />
</td>
<td className="px-[10px] py-[7px] text-center text-fg-sub">· </td>
<td className="px-[10px] py-[7px] text-center text-fg-sub">··</td>
</tr>
<tr className="border-b border-white/[0.04] bg-white/[0.01]">
<td className="px-[10px] py-[7px] font-bold text-fg-sub">(IR)</td>
<td className="px-[10px] py-[7px] text-center text-fg-sub">8~14μm</td>
<td className="px-[10px] py-[7px] text-center text-fg-sub">
<br />
</td>
<td className="px-[10px] py-[7px] text-center text-fg-sub">
<br />
</td>
<td className="px-[10px] py-[7px] text-center text-fg-sub">
<br />
</td>
<td className="px-[10px] py-[7px] text-center text-fg-sub">
·
<br />
NOAA AVHRR
</td>
</tr>
<tr className="border-b border-white/[0.04]">
<td className="px-[10px] py-[7px] font-bold text-fg-sub">SAR</td>
<td className="px-[10px] py-[7px] text-center text-fg-sub">
1~10cm
<br />
()
</td>
<td className="px-[10px] py-[7px] text-center text-fg-sub">
<br />
</td>
<td className="px-[10px] py-[7px] text-center text-fg-sub">
·
<br />
</td>
<td className="px-[10px] py-[7px] text-center text-fg-sub">
<br />
</td>
<td className="px-[10px] py-[7px] text-center text-fg-sub">
(Sentinel-1
<br />
KOMPSAT-5)
</td>
</tr>
<tr className="border-b border-white/[0.04] bg-white/[0.01]">
<td className="px-[10px] py-[7px] font-bold text-fg-sub">SLAR/RAR</td>
<td className="px-[10px] py-[7px] text-center text-fg-sub"></td>
<td className="px-[10px] py-[7px] text-center text-fg-sub">
<br />
</td>
<td className="px-[10px] py-[7px] text-center text-fg-sub">
·
<br />
</td>
<td className="px-[10px] py-[7px] text-center text-fg-sub">
<br />
</td>
<td className="px-[10px] py-[7px] text-center text-fg-sub"> </td>
</tr>
<tr className="border-b border-white/[0.04]">
<td className="px-[10px] py-[7px] font-bold text-fg-sub">UV </td>
<td className="px-[10px] py-[7px] text-center text-fg-sub">0.3~0.4μm</td>
<td className="px-[10px] py-[7px] text-center text-fg-sub">
<br />
</td>
<td className="px-[10px] py-[7px] text-center text-fg-sub">
(μm급)
<br />
</td>
<td className="px-[10px] py-[7px] text-center text-fg-sub">
<br />
</td>
<td className="px-[10px] py-[7px] text-center text-fg-sub"></td>
</tr>
<tr className="bg-white/[0.01]">
<td className="px-[10px] py-[7px] font-bold text-fg-sub"> </td>
<td className="px-[10px] py-[7px] text-center text-fg-sub">
cm
<br />
()
</td>
<td className="px-[10px] py-[7px] text-center text-fg-sub">
<br />
</td>
<td className="px-[10px] py-[7px] text-center text-fg-sub">
<br />
</td>
<td className="px-[10px] py-[7px] text-center text-fg-sub">
(50km)
<br />
NGSST
</td>
<td className="px-[10px] py-[7px] text-center text-fg-sub">
AMSR-E
<br />
(NGSST )
</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* NGSST 카드 */}
<div className="bg-bg-card border border-stroke rounded-[10px] p-[14px]">
<div className="text-label-2 font-bold font-korean mb-[10px]">
🌡 NGSST (New Generation SST) 10-1567431
</div>
<div className="grid grid-cols-2 gap-3">
<div className="text-caption text-fg-sub font-korean leading-[1.8]">
Kawamura .{' '}
<b>(AVHRR·MODIS)</b> <b>(AMSR-E)</b>
.
<div className="mt-2 bg-bg-base rounded-[6px] p-[10px] font-mono text-caption text-fg leading-[2]">
SST() = <span>0.15</span> × DN <span>3.0</span>
<br />
<span className="text-fg-disabled">DN: 바이너리 1byte </span>
</div>
</div>
<div className="flex flex-col gap-[5px] text-caption font-korean">
<div
className="px-[9px] py-[6px] rounded-[5px]"
style={{
background: 'color-mix(in srgb, var(--color-accent) 5%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-accent) 12%, transparent)',
}}
>
<span className="font-bold" style={{ color: 'var(--color-accent)' }}>
</span>{' '}
: 116~166°E, 13~63°N ( 50×50°)
</div>
<div
className="px-[9px] py-[6px] rounded-[5px]"
style={{
background: 'color-mix(in srgb, var(--color-accent) 5%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-accent) 12%, transparent)',
}}
>
<span className="font-bold" style={{ color: 'var(--color-accent)' }}>
</span>{' '}
: 3( 5km) · 1000×1000
</div>
<div
className="px-[9px] py-[6px] rounded-[5px]"
style={{
background: 'color-mix(in srgb, var(--color-accent) 5%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-accent) 12%, transparent)',
}}
>
<span className="font-bold" style={{ color: 'var(--color-accent)' }}>
</span>{' '}
: 12 FTP · 5
</div>
<div
className="px-[9px] py-[6px] rounded-[5px]"
style={{
background: 'color-mix(in srgb, var(--color-accent) 5%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-accent) 12%, transparent)',
}}
>
<span className="font-bold" style={{ color: 'var(--color-accent)' }}>
</span>{' '}
: Akima(1978) 2 5 500m
</div>
<div
className="px-[9px] py-[6px] rounded-[5px]"
style={{
background: 'color-mix(in srgb, var(--color-accent) 5%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-accent) 12%, transparent)',
}}
>
<span className="font-bold" style={{ color: 'var(--color-accent)' }}>
WING
</span>{' '}
: ··
</div>
</div>
</div>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,128 @@
export function PanelESIMap() {
return (
<div>
{/* 헤더 카드 */}
<div className="rounded-xl p-4 mb-[14px] bg-bg-card border border-stroke">
<div className="text-label-1 font-bold text-fg font-korean mb-2">
🗺 ESI (Environmental Sensitivity Index Map)
</div>
<div className="text-caption text-fg-sub font-korean leading-[1.8]">
ESI ( 10-1567431) .
·· {' '}
<b style={{ color: 'var(--color-accent)' }}> </b> .
1999~2002 , ······ .
</div>
<div
className="mt-2 px-[10px] py-[7px] rounded-[5px] text-caption text-fg-disabled font-korean"
style={{
// background: 'color-mix(in srgb, var(--color-base) 6%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-accent) 15%, transparent)',
}}
>
원전: NOAA ESI Mapping Program · 적용: 해양수산부·
</div>
</div>
{/* ESI 3종 카드 */}
<div className="grid grid-cols-3 gap-3 mb-[14px]">
<div className="bg-bg-card border border-stroke rounded-[10px] p-[14px]">
<div className="text-label-2 font-bold text-fg font-korean mb-2">
(Shoreline)
</div>
<div className="text-caption text-fg-sub font-korean leading-[1.7] mb-2">
1~10 . · .
</div>
<div className="flex flex-col gap-[3px] text-caption font-korean">
<div
className="px-[7px] py-[3px] rounded-[3px] text-fg-sub"
style={{ background: 'color-mix(in srgb, var(--color-accent) 5%, transparent)' }}
>
ESI 1~2: 노출 · ( )
</div>
<div
className="px-[7px] py-[3px] rounded-[3px] text-fg-sub"
style={{ background: 'color-mix(in srgb, var(--color-accent) 5%, transparent)' }}
>
ESI 5~7: 자갈· ()
</div>
<div
className="px-[7px] py-[3px] rounded-[3px] text-fg-sub"
style={{ background: 'color-mix(in srgb, var(--color-accent) 5%, transparent)' }}
>
ESI 8~10: 조간대·· ()
</div>
</div>
</div>
<div className="bg-bg-card border border-stroke rounded-[10px] p-[14px]">
<div className="text-label-2 font-bold text-fg font-korean mb-2">
(Biological Resources)
</div>
<div className="text-caption text-fg-sub font-korean leading-[1.7] mb-2">
·· .
</div>
<div className="flex flex-col gap-[3px] text-caption font-korean">
<div
className="px-[7px] py-[3px] rounded-[3px] text-fg-sub"
style={{ background: 'color-mix(in srgb, var(--color-accent) 5%, transparent)' }}
>
· ·
</div>
<div
className="px-[7px] py-[3px] rounded-[3px] text-fg-sub"
style={{ background: 'color-mix(in srgb, var(--color-accent) 5%, transparent)' }}
>
·
</div>
<div
className="px-[7px] py-[3px] rounded-[3px] text-fg-sub"
style={{ background: 'color-mix(in srgb, var(--color-accent) 5%, transparent)' }}
>
</div>
</div>
</div>
<div className="bg-bg-card border border-stroke rounded-[10px] p-[14px]">
<div className="text-label-2 font-bold text-fg font-korean mb-2">
· (Human-Use)
</div>
<div className="text-caption text-fg-sub font-korean leading-[1.7] mb-2">
· .
</div>
<div className="flex flex-col gap-[3px] text-caption font-korean">
<div
className="px-[7px] py-[3px] rounded-[3px] text-fg-sub"
style={{ background: 'color-mix(in srgb, var(--color-accent) 5%, transparent)' }}
>
··
</div>
<div
className="px-[7px] py-[3px] rounded-[3px] text-fg-sub"
style={{ background: 'color-mix(in srgb, var(--color-accent) 5%, transparent)' }}
>
··
</div>
<div
className="px-[7px] py-[3px] rounded-[3px] text-fg-sub"
style={{ background: 'color-mix(in srgb, var(--color-accent) 5%, transparent)' }}
>
··
</div>
</div>
</div>
</div>
{/* ESI 구축 현황 */}
<div className="bg-bg-card border border-stroke rounded-[10px] p-[14px]">
<div className="text-label-2 font-bold text-fg font-korean mb-2">
📏 ESI ( 10-1567431)
</div>
<div className="text-caption text-fg-sub font-korean leading-[1.8]">
ESI 1999~2002( 3) {' '}
<b className="text-fg">25,000:1 </b> . ·
(ENC) .
ESI DB와 .
</div>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,144 @@
export function PanelOverview() {
return (
<div>
{/* 개요 카드 */}
<div className="rounded-xl p-5 mb-4 bg-bg-card border border-stroke">
<div className="grid grid-cols-2 gap-5">
<div>
<div className="flex items-center gap-2 mb-2.5">
<div
className="w-[30px] h-[30px] rounded-lg flex items-center justify-center text-base bg-bg-elevated border border-stroke"
// style={{ backgroundColor: 'var(--bg-base)' }}
>
📋
</div>
<span className="text-label-1 font-bold text-fg font-korean">?</span>
</div>
<div className="text-caption text-fg-sub font-korean leading-[1.8]">
{' '}
<b style={{ color: 'var(--color-accent)' }}>··</b>
·· , (ESI) {' '}
<b style={{ color: 'var(--color-accent)' }}> · </b>
.
</div>
</div>
<div>
<div className="flex items-center gap-2 mb-2.5">
<div
className="w-[30px] h-[30px] rounded-lg flex items-center justify-center text-base bg-bg-elevated border border-stroke"
// style={{ background: 'color-mix(in srgb, var(--color-accent) 15%, transparent)' }}
>
🎯
</div>
<span className="text-label-1 font-bold text-fg font-korean">WING </span>
</div>
<div className="flex flex-col gap-[5px] text-caption text-fg-sub font-korean">
<div className="px-[10px] py-[6px] rounded-[6px] bg-bg-base border border-stroke">
{/* <span style={{ color: 'var(--color-fg)', fontWeight: 700 }}>①</span> */}
<b> ·</b>
</div>
<div className="px-[10px] py-[6px] rounded-[6px] bg-bg-base border border-stroke">
{/* <span style={{ color: 'var(--color-fg)', fontWeight: 700 }}>②</span>{' '} */}
<b>(SST) </b>
</div>
<div className="px-[10px] py-[6px] rounded-[6px] bg-bg-base border border-stroke">
{/* <span style={{ color: 'var(--color-fg)', fontWeight: 700 }}>③</span>{' '} */}
<b>ESI </b>
</div>
<div className="px-[10px] py-[6px] rounded-[6px] bg-bg-base border border-stroke">
{/* <span style={{ color: 'var(--color-fg)', fontWeight: 700 }}>④</span> */}
<b>3D </b> ·
</div>
</div>
</div>
</div>
</div>
{/* 통합 흐름 카드 */}
<div className="bg-bg-card border border-stroke rounded-[10px] p-4 mb-4">
<div className="text-label-2 font-bold text-fg font-korean mb-[14px]">
</div>
<div className="flex items-center justify-center gap-0 flex-wrap py-2">
{[
{ title: '탐지 플랫폼', desc: '드론·항공기·위성' },
{ title: '센서 데이터', desc: '광학·IR·SAR·SST' },
{ title: '영상 처리', desc: '좌표변환·면적산정' },
{ title: '확산모델 입력', desc: '유출위치·유출량·SST' },
{ title: '방제 의사결정', desc: 'ESI 연계·자원 배치' },
].map((step, i) => (
<div key={i} className="contents">
{i > 0 && (
<div className="flex items-center gap-0.5 text-fg-disabled">
<div className="w-4 h-[1px] bg-stroke-light" />
<span className="text-caption leading-none"></span>
</div>
)}
<div className="px-4 py-3 rounded-lg text-center text-caption font-korean bg-bg-base border border-stroke">
<div className="font-bold text-fg">{step.title}</div>
<div className="text-fg-disabled">{step.desc}</div>
</div>
</div>
))}
</div>
</div>
{/* 플랫폼 3종 카드 */}
<div className="grid grid-cols-3 gap-3">
<div className="bg-bg-card border border-stroke rounded-[10px] p-[14px]">
<div className="text-label-2 font-bold text-fg font-korean mb-2"> (UAV)</div>
<div className="text-caption text-fg-sub font-korean leading-[1.7] mb-2">
· . · , , 3D .
</div>
<div className="flex flex-col gap-[3px] text-caption font-korean">
<div className="px-[7px] py-[3px] rounded-[3px] text-fg-sub bg-bg-base">
고도: 30~500m · 속도: 15~25m/s
</div>
<div className="px-[7px] py-[3px] rounded-[3px] text-fg-sub bg-bg-base">
GSD: 1~5cm/px (100m )
</div>
<div className="px-[7px] py-[3px] rounded-[3px] text-fg-sub bg-bg-base">
운용반경: 5~30km · 체공: 30~90
</div>
</div>
</div>
<div className="bg-bg-card border border-stroke rounded-[10px] p-[14px]">
<div className="text-label-2 font-bold text-fg font-korean mb-2"> </div>
<div className="text-caption text-fg-sub font-korean leading-[1.7] mb-2">
· . ·IR·SAR·SLAR·UV .
.
</div>
<div className="flex flex-col gap-[3px] text-caption font-korean">
<div className="px-[7px] py-[3px] rounded-[3px] text-fg-sub bg-bg-base">
고도: 300~3,000m · 속도: 60~150m/s
</div>
<div className="px-[7px] py-[3px] rounded-[3px] text-fg-sub bg-bg-base">
탐색폭: 5~50km · 체공: 4~8
</div>
<div className="px-[7px] py-[3px] rounded-[3px] text-fg-sub bg-bg-base">
· SAR
</div>
</div>
</div>
<div className="bg-bg-card border border-stroke rounded-[10px] p-[14px]">
<div className="text-label-2 font-bold text-fg font-korean mb-2"></div>
<div className="text-caption text-fg-sub font-korean leading-[1.7] mb-2">
· . SST(NOAA AVHRR·NGSST)·SAR(Sentinel-1)·(KOMPSAT) .
.
</div>
<div className="flex flex-col gap-[3px] text-caption font-korean">
<div className="px-[7px] py-[3px] rounded-[3px] text-fg-sub bg-bg-base">
NGSST: 5km · 1
</div>
<div className="px-[7px] py-[3px] rounded-[3px] text-fg-sub bg-bg-base">
SAR: 5~25m ·
</div>
<div className="px-[7px] py-[3px] rounded-[3px] text-fg-sub bg-bg-base">
KOMPSAT-5: X-band SAR 1m급
</div>
</div>
</div>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More