Compare commits
No commits in common. "7a5028226b28878c7daf3e5cc7fa6aa8b03e68d4" and "28544d5c8fa6a0f6cfb50dd7d5e030ef6c0a9e1d" have entirely different histories.
7a5028226b
...
28544d5c8f
@ -84,8 +84,5 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"enabledPlugins": {
|
|
||||||
"frontend-design@claude-plugins-official": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"applied_global_version": "1.6.1",
|
"applied_global_version": "1.6.1",
|
||||||
"applied_date": "2026-04-16",
|
"applied_date": "2026-04-14",
|
||||||
"project_type": "react-ts",
|
"project_type": "react-ts",
|
||||||
"gitea_url": "https://gitea.gc-si.dev",
|
"gitea_url": "https://gitea.gc-si.dev",
|
||||||
"custom_pre_commit": true
|
"custom_pre_commit": true
|
||||||
}
|
}
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -102,7 +102,4 @@ frontend/public/hns-manual/images/
|
|||||||
|
|
||||||
|
|
||||||
# mcp
|
# mcp
|
||||||
.mcp.json
|
.mcp.json
|
||||||
|
|
||||||
# python
|
|
||||||
.venv
|
|
||||||
@ -54,7 +54,7 @@ wing/
|
|||||||
│ │ ├── types/ backtrack, boomLine, hns, navigation
|
│ │ ├── types/ backtrack, boomLine, hns, navigation
|
||||||
│ │ ├── utils/ coordinates, geo, sanitize, cn.ts
|
│ │ ├── utils/ coordinates, geo, sanitize, cn.ts
|
||||||
│ │ └── data/ layerData.ts (UI 레이어 트리)
|
│ │ └── data/ layerData.ts (UI 레이어 트리)
|
||||||
│ └── components/ 탭 단위 패키지 (@components/ alias)
|
│ └── tabs/ 탭 단위 패키지 (@tabs/ alias)
|
||||||
│ ├── prediction/ 확산 예측 (OilSpillView, 역추적, 오일붐)
|
│ ├── prediction/ 확산 예측 (OilSpillView, 역추적, 오일붐)
|
||||||
│ ├── hns/ HNS 분석 (시나리오, 물질 DB, 재계산)
|
│ ├── hns/ HNS 분석 (시나리오, 물질 DB, 재계산)
|
||||||
│ ├── rescue/ 구조 시나리오
|
│ ├── rescue/ 구조 시나리오
|
||||||
@ -96,7 +96,7 @@ wing/
|
|||||||
### Path Alias
|
### Path Alias
|
||||||
|
|
||||||
- `@common/*` -> `src/common/*` (공통 모듈)
|
- `@common/*` -> `src/common/*` (공통 모듈)
|
||||||
- `@components/*` -> `src/components/*` (탭 패키지)
|
- `@tabs/*` -> `src/tabs/*` (탭 패키지)
|
||||||
|
|
||||||
## 팀 컨벤션
|
## 팀 컨벤션
|
||||||
|
|
||||||
@ -107,8 +107,6 @@ wing/
|
|||||||
- `naming.md` -- 네이밍 규칙
|
- `naming.md` -- 네이밍 규칙
|
||||||
- `testing.md` -- 테스트 규칙
|
- `testing.md` -- 테스트 규칙
|
||||||
- `subagent-policy.md` -- 서브에이전트 활용 정책
|
- `subagent-policy.md` -- 서브에이전트 활용 정책
|
||||||
- `design-system.md` -- AI 에이전트 UI 디자인 시스템 규칙 (영문, 실사용)
|
|
||||||
- `design-system-ko.md` -- 디자인 시스템 규칙 (한국어 참고용)
|
|
||||||
|
|
||||||
## 개발 문서 (docs/)
|
## 개발 문서 (docs/)
|
||||||
|
|
||||||
|
|||||||
@ -77,7 +77,7 @@ cd backend && npm run db:seed # DB 초기 데이터
|
|||||||
|
|
||||||
## 프로젝트 구조
|
## 프로젝트 구조
|
||||||
|
|
||||||
Path Alias: `@common/*` -> `src/common/*`, `@components/*` -> `src/components/*`
|
Path Alias: `@common/*` -> `src/common/*`, `@tabs/*` -> `src/tabs/*`
|
||||||
|
|
||||||
```
|
```
|
||||||
wing/
|
wing/
|
||||||
@ -95,7 +95,7 @@ wing/
|
|||||||
│ │ ├── types/ backtrack, boomLine, hns, navigation
|
│ │ ├── types/ backtrack, boomLine, hns, navigation
|
||||||
│ │ ├── utils/ coordinates, geo, sanitize, cn.ts
|
│ │ ├── utils/ coordinates, geo, sanitize, cn.ts
|
||||||
│ │ └── data/ layerData.ts (UI 레이어 트리)
|
│ │ └── data/ layerData.ts (UI 레이어 트리)
|
||||||
│ └── tabs/ 탭 단위 패키지 (@components/ alias)
|
│ └── tabs/ 탭 단위 패키지 (@tabs/ alias)
|
||||||
│ ├── prediction/ 확산 예측 (OilSpillView, 역추적, 오일붐)
|
│ ├── prediction/ 확산 예측 (OilSpillView, 역추적, 오일붐)
|
||||||
│ ├── hns/ HNS 분석 (시나리오, 물질 DB, 재계산)
|
│ ├── hns/ HNS 분석 (시나리오, 물질 DB, 재계산)
|
||||||
│ ├── rescue/ 구조 시나리오
|
│ ├── rescue/ 구조 시나리오
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { mkdirSync, existsSync } from 'fs';
|
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
import {
|
import {
|
||||||
listMedia,
|
listMedia,
|
||||||
createMedia,
|
createMedia,
|
||||||
@ -27,29 +25,6 @@ import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const stitchUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } });
|
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 라우트
|
// AERIAL_MEDIA 라우트
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@ -98,96 +73,6 @@ 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 — 원본 이미지 다운로드
|
// GET /api/aerial/media/:sn/download — 원본 이미지 다운로드
|
||||||
router.get('/media/:sn/download', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
|
router.get('/media/:sn/download', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -378,7 +378,7 @@ PUT, DELETE, PATCH 등 기타 메서드는 사용하지 않는다.
|
|||||||
각 탭은 `tabs/{탭명}/services/{탭명}Api.ts`에 API 함수를 정의한다.
|
각 탭은 `tabs/{탭명}/services/{탭명}Api.ts`에 API 함수를 정의한다.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// frontend/src/components/board/services/boardApi.ts
|
// frontend/src/tabs/board/services/boardApi.ts
|
||||||
import { api } from '@common/services/api';
|
import { api } from '@common/services/api';
|
||||||
|
|
||||||
// 인터페이스 정의
|
// 인터페이스 정의
|
||||||
@ -490,7 +490,7 @@ interface MenuConfigItem {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// frontend/src/common/store/newStore.ts (공통) 또는
|
// frontend/src/common/store/newStore.ts (공통) 또는
|
||||||
// frontend/src/components/{탭}/store/newStore.ts (탭 전용)
|
// frontend/src/tabs/{탭}/store/newStore.ts (탭 전용)
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
interface MyState {
|
interface MyState {
|
||||||
@ -514,7 +514,7 @@ export const useMyStore = create<MyState>((set) => ({
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { fetchBoardPosts, createBoardPost } from '@components/board/services/boardApi';
|
import { fetchBoardPosts, createBoardPost } from '@tabs/board/services/boardApi';
|
||||||
|
|
||||||
// 조회 (캐싱 + 자동 리페치)
|
// 조회 (캐싱 + 자동 리페치)
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
@ -1491,13 +1491,13 @@ const result = await authPool.query('SELECT * FROM AUTH_USER WHERE USER_ID = $1'
|
|||||||
### 파일 위치
|
### 파일 위치
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/src/components/{탭명}/services/{탭명}Api.ts
|
frontend/src/tabs/{탭명}/services/{탭명}Api.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
### 작성 패턴
|
### 작성 패턴
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// frontend/src/components/{탭명}/services/{탭명}Api.ts
|
// frontend/src/tabs/{탭명}/services/{탭명}Api.ts
|
||||||
import { api } from '@common/services/api';
|
import { api } from '@common/services/api';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@ -736,13 +736,13 @@ ON CONFLICT DO NOTHING;
|
|||||||
### 파일 위치
|
### 파일 위치
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/src/components/{탭명}/services/{tabName}Api.ts
|
frontend/src/tabs/{탭명}/services/{tabName}Api.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
### 기본 구조
|
### 기본 구조
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// frontend/src/components/{탭명}/services/{tabName}Api.ts
|
// frontend/src/tabs/{탭명}/services/{tabName}Api.ts
|
||||||
|
|
||||||
import { api } from '@common/services/api';
|
import { api } from '@common/services/api';
|
||||||
|
|
||||||
@ -1376,7 +1376,7 @@ export default router;
|
|||||||
### 4단계: 프론트엔드 API 서비스
|
### 4단계: 프론트엔드 API 서비스
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// frontend/src/components/assets/services/equipmentApi.ts
|
// frontend/src/tabs/assets/services/equipmentApi.ts
|
||||||
|
|
||||||
import { api } from '@common/services/api';
|
import { api } from '@common/services/api';
|
||||||
|
|
||||||
|
|||||||
@ -163,11 +163,11 @@ Frontend에서 두 가지 경로 별칭을 사용한다:
|
|||||||
| Alias | 실제 경로 | 용도 |
|
| Alias | 실제 경로 | 용도 |
|
||||||
|-------|----------|------|
|
|-------|----------|------|
|
||||||
| `@common/*` | `src/common/*` | 공통 모듈 (컴포넌트, 훅, 서비스, 스토어) |
|
| `@common/*` | `src/common/*` | 공통 모듈 (컴포넌트, 훅, 서비스, 스토어) |
|
||||||
| `@components/*` | `src/components/*` | 탭별 패키지 (11개 탭) |
|
| `@tabs/*` | `src/tabs/*` | 탭별 패키지 (11개 탭) |
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { useAuth } from '@common/hooks/useAuth';
|
import { useAuth } from '@common/hooks/useAuth';
|
||||||
import OilSpillView from '@components/prediction/components/OilSpillView';
|
import OilSpillView from '@tabs/prediction/components/OilSpillView';
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -495,7 +495,7 @@ pre-commit: [backend] 타입 체크 성공
|
|||||||
git status
|
git status
|
||||||
|
|
||||||
# 스테이징 (파일 지정)
|
# 스테이징 (파일 지정)
|
||||||
git add frontend/src/components/incidents/components/IncidentDetailView.tsx
|
git add frontend/src/tabs/incidents/components/IncidentDetailView.tsx
|
||||||
git add backend/src/incidents/incidentService.ts
|
git add backend/src/incidents/incidentService.ts
|
||||||
|
|
||||||
# 커밋 (pre-commit + commit-msg 검증 자동 실행)
|
# 커밋 (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줄로 요약
|
- 변경 내용을 1~3줄로 요약
|
||||||
|
|
||||||
## 변경 파일
|
## 변경 파일
|
||||||
- `frontend/src/components/incidents/components/IncidentDetailView.tsx` (신규)
|
- `frontend/src/tabs/incidents/components/IncidentDetailView.tsx` (신규)
|
||||||
- `backend/src/incidents/incidentService.ts` (수정)
|
- `backend/src/incidents/incidentService.ts` (수정)
|
||||||
|
|
||||||
## Test plan
|
## Test plan
|
||||||
@ -754,8 +754,8 @@ chmod +x .githooks/pre-commit .githooks/commit-msg
|
|||||||
| `database/migration/017_incident_detail.sql` | DB 마이그레이션 (필요 시) |
|
| `database/migration/017_incident_detail.sql` | DB 마이그레이션 (필요 시) |
|
||||||
| `backend/src/incidents/incidentService.ts` | 상세 조회 함수 추가 |
|
| `backend/src/incidents/incidentService.ts` | 상세 조회 함수 추가 |
|
||||||
| `backend/src/incidents/incidentRouter.ts` | `GET /api/incidents/:id` 라우트 |
|
| `backend/src/incidents/incidentRouter.ts` | `GET /api/incidents/:id` 라우트 |
|
||||||
| `frontend/src/components/incidents/services/incidentsApi.ts` | API 호출 함수 |
|
| `frontend/src/tabs/incidents/services/incidentsApi.ts` | API 호출 함수 |
|
||||||
| `frontend/src/components/incidents/components/IncidentDetailView.tsx` | 상세 뷰 컴포넌트 |
|
| `frontend/src/tabs/incidents/components/IncidentDetailView.tsx` | 상세 뷰 컴포넌트 |
|
||||||
|
|
||||||
#### Step 2. 브랜치 생성
|
#### Step 2. 브랜치 생성
|
||||||
|
|
||||||
@ -797,7 +797,7 @@ router.get('/:id', requireAuth, async (req, res) => {
|
|||||||
**Frontend - API:**
|
**Frontend - API:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// frontend/src/components/incidents/services/incidentsApi.ts
|
// frontend/src/tabs/incidents/services/incidentsApi.ts
|
||||||
export async function fetchIncidentById(id: number) {
|
export async function fetchIncidentById(id: number) {
|
||||||
const { data } = await api.get(`/incidents/${id}`);
|
const { data } = await api.get(`/incidents/${id}`);
|
||||||
return data;
|
return data;
|
||||||
@ -807,7 +807,7 @@ export async function fetchIncidentById(id: number) {
|
|||||||
**Frontend - Component:**
|
**Frontend - Component:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// frontend/src/components/incidents/components/IncidentDetailView.tsx
|
// frontend/src/tabs/incidents/components/IncidentDetailView.tsx
|
||||||
const IncidentDetailView = ({ incidentId }: IncidentDetailViewProps) => {
|
const IncidentDetailView = ({ incidentId }: IncidentDetailViewProps) => {
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['incident', incidentId],
|
queryKey: ['incident', incidentId],
|
||||||
@ -829,7 +829,7 @@ cd ../backend && npx tsc --noEmit
|
|||||||
#### Step 5. 커밋 & 푸시
|
#### Step 5. 커밋 & 푸시
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add backend/src/incidents/ frontend/src/components/incidents/
|
git add backend/src/incidents/ frontend/src/tabs/incidents/
|
||||||
git commit -m "feat(incidents): 사고 상세 조회 페이지 추가"
|
git commit -m "feat(incidents): 사고 상세 조회 페이지 추가"
|
||||||
# pre-commit: TypeScript OK, ESLint OK
|
# pre-commit: TypeScript OK, ESLint OK
|
||||||
# commit-msg: Conventional Commits OK
|
# commit-msg: Conventional Commits OK
|
||||||
|
|||||||
@ -31,9 +31,9 @@ board 탭을 기준 템플릿으로 사용하며, 각 단계별 실제 코드
|
|||||||
|
|
||||||
| 단계 | 파일 | 작업 |
|
| 단계 | 파일 | 작업 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| **Step 1** | `frontend/src/components/{탭명}/components/{TabName}View.tsx` | 뷰 컴포넌트 생성 |
|
| **Step 1** | `frontend/src/tabs/{탭명}/components/{TabName}View.tsx` | 뷰 컴포넌트 생성 |
|
||||||
| | `frontend/src/components/{탭명}/services/{tabName}Api.ts` | API 서비스 생성 |
|
| | `frontend/src/tabs/{탭명}/services/{tabName}Api.ts` | API 서비스 생성 |
|
||||||
| | `frontend/src/components/{탭명}/index.ts` | re-export |
|
| | `frontend/src/tabs/{탭명}/index.ts` | re-export |
|
||||||
| **Step 2** | `frontend/src/common/types/navigation.ts` | MainTab 타입 추가 |
|
| **Step 2** | `frontend/src/common/types/navigation.ts` | MainTab 타입 추가 |
|
||||||
| | `frontend/src/App.tsx` | import + renderView case 추가 |
|
| | `frontend/src/App.tsx` | import + renderView case 추가 |
|
||||||
| | `frontend/src/common/hooks/useSubMenu.ts` | 서브메뉴 설정 (서브탭이 있는 경우) |
|
| | `frontend/src/common/hooks/useSubMenu.ts` | 서브메뉴 설정 (서브탭이 있는 경우) |
|
||||||
@ -52,7 +52,7 @@ board 탭을 기준 템플릿으로 사용하며, 각 단계별 실제 코드
|
|||||||
### 1-1. 디렉토리 구조
|
### 1-1. 디렉토리 구조
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/src/components/{탭명}/
|
frontend/src/tabs/{탭명}/
|
||||||
components/
|
components/
|
||||||
{TabName}View.tsx # 메인 뷰 컴포넌트
|
{TabName}View.tsx # 메인 뷰 컴포넌트
|
||||||
services/
|
services/
|
||||||
@ -65,7 +65,7 @@ frontend/src/components/{탭명}/
|
|||||||
서브탭이 **없는** 간단한 탭:
|
서브탭이 **없는** 간단한 탭:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// frontend/src/components/monitoring/components/MonitoringView.tsx
|
// frontend/src/tabs/monitoring/components/MonitoringView.tsx
|
||||||
|
|
||||||
export function MonitoringView() {
|
export function MonitoringView() {
|
||||||
return (
|
return (
|
||||||
@ -91,7 +91,7 @@ export function MonitoringView() {
|
|||||||
서브탭이 **있는** 탭 (board 패턴):
|
서브탭이 **있는** 탭 (board 패턴):
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// frontend/src/components/monitoring/components/MonitoringView.tsx
|
// frontend/src/tabs/monitoring/components/MonitoringView.tsx
|
||||||
|
|
||||||
import { useSubMenu } from '@common/hooks/useSubMenu';
|
import { useSubMenu } from '@common/hooks/useSubMenu';
|
||||||
|
|
||||||
@ -122,7 +122,7 @@ export function MonitoringView() {
|
|||||||
### 1-3. API 서비스 (보일러플레이트)
|
### 1-3. API 서비스 (보일러플레이트)
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// frontend/src/components/monitoring/services/monitoringApi.ts
|
// frontend/src/tabs/monitoring/services/monitoringApi.ts
|
||||||
|
|
||||||
import { api } from '@common/services/api';
|
import { api } from '@common/services/api';
|
||||||
|
|
||||||
@ -180,7 +180,7 @@ export async function createMonitoring(input: CreateMonitoringInput): Promise<{
|
|||||||
### 1-4. index.ts (re-export)
|
### 1-4. index.ts (re-export)
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// frontend/src/components/monitoring/index.ts
|
// frontend/src/tabs/monitoring/index.ts
|
||||||
|
|
||||||
export { MonitoringView } from './components/MonitoringView';
|
export { MonitoringView } from './components/MonitoringView';
|
||||||
```
|
```
|
||||||
@ -209,7 +209,7 @@ export type MainTab = 'prediction' | 'hns' | 'rescue' | ... | 'monitoring' | 'ad
|
|||||||
// frontend/src/App.tsx
|
// frontend/src/App.tsx
|
||||||
|
|
||||||
// 1. import 추가
|
// 1. import 추가
|
||||||
import { MonitoringView } from '@components/monitoring';
|
import { MonitoringView } from '@tabs/monitoring';
|
||||||
|
|
||||||
// 2. renderView switch에 case 추가
|
// 2. renderView switch에 case 추가
|
||||||
const renderView = () => {
|
const renderView = () => {
|
||||||
@ -577,13 +577,13 @@ CREATE INDEX IF NOT EXISTS IDX_MONITORING_REG_DTM ON MONITORING(REG_DTM DESC);
|
|||||||
### 1단계: 프론트엔드 파일 생성
|
### 1단계: 프론트엔드 파일 생성
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p frontend/src/components/monitoring/components
|
mkdir -p frontend/src/tabs/monitoring/components
|
||||||
mkdir -p frontend/src/components/monitoring/services
|
mkdir -p frontend/src/tabs/monitoring/services
|
||||||
```
|
```
|
||||||
|
|
||||||
- `frontend/src/components/monitoring/components/MonitoringView.tsx` 생성
|
- `frontend/src/tabs/monitoring/components/MonitoringView.tsx` 생성
|
||||||
- `frontend/src/components/monitoring/services/monitoringApi.ts` 생성
|
- `frontend/src/tabs/monitoring/services/monitoringApi.ts` 생성
|
||||||
- `frontend/src/components/monitoring/index.ts` 생성
|
- `frontend/src/tabs/monitoring/index.ts` 생성
|
||||||
|
|
||||||
### 2단계: 프론트엔드 기존 파일 수정
|
### 2단계: 프론트엔드 기존 파일 수정
|
||||||
|
|
||||||
@ -592,7 +592,7 @@ mkdir -p frontend/src/components/monitoring/services
|
|||||||
+ export type MainTab = '...' | 'monitoring' | 'admin';
|
+ export type MainTab = '...' | 'monitoring' | 'admin';
|
||||||
|
|
||||||
--- frontend/src/App.tsx
|
--- frontend/src/App.tsx
|
||||||
+ import { MonitoringView } from '@components/monitoring';
|
+ import { MonitoringView } from '@tabs/monitoring';
|
||||||
// renderView switch 내:
|
// renderView switch 내:
|
||||||
+ case 'monitoring':
|
+ case 'monitoring':
|
||||||
+ return <MonitoringView />;
|
+ return <MonitoringView />;
|
||||||
@ -644,9 +644,9 @@ cd backend && npx tsc --noEmit # 백엔드 컴파일 검증
|
|||||||
## 체크리스트
|
## 체크리스트
|
||||||
|
|
||||||
### 프론트엔드
|
### 프론트엔드
|
||||||
- [ ] `frontend/src/components/{탭명}/components/{TabName}View.tsx` 생성
|
- [ ] `frontend/src/tabs/{탭명}/components/{TabName}View.tsx` 생성
|
||||||
- [ ] `frontend/src/components/{탭명}/services/{tabName}Api.ts` 생성
|
- [ ] `frontend/src/tabs/{탭명}/services/{tabName}Api.ts` 생성
|
||||||
- [ ] `frontend/src/components/{탭명}/index.ts` re-export 생성
|
- [ ] `frontend/src/tabs/{탭명}/index.ts` re-export 생성
|
||||||
- [ ] `navigation.ts` MainTab 타입에 새 ID 추가
|
- [ ] `navigation.ts` MainTab 타입에 새 ID 추가
|
||||||
- [ ] `App.tsx` import + renderView switch case 추가
|
- [ ] `App.tsx` import + renderView switch case 추가
|
||||||
- [ ] `useSubMenu.ts` subMenuConfigs + subMenuState 추가 (서브탭 있는 경우)
|
- [ ] `useSubMenu.ts` subMenuConfigs + subMenuState 추가 (서브탭 있는 경우)
|
||||||
|
|||||||
@ -49,7 +49,7 @@ git checkout -b feature/{탭명}-crud
|
|||||||
```bash
|
```bash
|
||||||
# 탭 디렉토리 내 mock 데이터 검색
|
# 탭 디렉토리 내 mock 데이터 검색
|
||||||
grep -rn "mock\|Mock\|MOCK\|sample\|initial\|hardcod\|localStorage" \
|
grep -rn "mock\|Mock\|MOCK\|sample\|initial\|hardcod\|localStorage" \
|
||||||
frontend/src/components/{탭명}/
|
frontend/src/tabs/{탭명}/
|
||||||
|
|
||||||
# 공통 디렉토리에서 해당 탭 관련 데이터 확인 (반드시!)
|
# 공통 디렉토리에서 해당 탭 관련 데이터 확인 (반드시!)
|
||||||
grep -rn "{탭명}\|{Tab}" frontend/src/common/mock/
|
grep -rn "{탭명}\|{Tab}" frontend/src/common/mock/
|
||||||
@ -302,7 +302,7 @@ app.use('/api/{탭명}', newtabRouter);
|
|||||||
|
|
||||||
**1) API 서비스 파일 생성:**
|
**1) API 서비스 파일 생성:**
|
||||||
|
|
||||||
파일 위치: `frontend/src/components/{탭명}/services/{탭명}Api.ts`
|
파일 위치: `frontend/src/tabs/{탭명}/services/{탭명}Api.ts`
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { api } from '@common/services/api';
|
import { api } from '@common/services/api';
|
||||||
@ -476,7 +476,7 @@ CRUD 전체 흐름(생성 -> 조회 -> 수정 -> 삭제)을 확인하고 테스
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 해당 탭 디렉토리에서 mock 잔여 검색
|
# 해당 탭 디렉토리에서 mock 잔여 검색
|
||||||
grep -rn "mock\|Mock\|MOCK\|localStorage" frontend/src/components/{탭명}/
|
grep -rn "mock\|Mock\|MOCK\|localStorage" frontend/src/tabs/{탭명}/
|
||||||
|
|
||||||
# 공통 mock/data 디렉토리에서 해당 탭 관련 검색
|
# 공통 mock/data 디렉토리에서 해당 탭 관련 검색
|
||||||
grep -rn "{탭명}" frontend/src/common/mock/
|
grep -rn "{탭명}" frontend/src/common/mock/
|
||||||
@ -497,7 +497,7 @@ git status
|
|||||||
git add database/migration/017_{탭명}.sql
|
git add database/migration/017_{탭명}.sql
|
||||||
git add backend/src/{탭명}/
|
git add backend/src/{탭명}/
|
||||||
git add backend/src/server.ts
|
git add backend/src/server.ts
|
||||||
git add frontend/src/components/{탭명}/
|
git add frontend/src/tabs/{탭명}/
|
||||||
|
|
||||||
# 커밋 (Conventional Commits, 한국어)
|
# 커밋 (Conventional Commits, 한국어)
|
||||||
git commit -m "feat({탭명}): mock 데이터를 PostgreSQL + REST API로 전환"
|
git commit -m "feat({탭명}): mock 데이터를 PostgreSQL + REST API로 전환"
|
||||||
@ -602,7 +602,7 @@ AUTH_USER 주요 컬럼 참조:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 불충분 -- 탭 디렉토리만 검색
|
# 불충분 -- 탭 디렉토리만 검색
|
||||||
grep -rn "mock" frontend/src/components/{탭명}/
|
grep -rn "mock" frontend/src/tabs/{탭명}/
|
||||||
|
|
||||||
# 반드시 공통 디렉토리도 검색
|
# 반드시 공통 디렉토리도 검색
|
||||||
grep -rn "{탭명}\|{Tab}" frontend/src/common/mock/
|
grep -rn "{탭명}\|{Tab}" frontend/src/common/mock/
|
||||||
@ -780,8 +780,8 @@ export async function fetchCategories(): Promise<Category[]> {
|
|||||||
- [ ] 프론트 타입 체크 통과: `cd frontend && npx tsc --noEmit`
|
- [ ] 프론트 타입 체크 통과: `cd frontend && npx tsc --noEmit`
|
||||||
- [ ] ESLint 통과: `cd frontend && npx eslint .`
|
- [ ] ESLint 통과: `cd frontend && npx eslint .`
|
||||||
- [ ] CRUD 테스트: curl로 생성/조회/수정/삭제 정상 동작 확인
|
- [ ] CRUD 테스트: curl로 생성/조회/수정/삭제 정상 동작 확인
|
||||||
- [ ] Mock 잔여 0건: `grep -rn "mock\|Mock" frontend/src/components/{탭명}/` (UI 상수 제외)
|
- [ ] Mock 잔여 0건: `grep -rn "mock\|Mock" frontend/src/tabs/{탭명}/` (UI 상수 제외)
|
||||||
- [ ] PUT/DELETE 사용 0건: `grep -rn "api\.put\|api\.delete" frontend/src/components/{탭명}/`
|
- [ ] PUT/DELETE 사용 0건: `grep -rn "api\.put\|api\.delete" frontend/src/tabs/{탭명}/`
|
||||||
- [ ] 라우터 등록 확인: `server.ts`에 `app.use('/api/{탭명}', ...)` 추가됨
|
- [ ] 라우터 등록 확인: `server.ts`에 `app.use('/api/{탭명}', ...)` 추가됨
|
||||||
- [ ] 마이그레이션 실행 확인: psql로 테이블 생성 및 검증 SELECT 통과
|
- [ ] 마이그레이션 실행 확인: psql로 테이블 생성 및 검증 SELECT 통과
|
||||||
- [ ] 커밋 + 푸시 + MR 생성
|
- [ ] 커밋 + 푸시 + MR 생성
|
||||||
|
|||||||
@ -66,7 +66,7 @@ wing/
|
|||||||
│ │ ├── utils/ cn, coordinates, geo, sanitize
|
│ │ ├── utils/ cn, coordinates, geo, sanitize
|
||||||
│ │ ├── styles/ base.css, components.css, wing.css (@layer)
|
│ │ ├── styles/ base.css, components.css, wing.css (@layer)
|
||||||
│ │ └── constants/ featureIds.ts (FEATURE_ID 상수 체계)
|
│ │ └── constants/ featureIds.ts (FEATURE_ID 상수 체계)
|
||||||
│ └── tabs/ @components/ alias (11개 탭)
|
│ └── tabs/ @tabs/ alias (11개 탭)
|
||||||
│ ├── prediction/ 유류 확산 예측
|
│ ├── prediction/ 유류 확산 예측
|
||||||
│ ├── hns/ HNS 분석
|
│ ├── hns/ HNS 분석
|
||||||
│ ├── rescue/ 구조 시나리오
|
│ ├── rescue/ 구조 시나리오
|
||||||
@ -103,7 +103,7 @@ wing/
|
|||||||
| Alias | 경로 |
|
| Alias | 경로 |
|
||||||
|-------|------|
|
|-------|------|
|
||||||
| `@common/*` | `src/common/*` |
|
| `@common/*` | `src/common/*` |
|
||||||
| `@components/*` | `src/components/*` |
|
| `@tabs/*` | `src/tabs/*` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -5,17 +5,12 @@
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
- HNS: AEGL 등농도선 표출 및 자동 줌·동적 도메인 기능 추가
|
|
||||||
- 사건사고: 통합 분석 패널 HNS/구난 연동 및 사고 목록을 wing.ACDNT 기반으로 전환
|
- 사건사고: 통합 분석 패널 HNS/구난 연동 및 사고 목록을 wing.ACDNT 기반으로 전환
|
||||||
- 사건사고: 통합 분석 패널 분할 뷰 및 이전 분석 결과 비교 표출 + 분석 선택 모달 추가
|
- 사건사고: 통합 분석 패널 분할 뷰 및 이전 분석 결과 비교 표출 + 분석 선택 모달 추가
|
||||||
- 확산예측: 유출유 확산 요약 API 신규 (`/analyses/:acdntSn/oil-summary`, primary + byModel)
|
- 확산예측: 유출유 확산 요약 API 신규 (`/analyses/:acdntSn/oil-summary`, primary + byModel)
|
||||||
- HNS: 분석 생성 시 `acdntSn` 연결 지원
|
- HNS: 분석 생성 시 `acdntSn` 연결 지원
|
||||||
- GSC: 사고 목록 응답에 `acdntSn` 노출 및 민감자원 누적 카테고리 관리 + HNS 확산 레이어 유틸 추가
|
- GSC: 사고 목록 응답에 `acdntSn` 노출 및 민감자원 누적 카테고리 관리 + HNS 확산 레이어 유틸 추가
|
||||||
|
|
||||||
### 변경
|
|
||||||
- 탭 디렉토리를 MPA 컴포넌트 구조로 재편 (src/tabs → src/components, src/interfaces, src/types)
|
|
||||||
- TimelineControl 분리 및 aerial/hns 컴포넌트 개선
|
|
||||||
|
|
||||||
## [2026-04-15]
|
## [2026-04-15]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
|
|||||||
311
frontend/package-lock.json
generated
311
frontend/package-lock.json
generated
@ -1945,9 +1945,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
|
||||||
"integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
|
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@ -1959,9 +1959,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
|
||||||
"integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
|
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1973,9 +1973,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
|
||||||
"integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
|
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1987,9 +1987,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
|
||||||
"integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
|
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -2001,9 +2001,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
|
||||||
"integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
|
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -2015,9 +2015,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
|
||||||
"integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
|
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -2029,9 +2029,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
|
||||||
"integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
|
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@ -2043,9 +2043,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
|
||||||
"integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
|
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@ -2057,9 +2057,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
|
||||||
"integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
|
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -2071,9 +2071,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
|
||||||
"integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
|
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -2085,9 +2085,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
|
||||||
"integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
|
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@ -2099,9 +2099,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
|
||||||
"integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
|
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@ -2113,9 +2113,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
|
||||||
"integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
|
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@ -2127,9 +2127,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
|
||||||
"integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
|
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@ -2141,9 +2141,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
|
||||||
"integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
|
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@ -2155,9 +2155,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
|
||||||
"integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
|
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@ -2169,9 +2169,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
|
||||||
"integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
|
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@ -2183,9 +2183,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
|
||||||
"integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
|
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -2197,9 +2197,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
|
||||||
"integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
|
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -2211,9 +2211,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
|
||||||
"integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
|
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -2225,9 +2225,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
|
||||||
"integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
|
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -2239,9 +2239,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
|
||||||
"integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
|
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -2253,9 +2253,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
|
||||||
"integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
|
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@ -2267,9 +2267,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
|
||||||
"integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
|
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -2281,9 +2281,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
|
||||||
"integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
|
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -2711,9 +2711,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
"version": "2.1.0",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -2721,13 +2721,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||||
"version": "9.0.9",
|
"version": "9.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^2.0.2"
|
"brace-expansion": "^2.0.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
@ -2873,9 +2873,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.14.0",
|
"version": "6.12.6",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -2927,9 +2927,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/anymatch/node_modules/picomatch": {
|
"node_modules/anymatch/node_modules/picomatch": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -3015,14 +3015,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.15.0",
|
"version": "1.13.5",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
||||||
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
|
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.11",
|
"follow-redirects": "^1.15.11",
|
||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
"proxy-from-env": "^2.1.0"
|
"proxy-from-env": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
@ -3077,9 +3077,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.14",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -3946,9 +3946,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-xml-parser": {
|
"node_modules/fast-xml-parser": {
|
||||||
"version": "4.5.6",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.4.tgz",
|
||||||
"integrity": "sha512-Yd4vkROfJf8AuJrDIVMVmYfULKmIJszVsMv7Vo71aocsKgFxpdlpSHXSaInvyYfgw2PRuObQSW2GFpVMUjxu9A==",
|
"integrity": "sha512-jE8ugADnYOBsu1uaoayVl1tVKAMNOXyjwvv2U6udEA2ORBhDooJDWoGxTkhd4Qn4yh59JVVt/pKXtjPwx9OguQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@ -4055,16 +4055,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
"version": "3.4.2",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
||||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.16.0",
|
"version": "1.15.11",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@ -4904,9 +4904,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/micromatch/node_modules/picomatch": {
|
"node_modules/micromatch/node_modules/picomatch": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -4938,9 +4938,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -5169,9 +5169,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -5409,13 +5409,10 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "2.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
@ -5572,9 +5569,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/readdirp/node_modules/picomatch": {
|
"node_modules/readdirp/node_modules/picomatch": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -5636,9 +5633,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
||||||
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
|
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -5652,31 +5649,31 @@
|
|||||||
"npm": ">=8.0.0"
|
"npm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-android-arm-eabi": "4.60.1",
|
"@rollup/rollup-android-arm-eabi": "4.57.1",
|
||||||
"@rollup/rollup-android-arm64": "4.60.1",
|
"@rollup/rollup-android-arm64": "4.57.1",
|
||||||
"@rollup/rollup-darwin-arm64": "4.60.1",
|
"@rollup/rollup-darwin-arm64": "4.57.1",
|
||||||
"@rollup/rollup-darwin-x64": "4.60.1",
|
"@rollup/rollup-darwin-x64": "4.57.1",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.60.1",
|
"@rollup/rollup-freebsd-arm64": "4.57.1",
|
||||||
"@rollup/rollup-freebsd-x64": "4.60.1",
|
"@rollup/rollup-freebsd-x64": "4.57.1",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.60.1",
|
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.60.1",
|
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.60.1",
|
"@rollup/rollup-linux-arm64-musl": "4.57.1",
|
||||||
"@rollup/rollup-linux-loong64-gnu": "4.60.1",
|
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
|
||||||
"@rollup/rollup-linux-loong64-musl": "4.60.1",
|
"@rollup/rollup-linux-loong64-musl": "4.57.1",
|
||||||
"@rollup/rollup-linux-ppc64-gnu": "4.60.1",
|
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
|
||||||
"@rollup/rollup-linux-ppc64-musl": "4.60.1",
|
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.60.1",
|
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.60.1",
|
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.60.1",
|
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.60.1",
|
"@rollup/rollup-linux-x64-gnu": "4.57.1",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.60.1",
|
"@rollup/rollup-linux-x64-musl": "4.57.1",
|
||||||
"@rollup/rollup-openbsd-x64": "4.60.1",
|
"@rollup/rollup-openbsd-x64": "4.57.1",
|
||||||
"@rollup/rollup-openharmony-arm64": "4.60.1",
|
"@rollup/rollup-openharmony-arm64": "4.57.1",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.60.1",
|
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.60.1",
|
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
|
||||||
"@rollup/rollup-win32-x64-gnu": "4.60.1",
|
"@rollup/rollup-win32-x64-gnu": "4.57.1",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.60.1",
|
"@rollup/rollup-win32-x64-msvc": "4.57.1",
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -5804,9 +5801,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/socket.io-parser": {
|
"node_modules/socket.io-parser": {
|
||||||
"version": "4.2.6",
|
"version": "4.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
|
||||||
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@socket.io/component-emitter": "~3.1.0",
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
@ -6288,9 +6285,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.2",
|
"version": "7.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@ -1,25 +1,25 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Routes, Route } from 'react-router-dom';
|
import { Routes, Route } from 'react-router-dom';
|
||||||
import { GoogleOAuthProvider } from '@react-oauth/google';
|
import { GoogleOAuthProvider } from '@react-oauth/google';
|
||||||
import type { MainTab } from '@/types/navigation';
|
import type { MainTab } from '@common/types/navigation';
|
||||||
import { MainLayout } from '@components/common/layout/MainLayout';
|
import { MainLayout } from '@common/components/layout/MainLayout';
|
||||||
import { LoginPage } from '@components/common/auth/LoginPage';
|
import { LoginPage } from '@common/components/auth/LoginPage';
|
||||||
import { registerMainTabSwitcher } from '@common/hooks/useSubMenu';
|
import { registerMainTabSwitcher } from '@common/hooks/useSubMenu';
|
||||||
import { useAuthStore } from '@common/store/authStore';
|
import { useAuthStore } from '@common/store/authStore';
|
||||||
import { useMenuStore } from '@common/store/menuStore';
|
import { useMenuStore } from '@common/store/menuStore';
|
||||||
import { useMapStore } from '@common/store/mapStore';
|
import { useMapStore } from '@common/store/mapStore';
|
||||||
import { API_BASE_URL } from '@common/services/api';
|
import { API_BASE_URL } from '@common/services/api';
|
||||||
import { OilSpillView } from '@components/prediction';
|
import { OilSpillView } from '@tabs/prediction';
|
||||||
import { ReportsView } from '@components/reports';
|
import { ReportsView } from '@tabs/reports';
|
||||||
import { HNSView } from '@components/hns';
|
import { HNSView } from '@tabs/hns';
|
||||||
import { AerialView } from '@components/aerial';
|
import { AerialView } from '@tabs/aerial';
|
||||||
import { AssetsView } from '@components/assets';
|
import { AssetsView } from '@tabs/assets';
|
||||||
import { BoardView } from '@components/board';
|
import { BoardView } from '@tabs/board';
|
||||||
import { WeatherView } from '@components/weather';
|
import { WeatherView } from '@tabs/weather';
|
||||||
import { IncidentsView } from '@components/incidents';
|
import { IncidentsView } from '@tabs/incidents';
|
||||||
import { AdminView } from '@components/admin';
|
import { AdminView } from '@tabs/admin';
|
||||||
import { ScatView } from '@components/scat';
|
import { ScatView } from '@tabs/scat';
|
||||||
import { RescueView } from '@components/rescue';
|
import { RescueView } from '@tabs/rescue';
|
||||||
import { DesignPage } from '@/pages/design/DesignPage';
|
import { DesignPage } from '@/pages/design/DesignPage';
|
||||||
|
|
||||||
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || '';
|
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || '';
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { GoogleLogin, type CredentialResponse } from '@react-oauth/google';
|
import { GoogleLogin, type CredentialResponse } from '@react-oauth/google';
|
||||||
import { useAuthStore } from '@common/store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
|
||||||
/* Demo accounts (개발 모드 전용) */
|
/* Demo accounts (개발 모드 전용) */
|
||||||
const DEMO_ACCOUNTS = [{ id: 'admin', password: 'admin1234', label: '관리자 (경정)' }];
|
const DEMO_ACCOUNTS = [{ id: 'admin', password: 'admin1234', label: '관리자 (경정)' }];
|
||||||
0
frontend/src/components/common/layer/LayerTree.tsx → frontend/src/common/components/layer/LayerTree.tsx
Normal file → Executable file
0
frontend/src/components/common/layer/LayerTree.tsx → frontend/src/common/components/layer/LayerTree.tsx
Normal file → Executable file
2
frontend/src/components/common/layout/MainLayout.tsx → frontend/src/common/components/layout/MainLayout.tsx
Normal file → Executable file
2
frontend/src/components/common/layout/MainLayout.tsx → frontend/src/common/components/layout/MainLayout.tsx
Normal file → Executable file
@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { MainTab } from '@/types/navigation';
|
import type { MainTab } from '../../types/navigation';
|
||||||
import { TopBar } from './TopBar';
|
import { TopBar } from './TopBar';
|
||||||
import { SubMenuBar } from './SubMenuBar';
|
import { SubMenuBar } from './SubMenuBar';
|
||||||
|
|
||||||
4
frontend/src/components/common/layout/SubMenuBar.tsx → frontend/src/common/components/layout/SubMenuBar.tsx
Normal file → Executable file
4
frontend/src/components/common/layout/SubMenuBar.tsx → frontend/src/common/components/layout/SubMenuBar.tsx
Normal file → Executable file
@ -1,5 +1,5 @@
|
|||||||
import type { MainTab } from '@/types/navigation';
|
import type { MainTab } from '../../types/navigation';
|
||||||
import { useSubMenu } from '@common/hooks/useSubMenu';
|
import { useSubMenu } from '../../hooks/useSubMenu';
|
||||||
|
|
||||||
interface SubMenuBarProps {
|
interface SubMenuBarProps {
|
||||||
activeMainTab: MainTab;
|
activeMainTab: MainTab;
|
||||||
11
frontend/src/components/common/layout/TopBar.tsx → frontend/src/common/components/layout/TopBar.tsx
Normal file → Executable file
11
frontend/src/components/common/layout/TopBar.tsx → frontend/src/common/components/layout/TopBar.tsx
Normal file → Executable file
@ -1,11 +1,10 @@
|
|||||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
import type { MainTab } from '@/types/navigation';
|
import type { MainTab } from '../../types/navigation';
|
||||||
import { useAuthStore } from '@common/store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
import { useMenuStore } from '@common/store/menuStore';
|
import { useMenuStore } from '../../store/menuStore';
|
||||||
import { useMapStore } from '@common/store/mapStore';
|
import { useMapStore } from '../../store/mapStore';
|
||||||
import { useThemeStore } from '@common/store/themeStore';
|
import { useThemeStore } from '../../store/themeStore';
|
||||||
import UserManualPopup from '../ui/UserManualPopup';
|
import UserManualPopup from '../ui/UserManualPopup';
|
||||||
/* eslint-disable react-refresh/only-export-components */
|
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
activeTab: MainTab;
|
activeTab: MainTab;
|
||||||
2
frontend/src/components/common/map/BacktrackReplayBar.tsx → frontend/src/common/components/map/BacktrackReplayBar.tsx
Normal file → Executable file
2
frontend/src/components/common/map/BacktrackReplayBar.tsx → frontend/src/common/components/map/BacktrackReplayBar.tsx
Normal file → Executable file
@ -1,5 +1,5 @@
|
|||||||
import { useRef, useEffect } from 'react';
|
import { useRef, useEffect } from 'react';
|
||||||
import type { ReplayShip, CollisionEvent } from '@/types/backtrack';
|
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack';
|
||||||
|
|
||||||
interface BacktrackReplayBarProps {
|
interface BacktrackReplayBarProps {
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
2
frontend/src/components/common/map/BacktrackReplayOverlay.tsx → frontend/src/common/components/map/BacktrackReplayOverlay.tsx
Normal file → Executable file
2
frontend/src/components/common/map/BacktrackReplayOverlay.tsx → frontend/src/common/components/map/BacktrackReplayOverlay.tsx
Normal file → Executable file
@ -4,7 +4,7 @@ import type {
|
|||||||
CollisionEvent,
|
CollisionEvent,
|
||||||
ReplayPathPoint,
|
ReplayPathPoint,
|
||||||
BackwardParticleStep,
|
BackwardParticleStep,
|
||||||
} from '@/types/backtrack';
|
} from '@common/types/backtrack';
|
||||||
import { hexToRgba } from './mapUtils';
|
import { hexToRgba } from './mapUtils';
|
||||||
|
|
||||||
// Andrew's monotone chain — 전체 파티클 경로의 외각 폴리곤 계산
|
// Andrew's monotone chain — 전체 파티클 경로의 외각 폴리곤 계산
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useMap } from '@vis.gl/react-maplibre';
|
import { useMap } from '@vis.gl/react-maplibre';
|
||||||
import type { HydrDataStep } from '@interfaces/prediction/PredictionInterface';
|
import type { HydrDataStep } from '@tabs/prediction/services/predictionApi';
|
||||||
|
|
||||||
interface HydrParticleOverlayProps {
|
interface HydrParticleOverlayProps {
|
||||||
hydrStep: HydrDataStep | null;
|
hydrStep: HydrDataStep | null;
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useMap } from '@vis.gl/react-maplibre';
|
import { useMap } from '@vis.gl/react-maplibre';
|
||||||
import type { MapBounds } from '@/types/vessel';
|
import type { MapBounds } from '@common/types/vessel';
|
||||||
|
|
||||||
interface MapBoundsTrackerProps {
|
interface MapBoundsTrackerProps {
|
||||||
onBoundsChange?: (bounds: MapBounds) => void;
|
onBoundsChange?: (bounds: MapBounds) => void;
|
||||||
183
frontend/src/components/common/map/MapView.tsx → frontend/src/common/components/map/MapView.tsx
Normal file → Executable file
183
frontend/src/components/common/map/MapView.tsx → frontend/src/common/components/map/MapView.tsx
Normal file → Executable file
@ -12,16 +12,14 @@ import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core';
|
|||||||
import type { MapLayerMouseEvent } from 'maplibre-gl';
|
import type { MapLayerMouseEvent } from 'maplibre-gl';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
import { layerDatabase } from '@common/services/layerService';
|
import { layerDatabase } from '@common/services/layerService';
|
||||||
import type { PredictionModel } from '@/types/prediction/PredictionType';
|
import type { PredictionModel, SensitiveResource } from '@tabs/prediction/components/OilSpillView';
|
||||||
import type { SensitiveResource } from '@interfaces/prediction/PredictionInterface';
|
|
||||||
import type {
|
import type {
|
||||||
HydrDataStep,
|
HydrDataStep,
|
||||||
SensitiveResourceFeatureCollection,
|
SensitiveResourceFeatureCollection,
|
||||||
} from '@components/prediction/services/predictionApi';
|
} from '@tabs/prediction/services/predictionApi';
|
||||||
import HydrParticleOverlay from './HydrParticleOverlay';
|
import HydrParticleOverlay from './HydrParticleOverlay';
|
||||||
import { TimelineControl } from './TimelineControl';
|
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine';
|
||||||
import type { BoomLine, BoomLineCoord } from '@/types/boomLine';
|
import type { ReplayShip, CollisionEvent, BackwardParticleStep } from '@common/types/backtrack';
|
||||||
import type { ReplayShip, CollisionEvent, BackwardParticleStep } from '@/types/backtrack';
|
|
||||||
import { createBacktrackLayers } from './BacktrackReplayOverlay';
|
import { createBacktrackLayers } from './BacktrackReplayOverlay';
|
||||||
import { buildMeasureLayers } from './measureLayers';
|
import { buildMeasureLayers } from './measureLayers';
|
||||||
import { MeasureOverlay } from './MeasureOverlay';
|
import { MeasureOverlay } from './MeasureOverlay';
|
||||||
@ -41,8 +39,7 @@ import {
|
|||||||
VesselDetailModal,
|
VesselDetailModal,
|
||||||
type VesselHoverInfo,
|
type VesselHoverInfo,
|
||||||
} from './VesselInteraction';
|
} from './VesselInteraction';
|
||||||
import type { VesselPosition, MapBounds } from '@/types/vessel';
|
import type { VesselPosition, MapBounds } from '@common/types/vessel';
|
||||||
/* eslint-disable react-refresh/only-export-components */
|
|
||||||
|
|
||||||
const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080';
|
const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080';
|
||||||
|
|
||||||
@ -112,20 +109,12 @@ interface DispersionZone {
|
|||||||
angle: number;
|
angle: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DispersionContour {
|
|
||||||
level: string;
|
|
||||||
threshold: number;
|
|
||||||
color: string;
|
|
||||||
segments: Array<[[number, number], [number, number]]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DispersionResult {
|
interface DispersionResult {
|
||||||
zones: DispersionZone[];
|
zones: DispersionZone[];
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
windDirection: number;
|
windDirection: number;
|
||||||
substance: string;
|
substance: string;
|
||||||
concentration: Record<string, string>;
|
concentration: Record<string, string>;
|
||||||
contours?: DispersionContour[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MapViewProps {
|
interface MapViewProps {
|
||||||
@ -191,7 +180,7 @@ interface MapViewProps {
|
|||||||
onBoundsChange?: (bounds: MapBounds) => void;
|
onBoundsChange?: (bounds: MapBounds) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeckGLOverlay, FlyToController → @components/common/map/DeckGLOverlay, FlyToController 에서 import
|
// DeckGLOverlay, FlyToController → @common/components/map/DeckGLOverlay, FlyToController 에서 import
|
||||||
|
|
||||||
// MapBoundsTracker는 './MapBoundsTracker' 모듈로 추출됨 (IncidentsView 등 BaseMap 직접 사용처에서도 재사용)
|
// MapBoundsTracker는 './MapBoundsTracker' 모듈로 추출됨 (IncidentsView 등 BaseMap 직접 사용처에서도 재사용)
|
||||||
|
|
||||||
@ -810,9 +799,9 @@ export function MapView({
|
|||||||
if (dispersionHeatmap && dispersionHeatmap.length > 0) {
|
if (dispersionHeatmap && dispersionHeatmap.length > 0) {
|
||||||
const maxConc = Math.max(...dispersionHeatmap.map((p) => p.concentration));
|
const maxConc = Math.max(...dispersionHeatmap.map((p) => p.concentration));
|
||||||
const minConc = Math.min(
|
const minConc = Math.min(
|
||||||
...dispersionHeatmap.filter((p) => p.concentration > 0.001).map((p) => p.concentration),
|
...dispersionHeatmap.filter((p) => p.concentration > 0.01).map((p) => p.concentration),
|
||||||
);
|
);
|
||||||
const filtered = dispersionHeatmap.filter((p) => p.concentration > 0.001);
|
const filtered = dispersionHeatmap.filter((p) => p.concentration > 0.01);
|
||||||
console.log(
|
console.log(
|
||||||
'[MapView] HNS 히트맵:',
|
'[MapView] HNS 히트맵:',
|
||||||
dispersionHeatmap.length,
|
dispersionHeatmap.length,
|
||||||
@ -880,7 +869,7 @@ export function MapView({
|
|||||||
|
|
||||||
ctx.fillStyle = `rgba(${r},${g},${b},${a.toFixed(2)})`;
|
ctx.fillStyle = `rgba(${r},${g},${b},${a.toFixed(2)})`;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(px, py, 12, 0, Math.PI * 2);
|
ctx.arc(px, py, 6, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -900,15 +889,11 @@ export function MapView({
|
|||||||
|
|
||||||
// --- HNS 대기확산 구역 (ScatterplotLayer, meters 단위) ---
|
// --- HNS 대기확산 구역 (ScatterplotLayer, meters 단위) ---
|
||||||
if (dispersionResult && incidentCoord) {
|
if (dispersionResult && incidentCoord) {
|
||||||
// contour가 있으면 동심원 fill은 희미하게(contour가 실제 경계 표시), 없으면 진하게
|
|
||||||
const hasContours = !!(dispersionResult.contours && dispersionResult.contours.length > 0);
|
|
||||||
const zoneFillAlpha = hasContours ? 40 : 100;
|
|
||||||
const zoneLineAlpha = hasContours ? 80 : 180;
|
|
||||||
const zones = dispersionResult.zones.map((zone, idx) => ({
|
const zones = dispersionResult.zones.map((zone, idx) => ({
|
||||||
position: [incidentCoord.lon, incidentCoord.lat] as [number, number],
|
position: [incidentCoord.lon, incidentCoord.lat] as [number, number],
|
||||||
radius: zone.radius,
|
radius: zone.radius,
|
||||||
fillColor: hexToRgba(zone.color, zoneFillAlpha),
|
fillColor: hexToRgba(zone.color, 100),
|
||||||
lineColor: hexToRgba(zone.color, zoneLineAlpha),
|
lineColor: hexToRgba(zone.color, 180),
|
||||||
level: zone.level,
|
level: zone.level,
|
||||||
idx,
|
idx,
|
||||||
}));
|
}));
|
||||||
@ -988,27 +973,6 @@ export function MapView({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- HNS AEGL 등농도선 (PathLayer) ---
|
|
||||||
if (dispersionResult.contours) {
|
|
||||||
dispersionResult.contours.forEach((contour, cIdx) => {
|
|
||||||
if (contour.segments.length === 0) return;
|
|
||||||
const color = hexToRgba(contour.color, 230);
|
|
||||||
result.push(
|
|
||||||
new PathLayer({
|
|
||||||
id: `hns-contour-${cIdx}-${contour.level}`,
|
|
||||||
data: contour.segments,
|
|
||||||
getPath: (d: [[number, number], [number, number]]) => d,
|
|
||||||
getColor: color,
|
|
||||||
getWidth: 3,
|
|
||||||
widthUnits: 'pixels' as const,
|
|
||||||
capRounded: true,
|
|
||||||
jointRounded: true,
|
|
||||||
pickable: false,
|
|
||||||
}) as unknown as DeckLayer,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 역추적 리플레이 ---
|
// --- 역추적 리플레이 ---
|
||||||
@ -1495,7 +1459,6 @@ export function MapView({
|
|||||||
onTimeChange={setInternalCurrentTime}
|
onTimeChange={setInternalCurrentTime}
|
||||||
onPlayPause={() => setIsPlaying(!isPlaying)}
|
onPlayPause={() => setIsPlaying(!isPlaying)}
|
||||||
onSpeedChange={setPlaybackSpeed}
|
onSpeedChange={setPlaybackSpeed}
|
||||||
simulationStartTime={simulationStartTime}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -1775,6 +1738,130 @@ function CoordinateDisplay({ position, zoom }: { position: [number, number]; zoo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 타임라인 컨트롤
|
||||||
|
interface TimelineControlProps {
|
||||||
|
currentTime: number;
|
||||||
|
maxTime: number;
|
||||||
|
isPlaying: boolean;
|
||||||
|
playbackSpeed: number;
|
||||||
|
onTimeChange: (time: number) => void;
|
||||||
|
onPlayPause: () => void;
|
||||||
|
onSpeedChange: (speed: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineControl({
|
||||||
|
currentTime,
|
||||||
|
maxTime,
|
||||||
|
isPlaying,
|
||||||
|
playbackSpeed,
|
||||||
|
onTimeChange,
|
||||||
|
onPlayPause,
|
||||||
|
onSpeedChange,
|
||||||
|
}: TimelineControlProps) {
|
||||||
|
const progressPercent = (currentTime / maxTime) * 100;
|
||||||
|
|
||||||
|
const handleRewind = () => onTimeChange(Math.max(0, currentTime - 6));
|
||||||
|
const handleForward = () => onTimeChange(Math.min(maxTime, currentTime + 6));
|
||||||
|
const handleStart = () => onTimeChange(0);
|
||||||
|
const handleEnd = () => onTimeChange(maxTime);
|
||||||
|
|
||||||
|
const toggleSpeed = () => {
|
||||||
|
const speeds = [1, 2, 4];
|
||||||
|
const currentIndex = speeds.indexOf(playbackSpeed);
|
||||||
|
onSpeedChange(speeds[(currentIndex + 1) % speeds.length]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimelineClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const percent = (e.clientX - rect.left) / rect.width;
|
||||||
|
onTimeChange(Math.max(0, Math.min(maxTime, Math.round(percent * maxTime))));
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeLabels = [];
|
||||||
|
for (let t = 0; t <= maxTime; t += 6) {
|
||||||
|
timeLabels.push(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tlb">
|
||||||
|
<div className="tlc">
|
||||||
|
<div className="tb" onClick={handleStart}>
|
||||||
|
⏮
|
||||||
|
</div>
|
||||||
|
<div className="tb" onClick={handleRewind}>
|
||||||
|
◀
|
||||||
|
</div>
|
||||||
|
<div className={`tb ${isPlaying ? 'on' : ''}`} onClick={onPlayPause}>
|
||||||
|
{isPlaying ? '⏸' : '▶'}
|
||||||
|
</div>
|
||||||
|
<div className="tb" onClick={handleForward}>
|
||||||
|
▶▶
|
||||||
|
</div>
|
||||||
|
<div className="tb" onClick={handleEnd}>
|
||||||
|
⏭
|
||||||
|
</div>
|
||||||
|
<div className="w-2" />
|
||||||
|
<div className="tb" onClick={toggleSpeed}>
|
||||||
|
{playbackSpeed}×
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="tlt">
|
||||||
|
<div className="tlls">
|
||||||
|
{timeLabels.map((t) => (
|
||||||
|
<span
|
||||||
|
key={t}
|
||||||
|
className={`tll ${Math.abs(currentTime - t) < 1 ? 'on' : ''}`}
|
||||||
|
style={{ left: `${(t / maxTime) * 100}%` }}
|
||||||
|
>
|
||||||
|
{t}h
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="tlsw" onClick={handleTimelineClick}>
|
||||||
|
<div className="tlr">
|
||||||
|
<div className="tlp" style={{ width: `${progressPercent}%` }} />
|
||||||
|
{timeLabels.map((t) => (
|
||||||
|
<div
|
||||||
|
key={`marker-${t}`}
|
||||||
|
className={`tlm ${t % 12 === 0 ? 'mj' : ''}`}
|
||||||
|
style={{ left: `${(t / maxTime) * 100}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="tlth" style={{ left: `${progressPercent}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="tli">
|
||||||
|
{/* eslint-disable-next-line react-hooks/purity */}
|
||||||
|
<div className="tlct">
|
||||||
|
+{currentTime.toFixed(0)}h —{' '}
|
||||||
|
{(() => {
|
||||||
|
const base = simulationStartTime ? new Date(simulationStartTime) : new Date();
|
||||||
|
const d = new Date(base.getTime() + currentTime * 3600 * 1000);
|
||||||
|
return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')} KST`;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<div className="tlss">
|
||||||
|
<div className="tls">
|
||||||
|
<span className="tlsl">진행률</span>
|
||||||
|
<span className="tlsv">{progressPercent.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="tls">
|
||||||
|
<span className="tlsl">속도</span>
|
||||||
|
<span className="tlsv">{playbackSpeed}×</span>
|
||||||
|
</div>
|
||||||
|
<div className="tls">
|
||||||
|
<span className="tlsl">시간</span>
|
||||||
|
<span className="tlsv">
|
||||||
|
{currentTime.toFixed(0)}/{maxTime}h
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 기상 데이터 Mock
|
// 기상 데이터 Mock
|
||||||
function getWeatherData(position: [number, number]) {
|
function getWeatherData(position: [number, number]) {
|
||||||
const [lat, lng] = position;
|
const [lat, lng] = position;
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Marker } from '@vis.gl/react-maplibre';
|
import { Marker } from '@vis.gl/react-maplibre';
|
||||||
import { useMapStore } from '@common/store/mapStore';
|
import { useMapStore } from '../../store/mapStore';
|
||||||
import { midpointOf, centroid } from './measureLayers';
|
import { midpointOf, centroid } from './measureLayers';
|
||||||
|
|
||||||
/** 완료된 측정 결과의 지우기 버튼을 Marker로 렌더 */
|
/** 완료된 측정 결과의 지우기 버튼을 Marker로 렌더 */
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useMap } from '@vis.gl/react-maplibre';
|
import { useMap } from '@vis.gl/react-maplibre';
|
||||||
import { API_BASE_URL } from '@common/services/api';
|
import { API_BASE_URL } from '../../services/api';
|
||||||
/* eslint-disable react-refresh/only-export-components */
|
|
||||||
|
|
||||||
const PROXY_PREFIX = `${API_BASE_URL}/tiles/enc`;
|
const PROXY_PREFIX = `${API_BASE_URL}/tiles/enc`;
|
||||||
// MapLibre 내부 요청(sprite, tiles, glyphs)은 절대 URL이 필요
|
// MapLibre 내부 요청(sprite, tiles, glyphs)은 절대 URL이 필요
|
||||||
@ -1,10 +1,9 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
import { useMap } from '@vis.gl/react-maplibre';
|
import { useMap } from '@vis.gl/react-maplibre';
|
||||||
import { API_BASE_URL } from '@common/services/api';
|
import { API_BASE_URL } from '../../services/api';
|
||||||
import { useLayerTree } from '@common/hooks/useLayers';
|
import { useLayerTree } from '../../hooks/useLayers';
|
||||||
import type { Layer } from '@common/services/layerService';
|
import type { Layer } from '../../services/layerService';
|
||||||
import { getOpacityProp, getColorProp } from './srStyles';
|
import { getOpacityProp, getColorProp } from './srStyles';
|
||||||
/* eslint-disable react-refresh/only-export-components */
|
|
||||||
|
|
||||||
const SR_SOURCE_ID = 'sr';
|
const SR_SOURCE_ID = 'sr';
|
||||||
const PROXY_PREFIX = `${API_BASE_URL}/tiles`;
|
const PROXY_PREFIX = `${API_BASE_URL}/tiles`;
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { VesselPosition } from '@/types/vessel';
|
import type { VesselPosition } from '@common/types/vessel';
|
||||||
import { getShipKindLabel } from './VesselLayer';
|
import { getShipKindLabel } from './VesselLayer';
|
||||||
|
|
||||||
export interface VesselHoverInfo {
|
export interface VesselHoverInfo {
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { IconLayer, TextLayer } from '@deck.gl/layers';
|
import { IconLayer, TextLayer } from '@deck.gl/layers';
|
||||||
import type { VesselPosition } from '@/types/vessel';
|
import type { VesselPosition } from '@common/types/vessel';
|
||||||
|
|
||||||
export interface VesselLegendItem {
|
export interface VesselLegendItem {
|
||||||
code: string;
|
code: string;
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { ScatterplotLayer, PathLayer, TextLayer, PolygonLayer } from '@deck.gl/layers';
|
import { ScatterplotLayer, PathLayer, TextLayer, PolygonLayer } from '@deck.gl/layers';
|
||||||
import type { Layer as DeckLayer } from '@deck.gl/core';
|
import type { Layer as DeckLayer } from '@deck.gl/core';
|
||||||
import type { MeasurePoint, MeasureResult } from '@common/store/mapStore';
|
import type { MeasurePoint, MeasureResult } from '../../store/mapStore';
|
||||||
import { formatDistance, formatArea } from '@common/utils/geo';
|
import { formatDistance, formatArea } from '../../utils/geo';
|
||||||
|
|
||||||
const CYAN = [6, 182, 212, 220] as const;
|
const CYAN = [6, 182, 212, 220] as const;
|
||||||
const CYAN_FILL = [6, 182, 212, 60] as const;
|
const CYAN_FILL = [6, 182, 212, 60] as const;
|
||||||
0
frontend/src/components/common/ui/ComboBox.tsx → frontend/src/common/components/ui/ComboBox.tsx
Normal file → Executable file
0
frontend/src/components/common/ui/ComboBox.tsx → frontend/src/common/components/ui/ComboBox.tsx
Normal file → Executable file
1623
frontend/src/common/components/ui/UserManualPopup.tsx
Normal file
1623
frontend/src/common/components/ui/UserManualPopup.tsx
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -1,30 +0,0 @@
|
|||||||
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 type { StyleSpecification } from 'maplibre-gl';
|
||||||
import { useMapStore } from '@common/store/mapStore';
|
import { useMapStore } from '@common/store/mapStore';
|
||||||
import { LIGHT_STYLE, SATELLITE_3D_STYLE, ENC_EMPTY_STYLE } from '@components/common/map/mapStyles';
|
import { LIGHT_STYLE, SATELLITE_3D_STYLE, ENC_EMPTY_STYLE } from '@common/components/map/mapStyles';
|
||||||
|
|
||||||
export function useBaseMapStyle(): StyleSpecification {
|
export function useBaseMapStyle(): StyleSpecification {
|
||||||
const mapToggles = useMapStore((s) => s.mapToggles);
|
const mapToggles = useMapStore((s) => s.mapToggles);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useSyncExternalStore } from 'react';
|
import { useEffect, useSyncExternalStore } from 'react';
|
||||||
import type { MainTab } from '@/types/navigation';
|
import type { MainTab } from '../types/navigation';
|
||||||
import { useAuthStore } from '@common/store/authStore';
|
import { useAuthStore } from '@common/store/authStore';
|
||||||
import { API_BASE_URL } from '@common/services/api';
|
import { API_BASE_URL } from '@common/services/api';
|
||||||
|
|
||||||
@ -61,7 +61,6 @@ const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
|
|||||||
{ id: 'manual', label: '해경매뉴얼', icon: '📘' },
|
{ id: 'manual', label: '해경매뉴얼', icon: '📘' },
|
||||||
],
|
],
|
||||||
weather: null,
|
weather: null,
|
||||||
monitor: null,
|
|
||||||
admin: null, // 관리자 화면은 자체 사이드바 사용 (AdminSidebar.tsx)
|
admin: null, // 관리자 화면은 자체 사이드바 사용 (AdminSidebar.tsx)
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -77,7 +76,6 @@ const subMenuState: Record<MainTab, string> = {
|
|||||||
incidents: '',
|
incidents: '',
|
||||||
board: 'all',
|
board: 'all',
|
||||||
weather: '',
|
weather: '',
|
||||||
monitor: '',
|
|
||||||
admin: 'users',
|
admin: 'users',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
getInitialVesselSnapshot,
|
getInitialVesselSnapshot,
|
||||||
isVesselInitEnabled,
|
isVesselInitEnabled,
|
||||||
} from '@common/services/vesselApi';
|
} from '@common/services/vesselApi';
|
||||||
import type { VesselPosition, MapBounds } from '@/types/vessel';
|
import type { VesselPosition, MapBounds } from '@common/types/vessel';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 선박 신호 실시간 수신 훅
|
* 선박 신호 실시간 수신 훅
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// Deprecated: Mock 선박 데이터는 제거되었습니다.
|
// Deprecated: Mock 선박 데이터는 제거되었습니다.
|
||||||
// 실제 선박 신호는 @common/hooks/useVesselSignals + @components/common/map/VesselLayer 를 사용합니다.
|
// 실제 선박 신호는 @common/hooks/useVesselSignals + @common/components/map/VesselLayer 를 사용합니다.
|
||||||
// 범례는 @components/common/map/VesselLayer 의 VESSEL_LEGEND 를 import 하세요.
|
// 범례는 @common/components/map/VesselLayer 의 VESSEL_LEGEND 를 import 하세요.
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
import type { VesselPosition, MapBounds } from '@/types/vessel';
|
import type { VesselPosition, MapBounds } from '@common/types/vessel';
|
||||||
|
|
||||||
export async function getVesselsInArea(bounds: MapBounds): Promise<VesselPosition[]> {
|
export async function getVesselsInArea(bounds: MapBounds): Promise<VesselPosition[]> {
|
||||||
const res = await api.post<VesselPosition[]>('/vessels/in-area', { bounds });
|
const res = await api.post<VesselPosition[]>('/vessels/in-area', { bounds });
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { VesselPosition, MapBounds } from '@/types/vessel';
|
import type { VesselPosition, MapBounds } from '@common/types/vessel';
|
||||||
import { getVesselsInArea } from './vesselApi';
|
import { getVesselsInArea } from './vesselApi';
|
||||||
|
|
||||||
export interface VesselSignalClient {
|
export interface VesselSignalClient {
|
||||||
|
|||||||
@ -1,7 +1,44 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { WeatherSnapshot } from '@interfaces/weather/WeatherInterface';
|
|
||||||
|
|
||||||
export type { WeatherSnapshot };
|
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;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
interface WeatherSnapshotStore {
|
interface WeatherSnapshotStore {
|
||||||
snapshot: WeatherSnapshot | null;
|
snapshot: WeatherSnapshot | null;
|
||||||
|
|||||||
@ -325,7 +325,7 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 5px 4px;
|
padding: 5px 4px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.6875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -360,7 +360,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 0.8125rem;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: none;
|
border: none;
|
||||||
@ -386,7 +386,7 @@
|
|||||||
border: 1px solid rgba(6, 182, 212, 0.2);
|
border: 1px solid rgba(6, 182, 212, 0.2);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
font-size: 0.75rem;
|
font-size: 0.6875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@ -411,7 +411,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: 0.8125rem;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
background: rgba(15, 21, 36, 0.75);
|
background: rgba(15, 21, 36, 0.75);
|
||||||
@ -450,7 +450,7 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 5px 14px;
|
padding: 5px 14px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.75rem;
|
font-size: 0.6875rem;
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
@ -491,7 +491,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wii-value {
|
.wii-value {
|
||||||
font-size: 0.75rem;
|
font-size: 0.6875rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@ -538,7 +538,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1rem;
|
font-size: 14px;
|
||||||
transition: 0.2s;
|
transition: 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -621,7 +621,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: -18px;
|
top: -18px;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
font-size: 0.8125rem;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
filter: drop-shadow(0 0 4px rgba(245, 158, 11, 0.5));
|
filter: drop-shadow(0 0 4px rgba(245, 158, 11, 0.5));
|
||||||
}
|
}
|
||||||
@ -672,7 +672,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tlct {
|
.tlct {
|
||||||
font-size: 1rem;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@ -841,7 +841,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.layer-icon {
|
.layer-icon {
|
||||||
font-size: 1rem;
|
font-size: 14px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1115,7 +1115,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
font-size: 0.8125rem;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--fg-default);
|
color: var(--fg-default);
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
@ -1345,7 +1345,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lyr-ccustom label {
|
.lyr-ccustom label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.6875rem;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
}
|
}
|
||||||
@ -1369,7 +1369,7 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
.lyr-style-label {
|
.lyr-style-label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.6875rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
@ -1411,7 +1411,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.lyr-style-val {
|
.lyr-style-val {
|
||||||
font-size: 0.75rem;
|
font-size: 0.6875rem;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
min-width: 28px;
|
min-width: 28px;
|
||||||
|
|||||||
0
frontend/src/types/backtrack.ts → frontend/src/common/types/backtrack.ts
Normal file → Executable file
0
frontend/src/types/backtrack.ts → frontend/src/common/types/backtrack.ts
Normal file → Executable file
0
frontend/src/types/boomLine.ts → frontend/src/common/types/boomLine.ts
Normal file → Executable file
0
frontend/src/types/boomLine.ts → frontend/src/common/types/boomLine.ts
Normal file → Executable file
67
frontend/src/common/types/hns.ts
Normal file
67
frontend/src/common/types/hns.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
/* 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,
|
BoomLineCoord,
|
||||||
AlgorithmSettings,
|
AlgorithmSettings,
|
||||||
ContainmentResult,
|
ContainmentResult,
|
||||||
} from '@/types/boomLine';
|
} from '../types/boomLine';
|
||||||
|
|
||||||
const DEG2RAD = Math.PI / 180;
|
const DEG2RAD = Math.PI / 180;
|
||||||
const RAD2DEG = 180 / Math.PI;
|
const RAD2DEG = 180 / Math.PI;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { ImageAnalyzeResult } from '@interfaces/prediction/PredictionInterface';
|
import type { ImageAnalyzeResult } from '@tabs/prediction/services/predictionApi';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 항공탐색(유출유면적분석) → 유출유 확산예측 탭 간 데이터 전달용 모듈 레벨 시그널.
|
* 항공탐색(유출유면적분석) → 유출유 확산예측 탭 간 데이터 전달용 모듈 레벨 시그널.
|
||||||
|
|||||||
@ -1,567 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,417 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,563 +0,0 @@
|
|||||||
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,212 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,765 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,171 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,367 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,236 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,228 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,312 +0,0 @@
|
|||||||
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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,120 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,160 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,196 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
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}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,293 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,336 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,800 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,601 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,360 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,130 +0,0 @@
|
|||||||
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">< 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">>200μm</div>
|
|
||||||
<div className="text-fg-disabled">농축층</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,256 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,128 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,144 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,361 +0,0 @@
|
|||||||
export function PanelReferences() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* 섹션 헤더 */}
|
|
||||||
<div className="flex items-center gap-[10px] mb-4">
|
|
||||||
<div
|
|
||||||
className="w-9 h-9 rounded-[9px] flex items-center justify-center text-[18px]"
|
|
||||||
style={{
|
|
||||||
background: 'color-mix(in srgb, var(--color-caution) 20%, transparent)',
|
|
||||||
border: '1px solid color-mix(in srgb, var(--color-caution) 30%, transparent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
📜
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-label-1 font-bold text-fg font-korean">
|
|
||||||
등록특허 원문 기반 이론 근거
|
|
||||||
</div>
|
|
||||||
<div className="text-caption text-fg-disabled font-korean mt-[1px]">
|
|
||||||
WING 탑재 유출유 확산예측 시스템의 특허 원전 2건 전체 분석
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 특허 1: 10-1567431 */}
|
|
||||||
<div className="rounded-xl p-[18px] mb-4 bg-bg-card border border-stroke">
|
|
||||||
<div className="flex items-start gap-[14px] mb-[14px]">
|
|
||||||
<div
|
|
||||||
className="px-3 py-2 rounded-lg text-center whitespace-nowrap flex-shrink-0"
|
|
||||||
style={{
|
|
||||||
background: 'color-mix(in srgb, var(--color-info) 12%, transparent)',
|
|
||||||
border: '1px solid color-mix(in srgb, var(--color-info) 30%, transparent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="text-caption text-fg-disabled font-korean">대한민국 등록특허</div>
|
|
||||||
<div className="text-label-1 font-extrabold font-mono text-color-info">10-1567431</div>
|
|
||||||
<div className="text-caption text-fg-disabled font-korean mt-[2px]">
|
|
||||||
등록: 2015.11.03
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-label-1 font-bold text-fg font-korean mb-[5px] leading-[1.5]">
|
|
||||||
해양 유류오염사고 발생시 효율적인 방제방안 수립을 위한 유출유 확산 예측 방법
|
|
||||||
</div>
|
|
||||||
<div className="text-caption text-fg-sub font-korean leading-[1.8]">
|
|
||||||
<span>특허권자</span> : 한국해양과학기술원 | <span>발명자</span> : 이문진 · 김혜진 ·
|
|
||||||
이승현 · 전태병
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 청구항 1 */}
|
|
||||||
<div
|
|
||||||
className="bg-bg-base rounded-lg p-3 mb-3"
|
|
||||||
style={{ border: '1px solid color-mix(in srgb, var(--color-accent) 15%, transparent)' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="text-caption font-korean mb-[9px]"
|
|
||||||
// style={{ color: 'var(--color-accent)' }}
|
|
||||||
>
|
|
||||||
청구항 1 — ESI 기반 실시간 확산 3단계
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-[6px] text-caption font-korean">
|
|
||||||
<div className="flex gap-[10px] items-start">
|
|
||||||
<div
|
|
||||||
className="min-w-[32px] h-[22px] rounded-[5px] flex items-center justify-center text-caption flex-shrink-0"
|
|
||||||
style={{
|
|
||||||
// background: 'color-mix(in srgb, var(--color-accent) 15%, transparent)',
|
|
||||||
border: '1px solid color-mix(in srgb, var(--color-accent) 30%, transparent)',
|
|
||||||
// color: 'var(--color-accent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
S10
|
|
||||||
</div>
|
|
||||||
<div className="text-fg-sub leading-[1.7]">
|
|
||||||
<span className="text-fg">실시간 자료 수신</span> —
|
|
||||||
기상예측시스템·위성영상수신시스템·검조소 인터넷 연결. FTP로
|
|
||||||
기상자료+수온자료(NGSST)+조석정보 자동 수신
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-[10px] items-start">
|
|
||||||
<div
|
|
||||||
className="min-w-[32px] h-[22px] rounded-[5px] flex items-center justify-center text-caption flex-shrink-0"
|
|
||||||
style={{
|
|
||||||
// background: 'color-mix(in srgb, var(--color-accent) 15%, transparent)',
|
|
||||||
border: '1px solid color-mix(in srgb, var(--color-accent) 30%, transparent)',
|
|
||||||
// color: 'var(--color-accent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
S20
|
|
||||||
</div>
|
|
||||||
<div className="text-fg-sub leading-[1.7]">
|
|
||||||
<span className="text-fg">조류·취송류 예측</span> — CHARRY모델 조화분석 + 취송류
|
|
||||||
경험식(0.029×Vw, θw+18.6°)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-[10px] items-start">
|
|
||||||
<div
|
|
||||||
className="min-w-[32px] h-[22px] rounded-[5px] flex items-center justify-center text-caption flex-shrink-0"
|
|
||||||
style={{
|
|
||||||
// background: 'color-mix(in srgb, var(--color-accent) 15%, transparent)',
|
|
||||||
border: '1px solid color-mix(in srgb, var(--color-accent) 30%, transparent)',
|
|
||||||
// color: 'var(--color-accent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
S30
|
|
||||||
</div>
|
|
||||||
<div className="text-fg-sub leading-[1.7]">
|
|
||||||
<span className="text-fg">유출유 확산 실시간 예측</span> — Monte Carlo 입자추적 +
|
|
||||||
fBm 난류확산 + 풍화 5단계 → ESI 방제정보지도 기반 방제방안 수립
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* fBm 난류확산 */}
|
|
||||||
<div
|
|
||||||
className="bg-bg-base rounded-lg p-[11px] mb-[10px]"
|
|
||||||
style={{ border: '1px solid color-mix(in srgb, var(--color-success) 15%, transparent)' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="text-caption font-korean mb-[7px]"
|
|
||||||
// style={{ color: 'var(--color-accent)' }}
|
|
||||||
>
|
|
||||||
fBm 난류확산 |{' '}
|
|
||||||
<span className="text-fg-sub font-normal">σ²(t) = A·t^m, m=0.45~2.46</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-caption text-fg-sub font-korean leading-[1.7]">
|
|
||||||
분수 브라운운동(fBm) 기반 무작위 확산거리 생성. 등방성(isotropic) 확산 가정.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 풍화 5단계 */}
|
|
||||||
<div
|
|
||||||
className="bg-bg-base rounded-lg p-3"
|
|
||||||
// style={{ border: '1px solid color-mix(in srgb, var(--color-warning) 15%, transparent)' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="text-caption font-korean mb-[9px]"
|
|
||||||
// style={{ color: 'var(--color-accent)' }}
|
|
||||||
>
|
|
||||||
유출유 풍화(Weathering) 5단계
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 text-caption font-korean">
|
|
||||||
<div className="grid gap-2 px-2 py-[5px]" style={{ gridTemplateColumns: '60px 1fr' }}>
|
|
||||||
<div className="text-fg-sub">① 퍼짐</div>
|
|
||||||
<div className="text-fg-sub leading-[1.6]">
|
|
||||||
Fay(1969): 중력-관성력. Mackay et al.(1980) 표면장력-점성력
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2 px-2 py-[5px]" style={{ gridTemplateColumns: '60px 1fr' }}>
|
|
||||||
<div className="text-fg-sub">② 증발</div>
|
|
||||||
<div className="text-fg-sub leading-[1.6]">
|
|
||||||
Stiver & Mackay(1984) 해석적 방법. 수일~10일간 약 25%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2 px-2 py-[5px]" style={{ gridTemplateColumns: '60px 1fr' }}>
|
|
||||||
<div className="text-fg-sub">③ 소산</div>
|
|
||||||
<div className="text-fg-sub leading-[1.6]">
|
|
||||||
쇄파 기인. 파도에너지·풍속 함수. 전체 약 15%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2 px-2 py-[5px]" style={{ gridTemplateColumns: '60px 1fr' }}>
|
|
||||||
<div className="text-fg-sub">④ 유상화</div>
|
|
||||||
<div className="text-fg-sub leading-[1.6]">
|
|
||||||
Water-in-oil. Mackay et al.(1980) 풍속·수분 함수
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2 px-2 py-[5px]" style={{ gridTemplateColumns: '60px 1fr' }}>
|
|
||||||
<div className="text-fg-sub">⑤ 침강</div>
|
|
||||||
<div className="text-fg-sub leading-[1.6]">
|
|
||||||
용해·미생물 분해. 질량 손실률 = 초기 누유량에 선형 비례
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 특허 2: 10-1868791 */}
|
|
||||||
<div className="rounded-xl p-[18px] mb-4 bg-bg-card border border-stroke">
|
|
||||||
<div className="flex items-start gap-[14px] mb-[14px]">
|
|
||||||
<div
|
|
||||||
className="px-3 py-2 rounded-lg text-center whitespace-nowrap flex-shrink-0"
|
|
||||||
style={{
|
|
||||||
background: 'color-mix(in srgb, var(--color-info) 12%, transparent)',
|
|
||||||
border: '1px solid color-mix(in srgb, var(--color-info) 30%, transparent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="text-caption text-fg-disabled font-korean">대한민국 등록특허</div>
|
|
||||||
<div className="text-label-1 font-extrabold font-mono text-color-info">10-1868791</div>
|
|
||||||
<div className="text-caption text-fg-disabled font-korean mt-[2px]">
|
|
||||||
등록: 2018.06.12
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-label-1 font-bold text-fg font-korean mb-[5px] leading-[1.5]">
|
|
||||||
유출유(Oil spill) 확산 예측을 위한 입자 추적 모듈 최적화 방법 및 이를 이용한 예측
|
|
||||||
시스템
|
|
||||||
</div>
|
|
||||||
<div className="text-caption text-fg-sub font-korean leading-[1.8]">
|
|
||||||
<span>특허권자</span> : 주식회사 아라종합기술 | <span>발명자</span> :
|
|
||||||
김도연·김용혁·김충기·김성은·박상훈·오정환
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 최적화 5단계 */}
|
|
||||||
<div
|
|
||||||
className="bg-bg-base rounded-lg p-[11px] mb-[10px]"
|
|
||||||
style={{ border: '1px solid color-mix(in srgb, var(--color-info) 15%, transparent)' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="text-caption font-bold font-korean mb-[9px]"
|
|
||||||
style={{ color: 'var(--color-info)' }}
|
|
||||||
>
|
|
||||||
⚙️ 입자 추적 모듈 최적화 5단계
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 text-caption font-korean">
|
|
||||||
<div className="flex gap-[7px] px-2 py-[5px]">
|
|
||||||
<span className="min-w-[20px] text-fg-sub">(a)</span>
|
|
||||||
<div className="text-fg-sub leading-[1.6]">
|
|
||||||
뜰개 관측 + 예측자료 취득 : GPS 뜰개 투하 → 실제 이동경로 + 예측 기상·해양자료 취득
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-[7px] px-2 py-[5px]">
|
|
||||||
<span className="min-w-[20px] text-fg-sub">(b)</span>
|
|
||||||
<div className="text-fg-sub leading-[1.6]">
|
|
||||||
제1 입자 추적 모델 실행 : 예측자료 + 확산계수 → 제1 예측변화량(ΔModel) 산출
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-[7px] px-2 py-[5px]">
|
|
||||||
<span className="min-w-[20px] text-fg-sub">(c)</span>
|
|
||||||
<div className="text-fg-sub leading-[1.6]">
|
|
||||||
전처리 차분 : 관측경로 Δobs ↔ 제1모델 ΔModel 차분 처리
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-[7px] px-2 py-[5px]">
|
|
||||||
<span className="min-w-[20px] text-fg-sub">(d)</span>
|
|
||||||
<div className="text-fg-sub leading-[1.6]">
|
|
||||||
제2 입자 추적 모델 수립 : ΔModel 기반 제2모델 → ΔRevised 산출
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-[7px] px-2 py-[5px]">
|
|
||||||
<span className="min-w-[20px] text-fg-sub">(e)</span>
|
|
||||||
<div className="text-fg-sub leading-[1.6]">
|
|
||||||
최적화 알고리즘 적용 : ΔRevised ↔ Δobs 비교 → GA·DE·HS·PSO 매개변수 최적화 반복 수렴
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 수학 모델 + 알고리즘 */}
|
|
||||||
<div className="grid grid-cols-2 gap-[10px]">
|
|
||||||
<div className="bg-bg-base border border-stroke rounded-lg p-[11px]">
|
|
||||||
<div className="text-caption font-bold text-fg font-korean mb-[7px]">
|
|
||||||
입자 추적 수학 모델
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[5px] p-2 font-mono text-caption text-fg-sub leading-[2] bg-bg-base border border-stroke">
|
|
||||||
<span className="text-fg-disabled">제1모델:</span> Model<sub>x</sub> = cur<sub>u</sub>
|
|
||||||
·Δt + c·w<sub>u</sub>·Δt
|
|
||||||
<br />
|
|
||||||
<span className="text-fg-disabled">제2모델:</span> Rev<sub>x</sub> = a1·cur
|
|
||||||
<sub>u</sub>
|
|
||||||
+a2·cur<sub>v</sub>+...+a9
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-bg-base border border-stroke rounded-lg p-[11px]">
|
|
||||||
<div className="text-caption font-bold text-fg font-korean mb-[7px]">
|
|
||||||
4대 최적화 알고리즘
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-[3px] text-caption font-korean">
|
|
||||||
<div className="px-[7px] py-[3px] text-fg-sub">
|
|
||||||
GA : 유전 알고리즘 — 변이·교배 진화
|
|
||||||
</div>
|
|
||||||
<div className="px-[7px] py-[3px] text-fg-sub">
|
|
||||||
DE : 미분 진화 — 벡터 차이 기반 전역최적화
|
|
||||||
</div>
|
|
||||||
<div className="px-[7px] py-[3px] text-fg-sub">
|
|
||||||
HS : 하모니 서치 — 음악구성 수리모델
|
|
||||||
</div>
|
|
||||||
<div className="px-[7px] py-[3px] text-fg-sub">
|
|
||||||
PSO : 입자군집 최적화 — 새떼 군집행동 모방
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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-[10px]">
|
|
||||||
📚 특허 원문 인용 선행기술문헌 (심사관 인용 포함)
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-[3px] text-caption font-korean">
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
tag: '특허① 인용',
|
|
||||||
text: '해양환경안전학회지 제17권 4호 (김혜진·이문진 외) — KOSPS 상시 운용 체계 | 심사관 직접 인용',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tag: '특허① 인용',
|
|
||||||
text: '해양환경안전학회 2008 춘계학술발표회 — CHARRY 조류모델 | 심사관 직접 인용',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tag: '특허① 인용',
|
|
||||||
text: 'KR1020120121163 A — 심사관 인용 선행특허',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tag: '특허② 인용',
|
|
||||||
text: 'KR101538668 B1 / KR101378463 B1 — 심사관 인용 선행특허 2건',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tag: '특허② 인용',
|
|
||||||
text: '한국 등록특허 제10-1567431 — 발명배경 §[0007]에서 선행기술로 직접 인용',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tag: '이론 원전',
|
|
||||||
text: 'Fay(1969) · Mackay et al.(1980) · Stiver & Mackay(1984) · Mooney(1951) — 풍화 5단계 원전',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tag: '이론 원전',
|
|
||||||
text: 'Akima(1978a, 1978b) — 2차원 5차다항식 보간법 (수심·기상자료 보간)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tag: '이론 원전',
|
|
||||||
text: '이문진·강용균(2000) 한국해양학회지 — 취송류 경험식 0.029×Vw, θw+18.6° 원전',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tag: '이론 원전',
|
|
||||||
text: 'Bowden(1983) — fBm 난류확산 σ²=At^m (m=0.45~2.46)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tag: '이론 원전',
|
|
||||||
text: 'Wahr(1981) — 조석 Love number (k=0.3, h=0.61) · 기조력 계수',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tag: '이론 원전',
|
|
||||||
text: 'Flather & Heaps(1975) — 조석 간출지(tidal flat) 처리 기법',
|
|
||||||
},
|
|
||||||
].map((ref, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="grid gap-[7px] px-2 py-[5px]"
|
|
||||||
style={{ gridTemplateColumns: '80px 1fr' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="px-[5px] py-[2px] rounded-[3px] text-caption text-center h-fit text-color-accent whitespace-nowrap"
|
|
||||||
style={{
|
|
||||||
background: 'color-mix(in srgb, var(--color-accent) 8%, transparent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{ref.tag}
|
|
||||||
</div>
|
|
||||||
<div className="text-fg-sub leading-[1.6]">{ref.text}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,139 +0,0 @@
|
|||||||
export function PanelRemoteSensing() {
|
|
||||||
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-[10px]">
|
|
||||||
🛰️ 위성 원격탐사 — 유출유 탐지 원리
|
|
||||||
</div>
|
|
||||||
<div className="text-caption text-fg-sub font-korean leading-[1.8]">
|
|
||||||
해양 유출유는 해수면에 유막을 형성하여 전자기파 반사·방출·산란 특성을 변화시킵니다. 이
|
|
||||||
물리적 특성 변화를 위성·항공 센서로 감지하여 유막의 위치·범위·두께를 추정합니다.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 원리 4종 그리드 */}
|
|
||||||
<div className="grid grid-cols-2 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">열적외선 방출 원리</div>
|
|
||||||
<div className="text-caption text-fg-sub font-korean leading-[1.7] mb-2">
|
|
||||||
해수면 위 유막은 해수보다 <b style={{ color: 'var(--color-accent)' }}>방사율이 낮아</b>{' '}
|
|
||||||
동일 온도에서도 적외선 방출량이 다릅니다. 파장 10~12μm 열적외선 밴드에서 유막과 주변
|
|
||||||
해수의{' '}
|
|
||||||
<b style={{ color: 'var(--color-accent)' }}>밝기온도(Brightness Temperature) 차이</b>로
|
|
||||||
유막을 탐지합니다.
|
|
||||||
</div>
|
|
||||||
<div className="bg-bg-base rounded-[5px] px-2 py-2 text-caption text-fg-disabled font-korean">
|
|
||||||
⚠️ 구름은 열적외선을 완전 차단 → 마이크로파 보조 필요 (NGSST 융합 이유)
|
|
||||||
</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">
|
|
||||||
SAR 브래그 산란 원리
|
|
||||||
</div>
|
|
||||||
<div className="text-caption text-fg-sub font-korean leading-[1.7] mb-2">
|
|
||||||
해수면 위 유막은 <b style={{ color: 'var(--color-accent)' }}>표면장력을 증가</b>시켜
|
|
||||||
소파를 억제합니다. SAR에서 해수면 산란의 주원인인{' '}
|
|
||||||
<b style={{ color: 'var(--color-accent)' }}>브래그(Bragg) 후방산란이 감소</b>하여 유막
|
|
||||||
영역이 어둡게 나타납니다.
|
|
||||||
</div>
|
|
||||||
<div className="bg-bg-base rounded-[5px] px-2 py-2 text-caption text-fg-disabled font-korean">
|
|
||||||
활용: Sentinel-1(C-band) · KOMPSAT-5(X-band) · ALOS PALSAR(L-band)
|
|
||||||
</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">UV 형광 탐지 원리</div>
|
|
||||||
<div className="text-caption text-fg-sub font-korean leading-[1.7] mb-2">
|
|
||||||
석유계 탄화수소에 자외선(310~400nm)을 조사하면{' '}
|
|
||||||
<b style={{ color: 'var(--color-accent)' }}>형광 발광</b>합니다. 주야간 모두 활용
|
|
||||||
가능하나 야간 효과가 우수합니다. 수μm 수준의 매우 얇은 유막도 탐지 가능한 고감도
|
|
||||||
센서입니다.
|
|
||||||
</div>
|
|
||||||
<div className="bg-bg-base rounded-[5px] px-2 py-2 text-caption text-fg-disabled font-korean">
|
|
||||||
적용: 해경 감시 항공기 야간 탐색 · 비정상 유출 신고 확인
|
|
||||||
</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">
|
|
||||||
수동 마이크로파 복사계는 지구 방출 마이크로파를 수신합니다. 유막이 있으면{' '}
|
|
||||||
<b style={{ color: 'var(--color-accent)' }}>방사율 변화</b>로 밝기온도가 달라집니다.
|
|
||||||
파장이 길어 구름 완전 투과·야간 관측 가능.
|
|
||||||
</div>
|
|
||||||
<div className="bg-bg-base rounded-[5px] px-2 py-2 text-caption text-fg-disabled font-korean">
|
|
||||||
해상도 한계(50km)로 단독 사용 불가 → 열적외선과 융합 (NGSST 방식)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 전자해도 카드 */}
|
|
||||||
<div className="bg-bg-card border border-stroke rounded-[10px] p-[14px]">
|
|
||||||
<div
|
|
||||||
className="text-label-2 font-bold font-korean mb-[10px]"
|
|
||||||
// style={{ color: 'var(--color-accent)' }}
|
|
||||||
>
|
|
||||||
🗺️ 전자해도(ENC) 수심자료 처리 — 특허 10-1567431 기반
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="text-caption text-fg-sub font-korean leading-[1.8]">
|
|
||||||
항공탐색 좌표 데이터는 <b>전자해도(ENC) 수심격자</b>와 중첩되어 유출유의 수심환경,
|
|
||||||
조간대 분포, 해안선 형태를 분석합니다. 수심자료는 국립해양조사원 전자해도 약 300종에서
|
|
||||||
추출 후 <b>Akima 보간</b>으로 15초 등간격 격자에 정규화합니다.
|
|
||||||
</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>{' '}
|
|
||||||
: ENC 전자해도 무작위 측심점
|
|
||||||
</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>{' '}
|
|
||||||
: TIN(Triangulated Irregular Network) 구성
|
|
||||||
</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 2차원 5차다항식 (21개 계수)
|
|
||||||
</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>{' '}
|
|
||||||
: 15초(463m) 등간격 · 3,225,600 격자
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,208 +0,0 @@
|
|||||||
export function PanelSpreadModel() {
|
|
||||||
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">
|
|
||||||
🔗 항공탐색 데이터 → 유출유 확산예측 연계 체계
|
|
||||||
</div>
|
|
||||||
<div className="text-caption text-fg-sub font-korean leading-[1.8]">
|
|
||||||
이문진 박사 특허(등록특허 10-1567431)는{' '}
|
|
||||||
<b style={{ color: 'var(--color-accent)' }}>위성영상수신시스템(SST)</b>을
|
|
||||||
기상예측시스템·검조소와 함께 인터넷으로 연결하여, 항공탐색 데이터가 실시간 확산 예측의
|
|
||||||
핵심 입력자료가 되는 통합 네트워크를 구성합니다.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 입력/피드백 2열 카드 */}
|
|
||||||
<div className="grid grid-cols-2 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">
|
|
||||||
📡 항공탐색 → 모델 입력
|
|
||||||
</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-info) 5%, transparent)',
|
|
||||||
border: '1px solid color-mix(in srgb, var(--color-info) 12%, transparent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="font-bold text-fg mb-[1px]">📍 유출 위치 보정</div>
|
|
||||||
<div className="text-fg-disabled">드론·위성 영상 → GPS 좌표 → 모델 유출지점 갱신</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="px-[9px] py-[6px] rounded-[5px]"
|
|
||||||
style={{
|
|
||||||
background: 'color-mix(in srgb, var(--color-info) 5%, transparent)',
|
|
||||||
border: '1px solid color-mix(in srgb, var(--color-info) 12%, transparent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="font-bold text-fg mb-[1px]">🛢️ 유출량 역산</div>
|
|
||||||
<div className="text-fg-disabled">면적×두께 → 유량 추정 → 확산모델 유출량 보정</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="px-[9px] py-[6px] rounded-[5px]"
|
|
||||||
style={{
|
|
||||||
background: 'color-mix(in srgb, var(--color-info) 5%, transparent)',
|
|
||||||
border: '1px solid color-mix(in srgb, var(--color-info) 12%, transparent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="font-bold text-fg mb-[1px]">🌡️ SST 수온 입력</div>
|
|
||||||
<div className="text-fg-disabled">NGSST FTP 수신 → Akima 보간 → 풍화모델 수온값</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="px-[9px] py-[6px] rounded-[5px]"
|
|
||||||
style={{
|
|
||||||
background: 'color-mix(in srgb, var(--color-info) 5%, transparent)',
|
|
||||||
border: '1px solid color-mix(in srgb, var(--color-info) 12%, transparent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="font-bold text-fg mb-[1px]">🎨 풍화 상태 확인</div>
|
|
||||||
<div className="text-fg-disabled">색상 분류 → 증발비 추정 → 풍화모델 초기값 보정</div>
|
|
||||||
</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="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-info) 5%, transparent)',
|
|
||||||
border: '1px solid color-mix(in srgb, var(--color-info) 12%, transparent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="font-bold text-fg mb-[1px]">🗺️ 탐색 우선구역 제공</div>
|
|
||||||
<div className="text-fg-disabled">확산 예측 결과 → 다음 탐색 집중구역 자동 생성</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="px-[9px] py-[6px] rounded-[5px]"
|
|
||||||
style={{
|
|
||||||
background: 'color-mix(in srgb, var(--color-info) 5%, transparent)',
|
|
||||||
border: '1px solid color-mix(in srgb, var(--color-info) 12%, transparent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="font-bold text-fg mb-[1px]">📊 모델 검증 자료</div>
|
|
||||||
<div className="text-fg-disabled">
|
|
||||||
실측 유출유 위치 ↔ 예측값 오차 분석 → 정확도 평가
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="px-[9px] py-[6px] rounded-[5px]"
|
|
||||||
style={{
|
|
||||||
background: 'color-mix(in srgb, var(--color-info) 5%, transparent)',
|
|
||||||
border: '1px solid color-mix(in srgb, var(--color-info) 12%, transparent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="font-bold text-fg mb-[1px]">🏖️ ESI 피해 위험구역</div>
|
|
||||||
<div className="text-fg-disabled">
|
|
||||||
확산경로×ESI 중첩 → 항공탐색 ESI 현장확인 우선순위
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="px-[9px] py-[6px] rounded-[5px]"
|
|
||||||
style={{
|
|
||||||
background: 'color-mix(in srgb, var(--color-info) 5%, transparent)',
|
|
||||||
border: '1px solid color-mix(in srgb, var(--color-info) 12%, transparent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="font-bold text-fg mb-[1px]">🚢 방제자원 배치안</div>
|
|
||||||
<div className="text-fg-disabled">
|
|
||||||
예측 도달시간 → 오일펜스·방제정 최적 배치 좌표 제공
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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-3">
|
|
||||||
📡 특허 10-1567431 실시간 자료 연계 네트워크 (도면 9)
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-center gap-0 py-2">
|
|
||||||
<div className="flex flex-col gap-[6px] items-start">
|
|
||||||
<div
|
|
||||||
className="px-3 py-2 rounded-[7px] text-left text-caption font-korean"
|
|
||||||
style={{
|
|
||||||
background: 'color-mix(in srgb, var(--color-accent) 8%, transparent)',
|
|
||||||
border: '1px solid color-mix(in srgb, var(--color-accent) 20%, transparent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="font-bold text-fg">기상예측시스템</div>
|
|
||||||
<div className="text-fg-disabled">바람·기온·기압 · 국립환경과학원</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="px-3 py-2 rounded-[7px] text-left text-caption font-korean"
|
|
||||||
style={{
|
|
||||||
background: 'color-mix(in srgb, var(--color-accent) 8%, transparent)',
|
|
||||||
border: '1px solid color-mix(in srgb, var(--color-accent) 20%, transparent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="font-bold text-fg">위성영상수신시스템</div>
|
|
||||||
<div className="text-fg-disabled">SST(NGSST) · 토호쿠대학 FTP</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="px-3 py-2 rounded-[7px] text-left text-caption font-korean"
|
|
||||||
style={{
|
|
||||||
background: 'color-mix(in srgb, var(--color-accent) 8%, transparent)',
|
|
||||||
border: '1px solid color-mix(in srgb, var(--color-accent) 20%, transparent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="font-bold text-fg">검조소</div>
|
|
||||||
<div className="text-fg-disabled">실시간 조위 · 조석정보</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center justify-center self-stretch px-3 gap-[2px]">
|
|
||||||
<div className="flex-1" />
|
|
||||||
<div className="w-[30px] h-[1px] bg-stroke-light" />
|
|
||||||
<div className="flex-1" />
|
|
||||||
<div className="w-[30px] h-[1px] bg-stroke-light" />
|
|
||||||
<div className="flex-1" />
|
|
||||||
<div className="w-[30px] h-[1px] bg-stroke-light" />
|
|
||||||
<div className="flex-1" />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="px-5 py-4 rounded-[10px] text-center text-caption font-korean self-center"
|
|
||||||
style={{
|
|
||||||
background: 'color-mix(in srgb, var(--color-tertiary) 8%, transparent)',
|
|
||||||
border: '2px solid color-mix(in srgb, var(--color-tertiary) 30%, transparent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="text-base mb-1">🖥️</div>
|
|
||||||
<div className="font-bold text-fg">서버(WING)</div>
|
|
||||||
<div className="text-fg-disabled">
|
|
||||||
데이터 수신·처리
|
|
||||||
<br />
|
|
||||||
모델 구동
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-[30px] h-[1px] bg-stroke-light self-center" />
|
|
||||||
<div className="flex flex-col gap-[6px] items-center self-center">
|
|
||||||
<div
|
|
||||||
className="px-3 py-2 rounded-[7px] text-center text-caption font-korean"
|
|
||||||
style={{
|
|
||||||
background: 'color-mix(in srgb, var(--color-accent) 8%, transparent)',
|
|
||||||
border: '1px solid color-mix(in srgb, var(--color-accent) 20%, transparent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="font-bold text-fg">클라이언트</div>
|
|
||||||
<div className="text-fg-disabled">
|
|
||||||
유출지점·유출량
|
|
||||||
<br />
|
|
||||||
입력 및 결과 수령
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-caption text-fg-disabled font-korean text-center mt-2">
|
|
||||||
기상자료·수온자료·조석정보 실시간 수신 → CHARRY 조류 + 취송류 예측 → 유출유 확산 예측
|
|
||||||
(S10→S20→S30)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,152 +0,0 @@
|
|||||||
import type { MouseEvent } from 'react';
|
|
||||||
|
|
||||||
export interface TimelineControlProps {
|
|
||||||
currentTime: number;
|
|
||||||
maxTime: number;
|
|
||||||
isPlaying: boolean;
|
|
||||||
playbackSpeed: number;
|
|
||||||
onTimeChange: (time: number) => void;
|
|
||||||
onPlayPause: () => void;
|
|
||||||
onSpeedChange: (speed: number) => void;
|
|
||||||
simulationStartTime?: string;
|
|
||||||
stepSize?: number;
|
|
||||||
tickInterval?: number;
|
|
||||||
majorTickEvery?: number;
|
|
||||||
timeUnitLabel?: string;
|
|
||||||
formatOffset?: (t: number) => string;
|
|
||||||
formatAbsolute?: (t: number, base: Date) => string;
|
|
||||||
showSpeedToggle?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TimelineControl({
|
|
||||||
currentTime,
|
|
||||||
maxTime,
|
|
||||||
isPlaying,
|
|
||||||
playbackSpeed,
|
|
||||||
onTimeChange,
|
|
||||||
onPlayPause,
|
|
||||||
onSpeedChange,
|
|
||||||
simulationStartTime,
|
|
||||||
stepSize = 6,
|
|
||||||
tickInterval = 6,
|
|
||||||
majorTickEvery = 12,
|
|
||||||
timeUnitLabel = 'h',
|
|
||||||
formatOffset,
|
|
||||||
formatAbsolute,
|
|
||||||
showSpeedToggle = true,
|
|
||||||
}: TimelineControlProps) {
|
|
||||||
const progressPercent = maxTime > 0 ? (currentTime / maxTime) * 100 : 0;
|
|
||||||
|
|
||||||
const handleRewind = () => onTimeChange(Math.max(0, currentTime - stepSize));
|
|
||||||
const handleForward = () => onTimeChange(Math.min(maxTime, currentTime + stepSize));
|
|
||||||
const handleStart = () => onTimeChange(0);
|
|
||||||
const handleEnd = () => onTimeChange(maxTime);
|
|
||||||
|
|
||||||
const toggleSpeed = () => {
|
|
||||||
const speeds = [1, 2, 4];
|
|
||||||
const currentIndex = speeds.indexOf(playbackSpeed);
|
|
||||||
onSpeedChange(speeds[(currentIndex + 1) % speeds.length]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTimelineClick = (e: MouseEvent<HTMLDivElement>) => {
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
|
||||||
const percent = (e.clientX - rect.left) / rect.width;
|
|
||||||
onTimeChange(Math.max(0, Math.min(maxTime, Math.round(percent * maxTime))));
|
|
||||||
};
|
|
||||||
|
|
||||||
const timeLabels: number[] = [];
|
|
||||||
for (let t = 0; t <= maxTime; t += tickInterval) {
|
|
||||||
timeLabels.push(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultOffset = (t: number) => `+${t.toFixed(0)}${timeUnitLabel}`;
|
|
||||||
const defaultAbsolute = (t: number, base: Date) => {
|
|
||||||
const d = new Date(base.getTime() + t * 3600 * 1000);
|
|
||||||
return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')} KST`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const offsetStr = (formatOffset ?? defaultOffset)(currentTime);
|
|
||||||
const baseDate = simulationStartTime ? new Date(simulationStartTime) : new Date();
|
|
||||||
const absoluteStr = (formatAbsolute ?? defaultAbsolute)(currentTime, baseDate);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="tlb">
|
|
||||||
<div className="tlc">
|
|
||||||
<div className="tb" onClick={handleStart}>
|
|
||||||
⏮
|
|
||||||
</div>
|
|
||||||
<div className="tb" onClick={handleRewind}>
|
|
||||||
◀
|
|
||||||
</div>
|
|
||||||
<div className={`tb ${isPlaying ? 'on' : ''}`} onClick={onPlayPause}>
|
|
||||||
{isPlaying ? '⏸' : '▶'}
|
|
||||||
</div>
|
|
||||||
<div className="tb" onClick={handleForward}>
|
|
||||||
▶▶
|
|
||||||
</div>
|
|
||||||
<div className="tb" onClick={handleEnd}>
|
|
||||||
⏭
|
|
||||||
</div>
|
|
||||||
{showSpeedToggle && (
|
|
||||||
<>
|
|
||||||
<div className="w-2" />
|
|
||||||
<div className="tb" onClick={toggleSpeed}>
|
|
||||||
{playbackSpeed}×
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="tlt">
|
|
||||||
<div className="tlls">
|
|
||||||
{timeLabels.map((t) => (
|
|
||||||
<span
|
|
||||||
key={t}
|
|
||||||
className={`tll ${Math.abs(currentTime - t) < 1 ? 'on' : ''}`}
|
|
||||||
style={{ left: `${(t / maxTime) * 100}%` }}
|
|
||||||
>
|
|
||||||
{t}
|
|
||||||
{timeUnitLabel}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="tlsw" onClick={handleTimelineClick}>
|
|
||||||
<div className="tlr">
|
|
||||||
<div className="tlp" style={{ width: `${progressPercent}%` }} />
|
|
||||||
{timeLabels.map((t) => (
|
|
||||||
<div
|
|
||||||
key={`marker-${t}`}
|
|
||||||
className={`tlm ${t % majorTickEvery === 0 ? 'mj' : ''}`}
|
|
||||||
style={{ left: `${(t / maxTime) * 100}%` }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="tlth" style={{ left: `${progressPercent}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="tli">
|
|
||||||
<div className="tlct">
|
|
||||||
{offsetStr} — {absoluteStr}
|
|
||||||
</div>
|
|
||||||
<div className="tlss">
|
|
||||||
<div className="tls">
|
|
||||||
<span className="tlsl">진행률</span>
|
|
||||||
<span className="tlsv">{progressPercent.toFixed(0)}%</span>
|
|
||||||
</div>
|
|
||||||
{showSpeedToggle && (
|
|
||||||
<div className="tls">
|
|
||||||
<span className="tlsl">속도</span>
|
|
||||||
<span className="tlsv">{playbackSpeed}×</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="tls">
|
|
||||||
<span className="tlsl">시간</span>
|
|
||||||
<span className="tlsv">
|
|
||||||
{currentTime.toFixed(0)}/{maxTime}
|
|
||||||
{timeUnitLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,584 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { CHAPTERS } from '@common/data/manualChapters';
|
|
||||||
|
|
||||||
interface UserManualPopupProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
|
||||||
const [selectedChapterId, setSelectedChapterId] = useState<string>('ch01');
|
|
||||||
const [expandedScreenIds, setExpandedScreenIds] = useState<Set<string>>(new Set());
|
|
||||||
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
const selectedChapter = CHAPTERS.find((ch) => ch.id === selectedChapterId) ?? CHAPTERS[0];
|
|
||||||
|
|
||||||
const toggleScreen = (screenId: string) => {
|
|
||||||
setExpandedScreenIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(screenId)) {
|
|
||||||
next.delete(screenId);
|
|
||||||
} else {
|
|
||||||
next.add(screenId);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const expandAll = () => {
|
|
||||||
setExpandedScreenIds(new Set(selectedChapter.screens.map((s) => s.id)));
|
|
||||||
};
|
|
||||||
|
|
||||||
const collapseAll = () => {
|
|
||||||
setExpandedScreenIds(new Set());
|
|
||||||
};
|
|
||||||
|
|
||||||
const allExpanded =
|
|
||||||
selectedChapter.screens.length > 0 &&
|
|
||||||
selectedChapter.screens.every((s) => expandedScreenIds.has(s.id));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-[9999] flex items-center justify-center"
|
|
||||||
style={{ background: 'rgba(0,0,0,0.65)' }}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex flex-col rounded-lg overflow-hidden"
|
|
||||||
style={{
|
|
||||||
width: '90vw',
|
|
||||||
height: '85vh',
|
|
||||||
background: '#0f1729',
|
|
||||||
border: '1px solid #1e2a45',
|
|
||||||
boxShadow: '0 24px 64px rgba(0,0,0,0.5)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between px-6 py-4 flex-shrink-0"
|
|
||||||
style={{
|
|
||||||
background: '#0b1120',
|
|
||||||
borderBottom: '1px solid #1e2a45',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="font-bold text-subtitle" style={{ color: '#e2e8f0' }}>
|
|
||||||
Wing 사용자 매뉴얼
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="text-label-2 px-2 py-0.5 rounded font-mono"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(6,182,212,0.12)',
|
|
||||||
color: '#06b6d4',
|
|
||||||
border: '1px solid rgba(6,182,212,0.25)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
v0.5
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="flex items-center justify-center w-7 h-7 rounded text-title-4 font-semibold transition-colors"
|
|
||||||
style={{ color: '#94a3b8', background: 'transparent' }}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = '#1a2540';
|
|
||||||
e.currentTarget.style.color = '#e2e8f0';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = 'transparent';
|
|
||||||
e.currentTarget.style.color = '#94a3b8';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
X
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
|
||||||
{/* Left Sidebar */}
|
|
||||||
<div
|
|
||||||
className="flex-shrink-0 overflow-y-auto py-3"
|
|
||||||
style={{
|
|
||||||
width: '240px',
|
|
||||||
background: '#0b1120',
|
|
||||||
borderRight: '1px solid #1e2a45',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{CHAPTERS.map((chapter) => {
|
|
||||||
const isActive = chapter.id === selectedChapterId;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={chapter.id}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedChapterId(chapter.id);
|
|
||||||
setExpandedScreenIds(new Set());
|
|
||||||
}}
|
|
||||||
className="w-full text-left px-4 py-3 transition-colors"
|
|
||||||
style={{
|
|
||||||
background: isActive ? 'rgba(6,182,212,0.08)' : 'transparent',
|
|
||||||
borderLeft: isActive ? '2px solid #06b6d4' : '2px solid transparent',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!isActive) {
|
|
||||||
e.currentTarget.style.background = '#1a2540';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!isActive) {
|
|
||||||
e.currentTarget.style.background = 'transparent';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<span
|
|
||||||
className="flex-shrink-0 w-7 h-7 rounded flex items-center justify-center text-caption font-bold font-mono"
|
|
||||||
style={{
|
|
||||||
background: isActive ? 'rgba(6,182,212,0.18)' : 'rgba(255,255,255,0.05)',
|
|
||||||
color: isActive ? '#06b6d4' : '#64748b',
|
|
||||||
border: isActive ? '1px solid rgba(6,182,212,0.3)' : '1px solid #1e2a45',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{chapter.number}
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div
|
|
||||||
className="text-label-1 font-medium leading-tight truncate"
|
|
||||||
style={{ color: isActive ? '#06b6d4' : '#cbd5e1' }}
|
|
||||||
>
|
|
||||||
{chapter.title}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="text-caption leading-tight mt-0.5 truncate"
|
|
||||||
style={{ color: '#475569' }}
|
|
||||||
>
|
|
||||||
{chapter.subtitle}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
|
||||||
{/* Chapter heading */}
|
|
||||||
<div className="mb-5 pb-4" style={{ borderBottom: '1px solid #1e2a45' }}>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span
|
|
||||||
className="text-label-2 font-mono px-2 py-0.5 rounded font-bold"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(6,182,212,0.12)',
|
|
||||||
color: '#06b6d4',
|
|
||||||
border: '1px solid rgba(6,182,212,0.2)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
CH {selectedChapter.number}
|
|
||||||
</span>
|
|
||||||
<h2 className="text-title-2 font-semibold" style={{ color: '#e2e8f0' }}>
|
|
||||||
{selectedChapter.title}
|
|
||||||
</h2>
|
|
||||||
<span className="text-label-1" style={{ color: '#475569' }}>
|
|
||||||
{selectedChapter.subtitle}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-label-2 mr-1" style={{ color: '#64748b' }}>
|
|
||||||
{selectedChapter.screens.length}개 화면
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={allExpanded ? collapseAll : expandAll}
|
|
||||||
className="text-label-2 px-3 py-1 rounded transition-colors"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(6,182,212,0.08)',
|
|
||||||
color: '#06b6d4',
|
|
||||||
border: '1px solid rgba(6,182,212,0.2)',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = 'rgba(6,182,212,0.16)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = 'rgba(6,182,212,0.08)';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{allExpanded ? '전체 닫기' : '전체 열기'}
|
|
||||||
</button>
|
|
||||||
<a
|
|
||||||
href={`/manual/pdfs/${selectedChapter.id}.pdf`}
|
|
||||||
download={`${selectedChapter.number}_${selectedChapter.title}.pdf`}
|
|
||||||
className="text-label-2 px-3 py-1 rounded transition-colors inline-flex items-center"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(6,182,212,0.08)',
|
|
||||||
color: '#06b6d4',
|
|
||||||
border: '1px solid rgba(6,182,212,0.2)',
|
|
||||||
textDecoration: 'none',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = 'rgba(6,182,212,0.16)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = 'rgba(6,182,212,0.08)';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
PDF 다운로드
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Screen cards */}
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{selectedChapter.screens.map((screen) => {
|
|
||||||
const isExpanded = expandedScreenIds.has(screen.id);
|
|
||||||
const imageSrc = `/manual/image${screen.imageIndex}.png`;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={screen.id}
|
|
||||||
className="rounded-lg overflow-hidden"
|
|
||||||
style={{
|
|
||||||
background: '#141d33',
|
|
||||||
border: '1px solid #1e2a45',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Screen header (toggle) */}
|
|
||||||
<button
|
|
||||||
onClick={() => toggleScreen(screen.id)}
|
|
||||||
className="w-full text-left flex items-center gap-3 px-4 py-3 transition-colors"
|
|
||||||
style={{ background: 'transparent' }}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = '#1a2540';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = 'transparent';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="flex-shrink-0 text-caption font-mono font-bold px-1.5 py-0.5 rounded"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(6,182,212,0.1)',
|
|
||||||
color: '#06b6d4',
|
|
||||||
border: '1px solid rgba(6,182,212,0.2)',
|
|
||||||
minWidth: '36px',
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{screen.id}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="flex-1 text-title-4 font-medium"
|
|
||||||
style={{ color: '#cbd5e1' }}
|
|
||||||
>
|
|
||||||
{screen.name}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="flex-shrink-0 text-caption font-mono"
|
|
||||||
style={{
|
|
||||||
color: '#475569',
|
|
||||||
transition: 'transform 0.2s',
|
|
||||||
display: 'inline-block',
|
|
||||||
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
v
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Screen detail (expanded) */}
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="px-4 pb-5" style={{ borderTop: '1px solid #1e2a45' }}>
|
|
||||||
{/* Screenshot image */}
|
|
||||||
<div className="mt-4 mb-4">
|
|
||||||
<img
|
|
||||||
src={imageSrc}
|
|
||||||
alt={screen.name}
|
|
||||||
loading="lazy"
|
|
||||||
onClick={() => setLightboxSrc(imageSrc)}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
borderRadius: '6px',
|
|
||||||
border: '1px solid #1e2a45',
|
|
||||||
cursor: 'zoom-in',
|
|
||||||
display: 'block',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<p
|
|
||||||
className="mt-1 text-caption text-right"
|
|
||||||
style={{ color: '#475569' }}
|
|
||||||
>
|
|
||||||
이미지를 클릭하면 크게 볼 수 있다
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Menu path breadcrumb */}
|
|
||||||
<div
|
|
||||||
className="mb-3 text-label-2 font-mono px-2 py-1 rounded inline-block"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(71,85,105,0.15)',
|
|
||||||
color: '#64748b',
|
|
||||||
border: '1px solid #1e2a45',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{screen.menuPath}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Overview */}
|
|
||||||
<div className="mt-2">
|
|
||||||
<p
|
|
||||||
className="text-label-1 leading-relaxed"
|
|
||||||
style={{ color: '#94a3b8' }}
|
|
||||||
>
|
|
||||||
{screen.overview}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
{screen.description && (
|
|
||||||
<div
|
|
||||||
className="mt-3 px-3 py-2.5 rounded"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(30,42,69,0.6)',
|
|
||||||
border: '1px solid #1e2a45',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="text-label-2 font-semibold mb-1.5 uppercase tracking-wide"
|
|
||||||
style={{ color: '#475569' }}
|
|
||||||
>
|
|
||||||
화면 설명
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
className="text-label-1 leading-relaxed"
|
|
||||||
style={{ color: '#7f8ea3' }}
|
|
||||||
>
|
|
||||||
{screen.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Procedure */}
|
|
||||||
{screen.procedure && screen.procedure.length > 0 && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<div
|
|
||||||
className="text-label-2 font-semibold mb-2 uppercase tracking-wide"
|
|
||||||
style={{ color: '#475569' }}
|
|
||||||
>
|
|
||||||
사용 절차
|
|
||||||
</div>
|
|
||||||
<ol className="flex flex-col gap-1.5">
|
|
||||||
{screen.procedure.map((step, idx) => (
|
|
||||||
<li key={idx} className="flex items-start gap-2.5">
|
|
||||||
<span
|
|
||||||
className="flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center text-caption font-bold mt-0.5"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(6,182,212,0.12)',
|
|
||||||
color: '#06b6d4',
|
|
||||||
border: '1px solid rgba(6,182,212,0.2)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{idx + 1}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="text-label-1 leading-relaxed"
|
|
||||||
style={{ color: '#94a3b8' }}
|
|
||||||
>
|
|
||||||
{step}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Inputs */}
|
|
||||||
{screen.inputs && screen.inputs.length > 0 && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<div
|
|
||||||
className="text-label-2 font-semibold mb-2 uppercase tracking-wide"
|
|
||||||
style={{ color: '#475569' }}
|
|
||||||
>
|
|
||||||
입력 항목
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="rounded overflow-hidden"
|
|
||||||
style={{ border: '1px solid #1e2a45' }}
|
|
||||||
>
|
|
||||||
<table className="w-full text-label-1">
|
|
||||||
<thead>
|
|
||||||
<tr style={{ background: '#0f1729' }}>
|
|
||||||
<th
|
|
||||||
className="text-left px-3 py-2 font-medium"
|
|
||||||
style={{ color: '#64748b', width: '22%' }}
|
|
||||||
>
|
|
||||||
항목
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
className="text-left px-3 py-2 font-medium"
|
|
||||||
style={{ color: '#64748b', width: '18%' }}
|
|
||||||
>
|
|
||||||
유형
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
className="text-left px-3 py-2 font-medium"
|
|
||||||
style={{ color: '#64748b', width: '12%' }}
|
|
||||||
>
|
|
||||||
필수
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
className="text-left px-3 py-2 font-medium"
|
|
||||||
style={{ color: '#64748b' }}
|
|
||||||
>
|
|
||||||
설명
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{screen.inputs.map((input, idx) => (
|
|
||||||
<tr
|
|
||||||
key={idx}
|
|
||||||
style={{
|
|
||||||
borderTop: '1px solid #1e2a45',
|
|
||||||
background:
|
|
||||||
idx % 2 === 0
|
|
||||||
? 'transparent'
|
|
||||||
: 'rgba(255,255,255,0.01)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
className="px-3 py-2 font-medium"
|
|
||||||
style={{ color: '#cbd5e1' }}
|
|
||||||
>
|
|
||||||
{input.label}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2" style={{ color: '#64748b' }}>
|
|
||||||
{input.type}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
{input.required ? (
|
|
||||||
<span
|
|
||||||
className="text-caption font-bold px-1.5 py-0.5 rounded"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(239,68,68,0.1)',
|
|
||||||
color: '#f87171',
|
|
||||||
border: '1px solid rgba(239,68,68,0.2)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
필수
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="text-caption px-1.5 py-0.5 rounded"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(100,116,139,0.1)',
|
|
||||||
color: '#64748b',
|
|
||||||
border: '1px solid rgba(100,116,139,0.2)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
선택
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2" style={{ color: '#7f8ea3' }}>
|
|
||||||
{input.desc}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Notes */}
|
|
||||||
{screen.notes && screen.notes.length > 0 && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<div
|
|
||||||
className="text-label-2 font-semibold mb-2 uppercase tracking-wide"
|
|
||||||
style={{ color: '#475569' }}
|
|
||||||
>
|
|
||||||
유의사항
|
|
||||||
</div>
|
|
||||||
<ul className="flex flex-col gap-1.5">
|
|
||||||
{screen.notes.map((note, idx) => (
|
|
||||||
<li key={idx} className="flex items-start gap-2">
|
|
||||||
<span
|
|
||||||
className="flex-shrink-0 mt-1.5 w-1.5 h-1.5 rounded-full"
|
|
||||||
style={{ background: '#f59e0b' }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="text-label-1 leading-relaxed"
|
|
||||||
style={{ color: '#94a3b8' }}
|
|
||||||
>
|
|
||||||
{note}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Lightbox */}
|
|
||||||
{lightboxSrc !== null && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-[10000] flex items-center justify-center"
|
|
||||||
style={{ background: 'rgba(0,0,0,0.88)' }}
|
|
||||||
onClick={() => setLightboxSrc(null)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="relative"
|
|
||||||
style={{ maxWidth: '92vw', maxHeight: '90vh' }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={lightboxSrc}
|
|
||||||
alt="확대 이미지"
|
|
||||||
style={{
|
|
||||||
maxWidth: '92vw',
|
|
||||||
maxHeight: '88vh',
|
|
||||||
borderRadius: '8px',
|
|
||||||
border: '1px solid #1e2a45',
|
|
||||||
display: 'block',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => setLightboxSrc(null)}
|
|
||||||
className="absolute top-2 right-2 w-8 h-8 rounded flex items-center justify-center text-title-4 font-bold"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(15,23,41,0.85)',
|
|
||||||
color: '#94a3b8',
|
|
||||||
border: '1px solid #1e2a45',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.color = '#e2e8f0';
|
|
||||||
e.currentTarget.style.background = '#1a2540';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.color = '#94a3b8';
|
|
||||||
e.currentTarget.style.background = 'rgba(15,23,41,0.85)';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
X
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UserManualPopup;
|
|
||||||
@ -1,514 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { fetchHnsAnalyses } from '../services/hnsApi';
|
|
||||||
import type { HnsAnalysisItem, HnsScenario, HnsMaterial } from '@interfaces/hns/HnsInterface';
|
|
||||||
import type { Severity } from '@/types/hns/HnsType';
|
|
||||||
import { ScenarioDetail } from './contents/ScenarioDetail';
|
|
||||||
import { ScenarioComparison } from './contents/ScenarioComparison';
|
|
||||||
import { ScenarioMapOverlay } from './contents/ScenarioMapOverlay';
|
|
||||||
import { NewScenarioModal } from './contents/NewScenarioModal';
|
|
||||||
/* eslint-disable react-refresh/only-export-components */
|
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────
|
|
||||||
type ViewTab = 0 | 1 | 2;
|
|
||||||
|
|
||||||
export const SEVERITY_STYLE: Record<Severity, { bg: string; color: string }> = {
|
|
||||||
CRITICAL: { bg: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)' },
|
|
||||||
HIGH: { bg: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)' },
|
|
||||||
MEDIUM: { bg: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)' },
|
|
||||||
RESOLVED: { bg: 'rgba(34,197,94,0.15)', color: 'var(--color-success)' },
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Mock Data (시나리오 시뮬레이션 엔진 미구현 — 프론트 상수 유지) ──
|
|
||||||
const MOCK_SCENARIOS: HnsScenario[] = [
|
|
||||||
{
|
|
||||||
id: 'S-01',
|
|
||||||
name: '유출 직후 (초기 확산)',
|
|
||||||
severity: 'CRITICAL',
|
|
||||||
timeStep: 'T+0h',
|
|
||||||
datetime: '2024.11.03 08:00 KST',
|
|
||||||
wind: '풍속 5.2m/s SW',
|
|
||||||
maxConc: '850 ppm',
|
|
||||||
idlhRadius: '1.2 km',
|
|
||||||
erpg2: '2.8 km',
|
|
||||||
population: '3,200명',
|
|
||||||
description:
|
|
||||||
'톨루엔 2.5톤 순간 유출. SW 풍향으로 온산 산업단지 방향 확산. IDLH 초과 구역 발생.',
|
|
||||||
detail: {
|
|
||||||
maxConc: '850ppm',
|
|
||||||
idlhRadius: '1.2km',
|
|
||||||
erpg2: '2.8km',
|
|
||||||
windDir: 'SW 225°',
|
|
||||||
windSpeed: '5.2 m/s',
|
|
||||||
population: '3,200명',
|
|
||||||
spillAmount: '2.5 ton',
|
|
||||||
},
|
|
||||||
zones: {
|
|
||||||
idlh: '1.2 km (500ppm)',
|
|
||||||
erpg2: '2.8 km (300ppm)',
|
|
||||||
erpg1: '4.5 km (50ppm)',
|
|
||||||
twa: '6.2 km (20ppm)',
|
|
||||||
},
|
|
||||||
weather: {
|
|
||||||
dir: 'SW 225°',
|
|
||||||
speed: '5.2 m/s',
|
|
||||||
temp: '18.5°C',
|
|
||||||
stability: 'D (중립)',
|
|
||||||
humidity: '65%',
|
|
||||||
mixHeight: '850 m',
|
|
||||||
},
|
|
||||||
actions: [
|
|
||||||
'반경 1.2km 즉시 대피 명령',
|
|
||||||
'Level B 화학복 착용',
|
|
||||||
'화기 엄금 — 인화점 4°C',
|
|
||||||
'해양확산 동시 모니터링',
|
|
||||||
'IDLH 경계 실시간 측정',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'S-02',
|
|
||||||
name: '풍향 변화 시나리오',
|
|
||||||
severity: 'HIGH',
|
|
||||||
timeStep: 'T+1h',
|
|
||||||
datetime: '2024.11.03 09:00 KST',
|
|
||||||
wind: '풍속 4.8m/s SE',
|
|
||||||
maxConc: '420 ppm',
|
|
||||||
idlhRadius: '0.8 km',
|
|
||||||
erpg2: '2.1 km',
|
|
||||||
population: '5,100명',
|
|
||||||
description: '풍향 SE 전환. 주거지역 방향 확산 확대. 영향인구 증가. 대피 범위 조정 필요.',
|
|
||||||
detail: {
|
|
||||||
maxConc: '420ppm',
|
|
||||||
idlhRadius: '0.8km',
|
|
||||||
erpg2: '2.1km',
|
|
||||||
windDir: 'SE 135°',
|
|
||||||
windSpeed: '4.8 m/s',
|
|
||||||
population: '5,100명',
|
|
||||||
spillAmount: '2.5 ton',
|
|
||||||
},
|
|
||||||
zones: {
|
|
||||||
idlh: '0.8 km (500ppm)',
|
|
||||||
erpg2: '2.1 km (300ppm)',
|
|
||||||
erpg1: '3.8 km (50ppm)',
|
|
||||||
twa: '5.5 km (20ppm)',
|
|
||||||
},
|
|
||||||
weather: {
|
|
||||||
dir: 'SE 135°',
|
|
||||||
speed: '4.8 m/s',
|
|
||||||
temp: '19.2°C',
|
|
||||||
stability: 'C (약간 불안정)',
|
|
||||||
humidity: '62%',
|
|
||||||
mixHeight: '920 m',
|
|
||||||
},
|
|
||||||
actions: ['대피 범위 SE 방향 확장', '주거지역 주민 대피 알림', '실시간 농도 모니터링 강화'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'S-03',
|
|
||||||
name: '연속유출 확대',
|
|
||||||
severity: 'HIGH',
|
|
||||||
timeStep: 'T+3h',
|
|
||||||
datetime: '2024.11.03 11:00 KST',
|
|
||||||
wind: '풍속 3.5m/s S',
|
|
||||||
maxConc: '280 ppm',
|
|
||||||
idlhRadius: '0.5 km',
|
|
||||||
erpg2: '1.8 km',
|
|
||||||
population: '4,800명',
|
|
||||||
description: '연속유출 3시간 경과. 누적 유출량 증가. 풍속 감소로 체류 시간 증가.',
|
|
||||||
detail: {
|
|
||||||
maxConc: '280ppm',
|
|
||||||
idlhRadius: '0.5km',
|
|
||||||
erpg2: '1.8km',
|
|
||||||
windDir: 'S 180°',
|
|
||||||
windSpeed: '3.5 m/s',
|
|
||||||
population: '4,800명',
|
|
||||||
spillAmount: '4.2 ton',
|
|
||||||
},
|
|
||||||
zones: {
|
|
||||||
idlh: '0.5 km (500ppm)',
|
|
||||||
erpg2: '1.8 km (300ppm)',
|
|
||||||
erpg1: '3.2 km (50ppm)',
|
|
||||||
twa: '4.8 km (20ppm)',
|
|
||||||
},
|
|
||||||
weather: {
|
|
||||||
dir: 'S 180°',
|
|
||||||
speed: '3.5 m/s',
|
|
||||||
temp: '20.1°C',
|
|
||||||
stability: 'B (불안정)',
|
|
||||||
humidity: '58%',
|
|
||||||
mixHeight: '1,050 m',
|
|
||||||
},
|
|
||||||
actions: ['유출원 차단 작업 투입', '풍속 감소 체류 경고', '추가 모니터링 포인트 설치'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'S-04',
|
|
||||||
name: '유출 차단·잔류 확산',
|
|
||||||
severity: 'MEDIUM',
|
|
||||||
timeStep: 'T+6h',
|
|
||||||
datetime: '2024.11.03 14:00 KST',
|
|
||||||
wind: '풍속 6.1m/s W',
|
|
||||||
maxConc: '85 ppm',
|
|
||||||
idlhRadius: '—',
|
|
||||||
erpg2: '0.4 km',
|
|
||||||
population: '1,200명',
|
|
||||||
description: '유출원 차단 완료. 잔류 증기 자연 확산중. 풍속 증가로 희석 촉진.',
|
|
||||||
detail: {
|
|
||||||
maxConc: '85ppm',
|
|
||||||
idlhRadius: '—',
|
|
||||||
erpg2: '0.4km',
|
|
||||||
windDir: 'W 270°',
|
|
||||||
windSpeed: '6.1 m/s',
|
|
||||||
population: '1,200명',
|
|
||||||
spillAmount: '0 (차단)',
|
|
||||||
},
|
|
||||||
zones: {
|
|
||||||
idlh: '— (해소)',
|
|
||||||
erpg2: '0.4 km (300ppm)',
|
|
||||||
erpg1: '1.2 km (50ppm)',
|
|
||||||
twa: '2.1 km (20ppm)',
|
|
||||||
},
|
|
||||||
weather: {
|
|
||||||
dir: 'W 270°',
|
|
||||||
speed: '6.1 m/s',
|
|
||||||
temp: '21.3°C',
|
|
||||||
stability: 'C (약간 불안정)',
|
|
||||||
humidity: '52%',
|
|
||||||
mixHeight: '1,200 m',
|
|
||||||
},
|
|
||||||
actions: ['IDLH 구역 해소 확인', '잔류 농도 지속 모니터링', '일부 대피 해제 검토'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'S-05',
|
|
||||||
name: '대기확산 해제',
|
|
||||||
severity: 'RESOLVED',
|
|
||||||
timeStep: 'T+12h',
|
|
||||||
datetime: '2024.11.03 20:00 KST',
|
|
||||||
wind: '풍속 7.3m/s NW',
|
|
||||||
maxConc: '8 ppm',
|
|
||||||
idlhRadius: '—',
|
|
||||||
erpg2: '—',
|
|
||||||
population: '0명',
|
|
||||||
description: '전 구역 안전 농도 확인. 대피 해제. 잔류 오염 모니터링 지속.',
|
|
||||||
detail: {
|
|
||||||
maxConc: '8ppm',
|
|
||||||
idlhRadius: '—',
|
|
||||||
erpg2: '—',
|
|
||||||
windDir: 'NW 315°',
|
|
||||||
windSpeed: '7.3 m/s',
|
|
||||||
population: '0명',
|
|
||||||
spillAmount: '0 (종료)',
|
|
||||||
},
|
|
||||||
zones: { idlh: '— (해소)', erpg2: '— (해소)', erpg1: '— (해소)', twa: '0.3 km (20ppm)' },
|
|
||||||
weather: {
|
|
||||||
dir: 'NW 315°',
|
|
||||||
speed: '7.3 m/s',
|
|
||||||
temp: '16.8°C',
|
|
||||||
stability: 'D (중립)',
|
|
||||||
humidity: '68%',
|
|
||||||
mixHeight: '780 m',
|
|
||||||
},
|
|
||||||
actions: ['전 구역 대피 해제', '잔류 오염 최종 모니터링', '사후 환경 평가 실시'],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const MATERIALS: HnsMaterial[] = [
|
|
||||||
{
|
|
||||||
key: 'toluene',
|
|
||||||
name: '톨루엔',
|
|
||||||
mw: '92.14',
|
|
||||||
bp: '110.6°C',
|
|
||||||
fp: '4°C',
|
|
||||||
idlh: '500 ppm',
|
|
||||||
erpg2: '300 ppm',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'ammonia',
|
|
||||||
name: '암모니아',
|
|
||||||
mw: '17.03',
|
|
||||||
bp: '-33.3°C',
|
|
||||||
fp: 'N/A',
|
|
||||||
idlh: '300 ppm',
|
|
||||||
erpg2: '200 ppm',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'methanol',
|
|
||||||
name: '메탄올',
|
|
||||||
mw: '32.04',
|
|
||||||
bp: '64.7°C',
|
|
||||||
fp: '11°C',
|
|
||||||
idlh: '6,000 ppm',
|
|
||||||
erpg2: '1,000 ppm',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'hydrogen',
|
|
||||||
name: '수소',
|
|
||||||
mw: '2.016',
|
|
||||||
bp: '-252.9°C',
|
|
||||||
fp: 'N/A',
|
|
||||||
idlh: 'N/A',
|
|
||||||
erpg2: 'N/A',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'benzene',
|
|
||||||
name: '벤젠',
|
|
||||||
mw: '78.11',
|
|
||||||
bp: '80.1°C',
|
|
||||||
fp: '-11°C',
|
|
||||||
idlh: '500 ppm',
|
|
||||||
erpg2: '150 ppm',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'styrene',
|
|
||||||
name: '스티렌',
|
|
||||||
mw: '104.15',
|
|
||||||
bp: '145°C',
|
|
||||||
fp: '31°C',
|
|
||||||
idlh: '700 ppm',
|
|
||||||
erpg2: '250 ppm',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'lng',
|
|
||||||
name: 'LNG',
|
|
||||||
mw: '16.04',
|
|
||||||
bp: '-161.5°C',
|
|
||||||
fp: '-188°C',
|
|
||||||
idlh: 'N/A',
|
|
||||||
erpg2: '25,000 ppm',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ─── Main Component ─────────────────────────────────────
|
|
||||||
export function HNSScenarioView() {
|
|
||||||
const [incidents, setIncidents] = useState<HnsAnalysisItem[]>([]);
|
|
||||||
const [selectedIncident, setSelectedIncident] = useState(0);
|
|
||||||
const [scenarios, setScenarios] = useState(MOCK_SCENARIOS);
|
|
||||||
const [selectedIdx, setSelectedIdx] = useState(0);
|
|
||||||
const [checked, setChecked] = useState<Set<number>>(new Set([0, 1]));
|
|
||||||
const [activeView, setActiveView] = useState<ViewTab>(0);
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
fetchHnsAnalyses()
|
|
||||||
.then((items) => {
|
|
||||||
if (!cancelled) setIncidents(items);
|
|
||||||
})
|
|
||||||
.catch((err) => console.error('[hns] 사고 목록 조회 실패:', err));
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const selected = scenarios[selectedIdx];
|
|
||||||
|
|
||||||
const toggleCheck = (idx: number) => {
|
|
||||||
setChecked((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(idx)) next.delete(idx);
|
|
||||||
else next.add(idx);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col flex-1 w-full h-full overflow-hidden bg-bg-base">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between shrink-0 border-b border-stroke px-5 py-[14px] bg-bg-surface">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<span className="text-base">📊</span>
|
|
||||||
<div>
|
|
||||||
<div className="text-title-4 font-bold">HNS 대기확산 시나리오 관리</div>
|
|
||||||
<div className="text-label-2 text-fg-disabled">
|
|
||||||
시간·조건별 대기확산 예측 시나리오 비교·검토 및 대응 의사결정 지원
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<select
|
|
||||||
value={selectedIncident}
|
|
||||||
onChange={(e) => setSelectedIncident(Number(e.target.value))}
|
|
||||||
className="prd-i w-[280px] text-label-2"
|
|
||||||
>
|
|
||||||
{incidents.length === 0 ? (
|
|
||||||
<option value={0}>분석 데이터 없음</option>
|
|
||||||
) : (
|
|
||||||
incidents.map((inc, i) => (
|
|
||||||
<option key={inc.hnsAnlysSn} value={i}>
|
|
||||||
HNS-{String(inc.hnsAnlysSn).padStart(3, '0')} · {inc.anlysNm}
|
|
||||||
</option>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
onClick={() => setModalOpen(true)}
|
|
||||||
className="cursor-pointer whitespace-nowrap font-semibold text-color-accent text-label-2 px-[14px] py-1.5 rounded-sm"
|
|
||||||
style={{
|
|
||||||
border: '1px solid rgba(6,182,212,.3)',
|
|
||||||
background: 'rgba(6,182,212,.08)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
+ 신규 시나리오
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body: Left list + Right detail */}
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
|
||||||
{/* ── Left: Scenario List ── */}
|
|
||||||
<div
|
|
||||||
className="flex flex-col overflow-hidden shrink-0 border-r border-stroke bg-bg-surface"
|
|
||||||
style={{ width: '370px', minWidth: '370px' }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between border-b border-stroke px-[14px] py-2.5">
|
|
||||||
<span className="text-label-2 font-bold text-fg-disabled">
|
|
||||||
시나리오 목록 — 톨루엔 대기확산
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{['시간순', '위험도순'].map((label, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
className={`cursor-pointer px-2 py-[3px] text-caption font-semibold rounded-sm border border-stroke ${
|
|
||||||
i === 0
|
|
||||||
? 'bg-[rgba(6,182,212,0.08)] text-color-accent'
|
|
||||||
: 'bg-bg-card text-fg-disabled'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scrollable list */}
|
|
||||||
<div
|
|
||||||
className="flex-1 overflow-y-auto flex flex-col gap-1.5 p-2"
|
|
||||||
style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}
|
|
||||||
>
|
|
||||||
{scenarios.map((scn, idx) => {
|
|
||||||
const sev = SEVERITY_STYLE[scn.severity];
|
|
||||||
const isSel = selectedIdx === idx;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={scn.id}
|
|
||||||
className={`hns-scn-card ${isSel ? 'sel' : ''}`}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedIdx(idx);
|
|
||||||
setActiveView(0);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Title + badge */}
|
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checked.has(idx)}
|
|
||||||
onChange={() => toggleCheck(idx)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
style={{ accentColor: 'var(--color-accent)' }}
|
|
||||||
/>
|
|
||||||
<span className="text-label-1 font-bold">
|
|
||||||
{scn.id} {scn.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className="font-bold px-2 py-[2px] rounded-lg text-caption"
|
|
||||||
style={{ background: sev.bg, color: sev.color }}
|
|
||||||
>
|
|
||||||
{scn.severity}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Time row */}
|
|
||||||
<div className="flex items-center gap-1.5 mb-1.5">
|
|
||||||
<span
|
|
||||||
className="font-bold font-mono text-color-accent text-caption px-1.5 py-[2px] rounded-[3px]"
|
|
||||||
style={{ background: 'rgba(6,182,212,0.1)' }}
|
|
||||||
>
|
|
||||||
{scn.timeStep}
|
|
||||||
</span>
|
|
||||||
<span className="text-caption text-fg-disabled font-mono">{scn.datetime}</span>
|
|
||||||
<span className="ml-auto text-fg-disabled text-caption">{scn.wind}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Metrics grid */}
|
|
||||||
<div className="grid grid-cols-4 gap-1 font-mono text-caption">
|
|
||||||
{[
|
|
||||||
{ label: '최대농도', value: scn.maxConc, color: 'var(--color-accent)' },
|
|
||||||
{ label: 'IDLH반경', value: scn.idlhRadius, color: 'var(--color-accent)' },
|
|
||||||
{ label: 'ERPG-2', value: scn.erpg2, color: 'var(--color-accent)' },
|
|
||||||
{ label: '영향인구', value: scn.population, color: 'var(--color-accent)' },
|
|
||||||
].map((m, i) => (
|
|
||||||
<div key={i} className="text-center p-[3px] bg-bg-base rounded-[3px]">
|
|
||||||
<div className="text-fg-disabled text-caption">{m.label}</div>
|
|
||||||
<div className="font-bold" style={{ color: m.color }}>
|
|
||||||
{m.value}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div className="text-fg-sub mt-1.5 text-caption leading-[1.4]">
|
|
||||||
{scn.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom buttons */}
|
|
||||||
<div className="flex gap-2 border-t border-stroke px-[14px] py-2.5">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveView(1)}
|
|
||||||
className="flex-1 cursor-pointer font-semibold text-fg-sub text-label-2 p-2 rounded-sm bg-bg-card border border-stroke hover:bg-color-accent hover:text-fg"
|
|
||||||
>
|
|
||||||
선택 시나리오 비교
|
|
||||||
</button>
|
|
||||||
<button className="cursor-pointer font-semibold text-fg-sub text-label-2 px-[14px] py-2 rounded-sm bg-bg-card border border-stroke">
|
|
||||||
보고서
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Right: Detail Views ── */}
|
|
||||||
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
|
|
||||||
{/* View Tabs */}
|
|
||||||
<div className="flex border-b border-stroke shrink-0 px-4 bg-bg-surface">
|
|
||||||
{['시나리오 상세', '비교 차트', '확산범위 오버레이'].map((label, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
onClick={() => setActiveView(i as ViewTab)}
|
|
||||||
className={`rsc-atab ${activeView === i ? 'on' : ''}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* View 0: Detail */}
|
|
||||||
{activeView === 0 && selected && <ScenarioDetail scenario={selected} />}
|
|
||||||
|
|
||||||
{/* View 1: Comparison */}
|
|
||||||
{activeView === 1 && <ScenarioComparison />}
|
|
||||||
|
|
||||||
{/* View 2: Map overlay */}
|
|
||||||
{activeView === 2 && <ScenarioMapOverlay />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* New Scenario Modal */}
|
|
||||||
<NewScenarioModal
|
|
||||||
isOpen={modalOpen}
|
|
||||||
onClose={() => setModalOpen(false)}
|
|
||||||
onSubmit={(name) => {
|
|
||||||
const newScn: HnsScenario = {
|
|
||||||
...MOCK_SCENARIOS[0],
|
|
||||||
id: `S-${String(scenarios.length + 1).padStart(2, '0')}`,
|
|
||||||
name,
|
|
||||||
severity: 'MEDIUM',
|
|
||||||
};
|
|
||||||
setScenarios((prev) => [...prev, newScn]);
|
|
||||||
setModalOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,143 +0,0 @@
|
|||||||
import { useState, useRef } from 'react';
|
|
||||||
import { SystemOverviewPanel } from './contents/SystemOverviewPanel';
|
|
||||||
import { GaussianModelPanel } from './contents/GaussianModelPanel';
|
|
||||||
import { SubstanceScenarioPanel } from './contents/SubstanceScenarioPanel';
|
|
||||||
import { OceanCorrectionPanel } from './contents/OceanCorrectionPanel';
|
|
||||||
import { VerificationPanel } from './contents/VerificationPanel';
|
|
||||||
import { RealtimeComparePanel } from './contents/RealtimeComparePanel';
|
|
||||||
import { WrfChemPanel } from './contents/WrfChemPanel';
|
|
||||||
/* eslint-disable react-refresh/only-export-components */
|
|
||||||
|
|
||||||
const theoryTabs = [
|
|
||||||
{ icon: '🔬', name: '시스템 개요' },
|
|
||||||
{ icon: '🌀', name: '가우시안 모델' },
|
|
||||||
{ icon: '🧪', name: '물질별 시나리오' },
|
|
||||||
{ icon: '🌊', name: '해양환경 보정' },
|
|
||||||
{ icon: '✅', name: '모델 검증' },
|
|
||||||
{ icon: '⚡', name: '실시간 비교' },
|
|
||||||
{ icon: '🚀', name: 'WRF-Chem·발전' },
|
|
||||||
];
|
|
||||||
|
|
||||||
/* ═══ 공통 스타일 유틸 ═══ */
|
|
||||||
export const card = 'rounded-[10px] p-[14px] mb-4';
|
|
||||||
export const cardBg = 'bg-bg-card border border-stroke';
|
|
||||||
export const labelStyle = (color: string) =>
|
|
||||||
({ fontSize: 'var(--font-size-title-3)', fontWeight: 700, color, marginBottom: '10px' }) as const;
|
|
||||||
export const tag = (color: string) =>
|
|
||||||
({
|
|
||||||
padding: '3px 8px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontSize: 'var(--font-size-label-2)',
|
|
||||||
color,
|
|
||||||
background: `${color}14`,
|
|
||||||
border: `1px solid ${color}30`,
|
|
||||||
}) as const;
|
|
||||||
export const bodyText = 'text-label-1 text-fg-sub leading-[1.8]';
|
|
||||||
|
|
||||||
/* ═══ 패널 0: 시스템 개요 ═══ */
|
|
||||||
|
|
||||||
|
|
||||||
export function HNSTheoryView() {
|
|
||||||
const [activePanel, setActivePanel] = useState(0);
|
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const handleExportPDF = () => {
|
|
||||||
if (!contentRef.current) return;
|
|
||||||
const clone = contentRef.current.cloneNode(true) as HTMLElement;
|
|
||||||
clone.querySelectorAll('[data-html2pdf-ignore]').forEach((el) => el.remove());
|
|
||||||
const content = clone.innerHTML;
|
|
||||||
const styles = Array.from(document.querySelectorAll('style, link[rel="stylesheet"]'))
|
|
||||||
.map((el) => el.outerHTML)
|
|
||||||
.join('\n');
|
|
||||||
const fullHtml = `<!DOCTYPE html>
|
|
||||||
<html><head><meta charset="utf-8"/>
|
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:;">
|
|
||||||
<title>HNS 대기확산 모델 이론</title>
|
|
||||||
${styles}
|
|
||||||
<style>
|
|
||||||
:root { --t1: #ffffff !important; --t2: #d0d6e6 !important; --t3: #a8b0c8 !important; }
|
|
||||||
@media print { @page { size: A4; margin: 10mm; } body { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; } }
|
|
||||||
body { background: var(--bg-base); color: var(--fg-default); font-family: var(--font-korean); padding: 20px 24px; }
|
|
||||||
</style>
|
|
||||||
</head><body>${content}</body></html>`;
|
|
||||||
const blob = new Blob([fullHtml], { type: 'text/html; charset=utf-8' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const win = window.open(url, '_blank');
|
|
||||||
if (win) {
|
|
||||||
win.addEventListener('afterprint', () => URL.revokeObjectURL(url));
|
|
||||||
setTimeout(() => {
|
|
||||||
win.document.title = 'HNS_대기확산_모델_이론';
|
|
||||||
win.print();
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 30000);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col flex-1 h-full overflow-hidden bg-bg-base">
|
|
||||||
<div
|
|
||||||
className="flex-1 overflow-y-auto scrollbar-thin p-5"
|
|
||||||
style={{ scrollbarGutter: 'stable' }}
|
|
||||||
ref={contentRef}
|
|
||||||
>
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="flex items-center justify-between mb-5">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
className="w-[42px] h-[42px] rounded-[10px] flex items-center justify-center text-heading-3"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(6,182,212,.04)',
|
|
||||||
border: '1px solid rgba(6,182,212,.3)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
📐
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-title-2 font-bold text-fg">HNS 대기확산 모델 이론 및 검증</div>
|
|
||||||
<div className="text-label-2 text-fg-disabled mt-0.5">
|
|
||||||
WRF-Chem · Gaussian Plume/Puff · ROMS · 해양환경 보정 — Based on Lee Moon-Jin et al.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleExportPDF}
|
|
||||||
data-html2pdf-ignore
|
|
||||||
className="px-3.5 py-1.5 rounded-sm text-label-2 font-semibold cursor-pointer text-color-accent"
|
|
||||||
style={{
|
|
||||||
border: '1px solid rgba(6,182,212,.3)',
|
|
||||||
background: 'rgba(6,182,212,.08)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
📤 PDF 내보내기
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 서브탭 */}
|
|
||||||
<div className="flex gap-0.5 rounded-lg p-1 mb-5 bg-bg-card border border-stroke">
|
|
||||||
{theoryTabs.map((tab, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
onClick={() => setActivePanel(i)}
|
|
||||||
className={`flex-1 py-2 px-1 text-label-1 font-medium rounded-md transition-all duration-150 cursor-pointer border ${
|
|
||||||
activePanel === i
|
|
||||||
? 'border-stroke-light bg-bg-elevated text-fg'
|
|
||||||
: 'border-transparent bg-bg-card text-fg-disabled hover:text-fg-sub'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tab.icon} {tab.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 패널 콘텐츠 */}
|
|
||||||
{activePanel === 0 && <SystemOverviewPanel />}
|
|
||||||
{activePanel === 1 && <GaussianModelPanel />}
|
|
||||||
{activePanel === 2 && <SubstanceScenarioPanel />}
|
|
||||||
{activePanel === 3 && <OceanCorrectionPanel />}
|
|
||||||
{activePanel === 4 && <VerificationPanel />}
|
|
||||||
{activePanel === 5 && <RealtimeComparePanel />}
|
|
||||||
{activePanel === 6 && <WrfChemPanel />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,718 +0,0 @@
|
|||||||
import { cardBg } from '../HNSTheoryView';
|
|
||||||
|
|
||||||
export function GaussianModelPanel() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
|
||||||
{/* 가우시안 플룸 모델 */}
|
|
||||||
<div className={`${cardBg} rounded-[10px] p-4`}>
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '28px',
|
|
||||||
height: '28px',
|
|
||||||
borderRadius: '6px',
|
|
||||||
background: 'rgba(6,182,212,.15)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: 'var(--font-size-title-3)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
🌀
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 'var(--font-size-title-4)',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: 'var(--fg-default)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Gaussian Plume Model
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="font-mono"
|
|
||||||
style={{ fontSize: 'var(--font-size-caption)', color: 'var(--color-accent)' }}
|
|
||||||
>
|
|
||||||
연속 방출 (Continuous Release)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: 'var(--bg-base)',
|
|
||||||
border: '1px solid rgba(6,182,212,.2)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '14px',
|
|
||||||
marginBottom: '12px',
|
|
||||||
}}
|
|
||||||
className="font-mono"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 'var(--font-size-caption)',
|
|
||||||
color: 'var(--fg-disabled)',
|
|
||||||
marginBottom: '6px',
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
📌 농도 산출식 (Concentration Equation)
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 'var(--font-size-label-2)',
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
lineHeight: 2,
|
|
||||||
letterSpacing: '.3px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
C(x,y,z) = <span style={{ color: 'var(--color-accent)' }}>Q</span> / (2π ·{' '}
|
|
||||||
<span style={{ color: 'var(--color-accent)' }}>
|
|
||||||
σ<sub>y</sub>
|
|
||||||
</span>{' '}
|
|
||||||
·{' '}
|
|
||||||
<span style={{ color: 'var(--color-accent)' }}>
|
|
||||||
σ<sub>z</sub>
|
|
||||||
</span>{' '}
|
|
||||||
· <span style={{ color: 'var(--color-accent)' }}>u</span>)
|
|
||||||
<br />× exp(-y² / 2
|
|
||||||
<span style={{ color: 'var(--color-accent)' }}>
|
|
||||||
σ<sub>y</sub>
|
|
||||||
</span>
|
|
||||||
²)
|
|
||||||
<br />× [exp(-(z-H)² / 2
|
|
||||||
<span style={{ color: 'var(--color-accent)' }}>
|
|
||||||
σ<sub>z</sub>
|
|
||||||
</span>
|
|
||||||
²) + exp(-(z+H)² / 2
|
|
||||||
<span style={{ color: 'var(--color-accent)' }}>
|
|
||||||
σ<sub>z</sub>
|
|
||||||
</span>
|
|
||||||
²)]
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="grid grid-cols-2 gap-[6px]"
|
|
||||||
style={{ fontSize: 'var(--font-size-caption)' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '6px 8px',
|
|
||||||
background: 'rgba(6,182,212,.05)',
|
|
||||||
border: '1px solid rgba(6,182,212,.12)',
|
|
||||||
borderRadius: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="font-mono" style={{ color: 'var(--color-accent)', fontWeight: 700 }}>
|
|
||||||
Q
|
|
||||||
</span>{' '}
|
|
||||||
<span style={{ color: 'var(--fg-disabled)' }}>= 배출률 (g/s)</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '6px 8px',
|
|
||||||
background: 'rgba(6,182,212,.05)',
|
|
||||||
border: '1px solid rgba(6,182,212,.12)',
|
|
||||||
borderRadius: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="font-mono" style={{ color: 'var(--color-accent)', fontWeight: 700 }}>
|
|
||||||
u
|
|
||||||
</span>{' '}
|
|
||||||
<span style={{ color: 'var(--fg-disabled)' }}>= 풍속 (m/s)</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '6px 8px',
|
|
||||||
background: 'rgba(6,182,212,.05)',
|
|
||||||
border: '1px solid rgba(6,182,212,.12)',
|
|
||||||
borderRadius: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="font-mono" style={{ color: 'var(--color-accent)', fontWeight: 700 }}>
|
|
||||||
σ<sub>y</sub>
|
|
||||||
</span>{' '}
|
|
||||||
<span style={{ color: 'var(--fg-disabled)' }}>= 수평 확산계수</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '6px 8px',
|
|
||||||
background: 'rgba(6,182,212,.05)',
|
|
||||||
border: '1px solid rgba(6,182,212,.12)',
|
|
||||||
borderRadius: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="font-mono" style={{ color: 'var(--color-accent)', fontWeight: 700 }}>
|
|
||||||
σ<sub>z</sub>
|
|
||||||
</span>{' '}
|
|
||||||
<span style={{ color: 'var(--fg-disabled)' }}>= 연직 확산계수</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '6px 8px',
|
|
||||||
background: 'rgba(6,182,212,.04)',
|
|
||||||
border: '1px solid rgba(6,182,212,.12)',
|
|
||||||
borderRadius: '4px',
|
|
||||||
gridColumn: 'span 2',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="font-mono" style={{ color: 'var(--color-accent)', fontWeight: 700 }}>
|
|
||||||
H
|
|
||||||
</span>{' '}
|
|
||||||
<span style={{ color: 'var(--fg-disabled)' }}>
|
|
||||||
= 유효 방출고도 (m) — 물리적 높이 + 부력 상승 보정
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: '10px',
|
|
||||||
padding: '8px',
|
|
||||||
background: 'rgba(6,182,212,.04)',
|
|
||||||
border: '1px solid rgba(6,182,212,.12)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: 'var(--font-size-caption)',
|
|
||||||
color: 'var(--fg-sub)',
|
|
||||||
lineHeight: 1.6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
💡 <b style={{ color: 'var(--color-accent)' }}>적용 조건:</b> 정상 상태 연속 배출, 균일
|
|
||||||
풍속, 평탄 지형. 해양 HNS 사고에서 탱크 파손으로 인한 지속적 누출 시나리오에 적합.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 가우시안 퍼프 모델 */}
|
|
||||||
<div className={`${cardBg} rounded-[10px] p-4`}>
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '28px',
|
|
||||||
height: '28px',
|
|
||||||
borderRadius: '6px',
|
|
||||||
background: 'rgba(6,182,212,.15)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: 'var(--font-size-title-3)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
💨
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 'var(--font-size-title-4)',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: 'var(--fg-default)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Gaussian Puff Model
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="font-mono"
|
|
||||||
style={{ fontSize: 'var(--font-size-caption)', color: 'var(--color-accent)' }}
|
|
||||||
>
|
|
||||||
순간 방출 (Instantaneous Release)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: 'var(--bg-base)',
|
|
||||||
border: '1px solid rgba(6,182,212,.2)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '14px',
|
|
||||||
marginBottom: '12px',
|
|
||||||
}}
|
|
||||||
className="font-mono"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 'var(--font-size-caption)',
|
|
||||||
color: 'var(--fg-disabled)',
|
|
||||||
marginBottom: '6px',
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
📌 농도 산출식 (Puff Concentration)
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 'var(--font-size-label-2)',
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
lineHeight: 2,
|
|
||||||
letterSpacing: '.3px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
C(x,y,z,t) = <span style={{ color: 'var(--color-accent)' }}>M</span> / [(2π)
|
|
||||||
<sup>3/2</sup> ·{' '}
|
|
||||||
<span style={{ color: 'var(--color-accent)' }}>
|
|
||||||
σ<sub>x</sub>
|
|
||||||
</span>{' '}
|
|
||||||
·{' '}
|
|
||||||
<span style={{ color: 'var(--color-accent)' }}>
|
|
||||||
σ<sub>y</sub>
|
|
||||||
</span>{' '}
|
|
||||||
·{' '}
|
|
||||||
<span style={{ color: 'var(--color-accent)' }}>
|
|
||||||
σ<sub>z</sub>
|
|
||||||
</span>
|
|
||||||
]
|
|
||||||
<br />× exp(-(x-<span style={{ color: 'var(--color-accent)' }}>u</span>t)² / 2
|
|
||||||
<span style={{ color: 'var(--color-accent)' }}>
|
|
||||||
σ<sub>x</sub>
|
|
||||||
</span>
|
|
||||||
²)
|
|
||||||
<br />× exp(-y² / 2
|
|
||||||
<span style={{ color: 'var(--color-accent)' }}>
|
|
||||||
σ<sub>y</sub>
|
|
||||||
</span>
|
|
||||||
²)
|
|
||||||
<br />× [exp(-(z-H)² / 2
|
|
||||||
<span style={{ color: 'var(--color-accent)' }}>
|
|
||||||
σ<sub>z</sub>
|
|
||||||
</span>
|
|
||||||
²) + exp(-(z+H)² / 2
|
|
||||||
<span style={{ color: 'var(--color-accent)' }}>
|
|
||||||
σ<sub>z</sub>
|
|
||||||
</span>
|
|
||||||
²)]
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="grid grid-cols-2 gap-[6px]"
|
|
||||||
style={{ fontSize: 'var(--font-size-caption)' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '6px 8px',
|
|
||||||
background: 'rgba(6,182,212,.05)',
|
|
||||||
border: '1px solid rgba(6,182,212,.12)',
|
|
||||||
borderRadius: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="font-mono" style={{ color: 'var(--color-accent)', fontWeight: 700 }}>
|
|
||||||
M
|
|
||||||
</span>{' '}
|
|
||||||
<span style={{ color: 'var(--fg-disabled)' }}>= 총 배출량 (g)</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '6px 8px',
|
|
||||||
background: 'rgba(6,182,212,.05)',
|
|
||||||
border: '1px solid rgba(6,182,212,.12)',
|
|
||||||
borderRadius: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="font-mono" style={{ color: 'var(--color-accent)', fontWeight: 700 }}>
|
|
||||||
t
|
|
||||||
</span>{' '}
|
|
||||||
<span style={{ color: 'var(--fg-disabled)' }}>= 경과시간 (s)</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '6px 8px',
|
|
||||||
background: 'rgba(6,182,212,.05)',
|
|
||||||
border: '1px solid rgba(6,182,212,.12)',
|
|
||||||
borderRadius: '4px',
|
|
||||||
gridColumn: 'span 2',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="font-mono" style={{ color: 'var(--color-accent)', fontWeight: 700 }}>
|
|
||||||
σ<sub>x</sub>
|
|
||||||
</span>{' '}
|
|
||||||
<span style={{ color: 'var(--fg-disabled)' }}>
|
|
||||||
= 풍하방향 확산계수 — Plume에서는 u에 의해 이미 반영
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: '10px',
|
|
||||||
padding: '8px',
|
|
||||||
background: 'rgba(6,182,212,.04)',
|
|
||||||
border: '1px solid rgba(6,182,212,.12)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: 'var(--font-size-caption)',
|
|
||||||
color: 'var(--fg-sub)',
|
|
||||||
lineHeight: 1.6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
💡 <b style={{ color: 'var(--color-accent)' }}>적용 조건:</b> 순간적 대량 방출,
|
|
||||||
폭발·탱크 파열. 해양 사고 중 LPG/LNG 탱크 파열, 수소 연료선 폭발 시나리오에 적합.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pasquill-Gifford 확산 계수 테이블 */}
|
|
||||||
<div className={`${cardBg} rounded-[10px] p-4 mb-4`}>
|
|
||||||
<div className="flex items-center justify-between mb-[14px]">
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 'var(--font-size-title-4)',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: 'var(--fg-default)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
📊 Pasquill-Gifford 대기안정도 분류 및 확산계수
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="font-mono"
|
|
||||||
style={{ fontSize: 'var(--font-size-caption)', color: 'var(--fg-disabled)' }}
|
|
||||||
>
|
|
||||||
σ<sub>y</sub> = a·x<sup>b</sup> , σ<sub>z</sub> = c·x<sup>d</sup> + f
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ overflowX: 'auto' }}>
|
|
||||||
<table
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
borderCollapse: 'collapse',
|
|
||||||
fontSize: 'var(--font-size-caption)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<thead>
|
|
||||||
<tr style={{ background: 'rgba(6,182,212,.08)' }}>
|
|
||||||
<th
|
|
||||||
style={{
|
|
||||||
padding: '8px 10px',
|
|
||||||
textAlign: 'left',
|
|
||||||
borderBottom: '2px solid var(--stroke-light)',
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
fontWeight: 700,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
안정도
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
style={{
|
|
||||||
padding: '8px 10px',
|
|
||||||
textAlign: 'center',
|
|
||||||
borderBottom: '2px solid var(--stroke-light)',
|
|
||||||
color: 'var(--fg-sub)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
분류
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
style={{
|
|
||||||
padding: '8px 10px',
|
|
||||||
textAlign: 'center',
|
|
||||||
borderBottom: '2px solid var(--stroke-light)',
|
|
||||||
color: 'var(--fg-sub)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
기상 조건
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
className="font-mono"
|
|
||||||
style={{
|
|
||||||
padding: '8px 10px',
|
|
||||||
textAlign: 'center',
|
|
||||||
borderBottom: '2px solid var(--stroke-light)',
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
σ<sub>y</sub> 계수 (a, b)
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
className="font-mono"
|
|
||||||
style={{
|
|
||||||
padding: '8px 10px',
|
|
||||||
textAlign: 'center',
|
|
||||||
borderBottom: '2px solid var(--stroke-light)',
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
σ<sub>z</sub> 계수 (c, d, f)
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
style={{
|
|
||||||
padding: '8px 10px',
|
|
||||||
textAlign: 'center',
|
|
||||||
borderBottom: '2px solid var(--stroke-light)',
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
풍속 범위
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
style={{
|
|
||||||
padding: '8px 10px',
|
|
||||||
textAlign: 'center',
|
|
||||||
borderBottom: '2px solid var(--stroke-light)',
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
해양 적용성
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
grade: 'A',
|
|
||||||
class: '매우 불안정',
|
|
||||||
cond: '맑은 날 강한 일사',
|
|
||||||
sy: '0.3658, 0.9024',
|
|
||||||
sz: '0.192, 0.936, 0',
|
|
||||||
wind: '< 2 m/s',
|
|
||||||
ocean: {
|
|
||||||
label: '드물게',
|
|
||||||
bg: 'rgba(6,182,212,.12)',
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
},
|
|
||||||
gradeColor: 'var(--color-accent)',
|
|
||||||
rowBg: undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
grade: 'B',
|
|
||||||
class: '불안정',
|
|
||||||
cond: '맑은 날 약한 일사',
|
|
||||||
sy: '0.2751, 0.9031',
|
|
||||||
sz: '0.156, 0.922, 0',
|
|
||||||
wind: '2–3 m/s',
|
|
||||||
ocean: {
|
|
||||||
label: '드물게',
|
|
||||||
bg: 'rgba(6,182,212,.12)',
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
},
|
|
||||||
gradeColor: 'var(--color-accent)',
|
|
||||||
rowBg: undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
grade: 'C',
|
|
||||||
class: '약간 불안정',
|
|
||||||
cond: '흐린 날 주간',
|
|
||||||
sy: '0.2090, 0.9031',
|
|
||||||
sz: '0.116, 0.905, 0',
|
|
||||||
wind: '3–5 m/s',
|
|
||||||
ocean: {
|
|
||||||
label: '적합',
|
|
||||||
bg: 'rgba(6,182,212,.12)',
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
},
|
|
||||||
gradeColor: 'var(--color-accent)',
|
|
||||||
rowBg: undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
grade: 'D ★',
|
|
||||||
class: '중립',
|
|
||||||
cond: '흐린 날 / 해상풍',
|
|
||||||
sy: '0.1471, 0.9031',
|
|
||||||
sz: '0.079, 0.881, 0',
|
|
||||||
wind: '5–6 m/s',
|
|
||||||
ocean: {
|
|
||||||
label: '★ 해양 대표',
|
|
||||||
bg: 'rgba(6,182,212,.15)',
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
fontWeight: 700,
|
|
||||||
},
|
|
||||||
gradeColor: 'var(--color-accent)',
|
|
||||||
rowBg: 'rgba(6,182,212,.03)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
grade: 'E',
|
|
||||||
class: '약간 안정',
|
|
||||||
cond: '야간 약한 바람',
|
|
||||||
sy: '0.1046, 0.9031',
|
|
||||||
sz: '0.064, 0.871, 0',
|
|
||||||
wind: '3–5 m/s',
|
|
||||||
ocean: {
|
|
||||||
label: '적합',
|
|
||||||
bg: 'rgba(6,182,212,.12)',
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
},
|
|
||||||
gradeColor: 'var(--color-accent)',
|
|
||||||
rowBg: undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
grade: 'F',
|
|
||||||
class: '안정',
|
|
||||||
cond: '야간 맑은 하늘',
|
|
||||||
sy: '0.0722, 0.9031',
|
|
||||||
sz: '0.051, 0.814, 0',
|
|
||||||
wind: '< 3 m/s',
|
|
||||||
ocean: {
|
|
||||||
label: '위험↑',
|
|
||||||
bg: 'rgba(6,182,212,.12)',
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
},
|
|
||||||
gradeColor: 'var(--color-accent)',
|
|
||||||
rowBg: undefined,
|
|
||||||
},
|
|
||||||
].map((row, idx) => (
|
|
||||||
<tr
|
|
||||||
key={row.grade}
|
|
||||||
style={{
|
|
||||||
borderBottom: idx < 5 ? '1px solid rgba(255,255,255,.04)' : undefined,
|
|
||||||
background: row.rowBg,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<td style={{ padding: '7px 10px', fontWeight: 700, color: row.gradeColor }}>
|
|
||||||
{row.grade}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
style={{ padding: '7px 10px', textAlign: 'center', color: 'var(--fg-default)' }}
|
|
||||||
>
|
|
||||||
{row.class}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '7px 10px', textAlign: 'center', color: 'var(--fg-sub)' }}>
|
|
||||||
{row.cond}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className="font-mono"
|
|
||||||
style={{
|
|
||||||
padding: '7px 10px',
|
|
||||||
textAlign: 'center',
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{row.sy}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className="font-mono"
|
|
||||||
style={{
|
|
||||||
padding: '7px 10px',
|
|
||||||
textAlign: 'center',
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{row.sz}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className="font-mono"
|
|
||||||
style={{
|
|
||||||
padding: '7px 10px',
|
|
||||||
textAlign: 'center',
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{row.wind}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '7px 10px', textAlign: 'center' }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
padding: '2px 6px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
background: row.ocean.bg,
|
|
||||||
color: row.ocean.color,
|
|
||||||
fontSize: 'var(--font-size-caption)',
|
|
||||||
fontWeight: (row.ocean as { fontWeight?: number }).fontWeight ?? 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{row.ocean.label}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: '10px',
|
|
||||||
padding: '8px 10px',
|
|
||||||
background: 'rgba(6,182,212,.04)',
|
|
||||||
border: '1px solid rgba(6,182,212,.1)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: 'var(--font-size-caption)',
|
|
||||||
color: 'var(--fg-sub)',
|
|
||||||
lineHeight: 1.6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
★ 해양 환경에서는 <b style={{ color: 'var(--color-accent)' }}>D 등급(중립)</b>이 가장
|
|
||||||
빈번하게 나타남. 해풍·육풍 전환 시 일시적으로 A~C 등급 출현 가능. F 등급(안정)은 농도가
|
|
||||||
국지적으로 높게 유지되어 <b style={{ color: 'var(--color-accent)' }}>위험도 상승</b>.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 플룸 vs 퍼프 비교 */}
|
|
||||||
<div className={`${cardBg} rounded-[10px] p-4`}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 'var(--font-size-title-4)',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: 'var(--fg-default)',
|
|
||||||
marginBottom: '14px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
⚖ Plume vs Puff — 모델 선택 기준
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '12px',
|
|
||||||
background: 'rgba(6,182,212,.04)',
|
|
||||||
border: '1px solid rgba(6,182,212,.15)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 'var(--font-size-label-2)',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
marginBottom: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
🌀 Plume (연속 배출) 선택 기준
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="flex flex-col gap-[5px]"
|
|
||||||
style={{ fontSize: 'var(--font-size-caption)', color: 'var(--fg-sub)' }}
|
|
||||||
>
|
|
||||||
{[
|
|
||||||
'유출 지속시간 > 10분',
|
|
||||||
'탱크 균열/배관 파손 — 지속적 누출',
|
|
||||||
'풍속 > 1.5 m/s (정상류 가정 가능)',
|
|
||||||
'톨루엔, 벤젠, 자일렌 등 증발성 액체',
|
|
||||||
'암모니아 냉동 저장탱크 누출',
|
|
||||||
].map((item) => (
|
|
||||||
<div key={item} className="flex items-center gap-[5px]">
|
|
||||||
<span style={{ color: 'var(--color-accent)' }}>✓</span> {item}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '12px',
|
|
||||||
background: 'rgba(6,182,212,.04)',
|
|
||||||
border: '1px solid rgba(6,182,212,.15)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 'var(--font-size-label-2)',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
marginBottom: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
💨 Puff (순간 배출) 선택 기준
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="flex flex-col gap-[5px]"
|
|
||||||
style={{ fontSize: 'var(--font-size-caption)', color: 'var(--fg-sub)' }}
|
|
||||||
>
|
|
||||||
{[
|
|
||||||
'유출 지속시간 < 10분',
|
|
||||||
'탱크 폭발/BLEVE — 순간 방출',
|
|
||||||
'풍향 변동이 큰 경우 (여러 퍼프 중첩)',
|
|
||||||
'LPG, 수소, LNG 탱크 파열',
|
|
||||||
'컨테이너 화학물질 돌발 유출',
|
|
||||||
].map((item) => (
|
|
||||||
<div key={item} className="flex items-center gap-[5px]">
|
|
||||||
<span style={{ color: 'var(--color-accent)' }}>✓</span> {item}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,162 +0,0 @@
|
|||||||
export function HNSManualViewer() {
|
|
||||||
const card = 'rounded-md p-4 mb-3';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex-1 overflow-y-auto bg-bg-base"
|
|
||||||
style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}
|
|
||||||
>
|
|
||||||
<div className="px-5 py-4 max-w-[1200px] mx-auto">
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div>
|
|
||||||
<div className="text-base font-bold">📖 해양 HNS 대응 매뉴얼</div>
|
|
||||||
<div className="text-label-2 text-fg-disabled mt-0.5">
|
|
||||||
Marine HNS Response Manual — Bonn Agreement / HELCOM / REMPEC (WestMOPoCo 2024
|
|
||||||
한국어판)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 목차 카드 그리드 */}
|
|
||||||
<div className="grid mb-5" style={{ gridTemplateColumns: 'repeat(4,1fr)', gap: '10px' }}>
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
icon: '📘',
|
|
||||||
title: '1. 서론',
|
|
||||||
desc: 'HNS 정의 · OPRC-HNS 의정서 · HNS 협약 범위 및 목적',
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '⚖️',
|
|
||||||
title: '2. IMO 협약·의정서·규칙',
|
|
||||||
desc: 'SOLAS · MARPOL · IBC Code · IMDG Code · IGC Code',
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '🔬',
|
|
||||||
title: '3. HNS 거동 및 유해요소',
|
|
||||||
desc: 'SEBC 거동분류 · MSDS · GESAMP · 물리화학적 특성',
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '🛡️',
|
|
||||||
title: '4. 대비',
|
|
||||||
desc: '위험 평가 · 비상 계획 · 교육훈련 · 장비 비축',
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '🚨',
|
|
||||||
title: '5. 대응',
|
|
||||||
desc: '최초 조치 · 안전구역 · PPE · 모니터링 · 대응 기술',
|
|
||||||
color: 'var(--color-info)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '🔄',
|
|
||||||
title: '6. 유출 후 관리',
|
|
||||||
desc: '비용 문서화 · 환경 회복 · 사고 검토 · 교훈',
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '📋',
|
|
||||||
title: '7. 사례연구',
|
|
||||||
desc: '실제 HNS 해양사고 사례 분석 및 교훈',
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '📊',
|
|
||||||
title: '8. 자료표',
|
|
||||||
desc: '물질별 데이터시트 · AEGL · 노출 한계값',
|
|
||||||
color: 'var(--color-accent)',
|
|
||||||
},
|
|
||||||
].map((ch) => (
|
|
||||||
<div
|
|
||||||
key={ch.title}
|
|
||||||
className={card}
|
|
||||||
style={{
|
|
||||||
background: 'var(--bg-card)',
|
|
||||||
border: '1px solid var(--stroke-default)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: '.2s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="text-[20px] mb-1.5">{ch.icon}</div>
|
|
||||||
<div className="text-label-2 font-bold">{ch.title}</div>
|
|
||||||
<div className="text-caption text-fg-disabled mt-1 leading-[1.4]">{ch.desc}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SEBC 거동 분류 */}
|
|
||||||
<div className={`${card} bg-bg-card border border-stroke`}>
|
|
||||||
<div className="text-title-4 font-bold mb-2.5">
|
|
||||||
SEBC 거동 분류 (Standard European Behaviour Classification)
|
|
||||||
</div>
|
|
||||||
<div className="text-caption text-fg-disabled mb-2.5 leading-normal">
|
|
||||||
물질의 물리적·화학적 특성(용해도, 밀도, 증기압, 점도)에 따라 이론적 거동을 5가지 주요
|
|
||||||
범주 + 7가지 하위 범주로 분류
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(5,1fr)' }}>
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
icon: '💨',
|
|
||||||
label: 'G — 가스',
|
|
||||||
desc: '대기 중 확산\n증기압 > 101.3kPa\n예: 암모니아, 염소',
|
|
||||||
color: 'rgba(139,92,246',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '🌫️',
|
|
||||||
label: 'E — 증발',
|
|
||||||
desc: '수면→대기 증발\n증기압 > 3kPa\n예: 벤젠, 톨루엔',
|
|
||||||
color: 'rgba(6,182,212',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '🟡',
|
|
||||||
label: 'F — 부유',
|
|
||||||
desc: '해수면에 부유\n밀도 < 1.025\n예: 스티렌, 크실렌',
|
|
||||||
color: 'rgba(251,191,36',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '💧',
|
|
||||||
label: 'D — 용해',
|
|
||||||
desc: '해수에 용해\n용해도 > 5%\n예: 메탄올, 염산',
|
|
||||||
color: 'rgba(6,182,212',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '⬇️',
|
|
||||||
label: 'S — 침강',
|
|
||||||
desc: '해저로 침강\n밀도 > 1.025\n예: EDC, 사염화탄소',
|
|
||||||
color: 'rgba(139,148,158',
|
|
||||||
},
|
|
||||||
].map((s) => (
|
|
||||||
<div
|
|
||||||
key={s.label}
|
|
||||||
className="text-center px-[6px] py-[10px] rounded-sm"
|
|
||||||
style={{ background: `${s.color},.08)`, border: `1px solid ${s.color},.2)` }}
|
|
||||||
>
|
|
||||||
<div className="text-[22px] mb-1">{s.icon}</div>
|
|
||||||
<div className="text-label-2 font-bold" style={{ color: `${s.color},1)` }}>
|
|
||||||
{s.label}
|
|
||||||
</div>
|
|
||||||
<div className="text-fg-disabled whitespace-pre-line text-caption mt-[3px] leading-[1.3]">
|
|
||||||
{s.desc}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 출처 */}
|
|
||||||
<div className="text-fg-disabled rounded-sm bg-bg-card p-[10px] text-caption leading-[1.5]">
|
|
||||||
<b>출처:</b> Marine HNS Response Manual — Bonn Agreement / HELCOM / REMPEC (WestMOPoCo
|
|
||||||
Project, 2024 한국어판)
|
|
||||||
<br />
|
|
||||||
번역: 원해민, 이시연, 양보경, 강성길, 이성엽 — KRISO 선박해양플랜트연구소 / NOWPAP MERRAC
|
|
||||||
<br />
|
|
||||||
원본: Alcaro L., Brandt J., Giraud W., Mannozzi M., Nicolas-Kopec A. (2021) ISBN:
|
|
||||||
978-2-87893-147-1
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
불러오는 중...
Reference in New Issue
Block a user