Compare commits
No commits in common. "51da334e7a84d552b8bfe2dcec134dfdf0bc47fe" and "bf41763925b66bca3613627ace94e8de397c7f7d" have entirely different histories.
51da334e7a
...
bf41763925
@ -5,29 +5,29 @@
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(curl -s *)",
|
||||
"Bash(fnm *)",
|
||||
"Bash(git add *)",
|
||||
"Bash(npm run *)",
|
||||
"Bash(npm install *)",
|
||||
"Bash(npm test *)",
|
||||
"Bash(npx *)",
|
||||
"Bash(node *)",
|
||||
"Bash(git status)",
|
||||
"Bash(git diff *)",
|
||||
"Bash(git log *)",
|
||||
"Bash(git branch *)",
|
||||
"Bash(git checkout *)",
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit *)",
|
||||
"Bash(git config *)",
|
||||
"Bash(git diff *)",
|
||||
"Bash(git fetch *)",
|
||||
"Bash(git log *)",
|
||||
"Bash(git merge *)",
|
||||
"Bash(git pull *)",
|
||||
"Bash(git fetch *)",
|
||||
"Bash(git merge *)",
|
||||
"Bash(git stash *)",
|
||||
"Bash(git remote *)",
|
||||
"Bash(git config *)",
|
||||
"Bash(git rev-parse *)",
|
||||
"Bash(git show *)",
|
||||
"Bash(git stash *)",
|
||||
"Bash(git status)",
|
||||
"Bash(git tag *)",
|
||||
"Bash(node *)",
|
||||
"Bash(npm install *)",
|
||||
"Bash(npm run *)",
|
||||
"Bash(npm test *)",
|
||||
"Bash(npx *)"
|
||||
"Bash(curl -s *)",
|
||||
"Bash(fnm *)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(git push --force*)",
|
||||
@ -83,7 +83,5 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"deny": [],
|
||||
"allow": []
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"applied_global_version": "1.6.1",
|
||||
"applied_date": "2026-03-13",
|
||||
"applied_date": "2026-03-11",
|
||||
"project_type": "react-ts",
|
||||
"gitea_url": "https://gitea.gc-si.dev",
|
||||
"custom_pre_commit": true
|
||||
|
||||
@ -18,7 +18,6 @@ interface PredictionAnalysis {
|
||||
backtrackStatus: string;
|
||||
analyst: string;
|
||||
officeName: string;
|
||||
acdntSttsCd: string;
|
||||
}
|
||||
|
||||
interface PredictionDetail {
|
||||
@ -130,7 +129,6 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<Prediction
|
||||
SELECT
|
||||
A.ACDNT_SN,
|
||||
A.ACDNT_NM,
|
||||
A.ACDNT_STTS_CD,
|
||||
A.OCCRN_DTM,
|
||||
A.LAT,
|
||||
A.LNG,
|
||||
@ -188,7 +186,6 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<Prediction
|
||||
backtrackStatus: String(row['backtrack_status'] ?? 'pending').toLowerCase(),
|
||||
analyst: String(row['analyst_nm'] ?? ''),
|
||||
officeName: String(row['office_nm'] ?? ''),
|
||||
acdntSttsCd: String(row['acdnt_stts_cd'] ?? 'ACTIVE'),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@ -47,30 +47,6 @@ const UNIT_MAP: Record<string, string> = {
|
||||
'kL': 'KL', 'ton': 'TON', 'barrel': 'BBL',
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 신규 생성된 ACDNT/SPIL_DATA/PRED_EXEC 롤백 헬퍼
|
||||
// Python 호출 실패 시 이번 요청에서 생성된 레코드만 삭제한다.
|
||||
// ============================================================
|
||||
async function rollbackNewRecords(
|
||||
predExecSn: number | null,
|
||||
newSpilDataSn: number | null,
|
||||
newAcdntSn: number | null
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (predExecSn !== null) {
|
||||
await wingPool.query('DELETE FROM wing.PRED_EXEC WHERE PRED_EXEC_SN=$1', [predExecSn])
|
||||
}
|
||||
if (newSpilDataSn !== null) {
|
||||
await wingPool.query('DELETE FROM wing.SPIL_DATA WHERE SPIL_DATA_SN=$1', [newSpilDataSn])
|
||||
}
|
||||
if (newAcdntSn !== null) {
|
||||
await wingPool.query('DELETE FROM wing.ACDNT WHERE ACDNT_SN=$1', [newAcdntSn])
|
||||
}
|
||||
} catch (cleanupErr) {
|
||||
console.error('[simulation] 롤백 실패:', cleanupErr)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POST /api/simulation/run
|
||||
// 확산 시뮬레이션 실행 (OpenDrift)
|
||||
@ -116,30 +92,9 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
|
||||
return res.status(400).json({ error: '사고명은 200자 이내여야 합니다.' })
|
||||
}
|
||||
|
||||
// 2. Python NC 파일 존재 여부 확인 (ACDNT 생성 전에 수행하여 고아 레코드 방지)
|
||||
try {
|
||||
const checkRes = await fetch(`${PYTHON_API_URL}/check-nc`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ lat, lon, startTime }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
if (!checkRes.ok) {
|
||||
return res.status(409).json({
|
||||
error: '해당 좌표의 해양 기상 데이터가 없습니다.',
|
||||
message: 'NC 파일이 준비되지 않았습니다.',
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Python 서버 미기동 — 5번에서 처리
|
||||
}
|
||||
|
||||
// 1-B. acdntSn 미제공 시 ACDNT + SPIL_DATA 생성
|
||||
let resolvedAcdntSn: number | null = rawAcdntSn ? Number(rawAcdntSn) : null
|
||||
let resolvedSpilDataSn: number | null = null
|
||||
// 이번 요청에서 신규 생성된 레코드 추적 (Python 실패 시 롤백 대상)
|
||||
let newlyCreatedAcdntSn: number | null = null
|
||||
let newlyCreatedSpilDataSn: number | null = null
|
||||
|
||||
if (!resolvedAcdntSn && acdntNm) {
|
||||
try {
|
||||
@ -161,7 +116,6 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
|
||||
[acdntNm.trim(), occrn, lat, lon]
|
||||
)
|
||||
resolvedAcdntSn = acdntRes.rows[0].acdnt_sn as number
|
||||
newlyCreatedAcdntSn = resolvedAcdntSn
|
||||
|
||||
const spilRes = await wingPool.query(
|
||||
`INSERT INTO wing.SPIL_DATA (ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, REG_DTM)
|
||||
@ -177,13 +131,30 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
|
||||
]
|
||||
)
|
||||
resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number
|
||||
newlyCreatedSpilDataSn = resolvedSpilDataSn
|
||||
} catch (dbErr) {
|
||||
console.error('[simulation] ACDNT/SPIL_DATA INSERT 실패:', dbErr)
|
||||
return res.status(500).json({ error: '사고 정보 생성 실패' })
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Python NC 파일 존재 여부 확인
|
||||
try {
|
||||
const checkRes = await fetch(`${PYTHON_API_URL}/check-nc`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ lat, lon, startTime }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
if (!checkRes.ok) {
|
||||
return res.status(409).json({
|
||||
error: '해당 좌표의 해양 기상 데이터가 없습니다.',
|
||||
message: 'NC 파일이 준비되지 않았습니다.',
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Python 서버 미기동 — 5번에서 처리
|
||||
}
|
||||
|
||||
// 3. 기존 사고의 경우 SPIL_DATA_SN 조회
|
||||
if (resolvedAcdntSn && !resolvedSpilDataSn) {
|
||||
try {
|
||||
@ -244,7 +215,6 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
|
||||
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`,
|
||||
[errData.error || '분석 서버 포화', predExecSn]
|
||||
)
|
||||
await rollbackNewRecords(predExecSn, newlyCreatedSpilDataSn, newlyCreatedAcdntSn)
|
||||
return res.status(503).json({ error: errData.error || '분석 서버가 사용 중입니다. 잠시 후 재시도해 주세요.' })
|
||||
}
|
||||
|
||||
@ -259,7 +229,6 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
|
||||
`UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG='Python 분석 서버에 연결할 수 없습니다.', CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$1`,
|
||||
[predExecSn]
|
||||
)
|
||||
await rollbackNewRecords(predExecSn, newlyCreatedSpilDataSn, newlyCreatedAcdntSn)
|
||||
return res.status(503).json({ error: 'Python 분석 서버에 연결할 수 없습니다.' })
|
||||
}
|
||||
|
||||
|
||||
@ -1,462 +0,0 @@
|
||||
# WING-OPS 디자인 시스템 (백업)
|
||||
|
||||
Tailwind CSS + @apply 기반 디자인 시스템. `wing-*` CSS 클래스와 React UI 컴포넌트로 구성.
|
||||
|
||||
---
|
||||
|
||||
## 브랜드 (Brand)
|
||||
|
||||
### 로고
|
||||
|
||||
해양 환경 위기대응을 위한 통합 솔루션 **WING**의 로고.
|
||||
|
||||
- **파일**: `frontend/public/wing_logo_white.svg`
|
||||
- **네이티브 크기**: 280 × 20px (비율 14:1)
|
||||
- **색상**: 단색 흰색 (다크 배경 전용)
|
||||
|
||||
#### 로고 규격
|
||||
|
||||
| 용도 | 높이 | Tailwind | 비고 |
|
||||
|------|------|----------|------|
|
||||
| Header | 14px | `h-3.5` | TopBar 52px 높이 내 사용 (현재) |
|
||||
| Standard | 24px | `h-6` | 일반 UI, 문서 내 |
|
||||
| Large | 32px | `h-8` | 로그인, 랜딩 화면 |
|
||||
| **Minimum** | **14px** | `h-3.5` | 이보다 작게 사용 금지 |
|
||||
|
||||
#### 여백 규칙 (Clear Space)
|
||||
- 최소 여백: 로고 높이의 **50%** (상하좌우)
|
||||
- 로고 주변에 다른 텍스트나 아이콘이 침범하지 않도록 유지
|
||||
|
||||
### 테마 컬러
|
||||
|
||||
다크 테마 기반. 게시판(Board) 메뉴에서 추출한 컬러 체계.
|
||||
|
||||
#### 배경 (Background) — 딥 네이비
|
||||
|
||||
| 토큰 | CSS 변수 | HEX | 용도 |
|
||||
|------|----------|-----|------|
|
||||
| `bg-bg-0` | `--bg0` | `#0a0e1a` | 최하위 배경 (body, input) |
|
||||
| `bg-bg-1` | `--bg1` | `#0f1524` | 패널, 모달, 푸터 배경 |
|
||||
| `bg-bg-2` | `--bg2` | `#121929` | 테이블 헤더, elevated 영역 |
|
||||
| `bg-bg-3` | `--bg3` | `#1a2236` | 카드, 보조 버튼, 비활성 요소 |
|
||||
| `bg-hover` | `--bgH` | `#1e2844` | 행 hover |
|
||||
|
||||
#### 텍스트 (Text)
|
||||
|
||||
| 토큰 | CSS 변수 | HEX | 용도 |
|
||||
|------|----------|-----|------|
|
||||
| `text-text-1` | `--t1` | `#edf0f7` | 주 텍스트 (제목, 본문) |
|
||||
| `text-text-2` | `--t2` | `#b0b8cc` | 보조 텍스트 (라벨, 설명) |
|
||||
| `text-text-3` | `--t3` | `#8690a6` | 비활성/메타 (날짜, 조회수) |
|
||||
|
||||
#### 브랜드 강조색 (Accent)
|
||||
|
||||
| 토큰 | HEX | 용도 |
|
||||
|------|-----|------|
|
||||
| `primary-cyan` | `#06b6d4` | 주 강조색 — 활성 상태, 링크, CTA |
|
||||
| `primary-blue` | `#3b82f6` | 보조 강조색 — 그라데이션 끝점 |
|
||||
| **Primary Gradient** | `linear-gradient(135deg, #06b6d4, #3b82f6)` | Primary 버튼 |
|
||||
|
||||
#### 시맨틱 (Semantic)
|
||||
|
||||
| 토큰 | HEX | 용도 |
|
||||
|------|-----|------|
|
||||
| `red` / `danger` | `#ef4444` | 삭제, 필수 표시(*) |
|
||||
| `orange` | `#f97316` | 경고 |
|
||||
| `yellow` | `#eab308` | 주의 |
|
||||
| `green` | `#22c55e` | 성공, 정상 |
|
||||
| `purple` | `#a855f7` | 특수 강조 |
|
||||
|
||||
#### 테두리 (Border)
|
||||
|
||||
| 토큰 | CSS 변수 | HEX | 용도 |
|
||||
|------|----------|-----|------|
|
||||
| `border` | `--bd` | `#1e2a42` | 기본 구분선 |
|
||||
| `border-light` | `--bdL` | `#2a3a5c` | 밝은 테두리 |
|
||||
|
||||
#### 오버레이 (Overlay)
|
||||
|
||||
| 토큰 | 값 | 용도 |
|
||||
|------|-----|------|
|
||||
| `overlay` | `rgba(0, 0, 0, 0.55)` | 모달 배경 오버레이 |
|
||||
|
||||
---
|
||||
|
||||
## 디자인 원칙
|
||||
|
||||
### 컬러 사용 규칙
|
||||
- **상태 표현** (위험/정상/주의) → 컬러 배지 사용 (red, green, yellow)
|
||||
- **단순 분류** (공지사항, 자료실, Q&A) → 기본 텍스트 컬러 또는 neutral 배지
|
||||
- **강조** (고정글, 선택 항목) → accent 컬러 (cyan)
|
||||
- **원칙**: 색상은 정보를 전달할 때만 사용. 장식 목적 금지.
|
||||
|
||||
---
|
||||
|
||||
## 1. 토큰
|
||||
|
||||
### 컬러 팔레트
|
||||
|
||||
| CSS 변수 | 값 | 용도 |
|
||||
|----------|-----|------|
|
||||
| `--bg0` | `#0a0e1a` | 최하위 배경 (body, input) |
|
||||
| `--bg1` | `#0f1524` | 기본 패널 배경 |
|
||||
| `--bg2` | `#121929` | 테이블 헤더, elevated |
|
||||
| `--bg3` | `#1a2236` | 카드, 섹션 배경 |
|
||||
| `--bgH` | `#1e2844` | hover 상태 |
|
||||
| `--bd` | `#1e2a42` | 기본 테두리 |
|
||||
| `--bdL` | `#2a3a5c` | 밝은 테두리 |
|
||||
| `--t1` | `#edf0f7` | 기본 텍스트 (밝음) |
|
||||
| `--t2` | `#b0b8cc` | 보조 텍스트 |
|
||||
| `--t3` | `#8690a6` | 비활성/메타 텍스트 |
|
||||
| `--cyan` | `#06b6d4` | Primary accent |
|
||||
| `--blue` | `#3b82f6` | Secondary accent |
|
||||
| `--purple` | `#a855f7` | 특수 강조 |
|
||||
| `--red` | `#ef4444` | 위험/삭제 |
|
||||
| `--orange` | `#f97316` | 경고 |
|
||||
| `--yellow` | `#eab308` | 주의 |
|
||||
| `--green` | `#22c55e` | 성공/정상 |
|
||||
|
||||
### Z-Index 스케일
|
||||
|
||||
| 변수 | 값 | 용도 |
|
||||
|------|-----|------|
|
||||
| `--z-dropdown` | 100 | 드롭다운, 콤보박스 |
|
||||
| `--z-sticky` | 200 | sticky 헤더 |
|
||||
| `--z-overlay` | 1000 | 오버레이 |
|
||||
| `--z-modal` | 10000 | 모달 |
|
||||
| `--z-toast` | 10100 | 토스트 알림 |
|
||||
|
||||
### 패널 너비
|
||||
|
||||
| 변수 | 값 | 용도 |
|
||||
|------|-----|------|
|
||||
| `--panel-narrow` | 280px | 좁은 사이드패널 |
|
||||
| `--panel-default` | 300px | 기본 사이드패널 |
|
||||
| `--panel-wide` | 340px | 넓은 사이드패널 |
|
||||
|
||||
### 타이포그래피 (Tailwind)
|
||||
|
||||
| 클래스 | 크기 | 용도 |
|
||||
|--------|------|------|
|
||||
| `text-wing-meta` | 9px | 메타 텍스트, 날짜, 부가 정보 |
|
||||
| `text-wing-caption` | 10px | 캡션, 설명, 라벨 부연 |
|
||||
| `text-wing-body` | 11px | 본문, 라벨, 값 (가장 많이 사용) |
|
||||
| `text-wing-heading` | 13px | 섹션 헤더 |
|
||||
| `text-wing-title` | 15px | 페이지/모달 타이틀 |
|
||||
|
||||
---
|
||||
|
||||
## 2. CSS 클래스 (`wing-*`)
|
||||
|
||||
모든 클래스는 `frontend/src/common/styles/wing.css`에 정의.
|
||||
|
||||
### Layout
|
||||
|
||||
| 클래스 | 설명 |
|
||||
|--------|------|
|
||||
| `wing-panel` | flex column, full height, overflow hidden |
|
||||
| `wing-panel-scroll` | flex-1, overflow-y-auto, thin scrollbar |
|
||||
| `wing-panel-right` | 우측 사이드패널 (border-l, 300px) |
|
||||
| `wing-panel-left` | 좌측 사이드패널 (border-r, 300px) |
|
||||
| `wing-header-bar` | 헤더 바 (flex between, border-b, px-5) |
|
||||
| `wing-sidebar` | 사이드바 (flex col, border-r, bg1) |
|
||||
|
||||
### Card / Section
|
||||
|
||||
| 클래스 | 설명 |
|
||||
|--------|------|
|
||||
| `wing-card` | 카드 (rounded-md, p-4, border, bg3) |
|
||||
| `wing-card-sm` | 작은 카드 (rounded-sm, p-3) |
|
||||
| `wing-section` | 섹션 (rounded-md, p-4, mb-3) |
|
||||
| `wing-section-header` | 섹션 제목 (13px bold) |
|
||||
| `wing-section-desc` | 섹션 설명 (10px, t3 색상) |
|
||||
|
||||
### Typography
|
||||
|
||||
| 클래스 | 설명 |
|
||||
|--------|------|
|
||||
| `wing-title` | 15px bold korean |
|
||||
| `wing-subtitle` | 10px korean, t3 색상 |
|
||||
| `wing-label` | 11px semibold korean |
|
||||
| `wing-value` | 11px semibold mono |
|
||||
| `wing-meta` | 9px korean, t3 색상 |
|
||||
|
||||
### Button
|
||||
|
||||
| 클래스 | 설명 |
|
||||
|--------|------|
|
||||
| `wing-btn` | 기본 버튼 (px-3, py-1.5, 11px) |
|
||||
| `wing-btn-primary` | cyan→blue gradient, white |
|
||||
| `wing-btn-secondary` | bg3, border, t2 색상 |
|
||||
| `wing-btn-outline` | transparent, border |
|
||||
| `wing-btn-pdf` | blue 테마 |
|
||||
| `wing-btn-danger` | red 테마 |
|
||||
|
||||
### Input / Select / Textarea
|
||||
|
||||
| 클래스 | 설명 |
|
||||
|--------|------|
|
||||
| `wing-input` | 기본 입력 (w-full, 11px, cyan focus) |
|
||||
| `wing-select` | 셀렉트 (커스텀 화살표 포함) |
|
||||
| `wing-textarea` | 텍스트영역 (resize-vertical, min-h 80px) |
|
||||
| `wing-input-search` | 검색 입력 (256px 고정) |
|
||||
|
||||
### Table
|
||||
|
||||
| 클래스 | 설명 |
|
||||
|--------|------|
|
||||
| `wing-table` | 테이블 (w-full, 10px, collapse) |
|
||||
| `wing-table-head` | 헤더 셀 (bg2, t3, bold) |
|
||||
| `wing-table-cell` | 데이터 셀 (t2, border-b) |
|
||||
| `wing-table-row` | 행 hover (bgH, cursor-pointer) |
|
||||
|
||||
### Badge
|
||||
|
||||
| 클래스 | 설명 |
|
||||
|--------|------|
|
||||
| `wing-badge` | 기본 배지 (inline-flex, 9px bold) |
|
||||
| `wing-badge-neutral` | 회색 (단순 분류용 기본값) |
|
||||
| `wing-badge-red` | 위험/삭제 |
|
||||
| `wing-badge-blue` | 정보 |
|
||||
| `wing-badge-green` | 성공/정상 |
|
||||
| `wing-badge-yellow` | 주의 |
|
||||
| `wing-badge-purple` | 특수 |
|
||||
| `wing-badge-cyan` | 주요 |
|
||||
|
||||
### Modal
|
||||
|
||||
| 클래스 | 설명 |
|
||||
|--------|------|
|
||||
| `wing-overlay` | 오버레이 (fixed, blur, z-modal) |
|
||||
| `wing-modal` | 모달 컨테이너 (rounded-xl, bg1) |
|
||||
| `wing-modal-header` | 모달 헤더 (flex between, border-b) |
|
||||
| `wing-modal-body` | 모달 본문 (flex-1, scroll) |
|
||||
| `wing-modal-footer` | 모달 푸터 (flex end, border-t) |
|
||||
| `wing-modal-sm` | 400px |
|
||||
| `wing-modal-md` | 560px |
|
||||
| `wing-modal-lg` | 720px |
|
||||
|
||||
### Tab
|
||||
|
||||
| 클래스 | 설명 |
|
||||
|--------|------|
|
||||
| `wing-tab-bar` | 탭 바 (flex, rounded-lg, border) |
|
||||
| `wing-tab` | 탭 아이템 (flex-1, text-center) |
|
||||
| `wing-tab.active` | 활성 탭 (cyan border/bg/text) |
|
||||
|
||||
### Utility
|
||||
|
||||
| 클래스 | 설명 |
|
||||
|--------|------|
|
||||
| `wing-divider` | 구분선 (1px, full width) |
|
||||
| `wing-info-row` | 키-값 행 (flex between) |
|
||||
| `wing-info-label` | 키 라벨 (10px, t3) |
|
||||
| `wing-info-value` | 값 (11px, semibold mono) |
|
||||
|
||||
---
|
||||
|
||||
## 3. React 컴포넌트
|
||||
|
||||
위치: `frontend/src/common/components/ui/`
|
||||
|
||||
### Modal
|
||||
|
||||
```tsx
|
||||
import Modal from '@common/components/ui/Modal';
|
||||
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
title="제목"
|
||||
size="md"
|
||||
footer={
|
||||
<>
|
||||
<button className="wing-btn wing-btn-secondary" onClick={handleCancel}>취소</button>
|
||||
<button className="wing-btn wing-btn-primary" onClick={handleConfirm}>확인</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>모달 내용</p>
|
||||
</Modal>
|
||||
```
|
||||
|
||||
| Prop | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `isOpen` | `boolean` | 필수 | 표시 여부 |
|
||||
| `onClose` | `() => void` | 필수 | 닫기 콜백 |
|
||||
| `title` | `string` | 필수 | 헤더 제목 |
|
||||
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | 너비 (400/560/720px) |
|
||||
| `children` | `ReactNode` | 필수 | 본문 |
|
||||
| `footer` | `ReactNode` | - | 하단 버튼 영역 |
|
||||
| `closeOnBackdrop` | `boolean` | `true` | 배경 클릭 닫기 |
|
||||
|
||||
### Pagination
|
||||
|
||||
```tsx
|
||||
import Pagination from '@common/components/ui/Pagination';
|
||||
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
```
|
||||
|
||||
| Prop | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `currentPage` | `number` | 필수 | 현재 페이지 (1-based) |
|
||||
| `totalPages` | `number` | 필수 | 전체 페이지 수 |
|
||||
| `onPageChange` | `(page: number) => void` | 필수 | 페이지 변경 콜백 |
|
||||
| `showFirstLast` | `boolean` | `true` | 처음/끝 버튼 표시 |
|
||||
|
||||
### DataTable
|
||||
|
||||
```tsx
|
||||
import DataTable, { Column } from '@common/components/ui/DataTable';
|
||||
|
||||
interface Post {
|
||||
id: number;
|
||||
title: string;
|
||||
author: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const columns: Column<Post>[] = [
|
||||
{ key: 'id', label: '번호', width: '60px', align: 'center' },
|
||||
{ key: 'title', label: '제목' },
|
||||
{ key: 'author', label: '작성자', width: '100px' },
|
||||
{ key: 'createdAt', label: '작성일', width: '120px',
|
||||
render: (val) => new Date(val as string).toLocaleDateString() },
|
||||
];
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={posts}
|
||||
onRowClick={(post) => navigate(`/board/${post.id}`)}
|
||||
/>
|
||||
```
|
||||
|
||||
| Prop | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `columns` | `Column<T>[]` | 필수 | 컬럼 정의 |
|
||||
| `data` | `T[]` | 필수 | 데이터 배열 |
|
||||
| `onRowClick` | `(row: T) => void` | - | 행 클릭 콜백 |
|
||||
| `stickyHeader` | `boolean` | `true` | 헤더 고정 |
|
||||
| `emptyMessage` | `string` | `'데이터가 없습니다.'` | 빈 상태 메시지 |
|
||||
|
||||
### SidePanel
|
||||
|
||||
```tsx
|
||||
import SidePanel from '@common/components/ui/SidePanel';
|
||||
|
||||
<SidePanel
|
||||
position="right"
|
||||
width="default"
|
||||
header={<span className="wing-title">상세 정보</span>}
|
||||
footer={
|
||||
<button className="wing-btn wing-btn-primary w-full">저장</button>
|
||||
}
|
||||
>
|
||||
<div className="p-3">
|
||||
<div className="wing-info-row">
|
||||
<span className="wing-info-label">상태</span>
|
||||
<Badge color="green">정상</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</SidePanel>
|
||||
```
|
||||
|
||||
| Prop | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `position` | `'left' \| 'right'` | 필수 | 배치 방향 |
|
||||
| `width` | `'narrow' \| 'default' \| 'wide'` | `'default'` | 너비 (280/300/340px) |
|
||||
| `header` | `ReactNode` | - | 헤더 영역 |
|
||||
| `footer` | `ReactNode` | - | 하단 영역 |
|
||||
|
||||
### Badge
|
||||
|
||||
```tsx
|
||||
import Badge from '@common/components/ui/Badge';
|
||||
|
||||
<Badge color="green">정상</Badge> {/* 상태 표현 */}
|
||||
<Badge color="red">위험</Badge> {/* 상태 표현 */}
|
||||
<Badge>공지사항</Badge> {/* 단순 분류 → neutral */}
|
||||
<Badge>자료실</Badge> {/* 단순 분류 → neutral */}
|
||||
```
|
||||
|
||||
| Prop | 타입 | 기본값 | 설명 |
|
||||
|------|------|--------|------|
|
||||
| `color` | `'red' \| 'blue' \| 'green' \| 'yellow' \| 'purple' \| 'cyan' \| 'neutral'` | `'neutral'` | 배지 색상 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 적용 예시: Before → After
|
||||
|
||||
### Before (raw Tailwind 복붙)
|
||||
```tsx
|
||||
{/* 검색 */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="검색..."
|
||||
className="w-64 px-4 py-2 text-sm bg-bg-2 border border-border rounded text-text-1
|
||||
placeholder-text-3 focus:border-primary-cyan focus:outline-none"
|
||||
/>
|
||||
|
||||
{/* 테이블 */}
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3">제목</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-border hover:bg-bg-2 cursor-pointer">
|
||||
<td className="px-4 py-4 text-sm text-text-2">{item.title}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 배지 — 의미 없는 컬러 분화 */}
|
||||
<span className="px-2.5 py-0.5 rounded text-xs font-semibold bg-red-500/20 text-red-400">
|
||||
공지사항
|
||||
</span>
|
||||
|
||||
{/* 페이지네이션 — 직접 구현 */}
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 ...">이전</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 ...">다음</button>
|
||||
```
|
||||
|
||||
### After (디자인 시스템)
|
||||
```tsx
|
||||
{/* 검색 */}
|
||||
<input type="text" placeholder="검색..." className="wing-input-search" />
|
||||
|
||||
{/* 테이블 — React 컴포넌트 */}
|
||||
<DataTable columns={columns} data={posts} onRowClick={handleClick} />
|
||||
|
||||
{/* 배지 — neutral로 통일 */}
|
||||
<Badge>공지사항</Badge>
|
||||
<Badge>자료실</Badge>
|
||||
<Badge color="green">진행중</Badge> {/* 상태만 컬러 */}
|
||||
|
||||
{/* 페이지네이션 — React 컴포넌트 */}
|
||||
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 마이그레이션 가이드
|
||||
|
||||
### 작업 순서
|
||||
1. 파일 내 raw Tailwind 문자열을 `wing-*` 클래스로 교체
|
||||
2. 반복되는 행동 패턴 (모달, 페이지네이션, 테이블)을 React 컴포넌트로 교체
|
||||
3. 의미 없는 컬러 배지를 `<Badge>` (neutral)로 교체
|
||||
4. 브라우저에서 시각적 동일성 확인
|
||||
|
||||
### 판단 기준
|
||||
```
|
||||
순수 시각 + 5회 이상 반복 → wing-* CSS 클래스
|
||||
동작 포함 + 5회 이상 반복 → React 컴포넌트
|
||||
1-2회 사용 → 인라인 Tailwind 유지
|
||||
도메인 전용 시각 → components.css (유지)
|
||||
```
|
||||
@ -1,169 +0,0 @@
|
||||
# WING 디자인 시스템
|
||||
|
||||
해양 환경 위기대응 통합 솔루션 **WING**의 디자인 시스템.
|
||||
|
||||
---
|
||||
|
||||
## 브랜드 (Brand)
|
||||
|
||||
### 로고
|
||||
|
||||
해양에서 발생하는 상황을 종합적으로 지원하는 솔루션 WING의 로고.
|
||||
|
||||
- **파일**: `frontend/public/wing_logo_white.svg`
|
||||
- **네이티브 크기**: 280 × 20px (비율 14:1)
|
||||
- **색상**: 단색 흰색 (다크 배경 전용)
|
||||
|
||||
#### 로고 규격
|
||||
|
||||
| 용도 | 높이 | Tailwind | 비고 |
|
||||
|------|------|----------|------|
|
||||
| Header | 14px | `h-3.5` | TopBar 52px 높이 내 사용 (현재) |
|
||||
| Standard | 24px | `h-6` | 일반 UI, 문서 내 |
|
||||
| Large | 32px | `h-8` | 로그인, 랜딩 화면 |
|
||||
| **Minimum** | **14px** | `h-3.5` | 이보다 작게 사용 금지 |
|
||||
|
||||
#### 여백 규칙 (Clear Space)
|
||||
|
||||
- 최소 여백: 로고 높이의 **50%** (상하좌우)
|
||||
- 로고 주변에 다른 텍스트나 아이콘이 침범하지 않도록 유지
|
||||
|
||||
---
|
||||
|
||||
## 파운데이션 (Foundations)
|
||||
|
||||
### 브랜드 컬러 (Brand Color)
|
||||
|
||||
현재 다크 테마만 구현되어 있으며, 라이트 팔레트는 향후 전환용으로 정의해 둔다.
|
||||
|
||||
> **현재 상태**: 다크 테마 단일 고정 (테마 전환 인프라 미구축)
|
||||
|
||||
#### 배경 (Background)
|
||||
|
||||
| CSS 변수 | Dark | Light | 용도 |
|
||||
|----------|------|-------|------|
|
||||
| `--bg0` | `#0a0e1a` | `#ffffff` | 최하위 배경 (body, input) |
|
||||
| `--bg1` | `#0f1524` | `#f8f9fb` | 패널, 모달, 푸터 배경 |
|
||||
| `--bg2` | `#121929` | `#f0f2f5` | 테이블 헤더, elevated 영역 |
|
||||
| `--bg3` | `#1a2236` | `#e8ebf0` | 카드, 보조 버튼, 비활성 요소 |
|
||||
| `--bgH` | `#1e2844` | `#dde1e8` | 행 hover |
|
||||
|
||||
#### 텍스트 (Text)
|
||||
|
||||
| CSS 변수 | Dark | Light | 용도 |
|
||||
|----------|------|-------|------|
|
||||
| `--t1` | `#edf0f7` | `#1a1d26` | 주 텍스트 (제목, 본문) |
|
||||
| `--t2` | `#b0b8cc` | `#4a5568` | 보조 텍스트 (라벨, 설명) |
|
||||
| `--t3` | `#8690a6` | `#8690a6` | 비활성/메타 — 양 테마 공유 |
|
||||
|
||||
#### Accent Color
|
||||
|
||||
| CSS 변수 | Dark | Light | 용도 |
|
||||
|----------|------|-------|------|
|
||||
| `--cyan` | `#06b6d4` | `#0891b2` | 주 강조색 — 활성 상태, 링크, CTA |
|
||||
| `--blue` | `#3b82f6` | `#2563eb` | 보조 강조색 — 그라데이션 끝점 |
|
||||
| **Gradient** | `linear-gradient(135deg, #06b6d4, #3b82f6)` | `linear-gradient(135deg, #0891b2, #2563eb)` | Primary 버튼 |
|
||||
|
||||
> 라이트 테마에서 강조색을 한 단계 진하게 적용하여 흰 배경 위 가독성을 확보한다.
|
||||
|
||||
> **보조 컬러 검토**: 필요에 따라 시맨틱 컬러(red `#ef4444`, orange `#f97316`, yellow `#eab308`, green `#22c55e`, purple `#a855f7`)를 보조 컬러로 추가 검토 예정.
|
||||
|
||||
---
|
||||
|
||||
### 타이포그래피 (Typography)
|
||||
|
||||
#### 폰트 패밀리
|
||||
|
||||
| 토큰 | CSS 변수 | 폰트 | 용도 |
|
||||
|------|----------|------|------|
|
||||
| `font-sans` | — | Outfit, Noto Sans KR, sans-serif | 기본 UI (body) |
|
||||
| `font-korean` | `--fK` | Noto Sans KR, sans-serif | 한글 강조 |
|
||||
| `font-mono` | `--fM` | JetBrains Mono, monospace | 수치, 코드 |
|
||||
|
||||
#### 폰트 스케일
|
||||
|
||||
| 클래스 | 크기 | Line Height | Weight | 용도 |
|
||||
|--------|------|-------------|--------|------|
|
||||
| `text-wing-meta` | 9px | 1.4 | 400 | 메타 텍스트, 날짜, 부가 정보 |
|
||||
| `text-wing-caption` | 10px | 1.4 | 400 | 캡션, 설명, 라벨 부연 |
|
||||
| `text-wing-body` | 11px | 1.5 | 400 | 본문, 라벨, 값 (가장 많이 사용) |
|
||||
| `text-wing-heading` | 13px | 1.4 | 700 | 섹션 헤더 |
|
||||
| `text-wing-title` | 15px | 1.3 | 700 | 페이지/모달 타이틀 |
|
||||
|
||||
> 11px(`text-wing-body`)이 기본 본문 크기. 정보 밀도가 높은 운영 시스템 특성에 맞춘 compact 설계.
|
||||
|
||||
---
|
||||
|
||||
### 아이콘 (Icons)
|
||||
|
||||
- **라이브러리**: `lucide-react` (^0.564.0)
|
||||
- **스타일**: Stroke 기반, 일관된 2px 선 두께
|
||||
|
||||
| 크기 | px | 용도 |
|
||||
|------|-----|------|
|
||||
| Small | 16px | 인라인 텍스트, 버튼 내부 |
|
||||
| Default | 18px | 일반 UI 아이콘 |
|
||||
| Large | 20px | 강조, 헤더 영역 |
|
||||
|
||||
```tsx
|
||||
import { Search, ChevronDown, X } from 'lucide-react';
|
||||
|
||||
<Search size={16} /> {/* 인라인 */}
|
||||
<ChevronDown size={18} /> {/* 일반 */}
|
||||
<X size={20} /> {/* 강조 */}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 간격 (Spacing)
|
||||
|
||||
4px 그리드 기반. Tailwind 유틸리티 클래스 사용.
|
||||
|
||||
| 토큰 | 값 | Tailwind | 주요 사용처 |
|
||||
|------|-----|----------|------------|
|
||||
| `spacing-1` | 4px | `p-1`, `gap-1` | 최소 간격, 아이콘-텍스트 |
|
||||
| `spacing-1.5` | 6px | `py-1.5`, `gap-1.5` | 인풋 수직 패딩, 버튼 수직 패딩 |
|
||||
| `spacing-2` | 8px | `p-2`, `gap-2` | 테이블 셀, 버튼 그룹 간격 |
|
||||
| `spacing-3` | 12px | `p-3`, `gap-3` | 소형 카드 패딩 |
|
||||
| `spacing-4` | 16px | `p-4`, `gap-4` | 카드/섹션 패딩 |
|
||||
| `spacing-5` | 20px | `px-5` | 헤더/모달 수평 패딩 |
|
||||
|
||||
---
|
||||
|
||||
### 테두리 (Border)
|
||||
|
||||
#### 색상
|
||||
|
||||
| CSS 변수 | Dark | Light | 용도 |
|
||||
|----------|------|-------|------|
|
||||
| `--bd` | `#1e2a42` | `#d0d5dd` | 기본 구분선 |
|
||||
| `--bdL` | `#2a3a5c` | `#e0e4ea` | 밝은 테두리 |
|
||||
|
||||
#### Radius
|
||||
|
||||
| 토큰 | 값 | Tailwind | 용도 |
|
||||
|------|-----|----------|------|
|
||||
| `radius-xs` | 4px | `rounded` | 배지, 최소 요소 |
|
||||
| `radius-sm` | 6px | `rounded-sm` | 버튼, 인풋, 소형 카드 (가장 많이 사용) |
|
||||
| `radius-md` | 10px | `rounded-md` | 카드, 섹션 |
|
||||
| `radius-lg` | 12px | `rounded-xl` | 모달 |
|
||||
|
||||
> `rounded-sm`(6px)이 프로젝트 전반에서 지배적인 기본 반경값.
|
||||
|
||||
---
|
||||
|
||||
### 단위 체계 (Units)
|
||||
|
||||
> **현재 상태**: px 기반 단일 체계
|
||||
> **향후 방향**: rem 전환 검토 (접근성/반응성 고려)
|
||||
|
||||
| 영역 | 현재 단위 | 비고 |
|
||||
|------|-----------|------|
|
||||
| font-size | px | `9px`~`15px`, Tailwind arbitrary `text-[11px]` |
|
||||
| spacing/padding | px | raw CSS `6px 10px` 등 |
|
||||
| layout 치수 | px | 패널 280~340px, 모달 400~720px |
|
||||
| border-radius | px | `6px`, `10px`, `12px` |
|
||||
| html font-size | 미설정 | rem 기준점 없음 (브라우저 기본 16px) |
|
||||
|
||||
- Tailwind 기본 유틸리티(`px-5`, `text-sm` 등)는 내부적으로 rem을 사용하나, 이는 의도적 설계가 아닌 Tailwind 부산물
|
||||
- rem 전환 시 로드맵: `html { font-size }` 기준 설정 → Tailwind config rem 토큰 → wing.css 점진적 전환
|
||||
@ -4,21 +4,27 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-03-13]
|
||||
|
||||
### 추가
|
||||
- 오염분석 다각형/원 분석 기능 구현
|
||||
- 시뮬레이션 에러 모달 추가
|
||||
- 해류 캔버스 파티클 레이어 추가
|
||||
|
||||
### 수정
|
||||
- useSubMenu useEffect import 누락 수정
|
||||
## [2026-03-11.2]
|
||||
|
||||
### 추가
|
||||
- OpenDrift 유류 확산 시뮬레이션 통합 (비동기 폴링 구조)
|
||||
- flyTo 완료 후 자동 재생 기능
|
||||
- 이미지 분석 서버 Docker 패키징 (CPU 전용 환경)
|
||||
- SPIL_DATA 이미지 분석 결과 컬럼 인라인 통합
|
||||
- CPU 전용 Docker 환경 구축 (Dockerfile.cpu, docker-compose.cpu.yml)
|
||||
|
||||
### 변경
|
||||
- 보고서 해안부착 현황 개선
|
||||
- 이미지 분석/보고서/항공 UI 개선
|
||||
- CCTV/관리자 고도화
|
||||
|
||||
### 기타
|
||||
- 팀 워크플로우 동기화 (v1.6.1)
|
||||
- 팀 워크플로우 v1.6.1 적용일 갱신
|
||||
- 팀 워크플로우 v1.6.1 동기화 (custom_pre_commit 프로젝트 해시 불일치 해결)
|
||||
- 팀 워크플로우 v1.6.0 동기화 (해시 기반 자동 최신화, push/mr/release 워크플로우 체크, 팀 관리 파일 gitignore 처리)
|
||||
- 팀 워크플로우 v1.5.0 동기화 (스킬 7종 업데이트, version 스킬 신규, release-notes-guide 추가)
|
||||
|
||||
## [2026-03-11]
|
||||
|
||||
@ -30,27 +36,18 @@
|
||||
- 관리자 화면 고도화 — 사용자/권한/게시판/선박신호 패널
|
||||
- CCTV 오일 감지 GPU 추론 연동 및 HNS 초기 핀 제거
|
||||
- 유류오염보장계약 시드 데이터 추가 (1391건)
|
||||
- OpenDrift 유류 확산 시뮬레이션 통합 (비동기 폴링 구조)
|
||||
- flyTo 완료 후 자동 재생 기능
|
||||
- 이미지 분석 서버 Docker 패키징 (CPU 전용 환경)
|
||||
- SPIL_DATA 이미지 분석 결과 컬럼 인라인 통합
|
||||
- CPU 전용 Docker 환경 구축 (Dockerfile.cpu, docker-compose.cpu.yml)
|
||||
|
||||
### 수정
|
||||
- /orgs 라우트를 /:id 앞에 등록하여 라우트 매칭 수정
|
||||
|
||||
### 변경
|
||||
- 이미지 분석/보고서/항공 UI 개선
|
||||
- CCTV/관리자 고도화
|
||||
|
||||
### 문서
|
||||
- 프로젝트 문서 최신화 (KHOA API, Vite 프록시)
|
||||
|
||||
### 기타
|
||||
- CLAUDE_BOT_TOKEN 갱신
|
||||
- 팀 워크플로우 v1.6.1 동기화 (custom_pre_commit 프로젝트 해시 불일치 해결, 적용일 갱신)
|
||||
- 팀 워크플로우 v1.6.0 동기화 (해시 기반 자동 최신화, push/mr/release 워크플로우 체크, 팀 관리 파일 gitignore 처리)
|
||||
- 팀 워크플로우 v1.5.0 동기화 (스킬 7종 업데이트, version 스킬 신규, release-notes-guide 추가)
|
||||
- 팀 워크플로우 v1.6.1 동기화
|
||||
- 팀 워크플로우 v1.6.0 동기화
|
||||
- 팀 워크플로우 v1.5.0 동기화
|
||||
|
||||
## [2026-03-01]
|
||||
|
||||
|
||||
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@ -25,7 +25,6 @@
|
||||
"@vis.gl/react-maplibre": "^8.1.0",
|
||||
"axios": "^1.13.5",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"exifr": "^7.1.3",
|
||||
"hls.js": "^1.6.15",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.564.0",
|
||||
@ -3849,12 +3848,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/exifr": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
|
||||
"integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/extend-shallow": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
|
||||
|
||||
@ -27,7 +27,6 @@
|
||||
"@vis.gl/react-maplibre": "^8.1.0",
|
||||
"axios": "^1.13.5",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"exifr": "^7.1.3",
|
||||
"hls.js": "^1.6.15",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.564.0",
|
||||
|
||||
@ -18,7 +18,6 @@ import { IncidentsView } from '@tabs/incidents'
|
||||
import { AdminView } from '@tabs/admin'
|
||||
import { ScatView } from '@tabs/scat'
|
||||
import { RescueView } from '@tabs/rescue'
|
||||
import { ShowcaseView } from '@tabs/showcase'
|
||||
|
||||
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || ''
|
||||
|
||||
@ -98,8 +97,6 @@ function App() {
|
||||
return <AdminView />
|
||||
case 'rescue':
|
||||
return <RescueView />
|
||||
case 'showcase':
|
||||
return <ShowcaseView />
|
||||
default:
|
||||
return <div className="flex items-center justify-center h-full text-text-3">준비 중입니다...</div>
|
||||
}
|
||||
|
||||
@ -73,25 +73,6 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* 실시간 상황관리 */}
|
||||
<button
|
||||
onClick={() => window.open(import.meta.env.VITE_SITUATIONAL_URL ?? 'http://localhost:5174', '_blank')}
|
||||
className={`
|
||||
px-2.5 xl:px-4 py-2 rounded-sm text-[13px] transition-all duration-200
|
||||
font-korean tracking-[0.2px] font-semibold
|
||||
border-l border-l-[rgba(239,68,68,0.25)] ml-1
|
||||
text-[#f87171] hover:text-[#fca5a5] hover:bg-[rgba(239,68,68,0.1)]
|
||||
flex items-center gap-1.5
|
||||
`}
|
||||
title="실시간 상황관리"
|
||||
>
|
||||
<span className="hidden xl:flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#f87171] animate-pulse inline-block" />
|
||||
실시간 상황관리
|
||||
</span>
|
||||
<span className="xl:hidden text-[16px] leading-none">🛰</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -107,17 +88,6 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
|
||||
<button className="w-9 h-9 rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all">
|
||||
🔔
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onTabChange('showcase')}
|
||||
title="쇼케이스"
|
||||
className={`w-9 h-9 rounded-sm border flex items-center justify-center transition-all ${
|
||||
activeTab === 'showcase'
|
||||
? 'border-primary-cyan bg-[rgba(6,182,212,0.15)] text-primary-cyan'
|
||||
: 'border-border bg-bg-3 text-text-2 hover:bg-bg-hover hover:text-text-1'
|
||||
}`}
|
||||
>
|
||||
🎨
|
||||
</button>
|
||||
{hasPermission('admin') && (
|
||||
<button
|
||||
onClick={() => onTabChange('admin')}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
|
||||
import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||
import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer, PolygonLayer } from '@deck.gl/layers'
|
||||
import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer } from '@deck.gl/layers'
|
||||
import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core'
|
||||
import type { StyleSpecification } from 'maplibre-gl'
|
||||
import type { MapLayerMouseEvent } from 'maplibre-gl'
|
||||
@ -189,15 +189,6 @@ interface MapViewProps {
|
||||
mapCaptureRef?: React.MutableRefObject<(() => string | null) | null>
|
||||
onIncidentFlyEnd?: () => void
|
||||
flyToIncident?: { lon: number; lat: number }
|
||||
showCurrent?: boolean
|
||||
showWind?: boolean
|
||||
showBeached?: boolean
|
||||
showTimeLabel?: boolean
|
||||
simulationStartTime?: string
|
||||
drawAnalysisMode?: 'polygon' | 'circle' | null
|
||||
analysisPolygonPoints?: Array<{ lat: number; lon: number }>
|
||||
analysisCircleCenter?: { lat: number; lon: number } | null
|
||||
analysisCircleRadiusM?: number
|
||||
}
|
||||
|
||||
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved)
|
||||
@ -320,15 +311,6 @@ export function MapView({
|
||||
mapCaptureRef,
|
||||
onIncidentFlyEnd,
|
||||
flyToIncident,
|
||||
showCurrent = true,
|
||||
showWind = true,
|
||||
showBeached = false,
|
||||
showTimeLabel = false,
|
||||
simulationStartTime,
|
||||
drawAnalysisMode = null,
|
||||
analysisPolygonPoints = [],
|
||||
analysisCircleCenter,
|
||||
analysisCircleRadiusM = 0,
|
||||
}: MapViewProps) {
|
||||
const { mapToggles } = useMapStore()
|
||||
const isControlled = externalCurrentTime !== undefined
|
||||
@ -411,10 +393,8 @@ export function MapView({
|
||||
getPosition: (d: (typeof visibleParticles)[0]) => [d.lon, d.lat],
|
||||
getRadius: 3,
|
||||
getFillColor: (d: (typeof visibleParticles)[0]) => {
|
||||
// 1순위: stranded 입자 → showBeached=true 시 빨간색, false 시 회색
|
||||
if (d.stranded === 1) return showBeached
|
||||
? [239, 68, 68, 220] as [number, number, number, number]
|
||||
: [130, 130, 130, 70] as [number, number, number, number]
|
||||
// 1순위: stranded 입자 → 빨간색
|
||||
if (d.stranded === 1) return [239, 68, 68, 220] as [number, number, number, number]
|
||||
// 2순위: 현재 활성 스텝 → 모델 기본 색상
|
||||
if (d.time === activeStep) {
|
||||
const modelKey = d.model || Array.from(selectedModels)[0] || 'OpenDrift'
|
||||
@ -447,7 +427,7 @@ export function MapView({
|
||||
}
|
||||
},
|
||||
updateTriggers: {
|
||||
getFillColor: [selectedModels, currentTime, showBeached],
|
||||
getFillColor: [selectedModels, currentTime],
|
||||
},
|
||||
})
|
||||
)
|
||||
@ -549,91 +529,6 @@ export function MapView({
|
||||
)
|
||||
}
|
||||
|
||||
// --- 오염분석 다각형 그리기 ---
|
||||
if (analysisPolygonPoints.length > 0) {
|
||||
if (analysisPolygonPoints.length >= 3) {
|
||||
result.push(
|
||||
new PolygonLayer({
|
||||
id: 'analysis-polygon-fill',
|
||||
data: [{ polygon: analysisPolygonPoints.map(p => [p.lon, p.lat] as [number, number]) }],
|
||||
getPolygon: (d: { polygon: [number, number][] }) => d.polygon,
|
||||
getFillColor: [168, 85, 247, 40],
|
||||
getLineColor: [168, 85, 247, 220],
|
||||
getLineWidth: 2,
|
||||
stroked: true,
|
||||
filled: true,
|
||||
lineWidthMinPixels: 2,
|
||||
})
|
||||
)
|
||||
}
|
||||
result.push(
|
||||
new PathLayer({
|
||||
id: 'analysis-polygon-outline',
|
||||
data: [{
|
||||
path: [
|
||||
...analysisPolygonPoints.map(p => [p.lon, p.lat] as [number, number]),
|
||||
...(analysisPolygonPoints.length >= 3 ? [[analysisPolygonPoints[0].lon, analysisPolygonPoints[0].lat] as [number, number]] : []),
|
||||
],
|
||||
}],
|
||||
getPath: (d: { path: [number, number][] }) => d.path,
|
||||
getColor: [168, 85, 247, 220],
|
||||
getWidth: 2,
|
||||
getDashArray: [8, 4],
|
||||
dashJustified: true,
|
||||
widthMinPixels: 2,
|
||||
})
|
||||
)
|
||||
result.push(
|
||||
new ScatterplotLayer({
|
||||
id: 'analysis-polygon-points',
|
||||
data: analysisPolygonPoints.map(p => ({ position: [p.lon, p.lat] as [number, number] })),
|
||||
getPosition: (d: { position: [number, number] }) => d.position,
|
||||
getRadius: 5,
|
||||
getFillColor: [168, 85, 247, 255],
|
||||
getLineColor: [255, 255, 255, 255],
|
||||
getLineWidth: 2,
|
||||
stroked: true,
|
||||
radiusMinPixels: 5,
|
||||
radiusMaxPixels: 8,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// --- 오염분석 원 그리기 ---
|
||||
if (analysisCircleCenter) {
|
||||
result.push(
|
||||
new ScatterplotLayer({
|
||||
id: 'analysis-circle-center',
|
||||
data: [{ position: [analysisCircleCenter.lon, analysisCircleCenter.lat] as [number, number] }],
|
||||
getPosition: (d: { position: [number, number] }) => d.position,
|
||||
getRadius: 6,
|
||||
getFillColor: [168, 85, 247, 255],
|
||||
getLineColor: [255, 255, 255, 255],
|
||||
getLineWidth: 2,
|
||||
stroked: true,
|
||||
radiusMinPixels: 6,
|
||||
radiusMaxPixels: 9,
|
||||
})
|
||||
)
|
||||
if (analysisCircleRadiusM > 0) {
|
||||
result.push(
|
||||
new ScatterplotLayer({
|
||||
id: 'analysis-circle-area',
|
||||
data: [{ position: [analysisCircleCenter.lon, analysisCircleCenter.lat] as [number, number] }],
|
||||
getPosition: (d: { position: [number, number] }) => d.position,
|
||||
getRadius: analysisCircleRadiusM,
|
||||
radiusUnits: 'meters',
|
||||
getFillColor: [168, 85, 247, 35],
|
||||
getLineColor: [168, 85, 247, 200],
|
||||
getLineWidth: 2,
|
||||
stroked: true,
|
||||
filled: true,
|
||||
lineWidthMinPixels: 2,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- HNS 대기확산 히트맵 (BitmapLayer, 고정 이미지) ---
|
||||
if (dispersionHeatmap && dispersionHeatmap.length > 0) {
|
||||
const maxConc = Math.max(...dispersionHeatmap.map(p => p.concentration));
|
||||
@ -887,39 +782,8 @@ export function MapView({
|
||||
)
|
||||
}
|
||||
|
||||
// --- 시간 표시 라벨 (TextLayer) ---
|
||||
if (visibleCenters.length > 0 && showTimeLabel) {
|
||||
const baseTime = simulationStartTime ? new Date(simulationStartTime) : null;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
result.push(
|
||||
new TextLayer({
|
||||
id: 'time-labels',
|
||||
data: visibleCenters,
|
||||
getPosition: (d: (typeof visibleCenters)[0]) => [d.lon, d.lat],
|
||||
getText: (d: (typeof visibleCenters)[0]) => {
|
||||
if (baseTime) {
|
||||
const dt = new Date(baseTime.getTime() + d.time * 3600 * 1000);
|
||||
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
|
||||
}
|
||||
return `+${d.time}h`;
|
||||
},
|
||||
getSize: 12,
|
||||
getColor: [255, 220, 50, 220] as [number, number, number, number],
|
||||
getPixelOffset: [0, 16] as [number, number],
|
||||
fontWeight: 'bold',
|
||||
outlineWidth: 2,
|
||||
outlineColor: [15, 21, 36, 200] as [number, number, number, number],
|
||||
billboard: true,
|
||||
sizeUnits: 'pixels' as const,
|
||||
updateTriggers: {
|
||||
getText: [simulationStartTime, currentTime],
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// --- 바람 화살표 (TextLayer) ---
|
||||
if (incidentCoord && windData.length > 0 && showWind) {
|
||||
if (incidentCoord && windData.length > 0) {
|
||||
type ArrowPoint = { lon: number; lat: number; bearing: number; speed: number }
|
||||
|
||||
const activeWindStep = windData[currentTime] ?? windData[0] ?? []
|
||||
@ -965,8 +829,6 @@ export function MapView({
|
||||
boomLines, isDrawingBoom, drawingPoints,
|
||||
dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay,
|
||||
sensitiveResources, centerPoints, windData,
|
||||
showWind, showBeached, showTimeLabel, simulationStartTime,
|
||||
analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM,
|
||||
])
|
||||
|
||||
// 3D 모드에 따른 지도 스타일 전환
|
||||
@ -982,7 +844,7 @@ export function MapView({
|
||||
}}
|
||||
mapStyle={currentMapStyle}
|
||||
className="w-full h-full"
|
||||
style={{ cursor: (isSelectingLocation || drawAnalysisMode !== null) ? 'crosshair' : 'grab' }}
|
||||
style={{ cursor: isSelectingLocation ? 'crosshair' : 'grab' }}
|
||||
onClick={handleMapClick}
|
||||
attributionControl={false}
|
||||
preserveDrawingBuffer={true}
|
||||
@ -1025,7 +887,7 @@ export function MapView({
|
||||
<DeckGLOverlay layers={deckLayers} />
|
||||
|
||||
{/* 해류 파티클 오버레이 */}
|
||||
{hydrData.length > 0 && showCurrent && (
|
||||
{hydrData.length > 0 && (
|
||||
<HydrParticleOverlay hydrStep={hydrData[currentTime] ?? null} />
|
||||
)}
|
||||
|
||||
@ -1066,16 +928,6 @@ export function MapView({
|
||||
오일펜스 배치 모드 — 지도를 클릭하여 포인트를 추가하세요 ({drawingPoints.length}개 포인트)
|
||||
</div>
|
||||
)}
|
||||
{drawAnalysisMode === 'polygon' && (
|
||||
<div className="boom-drawing-indicator" style={{ background: 'rgba(168,85,247,0.15)', borderColor: 'rgba(168,85,247,0.4)' }}>
|
||||
다각형 분석 모드 — 지도를 클릭하여 꼭짓점을 추가하세요 ({analysisPolygonPoints.length}개)
|
||||
</div>
|
||||
)}
|
||||
{drawAnalysisMode === 'circle' && (
|
||||
<div className="boom-drawing-indicator" style={{ background: 'rgba(168,85,247,0.15)', borderColor: 'rgba(168,85,247,0.4)' }}>
|
||||
{!analysisCircleCenter ? '원 분석 모드 — 중심점을 클릭하세요' : '반경 지점을 클릭하세요'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기상청 연계 정보 */}
|
||||
<WeatherInfoPanel position={currentPosition} />
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
import { cn } from '@common/utils/cn';
|
||||
|
||||
interface BadgeProps {
|
||||
color?: 'red' | 'blue' | 'green' | 'yellow' | 'purple' | 'cyan' | 'neutral';
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Badge = ({ color = 'neutral', children, className }: BadgeProps) => {
|
||||
return (
|
||||
<span className={cn('wing-badge', `wing-badge-${color}`, className)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default Badge;
|
||||
@ -1,21 +0,0 @@
|
||||
import { cn } from '@common/utils/cn';
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
hover?: boolean;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Card = ({ children, hover = true, onClick, className }: CardProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn('wing-card', hover && 'wing-card-hover', onClick && 'cursor-pointer', className)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Card;
|
||||
@ -1,94 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@common/utils/cn';
|
||||
|
||||
export interface Column<T> {
|
||||
key: keyof T | string;
|
||||
label: string;
|
||||
width?: string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
render?: (value: unknown, row: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
onRowClick?: (row: T) => void;
|
||||
stickyHeader?: boolean;
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const alignClass = (align?: 'left' | 'center' | 'right'): string => {
|
||||
if (align === 'center') return 'text-center';
|
||||
if (align === 'right') return 'text-right';
|
||||
return 'text-left';
|
||||
};
|
||||
|
||||
const DataTable = <T extends object>({
|
||||
columns,
|
||||
data,
|
||||
onRowClick,
|
||||
stickyHeader = true,
|
||||
emptyMessage = '데이터가 없습니다.',
|
||||
className,
|
||||
}: DataTableProps<T>) => {
|
||||
return (
|
||||
<div className={cn('w-full overflow-auto', className)}>
|
||||
<table className='wing-table'>
|
||||
<thead className={cn(stickyHeader && 'sticky top-0 z-10')}>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={String(col.key)}
|
||||
className={cn('wing-table-head', alignClass(col.align))}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
className='wing-table-cell text-center'
|
||||
style={{ color: 'var(--t3)' }}
|
||||
>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row, rowIdx) => (
|
||||
<tr
|
||||
key={rowIdx}
|
||||
className={cn(onRowClick && 'wing-table-row')}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
>
|
||||
{columns.map((col) => {
|
||||
const rawValue = (row as Record<string, unknown>)[String(col.key)];
|
||||
const cellContent = col.render
|
||||
? col.render(rawValue, row)
|
||||
: (rawValue as React.ReactNode);
|
||||
|
||||
return (
|
||||
<td
|
||||
key={String(col.key)}
|
||||
className={cn('wing-table-cell', alignClass(col.align))}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
>
|
||||
{cellContent}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataTable;
|
||||
@ -1,73 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { cn } from '@common/utils/cn';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
closeOnBackdrop?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Modal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
size = 'md',
|
||||
children,
|
||||
footer,
|
||||
closeOnBackdrop = true,
|
||||
className,
|
||||
}: ModalProps) => {
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleBackdropClick = () => {
|
||||
if (closeOnBackdrop) onClose();
|
||||
};
|
||||
|
||||
const handleModalClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const sizeClass =
|
||||
size === 'sm' ? 'wing-modal-sm' :
|
||||
size === 'lg' ? 'wing-modal-lg' :
|
||||
'wing-modal-md';
|
||||
|
||||
return (
|
||||
<div className='wing-overlay' onClick={handleBackdropClick}>
|
||||
<div
|
||||
className={cn('wing-modal', 'flex flex-col', sizeClass, className)}
|
||||
onClick={handleModalClick}
|
||||
>
|
||||
<div className='wing-modal-header'>
|
||||
<span className='wing-title'>{title}</span>
|
||||
<button className='wing-btn' onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className='wing-modal-body'>{children}</div>
|
||||
{footer && <div className='wing-modal-footer'>{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
@ -1,123 +0,0 @@
|
||||
import { cn } from '@common/utils/cn';
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
showFirstLast?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const getPageNumbers = (currentPage: number, totalPages: number): (number | '...')[] => {
|
||||
if (totalPages <= 5) {
|
||||
return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
const pages: (number | '...')[] = [];
|
||||
const delta = 2;
|
||||
const left = currentPage - delta;
|
||||
const right = currentPage + delta;
|
||||
|
||||
if (left > 1) {
|
||||
pages.push(1);
|
||||
if (left > 2) pages.push('...');
|
||||
}
|
||||
|
||||
for (let i = Math.max(1, left); i <= Math.min(totalPages, right); i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
if (right < totalPages) {
|
||||
if (right < totalPages - 1) pages.push('...');
|
||||
pages.push(totalPages);
|
||||
}
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
const Pagination = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
showFirstLast = true,
|
||||
className,
|
||||
}: PaginationProps) => {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const isFirst = currentPage === 1;
|
||||
const isLast = currentPage === totalPages;
|
||||
const pageNumbers = getPageNumbers(currentPage, totalPages);
|
||||
|
||||
const navBtnClass = (disabled: boolean) =>
|
||||
cn(
|
||||
'wing-btn wing-btn-secondary',
|
||||
disabled && 'opacity-40 cursor-not-allowed',
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center gap-1', className)}>
|
||||
{showFirstLast && (
|
||||
<button
|
||||
className={navBtnClass(isFirst)}
|
||||
onClick={() => !isFirst && onPageChange(1)}
|
||||
disabled={isFirst}
|
||||
aria-label='첫 페이지'
|
||||
>
|
||||
«
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={navBtnClass(isFirst)}
|
||||
onClick={() => !isFirst && onPageChange(currentPage - 1)}
|
||||
disabled={isFirst}
|
||||
aria-label='이전 페이지'
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
|
||||
{pageNumbers.map((page, idx) =>
|
||||
page === '...' ? (
|
||||
<span
|
||||
key={`ellipsis-${idx}`}
|
||||
className='wing-btn wing-btn-secondary opacity-50 cursor-default'
|
||||
>
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={page}
|
||||
className={cn(
|
||||
'wing-btn',
|
||||
page === currentPage ? 'wing-btn-primary' : 'wing-btn-secondary',
|
||||
)}
|
||||
onClick={() => onPageChange(page)}
|
||||
aria-current={page === currentPage ? 'page' : undefined}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
|
||||
<button
|
||||
className={navBtnClass(isLast)}
|
||||
onClick={() => !isLast && onPageChange(currentPage + 1)}
|
||||
disabled={isLast}
|
||||
aria-label='다음 페이지'
|
||||
>
|
||||
›
|
||||
</button>
|
||||
{showFirstLast && (
|
||||
<button
|
||||
className={navBtnClass(isLast)}
|
||||
onClick={() => !isLast && onPageChange(totalPages)}
|
||||
disabled={isLast}
|
||||
aria-label='마지막 페이지'
|
||||
>
|
||||
»
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pagination;
|
||||
@ -1,45 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@common/utils/cn';
|
||||
|
||||
interface SidePanelProps {
|
||||
position: 'left' | 'right';
|
||||
width?: 'narrow' | 'default' | 'wide';
|
||||
header?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const WIDTH_MAP: Record<NonNullable<SidePanelProps['width']>, string> = {
|
||||
narrow: 'var(--panel-narrow)',
|
||||
default: 'var(--panel-default)',
|
||||
wide: 'var(--panel-wide)',
|
||||
};
|
||||
|
||||
const SidePanel = ({
|
||||
position,
|
||||
width = 'default',
|
||||
header,
|
||||
footer,
|
||||
children,
|
||||
className,
|
||||
}: SidePanelProps) => {
|
||||
const panelClass = position === 'left' ? 'wing-panel-left' : 'wing-panel-right';
|
||||
|
||||
const style: React.CSSProperties =
|
||||
width !== 'default' ? { width: WIDTH_MAP[width] } : {};
|
||||
|
||||
return (
|
||||
<div className={cn(panelClass, className)} style={style}>
|
||||
{header && <div className='wing-header-bar'>{header}</div>}
|
||||
<div className='wing-panel-scroll'>{children}</div>
|
||||
{footer && (
|
||||
<div className='flex flex-col gap-1.5 p-2.5 border-t border-border'>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidePanel;
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useSyncExternalStore } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { MainTab } from '../types/navigation'
|
||||
import { useAuthStore } from '@common/store/authStore'
|
||||
import { API_BASE_URL } from '@common/services/api'
|
||||
@ -38,7 +38,7 @@ const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
|
||||
],
|
||||
aerial: [
|
||||
{ id: 'media', label: '영상사진관리', icon: '📷' },
|
||||
{ id: 'analysis', label: '영상사진합성', icon: '🧩' },
|
||||
{ id: 'analysis', label: '유출유면적분석', icon: '🧩' },
|
||||
{ id: 'realtime', label: '실시간드론', icon: '🛸' },
|
||||
{ id: 'sensor', label: '오염/선박3D분석', icon: '🔍' },
|
||||
{ id: 'satellite', label: '위성요청', icon: '🛰' },
|
||||
@ -91,10 +91,17 @@ function subscribe(listener: () => void) {
|
||||
}
|
||||
|
||||
export function useSubMenu(mainTab: MainTab) {
|
||||
const activeSubTab = useSyncExternalStore(subscribe, () => subMenuState[mainTab])
|
||||
const [activeSubTab, setActiveSubTabLocal] = useState(subMenuState[mainTab])
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribe(() => {
|
||||
setActiveSubTabLocal(subMenuState[mainTab])
|
||||
})
|
||||
return unsubscribe
|
||||
}, [mainTab])
|
||||
|
||||
const setActiveSubTab = (subTab: string) => {
|
||||
setSubTab(mainTab, subTab)
|
||||
}
|
||||
|
||||
@ -23,22 +23,6 @@
|
||||
--fM: JetBrains Mono, monospace;
|
||||
--rS: 6px;
|
||||
--rM: 8px;
|
||||
|
||||
/* ── Z-index 스케일 ── */
|
||||
--z-dropdown: 100;
|
||||
--z-sticky: 200;
|
||||
--z-overlay: 1000;
|
||||
--z-modal: 10000;
|
||||
--z-toast: 10100;
|
||||
|
||||
/* ── Panel Width ── */
|
||||
--panel-narrow: 280px;
|
||||
--panel-default: 300px;
|
||||
--panel-wide: 340px;
|
||||
|
||||
/* ── Transition ── */
|
||||
--transition-fast: 0.15s ease;
|
||||
--transition-normal: 0.2s ease;
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
@ -24,7 +24,6 @@
|
||||
}
|
||||
|
||||
/* ═══ Prediction Input Form ═══ */
|
||||
/* @deprecated: wing-input 사용 권장 */
|
||||
.prd-i {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
@ -46,7 +45,6 @@
|
||||
}
|
||||
|
||||
/* Select Dropdown */
|
||||
/* @deprecated: wing-select 사용 권장 */
|
||||
select.prd-i {
|
||||
cursor: pointer;
|
||||
padding-right: 30px;
|
||||
@ -221,7 +219,6 @@
|
||||
}
|
||||
|
||||
/* ═══ Buttons ═══ */
|
||||
/* @deprecated: wing-btn + wing-btn-primary/secondary 사용 권장 */
|
||||
.prd-btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
|
||||
@ -28,11 +28,6 @@
|
||||
.wing-card {
|
||||
@apply rounded-md p-4 border border-border;
|
||||
background: var(--bg3);
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.wing-card-hover:hover {
|
||||
border-color: rgba(6, 182, 212, 0.4);
|
||||
}
|
||||
|
||||
.wing-card-sm {
|
||||
@ -88,42 +83,7 @@
|
||||
|
||||
/* ── Badge ── */
|
||||
.wing-badge {
|
||||
@apply inline-flex items-center px-2 py-0.5 rounded text-xs font-bold font-korean;
|
||||
}
|
||||
|
||||
.wing-badge-neutral {
|
||||
background: rgba(134, 144, 166, 0.15);
|
||||
color: var(--t2);
|
||||
}
|
||||
|
||||
.wing-badge-red {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.wing-badge-blue {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.wing-badge-green {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.wing-badge-yellow {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.wing-badge-purple {
|
||||
background: rgba(168, 85, 247, 0.15);
|
||||
color: var(--purple);
|
||||
}
|
||||
|
||||
.wing-badge-cyan {
|
||||
background: rgba(6, 182, 212, 0.15);
|
||||
color: var(--cyan);
|
||||
@apply inline-flex items-center px-2 py-0.5 rounded text-[9px] font-bold font-korean;
|
||||
}
|
||||
|
||||
/* ── Button ── */
|
||||
@ -191,11 +151,11 @@
|
||||
|
||||
/* ── Table ── */
|
||||
.wing-table {
|
||||
@apply w-full text-sm font-korean;
|
||||
@apply w-full text-[10px] font-korean;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.wing-table-head {
|
||||
.wing-th {
|
||||
@apply text-left font-semibold;
|
||||
padding: 8px 10px;
|
||||
color: var(--t3);
|
||||
@ -203,26 +163,17 @@
|
||||
border-bottom: 1px solid var(--bd);
|
||||
}
|
||||
|
||||
.wing-table-cell {
|
||||
.wing-td {
|
||||
padding: 8px 10px;
|
||||
color: var(--t2);
|
||||
border-bottom: 1px solid var(--bd);
|
||||
}
|
||||
|
||||
.wing-table-row {
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.wing-table-row:hover {
|
||||
.wing-tr-hover:hover {
|
||||
background: var(--bgH);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 기존 alias (하위 호환) */
|
||||
.wing-th { @apply text-left font-semibold; padding: 8px 10px; color: var(--t3); background: var(--bg2); border-bottom: 1px solid var(--bd); }
|
||||
.wing-td { padding: 8px 10px; color: var(--t2); border-bottom: 1px solid var(--bd); }
|
||||
.wing-tr-hover:hover { background: var(--bgH); cursor: pointer; }
|
||||
|
||||
/* ── Tab Bar ── */
|
||||
.wing-tab-bar {
|
||||
@apply flex gap-0.5 rounded-lg p-1 border border-border;
|
||||
@ -250,7 +201,7 @@
|
||||
/* ── Modal ── */
|
||||
.wing-overlay {
|
||||
@apply fixed inset-0 flex items-center justify-center;
|
||||
z-index: var(--z-modal);
|
||||
z-index: 10000;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
@ -268,127 +219,6 @@
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
/* ── Select ── */
|
||||
.wing-select {
|
||||
@apply w-full rounded-sm text-[11px] font-korean outline-none;
|
||||
padding: 6px 10px;
|
||||
padding-right: 30px;
|
||||
background: var(--bg0);
|
||||
border: 1px solid var(--bd);
|
||||
color: var(--t1);
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
background-size: 10px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.wing-select:hover {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.wing-select:focus {
|
||||
border-color: var(--cyan);
|
||||
}
|
||||
|
||||
.wing-select option {
|
||||
background: #1a1f2e;
|
||||
color: var(--t1);
|
||||
padding: 10px;
|
||||
font-size: 11px;
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
}
|
||||
|
||||
/* ── Textarea ── */
|
||||
.wing-textarea {
|
||||
@apply w-full rounded-sm text-[11px] font-korean outline-none;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg0);
|
||||
border: 1px solid var(--bd);
|
||||
color: var(--t1);
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.wing-textarea:focus {
|
||||
border-color: var(--cyan);
|
||||
}
|
||||
|
||||
.wing-textarea::placeholder {
|
||||
color: var(--t3);
|
||||
}
|
||||
|
||||
/* ── Search Input ── */
|
||||
.wing-input-search {
|
||||
@apply rounded-sm text-[11px] font-korean outline-none;
|
||||
width: 256px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg0);
|
||||
border: 1px solid var(--bd);
|
||||
color: var(--t1);
|
||||
}
|
||||
|
||||
.wing-input-search:focus {
|
||||
border-color: var(--cyan);
|
||||
}
|
||||
|
||||
.wing-input-search::placeholder {
|
||||
color: var(--t3);
|
||||
}
|
||||
|
||||
|
||||
/* ── Side Panel ── */
|
||||
.wing-panel-right {
|
||||
@apply flex flex-col h-full overflow-hidden;
|
||||
width: var(--panel-default);
|
||||
border-left: 1px solid var(--bd);
|
||||
background: var(--bg1);
|
||||
}
|
||||
|
||||
.wing-panel-left {
|
||||
@apply flex flex-col h-full overflow-hidden;
|
||||
width: var(--panel-default);
|
||||
border-right: 1px solid var(--bd);
|
||||
background: var(--bg1);
|
||||
}
|
||||
|
||||
/* ── Modal Size Variants ── */
|
||||
.wing-modal-sm { width: 400px; max-height: 90vh; }
|
||||
.wing-modal-md { width: 560px; max-height: 90vh; }
|
||||
.wing-modal-lg { width: 720px; max-height: 90vh; }
|
||||
|
||||
/* ── Modal Body / Footer ── */
|
||||
.wing-modal-body {
|
||||
@apply flex-1 overflow-y-auto px-5 py-4;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--bdL) transparent;
|
||||
}
|
||||
|
||||
.wing-modal-footer {
|
||||
@apply flex justify-end gap-2 px-5 border-t border-border;
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ── Info Row (wing-kv alias) ── */
|
||||
.wing-info-row {
|
||||
@apply flex items-center justify-between;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.wing-info-label {
|
||||
@apply text-[10px] font-korean;
|
||||
color: var(--t3);
|
||||
}
|
||||
|
||||
.wing-info-value {
|
||||
@apply text-[11px] font-semibold font-mono;
|
||||
}
|
||||
|
||||
/* ── Utility ── */
|
||||
.wing-divider {
|
||||
@apply w-full;
|
||||
|
||||
@ -1 +1 @@
|
||||
export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'admin' | 'showcase';
|
||||
export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'admin';
|
||||
|
||||
@ -186,47 +186,6 @@ export function generateAIBoomLines(
|
||||
return boomLines
|
||||
}
|
||||
|
||||
/** Ray casting — 점이 다각형 내부인지 판정 */
|
||||
export function pointInPolygon(
|
||||
point: { lat: number; lon: number },
|
||||
polygon: { lat: number; lon: number }[]
|
||||
): boolean {
|
||||
if (polygon.length < 3) return false
|
||||
let inside = false
|
||||
const x = point.lon
|
||||
const y = point.lat
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const xi = polygon[i].lon, yi = polygon[i].lat
|
||||
const xj = polygon[j].lon, yj = polygon[j].lat
|
||||
const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)
|
||||
if (intersect) inside = !inside
|
||||
}
|
||||
return inside
|
||||
}
|
||||
|
||||
/** 다각형 면적 (km²) — Shoelace formula, 구면 보정 포함 */
|
||||
export function polygonAreaKm2(polygon: { lat: number; lon: number }[]): number {
|
||||
if (polygon.length < 3) return 0
|
||||
const n = polygon.length
|
||||
const latCenter = polygon.reduce((s, p) => s + p.lat, 0) / n
|
||||
const cosLat = Math.cos(latCenter * DEG2RAD)
|
||||
let area = 0
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n
|
||||
const x1 = polygon[i].lon * cosLat * EARTH_RADIUS * DEG2RAD / 1000
|
||||
const y1 = polygon[i].lat * EARTH_RADIUS * DEG2RAD / 1000
|
||||
const x2 = polygon[j].lon * cosLat * EARTH_RADIUS * DEG2RAD / 1000
|
||||
const y2 = polygon[j].lat * EARTH_RADIUS * DEG2RAD / 1000
|
||||
area += x1 * y2 - x2 * y1
|
||||
}
|
||||
return Math.abs(area) / 2
|
||||
}
|
||||
|
||||
/** 원 면적 (km²) */
|
||||
export function circleAreaKm2(radiusM: number): number {
|
||||
return Math.PI * (radiusM / 1000) ** 2
|
||||
}
|
||||
|
||||
/** 차단 시뮬레이션 실행 */
|
||||
export function runContainmentAnalysis(
|
||||
trajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>,
|
||||
|
||||
@ -184,7 +184,7 @@ export function MediaManagement() {
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={e => setSortBy(e.target.value)}
|
||||
className="wing-select py-1.5 w-auto"
|
||||
className="prd-i py-1.5 w-auto"
|
||||
>
|
||||
<option value="latest">최신순</option>
|
||||
<option value="name">이름순</option>
|
||||
@ -370,7 +370,7 @@ export function MediaManagement() {
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean">촬영 장비</label>
|
||||
<select className="wing-select w-full">
|
||||
<select className="prd-i w-full">
|
||||
<option>드론 (DJI M300 RTK)</option>
|
||||
<option>드론 (DJI Mavic 3E)</option>
|
||||
<option>유인항공기 (CN-235)</option>
|
||||
@ -382,7 +382,7 @@ export function MediaManagement() {
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean">연관 사고</label>
|
||||
<select className="wing-select w-full">
|
||||
<select className="prd-i w-full">
|
||||
<option>여수항 유류유출 (2026-01-18)</option>
|
||||
<option>통영 해역 기름오염 (2026-01-18)</option>
|
||||
<option>군산항 인근 오염 (2026-01-18)</option>
|
||||
@ -391,7 +391,7 @@ export function MediaManagement() {
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean">메모</label>
|
||||
<textarea
|
||||
className="wing-input w-full h-[60px] resize-y"
|
||||
className="prd-i w-full h-[60px] resize-y"
|
||||
placeholder="촬영 조건, 비고 등..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,29 +1,20 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import * as exifr from 'exifr';
|
||||
import { stitchImages } from '../services/aerialApi';
|
||||
import { analyzeImage } from '@tabs/prediction/services/predictionApi';
|
||||
import { setPendingImageAnalysis } from '@common/utils/imageAnalysisSignal';
|
||||
import { navigateToTab } from '@common/hooks/useSubMenu';
|
||||
import { decimalToDMS } from '@common/utils/coordinates';
|
||||
|
||||
const MAX_IMAGES = 6;
|
||||
|
||||
interface GpsInfo {
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
}
|
||||
|
||||
export function OilAreaAnalysis() {
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
|
||||
const [imageGpsInfos, setImageGpsInfos] = useState<(GpsInfo | undefined)[]>([]);
|
||||
const [stitchedBlob, setStitchedBlob] = useState<Blob | null>(null);
|
||||
const [stitchedPreviewUrl, setStitchedPreviewUrl] = useState<string | null>(null);
|
||||
const [isStitching, setIsStitching] = useState(false);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const processedFilesRef = useRef<Set<File>>(new Set());
|
||||
|
||||
// Object URL 메모리 누수 방지 — 언마운트 시 전체 revoke
|
||||
useEffect(() => {
|
||||
@ -34,34 +25,6 @@ export function OilAreaAnalysis() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 선택된 파일이 바뀔 때마다 새 파일의 EXIF GPS 추출
|
||||
useEffect(() => {
|
||||
selectedFiles.forEach((file, i) => {
|
||||
if (processedFilesRef.current.has(file)) return;
|
||||
processedFilesRef.current.add(file);
|
||||
|
||||
exifr.gps(file)
|
||||
.then(gps => {
|
||||
setImageGpsInfos(prev => {
|
||||
const updated = [...prev];
|
||||
while (updated.length <= i) updated.push(undefined);
|
||||
updated[i] = gps
|
||||
? { lat: gps.latitude, lon: gps.longitude }
|
||||
: { lat: null, lon: null };
|
||||
return updated;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setImageGpsInfos(prev => {
|
||||
const updated = [...prev];
|
||||
while (updated.length <= i) updated.push(undefined);
|
||||
updated[i] = { lat: null, lon: null };
|
||||
return updated;
|
||||
});
|
||||
});
|
||||
});
|
||||
}, [selectedFiles]);
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setError(null);
|
||||
const incoming = Array.from(e.target.files ?? []);
|
||||
@ -93,7 +56,6 @@ export function OilAreaAnalysis() {
|
||||
URL.revokeObjectURL(prev[idx]);
|
||||
return prev.filter((_, i) => i !== idx);
|
||||
});
|
||||
setImageGpsInfos(prev => prev.filter((_, i) => i !== idx));
|
||||
// 합성 결과 초기화 (선택 파일이 바뀌었으므로)
|
||||
setStitchedBlob(null);
|
||||
if (stitchedPreviewUrl) {
|
||||
@ -150,7 +112,7 @@ export function OilAreaAnalysis() {
|
||||
<div className="flex gap-5 h-full overflow-hidden">
|
||||
{/* ── Left Panel ── */}
|
||||
<div className="w-[280px] min-w-[280px] flex flex-col overflow-y-auto scrollbar-thin">
|
||||
<div className="text-sm font-bold mb-1 font-korean">🧩 영상사진합성</div>
|
||||
<div className="text-sm font-bold mb-1 font-korean">🧩 유출유면적분석</div>
|
||||
<div className="text-[11px] text-text-3 mb-4 font-korean">
|
||||
드론 사진을 합성하여 유출유 확산 면적과 기름 양을 산정합니다.
|
||||
</div>
|
||||
@ -239,34 +201,15 @@ export function OilAreaAnalysis() {
|
||||
{Array.from({ length: MAX_IMAGES }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-bg-3 border border-border rounded-sm overflow-hidden flex flex-col"
|
||||
className="bg-bg-3 border border-border rounded-sm overflow-hidden"
|
||||
style={{ height: '300px' }}
|
||||
>
|
||||
{previewUrls[i] ? (
|
||||
<>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<img
|
||||
src={previewUrls[i]}
|
||||
alt={selectedFiles[i]?.name ?? ''}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-2 py-1 bg-bg-0 border-t border-border shrink-0 flex items-start justify-between gap-1">
|
||||
<div className="text-[10px] text-text-2 truncate font-korean flex-1 min-w-0">
|
||||
{selectedFiles[i]?.name}
|
||||
</div>
|
||||
{imageGpsInfos[i] === undefined ? (
|
||||
<div className="text-[10px] text-text-3 font-korean shrink-0">GPS 읽는 중...</div>
|
||||
) : imageGpsInfos[i]?.lat !== null ? (
|
||||
<div className="text-[10px] text-primary-cyan font-mono leading-tight text-right shrink-0">
|
||||
{decimalToDMS(imageGpsInfos[i]!.lat!, true)}<br />
|
||||
{decimalToDMS(imageGpsInfos[i]!.lon!, false)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-text-3 font-korean shrink-0">GPS 정보 없음</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<img
|
||||
src={previewUrls[i]}
|
||||
alt={selectedFiles[i]?.name ?? ''}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-text-3 text-lg font-mono opacity-20">
|
||||
{i + 1}
|
||||
|
||||
@ -116,9 +116,9 @@ function AssetManagement() {
|
||||
placeholder="기관명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="wing-input w-40 py-1.5 px-2.5"
|
||||
className="prd-i w-40 py-1.5 px-2.5"
|
||||
/>
|
||||
<select value={regionFilter} onChange={e => setRegionFilter(e.target.value)} className="wing-select w-[100px] py-1.5 px-2">
|
||||
<select value={regionFilter} onChange={e => setRegionFilter(e.target.value)} className="prd-i w-[100px] py-1.5 px-2">
|
||||
<option value="all">전체 관할</option>
|
||||
<option value="남해">남해청</option>
|
||||
<option value="서해">서해청</option>
|
||||
@ -126,7 +126,7 @@ function AssetManagement() {
|
||||
<option value="동해">동해청</option>
|
||||
<option value="제주">제주청</option>
|
||||
</select>
|
||||
<select value={typeFilterVal} onChange={e => setTypeFilterVal(e.target.value)} className="wing-select w-[100px] py-1.5 px-2">
|
||||
<select value={typeFilterVal} onChange={e => setTypeFilterVal(e.target.value)} className="prd-i w-[100px] py-1.5 px-2">
|
||||
<option value="all">전체 유형</option>
|
||||
<option value="해경관할">해경관할</option>
|
||||
<option value="해경경찰서">해경경찰서</option>
|
||||
@ -140,7 +140,7 @@ function AssetManagement() {
|
||||
<option value="해군">해군</option>
|
||||
<option value="기타">기타</option>
|
||||
</select>
|
||||
<select value={equipFilter} onChange={e => setEquipFilter(e.target.value)} className="wing-select w-[100px] py-1.5 px-2">
|
||||
<select value={equipFilter} onChange={e => setEquipFilter(e.target.value)} className="prd-i w-[100px] py-1.5 px-2">
|
||||
<option value="all">전체 장비</option>
|
||||
<option value="vessel">방제선</option>
|
||||
<option value="skimmer">유회수기</option>
|
||||
|
||||
@ -37,7 +37,7 @@ function AssetUpload() {
|
||||
{/* Asset Classification */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean">자산 분류</label>
|
||||
<select className="wing-select w-full">
|
||||
<select className="prd-i w-full">
|
||||
<option>장비자재</option>
|
||||
<option>방제선</option>
|
||||
<option>경비함정</option>
|
||||
@ -50,7 +50,7 @@ function AssetUpload() {
|
||||
{/* Jurisdiction */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean">업로드 대상 관할</label>
|
||||
<select className="wing-select w-full">
|
||||
<select className="prd-i w-full">
|
||||
<option>남해청 - 여수서</option>
|
||||
<option>남해청 - 부산서</option>
|
||||
<option>남해청 - 울산서</option>
|
||||
|
||||
@ -173,7 +173,7 @@ function ShipInsurance() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-semibold text-text-3 mb-1">선박종류</label>
|
||||
<select value={shipTpFilter} onChange={e => setShipTpFilter(e.target.value)} className="wing-select min-w-[120px] border-border">
|
||||
<select value={shipTpFilter} onChange={e => setShipTpFilter(e.target.value)} className="prd-i min-w-[120px] border-border">
|
||||
<option value="">전체</option>
|
||||
<option value="일반선박">일반선박</option>
|
||||
<option value="유조선">유조선</option>
|
||||
@ -181,7 +181,7 @@ function ShipInsurance() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-semibold text-text-3 mb-1">발급기관</label>
|
||||
<select value={issueOrgFilter} onChange={e => setIssueOrgFilter(e.target.value)} className="wing-select min-w-[160px] border-border">
|
||||
<select value={issueOrgFilter} onChange={e => setIssueOrgFilter(e.target.value)} className="prd-i min-w-[160px] border-border">
|
||||
<option value="">전체</option>
|
||||
<option>부산지방해양수산청</option>
|
||||
<option>인천지방해양수산청</option>
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuthStore } from '@common/store/authStore'
|
||||
import { fetchBoardPost, type BoardPostDetail } from '../services/boardApi'
|
||||
import Badge from '@common/components/ui/Badge'
|
||||
|
||||
// 카테고리 코드 → 표시명
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
@ -11,6 +10,14 @@ const CATEGORY_LABELS: Record<string, string> = {
|
||||
MANUAL: '해경매뉴얼',
|
||||
}
|
||||
|
||||
// 카테고리별 배지 색상
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
NOTICE: 'bg-red-500/20 text-red-400',
|
||||
DATA: 'bg-green-500/20 text-green-400',
|
||||
QNA: 'bg-purple-500/20 text-purple-400',
|
||||
MANUAL: 'bg-blue-500/20 text-blue-400',
|
||||
}
|
||||
|
||||
interface BoardDetailViewProps {
|
||||
postSn: number
|
||||
onBack: () => void
|
||||
@ -57,10 +64,10 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-bg-0">
|
||||
{/* 헤더 */}
|
||||
<div className="wing-header-bar">
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="wing-btn wing-btn-secondary flex items-center gap-2"
|
||||
className="flex items-center gap-2 text-sm font-semibold text-text-2 hover:text-text-1 transition-colors"
|
||||
>
|
||||
<span>←</span>
|
||||
<span>목록으로</span>
|
||||
@ -69,13 +76,13 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="wing-btn wing-btn-secondary"
|
||||
className="px-4 py-2 text-sm font-semibold rounded bg-bg-2 text-text-1 border border-border hover:bg-bg-3 transition-colors"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="wing-btn bg-red-500/20 text-red-400 border border-red-500/30 hover:bg-red-500/30 transition-colors"
|
||||
className="px-4 py-2 text-sm font-semibold rounded bg-red-500/20 text-red-400 border border-red-500/30 hover:bg-red-500/30 transition-colors"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
@ -89,9 +96,13 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
|
||||
{/* 게시글 헤더 */}
|
||||
<div className="pb-6 border-b border-border">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Badge>{CATEGORY_LABELS[post.categoryCd] || post.categoryCd}</Badge>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold ${CATEGORY_COLORS[post.categoryCd] || 'bg-blue-500/20 text-blue-400'}`}>
|
||||
{CATEGORY_LABELS[post.categoryCd] || post.categoryCd}
|
||||
</span>
|
||||
{post.pinnedYn === 'Y' && (
|
||||
<Badge color="yellow">📌 고정</Badge>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold bg-yellow-500/20 text-yellow-400">
|
||||
📌 고정
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-text-1 mb-4">{post.title}</h1>
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuthStore } from '@common/store/authStore';
|
||||
import { fetchBoardPosts, type BoardPostItem } from '../services/boardApi';
|
||||
import Badge from '@common/components/ui/Badge';
|
||||
|
||||
// 카테고리 코드 ↔ 표시명 매핑
|
||||
const CATEGORY_MAP: Record<string, string> = {
|
||||
@ -18,6 +17,13 @@ const CATEGORY_FILTER: { label: string; code: string | null }[] = [
|
||||
{ label: 'Q&A', code: 'QNA' },
|
||||
];
|
||||
|
||||
const CATEGORY_STYLE: Record<string, string> = {
|
||||
NOTICE: 'bg-red-500/20 text-red-400',
|
||||
DATA: 'bg-blue-500/20 text-blue-400',
|
||||
QNA: 'bg-green-500/20 text-green-400',
|
||||
MANUAL: 'bg-yellow-500/20 text-yellow-400',
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
interface BoardListTableProps {
|
||||
@ -91,7 +97,7 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-bg-0">
|
||||
{/* Header with Search and Write Button */}
|
||||
<div className="wing-header-bar">
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex gap-2">
|
||||
{CATEGORY_FILTER.map((cat) => (
|
||||
@ -117,13 +123,13 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
className="wing-input-search"
|
||||
className="px-4 py-2 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none w-64"
|
||||
/>
|
||||
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={onWriteClick}
|
||||
className="wing-btn wing-btn-primary flex items-center gap-2"
|
||||
className="px-6 py-2 text-sm font-semibold rounded bg-primary-cyan text-bg-0 hover:opacity-90 transition-opacity flex items-center gap-2"
|
||||
>
|
||||
<span>+</span>
|
||||
<span>글쓰기</span>
|
||||
@ -140,15 +146,15 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="wing-table">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-border">
|
||||
<th className="wing-table-head w-20">번호</th>
|
||||
<th className="wing-table-head w-32">분류</th>
|
||||
<th className="wing-table-head">제목</th>
|
||||
<th className="wing-table-head w-32">작성자</th>
|
||||
<th className="wing-table-head w-32">작성일</th>
|
||||
<th className="wing-table-head w-24">조회수</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-20">번호</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">분류</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2">제목</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">작성자</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">작성일</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-24">조회수</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -156,19 +162,27 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
<tr
|
||||
key={post.sn}
|
||||
onClick={() => onPostClick(post.sn)}
|
||||
className="wing-table-row"
|
||||
className="border-b border-border hover:bg-bg-2 cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="wing-table-cell text-text-1">
|
||||
<td className="px-4 py-4 text-sm text-text-1">
|
||||
{post.pinnedYn === 'Y' ? (
|
||||
<Badge color="cyan">공지</Badge>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-red-500/20 text-red-400">
|
||||
공지
|
||||
</span>
|
||||
) : (
|
||||
post.sn
|
||||
)}
|
||||
</td>
|
||||
<td className="wing-table-cell">
|
||||
<Badge>{CATEGORY_MAP[post.categoryCd] || post.categoryCd}</Badge>
|
||||
<td className="px-4 py-4">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold ${
|
||||
CATEGORY_STYLE[post.categoryCd] || 'bg-gray-500/20 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{CATEGORY_MAP[post.categoryCd] || post.categoryCd}
|
||||
</span>
|
||||
</td>
|
||||
<td className="wing-table-cell">
|
||||
<td className="px-4 py-4">
|
||||
<span
|
||||
className={`text-sm ${
|
||||
post.pinnedYn === 'Y' ? 'font-semibold text-text-1' : 'text-text-1'
|
||||
@ -177,9 +191,9 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
{post.title}
|
||||
</span>
|
||||
</td>
|
||||
<td className="wing-table-cell">{post.authorName}</td>
|
||||
<td className="wing-table-cell text-text-3">{formatDate(post.regDtm)}</td>
|
||||
<td className="wing-table-cell text-text-3">{post.viewCnt}</td>
|
||||
<td className="px-4 py-4 text-sm text-text-2">{post.authorName}</td>
|
||||
<td className="px-4 py-4 text-sm text-text-3">{formatDate(post.regDtm)}</td>
|
||||
<td className="px-4 py-4 text-sm text-text-3">{post.viewCnt}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@ -200,7 +214,7 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="wing-btn wing-btn-secondary disabled:opacity-40"
|
||||
className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors disabled:opacity-40"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
@ -208,10 +222,10 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPage(p)}
|
||||
className={`wing-btn ${
|
||||
className={`px-3 py-1.5 text-sm rounded ${
|
||||
page === p
|
||||
? 'wing-btn-primary font-semibold'
|
||||
: 'wing-btn-secondary'
|
||||
? 'bg-primary-cyan text-bg-0 font-semibold'
|
||||
: 'bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
@ -220,7 +234,7 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="wing-btn wing-btn-secondary disabled:opacity-40"
|
||||
className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors disabled:opacity-40"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
|
||||
@ -3,8 +3,6 @@ import { useSubMenu } from '@common/hooks/useSubMenu'
|
||||
import { useAuthStore } from '@common/store/authStore'
|
||||
import { BoardWriteForm } from './BoardWriteForm'
|
||||
import { BoardDetailView } from './BoardDetailView'
|
||||
import Badge from '@common/components/ui/Badge'
|
||||
import Card from '@common/components/ui/Card'
|
||||
import {
|
||||
fetchBoardPosts,
|
||||
deleteBoardPost,
|
||||
@ -35,6 +33,14 @@ const CATEGORY_LABELS: Record<string, string> = {
|
||||
MANUAL: '해경매뉴얼',
|
||||
}
|
||||
|
||||
// 카테고리별 배지 색상
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
NOTICE: 'bg-red-500/20 text-red-400',
|
||||
DATA: 'bg-green-500/20 text-green-400',
|
||||
QNA: 'bg-purple-500/20 text-purple-400',
|
||||
MANUAL: 'bg-blue-500/20 text-blue-400',
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export function BoardView() {
|
||||
@ -184,6 +190,15 @@ export function BoardView() {
|
||||
|
||||
const filteredManuals = manualList
|
||||
|
||||
const catColor = (cat: string) => {
|
||||
switch (cat) {
|
||||
case '방제매뉴얼': return { bg: 'rgba(6,182,212,.15)', text: '#22d3ee' }
|
||||
case '대응매뉴얼': return { bg: 'rgba(249,115,22,.15)', text: '#f97316' }
|
||||
case '교육자료': return { bg: 'rgba(34,197,94,.15)', text: '#22c55e' }
|
||||
case '법령·규정': return { bg: 'rgba(168,85,247,.15)', text: '#a855f7' }
|
||||
default: return { bg: 'rgba(100,100,100,.15)', text: '#999' }
|
||||
}
|
||||
}
|
||||
|
||||
if (activeSubTab === 'manual') {
|
||||
return (
|
||||
@ -191,7 +206,7 @@ export function BoardView() {
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
<div className="flex flex-col h-full bg-bg-0">
|
||||
{/* 헤더 */}
|
||||
<div className="wing-header-bar">
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b" style={{ borderColor: 'var(--bd)', background: 'var(--bg1)' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">📘</span>
|
||||
@ -201,11 +216,12 @@ export function BoardView() {
|
||||
<div className="flex gap-1 ml-4">
|
||||
{manualCategories.map(cat => (
|
||||
<button key={cat} onClick={() => setManualCategory(cat)}
|
||||
className={`px-3 py-1.5 text-[11px] font-semibold rounded-md transition-all border ${
|
||||
manualCategory === cat
|
||||
? 'bg-primary-cyan/15 border-primary-cyan/30 text-primary-cyan'
|
||||
: 'bg-bg-3 border-border text-text-3'
|
||||
}`}>
|
||||
className="px-3 py-1.5 text-[11px] font-semibold rounded-md transition-all"
|
||||
style={{
|
||||
background: manualCategory === cat ? 'rgba(6,182,212,.15)' : 'var(--bg3)',
|
||||
border: manualCategory === cat ? '1px solid rgba(6,182,212,.3)' : '1px solid var(--bd)',
|
||||
color: manualCategory === cat ? 'var(--cyan)' : 'var(--t3)',
|
||||
}}>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
@ -213,9 +229,10 @@ export function BoardView() {
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="text" placeholder="매뉴얼 검색..." value={manualSearch} onChange={e => setManualSearch(e.target.value)}
|
||||
className="wing-input-search" />
|
||||
className="px-4 py-2 text-sm rounded w-64" style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', outline: 'none' }} />
|
||||
<button onClick={() => setShowUploadModal(true)}
|
||||
className="wing-btn wing-btn-primary flex items-center gap-1.5">
|
||||
className="px-5 py-2 text-[12px] font-semibold rounded-md transition-all flex items-center gap-1.5"
|
||||
style={{ background: 'rgba(6,182,212,.15)', border: '1px solid rgba(6,182,212,.3)', color: '#22d3ee', cursor: 'pointer' }}>
|
||||
📤 새로 업로드
|
||||
</button>
|
||||
</div>
|
||||
@ -229,18 +246,33 @@ export function BoardView() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))' }}>
|
||||
{filteredManuals.map(file => (
|
||||
<Card key={file.manualSn}>
|
||||
{filteredManuals.map(file => {
|
||||
const cc = catColor(file.catgNm)
|
||||
return (
|
||||
<div key={file.manualSn} className="rounded-xl p-4 transition-all" style={{
|
||||
background: 'var(--bg3)', border: '1px solid var(--bd)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = 'rgba(6,182,212,.4)' }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = 'var(--bd)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Badge>{file.catgNm}</Badge>
|
||||
<Badge>{file.version}</Badge>
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-semibold" style={{ background: cc.bg, color: cc.text }}>
|
||||
{file.catgNm}
|
||||
</span>
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded" style={{ background: 'rgba(59,130,246,.1)', color: '#3b82f6' }}>
|
||||
{file.version}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[12px] font-bold mb-3 leading-[1.5]">
|
||||
{file.title}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Badge>📄 {file.fileTp || 'PDF'}</Badge>
|
||||
<span className="text-[10px] text-text-3" style={{ fontFamily: 'var(--fM)' }}>{file.fileSz}</span>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded" style={{ background: 'rgba(239,68,68,.08)' }}>
|
||||
<span style={{ fontSize: 12 }}>📄</span>
|
||||
<span className="text-[10px] font-semibold" style={{ color: '#ef4444' }}>{file.fileTp || 'PDF'}</span>
|
||||
</div>
|
||||
<span className="text-[10px]" style={{ color: 'var(--t3)', fontFamily: 'var(--fM)' }}>{file.fileSz}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1 mb-2">
|
||||
<button onClick={(e) => {
|
||||
@ -255,7 +287,8 @@ export function BoardView() {
|
||||
})
|
||||
setShowUploadModal(true)
|
||||
}}
|
||||
className="wing-btn wing-btn-secondary text-[10px]"
|
||||
className="px-2 py-0.5 rounded text-[10px] font-semibold transition-all"
|
||||
style={{ background: 'rgba(59,130,246,.1)', border: '1px solid rgba(59,130,246,.2)', color: '#3b82f6', cursor: 'pointer' }}
|
||||
title="수정">
|
||||
✏️ 수정
|
||||
</button>
|
||||
@ -270,18 +303,19 @@ export function BoardView() {
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="wing-btn text-[10px] bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20"
|
||||
className="px-2 py-0.5 rounded text-[10px] font-semibold transition-all"
|
||||
style={{ background: 'rgba(239,68,68,.1)', border: '1px solid rgba(239,68,68,.2)', color: '#ef4444', cursor: 'pointer' }}
|
||||
title="삭제">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-3 border-t border-border">
|
||||
<div className="flex items-center justify-between pt-3" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||
<div className="flex items-center gap-3 text-[10px] text-text-3">
|
||||
<span>{file.authorNm}</span>
|
||||
<span>{new Date(file.regDtm).toLocaleDateString('ko-KR')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px] text-text-3" style={{ fontFamily: 'var(--fM)' }}>
|
||||
<span className="text-[10px]" style={{ color: 'var(--t3)', fontFamily: 'var(--fM)' }}>
|
||||
⬇ {file.dwnldCnt}
|
||||
</span>
|
||||
<button onClick={async (e) => {
|
||||
@ -316,13 +350,17 @@ export function BoardView() {
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}}
|
||||
className="wing-btn wing-btn-primary text-[10px]">
|
||||
className="px-3 py-1 rounded text-[10px] font-semibold transition-all" style={{
|
||||
background: 'rgba(6,182,212,.1)', border: '1px solid rgba(6,182,212,.25)',
|
||||
color: '#22d3ee', cursor: 'pointer',
|
||||
}}>
|
||||
📥 다운로드
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -354,14 +392,16 @@ export function BoardView() {
|
||||
<label className="block text-[11px] font-semibold text-text-2 mb-1.5">카테고리</label>
|
||||
<div className="flex gap-1.5">
|
||||
{['방제매뉴얼', '대응매뉴얼', '교육자료', '법령·규정'].map(cat => {
|
||||
const cc = catColor(cat)
|
||||
const isActive = uploadForm.category === cat
|
||||
return (
|
||||
<button key={cat} onClick={() => setUploadForm(prev => ({ ...prev, category: cat }))}
|
||||
className={`flex-1 py-2 px-1 rounded-md text-[11px] font-semibold cursor-pointer border ${
|
||||
isActive
|
||||
? 'bg-primary-cyan/15 border-primary-cyan/30 text-primary-cyan'
|
||||
: 'bg-bg-3 border-border text-text-3'
|
||||
}`}>
|
||||
className="flex-1 py-2 px-1 rounded-md text-[11px] font-semibold cursor-pointer"
|
||||
style={{
|
||||
background: isActive ? cc.bg : 'var(--bg3)',
|
||||
border: isActive ? `1px solid ${cc.text}40` : '1px solid var(--bd)',
|
||||
color: isActive ? cc.text : 'var(--t3)',
|
||||
}}>
|
||||
{cat}
|
||||
</button>
|
||||
)
|
||||
@ -372,13 +412,13 @@ export function BoardView() {
|
||||
<label className="block text-[11px] font-semibold text-text-2 mb-1.5">매뉴얼 제목</label>
|
||||
<input type="text" placeholder="매뉴얼 제목을 입력하세요" value={uploadForm.title}
|
||||
onChange={e => setUploadForm(prev => ({ ...prev, title: e.target.value }))}
|
||||
className="wing-input w-full" style={{ boxSizing: 'border-box' }} />
|
||||
className="w-full px-3 py-2.5 rounded-md text-xs bg-bg-2 border border-border outline-none" style={{ boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 mb-1.5">버전</label>
|
||||
<input type="text" placeholder="예: v1.0" value={uploadForm.version}
|
||||
onChange={e => setUploadForm(prev => ({ ...prev, version: e.target.value }))}
|
||||
className="wing-input w-full" style={{ boxSizing: 'border-box' }} />
|
||||
className="w-full px-3 py-2.5 rounded-md text-xs bg-bg-2 border border-border outline-none" style={{ boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 mb-1.5">파일 첨부</label>
|
||||
@ -418,7 +458,7 @@ export function BoardView() {
|
||||
</div>
|
||||
<div className="px-5 py-3 border-t border-border flex justify-end gap-2">
|
||||
<button onClick={() => { setShowUploadModal(false); setEditingManualId(null) }}
|
||||
className="wing-btn wing-btn-secondary">
|
||||
className="px-5 py-2 rounded-md text-xs font-semibold bg-bg-3 border border-border text-text-3 cursor-pointer">
|
||||
취소
|
||||
</button>
|
||||
<button onClick={async () => {
|
||||
@ -451,7 +491,7 @@ export function BoardView() {
|
||||
alert((err as { message?: string })?.message || '저장에 실패했습니다.')
|
||||
}
|
||||
}}
|
||||
className="wing-btn wing-btn-primary px-6 py-2">
|
||||
className="px-6 py-2 rounded-md text-xs font-semibold cursor-pointer" style={{ background: 'rgba(6,182,212,.2)', border: '1px solid rgba(6,182,212,.35)', color: '#22d3ee' }}>
|
||||
{editingManualId ? '✏️ 수정' : '📤 업로드'}
|
||||
</button>
|
||||
</div>
|
||||
@ -507,7 +547,7 @@ export function BoardView() {
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
<div className="flex flex-col h-full bg-bg-0">
|
||||
{/* 헤더 */}
|
||||
<div className="wing-header-bar">
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1">
|
||||
<div className="text-sm text-text-3">
|
||||
총 <span className="text-text-1 font-semibold">{totalCount}</span>건
|
||||
</div>
|
||||
@ -518,12 +558,12 @@ export function BoardView() {
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
className="wing-input-search"
|
||||
className="px-4 py-2 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none w-64"
|
||||
/>
|
||||
{hasPermission(getWriteResource(), 'CREATE') && (
|
||||
<button
|
||||
onClick={handleWriteClick}
|
||||
className="wing-btn wing-btn-primary"
|
||||
className="px-6 py-2 text-sm font-semibold rounded bg-primary-cyan text-bg-0 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
✏️ 글쓰기
|
||||
</button>
|
||||
@ -539,40 +579,42 @@ export function BoardView() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="wing-table">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-border">
|
||||
<th className="wing-table-head text-center w-16">번호</th>
|
||||
<th className="wing-table-head text-center w-24">분류</th>
|
||||
<th className="wing-table-head">제목</th>
|
||||
<th className="wing-table-head text-center w-24">작성자</th>
|
||||
<th className="wing-table-head text-center w-28">작성일</th>
|
||||
<th className="wing-table-head text-center w-16">조회</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-16">번호</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-24">분류</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2">제목</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-24">작성자</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-28">작성일</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-16">조회</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{posts.map((post) => (
|
||||
<tr
|
||||
key={post.sn}
|
||||
className="wing-table-row"
|
||||
className="border-b border-border hover:bg-bg-2 transition-colors"
|
||||
>
|
||||
<td className="wing-table-cell text-text-1 text-center">{post.sn}</td>
|
||||
<td className="wing-table-cell text-center">
|
||||
<Badge>{CATEGORY_LABELS[post.categoryCd] || post.categoryCd}</Badge>
|
||||
<td className="px-4 py-4 text-sm text-text-1 text-center">{post.sn}</td>
|
||||
<td className="px-4 py-4 text-center">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold ${CATEGORY_COLORS[post.categoryCd] || 'bg-blue-500/20 text-blue-400'}`}>
|
||||
{CATEGORY_LABELS[post.categoryCd] || post.categoryCd}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
className="wing-table-cell cursor-pointer"
|
||||
className="px-4 py-4 cursor-pointer"
|
||||
onClick={() => handlePostClick(post.sn)}
|
||||
>
|
||||
<span className={`text-sm ${post.pinnedYn === 'Y' ? 'font-semibold text-text-1' : 'text-text-1'} hover:text-primary-cyan transition-colors`}>
|
||||
{post.pinnedYn === 'Y' && '📌 '}{post.title}
|
||||
</span>
|
||||
</td>
|
||||
<td className="wing-table-cell text-center">{post.authorName}</td>
|
||||
<td className="wing-table-cell text-text-3 text-center">
|
||||
<td className="px-4 py-4 text-sm text-text-2 text-center">{post.authorName}</td>
|
||||
<td className="px-4 py-4 text-sm text-text-3 text-center">
|
||||
{new Date(post.regDtm).toLocaleDateString('ko-KR')}
|
||||
</td>
|
||||
<td className="wing-table-cell text-text-3 text-center">{post.viewCnt}</td>
|
||||
<td className="px-4 py-4 text-sm text-text-3 text-center">{post.viewCnt}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@ -594,10 +636,10 @@ export function BoardView() {
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPage(p)}
|
||||
className={`wing-btn ${
|
||||
className={`px-3 py-1.5 text-sm rounded transition-colors ${
|
||||
p === page
|
||||
? 'wing-btn-primary font-semibold'
|
||||
: 'wing-btn-secondary'
|
||||
? 'bg-primary-cyan/20 text-primary-cyan font-semibold'
|
||||
: 'bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
|
||||
@ -121,7 +121,7 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-bg-0">
|
||||
{/* 헤더 */}
|
||||
<div className="wing-header-bar">
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1">
|
||||
<h2 className="text-lg font-semibold text-text-1">
|
||||
{isEditMode ? '게시글 수정' : '게시글 작성'}
|
||||
</h2>
|
||||
@ -140,7 +140,7 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
|
||||
value={categoryCd}
|
||||
onChange={(e) => setCategoryCd(e.target.value)}
|
||||
disabled={isEditMode}
|
||||
className="wing-select w-full disabled:opacity-50"
|
||||
className="w-full px-4 py-2.5 text-sm bg-bg-2 border border-border rounded text-text-1 focus:border-primary-cyan focus:outline-none disabled:opacity-50"
|
||||
>
|
||||
{CATEGORY_OPTIONS.map(opt => (
|
||||
<option key={opt.code} value={opt.code}>{opt.label}</option>
|
||||
@ -159,7 +159,7 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={200}
|
||||
placeholder="제목을 입력하세요"
|
||||
className="wing-input w-full"
|
||||
className="w-full px-4 py-2.5 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -174,7 +174,7 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
|
||||
maxLength={10000}
|
||||
placeholder="내용을 입력하세요"
|
||||
rows={15}
|
||||
className="wing-textarea w-full"
|
||||
className="w-full px-4 py-3 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -192,7 +192,7 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="wing-btn wing-btn-secondary cursor-pointer"
|
||||
className="px-4 py-2 text-sm font-semibold rounded bg-bg-2 text-text-1 border border-border hover:bg-bg-3 cursor-pointer transition-colors"
|
||||
>
|
||||
파일 선택
|
||||
</label>
|
||||
@ -207,14 +207,14 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="wing-btn wing-btn-secondary"
|
||||
className="px-6 py-2.5 text-sm font-semibold rounded bg-bg-2 text-text-1 border border-border hover:bg-bg-3 transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="wing-btn wing-btn-primary disabled:opacity-50"
|
||||
className="px-6 py-2.5 text-sm font-semibold rounded bg-primary-cyan text-bg-0 hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? '저장 중...' : isEditMode ? '수정하기' : '등록하기'}
|
||||
</button>
|
||||
|
||||
@ -229,7 +229,7 @@ export function HNSLeftPanel({
|
||||
|
||||
{/* 사고명 직접 입력 */}
|
||||
<input
|
||||
className="wing-input w-full"
|
||||
className="prd-i w-full"
|
||||
value={accidentName}
|
||||
onChange={(e) => setAccidentName(e.target.value)}
|
||||
placeholder="사고명 직접 입력"
|
||||
@ -237,7 +237,7 @@ export function HNSLeftPanel({
|
||||
|
||||
{/* 또는 사고 리스트에서 선택 */}
|
||||
<ComboBox
|
||||
className="wing-input"
|
||||
className="prd-i"
|
||||
value={selectedIncidentSn}
|
||||
onChange={handleSelectIncident}
|
||||
placeholder="또는 사고 리스트에서 선택"
|
||||
@ -252,13 +252,13 @@ export function HNSLeftPanel({
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">사고 발생 일시</label>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<input
|
||||
className="wing-input"
|
||||
className="prd-i"
|
||||
type="date"
|
||||
value={accidentDate}
|
||||
onChange={(e) => setAccidentDate(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="wing-input"
|
||||
className="prd-i"
|
||||
type="time"
|
||||
value={accidentTime}
|
||||
onChange={(e) => setAccidentTime(e.target.value)}
|
||||
@ -269,7 +269,7 @@ export function HNSLeftPanel({
|
||||
{/* 좌표 + 지도 버튼 */}
|
||||
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 1fr auto' }}>
|
||||
<input
|
||||
className="wing-input flex-1 font-mono"
|
||||
className="prd-i flex-1 font-mono"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={incidentCoord?.lat.toFixed(4) ?? ''}
|
||||
@ -280,7 +280,7 @@ export function HNSLeftPanel({
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className="wing-input flex-1 font-mono"
|
||||
className="prd-i flex-1 font-mono"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={incidentCoord?.lon.toFixed(4) ?? ''}
|
||||
@ -304,7 +304,7 @@ export function HNSLeftPanel({
|
||||
{/* 유출형태 + 물질명 */}
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<ComboBox
|
||||
className="wing-input"
|
||||
className="prd-i"
|
||||
value={releaseType}
|
||||
onChange={(v) => setReleaseType(v as ReleaseType)}
|
||||
options={[
|
||||
@ -314,7 +314,7 @@ export function HNSLeftPanel({
|
||||
]}
|
||||
/>
|
||||
<ComboBox
|
||||
className="wing-input"
|
||||
className="prd-i"
|
||||
value={substance}
|
||||
onChange={handleSubstanceChange}
|
||||
options={[
|
||||
@ -335,14 +335,14 @@ export function HNSLeftPanel({
|
||||
{/* 유출량 + 단위 + 예측시간 */}
|
||||
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 65px 1fr' }}>
|
||||
<input
|
||||
className="wing-input font-mono"
|
||||
className="prd-i font-mono"
|
||||
type="number"
|
||||
value={releaseType === '순간 유출' ? totalRelease : emissionRate}
|
||||
onChange={(e) => releaseType === '순간 유출' ? setTotalRelease(e.target.value) : setEmissionRate(e.target.value)}
|
||||
placeholder={releaseType === '순간 유출' ? 'g' : 'g/s'}
|
||||
/>
|
||||
<ComboBox
|
||||
className="wing-input"
|
||||
className="prd-i"
|
||||
value={releaseType === '순간 유출' ? 'g' : 'g/s'}
|
||||
onChange={() => {}}
|
||||
options={
|
||||
@ -352,7 +352,7 @@ export function HNSLeftPanel({
|
||||
}
|
||||
/>
|
||||
<ComboBox
|
||||
className="wing-input"
|
||||
className="prd-i"
|
||||
value={predictionTime}
|
||||
onChange={setPredictionTime}
|
||||
options={[
|
||||
@ -371,7 +371,7 @@ export function HNSLeftPanel({
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">예측 알고리즘</label>
|
||||
<ComboBox
|
||||
className="wing-input"
|
||||
className="prd-i"
|
||||
value={algorithm}
|
||||
onChange={setAlgorithm}
|
||||
options={[
|
||||
@ -385,7 +385,7 @@ export function HNSLeftPanel({
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">확산 등급 기준</label>
|
||||
<ComboBox
|
||||
className="wing-input"
|
||||
className="prd-i"
|
||||
value={criteriaModel}
|
||||
onChange={setCriteriaModel}
|
||||
options={[
|
||||
@ -419,7 +419,7 @@ export function HNSLeftPanel({
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">배출률 (g/s)</label>
|
||||
<input
|
||||
className="wing-input w-full font-mono"
|
||||
className="prd-i w-full font-mono"
|
||||
type="number"
|
||||
value={emissionRate}
|
||||
onChange={(e) => setEmissionRate(e.target.value)}
|
||||
@ -430,7 +430,7 @@ export function HNSLeftPanel({
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">지속시간 (s)</label>
|
||||
<input
|
||||
className="wing-input w-full font-mono"
|
||||
className="prd-i w-full font-mono"
|
||||
type="number"
|
||||
value={releaseDuration}
|
||||
onChange={(e) => setReleaseDuration(e.target.value)}
|
||||
@ -442,7 +442,7 @@ export function HNSLeftPanel({
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">누출 높이 (m)</label>
|
||||
<input
|
||||
className="wing-input w-full font-mono"
|
||||
className="prd-i w-full font-mono"
|
||||
type="number"
|
||||
value={releaseHeight}
|
||||
onChange={(e) => setReleaseHeight(e.target.value)}
|
||||
@ -459,7 +459,7 @@ export function HNSLeftPanel({
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">총 누출량 (g)</label>
|
||||
<input
|
||||
className="wing-input w-full font-mono"
|
||||
className="prd-i w-full font-mono"
|
||||
type="number"
|
||||
value={totalRelease}
|
||||
onChange={(e) => setTotalRelease(e.target.value)}
|
||||
@ -470,7 +470,7 @@ export function HNSLeftPanel({
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">누출 높이 (m)</label>
|
||||
<input
|
||||
className="wing-input w-full font-mono"
|
||||
className="prd-i w-full font-mono"
|
||||
type="number"
|
||||
value={releaseHeight}
|
||||
onChange={(e) => setReleaseHeight(e.target.value)}
|
||||
@ -488,7 +488,7 @@ export function HNSLeftPanel({
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">배출률 (g/s)</label>
|
||||
<input
|
||||
className="wing-input w-full font-mono"
|
||||
className="prd-i w-full font-mono"
|
||||
type="number"
|
||||
value={emissionRate}
|
||||
onChange={(e) => setEmissionRate(e.target.value)}
|
||||
@ -499,7 +499,7 @@ export function HNSLeftPanel({
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">풀 반경 (m)</label>
|
||||
<input
|
||||
className="wing-input w-full font-mono"
|
||||
className="prd-i w-full font-mono"
|
||||
type="number"
|
||||
value={poolRadius}
|
||||
onChange={(e) => setPoolRadius(e.target.value)}
|
||||
@ -511,7 +511,7 @@ export function HNSLeftPanel({
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">누출 높이 (m)</label>
|
||||
<input
|
||||
className="wing-input w-full font-mono"
|
||||
className="prd-i w-full font-mono"
|
||||
type="number"
|
||||
value={releaseHeight}
|
||||
onChange={(e) => setReleaseHeight(e.target.value)}
|
||||
@ -588,7 +588,7 @@ export function HNSLeftPanel({
|
||||
{/* 실행 버튼 */}
|
||||
<div className="flex flex-col gap-1 mt-2">
|
||||
<button
|
||||
className="wing-btn wing-btn-primary"
|
||||
className="prd-btn pri"
|
||||
style={{ padding: '7px', fontSize: '11px' }}
|
||||
onClick={onRunPrediction}
|
||||
disabled={isRunningPrediction}
|
||||
@ -596,7 +596,7 @@ export function HNSLeftPanel({
|
||||
{isRunningPrediction ? '⏳ 실행 중...' : '🧪 대기확산 예측 실행'}
|
||||
</button>
|
||||
<button
|
||||
className="wing-btn wing-btn-secondary"
|
||||
className="prd-btn sec"
|
||||
style={{ padding: '7px', fontSize: '11px' }}
|
||||
onClick={handleReset}
|
||||
>
|
||||
|
||||
@ -109,7 +109,7 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNS
|
||||
{/* HNS 물질 */}
|
||||
<FG label="HNS 물질">
|
||||
<ComboBox
|
||||
className="wing-input"
|
||||
className="prd-i"
|
||||
value={substance}
|
||||
onChange={setSubstance}
|
||||
options={[
|
||||
@ -127,7 +127,7 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNS
|
||||
<div className="grid grid-cols-2 gap-[10px]">
|
||||
<FG label="유출 유형">
|
||||
<ComboBox
|
||||
className="wing-input"
|
||||
className="prd-i"
|
||||
value={releaseType}
|
||||
onChange={(v) => setReleaseType(v as RecalcParams['releaseType'])}
|
||||
options={[
|
||||
@ -139,7 +139,7 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNS
|
||||
</FG>
|
||||
<FG label={`${amountLabel} (${amountUnit})`}>
|
||||
<input
|
||||
className="wing-input font-mono"
|
||||
className="prd-i font-mono"
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
@ -152,7 +152,7 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNS
|
||||
<div className="grid grid-cols-2 gap-[10px]">
|
||||
<FG label="예측 알고리즘">
|
||||
<ComboBox
|
||||
className="wing-input"
|
||||
className="prd-i"
|
||||
value={algorithm}
|
||||
onChange={setAlgorithm}
|
||||
options={[
|
||||
@ -165,7 +165,7 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNS
|
||||
</FG>
|
||||
<FG label="예측 시간">
|
||||
<ComboBox
|
||||
className="wing-input"
|
||||
className="prd-i"
|
||||
value={predictionTime}
|
||||
onChange={setPredictionTime}
|
||||
options={[
|
||||
|
||||
@ -148,7 +148,7 @@ export function HNSScenarioView() {
|
||||
<select
|
||||
value={selectedIncident}
|
||||
onChange={(e) => setSelectedIncident(Number(e.target.value))}
|
||||
className="wing-select w-[280px] text-[11px]"
|
||||
className="prd-i w-[280px] text-[11px]"
|
||||
>
|
||||
{incidents.length === 0
|
||||
? <option value={0}>분석 데이터 없음</option>
|
||||
@ -708,16 +708,16 @@ function NewScenarioModal({ isOpen, onClose, onSubmit }: {
|
||||
{/* 기본 정보 */}
|
||||
<ModalSection title="기본 정보">
|
||||
<ModalField label="시나리오명">
|
||||
<input className="wing-input" value={name} onChange={e => setName(e.target.value)} placeholder="예: 풍향 변화 시나리오" />
|
||||
<input className="prd-i" value={name} onChange={e => setName(e.target.value)} placeholder="예: 풍향 변화 시나리오" />
|
||||
</ModalField>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<ModalField label="시간 단계">
|
||||
<select className="wing-select" value={timeStep} onChange={e => setTimeStep(e.target.value)}>
|
||||
<select className="prd-i" value={timeStep} onChange={e => setTimeStep(e.target.value)}>
|
||||
{['T+0h', 'T+1h', 'T+3h', 'T+6h', 'T+12h', 'T+24h'].map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</ModalField>
|
||||
<ModalField label="기준 시각">
|
||||
<input className="wing-input" type="text" defaultValue="2024-11-03 08:00" />
|
||||
<input className="prd-i" type="text" defaultValue="2024-11-03 08:00" />
|
||||
</ModalField>
|
||||
</div>
|
||||
</ModalSection>
|
||||
@ -725,7 +725,7 @@ function NewScenarioModal({ isOpen, onClose, onSubmit }: {
|
||||
{/* 물질·유출 조건 */}
|
||||
<ModalSection title="물질 · 유출 조건">
|
||||
<ModalField label="HNS 물질">
|
||||
<select className="wing-select" value={material} onChange={e => setMaterial(e.target.value)}>
|
||||
<select className="prd-i" value={material} onChange={e => setMaterial(e.target.value)}>
|
||||
{MATERIALS.map(m => <option key={m.key} value={m.key}>{m.name} ({m.key})</option>)}
|
||||
</select>
|
||||
</ModalField>
|
||||
@ -750,7 +750,7 @@ function NewScenarioModal({ isOpen, onClose, onSubmit }: {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<ModalField label="유출 유형">
|
||||
<select className="wing-select" value={releaseType} onChange={e => setReleaseType(e.target.value)}>
|
||||
<select className="prd-i" value={releaseType} onChange={e => setReleaseType(e.target.value)}>
|
||||
<option value="instant">순간 유출</option>
|
||||
<option value="continuous">연속 유출</option>
|
||||
<option value="semi">반연속</option>
|
||||
@ -758,8 +758,8 @@ function NewScenarioModal({ isOpen, onClose, onSubmit }: {
|
||||
</ModalField>
|
||||
<ModalField label="유출량">
|
||||
<div className="flex gap-1">
|
||||
<input className="wing-input flex-1" type="number" value={amount} onChange={e => setAmount(e.target.value)} />
|
||||
<select className="wing-select w-[60px]" value={unit} onChange={e => setUnit(e.target.value)}>
|
||||
<input className="prd-i flex-1" type="number" value={amount} onChange={e => setAmount(e.target.value)} />
|
||||
<select className="prd-i w-[60px]" value={unit} onChange={e => setUnit(e.target.value)}>
|
||||
{['t', 'kg', 'm³', 'L'].map(u => <option key={u} value={u}>{u}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
@ -771,31 +771,31 @@ function NewScenarioModal({ isOpen, onClose, onSubmit }: {
|
||||
<ModalSection title="기상 조건">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<ModalField label="풍향">
|
||||
<select className="wing-select" value={windDir} onChange={e => setWindDir(e.target.value)}>
|
||||
<select className="prd-i" value={windDir} onChange={e => setWindDir(e.target.value)}>
|
||||
{['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'].map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</ModalField>
|
||||
<ModalField label="풍속 (m/s)">
|
||||
<input className="wing-input" type="number" value={windSpeed} onChange={e => setWindSpeed(e.target.value)} step={0.1} />
|
||||
<input className="prd-i" type="number" value={windSpeed} onChange={e => setWindSpeed(e.target.value)} step={0.1} />
|
||||
</ModalField>
|
||||
<ModalField label="기온 (°C)">
|
||||
<input className="wing-input" type="number" value={temp} onChange={e => setTemp(e.target.value)} step={0.1} />
|
||||
<input className="prd-i" type="number" value={temp} onChange={e => setTemp(e.target.value)} step={0.1} />
|
||||
</ModalField>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<ModalField label="대기안정도 (Pasquill)">
|
||||
<select className="wing-select" value={stability} onChange={e => setStability(e.target.value)}>
|
||||
<select className="prd-i" value={stability} onChange={e => setStability(e.target.value)}>
|
||||
{['A (매우 불안정)', 'B (불안정)', 'C (약간 불안정)', 'D (중립)', 'E (약간 안정)', 'F (안정)'].map(s => <option key={s[0]} value={s[0]}>{s}</option>)}
|
||||
</select>
|
||||
</ModalField>
|
||||
<ModalField label="확산 모델">
|
||||
<select className="wing-select" value={model} onChange={e => setModel(e.target.value)}>
|
||||
<select className="prd-i" value={model} onChange={e => setModel(e.target.value)}>
|
||||
{['ALOHA', 'PHAST', 'CALPUFF', 'Lagrangian'].map(m => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
</ModalField>
|
||||
</div>
|
||||
<ModalField label="예측 시간">
|
||||
<select className="wing-select" value={predTime} onChange={e => setPredTime(e.target.value)}>
|
||||
<select className="prd-i" value={predTime} onChange={e => setPredTime(e.target.value)}>
|
||||
{['1', '3', '6', '12', '24', '48'].map(h => <option key={h} value={h}>{h}시간</option>)}
|
||||
</select>
|
||||
</ModalField>
|
||||
|
||||
@ -53,7 +53,7 @@ export function LeftPanel({
|
||||
predictionInput: true,
|
||||
incident: false,
|
||||
impactResources: false,
|
||||
infoLayer: false,
|
||||
infoLayer: true,
|
||||
oilBoom: false,
|
||||
})
|
||||
|
||||
@ -112,73 +112,45 @@ export function LeftPanel({
|
||||
</div>
|
||||
|
||||
{expandedSections.incident && (
|
||||
selectedAnalysis ? (
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
{/* Status Badge */}
|
||||
{(() => {
|
||||
const statusMap: Record<string, { label: string; style: string; dot: string }> = {
|
||||
ACTIVE: {
|
||||
label: '진행중',
|
||||
style: 'bg-[rgba(239,68,68,0.15)] text-status-red border border-[rgba(239,68,68,0.3)]',
|
||||
dot: 'bg-status-red animate-pulse',
|
||||
},
|
||||
INVESTIGATING: {
|
||||
label: '조사중',
|
||||
style: 'bg-[rgba(249,115,22,0.15)] text-status-orange border border-[rgba(249,115,22,0.3)]',
|
||||
dot: 'bg-status-orange animate-pulse',
|
||||
},
|
||||
CLOSED: {
|
||||
label: '종료',
|
||||
style: 'bg-[rgba(100,116,139,0.15)] text-text-3 border border-[rgba(100,116,139,0.3)]',
|
||||
dot: 'bg-text-3',
|
||||
},
|
||||
}
|
||||
const s = statusMap[selectedAnalysis.acdntSttsCd] ?? statusMap['ACTIVE']
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[9px] font-semibold ${s.style}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${s.dot}`} />
|
||||
{s.label}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
{/* Status Badge */}
|
||||
<div className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[9px] font-semibold bg-[rgba(239,68,68,0.15)] text-status-red border border-[rgba(239,68,68,0.3)]">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-status-red animate-pulse" />
|
||||
진행중
|
||||
</div>
|
||||
|
||||
{/* Info Grid */}
|
||||
<div className="grid gap-1">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고코드</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis.acdntSn}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고명</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis.acdntNm || '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고일시</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis.occurredAt ? selectedAnalysis.occurredAt.slice(0, 16) : '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">유종</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis.oilType || '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">유출량</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis.volume != null ? `${selectedAnalysis.volume.toFixed(2)} kl` : '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">담당자</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis.analyst || '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">위치</span>
|
||||
<span className="text-[11px] text-status-orange font-semibold font-korean">{selectedAnalysis.location || '—'}</span>
|
||||
</div>
|
||||
{/* Info Grid */}
|
||||
<div className="grid gap-1">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고코드</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis ? `INC-2025-${String(selectedAnalysis.id).padStart(4, '0')}` : 'INC-2025-0042'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고명</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis?.name || '씨프린스호'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">사고일시</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis?.occurredAt || '2025-02-10 06:30'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">유종</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis?.oilType || 'BUNKER_C'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">유출량</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-mono">{selectedAnalysis ? `${selectedAnalysis.volume.toFixed(2)} kl` : '350.00 kl'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">담당자</span>
|
||||
<span className="text-[11px] text-text-1 font-medium font-korean">{selectedAnalysis?.analyst || '남해청, 방재과'}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] text-text-3 min-w-[52px] font-korean">위치</span>
|
||||
<span className="text-[11px] text-status-orange font-semibold font-korean">{selectedAnalysis?.location || '여수 돌산 남방 5NM'}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 pb-4">
|
||||
<p className="text-[11px] text-text-3 font-korean text-center py-2">선택된 사고정보가 없습니다.</p>
|
||||
</div>
|
||||
)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@ -16,9 +16,8 @@ import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAna
|
||||
import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, SimulationRunResponse, SimulationSummary, WindPoint } from '../services/predictionApi'
|
||||
import { useSimulationStatus } from '../hooks/useSimulationStatus'
|
||||
import SimulationLoadingOverlay from './SimulationLoadingOverlay'
|
||||
import SimulationErrorModal from './SimulationErrorModal'
|
||||
import { api } from '@common/services/api'
|
||||
import { generateAIBoomLines, haversineDistance, pointInPolygon, polygonAreaKm2, circleAreaKm2 } from '@common/utils/geo'
|
||||
import { generateAIBoomLines } from '@common/utils/geo'
|
||||
import { consumePendingImageAnalysis } from '@common/utils/imageAnalysisSignal'
|
||||
|
||||
export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift'
|
||||
@ -36,13 +35,6 @@ export interface SensitiveResource {
|
||||
arrivalTimeH: number
|
||||
}
|
||||
|
||||
export interface DisplayControls {
|
||||
showCurrent: boolean; // 유향/유속
|
||||
showWind: boolean; // 풍향/풍속
|
||||
showBeached: boolean; // 해안부착
|
||||
showTimeLabel: boolean; // 시간 표시
|
||||
}
|
||||
|
||||
const DEMO_SENSITIVE_RESOURCES: SensitiveResource[] = [
|
||||
{ id: 'bc-1', name: '종포 해수욕장', type: 'beach', lat: 34.728, lon: 127.679, radiusM: 350, arrivalTimeH: 1 },
|
||||
{ id: 'aq-1', name: '국동 전복 양식장', type: 'aquaculture', lat: 34.718, lon: 127.672, radiusM: 500, arrivalTimeH: 3 },
|
||||
@ -122,8 +114,7 @@ export function OilSpillView() {
|
||||
const [windData, setWindData] = useState<WindPoint[][]>([])
|
||||
const [hydrData, setHydrData] = useState<(HydrDataStep | null)[]>([])
|
||||
const [isRunningSimulation, setIsRunningSimulation] = useState(false)
|
||||
const [simulationError, setSimulationError] = useState<string | null>(null)
|
||||
const [selectedModels, setSelectedModels] = useState<Set<PredictionModel>>(new Set(['OpenDrift']))
|
||||
const [selectedModels, setSelectedModels] = useState<Set<PredictionModel>>(new Set(['KOSPS']))
|
||||
const [predictionTime, setPredictionTime] = useState(48)
|
||||
const [accidentTime, setAccidentTime] = useState<string>('')
|
||||
const [spillType, setSpillType] = useState('연속')
|
||||
@ -151,14 +142,6 @@ export function OilSpillView() {
|
||||
const [layerOpacity, setLayerOpacity] = useState(50)
|
||||
const [layerBrightness, setLayerBrightness] = useState(50)
|
||||
|
||||
// 표시 정보 제어
|
||||
const [displayControls, setDisplayControls] = useState<DisplayControls>({
|
||||
showCurrent: true,
|
||||
showWind: true,
|
||||
showBeached: false,
|
||||
showTimeLabel: false,
|
||||
})
|
||||
|
||||
// 타임라인 플레이어 상태
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentStep, setCurrentStep] = useState(0) // 현재 시간값 (시간 단위)
|
||||
@ -192,16 +175,6 @@ export function OilSpillView() {
|
||||
const [simulationSummary, setSimulationSummary] = useState<SimulationSummary | null>(null)
|
||||
const { data: simStatus } = useSimulationStatus(currentExecSn)
|
||||
|
||||
// 오염분석 상태
|
||||
const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon')
|
||||
const [drawAnalysisMode, setDrawAnalysisMode] = useState<'polygon' | null>(null)
|
||||
const [analysisPolygonPoints, setAnalysisPolygonPoints] = useState<{ lat: number; lon: number }[]>([])
|
||||
const [circleRadiusNm, setCircleRadiusNm] = useState<number>(5)
|
||||
const [analysisResult, setAnalysisResult] = useState<{ area: number; particleCount: number; particlePercent: number; sensitiveCount: number } | null>(null)
|
||||
|
||||
// 원 분석용 derived 값 (state 아님)
|
||||
const analysisCircleCenter = analysisTab === 'circle' && incidentCoord ? incidentCoord : null
|
||||
const analysisCircleRadiusM = circleRadiusNm * 1852
|
||||
|
||||
const handleToggleLayer = (layerId: string, enabled: boolean) => {
|
||||
setEnabledLayers(prev => {
|
||||
@ -400,7 +373,6 @@ export function OilSpillView() {
|
||||
if (simStatus.status === 'ERROR') {
|
||||
setIsRunningSimulation(false);
|
||||
setCurrentExecSn(null);
|
||||
setSimulationError(simStatus.error ?? '시뮬레이션 처리 중 오류가 발생했습니다.');
|
||||
}
|
||||
}, [simStatus, incidentCoord, algorithmSettings]);
|
||||
|
||||
@ -493,7 +465,8 @@ export function OilSpillView() {
|
||||
setCenterPoints(cp ?? [])
|
||||
setWindData(wd ?? [])
|
||||
setHydrData(hd ?? [])
|
||||
if (coord) setBoomLines(generateAIBoomLines(trajectory, coord, algorithmSettings))
|
||||
const booms = generateAIBoomLines(trajectory, coord, algorithmSettings)
|
||||
setBoomLines(booms)
|
||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
||||
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
||||
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
|
||||
@ -509,9 +482,10 @@ export function OilSpillView() {
|
||||
}
|
||||
|
||||
// 데모 궤적 생성 (fallback)
|
||||
const demoTrajectory = generateDemoTrajectory(coord ?? { lat: 37.39, lon: 126.64 }, demoModels, parseInt(analysis.duration) || 48)
|
||||
const demoTrajectory = generateDemoTrajectory(coord, demoModels, parseInt(analysis.duration) || 48)
|
||||
setOilTrajectory(demoTrajectory)
|
||||
if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings))
|
||||
const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings)
|
||||
setBoomLines(demoBooms)
|
||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
||||
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
||||
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
|
||||
@ -524,65 +498,12 @@ export function OilSpillView() {
|
||||
const handleMapClick = (lon: number, lat: number) => {
|
||||
if (isDrawingBoom) {
|
||||
setDrawingPoints(prev => [...prev, { lat, lon }])
|
||||
} else if (drawAnalysisMode === 'polygon') {
|
||||
setAnalysisPolygonPoints(prev => [...prev, { lat, lon }])
|
||||
} else {
|
||||
setIncidentCoord({ lon, lat })
|
||||
setIsSelectingLocation(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartPolygonDraw = () => {
|
||||
setDrawAnalysisMode('polygon')
|
||||
setAnalysisPolygonPoints([])
|
||||
setAnalysisResult(null)
|
||||
}
|
||||
|
||||
const handleRunPolygonAnalysis = () => {
|
||||
if (analysisPolygonPoints.length < 3) return
|
||||
const currentParticles = oilTrajectory.filter(p => p.time === currentStep)
|
||||
const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1
|
||||
const inside = currentParticles.filter(p => pointInPolygon({ lat: p.lat, lon: p.lon }, analysisPolygonPoints)).length
|
||||
const sensitiveCount = sensitiveResources.filter(r => pointInPolygon({ lat: r.lat, lon: r.lon }, analysisPolygonPoints)).length
|
||||
setAnalysisResult({
|
||||
area: polygonAreaKm2(analysisPolygonPoints),
|
||||
particleCount: inside,
|
||||
particlePercent: Math.round((inside / totalIds) * 100),
|
||||
sensitiveCount,
|
||||
})
|
||||
setDrawAnalysisMode(null)
|
||||
}
|
||||
|
||||
const handleRunCircleAnalysis = () => {
|
||||
if (!incidentCoord) return
|
||||
const radiusM = circleRadiusNm * 1852
|
||||
const currentParticles = oilTrajectory.filter(p => p.time === currentStep)
|
||||
const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1
|
||||
const inside = currentParticles.filter(p =>
|
||||
haversineDistance({ lat: incidentCoord.lat, lon: incidentCoord.lon }, { lat: p.lat, lon: p.lon }) <= radiusM
|
||||
).length
|
||||
const sensitiveCount = sensitiveResources.filter(r =>
|
||||
haversineDistance({ lat: incidentCoord.lat, lon: incidentCoord.lon }, { lat: r.lat, lon: r.lon }) <= radiusM
|
||||
).length
|
||||
setAnalysisResult({
|
||||
area: circleAreaKm2(radiusM),
|
||||
particleCount: inside,
|
||||
particlePercent: Math.round((inside / totalIds) * 100),
|
||||
sensitiveCount,
|
||||
})
|
||||
}
|
||||
|
||||
const handleCancelAnalysis = () => {
|
||||
setDrawAnalysisMode(null)
|
||||
setAnalysisPolygonPoints([])
|
||||
}
|
||||
|
||||
const handleClearAnalysis = () => {
|
||||
setDrawAnalysisMode(null)
|
||||
setAnalysisPolygonPoints([])
|
||||
setAnalysisResult(null)
|
||||
}
|
||||
|
||||
const handleImageAnalysisResult = useCallback((result: ImageAnalyzeResult) => {
|
||||
setIncidentCoord({ lat: result.lat, lon: result.lon })
|
||||
setFlyToCoord({ lat: result.lat, lon: result.lon })
|
||||
@ -677,12 +598,9 @@ export function OilSpillView() {
|
||||
setIncidentName('');
|
||||
}
|
||||
// setIsRunningSimulation(false)는 폴링 결과 useEffect에서 처리
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setIsRunningSimulation(false);
|
||||
const msg =
|
||||
(err as { message?: string })?.message
|
||||
?? '시뮬레이션 실행 중 오류가 발생했습니다.';
|
||||
setSimulationError(msg);
|
||||
// 503 등 에러 시 상태 복원 (에러 메시지 표시는 향후 토스트로 처리)
|
||||
}
|
||||
}
|
||||
|
||||
@ -805,7 +723,7 @@ export function OilSpillView() {
|
||||
enabledLayers={enabledLayers}
|
||||
incidentCoord={incidentCoord ?? undefined}
|
||||
flyToIncident={flyToCoord}
|
||||
isSelectingLocation={isSelectingLocation || isDrawingBoom || drawAnalysisMode === 'polygon'}
|
||||
isSelectingLocation={isSelectingLocation || isDrawingBoom}
|
||||
onMapClick={handleMapClick}
|
||||
oilTrajectory={oilTrajectory}
|
||||
selectedModels={selectedModels}
|
||||
@ -821,10 +739,6 @@ export function OilSpillView() {
|
||||
flyToTarget={flyToTarget}
|
||||
fitBoundsTarget={fitBoundsTarget}
|
||||
onIncidentFlyEnd={handleFlyEnd}
|
||||
drawAnalysisMode={drawAnalysisMode}
|
||||
analysisPolygonPoints={analysisPolygonPoints}
|
||||
analysisCircleCenter={analysisCircleCenter}
|
||||
analysisCircleRadiusM={analysisCircleRadiusM}
|
||||
externalCurrentTime={oilTrajectory.length > 0 ? currentStep : undefined}
|
||||
backtrackReplay={isReplayActive && replayShips.length > 0 && incidentCoord ? {
|
||||
isActive: true,
|
||||
@ -834,11 +748,6 @@ export function OilSpillView() {
|
||||
totalFrames: TOTAL_REPLAY_FRAMES,
|
||||
incidentCoord,
|
||||
} : undefined}
|
||||
showCurrent={displayControls.showCurrent}
|
||||
showWind={displayControls.showWind}
|
||||
showBeached={displayControls.showBeached}
|
||||
showTimeLabel={displayControls.showTimeLabel}
|
||||
simulationStartTime={accidentTime || undefined}
|
||||
/>
|
||||
|
||||
{/* 타임라인 플레이어 (리플레이 비활성 시) */}
|
||||
@ -1023,30 +932,7 @@ export function OilSpillView() {
|
||||
</div>
|
||||
|
||||
{/* Right Panel */}
|
||||
{activeSubTab === 'analysis' && (
|
||||
<RightPanel
|
||||
onOpenBacktrack={handleOpenBacktrack}
|
||||
onOpenRecalc={() => setRecalcModalOpen(true)}
|
||||
onOpenReport={handleOpenReport}
|
||||
detail={analysisDetail}
|
||||
summary={simulationSummary}
|
||||
displayControls={displayControls}
|
||||
onDisplayControlsChange={setDisplayControls}
|
||||
analysisTab={analysisTab}
|
||||
onSwitchAnalysisTab={setAnalysisTab}
|
||||
drawAnalysisMode={drawAnalysisMode}
|
||||
analysisPolygonPoints={analysisPolygonPoints}
|
||||
circleRadiusNm={circleRadiusNm}
|
||||
onCircleRadiusChange={setCircleRadiusNm}
|
||||
analysisResult={analysisResult}
|
||||
incidentCoord={incidentCoord}
|
||||
onStartPolygonDraw={handleStartPolygonDraw}
|
||||
onRunPolygonAnalysis={handleRunPolygonAnalysis}
|
||||
onRunCircleAnalysis={handleRunCircleAnalysis}
|
||||
onCancelAnalysis={handleCancelAnalysis}
|
||||
onClearAnalysis={handleClearAnalysis}
|
||||
/>
|
||||
)}
|
||||
{activeSubTab === 'analysis' && <RightPanel onOpenBacktrack={handleOpenBacktrack} onOpenRecalc={() => setRecalcModalOpen(true)} onOpenReport={handleOpenReport} detail={analysisDetail} summary={simulationSummary} />}
|
||||
|
||||
{/* 확산 예측 실행 중 로딩 오버레이 */}
|
||||
{isRunningSimulation && (
|
||||
@ -1056,14 +942,6 @@ export function OilSpillView() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 확산 예측 에러 팝업 */}
|
||||
{simulationError && (
|
||||
<SimulationErrorModal
|
||||
message={simulationError}
|
||||
onClose={() => setSimulationError(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 재계산 모달 */}
|
||||
<RecalcModal
|
||||
isOpen={recalcModalOpen}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { decimalToDMS } from '@common/utils/coordinates'
|
||||
import { ComboBox } from '@common/components/ui/ComboBox'
|
||||
import { ALL_MODELS } from './OilSpillView'
|
||||
import type { PredictionModel } from './OilSpillView'
|
||||
import { analyzeImage } from '../services/predictionApi'
|
||||
import type { ImageAnalyzeResult } from '../services/predictionApi'
|
||||
@ -151,12 +152,12 @@ const PredictionInputSection = ({
|
||||
{inputMode === 'direct' && (
|
||||
<>
|
||||
<input
|
||||
className="wing-input"
|
||||
className="prd-i"
|
||||
placeholder="사고명 직접 입력"
|
||||
value={incidentName}
|
||||
onChange={(e) => onIncidentNameChange(e.target.value)}
|
||||
/>
|
||||
<input className="wing-input" placeholder="또는 사고 리스트에서 선택" readOnly />
|
||||
<input className="prd-i" placeholder="또는 사고 리스트에서 선택" readOnly />
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -212,7 +213,7 @@ const PredictionInputSection = ({
|
||||
|
||||
{/* 분석 실행 버튼 */}
|
||||
<button
|
||||
className="wing-btn wing-btn-primary"
|
||||
className="prd-btn pri"
|
||||
style={{ padding: '7px', fontSize: '11px' }}
|
||||
onClick={handleAnalyze}
|
||||
disabled={!uploadedFile || isAnalyzing}
|
||||
@ -263,7 +264,7 @@ const PredictionInputSection = ({
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<label className="text-[9px] text-text-3 font-korean">사고 발생 시각 (KST)</label>
|
||||
<input
|
||||
className="wing-input"
|
||||
className="prd-i"
|
||||
type="datetime-local"
|
||||
value={accidentTime}
|
||||
onChange={(e) => onAccidentTimeChange(e.target.value)}
|
||||
@ -275,7 +276,7 @@ const PredictionInputSection = ({
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 1fr auto' }}>
|
||||
<input
|
||||
className="wing-input"
|
||||
className="prd-i"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={incidentCoord?.lat ?? ''}
|
||||
@ -286,7 +287,7 @@ const PredictionInputSection = ({
|
||||
placeholder="위도°"
|
||||
/>
|
||||
<input
|
||||
className="wing-input"
|
||||
className="prd-i"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={incidentCoord?.lon ?? ''}
|
||||
@ -315,7 +316,7 @@ const PredictionInputSection = ({
|
||||
{/* Oil Type + Oil Kind */}
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<ComboBox
|
||||
className="wing-input"
|
||||
className="prd-i"
|
||||
value={spillType}
|
||||
onChange={onSpillTypeChange}
|
||||
options={[
|
||||
@ -325,7 +326,7 @@ const PredictionInputSection = ({
|
||||
]}
|
||||
/>
|
||||
<ComboBox
|
||||
className="wing-input"
|
||||
className="prd-i"
|
||||
value={oilType}
|
||||
onChange={onOilTypeChange}
|
||||
options={[
|
||||
@ -342,7 +343,7 @@ const PredictionInputSection = ({
|
||||
{/* Volume + Unit + Duration */}
|
||||
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 65px 1fr' }}>
|
||||
<input
|
||||
className="wing-input"
|
||||
className="prd-i"
|
||||
placeholder="유출량"
|
||||
type="number"
|
||||
min="1"
|
||||
@ -351,7 +352,7 @@ const PredictionInputSection = ({
|
||||
onChange={(e) => onSpillAmountChange(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
<ComboBox
|
||||
className="wing-input"
|
||||
className="prd-i"
|
||||
value={spillUnit}
|
||||
onChange={onSpillUnitChange}
|
||||
options={[
|
||||
@ -361,7 +362,7 @@ const PredictionInputSection = ({
|
||||
]}
|
||||
/>
|
||||
<ComboBox
|
||||
className="wing-input"
|
||||
className="prd-i"
|
||||
value={predictionTime}
|
||||
onChange={(v) => onPredictionTimeChange(parseInt(v))}
|
||||
options={[
|
||||
@ -378,13 +379,10 @@ const PredictionInputSection = ({
|
||||
<div className="h-px bg-border my-0.5" />
|
||||
|
||||
{/* Model Selection (다중 선택) */}
|
||||
{/* TODO: 현재 OpenDrift만 구동 가능. KOSPS·POSEIDON·앙상블은 엔진 연동 완료 후 활성화 예정 */}
|
||||
<div className="flex flex-wrap gap-[3px]">
|
||||
{/* 임시 비활성화 — OpenDrift만 구동 가능 (KOSPS 엔진 미연동)
|
||||
{ id: 'KOSPS' as PredictionModel, color: 'var(--cyan)' }, */}
|
||||
{/* 임시 비활성화 — OpenDrift만 구동 가능 (POSEIDON 엔진 미연동)
|
||||
{ id: 'POSEIDON' as PredictionModel, color: 'var(--red)' }, */}
|
||||
{([
|
||||
{ id: 'KOSPS' as PredictionModel, color: 'var(--cyan)' },
|
||||
{ id: 'POSEIDON' as PredictionModel, color: 'var(--red)' },
|
||||
{ id: 'OpenDrift' as PredictionModel, color: 'var(--blue)' },
|
||||
] as const).map(m => (
|
||||
<div
|
||||
@ -404,7 +402,6 @@ const PredictionInputSection = ({
|
||||
{m.id}
|
||||
</div>
|
||||
))}
|
||||
{/* 임시 비활성화 — OpenDrift만 구동 가능 (앙상블은 모든 모델 연동 후 활성화 예정)
|
||||
<div
|
||||
className={`prd-mc ${selectedModels.size === ALL_MODELS.length ? 'on' : ''} cursor-pointer`}
|
||||
onClick={() => {
|
||||
@ -418,19 +415,11 @@ const PredictionInputSection = ({
|
||||
<span className="prd-md" style={{ background: 'var(--purple)' }} />
|
||||
앙상블
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
|
||||
{/* 모델 미선택 경고 */}
|
||||
{selectedModels.size === 0 && (
|
||||
<p className="text-[10px] text-status-red font-korean">
|
||||
⚠ 예측 모델을 하나 이상 선택하세요.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Run Button */}
|
||||
<button
|
||||
className="wing-btn wing-btn-primary mt-0.5"
|
||||
className="prd-btn pri mt-0.5"
|
||||
style={{ padding: '7px', fontSize: '11px' }}
|
||||
onClick={onRunSimulation}
|
||||
disabled={isRunningSimulation}
|
||||
|
||||
@ -173,7 +173,7 @@ export function RecalcModal({
|
||||
{/* 유종 */}
|
||||
<FieldGroup label="유종">
|
||||
<select
|
||||
className="wing-select"
|
||||
className="prd-i"
|
||||
value={oilType}
|
||||
onChange={(e) => setOilType(e.target.value)}
|
||||
>
|
||||
@ -188,12 +188,12 @@ export function RecalcModal({
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
type="number"
|
||||
className="wing-input flex-1"
|
||||
className="prd-i flex-1"
|
||||
value={spillAmount}
|
||||
onChange={(e) => setSpillAmount(Number(e.target.value))}
|
||||
/>
|
||||
<select
|
||||
className="wing-select"
|
||||
className="prd-i"
|
||||
value={spillUnit}
|
||||
onChange={(e) => setSpillUnit(e.target.value as 'kl' | 'ton' | 'bbl')}
|
||||
style={{ width: '70px' }}
|
||||
@ -208,7 +208,7 @@ export function RecalcModal({
|
||||
{/* 유출 형태 */}
|
||||
<FieldGroup label="유출 형태">
|
||||
<select
|
||||
className="wing-select"
|
||||
className="prd-i"
|
||||
value={spillType}
|
||||
onChange={(e) => setSpillType(e.target.value)}
|
||||
>
|
||||
@ -221,7 +221,7 @@ export function RecalcModal({
|
||||
{/* 예측 시간 */}
|
||||
<FieldGroup label="예측 시간">
|
||||
<select
|
||||
className="wing-select"
|
||||
className="prd-i"
|
||||
value={predictionTime}
|
||||
onChange={(e) => setPredictionTime(Number(e.target.value))}
|
||||
>
|
||||
@ -240,7 +240,7 @@ export function RecalcModal({
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
className="wing-input font-mono"
|
||||
className="prd-i font-mono"
|
||||
value={lat}
|
||||
step={0.0001}
|
||||
onChange={(e) => setLat(Number(e.target.value))}
|
||||
@ -252,7 +252,7 @@ export function RecalcModal({
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
className="wing-input font-mono"
|
||||
className="prd-i font-mono"
|
||||
value={lon}
|
||||
step={0.0001}
|
||||
onChange={(e) => setLon(Number(e.target.value))}
|
||||
|
||||
@ -1,47 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import type { PredictionDetail, SimulationSummary } from '../services/predictionApi'
|
||||
import type { DisplayControls } from './OilSpillView'
|
||||
|
||||
interface AnalysisResult {
|
||||
area: number
|
||||
particleCount: number
|
||||
particlePercent: number
|
||||
sensitiveCount: number
|
||||
}
|
||||
|
||||
interface RightPanelProps {
|
||||
onOpenBacktrack?: () => void
|
||||
onOpenRecalc?: () => void
|
||||
onOpenReport?: () => void
|
||||
detail?: PredictionDetail | null
|
||||
summary?: SimulationSummary | null
|
||||
displayControls?: DisplayControls
|
||||
onDisplayControlsChange?: (controls: DisplayControls) => void
|
||||
analysisTab?: 'polygon' | 'circle'
|
||||
onSwitchAnalysisTab?: (tab: 'polygon' | 'circle') => void
|
||||
drawAnalysisMode?: 'polygon' | null
|
||||
analysisPolygonPoints?: Array<{ lat: number; lon: number }>
|
||||
circleRadiusNm?: number
|
||||
onCircleRadiusChange?: (nm: number) => void
|
||||
analysisResult?: AnalysisResult | null
|
||||
incidentCoord?: { lat: number; lon: number } | null
|
||||
onStartPolygonDraw?: () => void
|
||||
onRunPolygonAnalysis?: () => void
|
||||
onRunCircleAnalysis?: () => void
|
||||
onCancelAnalysis?: () => void
|
||||
onClearAnalysis?: () => void
|
||||
}
|
||||
|
||||
export function RightPanel({
|
||||
onOpenBacktrack, onOpenRecalc, onOpenReport, detail, summary,
|
||||
displayControls, onDisplayControlsChange,
|
||||
analysisTab = 'polygon', onSwitchAnalysisTab,
|
||||
drawAnalysisMode, analysisPolygonPoints = [],
|
||||
circleRadiusNm = 5, onCircleRadiusChange,
|
||||
analysisResult,
|
||||
onStartPolygonDraw, onRunPolygonAnalysis, onRunCircleAnalysis,
|
||||
onCancelAnalysis, onClearAnalysis,
|
||||
}: RightPanelProps) {
|
||||
export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail, summary }: { onOpenBacktrack?: () => void; onOpenRecalc?: () => void; onOpenReport?: () => void; detail?: PredictionDetail | null; summary?: SimulationSummary | null }) {
|
||||
const vessel = detail?.vessels?.[0]
|
||||
const vessel2 = detail?.vessels?.[1]
|
||||
const spill = detail?.spill
|
||||
@ -64,140 +24,20 @@ export function RightPanel({
|
||||
{/* 표시 정보 제어 */}
|
||||
<Section title="표시 정보 제어">
|
||||
<div className="grid grid-cols-2 gap-x-2.5 gap-y-1">
|
||||
<ControlledCheckbox
|
||||
checked={displayControls?.showCurrent ?? true}
|
||||
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showCurrent: v })}
|
||||
>유향/유속</ControlledCheckbox>
|
||||
<ControlledCheckbox
|
||||
checked={displayControls?.showWind ?? true}
|
||||
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showWind: v })}
|
||||
>풍향/풍속</ControlledCheckbox>
|
||||
<ControlledCheckbox
|
||||
checked={displayControls?.showBeached ?? false}
|
||||
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showBeached: v })}
|
||||
>해안부착</ControlledCheckbox>
|
||||
<ControlledCheckbox checked={false} onChange={() => {}} disabled>
|
||||
민감자원
|
||||
</ControlledCheckbox>
|
||||
<ControlledCheckbox
|
||||
checked={displayControls?.showTimeLabel ?? false}
|
||||
onChange={(v) => onDisplayControlsChange?.({ ...displayControls!, showTimeLabel: v })}
|
||||
>시간 표시</ControlledCheckbox>
|
||||
<CheckboxLabel checked>유향/유속</CheckboxLabel>
|
||||
<CheckboxLabel checked>풍향/풍속</CheckboxLabel>
|
||||
<CheckboxLabel>해안부착</CheckboxLabel>
|
||||
<CheckboxLabel>민감자원</CheckboxLabel>
|
||||
<CheckboxLabel>시간 표시</CheckboxLabel>
|
||||
<CheckboxLabel>날짜시간</CheckboxLabel>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 오염분석 */}
|
||||
<Section title="오염분석">
|
||||
{/* 탭 전환 */}
|
||||
<div className="flex gap-[3px] mb-2">
|
||||
{(['polygon', 'circle'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => { onSwitchAnalysisTab?.(tab); onClearAnalysis?.() }}
|
||||
className={`flex-1 py-1.5 px-1 rounded text-[9px] font-semibold font-korean border transition-colors ${
|
||||
analysisTab === tab
|
||||
? 'border-primary-cyan bg-[rgba(6,182,212,0.08)] text-primary-cyan'
|
||||
: 'border-border bg-bg-3 text-text-3 hover:text-text-2'
|
||||
}`}
|
||||
>
|
||||
{tab === 'polygon' ? '다각형 분석' : '원 분석'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 다각형 패널 */}
|
||||
{analysisTab === 'polygon' && (
|
||||
<div>
|
||||
<p className="text-[9px] text-text-3 font-korean mb-2 leading-relaxed">
|
||||
지도에서 다각형 영역을 지정하여 해당 범위 내 오염도를 분석합니다.
|
||||
</p>
|
||||
{!drawAnalysisMode && !analysisResult && (
|
||||
<button
|
||||
onClick={onStartPolygonDraw}
|
||||
className="w-full py-2 rounded text-[10px] font-bold font-korean text-white mb-0 transition-opacity hover:opacity-90"
|
||||
style={{ background: 'linear-gradient(135deg, var(--purple), var(--cyan))' }}
|
||||
>
|
||||
📐 다각형 분석수행
|
||||
</button>
|
||||
)}
|
||||
{drawAnalysisMode === 'polygon' && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[9px] text-purple-400 font-korean bg-[rgba(168,85,247,0.08)] rounded px-2 py-1.5 leading-relaxed">
|
||||
지도를 클릭하여 꼭짓점을 추가하세요<br />
|
||||
<span className="text-text-3">현재 {analysisPolygonPoints.length}개 선택됨</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={onRunPolygonAnalysis}
|
||||
disabled={analysisPolygonPoints.length < 3}
|
||||
className="flex-1 py-1.5 rounded text-[10px] font-bold font-korean text-white disabled:opacity-40 disabled:cursor-not-allowed transition-opacity"
|
||||
style={{ background: 'linear-gradient(135deg, var(--purple), var(--cyan))' }}
|
||||
>
|
||||
분석 실행
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancelAnalysis}
|
||||
className="py-1.5 px-2 rounded text-[10px] font-semibold font-korean border border-border text-text-3 hover:text-text-2 transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{analysisResult && !drawAnalysisMode && (
|
||||
<PollResult result={analysisResult} summary={summary} onClear={onClearAnalysis} onRerun={onStartPolygonDraw} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 원 분석 패널 */}
|
||||
{analysisTab === 'circle' && (
|
||||
<div>
|
||||
<p className="text-[9px] text-text-3 font-korean mb-2 leading-relaxed">
|
||||
반경(NM)을 지정하면 사고지점 기준 원형 영역의 오염도를 분석합니다.
|
||||
</p>
|
||||
<div className="text-[9px] font-semibold text-text-2 font-korean mb-1.5">반경 선택 (NM)</div>
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{[1, 3, 5, 10, 15, 20, 30, 50].map((nm) => (
|
||||
<button
|
||||
key={nm}
|
||||
onClick={() => onCircleRadiusChange?.(nm)}
|
||||
className={`w-8 h-7 rounded text-[10px] font-semibold font-mono border transition-all ${
|
||||
circleRadiusNm === nm
|
||||
? 'border-primary-cyan bg-[rgba(6,182,212,0.1)] text-primary-cyan'
|
||||
: 'border-border bg-bg-0 text-text-3 hover:text-text-2'
|
||||
}`}
|
||||
>
|
||||
{nm}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mb-2.5">
|
||||
<span className="text-[9px] text-text-3 font-korean whitespace-nowrap">직접 입력</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0.1"
|
||||
max="100"
|
||||
step="0.1"
|
||||
value={circleRadiusNm}
|
||||
onChange={(e) => onCircleRadiusChange?.(parseFloat(e.target.value) || 0.1)}
|
||||
className="w-14 text-center py-1 px-1 bg-bg-0 border border-border rounded text-[11px] font-mono text-text-1 outline-none focus:border-primary-cyan"
|
||||
style={{ colorScheme: 'dark' }}
|
||||
/>
|
||||
<span className="text-[9px] text-text-3 font-korean">NM</span>
|
||||
<button
|
||||
onClick={onRunCircleAnalysis}
|
||||
className="ml-auto py-1 px-3 rounded text-[9px] font-bold font-korean text-white transition-opacity hover:opacity-90"
|
||||
style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}
|
||||
>
|
||||
분석 실행
|
||||
</button>
|
||||
</div>
|
||||
{analysisResult && (
|
||||
<PollResult result={analysisResult} summary={summary} onClear={onClearAnalysis} radiusNm={circleRadiusNm} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button className="w-full py-2 px-3 bg-gradient-to-r from-purple-500 to-primary-cyan text-white rounded text-[10px] font-bold font-korean">
|
||||
📐 다각형 분석수행
|
||||
</button>
|
||||
</Section>
|
||||
|
||||
{/* 오염 종합 상황 */}
|
||||
@ -380,33 +220,18 @@ function Section({
|
||||
)
|
||||
}
|
||||
|
||||
function ControlledCheckbox({
|
||||
checked,
|
||||
onChange,
|
||||
children,
|
||||
disabled = false,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
children: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
function CheckboxLabel({ checked, children }: { checked?: boolean; children: string }) {
|
||||
return (
|
||||
<label
|
||||
className={`flex items-center gap-1.5 text-[10px] font-korean cursor-pointer ${
|
||||
disabled ? 'text-text-3 cursor-not-allowed opacity-40' : 'text-text-2'
|
||||
}`}
|
||||
>
|
||||
<label className="flex items-center gap-1.5 text-[10px] text-text-2 font-korean cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="w-[13px] h-[13px] accent-[var(--cyan)]"
|
||||
defaultChecked={checked}
|
||||
className="w-[13px] h-[13px]"
|
||||
className="accent-[var(--cyan)]"
|
||||
/>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function StatBox({
|
||||
@ -600,78 +425,3 @@ function InsuranceCard({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PollResult({
|
||||
result,
|
||||
summary,
|
||||
onClear,
|
||||
onRerun,
|
||||
radiusNm,
|
||||
}: {
|
||||
result: AnalysisResult
|
||||
summary?: SimulationSummary | null
|
||||
onClear?: () => void
|
||||
onRerun?: () => void
|
||||
radiusNm?: number
|
||||
}) {
|
||||
const pollutedArea = (result.area * result.particlePercent / 100).toFixed(2)
|
||||
return (
|
||||
<div className="mt-1 p-2.5 bg-bg-0 border border-[rgba(168,85,247,0.2)] rounded-md" style={{ position: 'relative', overflow: 'hidden' }}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, var(--purple), var(--cyan))' }} />
|
||||
{radiusNm && (
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-[10px] font-semibold text-text-1 font-korean">분석 결과</span>
|
||||
<span className="text-[9px] font-semibold text-primary-cyan font-mono">반경 {radiusNm} NM</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-1 mb-2">
|
||||
<div className="text-center py-1.5 px-1 bg-bg-3 rounded">
|
||||
<div className="text-[13px] font-bold font-mono" style={{ color: 'var(--red)' }}>{result.area.toFixed(2)}</div>
|
||||
<div className="text-[7px] text-text-3 font-korean mt-0.5">분석면적(km²)</div>
|
||||
</div>
|
||||
<div className="text-center py-1.5 px-1 bg-bg-3 rounded">
|
||||
<div className="text-[13px] font-bold font-mono" style={{ color: 'var(--orange)' }}>{result.particlePercent}%</div>
|
||||
<div className="text-[7px] text-text-3 font-korean mt-0.5">오염비율</div>
|
||||
</div>
|
||||
<div className="text-center py-1.5 px-1 bg-bg-3 rounded">
|
||||
<div className="text-[13px] font-bold font-mono" style={{ color: 'var(--cyan)' }}>{pollutedArea}</div>
|
||||
<div className="text-[7px] text-text-3 font-korean mt-0.5">오염면적(km²)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 text-[9px] font-korean">
|
||||
{summary && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-3">해상잔존량</span>
|
||||
<span className="font-semibold font-mono" style={{ color: 'var(--blue)' }}>{summary.remainingVolume.toFixed(2)} kL</span>
|
||||
</div>
|
||||
)}
|
||||
{summary && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-3">연안부착량</span>
|
||||
<span className="font-semibold font-mono" style={{ color: 'var(--red)' }}>{summary.beachedVolume.toFixed(2)} kL</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-3">민감자원 포함</span>
|
||||
<span className="font-semibold font-mono" style={{ color: 'var(--orange)' }}>{result.sensitiveCount}개소</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1.5 mt-2">
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="flex-1 py-1.5 rounded text-[9px] font-semibold font-korean border border-border text-text-3 hover:text-text-2 transition-colors"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
{onRerun && (
|
||||
<button
|
||||
onClick={onRerun}
|
||||
className="flex-1 py-1.5 rounded text-[9px] font-semibold font-korean border border-[rgba(168,85,247,0.3)] text-purple-400 hover:bg-[rgba(168,85,247,0.08)] transition-colors"
|
||||
>
|
||||
재분석
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,110 +0,0 @@
|
||||
interface SimulationErrorModalProps {
|
||||
message: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SimulationErrorModal = ({ message, onClose }: SimulationErrorModalProps) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(10, 14, 26, 0.75)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 360,
|
||||
background: 'var(--bg1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.35)',
|
||||
borderRadius: 'var(--rM)',
|
||||
padding: '28px 24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{/* 아이콘 + 제목 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(239, 68, 68, 0.12)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"
|
||||
fill="rgb(239, 68, 68)"
|
||||
opacity="0.9"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: 'var(--t1)', fontSize: 14, fontWeight: 600 }}>
|
||||
확산 예측 실패
|
||||
</div>
|
||||
<div style={{ color: 'var(--t3)', fontSize: 12, marginTop: 2 }}>
|
||||
시뮬레이션 실행 중 오류가 발생했습니다
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(239, 68, 68, 0.06)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||
borderRadius: 'var(--rS)',
|
||||
padding: '10px 14px',
|
||||
color: 'rgb(252, 165, 165)',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
|
||||
{/* 확인 버튼 */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
marginTop: 4,
|
||||
padding: '8px 0',
|
||||
background: 'rgba(239, 68, 68, 0.15)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.35)',
|
||||
borderRadius: 'var(--rS)',
|
||||
color: 'rgb(252, 165, 165)',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = 'rgba(239, 68, 68, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = 'rgba(239, 68, 68, 0.15)';
|
||||
}}
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimulationErrorModal;
|
||||
@ -18,7 +18,6 @@ export interface PredictionAnalysis {
|
||||
backtrackStatus: string;
|
||||
analyst: string;
|
||||
officeName: string;
|
||||
acdntSttsCd: string;
|
||||
}
|
||||
|
||||
export interface PredictionDetail {
|
||||
|
||||
@ -343,26 +343,13 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{sec.id === 'oil-coastal' && (() => {
|
||||
const coastLength = oilPayload?.pollution.coastLength;
|
||||
const hasNoCoastal = oilPayload && (
|
||||
!coastLength || coastLength === '—' || coastLength.startsWith('0.00')
|
||||
);
|
||||
if (hasNoCoastal) {
|
||||
return (
|
||||
<p className="text-[12px] text-text-2 font-korean">
|
||||
시뮬레이션 결과 유출유의 <span className="font-semibold text-text-1">해안 부착이 없습니다</span>.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p className="text-[12px] text-text-2 font-korean">
|
||||
최초 부착시간: <span className="font-semibold text-text-1">{oilPayload?.coastal?.firstTime ?? sampleOilData.coastal.firstTime}</span>
|
||||
{' / '}
|
||||
부착 해안길이: <span className="font-semibold text-text-1">{coastLength || sampleOilData.coastal.coastLength}</span>
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
{sec.id === 'oil-coastal' && (
|
||||
<p className="text-[12px] text-text-2 font-korean">
|
||||
최초 부착시간: <span className="font-semibold text-text-1">{oilPayload?.coastal?.firstTime ?? sampleOilData.coastal.firstTime}</span>
|
||||
{' / '}
|
||||
부착 해안길이: <span className="font-semibold text-text-1">{oilPayload?.pollution.coastLength || sampleOilData.coastal.coastLength}</span>
|
||||
</p>
|
||||
)}
|
||||
{sec.id === 'oil-defense' && (
|
||||
<div className="text-[12px] text-text-3 font-korean">
|
||||
<p className="mb-2">방제자원 배치 계획에 따른 전략을 수립합니다.</p>
|
||||
|
||||
@ -66,7 +66,7 @@ function ScatLeftPanel({
|
||||
<select
|
||||
value={jurisdictionFilter}
|
||||
onChange={(e) => onJurisdictionChange(e.target.value)}
|
||||
className="wing-select w-full"
|
||||
className="prd-i w-full"
|
||||
>
|
||||
<option>전체 (제주도)</option>
|
||||
<option>서귀포해양경비안전서</option>
|
||||
@ -81,7 +81,7 @@ function ScatLeftPanel({
|
||||
<select
|
||||
value={areaFilter}
|
||||
onChange={(e) => onAreaChange(e.target.value)}
|
||||
className="wing-select w-full"
|
||||
className="prd-i w-full"
|
||||
>
|
||||
<option>전체</option>
|
||||
{zones.map((z) => (
|
||||
@ -99,7 +99,7 @@ function ScatLeftPanel({
|
||||
<select
|
||||
value={phaseFilter}
|
||||
onChange={(e) => onPhaseChange(e.target.value)}
|
||||
className="wing-select w-full"
|
||||
className="prd-i w-full"
|
||||
>
|
||||
<option>Pre-SCAT (사전조사)</option>
|
||||
<option>SCAT (사고 시 조사)</option>
|
||||
@ -113,12 +113,12 @@ function ScatLeftPanel({
|
||||
placeholder="🔍 구간 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="wing-input flex-1"
|
||||
className="prd-i flex-1"
|
||||
/>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => onStatusChange(e.target.value)}
|
||||
className="wing-select w-[70px]"
|
||||
className="prd-i w-[70px]"
|
||||
>
|
||||
<option>전체</option>
|
||||
<option>완료</option>
|
||||
|
||||
@ -1,207 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import BrandLogo from './sections/BrandLogo';
|
||||
import BrandColor from './sections/BrandColor';
|
||||
import Typography from './sections/Typography';
|
||||
import Icons from './sections/Icons';
|
||||
import Spacing from './sections/Spacing';
|
||||
import Border from './sections/Border';
|
||||
import BadgeSection from './sections/BadgeSection';
|
||||
import ButtonSection from './sections/ButtonSection';
|
||||
import InputSection from './sections/InputSection';
|
||||
import CardSection from './sections/CardSection';
|
||||
import DataTableSection from './sections/DataTableSection';
|
||||
import PaginationSection from './sections/PaginationSection';
|
||||
import ModalSection from './sections/ModalSection';
|
||||
import SidePanelSection from './sections/SidePanelSection';
|
||||
import ComboBoxSection from './sections/ComboBoxSection';
|
||||
|
||||
/* ── 라이트 테마 CSS 변수 오버라이드 ── */
|
||||
const LIGHT_THEME_VARS: React.CSSProperties = {
|
||||
'--bg0': '#ffffff',
|
||||
'--bg1': '#f8f9fb',
|
||||
'--bg2': '#f0f2f5',
|
||||
'--bg3': '#e8ebf0',
|
||||
'--bgH': '#dde1e8',
|
||||
'--bd': '#d0d5dd',
|
||||
'--bdL': '#e0e4ea',
|
||||
'--t1': '#1a1d26',
|
||||
'--t2': '#4a5568',
|
||||
'--t3': '#8690a6',
|
||||
'--cyan': '#0891b2',
|
||||
'--blue': '#2563eb',
|
||||
'--red': '#dc2626',
|
||||
'--orange': '#ea580c',
|
||||
'--yellow': '#ca8a04',
|
||||
'--green': '#16a34a',
|
||||
'--purple': '#9333ea',
|
||||
} as React.CSSProperties;
|
||||
|
||||
/* ── 사이드바 메뉴 구조 ── */
|
||||
type SectionId =
|
||||
| 'logo'
|
||||
| 'brand-color' | 'typography' | 'icons' | 'spacing' | 'border'
|
||||
| 'badge' | 'button' | 'input' | 'card' | 'data-table' | 'pagination' | 'modal' | 'side-panel' | 'combo-box';
|
||||
|
||||
interface MenuItem {
|
||||
id: SectionId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface MenuGroup {
|
||||
title: string;
|
||||
items: MenuItem[];
|
||||
}
|
||||
|
||||
const MENU: MenuGroup[] = [
|
||||
{
|
||||
title: 'Brand',
|
||||
items: [
|
||||
{ id: 'logo', label: '로고 (Logo)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Foundations',
|
||||
items: [
|
||||
{ id: 'brand-color', label: '브랜드 컬러' },
|
||||
{ id: 'typography', label: '타이포그래피' },
|
||||
{ id: 'icons', label: '아이콘' },
|
||||
{ id: 'spacing', label: '간격' },
|
||||
{ id: 'border', label: '테두리' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Components',
|
||||
items: [
|
||||
{ id: 'badge', label: 'Badge' },
|
||||
{ id: 'button', label: 'Button' },
|
||||
{ id: 'input', label: 'Input' },
|
||||
{ id: 'card', label: 'Card' },
|
||||
{ id: 'data-table', label: 'DataTable' },
|
||||
{ id: 'pagination', label: 'Pagination' },
|
||||
{ id: 'modal', label: 'Modal' },
|
||||
{ id: 'side-panel', label: 'SidePanel' },
|
||||
{ id: 'combo-box', label: 'ComboBox' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const SECTION_MAP: Record<SectionId, () => React.ReactNode> = {
|
||||
'logo': () => <BrandLogo />,
|
||||
'brand-color': () => <BrandColor />,
|
||||
'typography': () => <Typography />,
|
||||
'icons': () => <Icons />,
|
||||
'spacing': () => <Spacing />,
|
||||
'border': () => <Border />,
|
||||
'badge': () => <BadgeSection />,
|
||||
'button': () => <ButtonSection />,
|
||||
'input': () => <InputSection />,
|
||||
'card': () => <CardSection />,
|
||||
'data-table': () => <DataTableSection />,
|
||||
'pagination': () => <PaginationSection />,
|
||||
'modal': () => <ModalSection />,
|
||||
'side-panel': () => <SidePanelSection />,
|
||||
'combo-box': () => <ComboBoxSection />,
|
||||
};
|
||||
|
||||
/* ── 메인 쇼케이스 ── */
|
||||
const ShowcaseView = () => {
|
||||
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
|
||||
const [activeSection, setActiveSection] = useState<SectionId>('logo');
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
|
||||
Brand: true,
|
||||
Foundations: true,
|
||||
Components: true,
|
||||
});
|
||||
|
||||
const isLight = theme === 'light';
|
||||
|
||||
const toggleGroup = (title: string) => {
|
||||
setOpenGroups((prev) => ({ ...prev, [title]: !prev[title] }));
|
||||
};
|
||||
|
||||
const renderSection = SECTION_MAP[activeSection];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full overflow-hidden w-full"
|
||||
style={isLight ? LIGHT_THEME_VARS : undefined}
|
||||
>
|
||||
{/* 사이드바 */}
|
||||
<div
|
||||
className="w-[240px] shrink-0 h-full overflow-y-auto border-r flex flex-col"
|
||||
style={{ background: 'var(--bg1)', borderColor: 'var(--bd)' }}
|
||||
>
|
||||
{/* 사이드바 헤더 + 테마 토글 */}
|
||||
<div className="p-4 border-b" style={{ borderColor: 'var(--bd)' }}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-wing-heading font-bold" style={{ color: 'var(--t1)' }}>
|
||||
Design System
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setTheme(isLight ? 'dark' : 'light')}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-sm border transition-all text-[10px] font-semibold"
|
||||
style={{
|
||||
background: 'var(--bg3)',
|
||||
borderColor: 'var(--bd)',
|
||||
color: 'var(--t1)',
|
||||
}}
|
||||
>
|
||||
<span>{isLight ? '☀️' : '🌙'}</span>
|
||||
{isLight ? 'Light' : 'Dark'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="wing-meta" style={{ color: 'var(--t3)' }}>WING UI 컴포넌트 프리뷰</p>
|
||||
</div>
|
||||
|
||||
{/* 메뉴 그룹 */}
|
||||
<nav className="flex-1 py-2">
|
||||
{MENU.map((group) => (
|
||||
<div key={group.title} className="mb-1">
|
||||
{/* 그룹 헤더 */}
|
||||
<button
|
||||
onClick={() => toggleGroup(group.title)}
|
||||
className="w-full flex items-center justify-between px-4 py-2 text-[11px] font-bold tracking-wide transition-colors hover:opacity-80"
|
||||
style={{ color: 'var(--t3)' }}
|
||||
>
|
||||
<span>{group.title}</span>
|
||||
<span className="text-[10px]">{openGroups[group.title] ? '▾' : '▸'}</span>
|
||||
</button>
|
||||
|
||||
{/* 하위 메뉴 */}
|
||||
{openGroups[group.title] && (
|
||||
<div>
|
||||
{group.items.map((item) => {
|
||||
const isActive = activeSection === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveSection(item.id)}
|
||||
className="w-full text-left px-4 py-1.5 pl-7 text-[11px] transition-all"
|
||||
style={{
|
||||
color: isActive ? 'var(--cyan)' : 'var(--t2)',
|
||||
background: isActive ? 'rgba(6,182,212,0.08)' : 'transparent',
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<div className="flex-1 overflow-y-auto p-6" style={{ background: 'var(--bg0)', color: 'var(--t1)' }}>
|
||||
<div className="max-w-[960px] mx-auto">
|
||||
{renderSection()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShowcaseView;
|
||||
@ -1 +0,0 @@
|
||||
export { default as ShowcaseView } from './ShowcaseView';
|
||||
@ -1,23 +0,0 @@
|
||||
import Badge from '@common/components/ui/Badge';
|
||||
|
||||
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
|
||||
<div className="mb-8">
|
||||
<h2 className="wing-section-header mb-1">{title}</h2>
|
||||
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const BADGE_COLORS = ['neutral', 'red', 'blue', 'green', 'yellow', 'purple', 'cyan'] as const;
|
||||
|
||||
const BadgeSection = () => (
|
||||
<Section title="Badge" desc="wing-badge 컬러 7종">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{BADGE_COLORS.map((color) => (
|
||||
<Badge key={color} color={color}>{color}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
export default BadgeSection;
|
||||
@ -1,48 +0,0 @@
|
||||
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
|
||||
<div className="mb-8">
|
||||
<h2 className="wing-section-header mb-1">{title}</h2>
|
||||
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Border = () => (
|
||||
<Section title="테두리 (Border)" desc="테두리 색상 + Border Radius 4단계">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="wing-label mb-2">테두리 색상</p>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="w-14 h-14 rounded-sm" style={{ border: '2px solid var(--bd)', background: 'var(--bg1)' }} />
|
||||
<span className="wing-meta">bd</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="w-14 h-14 rounded-sm" style={{ border: '2px solid var(--bdL)', background: 'var(--bg1)' }} />
|
||||
<span className="wing-meta">bdL</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="wing-label mb-2">Border Radius</p>
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ r: 4, label: 'xs (4px)' },
|
||||
{ r: 6, label: 'sm (6px)' },
|
||||
{ r: 10, label: 'md (10px)' },
|
||||
{ r: 12, label: 'lg (12px)' },
|
||||
].map((item) => (
|
||||
<div key={item.r} className="flex flex-col items-center gap-1">
|
||||
<div
|
||||
className="w-12 h-12 border-2 border-primary-cyan bg-bg-2"
|
||||
style={{ borderRadius: item.r }}
|
||||
/>
|
||||
<span className="wing-meta text-text-3">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
export default Border;
|
||||
@ -1,47 +0,0 @@
|
||||
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
|
||||
<div className="mb-8">
|
||||
<h2 className="wing-section-header mb-1">{title}</h2>
|
||||
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const BrandColor = () => (
|
||||
<Section title="브랜드 컬러 (Brand Color)" desc="배경, 텍스트, Accent Color">
|
||||
{/* 배경 */}
|
||||
<p className="wing-label mb-2">배경 (Background)</p>
|
||||
<div className="flex gap-2 mb-4">
|
||||
{['--bg0', '--bg1', '--bg2', '--bg3', '--bgH'].map((v) => (
|
||||
<div key={v} className="flex flex-col items-center gap-1">
|
||||
<div className="w-14 h-14 rounded-sm" style={{ background: `var(${v})`, border: '1px solid var(--bd)' }} />
|
||||
<span className="wing-meta">{v.replace('--', '')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 텍스트 */}
|
||||
<p className="wing-label mb-2">텍스트 (Text)</p>
|
||||
<div className="flex gap-4 mb-4 p-3 rounded-sm" style={{ background: 'var(--bg1)' }}>
|
||||
<span style={{ color: 'var(--t1)' }} className="text-wing-body">Text-1 주 텍스트</span>
|
||||
<span style={{ color: 'var(--t2)' }} className="text-wing-body">Text-2 보조 텍스트</span>
|
||||
<span style={{ color: 'var(--t3)' }} className="text-wing-body">Text-3 비활성 텍스트</span>
|
||||
</div>
|
||||
{/* Accent */}
|
||||
<p className="wing-label mb-2">Accent Color</p>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="w-14 h-14 rounded-sm" style={{ background: 'var(--cyan)' }} />
|
||||
<span className="wing-meta">Cyan</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="w-14 h-14 rounded-sm" style={{ background: 'var(--blue)' }} />
|
||||
<span className="wing-meta">Blue</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="w-14 h-14 rounded-sm" style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }} />
|
||||
<span className="wing-meta">Gradient</span>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
export default BrandColor;
|
||||
@ -1,31 +0,0 @@
|
||||
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
|
||||
<div className="mb-8">
|
||||
<h2 className="wing-section-header mb-1">{title}</h2>
|
||||
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const BrandLogo = () => (
|
||||
<Section title="로고 (Logo)" desc="WING 로고 규격 및 여백">
|
||||
<div className="p-6 rounded-sm flex flex-col gap-6" style={{ background: 'var(--bg1)' }}>
|
||||
<div>
|
||||
<p className="wing-label mb-3">기본 로고</p>
|
||||
<div className="flex gap-8 items-center">
|
||||
<div className="p-4 rounded-sm" style={{ background: 'var(--bg0)' }}>
|
||||
<img src="/wing_logo_white.svg" alt="WING Logo" className="h-5" />
|
||||
</div>
|
||||
<div className="p-4 rounded-sm" style={{ background: '#ffffff' }}>
|
||||
<img src="/wing_logo_text_white.svg" alt="WING Logo Text" className="h-5" style={{ filter: 'invert(1)' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="wing-label mb-2">여백 규정</p>
|
||||
<p className="wing-meta" style={{ color: 'var(--t3)' }}>로고 주변 최소 여백: 로고 높이의 50% 이상</p>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
export default BrandLogo;
|
||||
@ -1,21 +0,0 @@
|
||||
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
|
||||
<div className="mb-8">
|
||||
<h2 className="wing-section-header mb-1">{title}</h2>
|
||||
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ButtonSection = () => (
|
||||
<Section title="Button" desc="wing-btn 변형">
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
<button className="wing-btn wing-btn-primary">Primary</button>
|
||||
<button className="wing-btn wing-btn-secondary">Secondary</button>
|
||||
<button className="wing-btn wing-btn-outline">Outline</button>
|
||||
<button className="wing-btn wing-btn-danger">Danger</button>
|
||||
<button className="wing-btn wing-btn-primary" disabled>Disabled</button>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
export default ButtonSection;
|
||||
@ -1,30 +0,0 @@
|
||||
import Card from '@common/components/ui/Card';
|
||||
|
||||
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
|
||||
<div className="mb-8">
|
||||
<h2 className="wing-section-header mb-1">{title}</h2>
|
||||
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CardSection = () => (
|
||||
<Section title="Card" desc="wing-card 기본 + hover">
|
||||
<div className="grid grid-cols-3 gap-3 max-w-[720px]">
|
||||
<Card>
|
||||
<p className="wing-label mb-1">기본 카드</p>
|
||||
<p className="wing-meta text-text-3">hover: true (기본값)</p>
|
||||
</Card>
|
||||
<Card hover={false}>
|
||||
<p className="wing-label mb-1">Hover 비활성</p>
|
||||
<p className="wing-meta text-text-3">hover: false</p>
|
||||
</Card>
|
||||
<Card onClick={() => alert('클릭!')}>
|
||||
<p className="wing-label mb-1">클릭 가능 카드</p>
|
||||
<p className="wing-meta text-text-3">onClick 핸들러 있음</p>
|
||||
</Card>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
export default CardSection;
|
||||
@ -1,32 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { ComboBox } from '@common/components/ui/ComboBox';
|
||||
|
||||
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
|
||||
<div className="mb-8">
|
||||
<h2 className="wing-section-header mb-1">{title}</h2>
|
||||
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: 'prediction', label: '확산 예측' },
|
||||
{ value: 'hns', label: 'HNS 분석' },
|
||||
{ value: 'aerial', label: '항공 방제' },
|
||||
{ value: 'weather', label: '해양 기상' },
|
||||
{ value: 'scat', label: 'SCAT 조사' },
|
||||
];
|
||||
|
||||
const ComboBoxSection = () => {
|
||||
const [value, setValue] = useState('prediction');
|
||||
return (
|
||||
<Section title="ComboBox" desc="검색 가능한 드롭다운 선택">
|
||||
<div className="max-w-[240px]">
|
||||
<ComboBox value={value} onChange={setValue} options={OPTIONS} placeholder="기능 선택..." />
|
||||
</div>
|
||||
<p className="wing-meta text-text-3 mt-2">선택값: {value}</p>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComboBoxSection;
|
||||
@ -1,39 +0,0 @@
|
||||
import Badge from '@common/components/ui/Badge';
|
||||
import DataTable from '@common/components/ui/DataTable';
|
||||
|
||||
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
|
||||
<div className="mb-8">
|
||||
<h2 className="wing-section-header mb-1">{title}</h2>
|
||||
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SAMPLE_DATA = [
|
||||
{ id: 1, title: '유류 확산 예측 보고서', author: '이동녘', category: '공지사항', date: '2026-03-10', views: 142 },
|
||||
{ id: 2, title: 'HNS 물질 분석 자료', author: '김해양', category: '자료실', date: '2026-03-09', views: 87 },
|
||||
{ id: 3, title: '항공 방제 드론 운영 매뉴얼', author: '박방제', category: '매뉴얼', date: '2026-03-08', views: 256 },
|
||||
{ id: 4, title: 'SCAT 해안조사 결과', author: '최조사', category: '보고서', date: '2026-03-07', views: 64 },
|
||||
{ id: 5, title: '기상 데이터 연동 가이드', author: '정기상', category: 'Q&A', date: '2026-03-06', views: 33 },
|
||||
];
|
||||
|
||||
const TABLE_COLUMNS = [
|
||||
{ key: 'id' as const, label: '번호', width: '60px', align: 'center' as const },
|
||||
{ key: 'title' as const, label: '제목' },
|
||||
{ key: 'author' as const, label: '작성자', width: '100px' },
|
||||
{ key: 'category' as const, label: '분류', width: '100px', render: (val: unknown) => <Badge>{val as string}</Badge> },
|
||||
{ key: 'date' as const, label: '작성일', width: '120px' },
|
||||
{ key: 'views' as const, label: '조회', width: '60px', align: 'center' as const },
|
||||
];
|
||||
|
||||
const DataTableSection = () => (
|
||||
<Section title="DataTable" desc="wing-table 기반 제네릭 테이블">
|
||||
<DataTable
|
||||
columns={TABLE_COLUMNS}
|
||||
data={SAMPLE_DATA}
|
||||
onRowClick={(row) => alert(`선택: ${row.title}`)}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
|
||||
export default DataTableSection;
|
||||
@ -1,31 +0,0 @@
|
||||
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
|
||||
<div className="mb-8">
|
||||
<h2 className="wing-section-header mb-1">{title}</h2>
|
||||
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Icons = () => (
|
||||
<Section title="아이콘 (Icons)" desc="lucide-react 기반, 16/18/20px 3단계">
|
||||
<div className="p-4 rounded-sm space-y-4" style={{ background: 'var(--bg1)' }}>
|
||||
<p className="wing-meta" style={{ color: 'var(--t3)' }}>
|
||||
프로젝트 전체에서 lucide-react 아이콘 라이브러리를 사용합니다.
|
||||
</p>
|
||||
<div className="flex gap-6 items-end">
|
||||
{[16, 18, 20].map((size) => (
|
||||
<div key={size} className="flex flex-col items-center gap-2">
|
||||
<div className="w-10 h-10 rounded-sm flex items-center justify-center" style={{ background: 'var(--bg2)', border: '1px solid var(--bd)' }}>
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="wing-meta" style={{ color: 'var(--t3)' }}>{size}px</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
export default Icons;
|
||||
@ -1,53 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { ComboBox } from '@common/components/ui/ComboBox';
|
||||
|
||||
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
|
||||
<div className="mb-8">
|
||||
<h2 className="wing-section-header mb-1">{title}</h2>
|
||||
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const COMBO_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'notice', label: '공지사항' },
|
||||
{ value: 'data', label: '자료실' },
|
||||
{ value: 'manual', label: '매뉴얼' },
|
||||
];
|
||||
|
||||
const InputSection = () => {
|
||||
const [comboValue, setComboValue] = useState('all');
|
||||
return (
|
||||
<Section title="Input / Select / ComboBox" desc="wing-input, wing-select, ComboBox">
|
||||
<div className="grid grid-cols-3 gap-3 max-w-[720px]">
|
||||
<div>
|
||||
<label className="wing-label mb-1 block">Input</label>
|
||||
<input className="wing-input" placeholder="텍스트 입력..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className="wing-label mb-1 block">Search</label>
|
||||
<input className="wing-input-search" placeholder="검색..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className="wing-label mb-1 block">Select</label>
|
||||
<select className="wing-select">
|
||||
<option>옵션 1</option>
|
||||
<option>옵션 2</option>
|
||||
<option>옵션 3</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="wing-label mb-1 block">Textarea</label>
|
||||
<textarea className="wing-textarea" rows={2} placeholder="여러 줄 입력..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className="wing-label mb-1 block">ComboBox</label>
|
||||
<ComboBox value={comboValue} onChange={setComboValue} options={COMBO_OPTIONS} />
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputSection;
|
||||
@ -1,46 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import Modal from '@common/components/ui/Modal';
|
||||
|
||||
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
|
||||
<div className="mb-8">
|
||||
<h2 className="wing-section-header mb-1">{title}</h2>
|
||||
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ModalSection = () => {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalSize, setModalSize] = useState<'sm' | 'md' | 'lg'>('md');
|
||||
return (
|
||||
<Section title="Modal" desc="wing-modal 3가지 사이즈">
|
||||
<div className="flex gap-2">
|
||||
{(['sm', 'md', 'lg'] as const).map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
className="wing-btn wing-btn-secondary"
|
||||
onClick={() => { setModalSize(size); setModalOpen(true); }}
|
||||
>
|
||||
모달 {size.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Modal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
title={`모달 (${modalSize})`}
|
||||
size={modalSize}
|
||||
footer={
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button className="wing-btn wing-btn-secondary" onClick={() => setModalOpen(false)}>취소</button>
|
||||
<button className="wing-btn wing-btn-primary" onClick={() => setModalOpen(false)}>확인</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p className="text-text-2 text-wing-body">모달 본문 영역입니다. 사이즈: {modalSize}</p>
|
||||
</Modal>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalSection;
|
||||
@ -1,30 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import Pagination from '@common/components/ui/Pagination';
|
||||
|
||||
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
|
||||
<div className="mb-8">
|
||||
<h2 className="wing-section-header mb-1">{title}</h2>
|
||||
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PaginationSection = () => {
|
||||
const [page, setPage] = useState(1);
|
||||
return (
|
||||
<Section title="Pagination" desc="wing-btn 기반 페이지네이션">
|
||||
<div className="flex gap-6 items-center">
|
||||
<div>
|
||||
<p className="wing-meta text-text-3 mb-2">7페이지 중 {page}페이지</p>
|
||||
<Pagination currentPage={page} totalPages={7} onPageChange={setPage} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="wing-meta text-text-3 mb-2">3페이지 (showFirstLast: false)</p>
|
||||
<Pagination currentPage={1} totalPages={3} onPageChange={() => {}} showFirstLast={false} />
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaginationSection;
|
||||
@ -1,67 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import Badge from '@common/components/ui/Badge';
|
||||
import SidePanel from '@common/components/ui/SidePanel';
|
||||
|
||||
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
|
||||
<div className="mb-8">
|
||||
<h2 className="wing-section-header mb-1">{title}</h2>
|
||||
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SidePanelSection = () => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Section title="SidePanel" desc="wing-panel-right/left 사이드패널">
|
||||
<button
|
||||
className="wing-btn wing-btn-secondary"
|
||||
onClick={() => setVisible(!visible)}
|
||||
>
|
||||
{visible ? '사이드패널 닫기' : '사이드패널 열기'}
|
||||
</button>
|
||||
</Section>
|
||||
{visible && (
|
||||
<SidePanel
|
||||
position="right"
|
||||
width="default"
|
||||
header={
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className="wing-title">상세 정보</span>
|
||||
<button className="wing-btn wing-btn-secondary" onClick={() => setVisible(false)}>닫기</button>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<button className="wing-btn wing-btn-primary w-full">저장</button>
|
||||
}
|
||||
>
|
||||
<div className="p-3 space-y-3">
|
||||
<div className="wing-info-row">
|
||||
<span className="wing-info-label">상태</span>
|
||||
<Badge color="green">정상</Badge>
|
||||
</div>
|
||||
<div className="wing-info-row">
|
||||
<span className="wing-info-label">분류</span>
|
||||
<Badge>공지사항</Badge>
|
||||
</div>
|
||||
<div className="wing-info-row">
|
||||
<span className="wing-info-label">작성자</span>
|
||||
<span className="wing-info-value">이동녘</span>
|
||||
</div>
|
||||
<div className="wing-info-row">
|
||||
<span className="wing-info-label">작성일</span>
|
||||
<span className="wing-info-value">2026-03-10</span>
|
||||
</div>
|
||||
<div className="wing-info-row">
|
||||
<span className="wing-info-label">조회수</span>
|
||||
<span className="wing-info-value">142</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidePanel>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidePanelSection;
|
||||
@ -1,25 +0,0 @@
|
||||
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
|
||||
<div className="mb-8">
|
||||
<h2 className="wing-section-header mb-1">{title}</h2>
|
||||
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Spacing = () => (
|
||||
<Section title="간격 (Spacing)" desc="4px 그리드 기반 6단계">
|
||||
<div>
|
||||
<p className="wing-label mb-2">Spacing (4px grid)</p>
|
||||
<div className="flex items-end gap-2">
|
||||
{[4, 6, 8, 12, 16, 20].map((s) => (
|
||||
<div key={s} className="flex flex-col items-center gap-1">
|
||||
<div className="bg-primary-cyan/30 border border-primary-cyan/50" style={{ width: s, height: 40 }} />
|
||||
<span className="wing-meta text-text-3">{s}px</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
export default Spacing;
|
||||
@ -1,43 +0,0 @@
|
||||
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
|
||||
<div className="mb-8">
|
||||
<h2 className="wing-section-header mb-1">{title}</h2>
|
||||
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Typography = () => (
|
||||
<Section title="타이포그래피 (Typography)" desc="폰트 패밀리 + 5단계 스케일">
|
||||
<div className="p-4 bg-bg-1 rounded-sm space-y-3">
|
||||
<div className="flex items-baseline gap-4">
|
||||
<span className="text-wing-title font-bold text-text-1">Title 15px</span>
|
||||
<span className="wing-meta text-text-3">font-bold, line-height 1.3</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-4">
|
||||
<span className="text-wing-heading font-bold text-text-1">Heading 13px</span>
|
||||
<span className="wing-meta text-text-3">font-bold, line-height 1.4</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-4">
|
||||
<span className="text-wing-body text-text-1">Body 11px — 가장 많이 사용되는 기본 본문</span>
|
||||
<span className="wing-meta text-text-3">line-height 1.5</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-4">
|
||||
<span className="text-wing-caption text-text-2">Caption 10px — 캡션, 설명</span>
|
||||
<span className="wing-meta text-text-3">line-height 1.4</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-4">
|
||||
<span className="text-wing-meta text-text-3">Meta 9px — 날짜, 부가 정보</span>
|
||||
<span className="wing-meta text-text-3">line-height 1.4</span>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3 mt-3">
|
||||
<p className="wing-meta text-text-3 mb-2">폰트 패밀리</p>
|
||||
<div className="flex gap-6">
|
||||
<span className="text-wing-body font-sans text-text-2">Sans: Outfit + Noto Sans KR</span>
|
||||
<span className="text-wing-body font-mono text-text-2">Mono: JetBrains Mono</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
export default Typography;
|
||||
@ -44,13 +44,6 @@ export default {
|
||||
mono: ['JetBrains Mono', 'monospace'],
|
||||
korean: ['Noto Sans KR', 'sans-serif'],
|
||||
},
|
||||
fontSize: {
|
||||
'wing-meta': ['9px', { lineHeight: '1.4' }],
|
||||
'wing-caption': ['10px', { lineHeight: '1.4' }],
|
||||
'wing-body': ['11px', { lineHeight: '1.5' }],
|
||||
'wing-heading': ['13px', { lineHeight: '1.4' }],
|
||||
'wing-title': ['15px', { lineHeight: '1.3' }],
|
||||
},
|
||||
borderRadius: {
|
||||
sm: '6px',
|
||||
md: '10px',
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user