Merge pull request 'release: 2026-04-14 (267건 커밋)' (#171) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 36s
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 36s
This commit is contained in:
커밋
20d5c08bc7
22
CLAUDE.md
22
CLAUDE.md
@ -127,6 +127,28 @@ wing/
|
|||||||
|
|
||||||
## 진행 중 작업 (완료 후 삭제)
|
## 진행 중 작업 (완료 후 삭제)
|
||||||
|
|
||||||
|
### 폰트 크기 업스케일 작업 (진행 중)
|
||||||
|
|
||||||
|
반드시 `memory/font-upscale-plan.md`를 읽고 Phase 진행 상황을 확인할 것.
|
||||||
|
|
||||||
|
**토큰 변경 매핑 (이름 유지, 값만 변경):**
|
||||||
|
- `caption`/`label-2`/`title-6`: 11px → **12px** (0.75rem)
|
||||||
|
- `label-1`/`title-5`: 12px → **13px** (0.8125rem)
|
||||||
|
- `body-2`/`title-4`: 13px → **14px** (0.875rem)
|
||||||
|
- `body-1`/`title-3`: 14px → **16px** (1rem)
|
||||||
|
|
||||||
|
**네비게이션 클래스 교체:**
|
||||||
|
- TopBar 메인탭: `text-title-4` → `text-title-2` (16px)
|
||||||
|
- SubMenuBar 서브탭: `text-title-5` → `text-title-4` (14px)
|
||||||
|
|
||||||
|
**작업 범위:**
|
||||||
|
- Phase 1: tailwind.config.js + base.css 토큰 값 수정
|
||||||
|
- Phase 2: components.css 하드코딩(27곳) + wing.css `.wing-tab` text-xs→text-caption
|
||||||
|
- Phase 3: TopBar.tsx + SubMenuBar.tsx 클래스 교체
|
||||||
|
- Phase 4: text-xs→text-caption, text-sm→text-body-2 스크립트 교체 (design 페이지 제외, 608건)
|
||||||
|
- Phase 5: prediction 탭 인라인 fontSize 수정
|
||||||
|
- Phase 6 (보류): wing-header-bar 패딩 — 폰트 변경 후 유저 확인 후 진행
|
||||||
|
|
||||||
### 디자인 시스템 폰트+색상 통일 작업
|
### 디자인 시스템 폰트+색상 통일 작업
|
||||||
|
|
||||||
compact 후 반드시 `memory/design-system-work.md`를 읽고 작업 상태(완료/미완료 컴포넌트)를 확인할 것.
|
compact 후 반드시 `memory/design-system-work.md`를 읽고 작업 상태(완료/미완료 컴포넌트)를 확인할 것.
|
||||||
|
|||||||
@ -128,55 +128,125 @@ INSERT INTO RESCUE_OPS (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 4. RESCUE_SCENARIO 시드 데이터 (5건, RESCUE_OPS_SN=1 기준)
|
-- 4. RESCUE_SCENARIO 시드 데이터 (10건, RESCUE_OPS_SN=1 기준)
|
||||||
|
-- 긴급구난 모델 이론 기반 시간 단계별 시나리오
|
||||||
|
-- - 손상복원성(Damage Stability): GM, 횡경사, 트림 진행
|
||||||
|
-- - 종강도(Longitudinal Strength): BM 비율 모니터링
|
||||||
|
-- - 유출 모델링: 파공부 유출률 변화
|
||||||
|
-- - 부력 잔여량: 침수 구획 확대에 따른 부력 변화
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
INSERT INTO RESCUE_SCENARIO (
|
INSERT INTO RESCUE_SCENARIO (
|
||||||
RESCUE_OPS_SN, TIME_STEP, SCENARIO_DTM, SVRT_CD,
|
RESCUE_OPS_SN, TIME_STEP, SCENARIO_DTM, SVRT_CD,
|
||||||
GM_M, LIST_DEG, TRIM_M, BUOYANCY_PCT, OIL_RATE_LPM, BM_RATIO_PCT,
|
GM_M, LIST_DEG, TRIM_M, BUOYANCY_PCT, OIL_RATE_LPM, BM_RATIO_PCT,
|
||||||
DESCRIPTION, COMPARTMENTS, ASSESSMENT, ACTIONS, SORT_ORD
|
DESCRIPTION, COMPARTMENTS, ASSESSMENT, ACTIONS, SORT_ORD
|
||||||
) VALUES
|
) VALUES
|
||||||
|
-- S-01: 사고 발생 (Initial Impact)
|
||||||
|
-- 충돌 직후 초기 손상 상태. 손상복원성 이론에 따라 파공부 침수 시작, GM 급락
|
||||||
(
|
(
|
||||||
1, 'T+0h', '2024-10-27 10:30:00+09', 'CRITICAL',
|
1, 'T+0h', '2024-10-27 10:30:00+09', 'CRITICAL',
|
||||||
0.8, 15.0, 2.5, 30.0, 100.0, 92.0,
|
0.8, 15.0, 2.5, 30.0, 100.0, 92.0,
|
||||||
'좌현 35° 충돌로 No.1P 화물탱크 파공, 벙커C유 유출 개시. 좌현 경사 15°, GM 위험수준.',
|
'좌현 35° 충돌로 No.1P 화물탱크 파공. 벙커C유 유출 개시. 손상복원성 분석: 초기 GM 0.8m으로 IMO 기준(1.0m) 미달, 복원력 위험 판정.',
|
||||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"BREACHED","color":"var(--red)"},{"name":"#2 Port Tank","status":"RISK","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"BREACHED","color":"var(--red)"},{"name":"#2 Port Tank","status":"RISK","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
||||||
'[{"label":"복원력","value":"위험 (GM < 1.0m)","color":"var(--red)"},{"label":"유출 위험","value":"활발 유출중","color":"var(--red)"},{"label":"선체 강도","value":"BM 92% (경계)","color":"var(--orange)"},{"label":"승선인원","value":"15/20 확인, 5명 수색중","color":"var(--red)"}]',
|
'[{"label":"복원력","value":"위험 (GM 0.8m < IMO 1.0m)","color":"var(--red)"},{"label":"유출 위험","value":"활발 유출중 (100 L/min)","color":"var(--red)"},{"label":"선체 강도","value":"BM 92% (경계)","color":"var(--orange)"},{"label":"승선인원","value":"15/20 확인, 5명 수색중","color":"var(--red)"}]',
|
||||||
'[{"time":"10:30","text":"충돌 발생, VHF Ch.16 조난 통보","color":"var(--red)"},{"time":"10:35","text":"해경 3009함 출동 지시","color":"var(--orange)"},{"time":"10:42","text":"인근 선박 구조 활동 개시","color":"var(--cyan)"},{"time":"10:50","text":"유출유 방제선 배치 요청","color":"var(--orange)"}]',
|
'[{"time":"10:30","text":"충돌 발생, VHF Ch.16 조난 통보 (GMDSS DSC Alert)","color":"var(--red)"},{"time":"10:32","text":"EPIRB 자동 발신 확인","color":"var(--red)"},{"time":"10:35","text":"해경 3009함 출동 지시","color":"var(--orange)"},{"time":"10:42","text":"인근 선박 구조 활동 개시","color":"var(--cyan)"}]',
|
||||||
1
|
1
|
||||||
),
|
),
|
||||||
|
-- S-02: 초동 손상 평가 (Emergency Damage Assessment)
|
||||||
|
-- 잠수사 투입, 파공부 규모 확인. 침수 진행 모델링: 파공면적 A, 수두차 h 기반 유입률 Q=Cd·A·√(2gh)
|
||||||
(
|
(
|
||||||
1, 'T+2h', '2024-10-27 12:30:00+09', 'HIGH',
|
1, 'T+30m', '2024-10-27 11:00:00+09', 'CRITICAL',
|
||||||
0.6, 18.0, 3.2, 25.0, 150.0, 88.0,
|
0.7, 17.0, 2.8, 28.0, 120.0, 90.0,
|
||||||
'침수 확대로 경사 증가, 유출량 증가 추세. 긴급 이초 작업 검토 필요.',
|
'잠수사 수중 조사 결과 좌현 No.1P 파공 크기 1.2m×0.8m 확인. Bernoulli 유입률 모델 적용: 수두차 4.5m 기준 유입률 약 2.1㎥/min. 30분 경과 침수량 추정 63㎥.',
|
||||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"BREACHED","color":"var(--red)"},{"name":"#2 Port Tank","status":"RISK","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
||||||
'[{"label":"복원력","value":"위험 (GM 0.6m)","color":"var(--red)"},{"label":"유출 위험","value":"증가 추세","color":"var(--red)"},{"label":"선체 강도","value":"BM 88%","color":"var(--orange)"},{"label":"승선인원","value":"전원 퇴선 완료","color":"var(--green)"}]',
|
'[{"label":"복원력","value":"악화 (GM 0.7m, GZ 커브 감소)","color":"var(--red)"},{"label":"유출 위험","value":"증가 (120 L/min)","color":"var(--red)"},{"label":"선체 강도","value":"BM 90% — 종강도 모니터링 개시","color":"var(--orange)"},{"label":"승선인원","value":"15명 퇴선, 5명 수색중","color":"var(--red)"}]',
|
||||||
'[{"time":"12:00","text":"2차 침수 확인 (#2 PT)","color":"var(--red)"},{"time":"12:15","text":"긴급 이초 작업 개시","color":"var(--orange)"},{"time":"12:20","text":"오일펜스 1차 전개 완료","color":"var(--cyan)"},{"time":"12:30","text":"항공기 유출유 촬영 요청","color":"var(--cyan)"}]',
|
'[{"time":"10:50","text":"잠수사 투입, 수중 손상 조사 개시","color":"var(--cyan)"},{"time":"10:55","text":"파공 규모 확인: 1.2m×0.8m, 수선 하 2.5m","color":"var(--red)"},{"time":"11:00","text":"손상복원성 재계산 — IMO Res.A.749 기준 위험","color":"var(--red)"},{"time":"11:00","text":"유출유 방제선 배치 요청","color":"var(--orange)"}]',
|
||||||
2
|
2
|
||||||
),
|
),
|
||||||
|
-- S-03: 구조 작전 개시 (SAR Operations Initiated)
|
||||||
|
-- 해경 함정 현장 도착, 인명 구조 우선. GM 지속 하락, 복원력 한계 접근
|
||||||
(
|
(
|
||||||
1, 'T+6h', '2024-10-27 16:30:00+09', 'HIGH',
|
1, 'T+1h', '2024-10-27 11:30:00+09', 'CRITICAL',
|
||||||
0.4, 12.0, 2.8, 35.0, 80.0, 90.0,
|
0.65, 18.5, 3.0, 26.0, 135.0, 89.0,
|
||||||
'평형수 이동으로 경사 일부 복원. 유출률 감소 추세.',
|
'해경 3009함 현장 도착, SAR 작전 개시. 표류 예측 모델(Leeway Model) 적용: 풍속 8m/s, 해류 2.5kn NE 조건에서 실종자 표류 반경 1.2nm 산정. GZ 커브 분석: 최대 복원력 각도 25°로 감소.',
|
||||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"RISK","color":"var(--orange)"}]',
|
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODING","color":"var(--red)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
||||||
'[{"label":"복원력","value":"개선 추세 (GM 0.4m)","color":"var(--orange)"},{"label":"유출 위험","value":"감소 추세","color":"var(--orange)"},{"label":"선체 강도","value":"BM 90%","color":"var(--orange)"},{"label":"구조 상황","value":"구조 작전 진행중","color":"var(--cyan)"}]',
|
'[{"label":"복원력","value":"한계 접근 (GM 0.65m, GZ_max 25°)","color":"var(--red)"},{"label":"유출 위험","value":"파공 확대 우려 (135 L/min)","color":"var(--red)"},{"label":"선체 강도","value":"BM 89% — Hogging 모멘트 증가","color":"var(--orange)"},{"label":"인명구조","value":"실종 5명 수색중, 표류 반경 1.2nm","color":"var(--red)"}]',
|
||||||
'[{"time":"14:00","text":"평형수 이동 작업 개시","color":"var(--cyan)"},{"time":"15:00","text":"해상크레인 도착","color":"var(--cyan)"},{"time":"15:30","text":"잔류유 이적 작업 개시","color":"var(--orange)"},{"time":"16:30","text":"예인준비 완료","color":"var(--green)"}]',
|
'[{"time":"11:10","text":"해경 3009함 현장 도착, SAR 구역 설정","color":"var(--cyan)"},{"time":"11:15","text":"실종자 Leeway 표류 예측 모델 적용","color":"var(--cyan)"},{"time":"11:20","text":"회전익 항공기 수색 개시 (R=1.2nm)","color":"var(--cyan)"},{"time":"11:30","text":"#2 Port Tank 2차 침수 징후 감지","color":"var(--red)"}]',
|
||||||
3
|
3
|
||||||
),
|
),
|
||||||
|
-- S-04: 침수 확대 및 복원력 위기 (Flooding Progression & Stability Crisis)
|
||||||
|
-- 2차 구획 침수, 자유표면효과(Free Surface Effect) 반영 GM 급락
|
||||||
(
|
(
|
||||||
1, 'T+12h', '2024-10-27 22:30:00+09', 'MEDIUM',
|
1, 'T+2h', '2024-10-27 12:30:00+09', 'CRITICAL',
|
||||||
0.6, 8.0, 1.5, 50.0, 30.0, 94.0,
|
0.5, 20.0, 3.5, 22.0, 160.0, 86.0,
|
||||||
'예인 작업 진행중, 선체 안정화 확인. 유출 대부분 차단.',
|
'격벽 관통으로 #2 Port Tank 침수 확대. 자유표면효과(FSE) 보정: GM_fluid = GM_solid - Σ(i/∇) = 0.5m. 종강도 분석: 중앙부 Sagging 모멘트 허용치 86% 도달. 침몰 위험 단계 진입.',
|
||||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
||||||
'[{"label":"복원력","value":"안정 (GM 0.6m)","color":"var(--orange)"},{"label":"유출 위험","value":"대부분 차단","color":"var(--green)"},{"label":"선체 강도","value":"BM 94%","color":"var(--green)"},{"label":"예인 상태","value":"목포항 예인 진행중","color":"var(--cyan)"}]',
|
'[{"label":"복원력","value":"위기 (GM 0.5m, FSE 보정 후)","color":"var(--red)"},{"label":"유출 위험","value":"최대치 접근 (160 L/min)","color":"var(--red)"},{"label":"선체 강도","value":"BM 86% — Sagging 허용치 경고","color":"var(--red)"},{"label":"승선인원","value":"실종 3명 발견, 2명 수색 지속","color":"var(--orange)"}]',
|
||||||
'[{"time":"18:00","text":"예인 개시 (목포항 방향)","color":"var(--cyan)"},{"time":"19:00","text":"유출유 차단 확인","color":"var(--green)"},{"time":"20:00","text":"야간 감시 체제 전환","color":"var(--orange)"},{"time":"22:30","text":"예인 50% 진행","color":"var(--cyan)"}]',
|
'[{"time":"12:00","text":"#2 Port Tank 격벽 관통 침수 확인","color":"var(--red)"},{"time":"12:10","text":"자유표면효과(FSE) 보정 재계산","color":"var(--red)"},{"time":"12:15","text":"긴급 Counter-Flooding 검토","color":"var(--orange)"},{"time":"12:30","text":"실종자 3명 추가 발견 구조","color":"var(--green)"}]',
|
||||||
4
|
4
|
||||||
),
|
),
|
||||||
|
-- S-05: 응급 복원 작업 (Emergency Counter-Flooding)
|
||||||
|
-- Counter-Flooding 이론: 반대편 구획 의도적 침수로 횡경사 교정
|
||||||
|
(
|
||||||
|
1, 'T+3h', '2024-10-27 13:30:00+09', 'HIGH',
|
||||||
|
0.55, 16.0, 3.2, 25.0, 140.0, 87.0,
|
||||||
|
'Counter-Flooding 실시: #3 Stbd Tank에 평형수 280톤 주입하여 횡경사 20°→16° 교정. 복원력 일시적 개선. 종강도: Counter-Flooding으로 중량 재배분, BM 87% 유지. 유출률 감소 추세.',
|
||||||
|
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"BALLASTED","color":"var(--orange)"}]',
|
||||||
|
'[{"label":"복원력","value":"개선 중 (GM 0.55m, 경사 16°)","color":"var(--orange)"},{"label":"유출 위험","value":"감소 추세 (140 L/min)","color":"var(--orange)"},{"label":"선체 강도","value":"BM 87% — Counter-Flooding 영향 평가","color":"var(--orange)"},{"label":"구조 상황","value":"실종 2명 수색 지속, 헬기 투입","color":"var(--orange)"}]',
|
||||||
|
'[{"time":"12:45","text":"Counter-Flooding 결정 — #3 Stbd 평형수 주입 개시","color":"var(--orange)"},{"time":"13:00","text":"평형수 280톤 주입, 횡경사 20°→18° 교정 진행","color":"var(--cyan)"},{"time":"13:15","text":"종강도 재계산 — 허용 범위 내 확인","color":"var(--cyan)"},{"time":"13:30","text":"횡경사 16° 안정화, 유출률 감소 확인","color":"var(--green)"}]',
|
||||||
|
5
|
||||||
|
),
|
||||||
|
-- S-06: 선체 안정화 및 잔류유 이적 (Hull Stabilization & Oil Transfer)
|
||||||
|
-- 평형수 조정 완료, 임시 보강. Trim/Stability Booklet 기준 안정 범위 진입
|
||||||
|
(
|
||||||
|
1, 'T+6h', '2024-10-27 16:30:00+09', 'HIGH',
|
||||||
|
0.7, 12.0, 2.5, 32.0, 80.0, 90.0,
|
||||||
|
'임시 수중패치 설치, 유입률 감소. 평형수 재조정으로 GM 0.7m 회복. Trim/Stability Booklet 기준 예인 가능 최소 조건(GM≥0.5m, List≤15°) 충족. 잔류유 이적선(M/T) 배치.',
|
||||||
|
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"PATCHED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"BALLASTED","color":"var(--orange)"}]',
|
||||||
|
'[{"label":"복원력","value":"개선 (GM 0.7m, 예인 가능 조건 충족)","color":"var(--orange)"},{"label":"유출 위험","value":"수중패치 효과 (80 L/min)","color":"var(--orange)"},{"label":"선체 강도","value":"BM 90% — 안정 범위","color":"var(--green)"},{"label":"구조 상황","value":"전원 구조 완료 (실종 2명 발견)","color":"var(--green)"}]',
|
||||||
|
'[{"time":"14:00","text":"수중패치 설치 작업 개시","color":"var(--cyan)"},{"time":"14:30","text":"잠수사 수중패치 설치 완료","color":"var(--green)"},{"time":"15:00","text":"해상크레인 도착, 잔류유 이적 준비","color":"var(--cyan)"},{"time":"16:30","text":"잔류유 1차 이적 완료 (약 45kL), 예인 준비 개시","color":"var(--green)"}]',
|
||||||
|
6
|
||||||
|
),
|
||||||
|
-- S-07: 오일 방제 전개 (Oil Boom Deployment & Containment)
|
||||||
|
-- 방제 이론: 오일붐 2중 전개, 유회수기 배치, 확산 모델 기반 방제 구역 설정
|
||||||
|
(
|
||||||
|
1, 'T+8h', '2024-10-27 18:30:00+09', 'MEDIUM',
|
||||||
|
0.8, 10.0, 2.0, 38.0, 55.0, 91.0,
|
||||||
|
'오일붐 2중 전개 완료, 유회수기 3대 가동. 유출유 확산 예측 모델(GNOME) 적용: 풍향 NE 8m/s, 해류 2.5kn 조건에서 12시간 후 확산 면적 2.3km² 예측. 기계적 회수율 35% 달성.',
|
||||||
|
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"PATCHED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"BALLASTED","color":"var(--orange)"}]',
|
||||||
|
'[{"label":"복원력","value":"안정 (GM 0.8m)","color":"var(--orange)"},{"label":"유출 위험","value":"방제 진행 (55 L/min, 회수율 35%)","color":"var(--orange)"},{"label":"선체 강도","value":"BM 91%","color":"var(--green)"},{"label":"방제 현황","value":"오일붐 2중, 유회수기 3대 가동","color":"var(--cyan)"}]',
|
||||||
|
'[{"time":"17:00","text":"오일붐 1차 전개 (500m)","color":"var(--cyan)"},{"time":"17:30","text":"오일붐 2차 전개 (300m, 이중 방어선)","color":"var(--cyan)"},{"time":"17:45","text":"유회수기 3대 배치·가동 개시","color":"var(--cyan)"},{"time":"18:30","text":"GNOME 확산 예측 갱신 — 방제 구역 재설정","color":"var(--orange)"}]',
|
||||||
|
7
|
||||||
|
),
|
||||||
|
-- S-08: 예인 작업 개시 (Towing Operation Commenced)
|
||||||
|
-- 예인 이론: 예인 저항 계산, 기상·해상 조건 판단, 예인 경로 최적화
|
||||||
|
(
|
||||||
|
1, 'T+12h', '2024-10-27 22:30:00+09', 'MEDIUM',
|
||||||
|
0.9, 8.0, 1.5, 45.0, 30.0, 94.0,
|
||||||
|
'예인 개시. 예인 저항 계산: Rt = 1/2·ρ·Cd·A·V² 기반 예인선 4,000HP급 배정. 예인 경로: 현 위치→목포항 직선 42nm, 예인 속도 3kn 기준 ETA 14시간. 야간 감시 체제 전환.',
|
||||||
|
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"PATCHED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"BALLASTED","color":"var(--orange)"}]',
|
||||||
|
'[{"label":"복원력","value":"안정 (GM 0.9m)","color":"var(--orange)"},{"label":"유출 위험","value":"억제 중 (30 L/min)","color":"var(--green)"},{"label":"선체 강도","value":"BM 94% — 예인 하중 반영","color":"var(--green)"},{"label":"예인 상태","value":"목포항 방향, ETA 14h, 3kn","color":"var(--cyan)"}]',
|
||||||
|
'[{"time":"18:00","text":"예인 접속 완료, 예인삭 250m 전개","color":"var(--cyan)"},{"time":"18:30","text":"예인 개시 (목포항 방향, 3kn)","color":"var(--cyan)"},{"time":"20:00","text":"야간 감시 체제 전환 (2시간 교대)","color":"var(--orange)"},{"time":"22:30","text":"예인 진행률 30%, 선체 상태 안정","color":"var(--green)"}]',
|
||||||
|
8
|
||||||
|
),
|
||||||
|
-- S-09: 이동 중 감시 및 안정성 유지 (Transit Monitoring)
|
||||||
|
-- 예인 중 동적 안정성 모니터링: 파랑 응답(RAO) 기반 횡동요 예측
|
||||||
|
(
|
||||||
|
1, 'T+18h', '2024-10-28 04:30:00+09', 'MEDIUM',
|
||||||
|
1.0, 5.0, 1.0, 55.0, 15.0, 96.0,
|
||||||
|
'예인 진행률 65%. 파랑 응답 분석(RAO): 유의파고 1.2m, 주기 6s 조건에서 횡동요 진폭 ±3° 예측 — 안전 범위 내. 잔류 유출률 15 L/min으로 대폭 감소. 선체 안정성 지속 개선.',
|
||||||
|
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"PATCHED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"STABLE","color":"var(--green)"}]',
|
||||||
|
'[{"label":"복원력","value":"양호 (GM 1.0m, IMO 기준 충족)","color":"var(--green)"},{"label":"유출 위험","value":"미량 유출 (15 L/min)","color":"var(--green)"},{"label":"선체 강도","value":"BM 96% — 정상 범위","color":"var(--green)"},{"label":"예인 상태","value":"진행률 65%, ETA 5.5h","color":"var(--cyan)"}]',
|
||||||
|
'[{"time":"00:00","text":"야간 예인 정상 진행, 기상 양호","color":"var(--green)"},{"time":"02:00","text":"파랑 응답 분석 — 안전 범위 확인","color":"var(--green)"},{"time":"03:00","text":"잔류유 유출률 15 L/min 확인","color":"var(--green)"},{"time":"04:30","text":"목포항 VTS 통보, 입항 예정 협의","color":"var(--cyan)"}]',
|
||||||
|
9
|
||||||
|
),
|
||||||
|
-- S-10: 상황 종료 및 사후 평가 (Resolution & Post-Assessment)
|
||||||
|
-- 접안 완료, 잔류유 이적, 사후 안정성 평가
|
||||||
(
|
(
|
||||||
1, 'T+24h', '2024-10-28 10:30:00+09', 'RESOLVED',
|
1, 'T+24h', '2024-10-28 10:30:00+09', 'RESOLVED',
|
||||||
1.2, 3.0, 0.5, 75.0, 5.0, 98.0,
|
1.2, 3.0, 0.5, 75.0, 5.0, 98.0,
|
||||||
'목포항 도착, 선체 안정. 잔류유 이적 완료.',
|
'목포항 접안 완료. 잔류유 전량 이적(총 120kL). 최종 손상복원성 평가: GM 1.2m으로 IMO 기준 충족, 횡경사 3° 잔류. 종강도 BM 98% 정상. 방제 총 회수량 85kL (회수율 71%). 상황 종료 선포.',
|
||||||
'[{"name":"#1 FP Tank","status":"SEALED","color":"var(--orange)"},{"name":"#1 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
'[{"name":"#1 FP Tank","status":"SEALED","color":"var(--orange)"},{"name":"#1 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"STABLE","color":"var(--green)"}]',
|
||||||
'[{"label":"복원력","value":"안전 (GM 1.2m)","color":"var(--green)"},{"label":"유출 위험","value":"차단 완료","color":"var(--green)"},{"label":"선체 강도","value":"BM 98% 정상","color":"var(--green)"},{"label":"예인 상태","value":"목포항 접안 완료","color":"var(--green)"}]',
|
'[{"label":"복원력","value":"안전 (GM 1.2m, IMO 기준 초과)","color":"var(--green)"},{"label":"유출 위험","value":"차단 완료 (잔류 5 L/min)","color":"var(--green)"},{"label":"선체 강도","value":"BM 98% 정상","color":"var(--green)"},{"label":"최종 상태","value":"접안 완료, 잔류유 이적 완료","color":"var(--green)"}]',
|
||||||
'[{"time":"06:00","text":"목포항 접근","color":"var(--cyan)"},{"time":"08:00","text":"도선사 승선, 접안 개시","color":"var(--cyan)"},{"time":"09:30","text":"접안 완료","color":"var(--green)"},{"time":"10:30","text":"잔류유 이적 완료, 상황 종료","color":"var(--green)"}]',
|
'[{"time":"06:00","text":"목포항 접근, 도선사 대기","color":"var(--cyan)"},{"time":"08:00","text":"도선사 승선, 접안 개시","color":"var(--cyan)"},{"time":"09:30","text":"접안 완료, 잔류유 이적선 접현","color":"var(--green)"},{"time":"10:30","text":"잔류유 전량 이적 완료, 상황 종료 선포","color":"var(--green)"}]',
|
||||||
5
|
10
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,6 +4,16 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2026-04-14]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 디자인 시스템: HNS·사건사고·확산예측·SCAT·기상 탭 디자인 시스템 토큰 전면 적용
|
||||||
|
- 관리자: 비식별화조치 메뉴 및 패널 추가
|
||||||
|
- 긴급구난/예측도 OSM 지도 적용 및 관리자 패널 추가
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- 디자인 시스템: 폰트 업스케일 토큰 값 변경 및 전체 탭 색상·폰트 통일
|
||||||
|
|
||||||
## [2026-04-13]
|
## [2026-04-13]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
|
|||||||
@ -120,7 +120,7 @@ export function LoginPage() {
|
|||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<span
|
<span
|
||||||
className="absolute text-sm text-fg-disabled pointer-events-none"
|
className="absolute text-body-2 text-fg-disabled pointer-events-none"
|
||||||
style={{ left: 12, top: '50%', transform: 'translateY(-50%)' }}
|
style={{ left: 12, top: '50%', transform: 'translateY(-50%)' }}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@ -174,7 +174,7 @@ export function LoginPage() {
|
|||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<span
|
<span
|
||||||
className="absolute text-sm text-fg-disabled pointer-events-none"
|
className="absolute text-body-2 text-fg-disabled pointer-events-none"
|
||||||
style={{ left: 12, top: '50%', transform: 'translateY(-50%)' }}
|
style={{ left: 12, top: '50%', transform: 'translateY(-50%)' }}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@ -249,7 +249,7 @@ export function LoginPage() {
|
|||||||
color: '#67e8f9',
|
color: '#67e8f9',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-sm shrink-0 mt-px">
|
<span className="text-body-2 shrink-0 mt-px">
|
||||||
<svg
|
<svg
|
||||||
width="14"
|
width="14"
|
||||||
height="14"
|
height="14"
|
||||||
@ -303,7 +303,7 @@ export function LoginPage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="w-full text-color-accent text-sm font-bold rounded-md border"
|
className="w-full text-color-accent text-body-2 font-bold rounded-md border"
|
||||||
style={{
|
style={{
|
||||||
padding: '12px',
|
padding: '12px',
|
||||||
background: isLoading
|
background: isLoading
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export function SubMenuBar({ activeMainTab }: SubMenuBarProps) {
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => setActiveSubTab(item.id)}
|
onClick={() => setActiveSubTab(item.id)}
|
||||||
className={`
|
className={`
|
||||||
px-4 py-2.5 text-title-5 font-medium transition-all duration-200
|
px-4 py-2.5 text-title-4 font-medium transition-all duration-200
|
||||||
font-korean tracking-navigation
|
font-korean tracking-navigation
|
||||||
${activeSubTab === item.id ? 'text-color-accent' : 'text-fg-sub hover:text-fg'}
|
${activeSubTab === item.id ? 'text-color-accent' : 'text-fg-sub hover:text-fg'}
|
||||||
`}
|
`}
|
||||||
|
|||||||
@ -56,11 +56,7 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
|
|||||||
className="flex items-center hover:opacity-80 transition-opacity cursor-pointer"
|
className="flex items-center hover:opacity-80 transition-opacity cursor-pointer"
|
||||||
title="홈으로 이동"
|
title="홈으로 이동"
|
||||||
>
|
>
|
||||||
<img
|
<img src="/wing_logo_white.svg" alt="WING 해양환경 위기대응" className="h-5 wing-logo" />
|
||||||
src="/wing_logo_white.svg"
|
|
||||||
alt="WING 해양환경 위기대응"
|
|
||||||
className="h-3.5 wing-logo"
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
@ -87,7 +83,7 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
|
|||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
title={tab.label}
|
title={tab.label}
|
||||||
className={`
|
className={`
|
||||||
px-2.5 xl:px-4 py-2 text-title-4 font-bold transition-all duration-200
|
px-2.5 xl:px-4 py-2 text-title-2 font-bold transition-all duration-200
|
||||||
font-korean tracking-navigation border-b-2 border-transparent
|
font-korean tracking-navigation border-b-2 border-transparent
|
||||||
${isIncident ? 'ml-1' : ''}
|
${isIncident ? 'ml-1' : ''}
|
||||||
${isMonitor ? 'ml-1 flex items-center gap-1.5' : ''}
|
${isMonitor ? 'ml-1 flex items-center gap-1.5' : ''}
|
||||||
@ -127,7 +123,7 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
|
|||||||
{/* Right Section */}
|
{/* Right Section */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Status Badge */}
|
{/* Status Badge */}
|
||||||
{/* <div className="flex items-center gap-2 px-3 py-1.5 bg-[rgba(239,68,68,0.1)] border border-[rgba(239,68,68,0.2)] rounded-sm text-xs font-medium text-color-danger animate-pulse">
|
{/* <div className="flex items-center gap-2 px-3 py-1.5 bg-[rgba(239,68,68,0.1)] border border-[rgba(239,68,68,0.2)] rounded-sm text-caption font-medium text-color-danger animate-pulse">
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-color-danger animate-pulse" />
|
<div className="w-1.5 h-1.5 rounded-full bg-color-danger animate-pulse" />
|
||||||
사고 진행중
|
사고 진행중
|
||||||
</div> */}
|
</div> */}
|
||||||
|
|||||||
@ -141,7 +141,7 @@ export function BacktrackReplayBar({
|
|||||||
className="w-2 h-2 rounded-full bg-color-tertiary"
|
className="w-2 h-2 rounded-full bg-color-tertiary"
|
||||||
style={{ boxShadow: '0 0 8px rgba(168,85,247,0.5)' }}
|
style={{ boxShadow: '0 0 8px rgba(168,85,247,0.5)' }}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs font-bold">역추적 리플레이</span>
|
<span className="text-caption font-bold">역추적 리플레이</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
@ -180,7 +180,7 @@ export function BacktrackReplayBar({
|
|||||||
{/* Play/Pause */}
|
{/* Play/Pause */}
|
||||||
<button
|
<button
|
||||||
onClick={handlePlayClick}
|
onClick={handlePlayClick}
|
||||||
className="shrink-0 w-9 h-9 rounded-full flex items-center justify-center text-sm cursor-pointer"
|
className="shrink-0 w-9 h-9 rounded-full flex items-center justify-center text-body-2 cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
background: isPlaying ? 'var(--color-tertiary)' : 'rgba(168,85,247,0.15)',
|
background: isPlaying ? 'var(--color-tertiary)' : 'rgba(168,85,247,0.15)',
|
||||||
border: `2px solid ${isPlaying ? 'var(--color-tertiary)' : 'rgba(168,85,247,0.4)'}`,
|
border: `2px solid ${isPlaying ? 'var(--color-tertiary)' : 'rgba(168,85,247,0.4)'}`,
|
||||||
|
|||||||
@ -414,7 +414,10 @@ export function MapView({
|
|||||||
longitude: lng,
|
longitude: lng,
|
||||||
latitude: lat,
|
latitude: lat,
|
||||||
content: (
|
content: (
|
||||||
<div className="text-xs font-korean" style={{ minWidth: '180px', maxWidth: '260px' }}>
|
<div
|
||||||
|
className="text-caption font-korean"
|
||||||
|
style={{ minWidth: '180px', maxWidth: '260px' }}
|
||||||
|
>
|
||||||
<div className="font-semibold mb-1.5 pb-1 border-b border-[rgba(0,0,0,0.12)]">
|
<div className="font-semibold mb-1.5 pb-1 border-b border-[rgba(0,0,0,0.12)]">
|
||||||
{String(category ?? '민감자원')}
|
{String(category ?? '민감자원')}
|
||||||
</div>
|
</div>
|
||||||
@ -535,7 +538,7 @@ export function MapView({
|
|||||||
longitude: d.lon,
|
longitude: d.lon,
|
||||||
latitude: d.lat,
|
latitude: d.lat,
|
||||||
content: (
|
content: (
|
||||||
<div className="text-xs">
|
<div className="text-caption">
|
||||||
<strong>
|
<strong>
|
||||||
{modelKey} 입자 #{(d.particle ?? 0) + 1}
|
{modelKey} 입자 #{(d.particle ?? 0) + 1}
|
||||||
</strong>
|
</strong>
|
||||||
@ -604,7 +607,7 @@ export function MapView({
|
|||||||
longitude: info.coordinate?.[0] ?? 0,
|
longitude: info.coordinate?.[0] ?? 0,
|
||||||
latitude: info.coordinate?.[1] ?? 0,
|
latitude: info.coordinate?.[1] ?? 0,
|
||||||
content: (
|
content: (
|
||||||
<div className="text-xs" style={{ minWidth: '140px' }}>
|
<div className="text-caption" style={{ minWidth: '140px' }}>
|
||||||
<strong style={{ color: PRIORITY_COLORS[d.priority] }}>{d.name}</strong>
|
<strong style={{ color: PRIORITY_COLORS[d.priority] }}>{d.name}</strong>
|
||||||
<br />
|
<br />
|
||||||
우선순위: {PRIORITY_LABELS[d.priority] || d.priority}
|
우선순위: {PRIORITY_LABELS[d.priority] || d.priority}
|
||||||
@ -919,7 +922,7 @@ export function MapView({
|
|||||||
longitude: info.coordinate[0],
|
longitude: info.coordinate[0],
|
||||||
latitude: info.coordinate[1],
|
latitude: info.coordinate[1],
|
||||||
content: (
|
content: (
|
||||||
<div className="text-xs leading-relaxed" style={{ minWidth: 180 }}>
|
<div className="text-caption leading-relaxed" style={{ minWidth: 180 }}>
|
||||||
<strong className="text-color-warning">
|
<strong className="text-color-warning">
|
||||||
{dispersionResult.substance} 대기확산 면적
|
{dispersionResult.substance} 대기확산 면적
|
||||||
</strong>
|
</strong>
|
||||||
@ -1009,7 +1012,7 @@ export function MapView({
|
|||||||
longitude: d.lon,
|
longitude: d.lon,
|
||||||
latitude: d.lat,
|
latitude: d.lat,
|
||||||
content: (
|
content: (
|
||||||
<div className="text-xs" style={{ minWidth: '130px' }}>
|
<div className="text-caption" style={{ minWidth: '130px' }}>
|
||||||
<div className="flex items-center gap-1 mb-1">
|
<div className="flex items-center gap-1 mb-1">
|
||||||
<span>{SENSITIVE_ICONS[d.type]}</span>
|
<span>{SENSITIVE_ICONS[d.type]}</span>
|
||||||
<strong style={{ color: SENSITIVE_COLORS[d.type] }}>{d.name}</strong>
|
<strong style={{ color: SENSITIVE_COLORS[d.type] }}>{d.name}</strong>
|
||||||
@ -1458,19 +1461,19 @@ function MapControls({ center, zoom }: { center: [number, number]; zoom: number
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => map?.zoomIn()}
|
onClick={() => map?.zoomIn()}
|
||||||
className="w-[28px] h-[28px] bg-[rgba(18,25,41,0.65)] backdrop-blur-sm border border-[rgba(30,42,66,0.5)] rounded-sm text-fg-sub flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all text-xs"
|
className="w-[28px] h-[28px] bg-[color-mix(in_srgb,var(--bg-elevated)_85%,transparent)] backdrop-blur-sm border border-stroke rounded-sm text-fg-sub flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all text-caption"
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => map?.zoomOut()}
|
onClick={() => map?.zoomOut()}
|
||||||
className="w-[28px] h-[28px] bg-[rgba(18,25,41,0.65)] backdrop-blur-sm border border-[rgba(30,42,66,0.5)] rounded-sm text-fg-sub flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all text-xs"
|
className="w-[28px] h-[28px] bg-[color-mix(in_srgb,var(--bg-elevated)_85%,transparent)] backdrop-blur-sm border border-stroke rounded-sm text-fg-sub flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all text-caption"
|
||||||
>
|
>
|
||||||
−
|
−
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => map?.flyTo({ center: [center[1], center[0]], zoom, duration: 1000 })}
|
onClick={() => map?.flyTo({ center: [center[1], center[0]], zoom, duration: 1000 })}
|
||||||
className="w-[28px] h-[28px] bg-[rgba(18,25,41,0.65)] backdrop-blur-sm border border-[rgba(30,42,66,0.5)] rounded-sm text-fg-sub flex items-center justify-center hover:text-fg transition-all text-caption"
|
className="w-[28px] h-[28px] bg-[color-mix(in_srgb,var(--bg-elevated)_85%,transparent)] backdrop-blur-sm border border-stroke rounded-sm text-fg-sub flex items-center justify-center hover:text-fg transition-all text-caption"
|
||||||
>
|
>
|
||||||
🎯
|
🎯
|
||||||
</button>
|
</button>
|
||||||
@ -1575,7 +1578,7 @@ function MapLegend({
|
|||||||
className="flex items-center gap-1.5 mt-2 rounded"
|
className="flex items-center gap-1.5 mt-2 rounded"
|
||||||
style={{ padding: '6px', background: 'rgba(168,85,247,0.08)' }}
|
style={{ padding: '6px', background: 'rgba(168,85,247,0.08)' }}
|
||||||
>
|
>
|
||||||
<div className="text-xs">🧭</div>
|
<div className="text-caption">🧭</div>
|
||||||
<span className="text-caption text-fg-disabled">풍향 (방사형)</span>
|
<span className="text-caption text-fg-disabled">풍향 (방사형)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1888,7 +1891,9 @@ function BacktrackReplayBar({
|
|||||||
minWidth: '340px',
|
minWidth: '340px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="text-sm text-color-tertiary font-mono font-bold">{progress.toFixed(0)}%</div>
|
<div className="text-body-2 text-color-tertiary font-mono font-bold">
|
||||||
|
{progress.toFixed(0)}%
|
||||||
|
</div>
|
||||||
<div className="flex-1 h-1 bg-border relative rounded-[2px]">
|
<div className="flex-1 h-1 bg-border relative rounded-[2px]">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-[2px]"
|
className="h-full rounded-[2px]"
|
||||||
|
|||||||
@ -79,15 +79,15 @@
|
|||||||
--font-size-title-1: 1.125rem;
|
--font-size-title-1: 1.125rem;
|
||||||
--font-size-subtitle: 0.9375rem;
|
--font-size-subtitle: 0.9375rem;
|
||||||
--font-size-title-2: 1rem;
|
--font-size-title-2: 1rem;
|
||||||
--font-size-title-3: 0.875rem;
|
--font-size-title-3: 1rem;
|
||||||
--font-size-title-4: 0.8125rem;
|
--font-size-title-4: 0.875rem;
|
||||||
--font-size-title-5: 0.75rem;
|
--font-size-title-5: 0.8125rem;
|
||||||
--font-size-title-6: 0.6875rem;
|
--font-size-title-6: 0.75rem;
|
||||||
--font-size-body-1: 0.875rem;
|
--font-size-body-1: 1rem;
|
||||||
--font-size-body-2: 0.8125rem;
|
--font-size-body-2: 0.875rem;
|
||||||
--font-size-label-1: 0.75rem;
|
--font-size-label-1: 0.8125rem;
|
||||||
--font-size-label-2: 0.6875rem;
|
--font-size-label-2: 0.75rem;
|
||||||
--font-size-caption: 0.6875rem;
|
--font-size-caption: 0.75rem;
|
||||||
/* typography — font-weight */
|
/* typography — font-weight */
|
||||||
--font-weight-thin: 300;
|
--font-weight-thin: 300;
|
||||||
--font-weight-regular: 400;
|
--font-weight-regular: 400;
|
||||||
|
|||||||
@ -45,6 +45,22 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══ Incidents 사고 팝업 ✕ 버튼 — 라이트 지도 기준 검은색 고정 ═══ */
|
||||||
|
.incident-popup .maplibregl-popup-close-button {
|
||||||
|
color: #1a1d21;
|
||||||
|
background: transparent;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 16px;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
}
|
||||||
|
.incident-popup .maplibregl-popup-close-button:hover {
|
||||||
|
color: #000;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
/* ═══ Scrollbar ═══ */
|
/* ═══ Scrollbar ═══ */
|
||||||
.scrollbar-thin {
|
.scrollbar-thin {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
@ -78,7 +94,7 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: var(--fg-default);
|
color: var(--fg-default);
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
font-size: 11px;
|
font-size: 0.75rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,7 +119,7 @@
|
|||||||
|
|
||||||
.prd-date-input,
|
.prd-date-input,
|
||||||
.prd-time-input {
|
.prd-time-input {
|
||||||
font-size: 10px;
|
font-size: 0.75rem;
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -111,7 +127,7 @@
|
|||||||
.prd-time-input::-webkit-datetime-edit {
|
.prd-time-input::-webkit-datetime-edit {
|
||||||
color: var(--fg-sub);
|
color: var(--fg-sub);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 0.75rem;
|
||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,7 +207,7 @@
|
|||||||
background: #1a1f2e;
|
background: #1a1f2e;
|
||||||
color: var(--fg-default);
|
color: var(--fg-default);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
font-size: 11px;
|
font-size: 0.75rem;
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -278,7 +294,7 @@
|
|||||||
|
|
||||||
.combo-item {
|
.combo-item {
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
font-size: 11px;
|
font-size: 0.75rem;
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
color: var(--fg-sub);
|
color: var(--fg-sub);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -309,7 +325,7 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 5px 4px;
|
padding: 5px 4px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
font-size: 9px;
|
font-size: 0.6875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -328,7 +344,7 @@
|
|||||||
|
|
||||||
/* .prd-mc.on::before {
|
/* .prd-mc.on::before {
|
||||||
content: '✓ ';
|
content: '✓ ';
|
||||||
font-size: 9px;
|
font-size: 0.6875rem;
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
} */
|
} */
|
||||||
|
|
||||||
@ -370,7 +386,7 @@
|
|||||||
border: 1px solid rgba(6, 182, 212, 0.2);
|
border: 1px solid rgba(6, 182, 212, 0.2);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
font-size: 9px;
|
font-size: 0.6875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@ -551,7 +567,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tll {
|
.tll {
|
||||||
font-size: 10px;
|
font-size: 0.75rem;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
@ -620,7 +636,7 @@
|
|||||||
border: 1px solid var(--color-boom);
|
border: 1px solid var(--color-boom);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
font-size: 10px;
|
font-size: 0.75rem;
|
||||||
color: var(--color-boom);
|
color: var(--color-boom);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
@ -671,7 +687,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
font-size: 11px;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tlsl {
|
.tlsl {
|
||||||
@ -699,7 +715,7 @@
|
|||||||
border: 1px solid var(--stroke-default);
|
border: 1px solid var(--stroke-default);
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
color: var(--fg-sub);
|
color: var(--fg-sub);
|
||||||
font-size: 11px;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
@ -750,7 +766,7 @@
|
|||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
font-size: 11px;
|
font-size: 0.75rem;
|
||||||
color: var(--fg-sub);
|
color: var(--fg-sub);
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
}
|
}
|
||||||
@ -835,7 +851,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.layer-count {
|
.layer-count {
|
||||||
font-size: 10px;
|
font-size: 0.75rem;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
@ -856,7 +872,7 @@
|
|||||||
border: 1px solid rgba(245, 158, 11, 0.4);
|
border: 1px solid rgba(245, 158, 11, 0.4);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
font-size: 11px;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-boom);
|
color: var(--color-boom);
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
@ -889,7 +905,7 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
outline: none;
|
outline: none;
|
||||||
@ -926,7 +942,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 11px;
|
font-size: 0.75rem;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -936,7 +952,7 @@
|
|||||||
border: 1px solid var(--stroke-default);
|
border: 1px solid var(--stroke-default);
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
font-size: 10px;
|
font-size: 0.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@ -1123,7 +1139,7 @@
|
|||||||
|
|
||||||
.lyr-h1-cnt {
|
.lyr-h1-cnt {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 10px;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@ -1152,7 +1168,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
font-size: 11px;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--fg-sub);
|
color: var(--fg-sub);
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
@ -1176,7 +1192,7 @@
|
|||||||
|
|
||||||
.lyr-h2-cnt {
|
.lyr-h2-cnt {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 10px;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@ -1199,7 +1215,7 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 11px;
|
font-size: 0.75rem;
|
||||||
color: var(--fg-sub);
|
color: var(--fg-sub);
|
||||||
transition:
|
transition:
|
||||||
color 0.15s,
|
color 0.15s,
|
||||||
@ -1215,7 +1231,7 @@
|
|||||||
|
|
||||||
.lyr-cnt {
|
.lyr-cnt {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 10px;
|
font-size: 0.75rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@ -1329,7 +1345,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lyr-ccustom label {
|
.lyr-ccustom label {
|
||||||
font-size: 9px;
|
font-size: 0.6875rem;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
}
|
}
|
||||||
@ -1353,7 +1369,7 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
.lyr-style-label {
|
.lyr-style-label {
|
||||||
font-size: 9px;
|
font-size: 0.6875rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
@ -1370,7 +1386,7 @@
|
|||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
.lyr-style-name {
|
.lyr-style-name {
|
||||||
font-size: 10px;
|
font-size: 0.75rem;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-korean);
|
||||||
min-width: 32px;
|
min-width: 32px;
|
||||||
@ -1395,7 +1411,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.lyr-style-val {
|
.lyr-style-val {
|
||||||
font-size: 9px;
|
font-size: 0.6875rem;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
min-width: 28px;
|
min-width: 28px;
|
||||||
|
|||||||
@ -181,7 +181,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wing-tab {
|
.wing-tab {
|
||||||
@apply flex-1 py-2 px-1 text-xs font-semibold rounded-md text-center cursor-pointer font-korean;
|
@apply flex-1 py-2 px-1 text-caption font-semibold rounded-md text-center cursor-pointer font-korean;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@ -148,7 +148,7 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
|
|||||||
{/* ── 섹션 1: 헤더 ── */}
|
{/* ── 섹션 1: 헤더 ── */}
|
||||||
<div className="pb-10 mb-12 border-b border-solid" style={{ borderColor: dividerColor }}>
|
<div className="pb-10 mb-12 border-b border-solid" style={{ borderColor: dividerColor }}>
|
||||||
<p
|
<p
|
||||||
className="font-mono text-sm uppercase tracking-widest mb-3"
|
className="font-mono text-body-2 uppercase tracking-widest mb-3"
|
||||||
style={{ color: t.textAccent }}
|
style={{ color: t.textAccent }}
|
||||||
>
|
>
|
||||||
Components
|
Components
|
||||||
@ -244,7 +244,7 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-mono" style={{ color: t.textMuted }}>
|
<span className="text-caption font-mono" style={{ color: t.textMuted }}>
|
||||||
텍스트 + 아이콘 버튼
|
텍스트 + 아이콘 버튼
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -298,7 +298,7 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-mono" style={{ color: t.textMuted }}>
|
<span className="text-caption font-mono" style={{ color: t.textMuted }}>
|
||||||
아이콘 전용 버튼
|
아이콘 전용 버튼
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -339,7 +339,7 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
|
|||||||
<div key={size.label} className="flex items-center justify-between gap-8">
|
<div key={size.label} className="flex items-center justify-between gap-8">
|
||||||
{/* 라벨 */}
|
{/* 라벨 */}
|
||||||
<span
|
<span
|
||||||
className="font-mono text-sm w-36 shrink-0"
|
className="font-mono text-body-2 w-36 shrink-0"
|
||||||
style={{ color: t.textSecondary }}
|
style={{ color: t.textSecondary }}
|
||||||
>
|
>
|
||||||
{size.label}
|
{size.label}
|
||||||
@ -349,7 +349,7 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
|
|||||||
<div className="flex-1 flex items-center">
|
<div className="flex-1 flex items-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-md font-semibold text-sm"
|
className="rounded-md font-semibold text-body-2"
|
||||||
style={{
|
style={{
|
||||||
height: `${size.heightPx}px`,
|
height: `${size.heightPx}px`,
|
||||||
paddingLeft: `${size.px}px`,
|
paddingLeft: `${size.px}px`,
|
||||||
@ -380,7 +380,10 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
|
|||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
{/* Flexible */}
|
{/* Flexible */}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<span className="font-mono text-sm font-bold" style={{ color: t.textPrimary }}>
|
<span
|
||||||
|
className="font-mono text-body-2 font-bold"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
Flexible
|
Flexible
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@ -461,7 +464,7 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-mono text-xs" style={{ color: t.textSecondary }}>
|
<span className="font-mono text-caption" style={{ color: t.textSecondary }}>
|
||||||
콘텐츠에 맞게 너비가 자동으로 조정됩니다.
|
콘텐츠에 맞게 너비가 자동으로 조정됩니다.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -469,7 +472,10 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
|
|||||||
|
|
||||||
{/* Fixed */}
|
{/* Fixed */}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<span className="font-mono text-sm font-bold" style={{ color: t.textPrimary }}>
|
<span
|
||||||
|
className="font-mono text-body-2 font-bold"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
Fixed
|
Fixed
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@ -511,7 +517,10 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
|
|||||||
<div style={{ height: '1px', flex: 1, backgroundColor: annotationColor }} />
|
<div style={{ height: '1px', flex: 1, backgroundColor: annotationColor }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-mono text-xs ml-4" style={{ color: t.textSecondary }}>
|
<span
|
||||||
|
className="font-mono text-caption ml-4"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
너비가 고정된 버튼입니다.
|
너비가 고정된 버튼입니다.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -533,7 +542,7 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
|
|||||||
{ resolution: '해상도 320', width: '248px', maxWidth: '248px', padding: 16 },
|
{ resolution: '해상도 320', width: '248px', maxWidth: '248px', padding: 16 },
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<div key={item.resolution} className="flex flex-col gap-3">
|
<div key={item.resolution} className="flex flex-col gap-3">
|
||||||
<span className="font-mono text-sm" style={{ color: t.textSecondary }}>
|
<span className="font-mono text-body-2" style={{ color: t.textSecondary }}>
|
||||||
{item.resolution}
|
{item.resolution}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
@ -597,7 +606,7 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
|
|||||||
{VARIANTS.map((variant) => (
|
{VARIANTS.map((variant) => (
|
||||||
<th
|
<th
|
||||||
key={variant}
|
key={variant}
|
||||||
className="font-mono text-xs font-semibold text-center pb-4"
|
className="font-mono text-caption font-semibold text-center pb-4"
|
||||||
style={{
|
style={{
|
||||||
color: t.textSecondary,
|
color: t.textSecondary,
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
@ -615,7 +624,7 @@ export const ButtonContent = ({ theme }: ButtonContentProps) => {
|
|||||||
<tr key={row.state}>
|
<tr key={row.state}>
|
||||||
{/* 상태 라벨 */}
|
{/* 상태 라벨 */}
|
||||||
<td
|
<td
|
||||||
className="font-mono text-xs font-medium"
|
className="font-mono text-caption font-medium"
|
||||||
style={{
|
style={{
|
||||||
color: t.textSecondary,
|
color: t.textSecondary,
|
||||||
padding: rowIdx === 0 ? '8px 12px 8px 0' : '8px 12px 8px 0',
|
padding: rowIdx === 0 ? '8px 12px 8px 0' : '8px 12px 8px 0',
|
||||||
|
|||||||
@ -347,7 +347,7 @@ const TransparencyRow = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="font-bold text-sm" style={{ color: isDark ? '#dfe2f3' : '#0f172a' }}>
|
<span className="font-bold text-body-2" style={{ color: isDark ? '#dfe2f3' : '#0f172a' }}>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<div className="rounded-xl p-4" style={{ backgroundColor: sectionCardBg }}>
|
<div className="rounded-xl p-4" style={{ backgroundColor: sectionCardBg }}>
|
||||||
@ -453,7 +453,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
{/* ── 섹션 1: 헤더 ── */}
|
{/* ── 섹션 1: 헤더 ── */}
|
||||||
<div className="pb-10 mb-12 border-b border-solid" style={{ borderColor: dividerColor }}>
|
<div className="pb-10 mb-12 border-b border-solid" style={{ borderColor: dividerColor }}>
|
||||||
<p
|
<p
|
||||||
className="font-mono text-sm uppercase tracking-widest mb-3"
|
className="font-mono text-body-2 uppercase tracking-widest mb-3"
|
||||||
style={{ color: t.textAccent }}
|
style={{ color: t.textAccent }}
|
||||||
>
|
>
|
||||||
Foundations
|
Foundations
|
||||||
@ -476,7 +476,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => setActiveColorTab(tab)}
|
onClick={() => setActiveColorTab(tab)}
|
||||||
className="px-5 py-2.5 text-sm font-semibold border-none cursor-pointer bg-transparent"
|
className="px-5 py-2.5 text-body-2 font-semibold border-none cursor-pointer bg-transparent"
|
||||||
style={{
|
style={{
|
||||||
color: isActive ? t.textAccent : t.textMuted,
|
color: isActive ? t.textAccent : t.textMuted,
|
||||||
borderBottom: isActive ? `2px solid ${t.textAccent}` : '2px solid transparent',
|
borderBottom: isActive ? `2px solid ${t.textAccent}` : '2px solid transparent',
|
||||||
@ -505,7 +505,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
>
|
>
|
||||||
--{'{property}'}-{'{role}'}[-{'{variant}'}]
|
--{'{property}'}-{'{role}'}[-{'{variant}'}]
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm leading-relaxed" style={{ color: t.textSecondary }}>
|
<p className="text-body-2 leading-relaxed" style={{ color: t.textSecondary }}>
|
||||||
모든 컬러 토큰은{' '}
|
모든 컬러 토큰은{' '}
|
||||||
<strong style={{ color: t.textPrimary }}>Property-Role-Variant</strong> 3계층 구조를
|
<strong style={{ color: t.textPrimary }}>Property-Role-Variant</strong> 3계층 구조를
|
||||||
따릅니다. Property는 색상이 적용되는 CSS 속성, Role은 의미 기반 역할, Variant는 상태
|
따릅니다. Property는 색상이 적용되는 CSS 속성, Role은 의미 기반 역할, Variant는 상태
|
||||||
@ -551,17 +551,17 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="font-mono text-sm font-bold shrink-0 w-16"
|
className="font-mono text-body-2 font-bold shrink-0 w-16"
|
||||||
style={{ color: t.textAccent }}
|
style={{ color: t.textAccent }}
|
||||||
>
|
>
|
||||||
{row.prop}
|
{row.prop}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-sm" style={{ color: t.textSecondary }}>
|
<div className="text-body-2" style={{ color: t.textSecondary }}>
|
||||||
{row.desc}
|
{row.desc}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-mono text-xs" style={{ color: t.textMuted }}>
|
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
|
||||||
{row.example}
|
{row.example}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -581,7 +581,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
style={{ border: `1px solid ${dividerColor}` }}
|
style={{ border: `1px solid ${dividerColor}` }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="px-5 py-3 text-xs font-bold uppercase tracking-wider"
|
className="px-5 py-3 text-caption font-bold uppercase tracking-wider"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
|
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
|
||||||
color: t.textMuted,
|
color: t.textMuted,
|
||||||
@ -608,12 +608,12 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="font-mono text-sm font-semibold"
|
className="font-mono text-body-2 font-semibold"
|
||||||
style={{ color: t.textPrimary }}
|
style={{ color: t.textPrimary }}
|
||||||
>
|
>
|
||||||
{row.name}
|
{row.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs" style={{ color: t.textMuted }}>
|
<span className="text-caption" style={{ color: t.textMuted }}>
|
||||||
{row.desc}
|
{row.desc}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -626,7 +626,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
style={{ border: `1px solid ${dividerColor}` }}
|
style={{ border: `1px solid ${dividerColor}` }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="px-5 py-3 text-xs font-bold uppercase tracking-wider"
|
className="px-5 py-3 text-caption font-bold uppercase tracking-wider"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
|
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
|
||||||
color: t.textMuted,
|
color: t.textMuted,
|
||||||
@ -650,12 +650,12 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="font-mono text-sm font-semibold"
|
className="font-mono text-body-2 font-semibold"
|
||||||
style={{ color: t.textPrimary }}
|
style={{ color: t.textPrimary }}
|
||||||
>
|
>
|
||||||
{row.name}
|
{row.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs" style={{ color: t.textMuted }}>
|
<span className="text-caption" style={{ color: t.textMuted }}>
|
||||||
{row.desc}
|
{row.desc}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -672,7 +672,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>
|
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>
|
||||||
Semantic Tokens
|
Semantic Tokens
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm mb-6" style={{ color: t.textSecondary }}>
|
<p className="text-body-2 mb-6" style={{ color: t.textSecondary }}>
|
||||||
용도에 따라 의미를 부여한 토큰. 테마 전환 시 값이 변경됩니다.
|
용도에 따라 의미를 부여한 토큰. 테마 전환 시 값이 변경됩니다.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@ -745,7 +745,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
},
|
},
|
||||||
].map((group) => (
|
].map((group) => (
|
||||||
<div key={group.title} className="mb-8">
|
<div key={group.title} className="mb-8">
|
||||||
<h3 className="text-sm font-bold mb-3" style={{ color: t.textAccent }}>
|
<h3 className="text-body-2 font-bold mb-3" style={{ color: t.textAccent }}>
|
||||||
{group.title}
|
{group.title}
|
||||||
</h3>
|
</h3>
|
||||||
<div
|
<div
|
||||||
@ -754,7 +754,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
>
|
>
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div
|
<div
|
||||||
className="grid grid-cols-[80px_1fr_1fr_80px_1fr] gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider"
|
className="grid grid-cols-[80px_1fr_1fr_80px_1fr] gap-2 px-4 py-2.5 text-caption font-bold uppercase tracking-wider"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
|
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
|
||||||
color: t.textMuted,
|
color: t.textMuted,
|
||||||
@ -776,21 +776,21 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="font-mono text-xs line-through"
|
className="font-mono text-caption line-through"
|
||||||
style={{ color: t.textMuted }}
|
style={{ color: t.textMuted }}
|
||||||
>
|
>
|
||||||
{tk.legacy}
|
{tk.legacy}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="font-mono text-xs font-semibold"
|
className="font-mono text-caption font-semibold"
|
||||||
style={{ color: t.textPrimary }}
|
style={{ color: t.textPrimary }}
|
||||||
>
|
>
|
||||||
{tk.name}
|
{tk.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs" style={{ color: t.textMuted }}>
|
<span className="text-caption" style={{ color: t.textMuted }}>
|
||||||
{tk.desc}
|
{tk.desc}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-xs" style={{ color: t.textSecondary }}>
|
<span className="font-mono text-caption" style={{ color: t.textSecondary }}>
|
||||||
{tk.value}
|
{tk.value}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -804,7 +804,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="font-mono text-xs" style={{ color: t.textMuted }}>
|
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
|
||||||
{hexToRgb(tk.value)}
|
{hexToRgb(tk.value)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -823,7 +823,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>
|
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>
|
||||||
Palette Tokens
|
Palette Tokens
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm mb-6" style={{ color: t.textSecondary }}>
|
<p className="text-body-2 mb-6" style={{ color: t.textSecondary }}>
|
||||||
fg · bg · stroke 모든 맥락에서 사용되는 색상 원본. Property 접두사 없이{' '}
|
fg · bg · stroke 모든 맥락에서 사용되는 색상 원본. Property 접두사 없이{' '}
|
||||||
<code className="font-mono" style={{ color: t.textAccent }}>
|
<code className="font-mono" style={{ color: t.textAccent }}>
|
||||||
--color-*
|
--color-*
|
||||||
@ -836,7 +836,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
style={{ border: `1px solid ${dividerColor}` }}
|
style={{ border: `1px solid ${dividerColor}` }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="grid grid-cols-[80px_1fr_80px_1fr_1fr] gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider"
|
className="grid grid-cols-[80px_1fr_80px_1fr_1fr] gap-2 px-4 py-2.5 text-caption font-bold uppercase tracking-wider"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
|
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
|
||||||
color: t.textMuted,
|
color: t.textMuted,
|
||||||
@ -882,16 +882,19 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
|
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="font-mono text-xs line-through" style={{ color: t.textMuted }}>
|
<span
|
||||||
|
className="font-mono text-caption line-through"
|
||||||
|
style={{ color: t.textMuted }}
|
||||||
|
>
|
||||||
{tk.legacy}
|
{tk.legacy}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="font-mono text-xs font-semibold"
|
className="font-mono text-caption font-semibold"
|
||||||
style={{ color: t.textPrimary }}
|
style={{ color: t.textPrimary }}
|
||||||
>
|
>
|
||||||
{tk.name}
|
{tk.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-xs" style={{ color: t.textSecondary }}>
|
<span className="font-mono text-caption" style={{ color: t.textSecondary }}>
|
||||||
{tk.value}
|
{tk.value}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -905,11 +908,11 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="font-mono text-xs" style={{ color: t.textMuted }}>
|
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
|
||||||
{hexToRgb(tk.value)}
|
{hexToRgb(tk.value)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs" style={{ color: t.textMuted }}>
|
<span className="text-caption" style={{ color: t.textMuted }}>
|
||||||
{tk.desc}
|
{tk.desc}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -925,7 +928,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>
|
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>
|
||||||
Non-color Tokens
|
Non-color Tokens
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm mb-6" style={{ color: t.textSecondary }}>
|
<p className="text-body-2 mb-6" style={{ color: t.textSecondary }}>
|
||||||
타이포그래피, 라운딩 등 색상 외 토큰.
|
타이포그래피, 라운딩 등 색상 외 토큰.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@ -934,7 +937,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
style={{ border: `1px solid ${dividerColor}` }}
|
style={{ border: `1px solid ${dividerColor}` }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="grid grid-cols-[80px_1fr_1fr_1fr] gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider"
|
className="grid grid-cols-[80px_1fr_1fr_1fr] gap-2 px-4 py-2.5 text-caption font-bold uppercase tracking-wider"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
|
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
|
||||||
color: t.textMuted,
|
color: t.textMuted,
|
||||||
@ -979,17 +982,20 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
|
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="font-mono text-xs line-through" style={{ color: t.textMuted }}>
|
<span
|
||||||
|
className="font-mono text-caption line-through"
|
||||||
|
style={{ color: t.textMuted }}
|
||||||
|
>
|
||||||
{tk.legacy}
|
{tk.legacy}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="font-mono text-xs font-semibold"
|
className="font-mono text-caption font-semibold"
|
||||||
style={{ color: t.textPrimary }}
|
style={{ color: t.textPrimary }}
|
||||||
>
|
>
|
||||||
{tk.name}
|
{tk.name}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="text-xs font-mono px-2 py-0.5 rounded"
|
className="text-caption font-mono px-2 py-0.5 rounded"
|
||||||
style={{
|
style={{
|
||||||
color: t.textAccent,
|
color: t.textAccent,
|
||||||
backgroundColor: isDark ? 'rgba(6,182,212,0.05)' : 'rgba(6,182,212,0.08)',
|
backgroundColor: isDark ? 'rgba(6,182,212,0.05)' : 'rgba(6,182,212,0.08)',
|
||||||
@ -997,7 +1003,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
>
|
>
|
||||||
{tk.category}
|
{tk.category}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs" style={{ color: t.textMuted }}>
|
<span className="text-caption" style={{ color: t.textMuted }}>
|
||||||
{tk.desc}
|
{tk.desc}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -1015,7 +1021,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
<h2 className="text-xl font-bold mb-3" style={{ color: t.textPrimary }}>
|
<h2 className="text-xl font-bold mb-3" style={{ color: t.textPrimary }}>
|
||||||
프라이머리 색상(primary color)
|
프라이머리 색상(primary color)
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-8 text-sm" style={{ color: t.textSecondary }}>
|
<p className="mb-8 text-body-2" style={{ color: t.textSecondary }}>
|
||||||
Primary 색상은 해양 방제 시스템의 핵심 인터랙션 요소에 사용됩니다. Cyan~Blue
|
Primary 색상은 해양 방제 시스템의 핵심 인터랙션 요소에 사용됩니다. Cyan~Blue
|
||||||
그라디언트가 주요 액션 버튼과 강조 요소에 적용됩니다.
|
그라디언트가 주요 액션 버튼과 강조 요소에 적용됩니다.
|
||||||
</p>
|
</p>
|
||||||
@ -1023,7 +1029,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{/* Light Mode */}
|
{/* Light Mode */}
|
||||||
<div className="rounded-xl p-6" style={{ backgroundColor: '#f5f5f5' }}>
|
<div className="rounded-xl p-6" style={{ backgroundColor: '#f5f5f5' }}>
|
||||||
<p className="font-bold text-sm mb-4" style={{ color: '#1e293b' }}>
|
<p className="font-bold text-body-2 mb-4" style={{ color: '#1e293b' }}>
|
||||||
Light Mode
|
Light Mode
|
||||||
</p>
|
</p>
|
||||||
<ColorScaleBar
|
<ColorScaleBar
|
||||||
@ -1041,7 +1047,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
|
|
||||||
{/* Dark Mode */}
|
{/* Dark Mode */}
|
||||||
<div className="rounded-xl p-6" style={{ backgroundColor: '#1a1a2e' }}>
|
<div className="rounded-xl p-6" style={{ backgroundColor: '#1a1a2e' }}>
|
||||||
<p className="font-bold text-sm mb-4" style={{ color: '#fff' }}>
|
<p className="font-bold text-body-2 mb-4" style={{ color: '#fff' }}>
|
||||||
Dark Mode
|
Dark Mode
|
||||||
</p>
|
</p>
|
||||||
<ColorScaleBar
|
<ColorScaleBar
|
||||||
@ -1067,7 +1073,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
<h2 className="text-xl font-bold mb-3" style={{ color: t.textPrimary }}>
|
<h2 className="text-xl font-bold mb-3" style={{ color: t.textPrimary }}>
|
||||||
세컨더리 색상(secondary color)
|
세컨더리 색상(secondary color)
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-8 text-sm" style={{ color: t.textSecondary }}>
|
<p className="mb-8 text-body-2" style={{ color: t.textSecondary }}>
|
||||||
Secondary 색상은 UI의 배경과 구조적 요소에 사용됩니다. Navy 계열로 다크 모드의
|
Secondary 색상은 UI의 배경과 구조적 요소에 사용됩니다. Navy 계열로 다크 모드의
|
||||||
깊이감과 계층 구조를 표현합니다.
|
깊이감과 계층 구조를 표현합니다.
|
||||||
</p>
|
</p>
|
||||||
@ -1075,7 +1081,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{/* Light Mode */}
|
{/* Light Mode */}
|
||||||
<div className="rounded-xl p-6" style={{ backgroundColor: '#f5f5f5' }}>
|
<div className="rounded-xl p-6" style={{ backgroundColor: '#f5f5f5' }}>
|
||||||
<p className="font-bold text-sm mb-4" style={{ color: '#1e293b' }}>
|
<p className="font-bold text-body-2 mb-4" style={{ color: '#1e293b' }}>
|
||||||
Light Mode
|
Light Mode
|
||||||
</p>
|
</p>
|
||||||
<ColorScaleBar
|
<ColorScaleBar
|
||||||
@ -1093,7 +1099,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
|
|
||||||
{/* Dark Mode */}
|
{/* Dark Mode */}
|
||||||
<div className="rounded-xl p-6" style={{ backgroundColor: '#1a1a2e' }}>
|
<div className="rounded-xl p-6" style={{ backgroundColor: '#1a1a2e' }}>
|
||||||
<p className="font-bold text-sm mb-4" style={{ color: '#fff' }}>
|
<p className="font-bold text-body-2 mb-4" style={{ color: '#fff' }}>
|
||||||
Dark Mode
|
Dark Mode
|
||||||
</p>
|
</p>
|
||||||
<ColorScaleBar
|
<ColorScaleBar
|
||||||
@ -1119,11 +1125,11 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
<h2 className="text-xl font-bold mb-3" style={{ color: t.textPrimary }}>
|
<h2 className="text-xl font-bold mb-3" style={{ color: t.textPrimary }}>
|
||||||
그레이 색상(gray color) / 네추럴, 중립 색상
|
그레이 색상(gray color) / 네추럴, 중립 색상
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-2 text-sm" style={{ color: t.textSecondary }}>
|
<p className="mb-2 text-body-2" style={{ color: t.textSecondary }}>
|
||||||
Gray 색상은 주로 배경, 텍스트, 구분 선에 사용되며, 시각적 집중을 방해하지 않고
|
Gray 색상은 주로 배경, 텍스트, 구분 선에 사용되며, 시각적 집중을 방해하지 않고
|
||||||
콘텐츠에 초점을 맞추도록 도와주는 중립적인 색상이다.
|
콘텐츠에 초점을 맞추도록 도와주는 중립적인 색상이다.
|
||||||
</p>
|
</p>
|
||||||
<p className="mb-8 text-sm" style={{ color: t.textSecondary }}>
|
<p className="mb-8 text-body-2" style={{ color: t.textSecondary }}>
|
||||||
표준형 스타일의 그레이 색상은 주요 색상과 선명한 모드에서의 조화를 고려해 블루
|
표준형 스타일의 그레이 색상은 주요 색상과 선명한 모드에서의 조화를 고려해 블루
|
||||||
그레이 계열을 사용한다.
|
그레이 계열을 사용한다.
|
||||||
</p>
|
</p>
|
||||||
@ -1141,7 +1147,7 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
<h2 className="text-2xl font-bold mb-2" style={{ color: t.textPrimary }}>
|
<h2 className="text-2xl font-bold mb-2" style={{ color: t.textPrimary }}>
|
||||||
Transparent
|
Transparent
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-8 text-sm" style={{ color: t.textSecondary }}>
|
<p className="mb-8 text-body-2" style={{ color: t.textSecondary }}>
|
||||||
투명도와 음영을 활용하여 정보의 집중도를 조절합니다. 배경의 음영 처리는 투명도 65%를
|
투명도와 음영을 활용하여 정보의 집중도를 조절합니다. 배경의 음영 처리는 투명도 65%를
|
||||||
사용합니다.
|
사용합니다.
|
||||||
</p>
|
</p>
|
||||||
@ -1174,12 +1180,12 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
<div className="mb-16">
|
<div className="mb-16">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<p
|
<p
|
||||||
className="font-mono text-sm uppercase tracking-widest mb-1"
|
className="font-mono text-body-2 uppercase tracking-widest mb-1"
|
||||||
style={{ color: t.textAccent }}
|
style={{ color: t.textAccent }}
|
||||||
>
|
>
|
||||||
Primitive
|
Primitive
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm" style={{ color: t.textMuted }}>
|
<p className="text-body-2" style={{ color: t.textMuted }}>
|
||||||
UI 전반에서 사용하는 기본 색조 팔레트. 테마와 무관하게 고정된 값입니다.
|
UI 전반에서 사용하는 기본 색조 팔레트. 테마와 무관하게 고정된 값입니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -1214,17 +1220,17 @@ export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => {
|
|||||||
/>
|
/>
|
||||||
{/* 토큰명 */}
|
{/* 토큰명 */}
|
||||||
<span
|
<span
|
||||||
className="font-mono text-sm font-semibold ml-4"
|
className="font-mono text-body-2 font-semibold ml-4"
|
||||||
style={{ color: t.textPrimary }}
|
style={{ color: t.textPrimary }}
|
||||||
>
|
>
|
||||||
{token.name}
|
{token.name}
|
||||||
</span>
|
</span>
|
||||||
{/* HEX + RGB */}
|
{/* HEX + RGB */}
|
||||||
<div className="ml-auto text-right">
|
<div className="ml-auto text-right">
|
||||||
<div className="font-mono text-sm" style={{ color: t.textPrimary }}>
|
<div className="font-mono text-body-2" style={{ color: t.textPrimary }}>
|
||||||
{token.hex}
|
{token.hex}
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono text-xs" style={{ color: t.textMuted }}>
|
<div className="font-mono text-caption" style={{ color: t.textMuted }}>
|
||||||
{hexToRgb(token.hex)}
|
{hexToRgb(token.hex)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -13,7 +13,7 @@ export const ComponentsContent = () => {
|
|||||||
>
|
>
|
||||||
시스템 컴포넌트 카탈로그
|
시스템 컴포넌트 카탈로그
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-[#bcc9cd] font-korean text-sm leading-5 font-medium max-w-2xl">
|
<p className="text-[#bcc9cd] font-korean text-body-2 leading-5 font-medium max-w-2xl">
|
||||||
WING-OPS 해상 물류를 위한 시각적 아이덴티티 시스템입니다. 정밀도와 미션 크리티컬한 운영을
|
WING-OPS 해상 물류를 위한 시각적 아이덴티티 시스템입니다. 정밀도와 미션 크리티컬한 운영을
|
||||||
위해 설계된 고밀도 산업용 인터페이스입니다.
|
위해 설계된 고밀도 산업용 인터페이스입니다.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -256,7 +256,7 @@ const ComponentsOverview = ({ theme, onNavigate }: ComponentsOverviewProps) => {
|
|||||||
{/* ── 헤더 영역 ── */}
|
{/* ── 헤더 영역 ── */}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<span
|
<span
|
||||||
className="font-mono text-xs font-semibold uppercase"
|
className="font-mono text-caption font-semibold uppercase"
|
||||||
style={{ letterSpacing: '1.4px', color: t.textAccent }}
|
style={{ letterSpacing: '1.4px', color: t.textAccent }}
|
||||||
>
|
>
|
||||||
Components
|
Components
|
||||||
@ -264,7 +264,7 @@ const ComponentsOverview = ({ theme, onNavigate }: ComponentsOverviewProps) => {
|
|||||||
<h1 className="font-sans text-4xl font-bold leading-tight" style={{ color: t.textPrimary }}>
|
<h1 className="font-sans text-4xl font-bold leading-tight" style={{ color: t.textPrimary }}>
|
||||||
Overview
|
Overview
|
||||||
</h1>
|
</h1>
|
||||||
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
|
<p className="font-korean text-body-2 leading-6" style={{ color: t.textSecondary }}>
|
||||||
재사용 가능한 UI 컴포넌트 카탈로그입니다.
|
재사용 가능한 UI 컴포넌트 카탈로그입니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -307,7 +307,10 @@ const ComponentsOverview = ({ theme, onNavigate }: ComponentsOverviewProps) => {
|
|||||||
|
|
||||||
{/* 카드 라벨 */}
|
{/* 카드 라벨 */}
|
||||||
<div className="px-5 py-4">
|
<div className="px-5 py-4">
|
||||||
<span className="font-sans text-sm font-semibold" style={{ color: t.textPrimary }}>
|
<span
|
||||||
|
className="font-sans text-body-2 font-semibold"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
{card.label}
|
{card.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -154,7 +154,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
|||||||
style={{ backgroundColor: t.textAccent, boxShadow: t.systemActiveShadow }}
|
style={{ backgroundColor: t.textAccent, boxShadow: t.systemActiveShadow }}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="font-mono text-xs leading-4 uppercase"
|
className="font-mono text-caption leading-4 uppercase"
|
||||||
style={{ letterSpacing: '1.2px', color: t.textAccent }}
|
style={{ letterSpacing: '1.2px', color: t.textAccent }}
|
||||||
>
|
>
|
||||||
System Active
|
System Active
|
||||||
@ -192,11 +192,14 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
|||||||
/>
|
/>
|
||||||
{/* 정보 */}
|
{/* 정보 */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="font-mono text-xs leading-4" style={{ color: t.textAccent }}>
|
<span
|
||||||
|
className="font-mono text-caption leading-4"
|
||||||
|
style={{ color: t.textAccent }}
|
||||||
|
>
|
||||||
{item.token}
|
{item.token}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="font-korean text-sm leading-5 font-bold"
|
className="font-korean text-body-2 leading-5 font-bold"
|
||||||
style={{ color: t.textPrimary }}
|
style={{ color: t.textPrimary }}
|
||||||
>
|
>
|
||||||
{item.hex}
|
{item.hex}
|
||||||
@ -233,7 +236,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
|||||||
{item.token}
|
{item.token}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="font-korean text-sm font-bold pb-2"
|
className="font-korean text-body-2 font-bold pb-2"
|
||||||
style={{ color: t.textPrimary }}
|
style={{ color: t.textPrimary }}
|
||||||
>
|
>
|
||||||
{item.hex}
|
{item.hex}
|
||||||
@ -263,7 +266,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
|||||||
{item.token}
|
{item.token}
|
||||||
</span>
|
</span>
|
||||||
<span className={item.sampleClass}>{item.sampleText}</span>
|
<span className={item.sampleClass}>{item.sampleText}</span>
|
||||||
<span className="font-korean text-xs" style={{ color: item.descColor }}>
|
<span className="font-korean text-caption" style={{ color: item.descColor }}>
|
||||||
{item.desc}
|
{item.desc}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -297,7 +300,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
|||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span
|
<span
|
||||||
className="font-korean text-sm font-bold"
|
className="font-korean text-body-2 font-bold"
|
||||||
style={{ color: t.textPrimary }}
|
style={{ color: t.textPrimary }}
|
||||||
>
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
@ -435,7 +438,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
|||||||
<div className="grid grid-cols-2 gap-8">
|
<div className="grid grid-cols-2 gap-8">
|
||||||
{/* radius-sm */}
|
{/* radius-sm */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="font-mono text-xs leading-4" style={{ color: t.textMuted }}>
|
<span className="font-mono text-caption leading-4" style={{ color: t.textMuted }}>
|
||||||
{t.radiusSmLabel}
|
{t.radiusSmLabel}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
@ -453,7 +456,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
|||||||
Small Elements
|
Small Elements
|
||||||
</span>
|
</span>
|
||||||
<p
|
<p
|
||||||
className="font-korean text-xs leading-[19.5px] mt-1"
|
className="font-korean text-caption leading-[19.5px] mt-1"
|
||||||
style={{ color: t.radiusDescText }}
|
style={{ color: t.radiusDescText }}
|
||||||
>
|
>
|
||||||
Applied to tactical buttons, search inputs, and micro-cards for a precise, sharp
|
Applied to tactical buttons, search inputs, and micro-cards for a precise, sharp
|
||||||
@ -463,7 +466,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
|||||||
</div>
|
</div>
|
||||||
{/* radius-md */}
|
{/* radius-md */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="font-mono text-xs leading-4" style={{ color: t.textMuted }}>
|
<span className="font-mono text-caption leading-4" style={{ color: t.textMuted }}>
|
||||||
{t.radiusMdLabel}
|
{t.radiusMdLabel}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
@ -481,7 +484,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
|||||||
Structural Panels
|
Structural Panels
|
||||||
</span>
|
</span>
|
||||||
<p
|
<p
|
||||||
className="font-korean text-xs leading-[19.5px] mt-1"
|
className="font-korean text-caption leading-[19.5px] mt-1"
|
||||||
style={{ color: t.radiusDescText }}
|
style={{ color: t.radiusDescText }}
|
||||||
>
|
>
|
||||||
Applied to telemetry cards, floating modals, and primary operational panels to
|
Applied to telemetry cards, floating modals, and primary operational panels to
|
||||||
|
|||||||
@ -50,7 +50,10 @@ export const FloatContent = ({ theme }: FloatContentProps) => {
|
|||||||
<h1 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
|
<h1 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
|
||||||
Float
|
Float
|
||||||
</h1>
|
</h1>
|
||||||
<p className="font-korean text-sm leading-5 mt-1" style={{ color: t.textSecondary }}>
|
<p
|
||||||
|
className="font-korean text-body-2 leading-5 mt-1"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
화면 위에 떠서 표시되는 UI 패턴 카탈로그 — Modal, Dropdown, Overlay, Toast
|
화면 위에 떠서 표시되는 UI 패턴 카탈로그 — Modal, Dropdown, Overlay, Toast
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -70,7 +73,7 @@ export const FloatContent = ({ theme }: FloatContentProps) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="font-sans text-sm font-bold leading-5"
|
className="font-sans text-body-2 font-bold leading-5"
|
||||||
style={{ color: isActive ? t.textAccent : t.textMuted }}
|
style={{ color: isActive ? t.textAccent : t.textMuted }}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@ -174,7 +174,7 @@ const FoundationsOverview = ({ theme, onNavigate }: FoundationsOverviewProps) =>
|
|||||||
{/* ── 헤더 영역 ── */}
|
{/* ── 헤더 영역 ── */}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<span
|
<span
|
||||||
className="font-mono text-xs font-semibold uppercase"
|
className="font-mono text-caption font-semibold uppercase"
|
||||||
style={{ letterSpacing: '1.4px', color: t.textAccent }}
|
style={{ letterSpacing: '1.4px', color: t.textAccent }}
|
||||||
>
|
>
|
||||||
Foundations
|
Foundations
|
||||||
@ -182,7 +182,7 @@ const FoundationsOverview = ({ theme, onNavigate }: FoundationsOverviewProps) =>
|
|||||||
<h1 className="font-sans text-4xl font-bold leading-tight" style={{ color: t.textPrimary }}>
|
<h1 className="font-sans text-4xl font-bold leading-tight" style={{ color: t.textPrimary }}>
|
||||||
Overview
|
Overview
|
||||||
</h1>
|
</h1>
|
||||||
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
|
<p className="font-korean text-body-2 leading-6" style={{ color: t.textSecondary }}>
|
||||||
디자인의 기반이 되는 핵심 요소 사용 기준입니다.
|
디자인의 기반이 되는 핵심 요소 사용 기준입니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -225,7 +225,10 @@ const FoundationsOverview = ({ theme, onNavigate }: FoundationsOverviewProps) =>
|
|||||||
|
|
||||||
{/* 카드 라벨 */}
|
{/* 카드 라벨 */}
|
||||||
<div className="px-5 py-4">
|
<div className="px-5 py-4">
|
||||||
<span className="font-sans text-sm font-semibold" style={{ color: t.textPrimary }}>
|
<span
|
||||||
|
className="font-sans text-body-2 font-semibold"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
{card.label}
|
{card.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -324,7 +324,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
|
|||||||
<h1 className="font-sans text-3xl leading-9 font-bold" style={{ color: t.textPrimary }}>
|
<h1 className="font-sans text-3xl leading-9 font-bold" style={{ color: t.textPrimary }}>
|
||||||
Layout
|
Layout
|
||||||
</h1>
|
</h1>
|
||||||
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
|
<p className="font-korean text-body-2 leading-6" style={{ color: t.textSecondary }}>
|
||||||
WING-OPS는 데스크톱 전용 고정 뷰포트 애플리케이션입니다. 화면 전체를 채우는 고정
|
WING-OPS는 데스크톱 전용 고정 뷰포트 애플리케이션입니다. 화면 전체를 채우는 고정
|
||||||
레이아웃(100vh)으로, flex 기반의 패널 구조를 사용합니다. KRDS 가이드라인을 기반으로
|
레이아웃(100vh)으로, flex 기반의 패널 구조를 사용합니다. KRDS 가이드라인을 기반으로
|
||||||
xlarge / xxlarge 구간에 최적화되어 있습니다.
|
xlarge / xxlarge 구간에 최적화되어 있습니다.
|
||||||
@ -364,7 +364,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
|
|||||||
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
|
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
|
||||||
Breakpoint
|
Breakpoint
|
||||||
</h2>
|
</h2>
|
||||||
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
|
<p className="font-korean text-body-2 leading-5" style={{ color: t.textSecondary }}>
|
||||||
화면 크기에 따라 반응형 레이아웃을 사용하여 환경에 최적화된 구조로 표시됩니다. WING-OPS
|
화면 크기에 따라 반응형 레이아웃을 사용하여 환경에 최적화된 구조로 표시됩니다. WING-OPS
|
||||||
사용 구간(xl, 2xl)은 cyan으로 강조되어 있습니다.
|
사용 구간(xl, 2xl)은 cyan으로 강조되어 있습니다.
|
||||||
</p>
|
</p>
|
||||||
@ -499,7 +499,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
|
|||||||
borderColor: isDark ? 'rgba(76,215,246,0.45)' : 'rgba(6,182,212,0.40)',
|
borderColor: isDark ? 'rgba(76,215,246,0.45)' : 'rgba(6,182,212,0.40)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="font-korean text-xs" style={{ color: t.textSecondary }}>
|
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
|
||||||
WING-OPS 사용 중
|
WING-OPS 사용 중
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -511,7 +511,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
|
|||||||
borderColor: isDark ? 'rgba(140,144,159,0.25)' : 'rgba(148,163,184,0.30)',
|
borderColor: isDark ? 'rgba(140,144,159,0.25)' : 'rgba(148,163,184,0.30)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="font-korean text-xs" style={{ color: t.textSecondary }}>
|
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
|
||||||
미지원 (1280px 미만)
|
미지원 (1280px 미만)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -525,7 +525,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
|
|||||||
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
|
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
|
||||||
Grid
|
Grid
|
||||||
</h2>
|
</h2>
|
||||||
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
|
<p className="font-korean text-body-2 leading-5" style={{ color: t.textSecondary }}>
|
||||||
컬럼, 마진, 거터로 구성된 그리드 시스템입니다. 데스크톱 전용으로 xl / 2xl 두 구간만
|
컬럼, 마진, 거터로 구성된 그리드 시스템입니다. 데스크톱 전용으로 xl / 2xl 두 구간만
|
||||||
지원합니다.
|
지원합니다.
|
||||||
</p>
|
</p>
|
||||||
@ -548,7 +548,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
|
|||||||
style={{ borderColor: isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0' }}
|
style={{ borderColor: isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-korean text-xs" style={{ color: t.textMuted }}>
|
<span className="font-korean text-caption" style={{ color: t.textMuted }}>
|
||||||
Breakpoint
|
Breakpoint
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@ -562,10 +562,10 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-korean text-xs" style={{ color: t.textMuted }}>
|
<span className="font-korean text-caption" style={{ color: t.textMuted }}>
|
||||||
Width
|
Width
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-sm" style={{ color: t.textPrimary }}>
|
<span className="font-mono text-body-2" style={{ color: t.textPrimary }}>
|
||||||
{spec.width}
|
{spec.width}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -683,7 +683,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
|
|||||||
<span className="font-mono text-base" style={{ color: '#f97316' }}>
|
<span className="font-mono text-base" style={{ color: '#f97316' }}>
|
||||||
⚠
|
⚠
|
||||||
</span>
|
</span>
|
||||||
<span className="font-korean text-xs" style={{ color: t.textSecondary }}>
|
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
|
||||||
<strong style={{ color: '#f97316' }}>1280px 미만 미지원</strong> — Mobile / Tablet
|
<strong style={{ color: '#f97316' }}>1280px 미만 미지원</strong> — Mobile / Tablet
|
||||||
구간(xs / s / md / lg)은 데스크톱 전용 운영 정책에 따라 지원하지 않습니다.
|
구간(xs / s / md / lg)은 데스크톱 전용 운영 정책에 따라 지원하지 않습니다.
|
||||||
</span>
|
</span>
|
||||||
@ -696,7 +696,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
|
|||||||
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
|
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
|
||||||
App Shell
|
App Shell
|
||||||
</h2>
|
</h2>
|
||||||
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
|
<p className="font-korean text-body-2 leading-5" style={{ color: t.textSecondary }}>
|
||||||
WING-OPS 애플리케이션의 기본 레이아웃 구조와 KRDS Sub-page 영역 매핑입니다.
|
WING-OPS 애플리케이션의 기본 레이아웃 구조와 KRDS Sub-page 영역 매핑입니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -720,7 +720,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="font-mono text-sm font-bold" style={{ color: '#3b82f6' }}>
|
<span className="font-mono text-body-2 font-bold" style={{ color: '#3b82f6' }}>
|
||||||
TopBar
|
TopBar
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@ -932,7 +932,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
|
|||||||
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
|
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
|
||||||
Spacing
|
Spacing
|
||||||
</h2>
|
</h2>
|
||||||
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
|
<p className="font-korean text-body-2 leading-5" style={{ color: t.textSecondary }}>
|
||||||
UI 요소 간의 간격과 여백을 정의하여 콘텐츠의 위계와 가독성을 조율합니다. Tailwind
|
UI 요소 간의 간격과 여백을 정의하여 콘텐츠의 위계와 가독성을 조율합니다. Tailwind
|
||||||
spacing 토큰과 직결되며, 막대 길이는 실제 px 비율입니다.
|
spacing 토큰과 직결되며, 막대 길이는 실제 px 비율입니다.
|
||||||
</p>
|
</p>
|
||||||
@ -1000,7 +1000,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
|
|||||||
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
|
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
|
||||||
4pt Grid
|
4pt Grid
|
||||||
</h2>
|
</h2>
|
||||||
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
|
<p className="font-korean text-body-2 leading-5" style={{ color: t.textSecondary }}>
|
||||||
모든 여백과 간격을 4point 단위로 설정해 규칙성을 확보합니다. 컴팩트한 컴포넌트의 경우,
|
모든 여백과 간격을 4point 단위로 설정해 규칙성을 확보합니다. 컴팩트한 컴포넌트의 경우,
|
||||||
2의 배수 단위를 제한적으로 사용합니다.
|
2의 배수 단위를 제한적으로 사용합니다.
|
||||||
</p>
|
</p>
|
||||||
@ -1048,7 +1048,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
|
|||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-korean text-xs" style={{ color: t.textPrimary }}>
|
<span className="font-korean text-caption" style={{ color: t.textPrimary }}>
|
||||||
{item.text}
|
{item.text}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -1145,7 +1145,10 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
|
|||||||
minWidth: '180px',
|
minWidth: '180px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="font-korean text-xs font-bold" style={{ color: t.textPrimary }}>
|
<span
|
||||||
|
className="font-korean text-caption font-bold"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
카드 타이틀
|
카드 타이틀
|
||||||
</span>
|
</span>
|
||||||
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
|
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
|
||||||
@ -1185,7 +1188,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
|
|||||||
디자인 시스템 진실 소스
|
디자인 시스템 진실 소스
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
|
<p className="font-korean text-body-2 leading-5" style={{ color: t.textSecondary }}>
|
||||||
UI 요소의 레이어 스택 순서입니다. 높은 z-index가 위에 표시되며, 이 값은 디자인 시스템의
|
UI 요소의 레이어 스택 순서입니다. 높은 z-index가 위에 표시되며, 이 값은 디자인 시스템의
|
||||||
이상적 설계 값으로 실제 코드는 이 값에 맞춰 정정되어야 합니다.
|
이상적 설계 값으로 실제 코드는 이 값에 맞춰 정정되어야 합니다.
|
||||||
</p>
|
</p>
|
||||||
@ -1216,7 +1219,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
|
|||||||
className="w-2 h-2 rounded-full shrink-0"
|
className="w-2 h-2 rounded-full shrink-0"
|
||||||
style={{ backgroundColor: layer.color }}
|
style={{ backgroundColor: layer.color }}
|
||||||
/>
|
/>
|
||||||
<span className="font-mono text-xs font-bold" style={{ color: layer.color }}>
|
<span className="font-mono text-caption font-bold" style={{ color: layer.color }}>
|
||||||
{layer.name}
|
{layer.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
|
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
|
||||||
@ -1234,7 +1237,7 @@ export const LayoutContent = ({ theme }: LayoutContentProps) => {
|
|||||||
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
|
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
|
||||||
Reference
|
Reference
|
||||||
</h2>
|
</h2>
|
||||||
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
|
<p className="font-korean text-body-2 leading-5" style={{ color: t.textSecondary }}>
|
||||||
App Shell CSS 클래스와 KRDS Grid 규칙 비교 — 코드 작성 시 참조용입니다.
|
App Shell CSS 클래스와 KRDS Grid 규칙 비교 — 코드 작성 시 참조용입니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -70,17 +70,17 @@ export const RadiusContent = ({ theme }: RadiusContentProps) => {
|
|||||||
<h1 className="font-sans text-3xl leading-9 font-bold" style={{ color: t.textPrimary }}>
|
<h1 className="font-sans text-3xl leading-9 font-bold" style={{ color: t.textPrimary }}>
|
||||||
Radius
|
Radius
|
||||||
</h1>
|
</h1>
|
||||||
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
|
<p className="font-korean text-body-2 leading-5" style={{ color: t.textSecondary }}>
|
||||||
Radius는 컴포넌트 혹은 콘텐츠 모서리의 둥글기를 표현합니다.
|
Radius는 컴포넌트 혹은 콘텐츠 모서리의 둥글기를 표현합니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
|
<p className="font-korean text-body-2 leading-6" style={{ color: t.textSecondary }}>
|
||||||
Radius는 UI 구성 요소의 모서리를 둥글게 처리하여 부드럽고 현대적인 느낌을 제공합니다.
|
Radius는 UI 구성 요소의 모서리를 둥글게 처리하여 부드럽고 현대적인 느낌을 제공합니다.
|
||||||
일관된 Radius 값은 브랜드 아이덴티티를 강화하고, 사용자 경험을 향상시키며, 다양한 화면과
|
일관된 Radius 값은 브랜드 아이덴티티를 강화하고, 사용자 경험을 향상시키며, 다양한 화면과
|
||||||
컨텍스트에서 시각적 일관성을 유지하는 데 중요한 역할을 합니다.
|
컨텍스트에서 시각적 일관성을 유지하는 데 중요한 역할을 합니다.
|
||||||
</p>
|
</p>
|
||||||
<ul
|
<ul
|
||||||
className="flex flex-col gap-1 list-disc list-inside font-korean text-sm"
|
className="flex flex-col gap-1 list-disc list-inside font-korean text-body-2"
|
||||||
style={{ color: t.textSecondary }}
|
style={{ color: t.textSecondary }}
|
||||||
>
|
>
|
||||||
<li>
|
<li>
|
||||||
@ -203,7 +203,7 @@ export const RadiusContent = ({ theme }: RadiusContentProps) => {
|
|||||||
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
|
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
|
||||||
컴포넌트 매핑
|
컴포넌트 매핑
|
||||||
</h2>
|
</h2>
|
||||||
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
|
<p className="font-korean text-body-2 leading-5" style={{ color: t.textSecondary }}>
|
||||||
wing.css 컴포넌트 클래스에 적용된 Radius 토큰입니다.
|
wing.css 컴포넌트 클래스에 적용된 Radius 토큰입니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -233,7 +233,7 @@ export const RadiusContent = ({ theme }: RadiusContentProps) => {
|
|||||||
|
|
||||||
{/* 정보 */}
|
{/* 정보 */}
|
||||||
<div className="flex flex-col gap-1.5 flex-1">
|
<div className="flex flex-col gap-1.5 flex-1">
|
||||||
<span className="font-mono text-xs font-bold" style={{ color: t.textPrimary }}>
|
<span className="font-mono text-caption font-bold" style={{ color: t.textPrimary }}>
|
||||||
{item.className}
|
{item.className}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-row flex-wrap gap-2">
|
<div className="flex flex-row flex-wrap gap-2">
|
||||||
|
|||||||
@ -207,7 +207,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
{/* ── 섹션 1: 헤더 ── */}
|
{/* ── 섹션 1: 헤더 ── */}
|
||||||
<div className="pb-10 mb-12 border-b border-solid" style={{ borderColor: dividerColor }}>
|
<div className="pb-10 mb-12 border-b border-solid" style={{ borderColor: dividerColor }}>
|
||||||
<p
|
<p
|
||||||
className="font-mono text-sm uppercase tracking-widest mb-3"
|
className="font-mono text-body-2 uppercase tracking-widest mb-3"
|
||||||
style={{ color: t.textAccent }}
|
style={{ color: t.textAccent }}
|
||||||
>
|
>
|
||||||
Components
|
Components
|
||||||
@ -225,7 +225,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
<h2 className="text-2xl font-bold mb-2" style={{ color: t.textPrimary }}>
|
<h2 className="text-2xl font-bold mb-2" style={{ color: t.textPrimary }}>
|
||||||
Input Field
|
Input Field
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm" style={{ color: t.textSecondary }}>
|
<p className="text-body-2" style={{ color: t.textSecondary }}>
|
||||||
단일 행 텍스트를 입력받는 필드입니다.
|
단일 행 텍스트를 입력받는 필드입니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -246,7 +246,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
{/* Prefix label */}
|
{/* Prefix label */}
|
||||||
<div className="flex flex-col items-center gap-0.5" style={{ width: '70px' }}>
|
<div className="flex flex-col items-center gap-0.5" style={{ width: '70px' }}>
|
||||||
<span
|
<span
|
||||||
className="font-mono text-xs font-semibold"
|
className="font-mono text-caption font-semibold"
|
||||||
style={{ color: annotationColor }}
|
style={{ color: annotationColor }}
|
||||||
>
|
>
|
||||||
Prefix
|
Prefix
|
||||||
@ -272,7 +272,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
{/* Input label */}
|
{/* Input label */}
|
||||||
<div className="flex flex-col items-center gap-0.5">
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
<span
|
<span
|
||||||
className="font-mono text-xs font-semibold"
|
className="font-mono text-caption font-semibold"
|
||||||
style={{ color: annotationColor }}
|
style={{ color: annotationColor }}
|
||||||
>
|
>
|
||||||
Input
|
Input
|
||||||
@ -295,7 +295,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
{/* Suffix label */}
|
{/* Suffix label */}
|
||||||
<div className="flex flex-col items-center gap-0.5" style={{ width: '70px' }}>
|
<div className="flex flex-col items-center gap-0.5" style={{ width: '70px' }}>
|
||||||
<span
|
<span
|
||||||
className="font-mono text-xs font-semibold"
|
className="font-mono text-caption font-semibold"
|
||||||
style={{ color: annotationColor }}
|
style={{ color: annotationColor }}
|
||||||
>
|
>
|
||||||
Suffix
|
Suffix
|
||||||
@ -377,7 +377,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
|
|
||||||
{/* Suffix 텍스트 */}
|
{/* Suffix 텍스트 */}
|
||||||
<span
|
<span
|
||||||
className="shrink-0 ml-2 font-mono text-sm"
|
className="shrink-0 ml-2 font-mono text-body-2"
|
||||||
style={{ color: fieldPlaceholder }}
|
style={{ color: fieldPlaceholder }}
|
||||||
>
|
>
|
||||||
원
|
원
|
||||||
@ -402,7 +402,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="font-mono text-xs font-semibold"
|
className="font-mono text-caption font-semibold"
|
||||||
style={{ color: annotationColor }}
|
style={{ color: annotationColor }}
|
||||||
>
|
>
|
||||||
Container
|
Container
|
||||||
@ -437,7 +437,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
style={{ width: '1px', height: '10px', backgroundColor: annotationColor }}
|
style={{ width: '1px', height: '10px', backgroundColor: annotationColor }}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="font-mono text-xs font-semibold"
|
className="font-mono text-caption font-semibold"
|
||||||
style={{ color: annotationColor }}
|
style={{ color: annotationColor }}
|
||||||
>
|
>
|
||||||
Clear Button
|
Clear Button
|
||||||
@ -468,7 +468,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
Container
|
Container
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-5 text-sm" style={{ color: t.textSecondary }}>
|
<p className="mb-5 text-body-2" style={{ color: t.textSecondary }}>
|
||||||
입력 필드의 외곽 영역입니다. 테두리, 곡률, 내부 여백을 정의합니다.
|
입력 필드의 외곽 영역입니다. 테두리, 곡률, 내부 여백을 정의합니다.
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
|
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
|
||||||
@ -519,13 +519,13 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="font-mono text-xs" style={{ color: t.textSecondary }}>
|
<p className="font-mono text-caption" style={{ color: t.textSecondary }}>
|
||||||
height: 44px (Medium)
|
height: 44px (Medium)
|
||||||
</p>
|
</p>
|
||||||
<p className="font-mono text-xs mt-1" style={{ color: t.textSecondary }}>
|
<p className="font-mono text-caption mt-1" style={{ color: t.textSecondary }}>
|
||||||
padding: 12px (좌우)
|
padding: 12px (좌우)
|
||||||
</p>
|
</p>
|
||||||
<p className="font-mono text-xs mt-1" style={{ color: t.textSecondary }}>
|
<p className="font-mono text-caption mt-1" style={{ color: t.textSecondary }}>
|
||||||
border-radius: 6px
|
border-radius: 6px
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -543,14 +543,14 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
Placeholder
|
Placeholder
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-5 text-sm" style={{ color: t.textSecondary }}>
|
<p className="mb-5 text-body-2" style={{ color: t.textSecondary }}>
|
||||||
값이 입력되지 않았을 때 표시되는 안내 텍스트입니다. 입력 시 사라집니다.
|
값이 입력되지 않았을 때 표시되는 안내 텍스트입니다. 입력 시 사라집니다.
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
|
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
{/* 플레이스홀더 있는 필드 */}
|
{/* 플레이스홀더 있는 필드 */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xs font-mono" style={{ color: t.textSecondary }}>
|
<span className="text-caption font-mono" style={{ color: t.textSecondary }}>
|
||||||
플레이스홀더 있음
|
플레이스홀더 있음
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
@ -570,7 +570,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
|
|
||||||
{/* 빈 필드 (플레이스홀더 없음) */}
|
{/* 빈 필드 (플레이스홀더 없음) */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xs font-mono" style={{ color: t.textSecondary }}>
|
<span className="text-caption font-mono" style={{ color: t.textSecondary }}>
|
||||||
플레이스홀더 없음
|
플레이스홀더 없음
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
@ -597,14 +597,17 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
Label
|
Label
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-5 text-sm" style={{ color: t.textSecondary }}>
|
<p className="mb-5 text-body-2" style={{ color: t.textSecondary }}>
|
||||||
입력 필드의 용도를 설명하는 텍스트입니다. 필수 항목은 * 표시로 구분합니다.
|
입력 필드의 용도를 설명하는 텍스트입니다. 필수 항목은 * 표시로 구분합니다.
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
|
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
|
||||||
<div className="flex gap-8">
|
<div className="flex gap-8">
|
||||||
{/* 일반 라벨 */}
|
{/* 일반 라벨 */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-sm font-semibold mb-1.5" style={{ color: t.textPrimary }}>
|
<span
|
||||||
|
className="text-body-2 font-semibold mb-1.5"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
이름
|
이름
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
@ -624,7 +627,10 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
|
|
||||||
{/* 필수 라벨 */}
|
{/* 필수 라벨 */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-sm font-semibold mb-1.5" style={{ color: t.textPrimary }}>
|
<span
|
||||||
|
className="text-body-2 font-semibold mb-1.5"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
이메일 <span style={{ color: '#ef4444' }}>*</span>
|
이메일 <span style={{ color: '#ef4444' }}>*</span>
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
@ -655,7 +661,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
Input Text
|
Input Text
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-5 text-sm" style={{ color: t.textSecondary }}>
|
<p className="mb-5 text-body-2" style={{ color: t.textSecondary }}>
|
||||||
사용자가 실제로 입력한 텍스트입니다. 플레이스홀더보다 진한 색상으로 표시됩니다.
|
사용자가 실제로 입력한 텍스트입니다. 플레이스홀더보다 진한 색상으로 표시됩니다.
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
|
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
|
||||||
@ -673,7 +679,10 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
>
|
>
|
||||||
홍길동
|
홍길동
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-6 text-xs font-mono" style={{ color: t.textSecondary }}>
|
<div
|
||||||
|
className="flex gap-6 text-caption font-mono"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
<span>font-size: 14px</span>
|
<span>font-size: 14px</span>
|
||||||
<span>color: textPrimary</span>
|
<span>color: textPrimary</span>
|
||||||
<span>font-weight: 400</span>
|
<span>font-weight: 400</span>
|
||||||
@ -692,14 +701,14 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
Clear Icon
|
Clear Icon
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-5 text-sm" style={{ color: t.textSecondary }}>
|
<p className="mb-5 text-body-2" style={{ color: t.textSecondary }}>
|
||||||
입력값이 있을 때 표시되는 초기화 버튼입니다. 클릭 시 입력값을 삭제합니다.
|
입력값이 있을 때 표시되는 초기화 버튼입니다. 클릭 시 입력값을 삭제합니다.
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
|
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
{/* 텍스트 입력 + Clear 아이콘 표시 */}
|
{/* 텍스트 입력 + Clear 아이콘 표시 */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xs font-mono" style={{ color: t.textSecondary }}>
|
<span className="text-caption font-mono" style={{ color: t.textSecondary }}>
|
||||||
입력값 있음 (Clear 표시)
|
입력값 있음 (Clear 표시)
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
@ -736,7 +745,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
|
|
||||||
{/* 빈 상태 (Clear 미표시) */}
|
{/* 빈 상태 (Clear 미표시) */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xs font-mono" style={{ color: t.textSecondary }}>
|
<span className="text-caption font-mono" style={{ color: t.textSecondary }}>
|
||||||
입력값 없음 (Clear 미표시)
|
입력값 없음 (Clear 미표시)
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
@ -767,7 +776,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
Helper Text
|
Helper Text
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-5 text-sm" style={{ color: t.textSecondary }}>
|
<p className="mb-5 text-body-2" style={{ color: t.textSecondary }}>
|
||||||
입력 필드 하단에 표시되는 보조 텍스트입니다. 안내 또는 에러 메시지로 사용됩니다.
|
입력 필드 하단에 표시되는 보조 텍스트입니다. 안내 또는 에러 메시지로 사용됩니다.
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
|
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
|
||||||
@ -787,7 +796,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
>
|
>
|
||||||
비밀번호
|
비밀번호
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs" style={{ color: t.textMuted }}>
|
<span className="text-caption" style={{ color: t.textMuted }}>
|
||||||
영문, 숫자 포함 8자 이상
|
영문, 숫자 포함 8자 이상
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -807,7 +816,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
>
|
>
|
||||||
비밀번호
|
비밀번호
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs" style={{ color: '#ef4444' }}>
|
<span className="text-caption" style={{ color: '#ef4444' }}>
|
||||||
필수 입력 항목입니다.
|
필수 입력 항목입니다.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -828,7 +837,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
<div key={row.state} className="flex items-center gap-8">
|
<div key={row.state} className="flex items-center gap-8">
|
||||||
{/* 왼쪽: State 라벨 + 뱃지 */}
|
{/* 왼쪽: State 라벨 + 뱃지 */}
|
||||||
<div className="flex items-center gap-2 shrink-0" style={{ width: '200px' }}>
|
<div className="flex items-center gap-2 shrink-0" style={{ width: '200px' }}>
|
||||||
<span className="font-mono text-xs" style={{ color: t.textSecondary }}>
|
<span className="font-mono text-caption" style={{ color: t.textSecondary }}>
|
||||||
State
|
State
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@ -891,7 +900,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
<h2 className="text-2xl font-bold mb-2" style={{ color: t.textPrimary }}>
|
<h2 className="text-2xl font-bold mb-2" style={{ color: t.textPrimary }}>
|
||||||
Text Area
|
Text Area
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm" style={{ color: t.textSecondary }}>
|
<p className="text-body-2" style={{ color: t.textSecondary }}>
|
||||||
여러 줄의 텍스트를 입력받는 필드입니다.
|
여러 줄의 텍스트를 입력받는 필드입니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -911,7 +920,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
{/* Input Area label */}
|
{/* Input Area label */}
|
||||||
<div className="flex flex-col items-center gap-0.5" style={{ width: '80px' }}>
|
<div className="flex flex-col items-center gap-0.5" style={{ width: '80px' }}>
|
||||||
<span
|
<span
|
||||||
className="font-mono text-xs font-semibold"
|
className="font-mono text-caption font-semibold"
|
||||||
style={{ color: annotationColor }}
|
style={{ color: annotationColor }}
|
||||||
>
|
>
|
||||||
Input Area
|
Input Area
|
||||||
@ -933,7 +942,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
{/* Placeholder label */}
|
{/* Placeholder label */}
|
||||||
<div className="flex flex-col items-center gap-0.5">
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
<span
|
<span
|
||||||
className="font-mono text-xs font-semibold"
|
className="font-mono text-caption font-semibold"
|
||||||
style={{ color: annotationColor }}
|
style={{ color: annotationColor }}
|
||||||
>
|
>
|
||||||
Placeholder
|
Placeholder
|
||||||
@ -955,7 +964,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
{/* Character Counter label */}
|
{/* Character Counter label */}
|
||||||
<div className="flex flex-col items-center gap-0.5" style={{ width: '110px' }}>
|
<div className="flex flex-col items-center gap-0.5" style={{ width: '110px' }}>
|
||||||
<span
|
<span
|
||||||
className="font-mono text-xs font-semibold"
|
className="font-mono text-caption font-semibold"
|
||||||
style={{ color: annotationColor }}
|
style={{ color: annotationColor }}
|
||||||
>
|
>
|
||||||
Character Counter
|
Character Counter
|
||||||
@ -1042,7 +1051,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="font-mono text-xs font-semibold"
|
className="font-mono text-caption font-semibold"
|
||||||
style={{ color: annotationColor }}
|
style={{ color: annotationColor }}
|
||||||
>
|
>
|
||||||
Container
|
Container
|
||||||
@ -1075,7 +1084,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
style={{ width: '1px', height: '10px', backgroundColor: annotationColor }}
|
style={{ width: '1px', height: '10px', backgroundColor: annotationColor }}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="font-mono text-xs font-semibold"
|
className="font-mono text-caption font-semibold"
|
||||||
style={{ color: annotationColor }}
|
style={{ color: annotationColor }}
|
||||||
>
|
>
|
||||||
Resize Handle
|
Resize Handle
|
||||||
@ -1103,7 +1112,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
Container
|
Container
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-5 text-sm" style={{ color: t.textSecondary }}>
|
<p className="mb-5 text-body-2" style={{ color: t.textSecondary }}>
|
||||||
텍스트 영역의 외곽 컨테이너입니다. 기본 높이 112px이며 사용자가 리사이즈할 수
|
텍스트 영역의 외곽 컨테이너입니다. 기본 높이 112px이며 사용자가 리사이즈할 수
|
||||||
있습니다.
|
있습니다.
|
||||||
</p>
|
</p>
|
||||||
@ -1154,16 +1163,16 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<p className="font-mono text-xs" style={{ color: t.textSecondary }}>
|
<p className="font-mono text-caption" style={{ color: t.textSecondary }}>
|
||||||
height: 112px (default)
|
height: 112px (default)
|
||||||
</p>
|
</p>
|
||||||
<p className="font-mono text-xs mt-1" style={{ color: t.textSecondary }}>
|
<p className="font-mono text-caption mt-1" style={{ color: t.textSecondary }}>
|
||||||
padding: 12px
|
padding: 12px
|
||||||
</p>
|
</p>
|
||||||
<p className="font-mono text-xs mt-1" style={{ color: t.textSecondary }}>
|
<p className="font-mono text-caption mt-1" style={{ color: t.textSecondary }}>
|
||||||
border-radius: 6px
|
border-radius: 6px
|
||||||
</p>
|
</p>
|
||||||
<p className="font-mono text-xs mt-1" style={{ color: t.textSecondary }}>
|
<p className="font-mono text-caption mt-1" style={{ color: t.textSecondary }}>
|
||||||
resize: vertical
|
resize: vertical
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -1181,14 +1190,14 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
Placeholder
|
Placeholder
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-5 text-sm" style={{ color: t.textSecondary }}>
|
<p className="mb-5 text-body-2" style={{ color: t.textSecondary }}>
|
||||||
값이 입력되지 않았을 때 표시되는 안내 텍스트입니다.
|
값이 입력되지 않았을 때 표시되는 안내 텍스트입니다.
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
|
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
{/* 플레이스홀더 있는 TextArea */}
|
{/* 플레이스홀더 있는 TextArea */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xs font-mono" style={{ color: t.textSecondary }}>
|
<span className="text-caption font-mono" style={{ color: t.textSecondary }}>
|
||||||
플레이스홀더 있음
|
플레이스홀더 있음
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
@ -1209,7 +1218,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
|
|
||||||
{/* 빈 TextArea */}
|
{/* 빈 TextArea */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xs font-mono" style={{ color: t.textSecondary }}>
|
<span className="text-caption font-mono" style={{ color: t.textSecondary }}>
|
||||||
플레이스홀더 없음
|
플레이스홀더 없음
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
@ -1236,14 +1245,17 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
Label
|
Label
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-5 text-sm" style={{ color: t.textSecondary }}>
|
<p className="mb-5 text-body-2" style={{ color: t.textSecondary }}>
|
||||||
텍스트 영역의 용도를 설명하는 라벨입니다.
|
텍스트 영역의 용도를 설명하는 라벨입니다.
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
|
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
|
||||||
<div className="flex gap-8">
|
<div className="flex gap-8">
|
||||||
{/* 기본 라벨 */}
|
{/* 기본 라벨 */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-sm font-semibold mb-1.5" style={{ color: t.textPrimary }}>
|
<span
|
||||||
|
className="text-body-2 font-semibold mb-1.5"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
내용
|
내용
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
@ -1264,7 +1276,10 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
|
|
||||||
{/* 필수(*) 라벨 */}
|
{/* 필수(*) 라벨 */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-sm font-semibold mb-1.5" style={{ color: t.textPrimary }}>
|
<span
|
||||||
|
className="text-body-2 font-semibold mb-1.5"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
비고 <span style={{ color: '#ef4444' }}>*</span>
|
비고 <span style={{ color: '#ef4444' }}>*</span>
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
@ -1296,7 +1311,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
Input Text
|
Input Text
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-5 text-sm" style={{ color: t.textSecondary }}>
|
<p className="mb-5 text-body-2" style={{ color: t.textSecondary }}>
|
||||||
사용자가 입력한 여러 줄의 텍스트입니다.
|
사용자가 입력한 여러 줄의 텍스트입니다.
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
|
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
|
||||||
@ -1316,7 +1331,10 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
>
|
>
|
||||||
{'오늘 점검 내용을 기록합니다.\n상세 내용은 아래와 같습니다.'}
|
{'오늘 점검 내용을 기록합니다.\n상세 내용은 아래와 같습니다.'}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-6 text-xs font-mono" style={{ color: t.textSecondary }}>
|
<div
|
||||||
|
className="flex gap-6 text-caption font-mono"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
<span>font-size: 14px</span>
|
<span>font-size: 14px</span>
|
||||||
<span>color: textPrimary</span>
|
<span>color: textPrimary</span>
|
||||||
<span>line-height: 1.6</span>
|
<span>line-height: 1.6</span>
|
||||||
@ -1335,14 +1353,14 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
Clear Icon
|
Clear Icon
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-5 text-sm" style={{ color: t.textSecondary }}>
|
<p className="mb-5 text-body-2" style={{ color: t.textSecondary }}>
|
||||||
입력값 초기화 버튼입니다. 텍스트 영역 우상단에 표시됩니다.
|
입력값 초기화 버튼입니다. 텍스트 영역 우상단에 표시됩니다.
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
|
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
{/* 텍스트 있는 상태 (Clear 표시) */}
|
{/* 텍스트 있는 상태 (Clear 표시) */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xs font-mono" style={{ color: t.textSecondary }}>
|
<span className="text-caption font-mono" style={{ color: t.textSecondary }}>
|
||||||
입력값 있음 (Clear 표시)
|
입력값 있음 (Clear 표시)
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
@ -1380,7 +1398,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
|
|
||||||
{/* 빈 상태 (Clear 미표시) */}
|
{/* 빈 상태 (Clear 미표시) */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xs font-mono" style={{ color: t.textSecondary }}>
|
<span className="text-caption font-mono" style={{ color: t.textSecondary }}>
|
||||||
입력값 없음 (Clear 미표시)
|
입력값 없음 (Clear 미표시)
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
@ -1412,7 +1430,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
Helper Text
|
Helper Text
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-5 text-sm" style={{ color: t.textSecondary }}>
|
<p className="mb-5 text-body-2" style={{ color: t.textSecondary }}>
|
||||||
텍스트 영역 하단의 도움말 또는 에러 메시지입니다.
|
텍스트 영역 하단의 도움말 또는 에러 메시지입니다.
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
|
<div className="rounded-xl p-8" style={{ backgroundColor: sectionCardBg }}>
|
||||||
@ -1434,10 +1452,10 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
내용을 입력하세요
|
내용을 입력하세요
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between" style={{ width: '240px' }}>
|
<div className="flex items-center justify-between" style={{ width: '240px' }}>
|
||||||
<span className="text-xs" style={{ color: t.textMuted }}>
|
<span className="text-caption" style={{ color: t.textMuted }}>
|
||||||
상세 내용을 입력해 주세요
|
상세 내용을 입력해 주세요
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-mono" style={{ color: t.textMuted }}>
|
<span className="text-caption font-mono" style={{ color: t.textMuted }}>
|
||||||
0/500
|
0/500
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -1459,7 +1477,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
>
|
>
|
||||||
내용을 입력하세요
|
내용을 입력하세요
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs" style={{ color: '#ef4444' }}>
|
<span className="text-caption" style={{ color: '#ef4444' }}>
|
||||||
필수 입력 항목입니다.
|
필수 입력 항목입니다.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -1480,7 +1498,7 @@ export const TextFieldContent = ({ theme }: TextFieldContentProps) => {
|
|||||||
<div key={`ta-${row.state}`} className="flex items-start gap-8">
|
<div key={`ta-${row.state}`} className="flex items-start gap-8">
|
||||||
{/* 왼쪽: State 라벨 + 뱃지 */}
|
{/* 왼쪽: State 라벨 + 뱃지 */}
|
||||||
<div className="flex items-center gap-2 shrink-0 pt-3" style={{ width: '200px' }}>
|
<div className="flex items-center gap-2 shrink-0 pt-3" style={{ width: '200px' }}>
|
||||||
<span className="font-mono text-xs" style={{ color: t.textSecondary }}>
|
<span className="font-mono text-caption" style={{ color: t.textSecondary }}>
|
||||||
State
|
State
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@ -307,18 +307,18 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
|||||||
<h1 className="font-sans text-3xl leading-9 font-bold" style={{ color: t.textPrimary }}>
|
<h1 className="font-sans text-3xl leading-9 font-bold" style={{ color: t.textPrimary }}>
|
||||||
Typography
|
Typography
|
||||||
</h1>
|
</h1>
|
||||||
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
|
<p className="font-korean text-body-2 leading-5" style={{ color: t.textSecondary }}>
|
||||||
WING-OPS 인터페이스에서 사용되는 타이포그래피 체계입니다. 폰트 패밀리, 크기, 두께를
|
WING-OPS 인터페이스에서 사용되는 타이포그래피 체계입니다. 폰트 패밀리, 크기, 두께를
|
||||||
토큰과 컴포넌트 클래스로 정의하여 시각적 계층 구조와 일관성을 유지합니다.
|
토큰과 컴포넌트 클래스로 정의하여 시각적 계층 구조와 일관성을 유지합니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h3 className="font-korean text-sm font-bold" style={{ color: t.textPrimary }}>
|
<h3 className="font-korean text-body-2 font-bold" style={{ color: t.textPrimary }}>
|
||||||
개요
|
개요
|
||||||
</h3>
|
</h3>
|
||||||
<ul
|
<ul
|
||||||
className="flex flex-col gap-1 list-disc list-inside font-korean text-sm"
|
className="flex flex-col gap-1 list-disc list-inside font-korean text-body-2"
|
||||||
style={{ color: t.textSecondary }}
|
style={{ color: t.textSecondary }}
|
||||||
>
|
>
|
||||||
<li>
|
<li>
|
||||||
@ -337,7 +337,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
|||||||
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
|
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
|
||||||
글꼴
|
글꼴
|
||||||
</h2>
|
</h2>
|
||||||
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
|
<p className="font-korean text-body-2 leading-5" style={{ color: t.textSecondary }}>
|
||||||
사용자의 디바이스 환경을 고려하여, 시스템 폰트와 웹 폰트를 조합하여 사용합니다. 한국어
|
사용자의 디바이스 환경을 고려하여, 시스템 폰트와 웹 폰트를 조합하여 사용합니다. 한국어
|
||||||
UI에 최적화된 폰트 스택으로 다양한 기기에서 일관된 가독성을 보장합니다.
|
UI에 최적화된 폰트 스택으로 다양한 기기에서 일관된 가독성을 보장합니다.
|
||||||
</p>
|
</p>
|
||||||
@ -352,7 +352,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<pre
|
<pre
|
||||||
className="font-mono text-sm leading-6"
|
className="font-mono text-body-2 leading-6"
|
||||||
style={{ color: isDark ? '#c0c8dc' : '#475569' }}
|
style={{ color: isDark ? '#c0c8dc' : '#475569' }}
|
||||||
>
|
>
|
||||||
<span style={{ color: t.textAccent }}>font-family</span>
|
<span style={{ color: t.textAccent }}>font-family</span>
|
||||||
@ -390,7 +390,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-5 flex flex-col gap-4">
|
<div className="px-5 py-5 flex flex-col gap-4">
|
||||||
<div
|
<div
|
||||||
className="font-mono text-xs leading-5 rounded px-3 py-2"
|
className="font-mono text-caption leading-5 rounded px-3 py-2"
|
||||||
style={{
|
style={{
|
||||||
color: isDark ? '#9ba3b8' : '#64748b',
|
color: isDark ? '#9ba3b8' : '#64748b',
|
||||||
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.02)',
|
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.02)',
|
||||||
@ -398,7 +398,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
|||||||
>
|
>
|
||||||
{font.stack}
|
{font.stack}
|
||||||
</div>
|
</div>
|
||||||
<p className="font-korean text-xs leading-5" style={{ color: t.textSecondary }}>{font.usage}</p>
|
<p className="font-korean text-caption leading-5" style={{ color: t.textSecondary }}>{font.usage}</p>
|
||||||
<div className="flex flex-col gap-3 pt-2">
|
<div className="flex flex-col gap-3 pt-2">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="font-mono text-caption uppercase" style={{ letterSpacing: '1px', color: t.textMuted }}>Regular</span>
|
<span className="font-mono text-caption uppercase" style={{ letterSpacing: '1px', color: t.textMuted }}>Regular</span>
|
||||||
@ -421,7 +421,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
|||||||
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
|
<h2 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
|
||||||
타입 스케일
|
타입 스케일
|
||||||
</h2>
|
</h2>
|
||||||
<p className="font-korean text-sm leading-5" style={{ color: t.textSecondary }}>
|
<p className="font-korean text-body-2 leading-5" style={{ color: t.textSecondary }}>
|
||||||
Display, Heading, Body, Navigation, Label의 5가지 용도 카테고리에 맞게 조합하여
|
Display, Heading, Body, Navigation, Label의 5가지 용도 카테고리에 맞게 조합하여
|
||||||
사용합니다.
|
사용합니다.
|
||||||
</p>
|
</p>
|
||||||
@ -434,7 +434,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
|||||||
<h3 className="font-sans text-lg font-bold" style={{ color: t.textPrimary }}>
|
<h3 className="font-sans text-lg font-bold" style={{ color: t.textPrimary }}>
|
||||||
{category.name}
|
{category.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="font-korean text-xs leading-5" style={{ color: t.textSecondary }}>
|
<p className="font-korean text-caption leading-5" style={{ color: t.textSecondary }}>
|
||||||
{category.description}
|
{category.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -445,7 +445,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
|||||||
style={{ border: `1px solid ${isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0'}` }}
|
style={{ border: `1px solid ${isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0'}` }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="grid grid-cols-[1fr_80px_80px_80px_90px_140px] gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider"
|
className="grid grid-cols-[1fr_80px_80px_80px_90px_140px] gap-2 px-4 py-2.5 text-caption font-bold uppercase tracking-wider"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
|
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
|
||||||
color: t.textMuted,
|
color: t.textMuted,
|
||||||
@ -467,20 +467,23 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
|||||||
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
|
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="font-mono text-xs font-semibold" style={{ color: t.textAccent }}>
|
<span
|
||||||
|
className="font-mono text-caption font-semibold"
|
||||||
|
style={{ color: t.textAccent }}
|
||||||
|
>
|
||||||
{row.token}
|
{row.token}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>
|
<span className="font-mono text-caption" style={{ color: t.textPrimary }}>
|
||||||
{row.px}
|
{row.px}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>
|
<span className="font-mono text-caption" style={{ color: t.textPrimary }}>
|
||||||
{row.weight}
|
{row.weight}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="font-mono text-xs"
|
className="font-mono text-caption"
|
||||||
style={{ color: t.textMuted }}
|
style={{ color: t.textMuted }}
|
||||||
>{`${(row.lineHeight * 100).toFixed(0)}%`}</span>
|
>{`${(row.lineHeight * 100).toFixed(0)}%`}</span>
|
||||||
<span className="font-mono text-xs" style={{ color: t.textMuted }}>
|
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
|
||||||
{row.letterSpacing}
|
{row.letterSpacing}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@ -510,7 +513,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
|||||||
>
|
>
|
||||||
<div className="shrink-0 w-[100px]">
|
<div className="shrink-0 w-[100px]">
|
||||||
<div
|
<div
|
||||||
className="font-mono text-xs font-semibold"
|
className="font-mono text-caption font-semibold"
|
||||||
style={{ color: t.textAccent }}
|
style={{ color: t.textAccent }}
|
||||||
>
|
>
|
||||||
{row.token}
|
{row.token}
|
||||||
@ -533,7 +536,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
|||||||
{row.sample}
|
{row.sample}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 text-xs font-korean" style={{ color: t.textMuted }}>
|
<div className="shrink-0 text-caption font-korean" style={{ color: t.textMuted }}>
|
||||||
{row.role}
|
{row.role}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -551,7 +554,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
|||||||
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>
|
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>
|
||||||
폰트 두께 토큰
|
폰트 두께 토큰
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm" style={{ color: t.textSecondary }}>
|
<p className="text-body-2" style={{ color: t.textSecondary }}>
|
||||||
기본 두께 Regular(400), 강조 Bold(700). Medium(500)은 레이블과 소제목, Thin(300)은
|
기본 두께 Regular(400), 강조 Bold(700). Medium(500)은 레이블과 소제목, Thin(300)은
|
||||||
장식적 대형 텍스트에 사용합니다.
|
장식적 대형 텍스트에 사용합니다.
|
||||||
</p>
|
</p>
|
||||||
@ -561,7 +564,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
|||||||
style={{ border: `1px solid ${isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0'}` }}
|
style={{ border: `1px solid ${isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0'}` }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="grid grid-cols-[1fr_80px_1fr_1fr] gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider"
|
className="grid grid-cols-[1fr_80px_1fr_1fr] gap-2 px-4 py-2.5 text-caption font-bold uppercase tracking-wider"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
|
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
|
||||||
color: t.textMuted,
|
color: t.textMuted,
|
||||||
@ -606,17 +609,20 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
|||||||
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
|
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="font-mono text-xs font-semibold" style={{ color: t.textAccent }}>
|
<span
|
||||||
|
className="font-mono text-caption font-semibold"
|
||||||
|
style={{ color: t.textAccent }}
|
||||||
|
>
|
||||||
{row.token}
|
{row.token}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>
|
<span className="font-mono text-caption" style={{ color: t.textPrimary }}>
|
||||||
{row.value}
|
{row.value}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs" style={{ color: t.textSecondary }}>
|
<span className="text-caption" style={{ color: t.textSecondary }}>
|
||||||
{row.name}
|
{row.name}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="font-korean text-sm"
|
className="font-korean text-body-2"
|
||||||
style={{ color: t.textPrimary, fontWeight: Number(row.value) }}
|
style={{ color: t.textPrimary, fontWeight: Number(row.value) }}
|
||||||
>
|
>
|
||||||
{row.preview}
|
{row.preview}
|
||||||
@ -632,7 +638,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
|||||||
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>
|
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>
|
||||||
줄 높이 토큰
|
줄 높이 토큰
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm" style={{ color: t.textSecondary }}>
|
<p className="text-body-2" style={{ color: t.textSecondary }}>
|
||||||
대형 텍스트는 타이트하게(1.3), 본문은 여유롭게(1.6). 가독성과 공간 효율의 균형을
|
대형 텍스트는 타이트하게(1.3), 본문은 여유롭게(1.6). 가독성과 공간 효율의 균형을
|
||||||
맞춥니다.
|
맞춥니다.
|
||||||
</p>
|
</p>
|
||||||
@ -642,7 +648,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
|||||||
style={{ border: `1px solid ${isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0'}` }}
|
style={{ border: `1px solid ${isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0'}` }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="grid grid-cols-[1fr_80px_100px_1fr] gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider"
|
className="grid grid-cols-[1fr_80px_100px_1fr] gap-2 px-4 py-2.5 text-caption font-bold uppercase tracking-wider"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
|
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
|
||||||
color: t.textMuted,
|
color: t.textMuted,
|
||||||
@ -677,16 +683,19 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
|||||||
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
|
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="font-mono text-xs font-semibold" style={{ color: t.textAccent }}>
|
<span
|
||||||
|
className="font-mono text-caption font-semibold"
|
||||||
|
style={{ color: t.textAccent }}
|
||||||
|
>
|
||||||
{row.token}
|
{row.token}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>
|
<span className="font-mono text-caption" style={{ color: t.textPrimary }}>
|
||||||
{row.value}
|
{row.value}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-xs" style={{ color: t.textMuted }}>
|
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
|
||||||
{row.pct}
|
{row.pct}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs" style={{ color: t.textSecondary }}>
|
<span className="text-caption" style={{ color: t.textSecondary }}>
|
||||||
{row.desc}
|
{row.desc}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -700,7 +709,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
|||||||
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>
|
<h2 className="text-xl font-bold mb-2" style={{ color: t.textPrimary }}>
|
||||||
자간 토큰
|
자간 토큰
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm" style={{ color: t.textSecondary }}>
|
<p className="text-body-2" style={{ color: t.textSecondary }}>
|
||||||
카테고리별로 자간을 정의합니다. Display는 넓게, Body는 기본값을 사용합니다.
|
카테고리별로 자간을 정의합니다. Display는 넓게, Body는 기본값을 사용합니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -709,7 +718,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
|||||||
style={{ border: `1px solid ${isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0'}` }}
|
style={{ border: `1px solid ${isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0'}` }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="grid grid-cols-[1fr_80px_140px_1fr] gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider"
|
className="grid grid-cols-[1fr_80px_140px_1fr] gap-2 px-4 py-2.5 text-caption font-bold uppercase tracking-wider"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
|
backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : '#f1f5f9',
|
||||||
color: t.textMuted,
|
color: t.textMuted,
|
||||||
@ -760,10 +769,13 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
|||||||
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
|
backgroundColor: isDark ? 'rgba(255,255,255,0.02)' : '#fafafa',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="font-mono text-xs font-semibold" style={{ color: t.textAccent }}>
|
<span
|
||||||
|
className="font-mono text-caption font-semibold"
|
||||||
|
style={{ color: t.textAccent }}
|
||||||
|
>
|
||||||
{row.token}
|
{row.token}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>
|
<span className="font-mono text-caption" style={{ color: t.textPrimary }}>
|
||||||
{row.value}
|
{row.value}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@ -776,7 +788,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
|||||||
>
|
>
|
||||||
{row.tw}
|
{row.tw}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs" style={{ color: t.textSecondary }}>
|
<span className="text-caption" style={{ color: t.textSecondary }}>
|
||||||
{row.category}
|
{row.category}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -167,7 +167,7 @@ export const ButtonCatalogSection = () => {
|
|||||||
<div className="border-b border-solid border-[#1e2a42] pt-4 pr-6 pb-4 pl-6 flex flex-row gap-3 items-center justify-start self-stretch shrink-0 relative">
|
<div className="border-b border-solid border-[#1e2a42] pt-4 pr-6 pb-4 pl-6 flex flex-row gap-3 items-center justify-start self-stretch shrink-0 relative">
|
||||||
<div className="bg-[#06b6d4] rounded-xl shrink-0 w-1 h-4 relative" />
|
<div className="bg-[#06b6d4] rounded-xl shrink-0 w-1 h-4 relative" />
|
||||||
<div
|
<div
|
||||||
className="text-[#22d3ee] text-left font-korean text-xs leading-4 font-medium uppercase relative flex items-center justify-start"
|
className="text-[#22d3ee] text-left font-korean text-caption leading-4 font-medium uppercase relative flex items-center justify-start"
|
||||||
style={{ letterSpacing: '1.2px' }}
|
style={{ letterSpacing: '1.2px' }}
|
||||||
>
|
>
|
||||||
제어 인터페이스: 버튼
|
제어 인터페이스: 버튼
|
||||||
@ -185,7 +185,7 @@ export const ButtonCatalogSection = () => {
|
|||||||
className="pt-px pr-2 pb-[17.5px] pl-2 flex flex-col gap-0 items-start justify-start flex-1 min-w-0 relative"
|
className="pt-px pr-2 pb-[17.5px] pl-2 flex flex-col gap-0 items-start justify-start flex-1 min-w-0 relative"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="text-[#64748b] text-left font-korean text-xs font-medium uppercase relative flex items-center justify-start"
|
className="text-[#64748b] text-left font-korean text-caption font-medium uppercase relative flex items-center justify-start"
|
||||||
style={{ letterSpacing: '-0.55px' }}
|
style={{ letterSpacing: '-0.55px' }}
|
||||||
>
|
>
|
||||||
{header}
|
{header}
|
||||||
@ -207,7 +207,7 @@ export const ButtonCatalogSection = () => {
|
|||||||
>
|
>
|
||||||
{/* 버튼 유형 레이블 */}
|
{/* 버튼 유형 레이블 */}
|
||||||
<div className="pt-[31.5px] pr-2 pb-[31.5px] pl-2 flex flex-col gap-0 items-start justify-start flex-1 min-w-0 relative">
|
<div className="pt-[31.5px] pr-2 pb-[31.5px] pl-2 flex flex-col gap-0 items-start justify-start flex-1 min-w-0 relative">
|
||||||
<div className="text-[#bcc9cd] text-left font-korean text-xs font-medium relative flex items-center justify-start">
|
<div className="text-[#bcc9cd] text-left font-korean text-caption font-medium relative flex items-center justify-start">
|
||||||
{row.label}
|
{row.label}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -129,7 +129,7 @@ export const CardSection = () => {
|
|||||||
<div className="text-white text-left font-sans font-bold text-4xl leading-10 relative flex items-center justify-start">
|
<div className="text-white text-left font-sans font-bold text-4xl leading-10 relative flex items-center justify-start">
|
||||||
24.8
|
24.8
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[#64748b] text-left font-sans font-semibold text-sm leading-5 relative flex items-center justify-start">
|
<div className="text-[#64748b] text-left font-sans font-semibold text-body-2 leading-5 relative flex items-center justify-start">
|
||||||
노트 (knots)
|
노트 (knots)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -61,7 +61,7 @@ export const IconBadgeSection = () => {
|
|||||||
<div className="bg-[#e89337] rounded-xl shrink-0 w-1 h-4 relative"></div>
|
<div className="bg-[#e89337] rounded-xl shrink-0 w-1 h-4 relative"></div>
|
||||||
<div className="flex flex-col gap-0 items-start justify-start shrink-0 relative">
|
<div className="flex flex-col gap-0 items-start justify-start shrink-0 relative">
|
||||||
<div
|
<div
|
||||||
className="text-[#22d3ee] text-left font-korean text-xs leading-4 font-medium uppercase relative flex items-center justify-start"
|
className="text-[#22d3ee] text-left font-korean text-caption leading-4 font-medium uppercase relative flex items-center justify-start"
|
||||||
style={{ letterSpacing: '1.2px' }}
|
style={{ letterSpacing: '1.2px' }}
|
||||||
>
|
>
|
||||||
마이크로 컨트롤: 아이콘 버튼
|
마이크로 컨트롤: 아이콘 버튼
|
||||||
@ -113,7 +113,7 @@ export const IconBadgeSection = () => {
|
|||||||
<div className="bg-[#93000a] rounded-xl shrink-0 w-1 h-4 relative"></div>
|
<div className="bg-[#93000a] rounded-xl shrink-0 w-1 h-4 relative"></div>
|
||||||
<div className="flex flex-col gap-0 items-start justify-start shrink-0 relative">
|
<div className="flex flex-col gap-0 items-start justify-start shrink-0 relative">
|
||||||
<div
|
<div
|
||||||
className="text-[#22d3ee] text-left font-korean text-xs leading-4 font-medium uppercase relative flex items-center justify-start"
|
className="text-[#22d3ee] text-left font-korean text-caption leading-4 font-medium uppercase relative flex items-center justify-start"
|
||||||
style={{ letterSpacing: '1.2px' }}
|
style={{ letterSpacing: '1.2px' }}
|
||||||
>
|
>
|
||||||
마이크로 컨트롤: 아이콘 버튼
|
마이크로 컨트롤: 아이콘 버튼
|
||||||
|
|||||||
@ -36,10 +36,10 @@ export const FloatDropdownContent = ({ theme }: FloatDropdownContentProps) => {
|
|||||||
<h2 className="font-sans text-xl font-bold" style={{ color: t.textPrimary }}>
|
<h2 className="font-sans text-xl font-bold" style={{ color: t.textPrimary }}>
|
||||||
Dropdown
|
Dropdown
|
||||||
</h2>
|
</h2>
|
||||||
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
|
<p className="font-korean text-body-2 leading-6" style={{ color: t.textSecondary }}>
|
||||||
트리거 요소에{' '}
|
트리거 요소에{' '}
|
||||||
<code
|
<code
|
||||||
className="font-mono text-xs px-1.5 py-0.5 rounded"
|
className="font-mono text-caption px-1.5 py-0.5 rounded"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
|
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
|
||||||
color: t.textAccent,
|
color: t.textAccent,
|
||||||
@ -50,7 +50,7 @@ export const FloatDropdownContent = ({ theme }: FloatDropdownContentProps) => {
|
|||||||
로 부착되는 선택 목록. 5개 이상의 선택지가 있는 단일 선택에 사용한다. 프로젝트 공통
|
로 부착되는 선택 목록. 5개 이상의 선택지가 있는 단일 선택에 사용한다. 프로젝트 공통
|
||||||
컴포넌트는{' '}
|
컴포넌트는{' '}
|
||||||
<code
|
<code
|
||||||
className="font-mono text-xs px-1.5 py-0.5 rounded"
|
className="font-mono text-caption px-1.5 py-0.5 rounded"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
|
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
|
||||||
color: t.textAccent,
|
color: t.textAccent,
|
||||||
@ -84,7 +84,7 @@ export const FloatDropdownContent = ({ theme }: FloatDropdownContentProps) => {
|
|||||||
>
|
>
|
||||||
<div className="grid grid-cols-2 gap-6">
|
<div className="grid grid-cols-2 gap-6">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="font-korean text-xs" style={{ color: t.textMuted }}>
|
<span className="font-korean text-caption" style={{ color: t.textMuted }}>
|
||||||
유출 유형
|
유출 유형
|
||||||
</span>
|
</span>
|
||||||
<ComboBox
|
<ComboBox
|
||||||
@ -98,7 +98,7 @@ export const FloatDropdownContent = ({ theme }: FloatDropdownContentProps) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="font-korean text-xs" style={{ color: t.textMuted }}>
|
<span className="font-korean text-caption" style={{ color: t.textMuted }}>
|
||||||
예측 알고리즘
|
예측 알고리즘
|
||||||
</span>
|
</span>
|
||||||
<ComboBox
|
<ComboBox
|
||||||
@ -112,7 +112,7 @@ export const FloatDropdownContent = ({ theme }: FloatDropdownContentProps) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-korean text-xs" style={{ color: t.textMuted }}>
|
<p className="font-korean text-caption" style={{ color: t.textMuted }}>
|
||||||
위 컴포넌트는{' '}
|
위 컴포넌트는{' '}
|
||||||
<code className="font-mono" style={{ color: t.textAccent }}>
|
<code className="font-mono" style={{ color: t.textAccent }}>
|
||||||
@common/components/ui/ComboBox
|
@common/components/ui/ComboBox
|
||||||
@ -373,7 +373,7 @@ export const FloatDropdownContent = ({ theme }: FloatDropdownContentProps) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="py-2.5 px-4">
|
<div className="py-2.5 px-4">
|
||||||
<span className="font-korean text-xs" style={{ color: t.textSecondary }}>
|
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
|
||||||
{row.desc}
|
{row.desc}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -423,10 +423,16 @@ export const FloatDropdownContent = ({ theme }: FloatDropdownContentProps) => {
|
|||||||
: t.cardBorder,
|
: t.cardBorder,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="font-korean text-sm font-medium" style={{ color: t.textPrimary }}>
|
<span
|
||||||
|
className="font-korean text-body-2 font-medium"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-korean text-xs leading-5" style={{ color: t.textSecondary }}>
|
<span
|
||||||
|
className="font-korean text-caption leading-5"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
{item.desc}
|
{item.desc}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -88,9 +88,9 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
|
|||||||
<h2 className="font-sans text-xl font-bold" style={{ color: t.textPrimary }}>
|
<h2 className="font-sans text-xl font-bold" style={{ color: t.textPrimary }}>
|
||||||
Modal
|
Modal
|
||||||
</h2>
|
</h2>
|
||||||
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
|
<p className="font-korean text-body-2 leading-6" style={{ color: t.textSecondary }}>
|
||||||
<code
|
<code
|
||||||
className="font-mono text-xs px-1.5 py-0.5 rounded"
|
className="font-mono text-caption px-1.5 py-0.5 rounded"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
|
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
|
||||||
color: t.textAccent,
|
color: t.textAccent,
|
||||||
@ -156,7 +156,7 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span className="font-korean text-xs" style={{ color: t.textMuted }}>
|
<span className="font-korean text-caption" style={{ color: t.textMuted }}>
|
||||||
{SIZE_CONFIG[activeSize].desc}
|
{SIZE_CONFIG[activeSize].desc}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -166,7 +166,7 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsModalOpen(true)}
|
onClick={() => setIsModalOpen(true)}
|
||||||
className="px-4 py-2 rounded border border-solid font-korean text-sm font-medium transition-opacity hover:opacity-80"
|
className="px-4 py-2 rounded border border-solid font-korean text-body-2 font-medium transition-opacity hover:opacity-80"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isDark ? 'rgba(76,215,246,0.15)' : 'rgba(6,182,212,0.12)',
|
backgroundColor: isDark ? 'rgba(76,215,246,0.15)' : 'rgba(6,182,212,0.12)',
|
||||||
borderColor: t.textAccent,
|
borderColor: t.textAccent,
|
||||||
@ -178,7 +178,7 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsConfirmOpen(true)}
|
onClick={() => setIsConfirmOpen(true)}
|
||||||
className="px-4 py-2 rounded border border-solid font-korean text-sm font-medium transition-opacity hover:opacity-80"
|
className="px-4 py-2 rounded border border-solid font-korean text-body-2 font-medium transition-opacity hover:opacity-80"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isDark ? 'rgba(239,68,68,0.10)' : 'rgba(239,68,68,0.06)',
|
backgroundColor: isDark ? 'rgba(239,68,68,0.10)' : 'rgba(239,68,68,0.06)',
|
||||||
borderColor: 'rgba(239,68,68,0.40)',
|
borderColor: 'rgba(239,68,68,0.40)',
|
||||||
@ -200,7 +200,7 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
|
|||||||
className="rounded-lg border border-solid p-5 flex flex-col gap-3"
|
className="rounded-lg border border-solid p-5 flex flex-col gap-3"
|
||||||
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
||||||
>
|
>
|
||||||
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
|
<p className="font-korean text-body-2 leading-6" style={{ color: t.textSecondary }}>
|
||||||
Confirm은 독립 컴포넌트가 아닌 <strong>Modal의 variant</strong>다. 타이틀 + 단문 메시지
|
Confirm은 독립 컴포넌트가 아닌 <strong>Modal의 variant</strong>다. 타이틀 + 단문 메시지
|
||||||
+ 취소/확인 2버튼 구성. 파괴적 작업(삭제, 초기화) 전 사용자 의도를 확인한다.
|
+ 취소/확인 2버튼 구성. 파괴적 작업(삭제, 초기화) 전 사용자 의도를 확인한다.
|
||||||
</p>
|
</p>
|
||||||
@ -423,7 +423,7 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
|
|||||||
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="font-mono text-sm rounded border border-solid px-2 py-0.5 shrink-0"
|
className="font-mono text-body-2 rounded border border-solid px-2 py-0.5 shrink-0"
|
||||||
style={{ color: t.textAccent, borderColor: t.cardBorder }}
|
style={{ color: t.textAccent, borderColor: t.cardBorder }}
|
||||||
>
|
>
|
||||||
{row.range}
|
{row.range}
|
||||||
@ -434,7 +434,10 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
|
|||||||
>
|
>
|
||||||
{row.status}
|
{row.status}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-korean text-xs leading-5" style={{ color: t.textSecondary }}>
|
<span
|
||||||
|
className="font-korean text-caption leading-5"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
{row.desc}
|
{row.desc}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -480,7 +483,7 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="py-2.5 px-4">
|
<div className="py-2.5 px-4">
|
||||||
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>
|
<span className="font-mono text-caption" style={{ color: t.textPrimary }}>
|
||||||
{item.component}
|
{item.component}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -496,7 +499,7 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="py-2.5 px-4">
|
<div className="py-2.5 px-4">
|
||||||
<span className="font-korean text-xs" style={{ color: t.textSecondary }}>
|
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
|
||||||
{item.trigger}
|
{item.trigger}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -533,7 +536,10 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
|
|||||||
className="flex items-center justify-between px-5 py-4 border-b border-solid shrink-0"
|
className="flex items-center justify-between px-5 py-4 border-b border-solid shrink-0"
|
||||||
style={{ borderColor: modalBorder }}
|
style={{ borderColor: modalBorder }}
|
||||||
>
|
>
|
||||||
<span className="font-korean text-sm font-medium" style={{ color: t.textPrimary }}>
|
<span
|
||||||
|
className="font-korean text-body-2 font-medium"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
Modal Preview — {SIZE_CONFIG[activeSize].label} ({SIZE_CONFIG[activeSize].width})
|
Modal Preview — {SIZE_CONFIG[activeSize].label} ({SIZE_CONFIG[activeSize].width})
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@ -542,16 +548,16 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
|
|||||||
className="w-7 h-7 rounded flex items-center justify-center hover:opacity-70 transition-opacity"
|
className="w-7 h-7 rounded flex items-center justify-center hover:opacity-70 transition-opacity"
|
||||||
style={{ backgroundColor: isDark ? 'rgba(66,71,84,0.25)' : '#f1f5f9' }}
|
style={{ backgroundColor: isDark ? 'rgba(66,71,84,0.25)' : '#f1f5f9' }}
|
||||||
>
|
>
|
||||||
<span className="font-mono text-sm" style={{ color: t.textMuted }}>
|
<span className="font-mono text-body-2" style={{ color: t.textMuted }}>
|
||||||
✕
|
✕
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-5 flex flex-col gap-3 overflow-y-auto">
|
<div className="px-5 py-5 flex flex-col gap-3 overflow-y-auto">
|
||||||
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
|
<p className="font-korean text-body-2 leading-6" style={{ color: t.textSecondary }}>
|
||||||
이 모달은{' '}
|
이 모달은{' '}
|
||||||
<code
|
<code
|
||||||
className="font-mono text-xs px-1 rounded"
|
className="font-mono text-caption px-1 rounded"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
|
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
|
||||||
color: t.textAccent,
|
color: t.textAccent,
|
||||||
@ -568,7 +574,7 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
|
|||||||
className="rounded border border-solid px-3 py-2.5"
|
className="rounded border border-solid px-3 py-2.5"
|
||||||
style={{ borderColor: t.cardBorder }}
|
style={{ borderColor: t.cardBorder }}
|
||||||
>
|
>
|
||||||
<span className="font-korean text-xs" style={{ color: t.textMuted }}>
|
<span className="font-korean text-caption" style={{ color: t.textMuted }}>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -582,7 +588,7 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsModalOpen(false)}
|
onClick={() => setIsModalOpen(false)}
|
||||||
className="px-4 py-2 rounded border border-solid font-korean text-sm transition-opacity hover:opacity-70"
|
className="px-4 py-2 rounded border border-solid font-korean text-body-2 transition-opacity hover:opacity-70"
|
||||||
style={{ borderColor: t.cardBorder, color: t.textMuted }}
|
style={{ borderColor: t.cardBorder, color: t.textMuted }}
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
@ -590,7 +596,7 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsModalOpen(false)}
|
onClick={() => setIsModalOpen(false)}
|
||||||
className="px-4 py-2 rounded font-korean text-sm font-medium transition-opacity hover:opacity-80"
|
className="px-4 py-2 rounded font-korean text-body-2 font-medium transition-opacity hover:opacity-80"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isDark ? 'rgba(76,215,246,0.18)' : 'rgba(6,182,212,0.14)',
|
backgroundColor: isDark ? 'rgba(76,215,246,0.18)' : 'rgba(6,182,212,0.14)',
|
||||||
color: t.textAccent,
|
color: t.textAccent,
|
||||||
@ -624,13 +630,16 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span style={{ color: '#ef4444', fontSize: '16px' }}>⚠</span>
|
<span style={{ color: '#ef4444', fontSize: '16px' }}>⚠</span>
|
||||||
<span className="font-korean text-sm font-medium" style={{ color: t.textPrimary }}>
|
<span
|
||||||
|
className="font-korean text-body-2 font-medium"
|
||||||
|
style={{ color: t.textPrimary }}
|
||||||
|
>
|
||||||
항목을 삭제하시겠습니까?
|
항목을 삭제하시겠습니까?
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-4">
|
<div className="px-5 py-4">
|
||||||
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
|
<p className="font-korean text-body-2 leading-6" style={{ color: t.textSecondary }}>
|
||||||
삭제된 데이터는 복구할 수 없습니다. 계속 진행하시겠습니까?
|
삭제된 데이터는 복구할 수 없습니다. 계속 진행하시겠습니까?
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -641,7 +650,7 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsConfirmOpen(false)}
|
onClick={() => setIsConfirmOpen(false)}
|
||||||
className="px-4 py-2 rounded border border-solid font-korean text-sm transition-opacity hover:opacity-70"
|
className="px-4 py-2 rounded border border-solid font-korean text-body-2 transition-opacity hover:opacity-70"
|
||||||
style={{ borderColor: t.cardBorder, color: t.textMuted }}
|
style={{ borderColor: t.cardBorder, color: t.textMuted }}
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
@ -649,7 +658,7 @@ export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsConfirmOpen(false)}
|
onClick={() => setIsConfirmOpen(false)}
|
||||||
className="px-4 py-2 rounded font-korean text-sm font-medium transition-opacity hover:opacity-80"
|
className="px-4 py-2 rounded font-korean text-body-2 font-medium transition-opacity hover:opacity-80"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isDark ? 'rgba(239,68,68,0.18)' : 'rgba(239,68,68,0.12)',
|
backgroundColor: isDark ? 'rgba(239,68,68,0.18)' : 'rgba(239,68,68,0.12)',
|
||||||
color: '#ef4444',
|
color: '#ef4444',
|
||||||
|
|||||||
@ -63,10 +63,10 @@ export const FloatOverlayContent = ({ theme }: FloatOverlayContentProps) => {
|
|||||||
<h2 className="font-sans text-xl font-bold" style={{ color: t.textPrimary }}>
|
<h2 className="font-sans text-xl font-bold" style={{ color: t.textPrimary }}>
|
||||||
Overlay
|
Overlay
|
||||||
</h2>
|
</h2>
|
||||||
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
|
<p className="font-korean text-body-2 leading-6" style={{ color: t.textSecondary }}>
|
||||||
지도 컨테이너 위에
|
지도 컨테이너 위에
|
||||||
<code
|
<code
|
||||||
className="font-mono text-xs px-1.5 py-0.5 mx-1 rounded"
|
className="font-mono text-caption px-1.5 py-0.5 mx-1 rounded"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
|
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
|
||||||
color: t.textAccent,
|
color: t.textAccent,
|
||||||
@ -133,12 +133,12 @@ export const FloatOverlayContent = ({ theme }: FloatOverlayContentProps) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="py-2.5 px-4">
|
<div className="py-2.5 px-4">
|
||||||
<span className="font-korean text-xs" style={{ color: t.textSecondary }}>
|
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
|
||||||
{row.overlay}
|
{row.overlay}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="py-2.5 px-4">
|
<div className="py-2.5 px-4">
|
||||||
<span className="font-korean text-xs" style={{ color: t.textSecondary }}>
|
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
|
||||||
{row.modal}
|
{row.modal}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -284,7 +284,7 @@ export const FloatOverlayContent = ({ theme }: FloatOverlayContentProps) => {
|
|||||||
className="rounded-lg border border-solid p-5 flex flex-col gap-4"
|
className="rounded-lg border border-solid p-5 flex flex-col gap-4"
|
||||||
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
||||||
>
|
>
|
||||||
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
|
<p className="font-korean text-body-2 leading-6" style={{ color: t.textSecondary }}>
|
||||||
<strong>ScatPopup</strong>은 지도 마커에 앵커된 컨텍스트 팝업이다. Modal(fixed 뷰포트
|
<strong>ScatPopup</strong>은 지도 마커에 앵커된 컨텍스트 팝업이다. Modal(fixed 뷰포트
|
||||||
중앙)과 달리 마커 위치에서 동적으로 좌표를 계산하며, 지도 패닝·줌 시 위치가 함께
|
중앙)과 달리 마커 위치에서 동적으로 좌표를 계산하며, 지도 패닝·줌 시 위치가 함께
|
||||||
업데이트된다.
|
업데이트된다.
|
||||||
@ -307,7 +307,7 @@ export const FloatOverlayContent = ({ theme }: FloatOverlayContentProps) => {
|
|||||||
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
|
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-xs" style={{ color: t.textAccent }}>
|
<span className="font-mono text-caption" style={{ color: t.textAccent }}>
|
||||||
{item.value}
|
{item.value}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
|
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
|
||||||
@ -323,7 +323,7 @@ export const FloatOverlayContent = ({ theme }: FloatOverlayContentProps) => {
|
|||||||
borderColor: 'rgba(234,179,8,0.25)',
|
borderColor: 'rgba(234,179,8,0.25)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="font-korean text-xs" style={{ color: '#eab308' }}>
|
<span className="font-korean text-caption" style={{ color: '#eab308' }}>
|
||||||
주의: ScatPopup은 MapLibre GL JS의 Popup/Marker 컴포넌트가 아닌 React DOM으로 구현됨.
|
주의: ScatPopup은 MapLibre GL JS의 Popup/Marker 컴포넌트가 아닌 React DOM으로 구현됨.
|
||||||
지도 컨테이너 내부에 position: absolute로 렌더링된다.
|
지도 컨테이너 내부에 position: absolute로 렌더링된다.
|
||||||
</span>
|
</span>
|
||||||
@ -377,7 +377,7 @@ export const FloatOverlayContent = ({ theme }: FloatOverlayContentProps) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="py-2.5 px-3">
|
<div className="py-2.5 px-3">
|
||||||
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>
|
<span className="font-mono text-caption" style={{ color: t.textPrimary }}>
|
||||||
{item.component}
|
{item.component}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -413,7 +413,10 @@ export const FloatOverlayContent = ({ theme }: FloatOverlayContentProps) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="py-2.5 px-3">
|
<div className="py-2.5 px-3">
|
||||||
<span className="font-korean text-xs leading-5" style={{ color: t.textSecondary }}>
|
<span
|
||||||
|
className="font-korean text-caption leading-5"
|
||||||
|
style={{ color: t.textSecondary }}
|
||||||
|
>
|
||||||
{item.desc}
|
{item.desc}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -84,10 +84,10 @@ export const FloatToastContent = ({ theme }: FloatToastContentProps) => {
|
|||||||
미구현 — 설계 사양
|
미구현 — 설계 사양
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
|
<p className="font-korean text-body-2 leading-6" style={{ color: t.textSecondary }}>
|
||||||
화면을 차단하지 않는 비파괴적 알림.
|
화면을 차단하지 않는 비파괴적 알림.
|
||||||
<code
|
<code
|
||||||
className="font-mono text-xs px-1.5 py-0.5 mx-1 rounded"
|
className="font-mono text-caption px-1.5 py-0.5 mx-1 rounded"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
|
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
|
||||||
color: t.textAccent,
|
color: t.textAccent,
|
||||||
@ -97,7 +97,7 @@ export const FloatToastContent = ({ theme }: FloatToastContentProps) => {
|
|||||||
</code>
|
</code>
|
||||||
에 위치하며 일정 시간 후 자동으로 사라진다. 현재 프로젝트에서는{' '}
|
에 위치하며 일정 시간 후 자동으로 사라진다. 현재 프로젝트에서는{' '}
|
||||||
<code
|
<code
|
||||||
className="font-mono text-xs px-1.5 py-0.5 mx-1 rounded"
|
className="font-mono text-caption px-1.5 py-0.5 mx-1 rounded"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isDark ? 'rgba(239,68,68,0.08)' : 'rgba(239,68,68,0.05)',
|
backgroundColor: isDark ? 'rgba(239,68,68,0.08)' : 'rgba(239,68,68,0.05)',
|
||||||
color: '#ef4444',
|
color: '#ef4444',
|
||||||
@ -129,7 +129,7 @@ export const FloatToastContent = ({ theme }: FloatToastContentProps) => {
|
|||||||
className="rounded-lg border border-solid p-5 flex flex-col gap-4"
|
className="rounded-lg border border-solid p-5 flex flex-col gap-4"
|
||||||
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
||||||
>
|
>
|
||||||
<p className="font-korean text-xs" style={{ color: t.textMuted }}>
|
<p className="font-korean text-caption" style={{ color: t.textMuted }}>
|
||||||
버튼 클릭 시 화면 우하단에 Toast가 표시됩니다. 3초 후 자동으로 사라집니다.
|
버튼 클릭 시 화면 우하단에 Toast가 표시됩니다. 3초 후 자동으로 사라집니다.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
@ -280,7 +280,7 @@ export const FloatToastContent = ({ theme }: FloatToastContentProps) => {
|
|||||||
<span className="font-mono text-lg" style={{ color: cfg.color }}>
|
<span className="font-mono text-lg" style={{ color: cfg.color }}>
|
||||||
{cfg.icon}
|
{cfg.icon}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-sm font-bold" style={{ color: cfg.color }}>
|
<span className="font-mono text-body-2 font-bold" style={{ color: cfg.color }}>
|
||||||
{cfg.label}
|
{cfg.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -311,7 +311,7 @@ export const FloatToastContent = ({ theme }: FloatToastContentProps) => {
|
|||||||
className="rounded-lg border border-solid p-5 flex flex-col gap-4"
|
className="rounded-lg border border-solid p-5 flex flex-col gap-4"
|
||||||
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
||||||
>
|
>
|
||||||
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
|
<p className="font-korean text-body-2 leading-6" style={{ color: t.textSecondary }}>
|
||||||
Toast는 앱 어디서든 호출해야 하므로 <strong>Zustand store + useToast hook</strong>{' '}
|
Toast는 앱 어디서든 호출해야 하므로 <strong>Zustand store + useToast hook</strong>{' '}
|
||||||
패턴을 권장한다. ToastContainer는 App.tsx 최상위에 한 번만 렌더링한다.
|
패턴을 권장한다. ToastContainer는 App.tsx 최상위에 한 번만 렌더링한다.
|
||||||
</p>
|
</p>
|
||||||
@ -387,7 +387,7 @@ export const FloatToastContent = ({ theme }: FloatToastContentProps) => {
|
|||||||
<span className="font-mono text-base shrink-0" style={{ color: cfg.color }}>
|
<span className="font-mono text-base shrink-0" style={{ color: cfg.color }}>
|
||||||
{cfg.icon}
|
{cfg.icon}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-korean text-sm flex-1" style={{ color: t.textPrimary }}>
|
<span className="font-korean text-body-2 flex-1" style={{ color: t.textPrimary }}>
|
||||||
{toast.message}
|
{toast.message}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -6,7 +6,7 @@ interface AdminPlaceholderProps {
|
|||||||
const AdminPlaceholder = ({ label }: AdminPlaceholderProps) => (
|
const AdminPlaceholder = ({ label }: AdminPlaceholderProps) => (
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||||
<div className="text-4xl opacity-20">🚧</div>
|
<div className="text-4xl opacity-20">🚧</div>
|
||||||
<div className="text-sm font-korean text-fg-sub font-semibold">{label}</div>
|
<div className="text-body-2 font-korean text-fg-sub font-semibold">{label}</div>
|
||||||
<div className="text-label-2 font-korean text-fg-disabled">해당 기능은 준비 중입니다.</div>
|
<div className="text-label-2 font-korean text-fg-disabled">해당 기능은 준비 중입니다.</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -107,7 +107,7 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
|
|||||||
>
|
>
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="px-4 py-3 border-b border-stroke bg-bg-elevated shrink-0">
|
<div className="px-4 py-3 border-b border-stroke bg-bg-elevated shrink-0">
|
||||||
<div className="text-xs font-bold text-fg font-korean flex items-center gap-1.5">
|
<div className="text-caption font-bold text-fg font-korean flex items-center gap-1.5">
|
||||||
<span>⚙️</span> 관리자 설정
|
<span>⚙️</span> 관리자 설정
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -129,7 +129,7 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
|
|||||||
color: hasActiveChild ? 'var(--color-accent)' : 'var(--fg-default)',
|
color: hasActiveChild ? 'var(--color-accent)' : 'var(--fg-default)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-sm">{section.icon}</span>
|
<span className="text-body-2">{section.icon}</span>
|
||||||
<span className="flex-1 text-left">{section.label}</span>
|
<span className="flex-1 text-left">{section.label}</span>
|
||||||
<span
|
<span
|
||||||
className="text-caption text-fg-disabled transition-transform"
|
className="text-caption text-fg-disabled transition-transform"
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import AdminSidebar from './AdminSidebar';
|
import AdminSidebar from './AdminSidebar';
|
||||||
import AdminPlaceholder from './AdminPlaceholder';
|
import AdminPlaceholder from './AdminPlaceholder';
|
||||||
import { findMenuLabel } from './adminMenuConfig';
|
import { findMenuLabel } from './adminMenuConfig';
|
||||||
@ -19,9 +19,15 @@ import MonitorVesselPanel from './MonitorVesselPanel';
|
|||||||
import CollectHrPanel from './CollectHrPanel';
|
import CollectHrPanel from './CollectHrPanel';
|
||||||
import MonitorForecastPanel from './MonitorForecastPanel';
|
import MonitorForecastPanel from './MonitorForecastPanel';
|
||||||
import VesselMaterialsPanel from './VesselMaterialsPanel';
|
import VesselMaterialsPanel from './VesselMaterialsPanel';
|
||||||
|
import DeidentifyPanel from './DeidentifyPanel';
|
||||||
|
import RndPoseidonPanel from './RndPoseidonPanel';
|
||||||
|
import RndKospsPanel from './RndKospsPanel';
|
||||||
|
import RndHnsAtmosPanel from './RndHnsAtmosPanel';
|
||||||
|
import RndRescuePanel from './RndRescuePanel';
|
||||||
|
import SystemArchPanel from './SystemArchPanel';
|
||||||
|
|
||||||
/** 기존 패널이 있는 메뉴 ID 매핑 */
|
/** 기존 패널이 있는 메뉴 ID 매핑 */
|
||||||
const PANEL_MAP: Record<string, () => JSX.Element> = {
|
const PANEL_MAP: Record<string, () => React.JSX.Element> = {
|
||||||
users: () => <UsersPanel />,
|
users: () => <UsersPanel />,
|
||||||
permissions: () => <PermissionsPanel />,
|
permissions: () => <PermissionsPanel />,
|
||||||
menus: () => <MenusPanel />,
|
menus: () => <MenusPanel />,
|
||||||
@ -42,6 +48,12 @@ const PANEL_MAP: Record<string, () => JSX.Element> = {
|
|||||||
'monitor-vessel': () => <MonitorVesselPanel />,
|
'monitor-vessel': () => <MonitorVesselPanel />,
|
||||||
'collect-hr': () => <CollectHrPanel />,
|
'collect-hr': () => <CollectHrPanel />,
|
||||||
'monitor-forecast': () => <MonitorForecastPanel />,
|
'monitor-forecast': () => <MonitorForecastPanel />,
|
||||||
|
deidentify: () => <DeidentifyPanel />,
|
||||||
|
'rnd-poseidon': () => <RndPoseidonPanel />,
|
||||||
|
'rnd-kosps': () => <RndKospsPanel />,
|
||||||
|
'rnd-hns-atmos': () => <RndHnsAtmosPanel />,
|
||||||
|
'rnd-rescue': () => <RndRescuePanel />,
|
||||||
|
'system-arch': () => <SystemArchPanel />,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AdminView() {
|
export function AdminView() {
|
||||||
|
|||||||
@ -103,7 +103,7 @@ function AssetUploadPanel() {
|
|||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="px-6 py-4 border-b border-stroke flex-shrink-0">
|
<div className="px-6 py-4 border-b border-stroke flex-shrink-0">
|
||||||
<h1 className="text-lg font-bold text-fg font-korean">자산 현행화</h1>
|
<h1 className="text-lg font-bold text-fg font-korean">자산 현행화</h1>
|
||||||
<p className="text-xs text-fg-disabled mt-1 font-korean">
|
<p className="text-caption text-fg-disabled mt-1 font-korean">
|
||||||
자산 데이터를 업로드하여 현행화합니다
|
자산 데이터를 업로드하여 현행화합니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -115,7 +115,7 @@ function AssetUploadPanel() {
|
|||||||
<div className="flex-1 max-w-[560px] space-y-4">
|
<div className="flex-1 max-w-[560px] space-y-4">
|
||||||
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
||||||
<div className="px-5 py-3 border-b border-stroke">
|
<div className="px-5 py-3 border-b border-stroke">
|
||||||
<h2 className="text-sm font-bold text-fg font-korean">파일 업로드</h2>
|
<h2 className="text-body-2 font-bold text-fg font-korean">파일 업로드</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-4 space-y-4">
|
<div className="px-5 py-4 space-y-4">
|
||||||
{/* 드롭존 */}
|
{/* 드롭존 */}
|
||||||
@ -135,12 +135,12 @@ function AssetUploadPanel() {
|
|||||||
>
|
>
|
||||||
<div className="text-3xl mb-2 opacity-40">📁</div>
|
<div className="text-3xl mb-2 opacity-40">📁</div>
|
||||||
{selectedFile ? (
|
{selectedFile ? (
|
||||||
<div className="text-xs font-semibold text-color-accent font-korean mb-1">
|
<div className="text-caption font-semibold text-color-accent font-korean mb-1">
|
||||||
{selectedFile.name}
|
{selectedFile.name}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="text-xs font-semibold text-fg-sub font-korean mb-1">
|
<div className="text-caption font-semibold text-fg-sub font-korean mb-1">
|
||||||
파일을 드래그하거나 클릭하여 업로드
|
파일을 드래그하거나 클릭하여 업로드
|
||||||
</div>
|
</div>
|
||||||
<div className="text-caption text-fg-disabled font-korean mb-3">
|
<div className="text-caption text-fg-disabled font-korean mb-3">
|
||||||
@ -148,7 +148,7 @@ function AssetUploadPanel() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="px-4 py-1.5 text-xs font-semibold rounded-md bg-color-accent text-bg-0
|
className="px-4 py-1.5 text-caption font-semibold rounded-md bg-color-accent text-bg-0
|
||||||
hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
|
hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@ -176,7 +176,7 @@ function AssetUploadPanel() {
|
|||||||
<select
|
<select
|
||||||
value={assetCategory}
|
value={assetCategory}
|
||||||
onChange={(e) => setAssetCategory(e.target.value)}
|
onChange={(e) => setAssetCategory(e.target.value)}
|
||||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md
|
||||||
text-fg focus:border-color-accent focus:outline-none font-korean"
|
text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||||
>
|
>
|
||||||
{ASSET_CATEGORIES.map((c) => (
|
{ASSET_CATEGORIES.map((c) => (
|
||||||
@ -195,7 +195,7 @@ function AssetUploadPanel() {
|
|||||||
<select
|
<select
|
||||||
value={jurisdiction}
|
value={jurisdiction}
|
||||||
onChange={(e) => setJurisdiction(e.target.value)}
|
onChange={(e) => setJurisdiction(e.target.value)}
|
||||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md
|
||||||
text-fg focus:border-color-accent focus:outline-none font-korean"
|
text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||||
>
|
>
|
||||||
{JURISDICTIONS.map((j) => (
|
{JURISDICTIONS.map((j) => (
|
||||||
@ -212,7 +212,7 @@ function AssetUploadPanel() {
|
|||||||
업로드 방식
|
업로드 방식
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs text-fg-sub font-korean">
|
<label className="flex items-center gap-1.5 cursor-pointer text-caption text-fg-sub font-korean">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
checked={uploadMode === 'add'}
|
checked={uploadMode === 'add'}
|
||||||
@ -221,7 +221,7 @@ function AssetUploadPanel() {
|
|||||||
/>
|
/>
|
||||||
추가 (기존 + 신규)
|
추가 (기존 + 신규)
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-1.5 cursor-pointer text-xs text-fg-sub font-korean">
|
<label className="flex items-center gap-1.5 cursor-pointer text-caption text-fg-sub font-korean">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
checked={uploadMode === 'replace'}
|
checked={uploadMode === 'replace'}
|
||||||
@ -238,7 +238,7 @@ function AssetUploadPanel() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleUpload}
|
onClick={handleUpload}
|
||||||
disabled={!selectedFile || uploaded}
|
disabled={!selectedFile || uploaded}
|
||||||
className={`w-full py-2.5 text-xs font-semibold rounded-md transition-all font-korean disabled:opacity-50 ${
|
className={`w-full py-2.5 text-caption font-semibold rounded-md transition-all font-korean disabled:opacity-50 ${
|
||||||
uploaded
|
uploaded
|
||||||
? 'bg-[rgba(34,197,94,0.15)] text-color-success border border-status-green/30'
|
? 'bg-[rgba(34,197,94,0.15)] text-color-success border border-status-green/30'
|
||||||
: 'bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
: 'bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
||||||
@ -255,7 +255,7 @@ function AssetUploadPanel() {
|
|||||||
{/* 수정 권한 체계 */}
|
{/* 수정 권한 체계 */}
|
||||||
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
||||||
<div className="px-5 py-3 border-b border-stroke">
|
<div className="px-5 py-3 border-b border-stroke">
|
||||||
<h2 className="text-sm font-bold text-fg font-korean">수정 권한 체계</h2>
|
<h2 className="text-body-2 font-bold text-fg font-korean">수정 권한 체계</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-4 space-y-2">
|
<div className="px-5 py-4 space-y-2">
|
||||||
{PERM_ITEMS.map((p) => (
|
{PERM_ITEMS.map((p) => (
|
||||||
@ -264,13 +264,15 @@ function AssetUploadPanel() {
|
|||||||
className="flex items-center gap-3 px-4 py-3 bg-bg-elevated border border-stroke rounded-md"
|
className="flex items-center gap-3 px-4 py-3 bg-bg-elevated border border-stroke rounded-md"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm flex-shrink-0"
|
className="w-8 h-8 rounded-full flex items-center justify-center text-body-2 flex-shrink-0"
|
||||||
style={{ background: p.bg }}
|
style={{ background: p.bg }}
|
||||||
>
|
>
|
||||||
{p.icon}
|
{p.icon}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className={`text-xs font-bold font-korean ${p.color}`}>{p.role}</div>
|
<div className={`text-caption font-bold font-korean ${p.color}`}>
|
||||||
|
{p.role}
|
||||||
|
</div>
|
||||||
<div className="text-caption text-fg-disabled font-korean mt-0.5">
|
<div className="text-caption text-fg-disabled font-korean mt-0.5">
|
||||||
{p.desc}
|
{p.desc}
|
||||||
</div>
|
</div>
|
||||||
@ -283,7 +285,7 @@ function AssetUploadPanel() {
|
|||||||
{/* 최근 업로드 이력 */}
|
{/* 최근 업로드 이력 */}
|
||||||
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
||||||
<div className="px-5 py-3 border-b border-stroke">
|
<div className="px-5 py-3 border-b border-stroke">
|
||||||
<h2 className="text-sm font-bold text-fg font-korean">최근 업로드 이력</h2>
|
<h2 className="text-body-2 font-bold text-fg font-korean">최근 업로드 이력</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-4 space-y-2">
|
<div className="px-5 py-4 space-y-2">
|
||||||
{uploadHistory.length === 0 ? (
|
{uploadHistory.length === 0 ? (
|
||||||
@ -297,7 +299,9 @@ function AssetUploadPanel() {
|
|||||||
className="flex justify-between items-center px-4 py-3 bg-bg-elevated border border-stroke rounded-md"
|
className="flex justify-between items-center px-4 py-3 bg-bg-elevated border border-stroke rounded-md"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-semibold text-fg font-korean">{h.fileNm}</div>
|
<div className="text-caption font-semibold text-fg font-korean">
|
||||||
|
{h.fileNm}
|
||||||
|
</div>
|
||||||
<div className="text-caption text-fg-disabled mt-0.5 font-korean">
|
<div className="text-caption text-fg-disabled mt-0.5 font-korean">
|
||||||
{formatDate(h.regDtm)} · {h.uploaderNm} · {h.uploadCnt.toLocaleString()}건
|
{formatDate(h.regDtm)} · {h.uploaderNm} · {h.uploadCnt.toLocaleString()}건
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -119,8 +119,8 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
|
|||||||
<div className="flex flex-col h-full overflow-hidden">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1">
|
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1">
|
||||||
<h2 className="text-sm font-semibold text-fg">게시판 관리</h2>
|
<h2 className="text-body-2 font-semibold text-fg">게시판 관리</h2>
|
||||||
<span className="text-xs text-fg-disabled">총 {data?.totalCount ?? 0}건</span>
|
<span className="text-caption text-fg-disabled">총 {data?.totalCount ?? 0}건</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 카테고리 탭 + 검색 */}
|
{/* 카테고리 탭 + 검색 */}
|
||||||
@ -130,7 +130,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
|
|||||||
<button
|
<button
|
||||||
key={tab.code}
|
key={tab.code}
|
||||||
onClick={() => handleCategoryChange(tab.code)}
|
onClick={() => handleCategoryChange(tab.code)}
|
||||||
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
className={`px-3 py-1 text-caption rounded-full transition-colors ${
|
||||||
activeCategory === tab.code
|
activeCategory === tab.code
|
||||||
? 'bg-blue-500/20 text-blue-400 font-medium'
|
? 'bg-blue-500/20 text-blue-400 font-medium'
|
||||||
: 'text-fg-disabled hover:text-fg-sub hover:bg-bg-elevated'
|
: 'text-fg-disabled hover:text-fg-sub hover:bg-bg-elevated'
|
||||||
@ -146,11 +146,11 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
|
|||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
placeholder="제목/작성자 검색"
|
placeholder="제목/작성자 검색"
|
||||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-fg placeholder:text-text-4 w-48"
|
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-fg placeholder:text-text-4 w-48"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-fg-sub hover:bg-bg-card"
|
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-fg-sub hover:bg-bg-card"
|
||||||
>
|
>
|
||||||
검색
|
검색
|
||||||
</button>
|
</button>
|
||||||
@ -162,7 +162,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
|
|||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={selected.size === 0 || deleting}
|
disabled={selected.size === 0 || deleting}
|
||||||
className="px-3 py-1 text-xs rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-40 disabled:cursor-not-allowed"
|
className="px-3 py-1 text-caption rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{deleting ? '삭제 중...' : `선택 삭제 (${selected.size})`}
|
{deleting ? '삭제 중...' : `선택 삭제 (${selected.size})`}
|
||||||
</button>
|
</button>
|
||||||
@ -170,7 +170,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
|
|||||||
|
|
||||||
{/* 테이블 */}
|
{/* 테이블 */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-caption">
|
||||||
<thead className="sticky top-0 bg-bg-surface z-10">
|
<thead className="sticky top-0 bg-bg-surface z-10">
|
||||||
<tr className="border-b border-stroke-1 text-fg-disabled">
|
<tr className="border-b border-stroke-1 text-fg-disabled">
|
||||||
<th className="w-8 py-2 text-center">
|
<th className="w-8 py-2 text-center">
|
||||||
@ -222,7 +222,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
|
|||||||
<button
|
<button
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
disabled={page <= 1}
|
disabled={page <= 1}
|
||||||
className="px-2 py-1 text-xs rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
|
className="px-2 py-1 text-caption rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
|
||||||
>
|
>
|
||||||
<
|
<
|
||||||
</button>
|
</button>
|
||||||
@ -234,7 +234,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
|
|||||||
<button
|
<button
|
||||||
key={p}
|
key={p}
|
||||||
onClick={() => setPage(p)}
|
onClick={() => setPage(p)}
|
||||||
className={`w-7 h-7 text-xs rounded ${
|
className={`w-7 h-7 text-caption rounded ${
|
||||||
p === page
|
p === page
|
||||||
? 'bg-blue-500/20 text-blue-400 font-medium'
|
? 'bg-blue-500/20 text-blue-400 font-medium'
|
||||||
: 'text-fg-disabled hover:bg-bg-elevated'
|
: 'text-fg-disabled hover:bg-bg-elevated'
|
||||||
@ -247,7 +247,7 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
|
|||||||
<button
|
<button
|
||||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
disabled={page >= totalPages}
|
disabled={page >= totalPages}
|
||||||
className="px-2 py-1 text-xs rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
|
className="px-2 py-1 text-caption rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
|
||||||
>
|
>
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -99,13 +99,15 @@ function CleanupEquipPanel() {
|
|||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-bold text-fg font-korean">방제장비 현황</h1>
|
<h1 className="text-lg font-bold text-fg font-korean">방제장비 현황</h1>
|
||||||
<p className="text-xs text-fg-disabled mt-1 font-korean">총 {filtered.length}개 기관</p>
|
<p className="text-caption text-fg-disabled mt-1 font-korean">
|
||||||
|
총 {filtered.length}개 기관
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<select
|
<select
|
||||||
value={regionFilter}
|
value={regionFilter}
|
||||||
onChange={handleFilterChange(setRegionFilter)}
|
onChange={handleFilterChange(setRegionFilter)}
|
||||||
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
className="px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||||
>
|
>
|
||||||
<option value="전체">전체 관할청</option>
|
<option value="전체">전체 관할청</option>
|
||||||
<option value="남해">남해청</option>
|
<option value="남해">남해청</option>
|
||||||
@ -117,7 +119,7 @@ function CleanupEquipPanel() {
|
|||||||
<select
|
<select
|
||||||
value={typeFilter}
|
value={typeFilter}
|
||||||
onChange={handleFilterChange(setTypeFilter)}
|
onChange={handleFilterChange(setTypeFilter)}
|
||||||
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
className="px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||||
>
|
>
|
||||||
<option value="전체">전체 유형</option>
|
<option value="전체">전체 유형</option>
|
||||||
{typeOptions.map((t) => (
|
{typeOptions.map((t) => (
|
||||||
@ -129,7 +131,7 @@ function CleanupEquipPanel() {
|
|||||||
<select
|
<select
|
||||||
value={equipFilter}
|
value={equipFilter}
|
||||||
onChange={handleFilterChange(setEquipFilter)}
|
onChange={handleFilterChange(setEquipFilter)}
|
||||||
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
className="px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||||
>
|
>
|
||||||
<option value="전체">전체 장비</option>
|
<option value="전체">전체 장비</option>
|
||||||
<option value="방제선">방제선</option>
|
<option value="방제선">방제선</option>
|
||||||
@ -146,11 +148,11 @@ function CleanupEquipPanel() {
|
|||||||
setSearchTerm(e.target.value);
|
setSearchTerm(e.target.value);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
className="w-56 px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
className="w-56 px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={load}
|
onClick={load}
|
||||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-bg-elevated border border-stroke text-fg-sub hover:border-color-accent hover:text-color-accent transition-all font-korean"
|
className="px-4 py-2 text-caption font-semibold rounded-md bg-bg-elevated border border-stroke text-fg-sub hover:border-color-accent hover:text-color-accent transition-all font-korean"
|
||||||
>
|
>
|
||||||
새로고침
|
새로고침
|
||||||
</button>
|
</button>
|
||||||
@ -160,7 +162,7 @@ function CleanupEquipPanel() {
|
|||||||
{/* 테이블 */}
|
{/* 테이블 */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-32 text-fg-disabled text-sm font-korean">
|
<div className="flex items-center justify-center h-32 text-fg-disabled text-body-2 font-korean">
|
||||||
불러오는 중...
|
불러오는 중...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -217,7 +219,7 @@ function CleanupEquipPanel() {
|
|||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={11}
|
colSpan={11}
|
||||||
className="px-6 py-10 text-center text-xs text-fg-disabled font-korean"
|
className="px-6 py-10 text-center text-caption text-fg-disabled font-korean"
|
||||||
>
|
>
|
||||||
조회된 기관이 없습니다.
|
조회된 기관이 없습니다.
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -211,7 +211,7 @@ const HEADERS = [
|
|||||||
function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean }) {
|
function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
<table className="w-full text-xs border-collapse">
|
<table className="w-full text-caption border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||||
{HEADERS.map((h) => (
|
{HEADERS.map((h) => (
|
||||||
@ -317,10 +317,10 @@ export default function CollectHrPanel() {
|
|||||||
<div className="flex flex-col h-full overflow-hidden">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
|
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
|
||||||
<h2 className="text-sm font-semibold text-t1">인사정보 수집 현황</h2>
|
<h2 className="text-body-2 font-semibold text-t1">인사정보 수집 현황</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{lastUpdate && (
|
{lastUpdate && (
|
||||||
<span className="text-xs text-t3">
|
<span className="text-caption text-t3">
|
||||||
갱신:{' '}
|
갱신:{' '}
|
||||||
{lastUpdate.toLocaleTimeString('ko-KR', {
|
{lastUpdate.toLocaleTimeString('ko-KR', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
@ -332,7 +332,7 @@ export default function CollectHrPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={fetchData}
|
onClick={fetchData}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
||||||
@ -354,11 +354,11 @@ export default function CollectHrPanel() {
|
|||||||
|
|
||||||
{/* 상태 표시줄 */}
|
{/* 상태 표시줄 */}
|
||||||
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base">
|
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base">
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-emerald-500/10 text-emerald-400">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-emerald-500/10 text-emerald-400">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
||||||
수집 완료 {completedCount}건
|
수집 완료 {completedCount}건
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-t3">
|
<span className="text-caption text-t3">
|
||||||
전체 {rows.length}건 (활성: {activeCount} / 비활성: {rows.length - activeCount})
|
전체 {rows.length}건 (활성: {activeCount} / 비활성: {rows.length - activeCount})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
1241
frontend/src/tabs/admin/components/DeidentifyPanel.tsx
Normal file
1241
frontend/src/tabs/admin/components/DeidentifyPanel.tsx
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -130,7 +130,9 @@ const DispersingZonePanel = () => {
|
|||||||
onClick={() => handleToggleExpand(zone)}
|
onClick={() => handleToggleExpand(zone)}
|
||||||
>
|
>
|
||||||
<span className={`w-3 h-3 rounded-sm shrink-0 ${swatchColor}`} />
|
<span className={`w-3 h-3 rounded-sm shrink-0 ${swatchColor}`} />
|
||||||
<span className="flex-1 text-xs font-semibold text-fg font-korean">{info.label}</span>
|
<span className="flex-1 text-caption font-semibold text-fg font-korean">
|
||||||
|
{info.label}
|
||||||
|
</span>
|
||||||
{/* 토글 스위치 */}
|
{/* 토글 스위치 */}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@ -209,7 +211,7 @@ const DispersingZonePanel = () => {
|
|||||||
<div className="w-[280px] bg-bg-surface border-l border-stroke flex flex-col overflow-hidden shrink-0">
|
<div className="w-[280px] bg-bg-surface border-l border-stroke flex flex-col overflow-hidden shrink-0">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="px-4 py-4 border-b border-stroke shrink-0">
|
<div className="px-4 py-4 border-b border-stroke shrink-0">
|
||||||
<h1 className="text-sm font-bold text-fg font-korean">유처리제 제한구역</h1>
|
<h1 className="text-body-2 font-bold text-fg font-korean">유처리제 제한구역</h1>
|
||||||
<p className="text-label-2 text-fg-disabled mt-0.5 font-korean">해양환경관리법 기준</p>
|
<p className="text-label-2 text-fg-disabled mt-0.5 font-korean">해양환경관리법 기준</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -186,7 +186,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
|
|||||||
};
|
};
|
||||||
|
|
||||||
const inputCls =
|
const inputCls =
|
||||||
'w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none';
|
'w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none';
|
||||||
const labelCls = 'block text-label-2 font-semibold text-fg-sub font-korean mb-1.5';
|
const labelCls = 'block text-label-2 font-semibold text-fg-sub font-korean mb-1.5';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -194,7 +194,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
|
|||||||
<div className="bg-bg-surface border border-stroke rounded-lg shadow-lg w-[480px] max-h-[90vh] flex flex-col">
|
<div className="bg-bg-surface border border-stroke rounded-lg shadow-lg w-[480px] max-h-[90vh] flex flex-col">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke shrink-0">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke shrink-0">
|
||||||
<h2 className="text-sm font-bold text-fg font-korean">
|
<h2 className="text-body-2 font-bold text-fg font-korean">
|
||||||
{mode === 'create' ? '레이어 등록' : '레이어 수정'}
|
{mode === 'create' ? '레이어 등록' : '레이어 수정'}
|
||||||
</h2>
|
</h2>
|
||||||
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
|
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
|
||||||
@ -214,7 +214,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
|
|||||||
? handleParentChange(e.target.value)
|
? handleParentChange(e.target.value)
|
||||||
: handleField('upLayerCd', e.target.value)
|
: handleField('upLayerCd', e.target.value)
|
||||||
}
|
}
|
||||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none"
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">(없음)</option>
|
<option value="">(없음)</option>
|
||||||
{options
|
{options
|
||||||
@ -311,7 +311,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
|
|||||||
<select
|
<select
|
||||||
value={form.useYn}
|
value={form.useYn}
|
||||||
onChange={(e) => handleField('useYn', e.target.value)}
|
onChange={(e) => handleField('useYn', e.target.value)}
|
||||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none"
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="Y">사용</option>
|
<option value="Y">사용</option>
|
||||||
<option value="N">미사용</option>
|
<option value="N">미사용</option>
|
||||||
@ -329,14 +329,14 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-3 py-1.5 text-xs border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
className="px-3 py-1.5 text-caption border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="px-3 py-1.5 text-xs bg-color-accent text-bg-0 rounded hover:opacity-90 disabled:opacity-50 transition-all font-korean"
|
className="px-3 py-1.5 text-caption bg-color-accent text-bg-0 rounded hover:opacity-90 disabled:opacity-50 transition-all font-korean"
|
||||||
>
|
>
|
||||||
{saving ? '저장 중...' : mode === 'create' ? '등록' : '저장'}
|
{saving ? '저장 중...' : mode === 'create' ? '등록' : '저장'}
|
||||||
</button>
|
</button>
|
||||||
@ -449,11 +449,11 @@ const LayerPanel = () => {
|
|||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-bold text-fg font-korean">레이어 관리</h1>
|
<h1 className="text-lg font-bold text-fg font-korean">레이어 관리</h1>
|
||||||
<p className="text-xs text-fg-disabled mt-1 font-korean">총 {total}개</p>
|
<p className="text-caption text-fg-disabled mt-1 font-korean">총 {total}개</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setModal({ mode: 'create' })}
|
onClick={() => setModal({ mode: 'create' })}
|
||||||
className="px-3 py-1.5 text-xs font-semibold bg-color-accent text-bg-0 rounded hover:opacity-90 transition-opacity font-korean"
|
className="px-3 py-1.5 text-caption font-semibold bg-color-accent text-bg-0 rounded hover:opacity-90 transition-opacity font-korean"
|
||||||
>
|
>
|
||||||
신규 등록
|
신규 등록
|
||||||
</button>
|
</button>
|
||||||
@ -465,12 +465,12 @@ const LayerPanel = () => {
|
|||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
placeholder="레이어코드 / 레이어명 검색"
|
placeholder="레이어코드 / 레이어명 검색"
|
||||||
className="flex-1 px-3 py-1.5 text-xs bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
className="flex-1 px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={filterUseYn}
|
value={filterUseYn}
|
||||||
onChange={(e) => setFilterUseYn(e.target.value)}
|
onChange={(e) => setFilterUseYn(e.target.value)}
|
||||||
className="px-2 py-1.5 text-xs bg-bg-elevated border border-stroke rounded text-fg focus:border-color-accent focus:outline-none font-korean"
|
className="px-2 py-1.5 text-caption bg-bg-elevated border border-stroke rounded text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||||
>
|
>
|
||||||
<option value="">전체</option>
|
<option value="">전체</option>
|
||||||
<option value="Y">사용</option>
|
<option value="Y">사용</option>
|
||||||
@ -478,7 +478,7 @@ const LayerPanel = () => {
|
|||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
onClick={handleSearch}
|
onClick={handleSearch}
|
||||||
className="px-3 py-1.5 text-xs border border-stroke text-fg-sub rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
className="px-3 py-1.5 text-caption border border-stroke text-fg-sub rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||||
>
|
>
|
||||||
검색
|
검색
|
||||||
</button>
|
</button>
|
||||||
@ -487,7 +487,7 @@ const LayerPanel = () => {
|
|||||||
|
|
||||||
{/* 오류 메시지 */}
|
{/* 오류 메시지 */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="px-6 py-2 text-xs text-red-400 bg-[rgba(239,68,68,0.05)] border-b border-stroke shrink-0 font-korean">
|
<div className="px-6 py-2 text-caption text-red-400 bg-[rgba(239,68,68,0.05)] border-b border-stroke shrink-0 font-korean">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -495,7 +495,7 @@ const LayerPanel = () => {
|
|||||||
{/* 테이블 영역 */}
|
{/* 테이블 영역 */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-full text-fg-disabled text-sm font-korean">
|
<div className="flex items-center justify-center h-full text-fg-disabled text-body-2 font-korean">
|
||||||
불러오는 중...
|
불러오는 중...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -539,7 +539,7 @@ const LayerPanel = () => {
|
|||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={10}
|
colSpan={10}
|
||||||
className="px-4 py-12 text-center text-fg-disabled text-sm font-korean"
|
className="px-4 py-12 text-center text-fg-disabled text-body-2 font-korean"
|
||||||
>
|
>
|
||||||
데이터가 없습니다.
|
데이터가 없습니다.
|
||||||
</td>
|
</td>
|
||||||
@ -551,15 +551,15 @@ const LayerPanel = () => {
|
|||||||
className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
||||||
>
|
>
|
||||||
{/* 번호 */}
|
{/* 번호 */}
|
||||||
<td className="px-4 py-3 text-xs text-fg-disabled font-mono">
|
<td className="px-4 py-3 text-caption text-fg-disabled font-mono">
|
||||||
{(page - 1) * PAGE_SIZE + idx + 1}
|
{(page - 1) * PAGE_SIZE + idx + 1}
|
||||||
</td>
|
</td>
|
||||||
{/* 레이어코드 */}
|
{/* 레이어코드 */}
|
||||||
<td className="px-4 py-3 text-label-2 text-fg-sub font-mono">{item.layerCd}</td>
|
<td className="px-4 py-3 text-label-2 text-fg-sub font-mono">{item.layerCd}</td>
|
||||||
{/* 레이어명 */}
|
{/* 레이어명 */}
|
||||||
<td className="px-4 py-3 text-xs text-fg font-korean">{item.layerNm}</td>
|
<td className="px-4 py-3 text-caption text-fg font-korean">{item.layerNm}</td>
|
||||||
{/* 레이어전체명 */}
|
{/* 레이어전체명 */}
|
||||||
<td className="px-4 py-3 text-xs text-fg-sub font-korean max-w-[200px]">
|
<td className="px-4 py-3 text-caption text-fg-sub font-korean max-w-[200px]">
|
||||||
<span className="block truncate" title={item.layerFullNm}>
|
<span className="block truncate" title={item.layerFullNm}>
|
||||||
{item.layerFullNm}
|
{item.layerFullNm}
|
||||||
</span>
|
</span>
|
||||||
@ -575,7 +575,7 @@ const LayerPanel = () => {
|
|||||||
{item.wmsLayerNm ?? <span className="text-fg-disabled">-</span>}
|
{item.wmsLayerNm ?? <span className="text-fg-disabled">-</span>}
|
||||||
</td>
|
</td>
|
||||||
{/* 정렬순서 */}
|
{/* 정렬순서 */}
|
||||||
<td className="px-4 py-3 text-xs text-fg-disabled text-center font-mono">
|
<td className="px-4 py-3 text-caption text-fg-disabled text-center font-mono">
|
||||||
{item.sortOrd}
|
{item.sortOrd}
|
||||||
</td>
|
</td>
|
||||||
{/* 등록일시 */}
|
{/* 등록일시 */}
|
||||||
@ -614,13 +614,13 @@ const LayerPanel = () => {
|
|||||||
<div className="flex items-center justify-center gap-1.5 flex-nowrap">
|
<div className="flex items-center justify-center gap-1.5 flex-nowrap">
|
||||||
<button
|
<button
|
||||||
onClick={() => setModal({ mode: 'edit', data: item })}
|
onClick={() => setModal({ mode: 'edit', data: item })}
|
||||||
className="px-3 py-1 text-xs rounded bg-[rgba(6,182,212,0.15)] text-color-accent hover:bg-[rgba(6,182,212,0.25)] font-korean whitespace-nowrap"
|
className="px-3 py-1 text-caption rounded bg-[rgba(6,182,212,0.15)] text-color-accent hover:bg-[rgba(6,182,212,0.25)] font-korean whitespace-nowrap"
|
||||||
>
|
>
|
||||||
수정
|
수정
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(item.layerCd)}
|
onClick={() => handleDelete(item.layerCd)}
|
||||||
className="px-3 py-1 text-xs rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 font-korean whitespace-nowrap"
|
className="px-3 py-1 text-caption rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 font-korean whitespace-nowrap"
|
||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -78,7 +78,7 @@ function MapBaseModal({
|
|||||||
<div className="bg-bg-surface border border-stroke rounded-lg shadow-lg w-[520px] max-h-[90vh] flex flex-col">
|
<div className="bg-bg-surface border border-stroke rounded-lg shadow-lg w-[520px] max-h-[90vh] flex flex-col">
|
||||||
{/* 모달 헤더 */}
|
{/* 모달 헤더 */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
||||||
<h2 className="text-sm font-bold text-fg font-korean">
|
<h2 className="text-body-2 font-bold text-fg font-korean">
|
||||||
{isEdit ? '지도 수정' : '지도 등록'}
|
{isEdit ? '지도 수정' : '지도 등록'}
|
||||||
</h2>
|
</h2>
|
||||||
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
|
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
|
||||||
@ -108,7 +108,7 @@ function MapBaseModal({
|
|||||||
value={form.mapNm}
|
value={form.mapNm}
|
||||||
onChange={(e) => setField('mapNm', e.target.value)}
|
onChange={(e) => setField('mapNm', e.target.value)}
|
||||||
placeholder="지도 이름을 입력하세요"
|
placeholder="지도 이름을 입력하세요"
|
||||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -123,7 +123,7 @@ function MapBaseModal({
|
|||||||
onChange={(e) => setField('mapKey', e.target.value)}
|
onChange={(e) => setField('mapKey', e.target.value)}
|
||||||
placeholder="고유 식별 키 (영문/숫자)"
|
placeholder="고유 식별 키 (영문/숫자)"
|
||||||
disabled={isEdit}
|
disabled={isEdit}
|
||||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -135,7 +135,7 @@ function MapBaseModal({
|
|||||||
<select
|
<select
|
||||||
value={form.mapLevelCd}
|
value={form.mapLevelCd}
|
||||||
onChange={(e) => setField('mapLevelCd', e.target.value)}
|
onChange={(e) => setField('mapLevelCd', e.target.value)}
|
||||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||||
>
|
>
|
||||||
<option value="">선택</option>
|
<option value="">선택</option>
|
||||||
{MAP_LEVEL_OPTIONS.map((opt) => (
|
{MAP_LEVEL_OPTIONS.map((opt) => (
|
||||||
@ -156,7 +156,7 @@ function MapBaseModal({
|
|||||||
value={form.mapSrc}
|
value={form.mapSrc}
|
||||||
onChange={(e) => setField('mapSrc', e.target.value)}
|
onChange={(e) => setField('mapSrc', e.target.value)}
|
||||||
placeholder="타일 URL 또는 파일 경로"
|
placeholder="타일 URL 또는 파일 경로"
|
||||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -170,7 +170,7 @@ function MapBaseModal({
|
|||||||
value={form.mapDc}
|
value={form.mapDc}
|
||||||
onChange={(e) => setField('mapDc', e.target.value)}
|
onChange={(e) => setField('mapDc', e.target.value)}
|
||||||
placeholder="지도에 대한 설명을 입력하세요"
|
placeholder="지도에 대한 설명을 입력하세요"
|
||||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean resize-none"
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -193,7 +193,7 @@ function MapBaseModal({
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs text-fg-sub font-korean">
|
<span className="text-caption text-fg-sub font-korean">
|
||||||
{form.useYn === 'Y' ? '사용' : '미사용'}
|
{form.useYn === 'Y' ? '사용' : '미사용'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -208,14 +208,14 @@ function MapBaseModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-xs border border-stroke text-fg-sub rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
className="px-4 py-2 text-caption border border-stroke text-fg-sub rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all disabled:opacity-50 font-korean"
|
className="px-4 py-2 text-caption font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all disabled:opacity-50 font-korean"
|
||||||
>
|
>
|
||||||
{saving ? '저장 중...' : isEdit ? '수정' : '등록'}
|
{saving ? '저장 중...' : isEdit ? '수정' : '등록'}
|
||||||
</button>
|
</button>
|
||||||
@ -350,11 +350,11 @@ function MapBasePanel() {
|
|||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-bold text-fg font-korean">지도 관리</h1>
|
<h1 className="text-lg font-bold text-fg font-korean">지도 관리</h1>
|
||||||
<p className="text-xs text-fg-disabled mt-1 font-korean">총 {total}건</p>
|
<p className="text-caption text-fg-disabled mt-1 font-korean">총 {total}건</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => openModal(null)}
|
onClick={() => openModal(null)}
|
||||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
|
className="px-4 py-2 text-caption font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
|
||||||
>
|
>
|
||||||
+ 등록
|
+ 등록
|
||||||
</button>
|
</button>
|
||||||
@ -375,7 +375,7 @@ function MapBasePanel() {
|
|||||||
|
|
||||||
{/* 테이블 영역 */}
|
{/* 테이블 영역 */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-caption">
|
||||||
<thead className="sticky top-0 bg-bg-surface z-10">
|
<thead className="sticky top-0 bg-bg-surface z-10">
|
||||||
<tr className="border-b border-stroke text-fg-disabled">
|
<tr className="border-b border-stroke text-fg-disabled">
|
||||||
<th className="w-12 py-3 text-center">번호</th>
|
<th className="w-12 py-3 text-center">번호</th>
|
||||||
@ -433,7 +433,7 @@ function MapBasePanel() {
|
|||||||
<td className="py-3 text-center">
|
<td className="py-3 text-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => openModal(item)}
|
onClick={() => openModal(item)}
|
||||||
className="px-3 py-1 text-xs rounded bg-[rgba(6,182,212,0.15)] text-color-accent hover:bg-[rgba(6,182,212,0.25)]"
|
className="px-3 py-1 text-caption rounded bg-[rgba(6,182,212,0.15)] text-color-accent hover:bg-[rgba(6,182,212,0.25)]"
|
||||||
>
|
>
|
||||||
수정
|
수정
|
||||||
</button>
|
</button>
|
||||||
@ -441,7 +441,7 @@ function MapBasePanel() {
|
|||||||
<td className="py-3 text-center">
|
<td className="py-3 text-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(item)}
|
onClick={() => handleDelete(item)}
|
||||||
className="px-3 py-1 text-xs rounded bg-red-500/20 text-red-400 hover:bg-red-500/30"
|
className="px-3 py-1 text-caption rounded bg-red-500/20 text-red-400 hover:bg-red-500/30"
|
||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
</button>
|
</button>
|
||||||
@ -452,7 +452,7 @@ function MapBasePanel() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{!loading && items.length === 0 && (
|
{!loading && items.length === 0 && (
|
||||||
<div className="flex items-center justify-center h-32 text-xs text-fg-disabled font-korean">
|
<div className="flex items-center justify-center h-32 text-caption text-fg-disabled font-korean">
|
||||||
등록된 지도가 없습니다.
|
등록된 지도가 없습니다.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -464,7 +464,7 @@ function MapBasePanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
disabled={page <= 1}
|
disabled={page <= 1}
|
||||||
className="px-2 py-1 text-xs rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
|
className="px-2 py-1 text-caption rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
|
||||||
>
|
>
|
||||||
<
|
<
|
||||||
</button>
|
</button>
|
||||||
@ -476,7 +476,7 @@ function MapBasePanel() {
|
|||||||
<button
|
<button
|
||||||
key={p}
|
key={p}
|
||||||
onClick={() => setPage(p)}
|
onClick={() => setPage(p)}
|
||||||
className={`w-7 h-7 text-xs rounded ${
|
className={`w-7 h-7 text-caption rounded ${
|
||||||
p === page
|
p === page
|
||||||
? 'bg-blue-500/20 text-blue-400 font-medium'
|
? 'bg-blue-500/20 text-blue-400 font-medium'
|
||||||
: 'text-fg-disabled hover:bg-bg-elevated'
|
: 'text-fg-disabled hover:bg-bg-elevated'
|
||||||
@ -489,7 +489,7 @@ function MapBasePanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
disabled={page >= totalPages}
|
disabled={page >= totalPages}
|
||||||
className="px-2 py-1 text-xs rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
|
className="px-2 py-1 text-caption rounded text-fg-disabled hover:bg-bg-elevated disabled:opacity-30"
|
||||||
>
|
>
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -124,7 +124,7 @@ function MenusPanel() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-fg-disabled text-sm font-korean">메뉴 설정을 불러오는 중...</div>
|
<div className="text-fg-disabled text-body-2 font-korean">메뉴 설정을 불러오는 중...</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -136,14 +136,14 @@ function MenusPanel() {
|
|||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-bold text-fg font-korean">메뉴 관리</h1>
|
<h1 className="text-lg font-bold text-fg font-korean">메뉴 관리</h1>
|
||||||
<p className="text-xs text-fg-disabled mt-1 font-korean">
|
<p className="text-caption text-fg-disabled mt-1 font-korean">
|
||||||
메뉴 표시 여부, 순서, 라벨, 아이콘을 관리합니다
|
메뉴 표시 여부, 순서, 라벨, 아이콘을 관리합니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={!hasChanges || saving}
|
disabled={!hasChanges || saving}
|
||||||
className={`px-4 py-2 text-xs font-semibold rounded-md transition-all font-korean ${
|
className={`px-4 py-2 text-caption font-semibold rounded-md transition-all font-korean ${
|
||||||
hasChanges && !saving
|
hasChanges && !saving
|
||||||
? 'bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
? 'bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
||||||
: 'bg-bg-card text-fg-disabled cursor-not-allowed'
|
: 'bg-bg-card text-fg-disabled cursor-not-allowed'
|
||||||
@ -188,7 +188,7 @@ function MenusPanel() {
|
|||||||
<DragOverlay>
|
<DragOverlay>
|
||||||
{activeMenu ? (
|
{activeMenu ? (
|
||||||
<div className="flex items-center gap-3 px-4 py-3 rounded-md border border-color-accent bg-bg-surface shadow-lg opacity-90 max-w-[700px]">
|
<div className="flex items-center gap-3 px-4 py-3 rounded-md border border-color-accent bg-bg-surface shadow-lg opacity-90 max-w-[700px]">
|
||||||
<span className="text-fg-disabled text-xs">⠿</span>
|
<span className="text-fg-disabled text-caption">⠿</span>
|
||||||
<span className="text-title-2">{activeMenu.icon}</span>
|
<span className="text-title-2">{activeMenu.icon}</span>
|
||||||
<span className="text-title-4 font-semibold text-fg font-korean">
|
<span className="text-title-4 font-semibold text-fg font-korean">
|
||||||
{activeMenu.label}
|
{activeMenu.label}
|
||||||
|
|||||||
@ -45,24 +45,24 @@ function formatTime(iso: string | null): string {
|
|||||||
|
|
||||||
function StatusCell({ row }: { row: NumericalDataStatus }) {
|
function StatusCell({ row }: { row: NumericalDataStatus }) {
|
||||||
if (row.lastStatus === 'COMPLETED') {
|
if (row.lastStatus === 'COMPLETED') {
|
||||||
return <span className="text-emerald-400 text-xs">정상</span>;
|
return <span className="text-emerald-400 text-caption">정상</span>;
|
||||||
}
|
}
|
||||||
if (row.lastStatus === 'FAILED') {
|
if (row.lastStatus === 'FAILED') {
|
||||||
return (
|
return (
|
||||||
<span className="text-red-400 text-xs">
|
<span className="text-red-400 text-caption">
|
||||||
오류{row.consecutiveFailures > 0 ? ` (${row.consecutiveFailures}회 연속)` : ''}
|
오류{row.consecutiveFailures > 0 ? ` (${row.consecutiveFailures}회 연속)` : ''}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (row.lastStatus === 'STARTED') {
|
if (row.lastStatus === 'STARTED') {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 text-cyan-400 text-xs">
|
<span className="inline-flex items-center gap-1 text-cyan-400 text-caption">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
||||||
실행 중
|
실행 중
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <span className="text-t3 text-xs">-</span>;
|
return <span className="text-t3 text-caption">-</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({
|
function StatusBadge({
|
||||||
@ -76,7 +76,7 @@ function StatusBadge({
|
|||||||
}) {
|
}) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-bg-elevated text-t2">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-bg-elevated text-t2">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
||||||
조회 중...
|
조회 중...
|
||||||
</span>
|
</span>
|
||||||
@ -84,7 +84,7 @@ function StatusBadge({
|
|||||||
}
|
}
|
||||||
if (errorCount === total && total > 0) {
|
if (errorCount === total && total > 0) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-red-500/10 text-red-400">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-red-500/10 text-red-400">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
|
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
|
||||||
연계 오류
|
연계 오류
|
||||||
</span>
|
</span>
|
||||||
@ -92,14 +92,14 @@ function StatusBadge({
|
|||||||
}
|
}
|
||||||
if (errorCount > 0) {
|
if (errorCount > 0) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-yellow-500/10 text-yellow-400">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-yellow-500/10 text-yellow-400">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-yellow-400" />
|
<span className="w-1.5 h-1.5 rounded-full bg-yellow-400" />
|
||||||
일부 오류 ({errorCount}/{total})
|
일부 오류 ({errorCount}/{total})
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-emerald-500/10 text-emerald-400">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-emerald-500/10 text-emerald-400">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
||||||
정상
|
정상
|
||||||
</span>
|
</span>
|
||||||
@ -118,7 +118,7 @@ const TABLE_HEADERS = [
|
|||||||
function ForecastTable({ rows, loading }: { rows: NumericalDataStatus[]; loading: boolean }) {
|
function ForecastTable({ rows, loading }: { rows: NumericalDataStatus[]; loading: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
<table className="w-full text-xs border-collapse">
|
<table className="w-full text-caption border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||||
{TABLE_HEADERS.map((h) => (
|
{TABLE_HEADERS.map((h) => (
|
||||||
@ -193,10 +193,10 @@ export default function MonitorForecastPanel() {
|
|||||||
<div className="flex flex-col h-full overflow-hidden">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
|
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
|
||||||
<h2 className="text-sm font-semibold text-t1">수치예측자료 모니터링</h2>
|
<h2 className="text-body-2 font-semibold text-t1">수치예측자료 모니터링</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{lastUpdate && (
|
{lastUpdate && (
|
||||||
<span className="text-xs text-t3">
|
<span className="text-caption text-t3">
|
||||||
갱신:{' '}
|
갱신:{' '}
|
||||||
{lastUpdate.toLocaleTimeString('ko-KR', {
|
{lastUpdate.toLocaleTimeString('ko-KR', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
@ -208,7 +208,7 @@ export default function MonitorForecastPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => void fetchData()}
|
onClick={() => void fetchData()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
||||||
@ -234,7 +234,7 @@ export default function MonitorForecastPanel() {
|
|||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={`px-4 py-2.5 text-xs font-medium border-b-2 transition-colors ${
|
className={`px-4 py-2.5 text-caption font-medium border-b-2 transition-colors ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'border-cyan-400 text-cyan-400'
|
? 'border-cyan-400 text-cyan-400'
|
||||||
: 'border-transparent text-t3 hover:text-t2'
|
: 'border-transparent text-t3 hover:text-t2'
|
||||||
@ -248,7 +248,9 @@ export default function MonitorForecastPanel() {
|
|||||||
{/* 상태 표시줄 */}
|
{/* 상태 표시줄 */}
|
||||||
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base">
|
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base">
|
||||||
<StatusBadge loading={loading} errorCount={errorCount} total={totalCount} />
|
<StatusBadge loading={loading} errorCount={errorCount} total={totalCount} />
|
||||||
{!loading && totalCount > 0 && <span className="text-xs text-t3">모델 {totalCount}개</span>}
|
{!loading && totalCount > 0 && (
|
||||||
|
<span className="text-caption text-t3">모델 {totalCount}개</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 테이블 */}
|
{/* 테이블 */}
|
||||||
|
|||||||
@ -84,7 +84,7 @@ function StatusBadge({
|
|||||||
}) {
|
}) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-bg-elevated text-t2">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-bg-elevated text-t2">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
||||||
조회 중...
|
조회 중...
|
||||||
</span>
|
</span>
|
||||||
@ -92,7 +92,7 @@ function StatusBadge({
|
|||||||
}
|
}
|
||||||
if (errorCount === total) {
|
if (errorCount === total) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-red-500/10 text-red-400">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-red-500/10 text-red-400">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
|
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
|
||||||
연계 오류
|
연계 오류
|
||||||
</span>
|
</span>
|
||||||
@ -100,14 +100,14 @@ function StatusBadge({
|
|||||||
}
|
}
|
||||||
if (errorCount > 0) {
|
if (errorCount > 0) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-yellow-500/10 text-yellow-400">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-yellow-500/10 text-yellow-400">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-yellow-400" />
|
<span className="w-1.5 h-1.5 rounded-full bg-yellow-400" />
|
||||||
일부 오류 ({errorCount}/{total})
|
일부 오류 ({errorCount}/{total})
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-emerald-500/10 text-emerald-400">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-emerald-500/10 text-emerald-400">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
||||||
정상
|
정상
|
||||||
</span>
|
</span>
|
||||||
@ -130,7 +130,7 @@ function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
<table className="w-full text-xs border-collapse">
|
<table className="w-full text-caption border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||||
{headers.map((h) => (
|
{headers.map((h) => (
|
||||||
@ -172,11 +172,11 @@ function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) {
|
|||||||
<td className="px-3 py-2 text-t2">{fmt(row.data?.tide_level, 0)}</td>
|
<td className="px-3 py-2 text-t2">{fmt(row.data?.tide_level, 0)}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
{row.error ? (
|
{row.error ? (
|
||||||
<span className="text-red-400 text-xs">오류</span>
|
<span className="text-red-400 text-caption">오류</span>
|
||||||
) : row.data ? (
|
) : row.data ? (
|
||||||
<span className="text-emerald-400 text-xs">정상</span>
|
<span className="text-emerald-400 text-caption">정상</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-t3 text-xs">-</span>
|
<span className="text-t3 text-caption">-</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -201,7 +201,7 @@ function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolea
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
<table className="w-full text-xs border-collapse">
|
<table className="w-full text-caption border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||||
{headers.map((h) => (
|
{headers.map((h) => (
|
||||||
@ -241,11 +241,11 @@ function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolea
|
|||||||
<td className="px-3 py-2 text-t2">{fmt(row.data?.humidity, 0)}</td>
|
<td className="px-3 py-2 text-t2">{fmt(row.data?.humidity, 0)}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
{row.error ? (
|
{row.error ? (
|
||||||
<span className="text-red-400 text-xs">오류</span>
|
<span className="text-red-400 text-caption">오류</span>
|
||||||
) : row.data ? (
|
) : row.data ? (
|
||||||
<span className="text-emerald-400 text-xs">정상</span>
|
<span className="text-emerald-400 text-caption">정상</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-t3 text-xs">-</span>
|
<span className="text-t3 text-caption">-</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -261,7 +261,7 @@ function MarineTable({ rows, loading }: { rows: MarineRow[]; loading: boolean })
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
<table className="w-full text-xs border-collapse">
|
<table className="w-full text-caption border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||||
{headers.map((h) => (
|
{headers.map((h) => (
|
||||||
@ -294,11 +294,11 @@ function MarineTable({ rows, loading }: { rows: MarineRow[]; loading: boolean })
|
|||||||
<td className="px-3 py-2 text-t2">{fmt(row.data?.temperature)}</td>
|
<td className="px-3 py-2 text-t2">{fmt(row.data?.temperature)}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
{row.error ? (
|
{row.error ? (
|
||||||
<span className="text-red-400 text-xs">오류</span>
|
<span className="text-red-400 text-caption">오류</span>
|
||||||
) : row.data ? (
|
) : row.data ? (
|
||||||
<span className="text-emerald-400 text-xs">정상</span>
|
<span className="text-emerald-400 text-caption">정상</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-t3 text-xs">-</span>
|
<span className="text-t3 text-caption">-</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -441,10 +441,10 @@ export default function MonitorRealtimePanel() {
|
|||||||
<div className="flex flex-col h-full overflow-hidden">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
|
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
|
||||||
<h2 className="text-sm font-semibold text-t1">실시간 관측자료 모니터링</h2>
|
<h2 className="text-body-2 font-semibold text-t1">실시간 관측자료 모니터링</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{lastUpdate && (
|
{lastUpdate && (
|
||||||
<span className="text-xs text-t3">
|
<span className="text-caption text-t3">
|
||||||
갱신:{' '}
|
갱신:{' '}
|
||||||
{lastUpdate.toLocaleTimeString('ko-KR', {
|
{lastUpdate.toLocaleTimeString('ko-KR', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
@ -456,7 +456,7 @@ export default function MonitorRealtimePanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className={`w-3.5 h-3.5 ${isLoading ? 'animate-spin' : ''}`}
|
className={`w-3.5 h-3.5 ${isLoading ? 'animate-spin' : ''}`}
|
||||||
@ -482,7 +482,7 @@ export default function MonitorRealtimePanel() {
|
|||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={`px-4 py-2.5 text-xs font-medium border-b-2 transition-colors ${
|
className={`px-4 py-2.5 text-caption font-medium border-b-2 transition-colors ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'border-cyan-400 text-cyan-400'
|
? 'border-cyan-400 text-cyan-400'
|
||||||
: 'border-transparent text-t3 hover:text-t2'
|
: 'border-transparent text-t3 hover:text-t2'
|
||||||
@ -496,7 +496,7 @@ export default function MonitorRealtimePanel() {
|
|||||||
{/* 상태 표시줄 */}
|
{/* 상태 표시줄 */}
|
||||||
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base">
|
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base">
|
||||||
<StatusBadge loading={isLoading} errorCount={errorCount} total={totalCount} />
|
<StatusBadge loading={isLoading} errorCount={errorCount} total={totalCount} />
|
||||||
<span className="text-xs text-t3">
|
<span className="text-caption text-t3">
|
||||||
{activeTab === 'khoa' && `관측소 ${totalCount}개`}
|
{activeTab === 'khoa' && `관측소 ${totalCount}개`}
|
||||||
{activeTab === 'kma-ultra' && `지점 ${totalCount}개`}
|
{activeTab === 'kma-ultra' && `지점 ${totalCount}개`}
|
||||||
{activeTab === 'kma-marine' && `해역 ${totalCount}개`}
|
{activeTab === 'kma-marine' && `해역 ${totalCount}개`}
|
||||||
|
|||||||
@ -300,7 +300,7 @@ function StatusBadge({
|
|||||||
}) {
|
}) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-bg-elevated text-t2">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-bg-elevated text-t2">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
||||||
조회 중...
|
조회 중...
|
||||||
</span>
|
</span>
|
||||||
@ -309,7 +309,7 @@ function StatusBadge({
|
|||||||
const offCount = total - onCount;
|
const offCount = total - onCount;
|
||||||
if (offCount === total) {
|
if (offCount === total) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-red-500/10 text-red-400">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-red-500/10 text-red-400">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
|
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
|
||||||
전체 OFF
|
전체 OFF
|
||||||
</span>
|
</span>
|
||||||
@ -317,14 +317,14 @@ function StatusBadge({
|
|||||||
}
|
}
|
||||||
if (offCount > 0) {
|
if (offCount > 0) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-yellow-500/10 text-yellow-400">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-yellow-500/10 text-yellow-400">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-yellow-400" />
|
<span className="w-1.5 h-1.5 rounded-full bg-yellow-400" />
|
||||||
일부 OFF ({offCount}/{total})
|
일부 OFF ({offCount}/{total})
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-emerald-500/10 text-emerald-400">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-caption bg-emerald-500/10 text-emerald-400">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
||||||
전체 정상
|
전체 정상
|
||||||
</span>
|
</span>
|
||||||
@ -376,7 +376,7 @@ const HEADERS = [
|
|||||||
function VesselTable({ rows, loading }: { rows: VesselMonitorRow[]; loading: boolean }) {
|
function VesselTable({ rows, loading }: { rows: VesselMonitorRow[]; loading: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
<table className="w-full text-xs border-collapse">
|
<table className="w-full text-caption border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||||
{HEADERS.map((h) => (
|
{HEADERS.map((h) => (
|
||||||
@ -462,10 +462,10 @@ export default function MonitorVesselPanel() {
|
|||||||
<div className="flex flex-col h-full overflow-hidden">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
|
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke-1 shrink-0">
|
||||||
<h2 className="text-sm font-semibold text-t1">선박위치정보 모니터링</h2>
|
<h2 className="text-body-2 font-semibold text-t1">선박위치정보 모니터링</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{lastUpdate && (
|
{lastUpdate && (
|
||||||
<span className="text-xs text-t3">
|
<span className="text-caption text-t3">
|
||||||
갱신:{' '}
|
갱신:{' '}
|
||||||
{lastUpdate.toLocaleTimeString('ko-KR', {
|
{lastUpdate.toLocaleTimeString('ko-KR', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
@ -477,7 +477,7 @@ export default function MonitorVesselPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={fetchData}
|
onClick={fetchData}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-caption rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
||||||
@ -500,7 +500,7 @@ export default function MonitorVesselPanel() {
|
|||||||
{/* 상태 표시줄 */}
|
{/* 상태 표시줄 */}
|
||||||
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base">
|
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base">
|
||||||
<StatusBadge loading={loading} onCount={onCount} total={rows.length} />
|
<StatusBadge loading={loading} onCount={onCount} total={rows.length} />
|
||||||
<span className="text-xs text-t3">
|
<span className="text-caption text-t3">
|
||||||
연계 채널 {rows.length}개 (ON: {onCount} / OFF: {rows.length - onCount})
|
연계 채널 {rows.length}개 (ON: {onCount} / OFF: {rows.length - onCount})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -557,7 +557,7 @@ function RolePermTab({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center text-fg-disabled text-sm font-korean">
|
<div className="flex-1 flex items-center justify-center text-fg-disabled text-body-2 font-korean">
|
||||||
역할을 선택하세요
|
역할을 선택하세요
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -567,7 +567,7 @@ function RolePermTab({
|
|||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
<div className="w-[400px] bg-bg-surface rounded-lg border border-stroke shadow-2xl">
|
<div className="w-[400px] bg-bg-surface rounded-lg border border-stroke shadow-2xl">
|
||||||
<div className="px-5 py-4 border-b border-stroke">
|
<div className="px-5 py-4 border-b border-stroke">
|
||||||
<h3 className="text-sm font-bold text-fg font-korean">새 역할 추가</h3>
|
<h3 className="text-body-2 font-bold text-fg font-korean">새 역할 추가</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-4 flex flex-col gap-3">
|
<div className="px-5 py-4 flex flex-col gap-3">
|
||||||
<div>
|
<div>
|
||||||
@ -581,7 +581,7 @@ function RolePermTab({
|
|||||||
setNewRoleCode(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, ''))
|
setNewRoleCode(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, ''))
|
||||||
}
|
}
|
||||||
placeholder="CUSTOM_ROLE"
|
placeholder="CUSTOM_ROLE"
|
||||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
||||||
/>
|
/>
|
||||||
<p className="text-caption text-fg-disabled mt-1 font-korean">
|
<p className="text-caption text-fg-disabled mt-1 font-korean">
|
||||||
영문 대문자, 숫자, 언더스코어만 허용 (생성 후 변경 불가)
|
영문 대문자, 숫자, 언더스코어만 허용 (생성 후 변경 불가)
|
||||||
@ -596,7 +596,7 @@ function RolePermTab({
|
|||||||
value={newRoleName}
|
value={newRoleName}
|
||||||
onChange={(e) => setNewRoleName(e.target.value)}
|
onChange={(e) => setNewRoleName(e.target.value)}
|
||||||
placeholder="사용자 정의 역할"
|
placeholder="사용자 정의 역할"
|
||||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -608,7 +608,7 @@ function RolePermTab({
|
|||||||
value={newRoleDesc}
|
value={newRoleDesc}
|
||||||
onChange={(e) => setNewRoleDesc(e.target.value)}
|
onChange={(e) => setNewRoleDesc(e.target.value)}
|
||||||
placeholder="역할에 대한 설명"
|
placeholder="역할에 대한 설명"
|
||||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{createError && (
|
{createError && (
|
||||||
@ -620,14 +620,14 @@ function RolePermTab({
|
|||||||
<div className="px-5 py-3 border-t border-stroke flex justify-end gap-2">
|
<div className="px-5 py-3 border-t border-stroke flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreateForm(false)}
|
onClick={() => setShowCreateForm(false)}
|
||||||
className="px-4 py-2 text-xs text-fg-disabled border border-stroke rounded-md hover:bg-bg-surface-hover font-korean"
|
className="px-4 py-2 text-caption text-fg-disabled border border-stroke rounded-md hover:bg-bg-surface-hover font-korean"
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleCreateRole}
|
onClick={handleCreateRole}
|
||||||
disabled={!newRoleCode || !newRoleName || creating}
|
disabled={!newRoleCode || !newRoleName || creating}
|
||||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean disabled:opacity-50"
|
className="px-4 py-2 text-caption font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{creating ? '생성 중...' : '생성'}
|
{creating ? '생성 중...' : '생성'}
|
||||||
</button>
|
</button>
|
||||||
@ -815,7 +815,7 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
|
|||||||
onFocus={() => setShowDropdown(true)}
|
onFocus={() => setShowDropdown(true)}
|
||||||
placeholder={loadingUsers ? '불러오는 중...' : '이름, 계정, 조직으로 검색...'}
|
placeholder={loadingUsers ? '불러오는 중...' : '이름, 계정, 조직으로 검색...'}
|
||||||
disabled={loadingUsers}
|
disabled={loadingUsers}
|
||||||
className="w-full max-w-sm px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean disabled:opacity-50"
|
className="w-full max-w-sm px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
{showDropdown && filteredUsers.length > 0 && (
|
{showDropdown && filteredUsers.length > 0 && (
|
||||||
<div className="absolute left-0 top-full mt-1 w-full max-w-sm bg-bg-surface border border-stroke rounded-md shadow-xl z-20 overflow-auto max-h-52">
|
<div className="absolute left-0 top-full mt-1 w-full max-w-sm bg-bg-surface border border-stroke rounded-md shadow-xl z-20 overflow-auto max-h-52">
|
||||||
@ -826,7 +826,7 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
|
|||||||
className="w-full px-3 py-2 text-left hover:bg-bg-surface-hover transition-colors flex items-center gap-2"
|
className="w-full px-3 py-2 text-left hover:bg-bg-surface-hover transition-colors flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-xs font-semibold text-fg font-korean truncate">
|
<div className="text-caption font-semibold text-fg font-korean truncate">
|
||||||
{user.name}
|
{user.name}
|
||||||
{user.rank && (
|
{user.rank && (
|
||||||
<span className="ml-1 text-caption text-fg-disabled font-korean">
|
<span className="ml-1 text-caption text-fg-disabled font-korean">
|
||||||
@ -848,7 +848,7 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showDropdown && !loadingUsers && filteredUsers.length === 0 && searchQuery && (
|
{showDropdown && !loadingUsers && filteredUsers.length === 0 && searchQuery && (
|
||||||
<div className="absolute left-0 top-full mt-1 w-full max-w-sm bg-bg-surface border border-stroke rounded-md shadow-xl z-20 px-3 py-2 text-xs text-fg-disabled font-korean">
|
<div className="absolute left-0 top-full mt-1 w-full max-w-sm bg-bg-surface border border-stroke rounded-md shadow-xl z-20 px-3 py-2 text-caption text-fg-disabled font-korean">
|
||||||
검색 결과 없음
|
검색 결과 없음
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -954,13 +954,13 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center text-fg-disabled text-sm font-korean">
|
<div className="flex-1 flex items-center justify-center text-fg-disabled text-body-2 font-korean">
|
||||||
역할을 하나 이상 할당하면 유효 권한이 표시됩니다
|
역할을 하나 이상 할당하면 유효 권한이 표시됩니다
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center text-fg-disabled text-sm font-korean">
|
<div className="flex-1 flex items-center justify-center text-fg-disabled text-body-2 font-korean">
|
||||||
사용자를 선택하세요
|
사용자를 선택하세요
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -1180,7 +1180,7 @@ function PermissionsPanel() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-32 text-fg-disabled text-sm font-korean">
|
<div className="flex items-center justify-center h-32 text-fg-disabled text-body-2 font-korean">
|
||||||
불러오는 중...
|
불러오는 중...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -1194,7 +1194,7 @@ function PermissionsPanel() {
|
|||||||
style={{ flexShrink: 0 }}
|
style={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-sm font-bold text-fg font-korean">권한 관리</h1>
|
<h1 className="text-body-2 font-bold text-fg font-korean">권한 관리</h1>
|
||||||
<p className="text-caption text-fg-disabled mt-0.5 font-korean">
|
<p className="text-caption text-fg-disabled mt-0.5 font-korean">
|
||||||
역할별 리소스 × CRUD 권한 설정
|
역할별 리소스 × CRUD 권한 설정
|
||||||
</p>
|
</p>
|
||||||
@ -1203,7 +1203,7 @@ function PermissionsPanel() {
|
|||||||
<div className="flex items-center gap-1 p-1 bg-bg-elevated rounded-lg border border-stroke">
|
<div className="flex items-center gap-1 p-1 bg-bg-elevated rounded-lg border border-stroke">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('role')}
|
onClick={() => setActiveTab('role')}
|
||||||
className={`px-4 py-1.5 text-xs font-semibold rounded-md transition-all font-korean ${
|
className={`px-4 py-1.5 text-caption font-semibold rounded-md transition-all font-korean ${
|
||||||
activeTab === 'role'
|
activeTab === 'role'
|
||||||
? 'bg-color-accent text-bg-0 shadow-[0_0_8px_rgba(6,182,212,0.25)]'
|
? 'bg-color-accent text-bg-0 shadow-[0_0_8px_rgba(6,182,212,0.25)]'
|
||||||
: 'text-fg-disabled hover:text-fg-sub'
|
: 'text-fg-disabled hover:text-fg-sub'
|
||||||
@ -1213,7 +1213,7 @@ function PermissionsPanel() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('user')}
|
onClick={() => setActiveTab('user')}
|
||||||
className={`px-4 py-1.5 text-xs font-semibold rounded-md transition-all font-korean ${
|
className={`px-4 py-1.5 text-caption font-semibold rounded-md transition-all font-korean ${
|
||||||
activeTab === 'user'
|
activeTab === 'user'
|
||||||
? 'bg-color-accent text-bg-0 shadow-[0_0_8px_rgba(6,182,212,0.25)]'
|
? 'bg-color-accent text-bg-0 shadow-[0_0_8px_rgba(6,182,212,0.25)]'
|
||||||
: 'text-fg-disabled hover:text-fg-sub'
|
: 'text-fg-disabled hover:text-fg-sub'
|
||||||
|
|||||||
638
frontend/src/tabs/admin/components/RndHnsAtmosPanel.tsx
Normal file
638
frontend/src/tabs/admin/components/RndHnsAtmosPanel.tsx
Normal file
@ -0,0 +1,638 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
// ─── 타입 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type PipelineStatus = '정상' | '지연' | '중단';
|
||||||
|
type ReceiveStatus = '수신완료' | '수신대기' | '수신실패' | '시간초과';
|
||||||
|
type ProcessStatus = '처리완료' | '처리중' | '대기' | '오류';
|
||||||
|
type DataSource = 'HYCOM' | '기상청' | '충북대 API';
|
||||||
|
type AlertLevel = '경고' | '주의' | '정보';
|
||||||
|
|
||||||
|
interface PipelineNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: PipelineStatus;
|
||||||
|
lastReceived: string;
|
||||||
|
cycle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataLogRow {
|
||||||
|
id: string;
|
||||||
|
timestamp: string;
|
||||||
|
source: DataSource;
|
||||||
|
dataType: string;
|
||||||
|
size: string;
|
||||||
|
receiveStatus: ReceiveStatus;
|
||||||
|
processStatus: ProcessStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertItem {
|
||||||
|
id: string;
|
||||||
|
level: AlertLevel;
|
||||||
|
message: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mock 데이터 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MOCK_PIPELINE: PipelineNode[] = [
|
||||||
|
{
|
||||||
|
id: 'hycom',
|
||||||
|
name: 'HYCOM 해양순환모델',
|
||||||
|
status: '정상',
|
||||||
|
lastReceived: '2026-04-11 06:00',
|
||||||
|
cycle: '6시간 주기',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kma',
|
||||||
|
name: '기상청 수치모델',
|
||||||
|
status: '정상',
|
||||||
|
lastReceived: '2026-04-11 06:00',
|
||||||
|
cycle: '3시간 주기',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chungbuk-api',
|
||||||
|
name: '충북대 API 서버',
|
||||||
|
status: '정상',
|
||||||
|
lastReceived: '2026-04-11 06:05',
|
||||||
|
cycle: 'API 호출',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'atmos-compute',
|
||||||
|
name: 'HNS 대기확산 연산',
|
||||||
|
status: '정상',
|
||||||
|
lastReceived: '2026-04-11 06:10',
|
||||||
|
cycle: '예측 시작 즉시',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'result-receive',
|
||||||
|
name: '결과 수신',
|
||||||
|
status: '지연',
|
||||||
|
lastReceived: '2026-04-11 06:00',
|
||||||
|
cycle: '연산 완료 즉시',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOCK_LOGS: DataLogRow[] = [
|
||||||
|
{
|
||||||
|
id: 'log-01',
|
||||||
|
timestamp: '2026-04-11 06:10',
|
||||||
|
source: 'HYCOM',
|
||||||
|
dataType: 'SST',
|
||||||
|
size: '98 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-02',
|
||||||
|
timestamp: '2026-04-11 06:10',
|
||||||
|
source: 'HYCOM',
|
||||||
|
dataType: '해류',
|
||||||
|
size: '142 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-03',
|
||||||
|
timestamp: '2026-04-11 06:05',
|
||||||
|
source: '기상청',
|
||||||
|
dataType: '풍향/풍속',
|
||||||
|
size: '38 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-04',
|
||||||
|
timestamp: '2026-04-11 06:05',
|
||||||
|
source: '기상청',
|
||||||
|
dataType: '기압',
|
||||||
|
size: '22 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-05',
|
||||||
|
timestamp: '2026-04-11 06:05',
|
||||||
|
source: '기상청',
|
||||||
|
dataType: '기온',
|
||||||
|
size: '19 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-06',
|
||||||
|
timestamp: '2026-04-11 06:05',
|
||||||
|
source: '기상청',
|
||||||
|
dataType: '대기안정도',
|
||||||
|
size: '14 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-07',
|
||||||
|
timestamp: '2026-04-11 06:07',
|
||||||
|
source: '충북대 API',
|
||||||
|
dataType: 'API 호출 요청',
|
||||||
|
size: '0.2 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-08',
|
||||||
|
timestamp: '2026-04-11 06:15',
|
||||||
|
source: '충북대 API',
|
||||||
|
dataType: 'HNS 확산 결과',
|
||||||
|
size: '-',
|
||||||
|
receiveStatus: '수신대기',
|
||||||
|
processStatus: '대기',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-09',
|
||||||
|
timestamp: '2026-04-11 06:00',
|
||||||
|
source: 'HYCOM',
|
||||||
|
dataType: 'SSH',
|
||||||
|
size: '54 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-10',
|
||||||
|
timestamp: '2026-04-11 03:05',
|
||||||
|
source: '기상청',
|
||||||
|
dataType: '풍향/풍속',
|
||||||
|
size: '37 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-11',
|
||||||
|
timestamp: '2026-04-11 03:07',
|
||||||
|
source: '충북대 API',
|
||||||
|
dataType: 'API 호출 요청',
|
||||||
|
size: '0.2 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-12',
|
||||||
|
timestamp: '2026-04-11 03:20',
|
||||||
|
source: '충북대 API',
|
||||||
|
dataType: 'HNS 확산 결과',
|
||||||
|
size: '12 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-13',
|
||||||
|
timestamp: '2026-04-11 03:20',
|
||||||
|
source: '충북대 API',
|
||||||
|
dataType: '피해범위 데이터',
|
||||||
|
size: '4 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-14',
|
||||||
|
timestamp: '2026-04-11 00:05',
|
||||||
|
source: '기상청',
|
||||||
|
dataType: '기압',
|
||||||
|
size: '23 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-15',
|
||||||
|
timestamp: '2026-04-11 00:00',
|
||||||
|
source: 'HYCOM',
|
||||||
|
dataType: 'SST',
|
||||||
|
size: '97 MB',
|
||||||
|
receiveStatus: '시간초과',
|
||||||
|
processStatus: '오류',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOCK_ALERTS: AlertItem[] = [
|
||||||
|
{
|
||||||
|
id: 'alert-01',
|
||||||
|
level: '주의',
|
||||||
|
message: '충북대 API 결과 수신 지연 — 최근 응답 15분 지연 (2026-04-11 06:15)',
|
||||||
|
timestamp: '2026-04-11 06:30',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'alert-02',
|
||||||
|
level: '정보',
|
||||||
|
message: 'HYCOM 데이터 정상 수신 완료 (2026-04-11 06:00)',
|
||||||
|
timestamp: '2026-04-11 06:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'alert-03',
|
||||||
|
level: '정보',
|
||||||
|
message: '금일 HNS 대기확산 예측 완료: 2회/4회',
|
||||||
|
timestamp: '2026-04-11 06:12',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Mock fetch ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface HnsAtmosData {
|
||||||
|
pipeline: PipelineNode[];
|
||||||
|
logs: DataLogRow[];
|
||||||
|
alerts: AlertItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchHnsAtmosData(): Promise<HnsAtmosData> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(
|
||||||
|
() =>
|
||||||
|
resolve({
|
||||||
|
pipeline: MOCK_PIPELINE,
|
||||||
|
logs: MOCK_LOGS,
|
||||||
|
alerts: MOCK_ALERTS,
|
||||||
|
}),
|
||||||
|
300,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getPipelineStatusStyle(status: PipelineStatus): string {
|
||||||
|
if (status === '정상') return 'text-emerald-400 bg-emerald-500/10';
|
||||||
|
if (status === '지연') return 'text-yellow-400 bg-yellow-500/10';
|
||||||
|
return 'text-red-400 bg-red-500/10';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPipelineBorderStyle(status: PipelineStatus): string {
|
||||||
|
if (status === '정상') return 'border-l-emerald-500';
|
||||||
|
if (status === '지연') return 'border-l-yellow-500';
|
||||||
|
return 'border-l-red-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReceiveStatusStyle(status: ReceiveStatus): string {
|
||||||
|
if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10';
|
||||||
|
if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10';
|
||||||
|
return 'text-red-400 bg-red-500/10';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProcessStatusStyle(status: ProcessStatus): string {
|
||||||
|
if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10';
|
||||||
|
if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10';
|
||||||
|
if (status === '대기') return 'text-yellow-400 bg-yellow-500/10';
|
||||||
|
return 'text-red-400 bg-red-500/10';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlertStyle(level: AlertLevel): string {
|
||||||
|
if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30';
|
||||||
|
if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30';
|
||||||
|
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function PipelineCard({ node }: { node: PipelineNode }) {
|
||||||
|
const badgeStyle = getPipelineStatusStyle(node.status);
|
||||||
|
const borderStyle = getPipelineBorderStyle(node.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
|
||||||
|
<span
|
||||||
|
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
|
||||||
|
>
|
||||||
|
{node.status}
|
||||||
|
</span>
|
||||||
|
<div className="text-label-2 text-t3 mt-0.5">최근 수신: {node.lastReceived}</div>
|
||||||
|
<div className="text-label-2 text-t3">{node.cycle}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: boolean }) {
|
||||||
|
if (loading && nodes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 animate-pulse">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-1">
|
||||||
|
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
|
||||||
|
{i < 4 && <span className="text-t3 text-sm px-0.5">→</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-stretch gap-1">
|
||||||
|
{nodes.map((node, idx) => (
|
||||||
|
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
|
||||||
|
<PipelineCard node={node} />
|
||||||
|
{idx < nodes.length - 1 && (
|
||||||
|
<span className="text-t3 text-sm shrink-0 px-0.5">→</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 수신 이력 테이블 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type FilterSource = 'all' | DataSource;
|
||||||
|
type FilterReceive = 'all' | ReceiveStatus;
|
||||||
|
type FilterPeriod = '6h' | '12h' | '24h';
|
||||||
|
|
||||||
|
const PERIOD_HOURS: Record<FilterPeriod, number> = { '6h': 6, '12h': 12, '24h': 24 };
|
||||||
|
|
||||||
|
function filterLogs(
|
||||||
|
rows: DataLogRow[],
|
||||||
|
source: FilterSource,
|
||||||
|
receive: FilterReceive,
|
||||||
|
period: FilterPeriod,
|
||||||
|
): DataLogRow[] {
|
||||||
|
const cutoff = new Date('2026-04-11T06:30:00');
|
||||||
|
const hours = PERIOD_HOURS[period];
|
||||||
|
const from = new Date(cutoff.getTime() - hours * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
return rows.filter((r) => {
|
||||||
|
if (source !== 'all' && r.source !== source) return false;
|
||||||
|
if (receive !== 'all' && r.receiveStatus !== receive) return false;
|
||||||
|
const ts = new Date(r.timestamp.replace(' ', 'T'));
|
||||||
|
if (ts < from) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '수신상태', '처리상태'];
|
||||||
|
|
||||||
|
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto">
|
||||||
|
<table className="w-full text-xs border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||||
|
{LOG_HEADERS.map((h) => (
|
||||||
|
<th
|
||||||
|
key={h}
|
||||||
|
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading && rows.length === 0
|
||||||
|
? Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<tr key={i} className="border-b border-stroke-1 animate-pulse">
|
||||||
|
{LOG_HEADERS.map((_, j) => (
|
||||||
|
<td key={j} className="px-3 py-2">
|
||||||
|
<div className="h-3 bg-bg-elevated rounded w-16" />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
: rows.map((row) => (
|
||||||
|
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
||||||
|
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
|
||||||
|
{row.timestamp}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
|
||||||
|
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
|
||||||
|
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span
|
||||||
|
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getReceiveStatusStyle(row.receiveStatus)}`}
|
||||||
|
>
|
||||||
|
{row.receiveStatus}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span
|
||||||
|
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getProcessStatusStyle(row.processStatus)}`}
|
||||||
|
>
|
||||||
|
{row.processStatus}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!loading && rows.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-3 py-8 text-center text-t3">
|
||||||
|
조회된 데이터가 없습니다.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 알림 목록 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean }) {
|
||||||
|
if (loading && alerts.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 animate-pulse">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-8 bg-bg-elevated rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alerts.length === 0) {
|
||||||
|
return <p className="text-xs text-t3 py-2">활성 알림이 없습니다.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{alerts.map((alert) => (
|
||||||
|
<div
|
||||||
|
key={alert.id}
|
||||||
|
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
|
||||||
|
>
|
||||||
|
<span className="font-semibold shrink-0">[{alert.level}]</span>
|
||||||
|
<span className="flex-1">{alert.message}</span>
|
||||||
|
<span className="shrink-0 opacity-70 font-mono">{alert.timestamp}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 메인 패널 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function RndHnsAtmosPanel() {
|
||||||
|
const [pipeline, setPipeline] = useState<PipelineNode[]>([]);
|
||||||
|
const [logs, setLogs] = useState<DataLogRow[]>([]);
|
||||||
|
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||||
|
|
||||||
|
// 필터
|
||||||
|
const [filterSource, setFilterSource] = useState<FilterSource>('all');
|
||||||
|
const [filterReceive, setFilterReceive] = useState<FilterReceive>('all');
|
||||||
|
const [filterPeriod, setFilterPeriod] = useState<FilterPeriod>('24h');
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchHnsAtmosData();
|
||||||
|
setPipeline(data.pipeline);
|
||||||
|
setLogs(data.logs);
|
||||||
|
setAlerts(data.alerts);
|
||||||
|
setLastUpdate(new Date());
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
void Promise.resolve().then(() => {
|
||||||
|
if (isMounted) void fetchData();
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const filteredLogs = filterLogs(logs, filterSource, filterReceive, filterPeriod);
|
||||||
|
|
||||||
|
const totalReceived = logs.filter((r) => r.receiveStatus === '수신완료').length;
|
||||||
|
const totalDelayed = logs.filter((r) => r.receiveStatus === '수신대기').length;
|
||||||
|
const totalFailed = logs.filter(
|
||||||
|
(r) => r.receiveStatus === '수신실패' || r.receiveStatus === '시간초과',
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
{/* ── 헤더 ── */}
|
||||||
|
<div className="shrink-0 border-b border-stroke-1">
|
||||||
|
<div className="flex items-center justify-between px-5 py-3">
|
||||||
|
<h2 className="text-sm font-semibold text-t1">HNS 대기확산 (충북대) 연계 모니터링</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{lastUpdate && (
|
||||||
|
<span className="text-xs text-t3">
|
||||||
|
갱신:{' '}
|
||||||
|
{lastUpdate.toLocaleTimeString('ko-KR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => void fetchData()}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 요약 통계 바 */}
|
||||||
|
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
|
||||||
|
<span>
|
||||||
|
정상 수신:{' '}
|
||||||
|
<span className="text-emerald-400 font-medium">{totalReceived}건</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-stroke-1">|</span>
|
||||||
|
<span>
|
||||||
|
지연: <span className="text-yellow-400 font-medium">{totalDelayed}건</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-stroke-1">|</span>
|
||||||
|
<span>
|
||||||
|
실패: <span className="text-red-400 font-medium">{totalFailed}건</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-stroke-1">|</span>
|
||||||
|
<span>
|
||||||
|
금일 예측 완료:{' '}
|
||||||
|
<span className="text-cyan-400 font-medium">2 / 4회</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 스크롤 영역 ── */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{/* 파이프라인 현황 */}
|
||||||
|
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
|
||||||
|
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
|
||||||
|
데이터 파이프라인 현황
|
||||||
|
</h3>
|
||||||
|
<PipelineFlow nodes={pipeline} loading={loading} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 필터 바 + 수신 이력 테이블 */}
|
||||||
|
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
|
||||||
|
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
|
||||||
|
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
|
||||||
|
데이터 수신 이력
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{/* 데이터소스 필터 */}
|
||||||
|
<select
|
||||||
|
value={filterSource}
|
||||||
|
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
|
||||||
|
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="all">모두 (소스)</option>
|
||||||
|
<option value="HYCOM">HYCOM</option>
|
||||||
|
<option value="기상청">기상청</option>
|
||||||
|
<option value="충북대 API">충북대 API</option>
|
||||||
|
</select>
|
||||||
|
{/* 수신상태 필터 */}
|
||||||
|
<select
|
||||||
|
value={filterReceive}
|
||||||
|
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
|
||||||
|
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="all">모두 (상태)</option>
|
||||||
|
<option value="수신완료">수신완료</option>
|
||||||
|
<option value="수신대기">수신대기</option>
|
||||||
|
<option value="수신실패">수신실패</option>
|
||||||
|
<option value="시간초과">시간초과</option>
|
||||||
|
</select>
|
||||||
|
{/* 기간 필터 */}
|
||||||
|
<select
|
||||||
|
value={filterPeriod}
|
||||||
|
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
|
||||||
|
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="6h">최근 6시간</option>
|
||||||
|
<option value="12h">최근 12시간</option>
|
||||||
|
<option value="24h">최근 24시간</option>
|
||||||
|
</select>
|
||||||
|
<span className="text-xs text-t3">{filteredLogs.length}건</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DataLogTable rows={filteredLogs} loading={loading} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 알림 현황 */}
|
||||||
|
<section className="px-5 pt-4 pb-5">
|
||||||
|
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">
|
||||||
|
알림 현황
|
||||||
|
</h3>
|
||||||
|
<AlertList alerts={alerts} loading={loading} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
638
frontend/src/tabs/admin/components/RndKospsPanel.tsx
Normal file
638
frontend/src/tabs/admin/components/RndKospsPanel.tsx
Normal file
@ -0,0 +1,638 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
// ─── 타입 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type PipelineStatus = '정상' | '지연' | '중단';
|
||||||
|
type ReceiveStatus = '수신완료' | '수신대기' | '수신실패' | '시간초과';
|
||||||
|
type ProcessStatus = '처리완료' | '처리중' | '대기' | '오류';
|
||||||
|
type DataSource = 'HYCOM' | '기상청' | 'KOSPS DLL';
|
||||||
|
type AlertLevel = '경고' | '주의' | '정보';
|
||||||
|
|
||||||
|
interface PipelineNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: PipelineStatus;
|
||||||
|
lastReceived: string;
|
||||||
|
cycle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataLogRow {
|
||||||
|
id: string;
|
||||||
|
timestamp: string;
|
||||||
|
source: DataSource;
|
||||||
|
dataType: string;
|
||||||
|
size: string;
|
||||||
|
receiveStatus: ReceiveStatus;
|
||||||
|
processStatus: ProcessStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertItem {
|
||||||
|
id: string;
|
||||||
|
level: AlertLevel;
|
||||||
|
message: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mock 데이터 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MOCK_PIPELINE: PipelineNode[] = [
|
||||||
|
{
|
||||||
|
id: 'hycom',
|
||||||
|
name: 'HYCOM 해양순환모델',
|
||||||
|
status: '정상',
|
||||||
|
lastReceived: '2026-04-11 06:00',
|
||||||
|
cycle: '6시간 주기',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kma',
|
||||||
|
name: '기상청 수치모델',
|
||||||
|
status: '정상',
|
||||||
|
lastReceived: '2026-04-11 06:00',
|
||||||
|
cycle: '3시간 주기',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kosps-server',
|
||||||
|
name: '광주 KOSPS 서버',
|
||||||
|
status: '정상',
|
||||||
|
lastReceived: '2026-04-11 06:05',
|
||||||
|
cycle: '수신 즉시',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fortran-dll',
|
||||||
|
name: 'KOSPS Fortran DLL 연산',
|
||||||
|
status: '지연',
|
||||||
|
lastReceived: '2026-04-11 05:45',
|
||||||
|
cycle: '예측 시작 즉시',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'result-api',
|
||||||
|
name: '결과 수신 API',
|
||||||
|
status: '정상',
|
||||||
|
lastReceived: '2026-04-11 06:10',
|
||||||
|
cycle: '예측 완료 즉시',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOCK_LOGS: DataLogRow[] = [
|
||||||
|
{
|
||||||
|
id: 'log-01',
|
||||||
|
timestamp: '2026-04-11 06:10',
|
||||||
|
source: 'KOSPS DLL',
|
||||||
|
dataType: 'DLL 응답 결과',
|
||||||
|
size: '28 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-02',
|
||||||
|
timestamp: '2026-04-11 06:05',
|
||||||
|
source: '기상청',
|
||||||
|
dataType: '풍향/풍속',
|
||||||
|
size: '38 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-03',
|
||||||
|
timestamp: '2026-04-11 06:05',
|
||||||
|
source: '기상청',
|
||||||
|
dataType: '기압',
|
||||||
|
size: '22 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-04',
|
||||||
|
timestamp: '2026-04-11 06:00',
|
||||||
|
source: 'HYCOM',
|
||||||
|
dataType: '해수면온도(SST)',
|
||||||
|
size: '98 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-05',
|
||||||
|
timestamp: '2026-04-11 06:00',
|
||||||
|
source: 'HYCOM',
|
||||||
|
dataType: '해류(U/V)',
|
||||||
|
size: '142 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-06',
|
||||||
|
timestamp: '2026-04-11 06:00',
|
||||||
|
source: '기상청',
|
||||||
|
dataType: '기온',
|
||||||
|
size: '19 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-07',
|
||||||
|
timestamp: '2026-04-11 05:55',
|
||||||
|
source: 'KOSPS DLL',
|
||||||
|
dataType: 'DLL 호출 요청',
|
||||||
|
size: '3 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리중',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-08',
|
||||||
|
timestamp: '2026-04-11 05:45',
|
||||||
|
source: 'KOSPS DLL',
|
||||||
|
dataType: 'DLL 응답 결과',
|
||||||
|
size: '-',
|
||||||
|
receiveStatus: '수신대기',
|
||||||
|
processStatus: '대기',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-09',
|
||||||
|
timestamp: '2026-04-11 03:10',
|
||||||
|
source: 'HYCOM',
|
||||||
|
dataType: '해류(U/V)',
|
||||||
|
size: '140 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-10',
|
||||||
|
timestamp: '2026-04-11 03:05',
|
||||||
|
source: '기상청',
|
||||||
|
dataType: '풍향/풍속',
|
||||||
|
size: '37 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-11',
|
||||||
|
timestamp: '2026-04-11 03:00',
|
||||||
|
source: 'HYCOM',
|
||||||
|
dataType: '해수면높이(SSH)',
|
||||||
|
size: '54 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-12',
|
||||||
|
timestamp: '2026-04-11 03:00',
|
||||||
|
source: 'KOSPS DLL',
|
||||||
|
dataType: 'DLL 응답 결과',
|
||||||
|
size: '27 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-13',
|
||||||
|
timestamp: '2026-04-11 00:05',
|
||||||
|
source: '기상청',
|
||||||
|
dataType: '기압',
|
||||||
|
size: '23 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-14',
|
||||||
|
timestamp: '2026-04-11 00:00',
|
||||||
|
source: 'HYCOM',
|
||||||
|
dataType: '해수면높이(SSH)',
|
||||||
|
size: '53 MB',
|
||||||
|
receiveStatus: '시간초과',
|
||||||
|
processStatus: '오류',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-15',
|
||||||
|
timestamp: '2026-04-11 00:00',
|
||||||
|
source: 'KOSPS DLL',
|
||||||
|
dataType: 'DLL 호출 요청',
|
||||||
|
size: '-',
|
||||||
|
receiveStatus: '수신실패',
|
||||||
|
processStatus: '오류',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOCK_ALERTS: AlertItem[] = [
|
||||||
|
{
|
||||||
|
id: 'alert-01',
|
||||||
|
level: '경고',
|
||||||
|
message: 'KOSPS Fortran DLL 응답 지연 — 평균 응답시간 초과',
|
||||||
|
timestamp: '2026-04-11 05:45',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'alert-02',
|
||||||
|
level: '주의',
|
||||||
|
message: 'HYCOM SSH 데이터 다음 수신 예정: 09:00',
|
||||||
|
timestamp: '2026-04-11 06:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'alert-03',
|
||||||
|
level: '정보',
|
||||||
|
message: '금일 KOSPS 예측 완료: 3회 / 6회',
|
||||||
|
timestamp: '2026-04-11 06:12',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Mock fetch ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface KospsData {
|
||||||
|
pipeline: PipelineNode[];
|
||||||
|
logs: DataLogRow[];
|
||||||
|
alerts: AlertItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchKospsData(): Promise<KospsData> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(
|
||||||
|
() =>
|
||||||
|
resolve({
|
||||||
|
pipeline: MOCK_PIPELINE,
|
||||||
|
logs: MOCK_LOGS,
|
||||||
|
alerts: MOCK_ALERTS,
|
||||||
|
}),
|
||||||
|
300,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getPipelineStatusStyle(status: PipelineStatus): string {
|
||||||
|
if (status === '정상') return 'text-emerald-400 bg-emerald-500/10';
|
||||||
|
if (status === '지연') return 'text-yellow-400 bg-yellow-500/10';
|
||||||
|
return 'text-red-400 bg-red-500/10';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPipelineBorderStyle(status: PipelineStatus): string {
|
||||||
|
if (status === '정상') return 'border-l-emerald-500';
|
||||||
|
if (status === '지연') return 'border-l-yellow-500';
|
||||||
|
return 'border-l-red-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReceiveStatusStyle(status: ReceiveStatus): string {
|
||||||
|
if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10';
|
||||||
|
if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10';
|
||||||
|
return 'text-red-400 bg-red-500/10';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProcessStatusStyle(status: ProcessStatus): string {
|
||||||
|
if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10';
|
||||||
|
if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10';
|
||||||
|
if (status === '대기') return 'text-yellow-400 bg-yellow-500/10';
|
||||||
|
return 'text-red-400 bg-red-500/10';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlertStyle(level: AlertLevel): string {
|
||||||
|
if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30';
|
||||||
|
if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30';
|
||||||
|
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function PipelineCard({ node }: { node: PipelineNode }) {
|
||||||
|
const badgeStyle = getPipelineStatusStyle(node.status);
|
||||||
|
const borderStyle = getPipelineBorderStyle(node.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
|
||||||
|
<span
|
||||||
|
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
|
||||||
|
>
|
||||||
|
{node.status}
|
||||||
|
</span>
|
||||||
|
<div className="text-label-2 text-t3 mt-0.5">최근 수신: {node.lastReceived}</div>
|
||||||
|
<div className="text-label-2 text-t3">{node.cycle}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: boolean }) {
|
||||||
|
if (loading && nodes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 animate-pulse">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-1">
|
||||||
|
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
|
||||||
|
{i < 4 && <span className="text-t3 text-sm px-0.5">→</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-stretch gap-1">
|
||||||
|
{nodes.map((node, idx) => (
|
||||||
|
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
|
||||||
|
<PipelineCard node={node} />
|
||||||
|
{idx < nodes.length - 1 && (
|
||||||
|
<span className="text-t3 text-sm shrink-0 px-0.5">→</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 수신 이력 테이블 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type FilterSource = 'all' | DataSource;
|
||||||
|
type FilterReceive = 'all' | ReceiveStatus;
|
||||||
|
type FilterPeriod = '6h' | '12h' | '24h';
|
||||||
|
|
||||||
|
const PERIOD_HOURS: Record<FilterPeriod, number> = { '6h': 6, '12h': 12, '24h': 24 };
|
||||||
|
|
||||||
|
function filterLogs(
|
||||||
|
rows: DataLogRow[],
|
||||||
|
source: FilterSource,
|
||||||
|
receive: FilterReceive,
|
||||||
|
period: FilterPeriod,
|
||||||
|
): DataLogRow[] {
|
||||||
|
const cutoff = new Date('2026-04-11T06:30:00');
|
||||||
|
const hours = PERIOD_HOURS[period];
|
||||||
|
const from = new Date(cutoff.getTime() - hours * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
return rows.filter((r) => {
|
||||||
|
if (source !== 'all' && r.source !== source) return false;
|
||||||
|
if (receive !== 'all' && r.receiveStatus !== receive) return false;
|
||||||
|
const ts = new Date(r.timestamp.replace(' ', 'T'));
|
||||||
|
if (ts < from) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '수신상태', '처리상태'];
|
||||||
|
|
||||||
|
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto">
|
||||||
|
<table className="w-full text-xs border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||||
|
{LOG_HEADERS.map((h) => (
|
||||||
|
<th
|
||||||
|
key={h}
|
||||||
|
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading && rows.length === 0
|
||||||
|
? Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<tr key={i} className="border-b border-stroke-1 animate-pulse">
|
||||||
|
{LOG_HEADERS.map((_, j) => (
|
||||||
|
<td key={j} className="px-3 py-2">
|
||||||
|
<div className="h-3 bg-bg-elevated rounded w-16" />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
: rows.map((row) => (
|
||||||
|
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
||||||
|
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
|
||||||
|
{row.timestamp}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
|
||||||
|
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
|
||||||
|
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span
|
||||||
|
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getReceiveStatusStyle(row.receiveStatus)}`}
|
||||||
|
>
|
||||||
|
{row.receiveStatus}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span
|
||||||
|
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getProcessStatusStyle(row.processStatus)}`}
|
||||||
|
>
|
||||||
|
{row.processStatus}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!loading && rows.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-3 py-8 text-center text-t3">
|
||||||
|
조회된 데이터가 없습니다.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 알림 목록 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean }) {
|
||||||
|
if (loading && alerts.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 animate-pulse">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-8 bg-bg-elevated rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alerts.length === 0) {
|
||||||
|
return <p className="text-xs text-t3 py-2">활성 알림이 없습니다.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{alerts.map((alert) => (
|
||||||
|
<div
|
||||||
|
key={alert.id}
|
||||||
|
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
|
||||||
|
>
|
||||||
|
<span className="font-semibold shrink-0">[{alert.level}]</span>
|
||||||
|
<span className="flex-1">{alert.message}</span>
|
||||||
|
<span className="shrink-0 opacity-70 font-mono">{alert.timestamp}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 메인 패널 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function RndKospsPanel() {
|
||||||
|
const [pipeline, setPipeline] = useState<PipelineNode[]>([]);
|
||||||
|
const [logs, setLogs] = useState<DataLogRow[]>([]);
|
||||||
|
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||||
|
|
||||||
|
// 필터
|
||||||
|
const [filterSource, setFilterSource] = useState<FilterSource>('all');
|
||||||
|
const [filterReceive, setFilterReceive] = useState<FilterReceive>('all');
|
||||||
|
const [filterPeriod, setFilterPeriod] = useState<FilterPeriod>('24h');
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchKospsData();
|
||||||
|
setPipeline(data.pipeline);
|
||||||
|
setLogs(data.logs);
|
||||||
|
setAlerts(data.alerts);
|
||||||
|
setLastUpdate(new Date());
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
void Promise.resolve().then(() => {
|
||||||
|
if (isMounted) void fetchData();
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const filteredLogs = filterLogs(logs, filterSource, filterReceive, filterPeriod);
|
||||||
|
|
||||||
|
const totalReceived = logs.filter((r) => r.receiveStatus === '수신완료').length;
|
||||||
|
const totalDelayed = logs.filter((r) => r.receiveStatus === '수신대기').length;
|
||||||
|
const totalFailed = logs.filter(
|
||||||
|
(r) => r.receiveStatus === '수신실패' || r.receiveStatus === '시간초과',
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
{/* ── 헤더 ── */}
|
||||||
|
<div className="shrink-0 border-b border-stroke-1">
|
||||||
|
<div className="flex items-center justify-between px-5 py-3">
|
||||||
|
<h2 className="text-sm font-semibold text-t1">유출유확산예측 (KOSPS) 연계 모니터링</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{lastUpdate && (
|
||||||
|
<span className="text-xs text-t3">
|
||||||
|
갱신:{' '}
|
||||||
|
{lastUpdate.toLocaleTimeString('ko-KR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => void fetchData()}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 요약 통계 바 */}
|
||||||
|
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
|
||||||
|
<span>
|
||||||
|
정상 수신:{' '}
|
||||||
|
<span className="text-emerald-400 font-medium">{totalReceived}건</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-stroke-1">|</span>
|
||||||
|
<span>
|
||||||
|
지연: <span className="text-yellow-400 font-medium">{totalDelayed}건</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-stroke-1">|</span>
|
||||||
|
<span>
|
||||||
|
실패: <span className="text-red-400 font-medium">{totalFailed}건</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-stroke-1">|</span>
|
||||||
|
<span>
|
||||||
|
금일 예측 완료:{' '}
|
||||||
|
<span className="text-cyan-400 font-medium">3 / 6회</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 스크롤 영역 ── */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{/* 파이프라인 현황 */}
|
||||||
|
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
|
||||||
|
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
|
||||||
|
데이터 파이프라인 현황
|
||||||
|
</h3>
|
||||||
|
<PipelineFlow nodes={pipeline} loading={loading} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 필터 바 + 수신 이력 테이블 */}
|
||||||
|
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
|
||||||
|
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
|
||||||
|
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
|
||||||
|
데이터 수신 이력
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{/* 데이터소스 필터 */}
|
||||||
|
<select
|
||||||
|
value={filterSource}
|
||||||
|
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
|
||||||
|
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="all">모두 (소스)</option>
|
||||||
|
<option value="HYCOM">HYCOM</option>
|
||||||
|
<option value="기상청">기상청</option>
|
||||||
|
<option value="KOSPS DLL">KOSPS DLL</option>
|
||||||
|
</select>
|
||||||
|
{/* 수신상태 필터 */}
|
||||||
|
<select
|
||||||
|
value={filterReceive}
|
||||||
|
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
|
||||||
|
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="all">모두 (상태)</option>
|
||||||
|
<option value="수신완료">수신완료</option>
|
||||||
|
<option value="수신대기">수신대기</option>
|
||||||
|
<option value="수신실패">수신실패</option>
|
||||||
|
<option value="시간초과">시간초과</option>
|
||||||
|
</select>
|
||||||
|
{/* 기간 필터 */}
|
||||||
|
<select
|
||||||
|
value={filterPeriod}
|
||||||
|
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
|
||||||
|
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="6h">최근 6시간</option>
|
||||||
|
<option value="12h">최근 12시간</option>
|
||||||
|
<option value="24h">최근 24시간</option>
|
||||||
|
</select>
|
||||||
|
<span className="text-xs text-t3">{filteredLogs.length}건</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DataLogTable rows={filteredLogs} loading={loading} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 알림 현황 */}
|
||||||
|
<section className="px-5 pt-4 pb-5">
|
||||||
|
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">
|
||||||
|
알림 현황
|
||||||
|
</h3>
|
||||||
|
<AlertList alerts={alerts} loading={loading} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
665
frontend/src/tabs/admin/components/RndPoseidonPanel.tsx
Normal file
665
frontend/src/tabs/admin/components/RndPoseidonPanel.tsx
Normal file
@ -0,0 +1,665 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
// ─── 타입 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type PipelineStatus = '정상' | '지연' | '중단';
|
||||||
|
type ReceiveStatus = '수신완료' | '수신대기' | '수신실패' | '시간초과';
|
||||||
|
type ProcessStatus = '처리완료' | '처리중' | '대기' | '오류';
|
||||||
|
type DataSource = 'HYCOM' | '기상수치모델' | '기상기술';
|
||||||
|
type AlertLevel = '경고' | '주의' | '정보';
|
||||||
|
|
||||||
|
interface PipelineNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: PipelineStatus;
|
||||||
|
lastReceived: string;
|
||||||
|
cycle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataLogRow {
|
||||||
|
id: string;
|
||||||
|
timestamp: string;
|
||||||
|
source: DataSource;
|
||||||
|
dataType: string;
|
||||||
|
size: string;
|
||||||
|
receiveStatus: ReceiveStatus;
|
||||||
|
processStatus: ProcessStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertItem {
|
||||||
|
id: string;
|
||||||
|
level: AlertLevel;
|
||||||
|
message: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mock 데이터 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MOCK_PIPELINE: PipelineNode[] = [
|
||||||
|
{
|
||||||
|
id: 'hycom',
|
||||||
|
name: 'HYCOM 해양순환모델',
|
||||||
|
status: '정상',
|
||||||
|
lastReceived: '2026-04-11 06:00',
|
||||||
|
cycle: '6시간 주기',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kma',
|
||||||
|
name: '기상수치모델(KMA)',
|
||||||
|
status: '정상',
|
||||||
|
lastReceived: '2026-04-11 06:00',
|
||||||
|
cycle: '3시간 주기',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'relay',
|
||||||
|
name: '기상기술 중계서버',
|
||||||
|
status: '지연',
|
||||||
|
lastReceived: '2026-04-11 05:30',
|
||||||
|
cycle: '3시간 주기',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'api',
|
||||||
|
name: '해경 9층 API서버',
|
||||||
|
status: '정상',
|
||||||
|
lastReceived: '2026-04-11 06:05',
|
||||||
|
cycle: '수신 즉시',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gpu',
|
||||||
|
name: 'GPU 연산서버',
|
||||||
|
status: '정상',
|
||||||
|
lastReceived: '2026-04-11 06:10',
|
||||||
|
cycle: '예측 시작 즉시',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOCK_LOGS: DataLogRow[] = [
|
||||||
|
{
|
||||||
|
id: 'log-01',
|
||||||
|
timestamp: '2026-04-11 06:10',
|
||||||
|
source: 'HYCOM',
|
||||||
|
dataType: '해류(U/V)',
|
||||||
|
size: '142 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-02',
|
||||||
|
timestamp: '2026-04-11 06:05',
|
||||||
|
source: '기상수치모델',
|
||||||
|
dataType: '풍향/풍속',
|
||||||
|
size: '38 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-03',
|
||||||
|
timestamp: '2026-04-11 06:05',
|
||||||
|
source: '기상수치모델',
|
||||||
|
dataType: '기압',
|
||||||
|
size: '22 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-04',
|
||||||
|
timestamp: '2026-04-11 06:00',
|
||||||
|
source: 'HYCOM',
|
||||||
|
dataType: '해수면온도(SST)',
|
||||||
|
size: '98 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-05',
|
||||||
|
timestamp: '2026-04-11 06:00',
|
||||||
|
source: 'HYCOM',
|
||||||
|
dataType: '해수면높이(SSH)',
|
||||||
|
size: '54 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-06',
|
||||||
|
timestamp: '2026-04-11 06:00',
|
||||||
|
source: '기상수치모델',
|
||||||
|
dataType: '기온',
|
||||||
|
size: '19 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-07',
|
||||||
|
timestamp: '2026-04-11 06:00',
|
||||||
|
source: '기상수치모델',
|
||||||
|
dataType: '강수량',
|
||||||
|
size: '11 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-08',
|
||||||
|
timestamp: '2026-04-11 06:00',
|
||||||
|
source: '기상기술',
|
||||||
|
dataType: '전처리 완료 데이터',
|
||||||
|
size: '310 MB',
|
||||||
|
receiveStatus: '수신대기',
|
||||||
|
processStatus: '대기',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-09',
|
||||||
|
timestamp: '2026-04-11 03:10',
|
||||||
|
source: 'HYCOM',
|
||||||
|
dataType: '해류(U/V)',
|
||||||
|
size: '140 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-10',
|
||||||
|
timestamp: '2026-04-11 03:05',
|
||||||
|
source: '기상수치모델',
|
||||||
|
dataType: '풍향/풍속',
|
||||||
|
size: '37 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-11',
|
||||||
|
timestamp: '2026-04-11 03:00',
|
||||||
|
source: 'HYCOM',
|
||||||
|
dataType: '해수면온도(SST)',
|
||||||
|
size: '97 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-12',
|
||||||
|
timestamp: '2026-04-11 03:00',
|
||||||
|
source: '기상기술',
|
||||||
|
dataType: '전처리 완료 데이터',
|
||||||
|
size: '305 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-13',
|
||||||
|
timestamp: '2026-04-11 00:05',
|
||||||
|
source: '기상수치모델',
|
||||||
|
dataType: '기압',
|
||||||
|
size: '23 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-14',
|
||||||
|
timestamp: '2026-04-11 00:00',
|
||||||
|
source: 'HYCOM',
|
||||||
|
dataType: '해수면높이(SSH)',
|
||||||
|
size: '53 MB',
|
||||||
|
receiveStatus: '시간초과',
|
||||||
|
processStatus: '오류',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-15',
|
||||||
|
timestamp: '2026-04-11 00:00',
|
||||||
|
source: '기상기술',
|
||||||
|
dataType: '전처리 완료 데이터',
|
||||||
|
size: '-',
|
||||||
|
receiveStatus: '수신실패',
|
||||||
|
processStatus: '오류',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-16',
|
||||||
|
timestamp: '2026-04-10 21:05',
|
||||||
|
source: '기상수치모델',
|
||||||
|
dataType: '풍향/풍속',
|
||||||
|
size: '36 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-17',
|
||||||
|
timestamp: '2026-04-10 21:00',
|
||||||
|
source: 'HYCOM',
|
||||||
|
dataType: '해류(U/V)',
|
||||||
|
size: '139 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-18',
|
||||||
|
timestamp: '2026-04-10 21:00',
|
||||||
|
source: '기상기술',
|
||||||
|
dataType: '전처리 완료 데이터',
|
||||||
|
size: '302 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOCK_ALERTS: AlertItem[] = [
|
||||||
|
{
|
||||||
|
id: 'alert-01',
|
||||||
|
level: '경고',
|
||||||
|
message: '기상기술 중계서버 데이터 30분 지연 — 06:00 배치 전처리 미완료',
|
||||||
|
timestamp: '2026-04-11 06:30',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'alert-02',
|
||||||
|
level: '주의',
|
||||||
|
message: 'HYCOM SSH 데이터 다음 수신 예정: 09:00',
|
||||||
|
timestamp: '2026-04-11 06:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'alert-03',
|
||||||
|
level: '정보',
|
||||||
|
message: 'GPU 연산서버 금일 처리 완료: 4회 / 8회',
|
||||||
|
timestamp: '2026-04-11 06:12',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Mock fetch ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface PoseidonData {
|
||||||
|
pipeline: PipelineNode[];
|
||||||
|
logs: DataLogRow[];
|
||||||
|
alerts: AlertItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchPoseidonData(): Promise<PoseidonData> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(
|
||||||
|
() =>
|
||||||
|
resolve({
|
||||||
|
pipeline: MOCK_PIPELINE,
|
||||||
|
logs: MOCK_LOGS,
|
||||||
|
alerts: MOCK_ALERTS,
|
||||||
|
}),
|
||||||
|
300,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getPipelineStatusStyle(status: PipelineStatus): string {
|
||||||
|
if (status === '정상') return 'text-emerald-400 bg-emerald-500/10';
|
||||||
|
if (status === '지연') return 'text-yellow-400 bg-yellow-500/10';
|
||||||
|
return 'text-red-400 bg-red-500/10';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPipelineBorderStyle(status: PipelineStatus): string {
|
||||||
|
if (status === '정상') return 'border-l-emerald-500';
|
||||||
|
if (status === '지연') return 'border-l-yellow-500';
|
||||||
|
return 'border-l-red-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReceiveStatusStyle(status: ReceiveStatus): string {
|
||||||
|
if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10';
|
||||||
|
if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10';
|
||||||
|
return 'text-red-400 bg-red-500/10';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProcessStatusStyle(status: ProcessStatus): string {
|
||||||
|
if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10';
|
||||||
|
if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10';
|
||||||
|
if (status === '대기') return 'text-yellow-400 bg-yellow-500/10';
|
||||||
|
return 'text-red-400 bg-red-500/10';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlertStyle(level: AlertLevel): string {
|
||||||
|
if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30';
|
||||||
|
if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30';
|
||||||
|
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function PipelineCard({ node }: { node: PipelineNode }) {
|
||||||
|
const badgeStyle = getPipelineStatusStyle(node.status);
|
||||||
|
const borderStyle = getPipelineBorderStyle(node.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
|
||||||
|
<span
|
||||||
|
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
|
||||||
|
>
|
||||||
|
{node.status}
|
||||||
|
</span>
|
||||||
|
<div className="text-label-2 text-t3 mt-0.5">최근 수신: {node.lastReceived}</div>
|
||||||
|
<div className="text-label-2 text-t3">{node.cycle}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: boolean }) {
|
||||||
|
if (loading && nodes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 animate-pulse">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-1">
|
||||||
|
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
|
||||||
|
{i < 4 && <span className="text-t3 text-sm px-0.5">→</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-stretch gap-1">
|
||||||
|
{nodes.map((node, idx) => (
|
||||||
|
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
|
||||||
|
<PipelineCard node={node} />
|
||||||
|
{idx < nodes.length - 1 && (
|
||||||
|
<span className="text-t3 text-sm shrink-0 px-0.5">→</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 수신 이력 테이블 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type FilterSource = 'all' | DataSource;
|
||||||
|
type FilterReceive = 'all' | ReceiveStatus;
|
||||||
|
type FilterPeriod = '6h' | '12h' | '24h';
|
||||||
|
|
||||||
|
const PERIOD_HOURS: Record<FilterPeriod, number> = { '6h': 6, '12h': 12, '24h': 24 };
|
||||||
|
|
||||||
|
function filterLogs(
|
||||||
|
rows: DataLogRow[],
|
||||||
|
source: FilterSource,
|
||||||
|
receive: FilterReceive,
|
||||||
|
period: FilterPeriod,
|
||||||
|
): DataLogRow[] {
|
||||||
|
const cutoff = new Date('2026-04-11T06:30:00');
|
||||||
|
const hours = PERIOD_HOURS[period];
|
||||||
|
const from = new Date(cutoff.getTime() - hours * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
return rows.filter((r) => {
|
||||||
|
if (source !== 'all' && r.source !== source) return false;
|
||||||
|
if (receive !== 'all' && r.receiveStatus !== receive) return false;
|
||||||
|
const ts = new Date(r.timestamp.replace(' ', 'T'));
|
||||||
|
if (ts < from) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '수신상태', '처리상태'];
|
||||||
|
|
||||||
|
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto">
|
||||||
|
<table className="w-full text-xs border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||||
|
{LOG_HEADERS.map((h) => (
|
||||||
|
<th
|
||||||
|
key={h}
|
||||||
|
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading && rows.length === 0
|
||||||
|
? Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<tr key={i} className="border-b border-stroke-1 animate-pulse">
|
||||||
|
{LOG_HEADERS.map((_, j) => (
|
||||||
|
<td key={j} className="px-3 py-2">
|
||||||
|
<div className="h-3 bg-bg-elevated rounded w-16" />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
: rows.map((row) => (
|
||||||
|
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
||||||
|
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
|
||||||
|
{row.timestamp}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
|
||||||
|
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
|
||||||
|
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span
|
||||||
|
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getReceiveStatusStyle(row.receiveStatus)}`}
|
||||||
|
>
|
||||||
|
{row.receiveStatus}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span
|
||||||
|
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getProcessStatusStyle(row.processStatus)}`}
|
||||||
|
>
|
||||||
|
{row.processStatus}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!loading && rows.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-3 py-8 text-center text-t3">
|
||||||
|
조회된 데이터가 없습니다.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 알림 목록 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean }) {
|
||||||
|
if (loading && alerts.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 animate-pulse">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-8 bg-bg-elevated rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alerts.length === 0) {
|
||||||
|
return <p className="text-xs text-t3 py-2">활성 알림이 없습니다.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{alerts.map((alert) => (
|
||||||
|
<div
|
||||||
|
key={alert.id}
|
||||||
|
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
|
||||||
|
>
|
||||||
|
<span className="font-semibold shrink-0">[{alert.level}]</span>
|
||||||
|
<span className="flex-1">{alert.message}</span>
|
||||||
|
<span className="shrink-0 opacity-70 font-mono">{alert.timestamp}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 메인 패널 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function RndPoseidonPanel() {
|
||||||
|
const [pipeline, setPipeline] = useState<PipelineNode[]>([]);
|
||||||
|
const [logs, setLogs] = useState<DataLogRow[]>([]);
|
||||||
|
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||||
|
|
||||||
|
// 필터
|
||||||
|
const [filterSource, setFilterSource] = useState<FilterSource>('all');
|
||||||
|
const [filterReceive, setFilterReceive] = useState<FilterReceive>('all');
|
||||||
|
const [filterPeriod, setFilterPeriod] = useState<FilterPeriod>('24h');
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchPoseidonData();
|
||||||
|
setPipeline(data.pipeline);
|
||||||
|
setLogs(data.logs);
|
||||||
|
setAlerts(data.alerts);
|
||||||
|
setLastUpdate(new Date());
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
void Promise.resolve().then(() => {
|
||||||
|
if (isMounted) void fetchData();
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const filteredLogs = filterLogs(logs, filterSource, filterReceive, filterPeriod);
|
||||||
|
|
||||||
|
const totalReceived = logs.filter((r) => r.receiveStatus === '수신완료').length;
|
||||||
|
const totalDelayed = logs.filter((r) => r.receiveStatus === '수신대기').length;
|
||||||
|
const totalFailed = logs.filter(
|
||||||
|
(r) => r.receiveStatus === '수신실패' || r.receiveStatus === '시간초과',
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
{/* ── 헤더 ── */}
|
||||||
|
<div className="shrink-0 border-b border-stroke-1">
|
||||||
|
<div className="flex items-center justify-between px-5 py-3">
|
||||||
|
<h2 className="text-sm font-semibold text-t1">유출유확산예측 (포세이돈) 연계 모니터링</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{lastUpdate && (
|
||||||
|
<span className="text-xs text-t3">
|
||||||
|
갱신:{' '}
|
||||||
|
{lastUpdate.toLocaleTimeString('ko-KR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => void fetchData()}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 요약 통계 바 */}
|
||||||
|
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
|
||||||
|
<span>
|
||||||
|
정상 수신:{' '}
|
||||||
|
<span className="text-emerald-400 font-medium">{totalReceived}건</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-stroke-1">|</span>
|
||||||
|
<span>
|
||||||
|
지연: <span className="text-yellow-400 font-medium">{totalDelayed}건</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-stroke-1">|</span>
|
||||||
|
<span>
|
||||||
|
실패: <span className="text-red-400 font-medium">{totalFailed}건</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-stroke-1">|</span>
|
||||||
|
<span>
|
||||||
|
금일 예측 완료:{' '}
|
||||||
|
<span className="text-cyan-400 font-medium">4 / 8회</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 스크롤 영역 ── */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{/* 파이프라인 현황 */}
|
||||||
|
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
|
||||||
|
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
|
||||||
|
데이터 파이프라인 현황
|
||||||
|
</h3>
|
||||||
|
<PipelineFlow nodes={pipeline} loading={loading} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 필터 바 + 수신 이력 테이블 */}
|
||||||
|
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
|
||||||
|
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
|
||||||
|
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
|
||||||
|
데이터 수신 이력
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{/* 데이터소스 필터 */}
|
||||||
|
<select
|
||||||
|
value={filterSource}
|
||||||
|
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
|
||||||
|
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="all">모두 (소스)</option>
|
||||||
|
<option value="HYCOM">HYCOM</option>
|
||||||
|
<option value="기상수치모델">기상수치모델</option>
|
||||||
|
<option value="기상기술">기상기술</option>
|
||||||
|
</select>
|
||||||
|
{/* 수신상태 필터 */}
|
||||||
|
<select
|
||||||
|
value={filterReceive}
|
||||||
|
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
|
||||||
|
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="all">모두 (상태)</option>
|
||||||
|
<option value="수신완료">수신완료</option>
|
||||||
|
<option value="수신대기">수신대기</option>
|
||||||
|
<option value="수신실패">수신실패</option>
|
||||||
|
<option value="시간초과">시간초과</option>
|
||||||
|
</select>
|
||||||
|
{/* 기간 필터 */}
|
||||||
|
<select
|
||||||
|
value={filterPeriod}
|
||||||
|
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
|
||||||
|
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="6h">최근 6시간</option>
|
||||||
|
<option value="12h">최근 12시간</option>
|
||||||
|
<option value="24h">최근 24시간</option>
|
||||||
|
</select>
|
||||||
|
<span className="text-xs text-t3">{filteredLogs.length}건</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DataLogTable rows={filteredLogs} loading={loading} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 알림 현황 */}
|
||||||
|
<section className="px-5 pt-4 pb-5">
|
||||||
|
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">
|
||||||
|
알림 현황
|
||||||
|
</h3>
|
||||||
|
<AlertList alerts={alerts} loading={loading} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
638
frontend/src/tabs/admin/components/RndRescuePanel.tsx
Normal file
638
frontend/src/tabs/admin/components/RndRescuePanel.tsx
Normal file
@ -0,0 +1,638 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
// ─── 타입 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type PipelineStatus = '정상' | '지연' | '중단';
|
||||||
|
type ReceiveStatus = '수신완료' | '수신대기' | '수신실패' | '시간초과';
|
||||||
|
type ProcessStatus = '처리완료' | '처리중' | '대기' | '오류';
|
||||||
|
type DataSource = 'HYCOM' | '기상청' | '긴급구난시스템';
|
||||||
|
type AlertLevel = '경고' | '주의' | '정보';
|
||||||
|
|
||||||
|
interface PipelineNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: PipelineStatus;
|
||||||
|
lastReceived: string;
|
||||||
|
cycle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataLogRow {
|
||||||
|
id: string;
|
||||||
|
timestamp: string;
|
||||||
|
source: DataSource;
|
||||||
|
dataType: string;
|
||||||
|
size: string;
|
||||||
|
receiveStatus: ReceiveStatus;
|
||||||
|
processStatus: ProcessStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertItem {
|
||||||
|
id: string;
|
||||||
|
level: AlertLevel;
|
||||||
|
message: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mock 데이터 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MOCK_PIPELINE: PipelineNode[] = [
|
||||||
|
{
|
||||||
|
id: 'hycom',
|
||||||
|
name: 'HYCOM 해양순환모델',
|
||||||
|
status: '정상',
|
||||||
|
lastReceived: '2026-04-11 06:00',
|
||||||
|
cycle: '6시간 주기',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kma',
|
||||||
|
name: '기상청 수치모델',
|
||||||
|
status: '정상',
|
||||||
|
lastReceived: '2026-04-11 06:00',
|
||||||
|
cycle: '3시간 주기',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rescue',
|
||||||
|
name: '해경 긴급구난 시스템',
|
||||||
|
status: '정상',
|
||||||
|
lastReceived: '2026-04-11 06:30',
|
||||||
|
cycle: '내부 연계',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'analysis',
|
||||||
|
name: '구난 분석 연산',
|
||||||
|
status: '정상',
|
||||||
|
lastReceived: '2026-04-11 06:35',
|
||||||
|
cycle: '연계 시작 즉시',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'result',
|
||||||
|
name: '결과 연계 수신',
|
||||||
|
status: '정상',
|
||||||
|
lastReceived: '2026-04-11 06:40',
|
||||||
|
cycle: '분석 완료 즉시',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOCK_LOGS: DataLogRow[] = [
|
||||||
|
{
|
||||||
|
id: 'log-01',
|
||||||
|
timestamp: '2026-04-11 06:40',
|
||||||
|
source: '긴급구난시스템',
|
||||||
|
dataType: '구난 가능성 판단',
|
||||||
|
size: '1.2 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-02',
|
||||||
|
timestamp: '2026-04-11 06:35',
|
||||||
|
source: '긴급구난시스템',
|
||||||
|
dataType: '선체상태 분석',
|
||||||
|
size: '3.4 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-03',
|
||||||
|
timestamp: '2026-04-11 06:30',
|
||||||
|
source: '긴급구난시스템',
|
||||||
|
dataType: '사고선 위치정보',
|
||||||
|
size: '0.8 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-04',
|
||||||
|
timestamp: '2026-04-11 06:30',
|
||||||
|
source: '긴급구난시스템',
|
||||||
|
dataType: '비상배인력 정보',
|
||||||
|
size: '0.5 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-05',
|
||||||
|
timestamp: '2026-04-11 06:00',
|
||||||
|
source: 'HYCOM',
|
||||||
|
dataType: '해수면온도(SST)',
|
||||||
|
size: '98 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-06',
|
||||||
|
timestamp: '2026-04-11 06:00',
|
||||||
|
source: 'HYCOM',
|
||||||
|
dataType: '해류(U/V)',
|
||||||
|
size: '142 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-07',
|
||||||
|
timestamp: '2026-04-11 06:00',
|
||||||
|
source: 'HYCOM',
|
||||||
|
dataType: '해수면높이(SSH)',
|
||||||
|
size: '54 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-08',
|
||||||
|
timestamp: '2026-04-11 06:00',
|
||||||
|
source: '기상청',
|
||||||
|
dataType: '풍향/풍속',
|
||||||
|
size: '38 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-09',
|
||||||
|
timestamp: '2026-04-11 06:00',
|
||||||
|
source: '기상청',
|
||||||
|
dataType: '기압',
|
||||||
|
size: '22 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-10',
|
||||||
|
timestamp: '2026-04-11 06:00',
|
||||||
|
source: '기상청',
|
||||||
|
dataType: '기온',
|
||||||
|
size: '19 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-11',
|
||||||
|
timestamp: '2026-04-11 03:30',
|
||||||
|
source: '긴급구난시스템',
|
||||||
|
dataType: '선체상태 분석',
|
||||||
|
size: '3.1 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-12',
|
||||||
|
timestamp: '2026-04-11 03:30',
|
||||||
|
source: '긴급구난시스템',
|
||||||
|
dataType: '구난 가능성 판단',
|
||||||
|
size: '1.1 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-13',
|
||||||
|
timestamp: '2026-04-11 00:30',
|
||||||
|
source: '긴급구난시스템',
|
||||||
|
dataType: '사고선 위치정보',
|
||||||
|
size: '0.8 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-14',
|
||||||
|
timestamp: '2026-04-11 00:00',
|
||||||
|
source: 'HYCOM',
|
||||||
|
dataType: '해수면높이(SSH)',
|
||||||
|
size: '53 MB',
|
||||||
|
receiveStatus: '시간초과',
|
||||||
|
processStatus: '오류',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log-15',
|
||||||
|
timestamp: '2026-04-10 21:30',
|
||||||
|
source: '긴급구난시스템',
|
||||||
|
dataType: '비상배인력 정보',
|
||||||
|
size: '0.4 MB',
|
||||||
|
receiveStatus: '수신완료',
|
||||||
|
processStatus: '처리완료',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOCK_ALERTS: AlertItem[] = [
|
||||||
|
{
|
||||||
|
id: 'alert-01',
|
||||||
|
level: '정보',
|
||||||
|
message: '해경 긴급구난 시스템 정상 연계 중',
|
||||||
|
timestamp: '2026-04-11 06:30',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'alert-02',
|
||||||
|
level: '정보',
|
||||||
|
message: 'HYCOM 데이터 정상 수신',
|
||||||
|
timestamp: '2026-04-11 06:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'alert-03',
|
||||||
|
level: '정보',
|
||||||
|
message: '금일 긴급구난 분석 완료: 5회/6회',
|
||||||
|
timestamp: '2026-04-11 06:40',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Mock fetch ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RescueData {
|
||||||
|
pipeline: PipelineNode[];
|
||||||
|
logs: DataLogRow[];
|
||||||
|
alerts: AlertItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchRescueData(): Promise<RescueData> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(
|
||||||
|
() =>
|
||||||
|
resolve({
|
||||||
|
pipeline: MOCK_PIPELINE,
|
||||||
|
logs: MOCK_LOGS,
|
||||||
|
alerts: MOCK_ALERTS,
|
||||||
|
}),
|
||||||
|
300,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 유틸 ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getPipelineStatusStyle(status: PipelineStatus): string {
|
||||||
|
if (status === '정상') return 'text-emerald-400 bg-emerald-500/10';
|
||||||
|
if (status === '지연') return 'text-yellow-400 bg-yellow-500/10';
|
||||||
|
return 'text-red-400 bg-red-500/10';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPipelineBorderStyle(status: PipelineStatus): string {
|
||||||
|
if (status === '정상') return 'border-l-emerald-500';
|
||||||
|
if (status === '지연') return 'border-l-yellow-500';
|
||||||
|
return 'border-l-red-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReceiveStatusStyle(status: ReceiveStatus): string {
|
||||||
|
if (status === '수신완료') return 'text-emerald-400 bg-emerald-500/10';
|
||||||
|
if (status === '수신대기') return 'text-yellow-400 bg-yellow-500/10';
|
||||||
|
return 'text-red-400 bg-red-500/10';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProcessStatusStyle(status: ProcessStatus): string {
|
||||||
|
if (status === '처리완료') return 'text-emerald-400 bg-emerald-500/10';
|
||||||
|
if (status === '처리중') return 'text-cyan-400 bg-cyan-500/10';
|
||||||
|
if (status === '대기') return 'text-yellow-400 bg-yellow-500/10';
|
||||||
|
return 'text-red-400 bg-red-500/10';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlertStyle(level: AlertLevel): string {
|
||||||
|
if (level === '경고') return 'text-red-400 bg-red-500/10 border-red-500/30';
|
||||||
|
if (level === '주의') return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30';
|
||||||
|
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 파이프라인 카드 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function PipelineCard({ node }: { node: PipelineNode }) {
|
||||||
|
const badgeStyle = getPipelineStatusStyle(node.status);
|
||||||
|
const borderStyle = getPipelineBorderStyle(node.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex-1 min-w-0 bg-bg-card rounded border border-stroke-1 border-l-2 ${borderStyle} p-3 flex flex-col gap-1.5`}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-medium text-t1 leading-snug">{node.name}</div>
|
||||||
|
<span
|
||||||
|
className={`self-start inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${badgeStyle}`}
|
||||||
|
>
|
||||||
|
{node.status}
|
||||||
|
</span>
|
||||||
|
<div className="text-label-2 text-t3 mt-0.5">최근 수신: {node.lastReceived}</div>
|
||||||
|
<div className="text-label-2 text-t3">{node.cycle}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PipelineFlow({ nodes, loading }: { nodes: PipelineNode[]; loading: boolean }) {
|
||||||
|
if (loading && nodes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 animate-pulse">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-1">
|
||||||
|
<div className="flex-1 h-16 bg-bg-elevated rounded w-28" />
|
||||||
|
{i < 4 && <span className="text-t3 text-sm px-0.5">→</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-stretch gap-1">
|
||||||
|
{nodes.map((node, idx) => (
|
||||||
|
<div key={node.id} className="flex items-center gap-1 flex-1 min-w-0">
|
||||||
|
<PipelineCard node={node} />
|
||||||
|
{idx < nodes.length - 1 && (
|
||||||
|
<span className="text-t3 text-sm shrink-0 px-0.5">→</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 수신 이력 테이블 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type FilterSource = 'all' | DataSource;
|
||||||
|
type FilterReceive = 'all' | ReceiveStatus;
|
||||||
|
type FilterPeriod = '6h' | '12h' | '24h';
|
||||||
|
|
||||||
|
const PERIOD_HOURS: Record<FilterPeriod, number> = { '6h': 6, '12h': 12, '24h': 24 };
|
||||||
|
|
||||||
|
function filterLogs(
|
||||||
|
rows: DataLogRow[],
|
||||||
|
source: FilterSource,
|
||||||
|
receive: FilterReceive,
|
||||||
|
period: FilterPeriod,
|
||||||
|
): DataLogRow[] {
|
||||||
|
const cutoff = new Date('2026-04-11T06:40:00');
|
||||||
|
const hours = PERIOD_HOURS[period];
|
||||||
|
const from = new Date(cutoff.getTime() - hours * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
return rows.filter((r) => {
|
||||||
|
if (source !== 'all' && r.source !== source) return false;
|
||||||
|
if (receive !== 'all' && r.receiveStatus !== receive) return false;
|
||||||
|
const ts = new Date(r.timestamp.replace(' ', 'T'));
|
||||||
|
if (ts < from) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOG_HEADERS = ['시간', '데이터소스', '데이터종류', '크기', '수신상태', '처리상태'];
|
||||||
|
|
||||||
|
function DataLogTable({ rows, loading }: { rows: DataLogRow[]; loading: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto">
|
||||||
|
<table className="w-full text-xs border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-bg-elevated text-t3 uppercase tracking-wide">
|
||||||
|
{LOG_HEADERS.map((h) => (
|
||||||
|
<th
|
||||||
|
key={h}
|
||||||
|
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading && rows.length === 0
|
||||||
|
? Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<tr key={i} className="border-b border-stroke-1 animate-pulse">
|
||||||
|
{LOG_HEADERS.map((_, j) => (
|
||||||
|
<td key={j} className="px-3 py-2">
|
||||||
|
<div className="h-3 bg-bg-elevated rounded w-16" />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
: rows.map((row) => (
|
||||||
|
<tr key={row.id} className="border-b border-stroke-1 hover:bg-bg-surface/50">
|
||||||
|
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">
|
||||||
|
{row.timestamp}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.source}</td>
|
||||||
|
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.dataType}</td>
|
||||||
|
<td className="px-3 py-2 text-t2 whitespace-nowrap font-mono">{row.size}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span
|
||||||
|
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getReceiveStatusStyle(row.receiveStatus)}`}
|
||||||
|
>
|
||||||
|
{row.receiveStatus}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span
|
||||||
|
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${getProcessStatusStyle(row.processStatus)}`}
|
||||||
|
>
|
||||||
|
{row.processStatus}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!loading && rows.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-3 py-8 text-center text-t3">
|
||||||
|
조회된 데이터가 없습니다.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 알림 목록 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AlertList({ alerts, loading }: { alerts: AlertItem[]; loading: boolean }) {
|
||||||
|
if (loading && alerts.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 animate-pulse">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-8 bg-bg-elevated rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alerts.length === 0) {
|
||||||
|
return <p className="text-xs text-t3 py-2">활성 알림이 없습니다.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{alerts.map((alert) => (
|
||||||
|
<div
|
||||||
|
key={alert.id}
|
||||||
|
className={`flex items-start gap-2 px-3 py-2 rounded border text-xs ${getAlertStyle(alert.level)}`}
|
||||||
|
>
|
||||||
|
<span className="font-semibold shrink-0">[{alert.level}]</span>
|
||||||
|
<span className="flex-1">{alert.message}</span>
|
||||||
|
<span className="shrink-0 opacity-70 font-mono">{alert.timestamp}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 메인 패널 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function RndRescuePanel() {
|
||||||
|
const [pipeline, setPipeline] = useState<PipelineNode[]>([]);
|
||||||
|
const [logs, setLogs] = useState<DataLogRow[]>([]);
|
||||||
|
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||||
|
|
||||||
|
// 필터
|
||||||
|
const [filterSource, setFilterSource] = useState<FilterSource>('all');
|
||||||
|
const [filterReceive, setFilterReceive] = useState<FilterReceive>('all');
|
||||||
|
const [filterPeriod, setFilterPeriod] = useState<FilterPeriod>('24h');
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchRescueData();
|
||||||
|
setPipeline(data.pipeline);
|
||||||
|
setLogs(data.logs);
|
||||||
|
setAlerts(data.alerts);
|
||||||
|
setLastUpdate(new Date());
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
void Promise.resolve().then(() => {
|
||||||
|
if (isMounted) void fetchData();
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const filteredLogs = filterLogs(logs, filterSource, filterReceive, filterPeriod);
|
||||||
|
|
||||||
|
const totalReceived = logs.filter((r) => r.receiveStatus === '수신완료').length;
|
||||||
|
const totalDelayed = logs.filter((r) => r.receiveStatus === '수신대기').length;
|
||||||
|
const totalFailed = logs.filter(
|
||||||
|
(r) => r.receiveStatus === '수신실패' || r.receiveStatus === '시간초과',
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
{/* ── 헤더 ── */}
|
||||||
|
<div className="shrink-0 border-b border-stroke-1">
|
||||||
|
<div className="flex items-center justify-between px-5 py-3">
|
||||||
|
<h2 className="text-sm font-semibold text-t1">긴급구난과제 연계 모니터링</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{lastUpdate && (
|
||||||
|
<span className="text-xs text-t3">
|
||||||
|
갱신:{' '}
|
||||||
|
{lastUpdate.toLocaleTimeString('ko-KR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => void fetchData()}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-bg-elevated hover:bg-bg-card text-t2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 요약 통계 바 */}
|
||||||
|
<div className="flex items-center gap-4 px-5 py-2 bg-bg-base text-xs text-t3 border-t border-stroke-1">
|
||||||
|
<span>
|
||||||
|
정상 수신:{' '}
|
||||||
|
<span className="text-emerald-400 font-medium">{totalReceived}건</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-stroke-1">|</span>
|
||||||
|
<span>
|
||||||
|
지연: <span className="text-yellow-400 font-medium">{totalDelayed}건</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-stroke-1">|</span>
|
||||||
|
<span>
|
||||||
|
실패: <span className="text-red-400 font-medium">{totalFailed}건</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-stroke-1">|</span>
|
||||||
|
<span>
|
||||||
|
금일 분석 완료:{' '}
|
||||||
|
<span className="text-cyan-400 font-medium">5 / 6회</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 스크롤 영역 ── */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{/* 파이프라인 현황 */}
|
||||||
|
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
|
||||||
|
<h3 className="text-xs font-semibold text-t2 mb-3 uppercase tracking-wide">
|
||||||
|
데이터 파이프라인 현황
|
||||||
|
</h3>
|
||||||
|
<PipelineFlow nodes={pipeline} loading={loading} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 필터 바 + 수신 이력 테이블 */}
|
||||||
|
<section className="px-5 pt-4 pb-3 border-b border-stroke-1">
|
||||||
|
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
|
||||||
|
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide shrink-0">
|
||||||
|
데이터 수신 이력
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{/* 데이터소스 필터 */}
|
||||||
|
<select
|
||||||
|
value={filterSource}
|
||||||
|
onChange={(e) => setFilterSource(e.target.value as FilterSource)}
|
||||||
|
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="all">모두 (소스)</option>
|
||||||
|
<option value="HYCOM">HYCOM</option>
|
||||||
|
<option value="기상청">기상청</option>
|
||||||
|
<option value="긴급구난시스템">긴급구난시스템</option>
|
||||||
|
</select>
|
||||||
|
{/* 수신상태 필터 */}
|
||||||
|
<select
|
||||||
|
value={filterReceive}
|
||||||
|
onChange={(e) => setFilterReceive(e.target.value as FilterReceive)}
|
||||||
|
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="all">모두 (상태)</option>
|
||||||
|
<option value="수신완료">수신완료</option>
|
||||||
|
<option value="수신대기">수신대기</option>
|
||||||
|
<option value="수신실패">수신실패</option>
|
||||||
|
<option value="시간초과">시간초과</option>
|
||||||
|
</select>
|
||||||
|
{/* 기간 필터 */}
|
||||||
|
<select
|
||||||
|
value={filterPeriod}
|
||||||
|
onChange={(e) => setFilterPeriod(e.target.value as FilterPeriod)}
|
||||||
|
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-t2 focus:outline-none focus:border-cyan-500 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="6h">최근 6시간</option>
|
||||||
|
<option value="12h">최근 12시간</option>
|
||||||
|
<option value="24h">최근 24시간</option>
|
||||||
|
</select>
|
||||||
|
<span className="text-xs text-t3">{filteredLogs.length}건</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DataLogTable rows={filteredLogs} loading={loading} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 알림 현황 */}
|
||||||
|
<section className="px-5 pt-4 pb-5">
|
||||||
|
<h3 className="text-xs font-semibold text-t2 uppercase tracking-wide mb-3">
|
||||||
|
알림 현황
|
||||||
|
</h3>
|
||||||
|
<AlertList alerts={alerts} loading={loading} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -136,7 +136,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
|||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-bold text-fg font-korean">{title}</h1>
|
<h1 className="text-lg font-bold text-fg font-korean">{title}</h1>
|
||||||
<p className="text-xs text-fg-disabled mt-1 font-korean">총 {total}개</p>
|
<p className="text-caption text-fg-disabled mt-1 font-korean">총 {total}개</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -146,12 +146,12 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
|||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
placeholder="레이어코드 / 레이어명 검색"
|
placeholder="레이어코드 / 레이어명 검색"
|
||||||
className="flex-1 px-3 py-1.5 text-xs bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
className="flex-1 px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={filterUseYn}
|
value={filterUseYn}
|
||||||
onChange={(e) => setFilterUseYn(e.target.value)}
|
onChange={(e) => setFilterUseYn(e.target.value)}
|
||||||
className="px-2 py-1.5 text-xs bg-bg-elevated border border-stroke rounded text-fg focus:border-color-accent focus:outline-none font-korean"
|
className="px-2 py-1.5 text-caption bg-bg-elevated border border-stroke rounded text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||||
>
|
>
|
||||||
<option value="">전체</option>
|
<option value="">전체</option>
|
||||||
<option value="Y">사용</option>
|
<option value="Y">사용</option>
|
||||||
@ -159,7 +159,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
|||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
onClick={handleSearch}
|
onClick={handleSearch}
|
||||||
className="px-3 py-1.5 text-xs border border-stroke text-fg-sub rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
className="px-3 py-1.5 text-caption border border-stroke text-fg-sub rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||||
>
|
>
|
||||||
검색
|
검색
|
||||||
</button>
|
</button>
|
||||||
@ -168,7 +168,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
|||||||
|
|
||||||
{/* 오류 메시지 */}
|
{/* 오류 메시지 */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="px-6 py-2 text-xs text-red-400 bg-[rgba(239,68,68,0.05)] border-b border-stroke shrink-0 font-korean">
|
<div className="px-6 py-2 text-caption text-red-400 bg-[rgba(239,68,68,0.05)] border-b border-stroke shrink-0 font-korean">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -176,7 +176,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
|||||||
{/* 테이블 영역 */}
|
{/* 테이블 영역 */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-full text-fg-disabled text-sm font-korean">
|
<div className="flex items-center justify-center h-full text-fg-disabled text-body-2 font-korean">
|
||||||
불러오는 중...
|
불러오는 중...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -217,7 +217,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
|||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={9}
|
colSpan={9}
|
||||||
className="px-4 py-12 text-center text-fg-disabled text-sm font-korean"
|
className="px-4 py-12 text-center text-fg-disabled text-body-2 font-korean"
|
||||||
>
|
>
|
||||||
데이터가 없습니다.
|
데이터가 없습니다.
|
||||||
</td>
|
</td>
|
||||||
@ -228,12 +228,12 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
|||||||
key={item.layerCd}
|
key={item.layerCd}
|
||||||
className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 text-xs text-fg-disabled font-mono">
|
<td className="px-4 py-3 text-caption text-fg-disabled font-mono">
|
||||||
{(page - 1) * PAGE_SIZE + idx + 1}
|
{(page - 1) * PAGE_SIZE + idx + 1}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-label-2 text-fg-sub font-mono">{item.layerCd}</td>
|
<td className="px-4 py-3 text-label-2 text-fg-sub font-mono">{item.layerCd}</td>
|
||||||
<td className="px-4 py-3 text-xs text-fg font-korean">{item.layerNm}</td>
|
<td className="px-4 py-3 text-caption text-fg font-korean">{item.layerNm}</td>
|
||||||
<td className="px-4 py-3 text-xs text-fg-sub font-korean max-w-[200px]">
|
<td className="px-4 py-3 text-caption text-fg-sub font-korean max-w-[200px]">
|
||||||
<span className="block truncate" title={item.layerFullNm}>
|
<span className="block truncate" title={item.layerFullNm}>
|
||||||
{item.layerFullNm}
|
{item.layerFullNm}
|
||||||
</span>
|
</span>
|
||||||
@ -246,7 +246,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
|||||||
<td className="px-4 py-3 text-label-2 text-fg-sub font-mono">
|
<td className="px-4 py-3 text-label-2 text-fg-sub font-mono">
|
||||||
{item.wmsLayerNm ?? <span className="text-fg-disabled">-</span>}
|
{item.wmsLayerNm ?? <span className="text-fg-disabled">-</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-xs text-fg-disabled text-center font-mono">
|
<td className="px-4 py-3 text-caption text-fg-disabled text-center font-mono">
|
||||||
{item.sortOrd}
|
{item.sortOrd}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-label-2 text-fg-disabled font-mono">
|
<td className="px-4 py-3 text-label-2 text-fg-disabled font-mono">
|
||||||
|
|||||||
@ -54,7 +54,7 @@ function SettingsPanel() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-32 text-fg-disabled text-sm font-korean">
|
<div className="flex items-center justify-center h-32 text-fg-disabled text-body-2 font-korean">
|
||||||
불러오는 중...
|
불러오는 중...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -64,7 +64,7 @@ function SettingsPanel() {
|
|||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="px-6 py-4 border-b border-stroke">
|
<div className="px-6 py-4 border-b border-stroke">
|
||||||
<h1 className="text-lg font-bold text-fg font-korean">시스템 설정</h1>
|
<h1 className="text-lg font-bold text-fg font-korean">시스템 설정</h1>
|
||||||
<p className="text-xs text-fg-disabled mt-1 font-korean">
|
<p className="text-caption text-fg-disabled mt-1 font-korean">
|
||||||
사용자 등록 및 권한 관련 시스템 설정을 관리합니다
|
사용자 등록 및 권한 관련 시스템 설정을 관리합니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -74,7 +74,7 @@ function SettingsPanel() {
|
|||||||
{/* 사용자 등록 설정 */}
|
{/* 사용자 등록 설정 */}
|
||||||
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
||||||
<div className="px-5 py-3 border-b border-stroke">
|
<div className="px-5 py-3 border-b border-stroke">
|
||||||
<h2 className="text-sm font-bold text-fg font-korean">사용자 등록 설정</h2>
|
<h2 className="text-body-2 font-bold text-fg font-korean">사용자 등록 설정</h2>
|
||||||
<p className="text-label-2 text-fg-disabled mt-0.5 font-korean">
|
<p className="text-label-2 text-fg-disabled mt-0.5 font-korean">
|
||||||
신규 사용자 등록 시 적용되는 정책을 설정합니다
|
신규 사용자 등록 시 적용되는 정책을 설정합니다
|
||||||
</p>
|
</p>
|
||||||
@ -140,7 +140,7 @@ function SettingsPanel() {
|
|||||||
{/* OAuth 설정 */}
|
{/* OAuth 설정 */}
|
||||||
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
||||||
<div className="px-5 py-3 border-b border-stroke">
|
<div className="px-5 py-3 border-b border-stroke">
|
||||||
<h2 className="text-sm font-bold text-fg font-korean">Google OAuth 설정</h2>
|
<h2 className="text-body-2 font-bold text-fg font-korean">Google OAuth 설정</h2>
|
||||||
<p className="text-label-2 text-fg-disabled mt-0.5 font-korean">
|
<p className="text-label-2 text-fg-disabled mt-0.5 font-korean">
|
||||||
Google 계정 로그인 시 자동 승인할 이메일 도메인을 설정합니다
|
Google 계정 로그인 시 자동 승인할 이메일 도메인을 설정합니다
|
||||||
</p>
|
</p>
|
||||||
@ -162,7 +162,7 @@ function SettingsPanel() {
|
|||||||
value={oauthDomainInput}
|
value={oauthDomainInput}
|
||||||
onChange={(e) => setOauthDomainInput(e.target.value)}
|
onChange={(e) => setOauthDomainInput(e.target.value)}
|
||||||
placeholder="gcsc.co.kr, example.com"
|
placeholder="gcsc.co.kr, example.com"
|
||||||
className="flex-1 px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
className="flex-1 px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@ -183,7 +183,7 @@ function SettingsPanel() {
|
|||||||
savingOAuth ||
|
savingOAuth ||
|
||||||
oauthDomainInput.trim() === (oauthSettings?.autoApproveDomains || '')
|
oauthDomainInput.trim() === (oauthSettings?.autoApproveDomains || '')
|
||||||
}
|
}
|
||||||
className={`px-4 py-2 text-xs font-semibold rounded-md transition-all font-korean whitespace-nowrap ${
|
className={`px-4 py-2 text-caption font-semibold rounded-md transition-all font-korean whitespace-nowrap ${
|
||||||
oauthDomainInput.trim() !== (oauthSettings?.autoApproveDomains || '')
|
oauthDomainInput.trim() !== (oauthSettings?.autoApproveDomains || '')
|
||||||
? 'bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
? 'bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
||||||
: 'bg-bg-card text-fg-disabled cursor-not-allowed'
|
: 'bg-bg-card text-fg-disabled cursor-not-allowed'
|
||||||
@ -220,7 +220,7 @@ function SettingsPanel() {
|
|||||||
{/* 현재 설정 상태 요약 */}
|
{/* 현재 설정 상태 요약 */}
|
||||||
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
||||||
<div className="px-5 py-3 border-b border-stroke">
|
<div className="px-5 py-3 border-b border-stroke">
|
||||||
<h2 className="text-sm font-bold text-fg font-korean">설정 상태 요약</h2>
|
<h2 className="text-body-2 font-bold text-fg font-korean">설정 상태 요약</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-4">
|
<div className="px-5 py-4">
|
||||||
<div className="flex flex-col gap-3 text-label-1 font-korean">
|
<div className="flex flex-col gap-3 text-label-1 font-korean">
|
||||||
|
|||||||
@ -71,7 +71,7 @@ function SortableMenuItem({
|
|||||||
<circle cx="9" cy="14" r="1.5" />
|
<circle cx="9" cy="14" r="1.5" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<span className="text-fg-disabled text-xs font-mono w-6 text-center shrink-0">
|
<span className="text-fg-disabled text-caption font-mono w-6 text-center shrink-0">
|
||||||
{idx + 1}
|
{idx + 1}
|
||||||
</span>
|
</span>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
@ -152,14 +152,14 @@ function SortableMenuItem({
|
|||||||
<button
|
<button
|
||||||
onClick={() => onMove(idx, -1)}
|
onClick={() => onMove(idx, -1)}
|
||||||
disabled={idx === 0}
|
disabled={idx === 0}
|
||||||
className="w-7 h-7 rounded border border-stroke bg-bg-elevated text-fg-disabled text-xs flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
className="w-7 h-7 rounded border border-stroke bg-bg-elevated text-fg-disabled text-caption flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
▲
|
▲
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onMove(idx, 1)}
|
onClick={() => onMove(idx, 1)}
|
||||||
disabled={idx === totalCount - 1}
|
disabled={idx === totalCount - 1}
|
||||||
className="w-7 h-7 rounded border border-stroke bg-bg-elevated text-fg-disabled text-xs flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
className="w-7 h-7 rounded border border-stroke bg-bg-elevated text-fg-disabled text-caption flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
▼
|
▼
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
1544
frontend/src/tabs/admin/components/SystemArchPanel.tsx
Normal file
1544
frontend/src/tabs/admin/components/SystemArchPanel.tsx
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -87,7 +87,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
|||||||
<div className="bg-bg-surface border border-stroke rounded-lg shadow-lg w-[480px] max-h-[90vh] flex flex-col">
|
<div className="bg-bg-surface border border-stroke rounded-lg shadow-lg w-[480px] max-h-[90vh] flex flex-col">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
||||||
<h2 className="text-sm font-bold text-fg font-korean">사용자 등록</h2>
|
<h2 className="text-body-2 font-bold text-fg font-korean">사용자 등록</h2>
|
||||||
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
|
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
|
||||||
<svg
|
<svg
|
||||||
width="16"
|
width="16"
|
||||||
@ -115,7 +115,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
|||||||
value={account}
|
value={account}
|
||||||
onChange={(e) => setAccount(e.target.value)}
|
onChange={(e) => setAccount(e.target.value)}
|
||||||
placeholder="로그인 계정 ID"
|
placeholder="로그인 계정 ID"
|
||||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -129,7 +129,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="초기 비밀번호"
|
placeholder="초기 비밀번호"
|
||||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -143,7 +143,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
|||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="실명"
|
placeholder="실명"
|
||||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -157,7 +157,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
|||||||
value={rank}
|
value={rank}
|
||||||
onChange={(e) => setRank(e.target.value)}
|
onChange={(e) => setRank(e.target.value)}
|
||||||
placeholder="예: 팀장, 주임 등"
|
placeholder="예: 팀장, 주임 등"
|
||||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -169,7 +169,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
|||||||
<select
|
<select
|
||||||
value={orgSn}
|
value={orgSn}
|
||||||
onChange={(e) => setOrgSn(e.target.value !== '' ? Number(e.target.value) : '')}
|
onChange={(e) => setOrgSn(e.target.value !== '' ? Number(e.target.value) : '')}
|
||||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||||
>
|
>
|
||||||
<option value="">소속 없음</option>
|
<option value="">소속 없음</option>
|
||||||
{allOrgs.map((org) => (
|
{allOrgs.map((org) => (
|
||||||
@ -191,7 +191,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="이메일 주소"
|
placeholder="이메일 주소"
|
||||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -217,7 +217,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
|||||||
onChange={() => toggleRole(role.sn)}
|
onChange={() => toggleRole(role.sn)}
|
||||||
style={{ accentColor: color }}
|
style={{ accentColor: color }}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs font-korean" style={{ color }}>
|
<span className="text-caption font-korean" style={{ color }}>
|
||||||
{role.name}
|
{role.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-caption text-fg-disabled font-mono">{role.code}</span>
|
<span className="text-caption text-fg-disabled font-mono">{role.code}</span>
|
||||||
@ -237,14 +237,14 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-xs border border-stroke text-fg-sub rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
className="px-4 py-2 text-caption border border-stroke text-fg-sub rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all disabled:opacity-50 font-korean"
|
className="px-4 py-2 text-caption font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all disabled:opacity-50 font-korean"
|
||||||
>
|
>
|
||||||
{submitting ? '등록 중...' : '등록'}
|
{submitting ? '등록 중...' : '등록'}
|
||||||
</button>
|
</button>
|
||||||
@ -332,7 +332,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
|||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-bold text-fg font-korean">사용자 정보</h2>
|
<h2 className="text-body-2 font-bold text-fg font-korean">사용자 정보</h2>
|
||||||
<p className="text-caption text-fg-disabled font-mono mt-0.5">{user.account}</p>
|
<p className="text-caption text-fg-disabled font-mono mt-0.5">{user.account}</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
|
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
|
||||||
@ -364,7 +364,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
|||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
className="w-full px-3 py-1.5 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
className="w-full px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
@ -377,7 +377,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
|||||||
value={rank}
|
value={rank}
|
||||||
onChange={(e) => setRank(e.target.value)}
|
onChange={(e) => setRank(e.target.value)}
|
||||||
placeholder="예: 팀장"
|
placeholder="예: 팀장"
|
||||||
className="w-full px-3 py-1.5 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
className="w-full px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -387,7 +387,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
|||||||
<select
|
<select
|
||||||
value={orgSn}
|
value={orgSn}
|
||||||
onChange={(e) => setOrgSn(e.target.value !== '' ? Number(e.target.value) : '')}
|
onChange={(e) => setOrgSn(e.target.value !== '' ? Number(e.target.value) : '')}
|
||||||
className="w-full px-3 py-1.5 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
className="w-full px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||||
>
|
>
|
||||||
<option value="">소속 없음</option>
|
<option value="">소속 없음</option>
|
||||||
{allOrgs.map((org) => (
|
{allOrgs.map((org) => (
|
||||||
@ -427,7 +427,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
|||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
placeholder="새 비밀번호 입력"
|
placeholder="새 비밀번호 입력"
|
||||||
className="w-full px-3 py-1.5 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
className="w-full px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -535,7 +535,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
|||||||
<div className="flex items-center justify-end px-6 py-3 border-t border-stroke">
|
<div className="flex items-center justify-end px-6 py-3 border-t border-stroke">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-xs border border-stroke text-fg-sub rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
className="px-4 py-2 text-caption border border-stroke text-fg-sub rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||||
>
|
>
|
||||||
닫기
|
닫기
|
||||||
</button>
|
</button>
|
||||||
@ -680,7 +680,7 @@ function UsersPanel() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-bold text-fg font-korean">사용자 관리</h1>
|
<h1 className="text-lg font-bold text-fg font-korean">사용자 관리</h1>
|
||||||
<p className="text-xs text-fg-disabled mt-1 font-korean">
|
<p className="text-caption text-fg-disabled mt-1 font-korean">
|
||||||
총 {filteredUsers.length}명
|
총 {filteredUsers.length}명
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -698,7 +698,7 @@ function UsersPanel() {
|
|||||||
setOrgFilter(e.target.value);
|
setOrgFilter(e.target.value);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
className="px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||||
>
|
>
|
||||||
<option value="">전체 소속</option>
|
<option value="">전체 소속</option>
|
||||||
{allOrgs.map((org) => (
|
{allOrgs.map((org) => (
|
||||||
@ -711,7 +711,7 @@ function UsersPanel() {
|
|||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
className="px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||||
>
|
>
|
||||||
<option value="">전체 상태</option>
|
<option value="">전체 상태</option>
|
||||||
<option value="PENDING">승인대기</option>
|
<option value="PENDING">승인대기</option>
|
||||||
@ -726,11 +726,11 @@ function UsersPanel() {
|
|||||||
placeholder="이름, 계정 검색..."
|
placeholder="이름, 계정 검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="w-56 px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
className="w-56 px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowRegisterModal(true)}
|
onClick={() => setShowRegisterModal(true)}
|
||||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
|
className="px-4 py-2 text-caption font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
|
||||||
>
|
>
|
||||||
+ 사용자 등록
|
+ 사용자 등록
|
||||||
</button>
|
</button>
|
||||||
@ -740,7 +740,7 @@ function UsersPanel() {
|
|||||||
{/* 테이블 */}
|
{/* 테이블 */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-32 text-fg-disabled text-sm font-korean">
|
<div className="flex items-center justify-center h-32 text-fg-disabled text-body-2 font-korean">
|
||||||
불러오는 중...
|
불러오는 중...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -781,7 +781,7 @@ function UsersPanel() {
|
|||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={9}
|
colSpan={9}
|
||||||
className="px-6 py-10 text-center text-xs text-fg-disabled font-korean"
|
className="px-6 py-10 text-center text-caption text-fg-disabled font-korean"
|
||||||
>
|
>
|
||||||
조회된 사용자가 없습니다.
|
조회된 사용자가 없습니다.
|
||||||
</td>
|
</td>
|
||||||
@ -897,7 +897,7 @@ function UsersPanel() {
|
|||||||
onChange={() => toggleRoleSelection(role.sn)}
|
onChange={() => toggleRoleSelection(role.sn)}
|
||||||
style={{ accentColor: color }}
|
style={{ accentColor: color }}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs font-korean" style={{ color }}>
|
<span className="text-caption font-korean" style={{ color }}>
|
||||||
{role.name}
|
{role.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-caption text-fg-disabled font-mono">
|
<span className="text-caption text-fg-disabled font-mono">
|
||||||
|
|||||||
@ -90,7 +90,7 @@ function VesselMaterialsPanel() {
|
|||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-bold text-fg font-korean">방제선 보유자재 현황</h1>
|
<h1 className="text-lg font-bold text-fg font-korean">방제선 보유자재 현황</h1>
|
||||||
<p className="text-xs text-fg-disabled mt-1 font-korean">
|
<p className="text-caption text-fg-disabled mt-1 font-korean">
|
||||||
총 {filtered.length}개 기관 (방제선 보유)
|
총 {filtered.length}개 기관 (방제선 보유)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -98,7 +98,7 @@ function VesselMaterialsPanel() {
|
|||||||
<select
|
<select
|
||||||
value={regionFilter}
|
value={regionFilter}
|
||||||
onChange={handleFilterChange(setRegionFilter)}
|
onChange={handleFilterChange(setRegionFilter)}
|
||||||
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
className="px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||||
>
|
>
|
||||||
<option value="전체">전체 관할청</option>
|
<option value="전체">전체 관할청</option>
|
||||||
<option value="남해">남해청</option>
|
<option value="남해">남해청</option>
|
||||||
@ -110,7 +110,7 @@ function VesselMaterialsPanel() {
|
|||||||
<select
|
<select
|
||||||
value={typeFilter}
|
value={typeFilter}
|
||||||
onChange={handleFilterChange(setTypeFilter)}
|
onChange={handleFilterChange(setTypeFilter)}
|
||||||
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
className="px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||||||
>
|
>
|
||||||
<option value="전체">전체 유형</option>
|
<option value="전체">전체 유형</option>
|
||||||
{typeOptions.map((t) => (
|
{typeOptions.map((t) => (
|
||||||
@ -127,11 +127,11 @@ function VesselMaterialsPanel() {
|
|||||||
setSearchTerm(e.target.value);
|
setSearchTerm(e.target.value);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
className="w-56 px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
className="w-56 px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={load}
|
onClick={load}
|
||||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-bg-elevated border border-stroke text-fg-sub hover:border-color-accent hover:text-color-accent transition-all font-korean"
|
className="px-4 py-2 text-caption font-semibold rounded-md bg-bg-elevated border border-stroke text-fg-sub hover:border-color-accent hover:text-color-accent transition-all font-korean"
|
||||||
>
|
>
|
||||||
새로고침
|
새로고침
|
||||||
</button>
|
</button>
|
||||||
@ -141,7 +141,7 @@ function VesselMaterialsPanel() {
|
|||||||
{/* 테이블 */}
|
{/* 테이블 */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-32 text-fg-disabled text-sm font-korean">
|
<div className="flex items-center justify-center h-32 text-fg-disabled text-body-2 font-korean">
|
||||||
불러오는 중...
|
불러오는 중...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -188,7 +188,7 @@ function VesselMaterialsPanel() {
|
|||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={11}
|
colSpan={11}
|
||||||
className="px-6 py-10 text-center text-xs text-fg-disabled font-korean"
|
className="px-6 py-10 text-center text-caption text-fg-disabled font-korean"
|
||||||
>
|
>
|
||||||
조회된 기관이 없습니다.
|
조회된 기관이 없습니다.
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -142,17 +142,17 @@ export default function VesselSignalPanel() {
|
|||||||
<div className="flex flex-col h-full overflow-hidden">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between px-6 py-3 border-b border-stroke-1">
|
<div className="flex items-center justify-between px-6 py-3 border-b border-stroke-1">
|
||||||
<h2 className="text-sm font-semibold text-fg">선박신호 수신 현황</h2>
|
<h2 className="text-body-2 font-semibold text-fg">선박신호 수신 현황</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={date}
|
value={date}
|
||||||
onChange={(e) => setDate(e.target.value)}
|
onChange={(e) => setDate(e.target.value)}
|
||||||
className="px-2 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-fg"
|
className="px-2 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-fg"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={load}
|
onClick={load}
|
||||||
className="px-3 py-1 text-xs rounded bg-bg-elevated border border-stroke-1 text-fg-sub hover:bg-bg-card"
|
className="px-3 py-1 text-caption rounded bg-bg-elevated border border-stroke-1 text-fg-sub hover:bg-bg-card"
|
||||||
>
|
>
|
||||||
새로고침
|
새로고침
|
||||||
</button>
|
</button>
|
||||||
@ -163,7 +163,7 @@ export default function VesselSignalPanel() {
|
|||||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<span className="text-xs text-fg-disabled">로딩 중...</span>
|
<span className="text-caption text-fg-disabled">로딩 중...</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@ -15,6 +15,7 @@ export const ADMIN_MENU: AdminMenuItem[] = [
|
|||||||
children: [
|
children: [
|
||||||
{ id: 'menus', label: '메뉴관리' },
|
{ id: 'menus', label: '메뉴관리' },
|
||||||
{ id: 'settings', label: '시스템설정' },
|
{ id: 'settings', label: '시스템설정' },
|
||||||
|
{ id: 'system-arch', label: '시스템구조' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -91,6 +92,17 @@ export const ADMIN_MENU: AdminMenuItem[] = [
|
|||||||
{ id: 'monitor-vessel', label: '선박위치정보' },
|
{ id: 'monitor-vessel', label: '선박위치정보' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'rnd',
|
||||||
|
label: 'R&D과제',
|
||||||
|
children: [
|
||||||
|
{ id: 'rnd-poseidon', label: '유출유확산예측(포세이돈)' },
|
||||||
|
{ id: 'rnd-kosps', label: '유출유확산예측(KOSPS)' },
|
||||||
|
{ id: 'rnd-hns-atmos', label: 'HNS대기확산(충북대)' },
|
||||||
|
{ id: 'rnd-rescue', label: '긴급구난과제' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: 'deidentify', label: '비식별화조치' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -589,7 +589,7 @@ export function CctvView() {
|
|||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="p-3 pb-2.5 border-b border-stroke shrink-0 bg-bg-elevated">
|
<div className="p-3 pb-2.5 border-b border-stroke shrink-0 bg-bg-elevated">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="text-xs font-bold text-fg font-korean flex items-center gap-1.5">
|
<div className="text-caption font-bold text-fg font-korean flex items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
className="w-[7px] h-[7px] rounded-full inline-block animate-pulse"
|
className="w-[7px] h-[7px] rounded-full inline-block animate-pulse"
|
||||||
style={{ background: 'var(--color-danger)' }}
|
style={{ background: 'var(--color-danger)' }}
|
||||||
@ -776,7 +776,7 @@ export function CctvView() {
|
|||||||
{/* 뷰어 툴바 */}
|
{/* 뷰어 툴바 */}
|
||||||
<div className="flex items-center justify-between px-4 py-2 border-b border-stroke bg-bg-elevated shrink-0 gap-2.5">
|
<div className="flex items-center justify-between px-4 py-2 border-b border-stroke bg-bg-elevated shrink-0 gap-2.5">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<div className="text-xs font-bold text-fg font-korean whitespace-nowrap overflow-hidden text-ellipsis">
|
<div className="text-caption font-bold text-fg font-korean whitespace-nowrap overflow-hidden text-ellipsis">
|
||||||
{selectedCamera ? `📹 ${selectedCamera.cameraNm}` : '📹 카메라를 선택하세요'}
|
{selectedCamera ? `📹 ${selectedCamera.cameraNm}` : '📹 카메라를 선택하세요'}
|
||||||
</div>
|
</div>
|
||||||
{selectedCamera?.sttsCd === 'LIVE' && (
|
{selectedCamera?.sttsCd === 'LIVE' && (
|
||||||
@ -966,7 +966,7 @@ export function CctvView() {
|
|||||||
<div key={srcKey} className="mb-5">
|
<div key={srcKey} className="mb-5">
|
||||||
{/* 출처 헤더 */}
|
{/* 출처 헤더 */}
|
||||||
<div className="flex items-center gap-2 mb-2 pb-1.5 border-b border-stroke">
|
<div className="flex items-center gap-2 mb-2 pb-1.5 border-b border-stroke">
|
||||||
<span className="text-sm">{group.icon}</span>
|
<span className="text-body-2">{group.icon}</span>
|
||||||
<span className="text-label-1 font-bold text-fg font-korean">
|
<span className="text-label-1 font-bold text-fg font-korean">
|
||||||
{group.label}
|
{group.label}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -421,7 +421,7 @@ export function MediaManagement() {
|
|||||||
<div className="fixed inset-0 z-[300] bg-black/60 backdrop-blur-sm flex items-center justify-center">
|
<div className="fixed inset-0 z-[300] bg-black/60 backdrop-blur-sm flex items-center justify-center">
|
||||||
<div className="bg-bg-surface border border-stroke rounded-md p-6 w-72 text-center">
|
<div className="bg-bg-surface border border-stroke rounded-md p-6 w-72 text-center">
|
||||||
<div className="text-2xl mb-3">📥</div>
|
<div className="text-2xl mb-3">📥</div>
|
||||||
<div className="text-sm font-bold font-korean mb-3">다운로드 완료</div>
|
<div className="text-body-2 font-bold font-korean mb-3">다운로드 완료</div>
|
||||||
<div className="text-title-4 font-korean text-fg-sub mb-1">
|
<div className="text-title-4 font-korean text-fg-sub mb-1">
|
||||||
총 <span className="text-color-accent font-bold">{downloadResult.total}</span>건 선택
|
총 <span className="text-color-accent font-bold">{downloadResult.total}</span>건 선택
|
||||||
</div>
|
</div>
|
||||||
@ -441,7 +441,7 @@ export function MediaManagement() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setDownloadResult(null)}
|
onClick={() => setDownloadResult(null)}
|
||||||
className="px-6 py-2 text-sm font-semibold rounded bg-[rgba(6,182,212,0.15)] text-color-accent border border-[rgba(6,182,212,0.3)] hover:bg-[rgba(6,182,212,0.25)] transition-colors font-korean"
|
className="px-6 py-2 text-body-2 font-semibold rounded bg-[rgba(6,182,212,0.15)] text-color-accent border border-[rgba(6,182,212,0.3)] hover:bg-[rgba(6,182,212,0.25)] transition-colors font-korean"
|
||||||
>
|
>
|
||||||
확인
|
확인
|
||||||
</button>
|
</button>
|
||||||
@ -475,7 +475,7 @@ export function MediaManagement() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="block text-xs font-semibold mb-1.5 text-fg-sub font-korean">
|
<label className="block text-caption font-semibold mb-1.5 text-fg-sub font-korean">
|
||||||
촬영 장비
|
촬영 장비
|
||||||
</label>
|
</label>
|
||||||
<select className="prd-i w-full">
|
<select className="prd-i w-full">
|
||||||
@ -489,7 +489,7 @@ export function MediaManagement() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="block text-xs font-semibold mb-1.5 text-fg-sub font-korean">
|
<label className="block text-caption font-semibold mb-1.5 text-fg-sub font-korean">
|
||||||
연관 사고
|
연관 사고
|
||||||
</label>
|
</label>
|
||||||
<select className="prd-i w-full">
|
<select className="prd-i w-full">
|
||||||
@ -499,7 +499,7 @@ export function MediaManagement() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block text-xs font-semibold mb-1.5 text-fg-sub font-korean">
|
<label className="block text-caption font-semibold mb-1.5 text-fg-sub font-korean">
|
||||||
메모
|
메모
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
@ -508,7 +508,7 @@ export function MediaManagement() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="w-full py-3 rounded-sm text-sm font-bold font-korean cursor-pointer hover:brightness-125 transition-all"
|
className="w-full py-3 rounded-sm text-body-2 font-bold font-korean cursor-pointer hover:brightness-125 transition-all"
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(6,182,212,0.15)',
|
background: 'rgba(6,182,212,0.15)',
|
||||||
border: '1px solid rgba(6,182,212,0.3)',
|
border: '1px solid rgba(6,182,212,0.3)',
|
||||||
|
|||||||
@ -239,7 +239,7 @@ export function OilAreaAnalysis() {
|
|||||||
<div className="flex gap-5 h-full overflow-hidden">
|
<div className="flex gap-5 h-full overflow-hidden">
|
||||||
{/* ── Left Panel ── */}
|
{/* ── Left Panel ── */}
|
||||||
<div className="w-[280px] min-w-[280px] flex flex-col overflow-y-auto scrollbar-thin">
|
<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-body-2 font-bold mb-1 font-korean">🧩 영상사진합성</div>
|
||||||
<div className="text-label-2 text-fg-disabled mb-4 font-korean">
|
<div className="text-label-2 text-fg-disabled mb-4 font-korean">
|
||||||
드론 사진을 합성하여 유출유 확산 면적과 기름 양을 산정합니다.
|
드론 사진을 합성하여 유출유 확산 면적과 기름 양을 산정합니다.
|
||||||
</div>
|
</div>
|
||||||
@ -256,7 +256,7 @@ export function OilAreaAnalysis() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={selectedFiles.length >= MAX_IMAGES || isStitching || isAnalyzing}
|
disabled={selectedFiles.length >= MAX_IMAGES || isStitching || isAnalyzing}
|
||||||
className="w-full py-2 mb-3 border border-dashed border-stroke rounded-sm text-xs font-korean text-fg-sub
|
className="w-full py-2 mb-3 border border-dashed border-stroke rounded-sm text-caption font-korean text-fg-sub
|
||||||
hover:border-color-accent hover:text-color-accent transition-colors cursor-pointer
|
hover:border-color-accent hover:text-color-accent transition-colors cursor-pointer
|
||||||
disabled:opacity-40 disabled:cursor-not-allowed"
|
disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -177,7 +177,7 @@ export function RealtimeDrone() {
|
|||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="p-3 pb-2.5 border-b border-stroke shrink-0 bg-bg-elevated">
|
<div className="p-3 pb-2.5 border-b border-stroke shrink-0 bg-bg-elevated">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="text-xs font-bold text-fg font-korean flex items-center gap-1.5">
|
<div className="text-caption font-bold text-fg font-korean flex items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
className="w-[7px] h-[7px] rounded-full inline-block"
|
className="w-[7px] h-[7px] rounded-full inline-block"
|
||||||
style={{
|
style={{
|
||||||
@ -225,7 +225,7 @@ export function RealtimeDrone() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-sm">🚁</div>
|
<div className="text-body-2">🚁</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-label-2 font-normal text-fg font-korean">
|
<div className="text-label-2 font-normal text-fg font-korean">
|
||||||
{stream.shipName}{' '}
|
{stream.shipName}{' '}
|
||||||
@ -300,7 +300,7 @@ export function RealtimeDrone() {
|
|||||||
{/* 툴바 */}
|
{/* 툴바 */}
|
||||||
<div className="flex items-center justify-between px-4 py-2 border-b border-stroke bg-bg-elevated shrink-0 gap-2.5">
|
<div className="flex items-center justify-between px-4 py-2 border-b border-stroke bg-bg-elevated shrink-0 gap-2.5">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<div className="text-xs font-bold text-fg font-korean whitespace-nowrap overflow-hidden text-ellipsis">
|
<div className="text-caption font-bold text-fg font-korean whitespace-nowrap overflow-hidden text-ellipsis">
|
||||||
{selectedStream ? `🚁 ${selectedStream.shipName}` : '🚁 드론 스트림을 선택하세요'}
|
{selectedStream ? `🚁 ${selectedStream.shipName}` : '🚁 드론 스트림을 선택하세요'}
|
||||||
</div>
|
</div>
|
||||||
{selectedStream?.status === 'streaming' && (
|
{selectedStream?.status === 'streaming' && (
|
||||||
@ -566,7 +566,7 @@ export function RealtimeDrone() {
|
|||||||
style={{ minWidth: 170, background: 'var(--bg-card)', borderRadius: 6 }}
|
style={{ minWidth: 170, background: 'var(--bg-card)', borderRadius: 6 }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5 mb-1">
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
<span className="text-sm">🚁</span>
|
<span className="text-body-2">🚁</span>
|
||||||
<div className="text-label-2 font-bold text-fg">{mapPopup.shipName}</div>
|
<div className="text-label-2 font-bold text-fg">{mapPopup.shipName}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-caption text-fg-disabled mb-0.5">
|
<div className="text-caption text-fg-disabled mb-0.5">
|
||||||
@ -813,7 +813,7 @@ export function RealtimeDrone() {
|
|||||||
].map((item, i) => (
|
].map((item, i) => (
|
||||||
<div key={i} className="px-2 py-1.5 bg-bg-base rounded text-center">
|
<div key={i} className="px-2 py-1.5 bg-bg-base rounded text-center">
|
||||||
<div className="text-caption text-fg-disabled font-korean">{item.label}</div>
|
<div className="text-caption text-fg-disabled font-korean">{item.label}</div>
|
||||||
<div className="text-sm font-mono text-fg">{item.value}</div>
|
<div className="text-body-2 font-mono text-fg">{item.value}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -485,7 +485,7 @@ export function SatelliteRequest() {
|
|||||||
<div className="flex items-center gap-3 mb-2 h-9">
|
<div className="flex items-center gap-3 mb-2 h-9">
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<div
|
<div
|
||||||
className="w-7 h-7 rounded-md flex items-center justify-center text-sm"
|
className="w-7 h-7 rounded-md flex items-center justify-center text-body-2"
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(6,182,212,0.15)',
|
background: 'rgba(6,182,212,0.15)',
|
||||||
border: '1px solid rgba(6,182,212,0.3)',
|
border: '1px solid rgba(6,182,212,0.3)',
|
||||||
@ -624,7 +624,7 @@ export function SatelliteRequest() {
|
|||||||
>
|
>
|
||||||
<div className="text-label-2 font-mono text-fg-sub">{r.id}</div>
|
<div className="text-label-2 font-mono text-fg-sub">{r.id}</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-semibold text-fg font-korean">{r.zone}</div>
|
<div className="text-caption font-semibold text-fg font-korean">{r.zone}</div>
|
||||||
<div className="text-caption text-fg-disabled font-mono mt-0.5">
|
<div className="text-caption text-fg-disabled font-mono mt-0.5">
|
||||||
{r.zoneCoord} · {r.zoneArea}
|
{r.zoneCoord} · {r.zoneArea}
|
||||||
</div>
|
</div>
|
||||||
@ -776,7 +776,9 @@ export function SatelliteRequest() {
|
|||||||
<div className="grid grid-cols-2 gap-3.5">
|
<div className="grid grid-cols-2 gap-3.5">
|
||||||
{/* 가용 위성 현황 */}
|
{/* 가용 위성 현황 */}
|
||||||
<div className="bg-bg-elevated border border-stroke rounded-md p-4">
|
<div className="bg-bg-elevated border border-stroke rounded-md p-4">
|
||||||
<div className="text-xs font-bold text-fg font-korean mb-3">🛰 가용 위성 현황</div>
|
<div className="text-caption font-bold text-fg font-korean mb-3">
|
||||||
|
🛰 가용 위성 현황
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{satellites.map((sat, i) => (
|
{satellites.map((sat, i) => (
|
||||||
<div
|
<div
|
||||||
@ -807,7 +809,7 @@ export function SatelliteRequest() {
|
|||||||
|
|
||||||
{/* 오늘 촬영 가능 시간 */}
|
{/* 오늘 촬영 가능 시간 */}
|
||||||
<div className="bg-bg-elevated border border-stroke rounded-md p-4">
|
<div className="bg-bg-elevated border border-stroke rounded-md p-4">
|
||||||
<div className="text-xs font-bold text-fg font-korean mb-3">
|
<div className="text-caption font-bold text-fg font-korean mb-3">
|
||||||
⏰ 오늘 촬영 가능 시간 (KST)
|
⏰ 오늘 촬영 가능 시간 (KST)
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
@ -1133,7 +1135,7 @@ export function SatelliteRequest() {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setMapSelectedItem(null)}
|
onClick={() => setMapSelectedItem(null)}
|
||||||
className="text-fg-disabled bg-transparent border-none cursor-pointer text-sm"
|
className="text-fg-disabled bg-transparent border-none cursor-pointer text-body-2"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
@ -1213,7 +1215,7 @@ export function SatelliteRequest() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-bold text-fg font-korean">BlackSky</div>
|
<div className="text-body-2 font-bold text-fg font-korean">BlackSky</div>
|
||||||
<div className="text-caption text-fg-disabled font-korean mt-px">
|
<div className="text-caption text-fg-disabled font-korean mt-px">
|
||||||
Maxar Electro-Optical API
|
Maxar Electro-Optical API
|
||||||
</div>
|
</div>
|
||||||
@ -1274,7 +1276,7 @@ export function SatelliteRequest() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-bold text-fg font-korean">
|
<div className="text-body-2 font-bold text-fg font-korean">
|
||||||
UP42 — EO + SAR
|
UP42 — EO + SAR
|
||||||
</div>
|
</div>
|
||||||
<div className="text-caption text-fg-disabled font-korean mt-px">
|
<div className="text-caption text-fg-disabled font-korean mt-px">
|
||||||
@ -1703,13 +1705,13 @@ export function SatelliteRequest() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setModalPhase('provider')}
|
onClick={() => setModalPhase('provider')}
|
||||||
className="px-5 py-2.5 rounded-lg border border-stroke text-xs font-semibold cursor-pointer font-korean text-fg-sub bg-bg-elevated"
|
className="px-5 py-2.5 rounded-lg border border-stroke text-caption font-semibold cursor-pointer font-korean text-fg-sub bg-bg-elevated"
|
||||||
>
|
>
|
||||||
← 뒤로
|
← 뒤로
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setModalPhase('none')}
|
onClick={() => setModalPhase('none')}
|
||||||
className="px-7 py-2.5 rounded-lg text-xs font-bold cursor-pointer font-korean text-color-accent"
|
className="px-7 py-2.5 rounded-lg text-caption font-bold cursor-pointer font-korean text-color-accent"
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(6,182,212,0.08)',
|
background: 'rgba(6,182,212,0.08)',
|
||||||
}}
|
}}
|
||||||
@ -2098,7 +2100,7 @@ export function SatelliteRequest() {
|
|||||||
{urgency}
|
{urgency}
|
||||||
</span>
|
</span>
|
||||||
{up42SelPass === pass.id && (
|
{up42SelPass === pass.id && (
|
||||||
<span className="text-xs text-fg">✓</span>
|
<span className="text-caption text-fg">✓</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -391,7 +391,7 @@ function Vessel3DModel({ viewMode, status }: { viewMode: string; status: string
|
|||||||
{isProcessing && (
|
{isProcessing && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-color-accent/40 text-xs font-mono animate-pulse">
|
<div className="text-color-accent/40 text-caption font-mono animate-pulse">
|
||||||
재구성 처리중...
|
재구성 처리중...
|
||||||
</div>
|
</div>
|
||||||
<div className="w-24 h-0.5 bg-bg-card rounded-full mt-2 mx-auto overflow-hidden">
|
<div className="w-24 h-0.5 bg-bg-card rounded-full mt-2 mx-auto overflow-hidden">
|
||||||
@ -712,7 +712,7 @@ function Pollution3DModel({ viewMode, status }: { viewMode: string; status: stri
|
|||||||
{isProcessing && (
|
{isProcessing && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-color-danger/40 text-xs font-mono animate-pulse">
|
<div className="text-color-danger/40 text-caption font-mono animate-pulse">
|
||||||
재구성 처리중...
|
재구성 처리중...
|
||||||
</div>
|
</div>
|
||||||
<div className="w-24 h-0.5 bg-bg-card rounded-full mt-2 mx-auto overflow-hidden">
|
<div className="w-24 h-0.5 bg-bg-card rounded-full mt-2 mx-auto overflow-hidden">
|
||||||
@ -818,7 +818,7 @@ export function SensorAnalysis() {
|
|||||||
className="absolute inset-0 flex items-center justify-center"
|
className="absolute inset-0 flex items-center justify-center"
|
||||||
style={{ background: 'var(--bg-base)' }}
|
style={{ background: 'var(--bg-base)' }}
|
||||||
>
|
>
|
||||||
<div className="text-fg-disabled/10 text-xs font-mono">
|
<div className="text-fg-disabled/10 text-caption font-mono">
|
||||||
{src.label.split(' ')[0]}
|
{src.label.split(' ')[0]}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -916,7 +916,7 @@ export function SensorAnalysis() {
|
|||||||
{ value: '0.023m', label: 'RMS오차' },
|
{ value: '0.023m', label: 'RMS오차' },
|
||||||
].map((s, i) => (
|
].map((s, i) => (
|
||||||
<div key={i} className="text-center">
|
<div key={i} className="text-center">
|
||||||
<div className="font-mono font-bold text-sm text-color-accent">{s.value}</div>
|
<div className="font-mono font-bold text-body-2 text-color-accent">{s.value}</div>
|
||||||
<div className="text-caption text-fg-disabled mt-0.5 font-korean">{s.label}</div>
|
<div className="text-caption text-fg-disabled mt-0.5 font-korean">{s.label}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -47,7 +47,7 @@ export function WingAI() {
|
|||||||
<div className="flex items-center gap-3 mb-4 h-9">
|
<div className="flex items-center gap-3 mb-4 h-9">
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<div
|
<div
|
||||||
className="w-7 h-7 rounded-md flex items-center justify-center text-sm"
|
className="w-7 h-7 rounded-md flex items-center justify-center text-body-2"
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(6,182,212,0.15)',
|
background: 'rgba(6,182,212,0.15)',
|
||||||
border: '1px solid rgba(6,182,212,0.3)',
|
border: '1px solid rgba(6,182,212,0.3)',
|
||||||
@ -1757,8 +1757,8 @@ function AoiPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="text-sm shrink-0">{active ? '◉' : '○'}</span>
|
<span className="text-body-2 shrink-0">{active ? '◉' : '○'}</span>
|
||||||
<span className="text-sm shrink-0">{src.icon}</span>
|
<span className="text-body-2 shrink-0">{src.icon}</span>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-caption font-bold font-korean">
|
<div className="text-caption font-bold font-korean">
|
||||||
{src.label}
|
{src.label}
|
||||||
|
|||||||
@ -83,7 +83,9 @@ function AssetManagement() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-fg-disabled text-sm font-korean">방제자산 데이터를 불러오는 중...</div>
|
<div className="text-fg-disabled text-body-2 font-korean">
|
||||||
|
방제자산 데이터를 불러오는 중...
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -415,7 +417,7 @@ function AssetManagement() {
|
|||||||
<aside className="w-[340px] min-w-[340px] bg-bg-surface border-l border-stroke flex flex-col">
|
<aside className="w-[340px] min-w-[340px] bg-bg-surface border-l border-stroke flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-4 border-b border-stroke">
|
<div className="p-4 border-b border-stroke">
|
||||||
<div className="text-sm font-bold mb-1 font-korean">{selectedOrg.name}</div>
|
<div className="text-body-2 font-bold mb-1 font-korean">{selectedOrg.name}</div>
|
||||||
<div className="text-label-2 text-fg-sub font-semibold font-korean mb-1">
|
<div className="text-label-2 text-fg-sub font-semibold font-korean mb-1">
|
||||||
{selectedOrg.type} · {regionShort(selectedOrg.jurisdiction)} · {selectedOrg.area}
|
{selectedOrg.type} · {regionShort(selectedOrg.jurisdiction)} · {selectedOrg.area}
|
||||||
</div>
|
</div>
|
||||||
@ -505,7 +507,7 @@ function AssetManagement() {
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center text-fg-disabled text-xs py-8 font-korean">
|
<div className="text-center text-fg-disabled text-caption py-8 font-korean">
|
||||||
상세 장비 데이터가 없습니다.
|
상세 장비 데이터가 없습니다.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -601,10 +603,10 @@ function AssetManagement() {
|
|||||||
|
|
||||||
{/* Bottom Actions */}
|
{/* Bottom Actions */}
|
||||||
{/* <div className="p-3.5 border-t border-stroke flex gap-2">
|
{/* <div className="p-3.5 border-t border-stroke flex gap-2">
|
||||||
<button className="flex-1 py-2.5 rounded-sm text-xs font-semibold font-korean text-white bg-color-accent border-none cursor-pointer hover:opacity-90 transition-opacity">
|
<button className="flex-1 py-2.5 rounded-sm text-caption font-semibold font-korean text-white bg-color-accent border-none cursor-pointer hover:opacity-90 transition-opacity">
|
||||||
📥 다운로드
|
📥 다운로드
|
||||||
</button>
|
</button>
|
||||||
<button className="flex-1 py-2.5 rounded-sm text-xs font-semibold font-korean bg-bg-card border border-stroke text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors">
|
<button className="flex-1 py-2.5 rounded-sm text-caption font-semibold font-korean bg-bg-card border border-stroke text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors">
|
||||||
✏ 수정
|
✏ 수정
|
||||||
</button>
|
</button>
|
||||||
</div> */}
|
</div> */}
|
||||||
|
|||||||
@ -27,7 +27,7 @@ function AssetUpload() {
|
|||||||
{/* Drop Zone */}
|
{/* Drop Zone */}
|
||||||
<div className="border-2 border-dashed border-stroke-light rounded-md py-10 px-5 text-center mb-5 cursor-pointer hover:border-[rgba(6,182,212,0.4)] transition-colors">
|
<div className="border-2 border-dashed border-stroke-light rounded-md py-10 px-5 text-center mb-5 cursor-pointer hover:border-[rgba(6,182,212,0.4)] transition-colors">
|
||||||
<div className="text-4xl mb-2.5 opacity-50">📁</div>
|
<div className="text-4xl mb-2.5 opacity-50">📁</div>
|
||||||
<div className="text-sm font-semibold mb-1.5 font-korean">
|
<div className="text-body-2 font-semibold mb-1.5 font-korean">
|
||||||
파일을 드래그하거나 클릭하여 업로드
|
파일을 드래그하거나 클릭하여 업로드
|
||||||
</div>
|
</div>
|
||||||
<div className="text-label-2 text-fg-disabled mb-4 font-korean">
|
<div className="text-label-2 text-fg-disabled mb-4 font-korean">
|
||||||
@ -43,7 +43,7 @@ function AssetUpload() {
|
|||||||
|
|
||||||
{/* Asset Classification */}
|
{/* Asset Classification */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block text-xs font-semibold mb-1.5 text-fg-sub font-korean">
|
<label className="block text-caption font-semibold mb-1.5 text-fg-sub font-korean">
|
||||||
자산 분류
|
자산 분류
|
||||||
</label>
|
</label>
|
||||||
<select className="prd-i w-full">
|
<select className="prd-i w-full">
|
||||||
@ -58,7 +58,7 @@ function AssetUpload() {
|
|||||||
|
|
||||||
{/* Jurisdiction */}
|
{/* Jurisdiction */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block text-xs font-semibold mb-1.5 text-fg-sub font-korean">
|
<label className="block text-caption font-semibold mb-1.5 text-fg-sub font-korean">
|
||||||
업로드 대상 관할
|
업로드 대상 관할
|
||||||
</label>
|
</label>
|
||||||
<select className="prd-i w-full">
|
<select className="prd-i w-full">
|
||||||
@ -74,10 +74,10 @@ function AssetUpload() {
|
|||||||
|
|
||||||
{/* Upload Mode */}
|
{/* Upload Mode */}
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<label className="block text-xs font-semibold mb-1.5 text-fg-sub font-korean">
|
<label className="block text-caption font-semibold mb-1.5 text-fg-sub font-korean">
|
||||||
업로드 방식
|
업로드 방식
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-4 text-xs text-fg-sub font-korean">
|
<div className="flex gap-4 text-caption text-fg-sub font-korean">
|
||||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@ -102,7 +102,7 @@ function AssetUpload() {
|
|||||||
{/* Upload Button */}
|
{/* Upload Button */}
|
||||||
<button
|
<button
|
||||||
onClick={handleUpload}
|
onClick={handleUpload}
|
||||||
className={`w-full py-3.5 rounded-sm text-sm font-bold font-korean border-none cursor-pointer transition-all ${
|
className={`w-full py-3.5 rounded-sm text-body-2 font-bold font-korean border-none cursor-pointer transition-all ${
|
||||||
uploaded
|
uploaded
|
||||||
? 'bg-[rgba(34,197,94,0.2)] text-color-success border border-status-green'
|
? 'bg-[rgba(34,197,94,0.2)] text-color-success border border-status-green'
|
||||||
: 'text-white'
|
: 'text-white'
|
||||||
@ -163,7 +163,7 @@ function AssetUpload() {
|
|||||||
{p.icon}
|
{p.icon}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className={`text-xs font-bold font-korean ${p.color}`}>{p.role}</div>
|
<div className={`text-caption font-bold font-korean ${p.color}`}>{p.role}</div>
|
||||||
<div className="text-caption text-fg-disabled font-korean">{p.desc}</div>
|
<div className="text-caption text-fg-disabled font-korean">{p.desc}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -179,7 +179,7 @@ function AssetUpload() {
|
|||||||
className="flex justify-between items-center p-3.5 px-4 bg-bg-card border border-stroke rounded-sm"
|
className="flex justify-between items-center p-3.5 px-4 bg-bg-card border border-stroke rounded-sm"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-semibold font-korean">{h.fileNm}</div>
|
<div className="text-caption font-semibold font-korean">{h.fileNm}</div>
|
||||||
<div className="text-caption text-fg-disabled mt-0.5 font-korean">
|
<div className="text-caption text-fg-disabled mt-0.5 font-korean">
|
||||||
{new Date(h.regDtm).toLocaleString('ko-KR')} · {h.uploaderNm} · {h.uploadCnt}건
|
{new Date(h.regDtm).toLocaleString('ko-KR')} · {h.uploaderNm} · {h.uploadCnt}건
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -27,10 +27,8 @@ export function AssetsView() {
|
|||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={`px-5 py-3.5 text-xs font-semibold transition-all font-korean border-b-2 ${
|
className={`px-4 py-2.5 text-title-4 font-medium transition-all duration-200 font-korean tracking-navigation ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id ? 'text-color-accent' : 'text-fg-sub hover:text-fg'
|
||||||
? 'text-color-accent border-color-accent'
|
|
||||||
: 'text-fg-disabled border-transparent hover:text-fg-sub'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
|
|||||||
@ -167,7 +167,7 @@ function ShipInsurance() {
|
|||||||
{total > 0 ? `${total.toLocaleString()}건` : '데이터 없음'}
|
{total > 0 ? `${total.toLocaleString()}건` : '데이터 없음'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-fg-disabled">
|
<div className="text-caption text-fg-disabled">
|
||||||
해양수산부 해운항만물류정보 공공데이터 기반
|
해양수산부 해운항만물류정보 공공데이터 기반
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -200,7 +200,7 @@ function ShipInsurance() {
|
|||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
placeholder="선박명, 호출부호, IMO, 선주명"
|
placeholder="선박명, 호출부호, IMO, 선주명"
|
||||||
className="w-full px-3.5 py-2 bg-bg-base border border-stroke rounded-sm text-xs outline-none box-border"
|
className="w-full px-3.5 py-2 bg-bg-base border border-stroke rounded-sm text-caption outline-none box-border"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -243,20 +243,20 @@ function ShipInsurance() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleSearch}
|
onClick={handleSearch}
|
||||||
className="px-4 py-2 bg-bg-base text-fg border border-stroke rounded-sm text-xs cursor-pointer"
|
className="px-4 py-2 bg-bg-base text-fg border border-stroke rounded-sm text-caption cursor-pointer"
|
||||||
>
|
>
|
||||||
조회
|
조회
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
className="px-4 py-2 bg-bg-base text-fg border border-stroke rounded-sm text-xs cursor-pointer"
|
className="px-4 py-2 bg-bg-base text-fg border border-stroke rounded-sm text-caption cursor-pointer"
|
||||||
>
|
>
|
||||||
초기화
|
초기화
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
disabled={total === 0}
|
disabled={total === 0}
|
||||||
className="px-4 py-2 text-xs cursor-pointer rounded-sm disabled:opacity-30 disabled:cursor-default bg-bg-base border border-stroke"
|
className="px-4 py-2 text-caption cursor-pointer rounded-sm disabled:opacity-30 disabled:cursor-default bg-bg-base border border-stroke"
|
||||||
>
|
>
|
||||||
엑셀 다운로드
|
엑셀 다운로드
|
||||||
</button>
|
</button>
|
||||||
@ -281,8 +281,8 @@ function ShipInsurance() {
|
|||||||
{/* 에러 */}
|
{/* 에러 */}
|
||||||
{error && !isLoading && (
|
{error && !isLoading && (
|
||||||
<div className="flex flex-col items-center justify-center py-16 px-5 bg-bg-card border border-stroke rounded-md">
|
<div className="flex flex-col items-center justify-center py-16 px-5 bg-bg-card border border-stroke rounded-md">
|
||||||
<div className="text-sm font-bold text-color-danger mb-2">조회 실패</div>
|
<div className="text-body-2 font-bold text-color-danger mb-2">조회 실패</div>
|
||||||
<div className="text-xs text-fg-disabled">{error}</div>
|
<div className="text-caption text-fg-disabled">{error}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -291,7 +291,7 @@ function ShipInsurance() {
|
|||||||
<>
|
<>
|
||||||
<div className="border border-stroke rounded-md overflow-hidden mb-3">
|
<div className="border border-stroke rounded-md overflow-hidden mb-3">
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-stroke">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-stroke">
|
||||||
<div className="text-xs font-bold">
|
<div className="text-caption font-bold">
|
||||||
조회 결과 <span className="text-color-accent">{total.toLocaleString()}</span>건
|
조회 결과 <span className="text-color-accent">{total.toLocaleString()}</span>건
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<span className="text-fg-disabled font-normal ml-2">
|
<span className="text-fg-disabled font-normal ml-2">
|
||||||
|
|||||||
@ -219,7 +219,7 @@ export function HNSLeftPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-80 min-w-[320px] flex flex-col h-full bg-bg-surface border-r border-stroke overflow-hidden">
|
<div className="w-full min-w-0 flex flex-col h-full bg-bg-surface border-r border-stroke overflow-hidden">
|
||||||
{/* Scrollable Content */}
|
{/* Scrollable Content */}
|
||||||
<div
|
<div
|
||||||
className="flex-1 overflow-y-scroll scrollbar-thin scrollbar-thumb-border-light scrollbar-track-transparent bg-bg-base"
|
className="flex-1 overflow-y-scroll scrollbar-thin scrollbar-thumb-border-light scrollbar-track-transparent bg-bg-base"
|
||||||
@ -799,15 +799,15 @@ export function HNSLeftPanel({
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex justify-between items-center p-2 bg-bg-base rounded">
|
<div className="flex justify-between items-center p-2 bg-bg-base rounded">
|
||||||
<span className="text-label-2 text-fg-disabled">전체 분석</span>
|
<span className="text-label-2 text-fg-disabled">전체 분석</span>
|
||||||
<span className="text-sm font-bold text-color-accent font-mono">8건</span>
|
<span className="text-body-2 font-bold text-color-accent font-mono">8건</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center p-2 bg-bg-base rounded">
|
<div className="flex justify-between items-center p-2 bg-bg-base rounded">
|
||||||
<span className="text-label-2 text-fg-disabled">고위험 (AEGL-3)</span>
|
<span className="text-label-2 text-fg-disabled">고위험 (AEGL-3)</span>
|
||||||
<span className="text-sm font-bold text-color-caution font-mono">3건</span>
|
<span className="text-body-2 font-bold text-color-caution font-mono">3건</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center p-2 bg-bg-base rounded">
|
<div className="flex justify-between items-center p-2 bg-bg-base rounded">
|
||||||
<span className="text-label-2 text-fg-disabled">중위험 (AEGL-2)</span>
|
<span className="text-label-2 text-fg-disabled">중위험 (AEGL-2)</span>
|
||||||
<span className="text-sm font-bold text-color-accent font-mono">5건</span>
|
<span className="text-body-2 font-bold text-color-accent font-mono">5건</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -35,8 +35,8 @@ export function HNSRightPanel({
|
|||||||
}: HNSRightPanelProps) {
|
}: HNSRightPanelProps) {
|
||||||
if (!dispersionResult) {
|
if (!dispersionResult) {
|
||||||
return (
|
return (
|
||||||
<div className="w-[300px] bg-bg-surface border-l border-stroke p-4 overflow-auto">
|
<div className="w-full h-full bg-bg-surface border-l border-stroke p-4 overflow-auto flex items-center justify-center">
|
||||||
<div className="flex flex-col gap-3 items-center justify-center h-full text-fg-disabled text-label-1">
|
<div className="flex flex-col gap-3 items-center text-fg-disabled text-label-1">
|
||||||
<div style={{ fontSize: '32px', opacity: 0.3 }}>📊</div>
|
<div style={{ fontSize: '32px', opacity: 0.3 }}>📊</div>
|
||||||
<div>예측 실행 후 결과가 표시됩니다</div>
|
<div>예측 실행 후 결과가 표시됩니다</div>
|
||||||
</div>
|
</div>
|
||||||
@ -58,7 +58,7 @@ export function HNSRightPanel({
|
|||||||
: 'ALOHA';
|
: 'ALOHA';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-[300px] bg-bg-surface border-l border-stroke p-4 overflow-auto flex flex-col gap-4">
|
<div className="w-full bg-bg-surface border-l border-stroke p-4 overflow-auto flex flex-col gap-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1.5 mb-2">
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
|
|||||||
@ -792,7 +792,7 @@ ${styles}
|
|||||||
<div className="rounded-[10px] p-[14px] border border-stroke bg-bg-card">
|
<div className="rounded-[10px] p-[14px] border border-stroke bg-bg-card">
|
||||||
<div className="flex items-center justify-between mb-[10px]">
|
<div className="flex items-center justify-between mb-[10px]">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-mono font-extrabold text-color-accent">
|
<span className="text-body-2 font-mono font-extrabold text-color-accent">
|
||||||
NH₃
|
NH₃
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
<span className="text-label-1 font-bold">암모니아</span>
|
<span className="text-label-1 font-bold">암모니아</span>
|
||||||
@ -891,7 +891,7 @@ ${styles}
|
|||||||
<div className="rounded-[10px] p-[14px] border border-stroke bg-bg-card">
|
<div className="rounded-[10px] p-[14px] border border-stroke bg-bg-card">
|
||||||
<div className="flex items-center justify-between mb-[10px]">
|
<div className="flex items-center justify-between mb-[10px]">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-mono font-extrabold text-color-accent">
|
<span className="text-body-2 font-mono font-extrabold text-color-accent">
|
||||||
CH₃OH
|
CH₃OH
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
<span className="text-label-1 font-bold">메탄올</span>
|
<span className="text-label-1 font-bold">메탄올</span>
|
||||||
@ -990,7 +990,9 @@ ${styles}
|
|||||||
<div className="rounded-[10px] p-[14px] border border-stroke bg-bg-card">
|
<div className="rounded-[10px] p-[14px] border border-stroke bg-bg-card">
|
||||||
<div className="flex items-center justify-between mb-[10px]">
|
<div className="flex items-center justify-between mb-[10px]">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-mono font-extrabold text-color-accent">H₂</span>{' '}
|
<span className="text-body-2 font-mono font-extrabold text-color-accent">
|
||||||
|
H₂
|
||||||
|
</span>{' '}
|
||||||
<span className="text-label-1 font-bold">수소</span>
|
<span className="text-label-1 font-bold">수소</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
@ -1048,7 +1050,7 @@ ${styles}
|
|||||||
<div className="rounded-[10px] p-[14px] border border-stroke bg-bg-card">
|
<div className="rounded-[10px] p-[14px] border border-stroke bg-bg-card">
|
||||||
<div className="flex items-center justify-between mb-[10px]">
|
<div className="flex items-center justify-between mb-[10px]">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-mono font-extrabold text-color-accent">
|
<span className="text-body-2 font-mono font-extrabold text-color-accent">
|
||||||
CH₄
|
CH₄
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
<span className="text-label-1 font-bold">LNG (메탄)</span>
|
<span className="text-label-1 font-bold">LNG (메탄)</span>
|
||||||
@ -1106,7 +1108,7 @@ ${styles}
|
|||||||
<div className="rounded-[10px] p-[14px] border border-stroke bg-bg-card">
|
<div className="rounded-[10px] p-[14px] border border-stroke bg-bg-card">
|
||||||
<div className="flex items-center justify-between mb-[10px]">
|
<div className="flex items-center justify-between mb-[10px]">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-mono font-extrabold text-color-accent">
|
<span className="text-body-2 font-mono font-extrabold text-color-accent">
|
||||||
C₆H₅OH
|
C₆H₅OH
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
<span className="text-label-1 font-bold">페놀</span>
|
<span className="text-label-1 font-bold">페놀</span>
|
||||||
@ -1164,7 +1166,7 @@ ${styles}
|
|||||||
<div className="rounded-[10px] p-[14px] border border-stroke bg-bg-card">
|
<div className="rounded-[10px] p-[14px] border border-stroke bg-bg-card">
|
||||||
<div className="flex items-center justify-between mb-[10px]">
|
<div className="flex items-center justify-between mb-[10px]">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-mono font-extrabold text-color-accent">
|
<span className="text-body-2 font-mono font-extrabold text-color-accent">
|
||||||
C₇H₈
|
C₇H₈
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
<span className="text-label-1 font-bold">톨루엔</span>
|
<span className="text-label-1 font-bold">톨루엔</span>
|
||||||
|
|||||||
@ -262,6 +262,8 @@ function DispersionTimeSlider({
|
|||||||
export function HNSView() {
|
export function HNSView() {
|
||||||
const { activeSubTab, setActiveSubTab } = useSubMenu('hns');
|
const { activeSubTab, setActiveSubTab } = useSubMenu('hns');
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
|
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||||
|
const [rightCollapsed, setRightCollapsed] = useState(false);
|
||||||
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null);
|
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null);
|
||||||
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
|
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
|
||||||
const [isRunningPrediction, setIsRunningPrediction] = useState(false);
|
const [isRunningPrediction, setIsRunningPrediction] = useState(false);
|
||||||
@ -890,22 +892,66 @@ export function HNSView() {
|
|||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Left Panel - 분석 목록일 때는 숨김 */}
|
{/* Left Panel - 분석 목록일 때는 숨김 */}
|
||||||
{activeSubTab === 'analysis' && (
|
{activeSubTab === 'analysis' && (
|
||||||
<HNSLeftPanel
|
<div className="shrink-0 overflow-hidden" style={{ width: leftCollapsed ? 0 : 320 }}>
|
||||||
activeSubTab={activeSubTab}
|
<HNSLeftPanel
|
||||||
onSubTabChange={setActiveSubTab}
|
activeSubTab={activeSubTab}
|
||||||
incidentCoord={incidentCoord}
|
onSubTabChange={setActiveSubTab}
|
||||||
onCoordChange={setIncidentCoord}
|
incidentCoord={incidentCoord}
|
||||||
onMapSelectClick={() => setIsSelectingLocation(true)}
|
onCoordChange={setIncidentCoord}
|
||||||
onRunPrediction={handleRunPrediction}
|
onMapSelectClick={() => setIsSelectingLocation(true)}
|
||||||
isRunningPrediction={isRunningPrediction}
|
onRunPrediction={handleRunPrediction}
|
||||||
onParamsChange={handleParamsChange}
|
isRunningPrediction={isRunningPrediction}
|
||||||
onReset={handleReset}
|
onParamsChange={handleParamsChange}
|
||||||
loadedParams={loadedParams}
|
onReset={handleReset}
|
||||||
/>
|
loadedParams={loadedParams}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Center - Map/Content Area */}
|
{/* Center - Map/Content Area */}
|
||||||
<div className="flex-1 relative overflow-hidden">
|
<div className="flex-1 relative overflow-hidden">
|
||||||
|
{/* Left panel toggle button */}
|
||||||
|
{activeSubTab === 'analysis' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setLeftCollapsed((v) => !v)}
|
||||||
|
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||||
|
style={{
|
||||||
|
left: 0,
|
||||||
|
width: 18,
|
||||||
|
height: 40,
|
||||||
|
background: 'var(--bg-elevated)',
|
||||||
|
border: '1px solid var(--stroke-default)',
|
||||||
|
borderLeft: 'none',
|
||||||
|
borderRadius: '0 6px 6px 0',
|
||||||
|
color: 'var(--fg-sub)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{leftCollapsed ? '▶' : '◀'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Right panel toggle button */}
|
||||||
|
{activeSubTab === 'analysis' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setRightCollapsed((v) => !v)}
|
||||||
|
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||||
|
style={{
|
||||||
|
right: 0,
|
||||||
|
width: 18,
|
||||||
|
height: 40,
|
||||||
|
background: 'var(--bg-elevated)',
|
||||||
|
border: '1px solid var(--stroke-default)',
|
||||||
|
borderRight: 'none',
|
||||||
|
borderRadius: '6px 0 0 6px',
|
||||||
|
color: 'var(--fg-sub)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rightCollapsed ? '◀' : '▶'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeSubTab === 'list' ? (
|
{activeSubTab === 'list' ? (
|
||||||
<HNSAnalysisListTable
|
<HNSAnalysisListTable
|
||||||
onTabChange={(v) =>
|
onTabChange={(v) =>
|
||||||
@ -942,14 +988,16 @@ export function HNSView() {
|
|||||||
|
|
||||||
{/* Right Panel - 분석 목록일 때는 숨김 */}
|
{/* Right Panel - 분석 목록일 때는 숨김 */}
|
||||||
{activeSubTab === 'analysis' && (
|
{activeSubTab === 'analysis' && (
|
||||||
<HNSRightPanel
|
<div className="shrink-0 overflow-hidden" style={{ width: rightCollapsed ? 0 : 300 }}>
|
||||||
dispersionResult={dispersionResult}
|
<HNSRightPanel
|
||||||
computedResult={computedResult}
|
dispersionResult={dispersionResult}
|
||||||
weatherData={inputParams?.weather ?? null}
|
computedResult={computedResult}
|
||||||
onOpenRecalc={() => setRecalcModalOpen(true)}
|
weatherData={inputParams?.weather ?? null}
|
||||||
onOpenReport={handleOpenReport}
|
onOpenRecalc={() => setRecalcModalOpen(true)}
|
||||||
onSave={handleSave}
|
onOpenReport={handleOpenReport}
|
||||||
/>
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* HNS 재계산 모달 */}
|
{/* HNS 재계산 모달 */}
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { DndContext } from '@dnd-kit/core';
|
||||||
|
import { useDraggable } from '@dnd-kit/core';
|
||||||
|
import type { DragEndEvent } from '@dnd-kit/core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 해양환경관리법 제22조 기반 선박 발생 오염물 배출 규정
|
* 해양환경관리법 제22조 기반 선박 발생 오염물 배출 규정
|
||||||
@ -100,33 +103,30 @@ const RULES: DischargeRule[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const ZONE_LABELS = ['~3해리', '3~12해리', '12~25해리', '25~50해리', '50해리+'];
|
const ZONE_LABELS = ['~3해리', '3~12해리', '12~25해리', '25~50해리', '50해리+'];
|
||||||
const ZONE_COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#64748b'];
|
const ZONE_COLORS = [
|
||||||
|
'var(--color-danger)',
|
||||||
|
'var(--color-warning)',
|
||||||
|
'var(--color-caution)',
|
||||||
|
'var(--color-success)',
|
||||||
|
'var(--fg-disabled)',
|
||||||
|
];
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: Status }) {
|
function StatusBadge({ status }: { status: Status }) {
|
||||||
if (status === 'forbidden')
|
if (status === 'forbidden')
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="text-caption font-bold px-1.5 py-0.5 rounded"
|
className="text-caption px-1.5 py-0.5 rounded"
|
||||||
style={{ background: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)' }}
|
style={{
|
||||||
|
background: 'color-mix(in srgb, var(--color-danger) 15%, transparent)',
|
||||||
|
color: 'var(--color-danger)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
배출불가
|
배출불가
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
if (status === 'allowed')
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="text-caption font-bold px-1.5 py-0.5 rounded"
|
|
||||||
style={{ background: 'rgba(34,197,94,0.15)', color: 'var(--color-success)' }}
|
|
||||||
>
|
|
||||||
배출가능
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span className="text-caption px-1.5 py-0.5 rounded text-fg-sub">
|
||||||
className="text-caption font-bold px-1.5 py-0.5 rounded"
|
{status === 'allowed' ? '배출가능' : '조건부'}
|
||||||
style={{ background: 'rgba(234,179,8,0.15)', color: 'var(--color-caution)' }}
|
|
||||||
>
|
|
||||||
조건부
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -139,15 +139,36 @@ interface DischargeZonePanelProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DischargeZonePanel({
|
export function DischargeZonePanel(props: DischargeZonePanelProps) {
|
||||||
|
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
function handleDragEnd(event: DragEndEvent) {
|
||||||
|
setOffset((prev) => ({ x: prev.x + event.delta.x, y: prev.y + event.delta.y }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext onDragEnd={handleDragEnd}>
|
||||||
|
<DraggablePanel {...props} offset={offset} />
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraggablePanel({
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
distanceNm,
|
distanceNm,
|
||||||
zoneIndex,
|
zoneIndex,
|
||||||
onClose,
|
onClose,
|
||||||
}: DischargeZonePanelProps) {
|
offset,
|
||||||
|
}: DischargeZonePanelProps & { offset: { x: number; y: number } }) {
|
||||||
const zoneIdx = zoneIndex;
|
const zoneIdx = zoneIndex;
|
||||||
const [expandedCat, setExpandedCat] = useState<string | null>(null);
|
const [expandedCat, setExpandedCat] = useState<string | null>(null);
|
||||||
|
const { attributes, listeners, setNodeRef, transform } = useDraggable({
|
||||||
|
id: 'discharge-panel',
|
||||||
|
});
|
||||||
|
|
||||||
|
const tx = offset.x + (transform?.x ?? 0);
|
||||||
|
const ty = offset.y + (transform?.y ?? 0);
|
||||||
|
|
||||||
const categories = [...new Set(RULES.map((r) => r.category))];
|
const categories = [...new Set(RULES.map((r) => r.category))];
|
||||||
|
|
||||||
@ -161,22 +182,33 @@ export function DischargeZonePanel({
|
|||||||
border: '1px solid var(--stroke-default)',
|
border: '1px solid var(--stroke-default)',
|
||||||
boxShadow: '0 16px 48px rgba(0,0,0,0.5)',
|
boxShadow: '0 16px 48px rgba(0,0,0,0.5)',
|
||||||
backdropFilter: 'blur(12px)',
|
backdropFilter: 'blur(12px)',
|
||||||
|
transform: `translate(${tx}px, ${ty}px)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header — drag handle */}
|
||||||
<div
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
className="shrink-0 flex items-center justify-between"
|
className="shrink-0 flex items-center justify-between"
|
||||||
style={{
|
style={{
|
||||||
padding: '10px 14px',
|
padding: '10px 14px',
|
||||||
borderBottom: '1px solid var(--stroke-default)',
|
borderBottom: '1px solid var(--stroke-default)',
|
||||||
background: 'var(--bg-elevated)',
|
background: 'var(--bg-elevated)',
|
||||||
|
cursor: 'grab',
|
||||||
|
userSelect: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-label-2 font-bold text-fg font-korean">🚢 오염물 배출 규정</div>
|
<div className="text-label-2 text-fg font-korean">🚢 오염물 배출 규정</div>
|
||||||
<div className="text-caption text-fg-sub font-korean">해양환경관리법 제22조</div>
|
<div className="text-caption text-fg-sub font-korean">해양환경관리법 제22조</div>
|
||||||
</div>
|
</div>
|
||||||
<span onClick={onClose} className="text-title-3 cursor-pointer text-fg-sub hover:text-fg">
|
<span
|
||||||
|
onClick={onClose}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
className="text-title-3 cursor-pointer text-fg-sub hover:text-fg"
|
||||||
|
style={{ pointerEvents: 'all' }}
|
||||||
|
>
|
||||||
✕
|
✕
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -194,10 +226,7 @@ export function DischargeZonePanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-caption text-fg-sub font-korean">영해기선 거리</span>
|
<span className="text-caption text-fg-sub font-korean">영해기선 거리</span>
|
||||||
<span
|
<span className="text-label-2 font-mono" style={{ color: ZONE_COLORS[zoneIdx] }}>
|
||||||
className="text-label-2 font-bold font-mono"
|
|
||||||
style={{ color: ZONE_COLORS[zoneIdx] }}
|
|
||||||
>
|
|
||||||
{distanceNm.toFixed(1)} NM
|
{distanceNm.toFixed(1)} NM
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -206,12 +235,10 @@ export function DischargeZonePanel({
|
|||||||
{ZONE_LABELS.map((label, i) => (
|
{ZONE_LABELS.map((label, i) => (
|
||||||
<div
|
<div
|
||||||
key={label}
|
key={label}
|
||||||
className="flex-1 text-center rounded-sm"
|
className="flex-1 text-center rounded-sm text-[10px]"
|
||||||
style={{
|
style={{
|
||||||
padding: '3px 0',
|
padding: '2px 0',
|
||||||
fontSize: 8,
|
color: i === zoneIdx ? '#000' : 'var(--fg-sub)',
|
||||||
fontWeight: i === zoneIdx ? 700 : 400,
|
|
||||||
color: i === zoneIdx ? 'var(--fg-default)' : 'var(--fg-sub)',
|
|
||||||
background: i === zoneIdx ? ZONE_COLORS[i] : 'var(--hover-overlay)',
|
background: i === zoneIdx ? ZONE_COLORS[i] : 'var(--hover-overlay)',
|
||||||
border: i === zoneIdx ? 'none' : '1px solid var(--stroke-light)',
|
border: i === zoneIdx ? 'none' : '1px solid var(--stroke-light)',
|
||||||
}}
|
}}
|
||||||
@ -231,8 +258,7 @@ export function DischargeZonePanel({
|
|||||||
const catRules = RULES.filter((r) => r.category === cat);
|
const catRules = RULES.filter((r) => r.category === cat);
|
||||||
const isExpanded = expandedCat === cat;
|
const isExpanded = expandedCat === cat;
|
||||||
const allForbidden = catRules.every((r) => r.zones[zoneIdx] === 'forbidden');
|
const allForbidden = catRules.every((r) => r.zones[zoneIdx] === 'forbidden');
|
||||||
const allAllowed = catRules.every((r) => r.zones[zoneIdx] === 'allowed');
|
const summaryColor = allForbidden ? 'var(--color-danger)' : 'var(--fg-sub)';
|
||||||
const summaryColor = allForbidden ? '#ef4444' : allAllowed ? '#22c55e' : '#eab308';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={cat} style={{ borderBottom: '1px solid var(--stroke-light)' }}>
|
<div key={cat} style={{ borderBottom: '1px solid var(--stroke-light)' }}>
|
||||||
@ -245,11 +271,11 @@ export function DischargeZonePanel({
|
|||||||
<div
|
<div
|
||||||
style={{ width: 6, height: 6, borderRadius: '50%', background: summaryColor }}
|
style={{ width: 6, height: 6, borderRadius: '50%', background: summaryColor }}
|
||||||
/>
|
/>
|
||||||
<span className="text-caption font-bold text-fg font-korean">{cat}</span>
|
<span className="text-caption text-fg font-korean">{cat}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-caption font-semibold" style={{ color: summaryColor }}>
|
<span className="text-caption" style={{ color: summaryColor }}>
|
||||||
{allForbidden ? '전체 불가' : allAllowed ? '전체 가능' : '항목별 상이'}
|
{allForbidden ? '전체 불가' : '허용'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-caption text-fg-sub">{isExpanded ? '▾' : '▸'}</span>
|
<span className="text-caption text-fg-sub">{isExpanded ? '▾' : '▸'}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -268,7 +294,7 @@ export function DischargeZonePanel({
|
|||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-caption text-fg font-korean">{rule.item}</span>
|
<span className="text-caption text-fg-sub font-korean">{rule.item}</span>
|
||||||
<StatusBadge status={rule.zones[zoneIdx]} />
|
<StatusBadge status={rule.zones[zoneIdx]} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -23,10 +23,10 @@ export function IncidentTable() {
|
|||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-stroke">
|
<div className="flex items-center justify-between px-5 py-4 border-b border-stroke">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-fg">유출유 확산 예측 목록</h1>
|
<h1 className="text-xl font-bold text-fg">유출유 확산 예측 목록</h1>
|
||||||
<p className="text-sm text-fg-disabled mt-1">총 {filteredIncidents.length}건</p>
|
<p className="text-body-2 text-fg-disabled mt-1">총 {filteredIncidents.length}건</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button className="px-4 py-2 text-sm font-semibold border border-stroke rounded-md bg-bg-card text-fg-sub hover:bg-bg-surface-hover hover:text-fg transition-all">
|
<button className="px-4 py-2 text-body-2 font-semibold border border-stroke rounded-md bg-bg-card text-fg-sub hover:bg-bg-surface-hover hover:text-fg transition-all">
|
||||||
시간별 검색
|
시간별 검색
|
||||||
</button>
|
</button>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@ -35,10 +35,10 @@ export function IncidentTable() {
|
|||||||
placeholder="검색..."
|
placeholder="검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="w-64 px-4 py-2 text-sm bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none"
|
className="w-64 px-4 py-2 text-body-2 bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button className="px-4 py-2 text-sm font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_16px_rgba(6,182,212,0.3)] transition-all">
|
<button className="px-4 py-2 text-body-2 font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_16px_rgba(6,182,212,0.3)] transition-all">
|
||||||
+ 새 분석
|
+ 새 분석
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -49,31 +49,31 @@ export function IncidentTable() {
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="sticky top-0 bg-bg-surface border-b border-stroke z-10">
|
<thead className="sticky top-0 bg-bg-surface border-b border-stroke z-10">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">
|
<th className="px-4 py-3 text-left text-caption font-bold text-fg-disabled uppercase tracking-wider">
|
||||||
번호
|
번호
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">
|
<th className="px-4 py-3 text-left text-caption font-bold text-fg-disabled uppercase tracking-wider">
|
||||||
사고명
|
사고명
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">
|
<th className="px-4 py-3 text-left text-caption font-bold text-fg-disabled uppercase tracking-wider">
|
||||||
사고시각
|
사고시각
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">
|
<th className="px-4 py-3 text-left text-caption font-bold text-fg-disabled uppercase tracking-wider">
|
||||||
선박유형
|
선박유형
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">
|
<th className="px-4 py-3 text-left text-caption font-bold text-fg-disabled uppercase tracking-wider">
|
||||||
유종
|
유종
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-right text-xs font-bold text-fg-disabled uppercase tracking-wider">
|
<th className="px-4 py-3 text-right text-caption font-bold text-fg-disabled uppercase tracking-wider">
|
||||||
유출량
|
유출량
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">
|
<th className="px-4 py-3 text-left text-caption font-bold text-fg-disabled uppercase tracking-wider">
|
||||||
사고유형
|
사고유형
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">
|
<th className="px-4 py-3 text-left text-caption font-bold text-fg-disabled uppercase tracking-wider">
|
||||||
상태
|
상태
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-bold text-fg-disabled uppercase tracking-wider">
|
<th className="px-4 py-3 text-left text-caption font-bold text-fg-disabled uppercase tracking-wider">
|
||||||
분석자
|
분석자
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -84,28 +84,28 @@ export function IncidentTable() {
|
|||||||
key={incident.acdntSn}
|
key={incident.acdntSn}
|
||||||
className="hover:bg-bg-elevated transition-colors cursor-pointer group"
|
className="hover:bg-bg-elevated transition-colors cursor-pointer group"
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 text-sm text-fg-sub font-mono">{incident.acdntSn}</td>
|
<td className="px-4 py-3 text-body-2 text-fg-sub font-mono">{incident.acdntSn}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-2 h-2 rounded-full bg-color-danger animate-pulse" />
|
<span className="w-2 h-2 rounded-full bg-color-danger animate-pulse" />
|
||||||
<span className="text-sm font-semibold text-fg group-hover:text-color-accent transition-colors">
|
<span className="text-body-2 font-semibold text-fg group-hover:text-color-accent transition-colors">
|
||||||
{incident.acdntNm}
|
{incident.acdntNm}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-fg-sub font-mono">{incident.occrnDtm}</td>
|
<td className="px-4 py-3 text-body-2 text-fg-sub font-mono">{incident.occrnDtm}</td>
|
||||||
<td className="px-4 py-3 text-sm text-fg-sub">{incident.vesselTp ?? '—'}</td>
|
<td className="px-4 py-3 text-body-2 text-fg-sub">{incident.vesselTp ?? '—'}</td>
|
||||||
<td className="px-4 py-3 text-sm text-fg-sub">{incident.oilTpCd ?? '—'}</td>
|
<td className="px-4 py-3 text-body-2 text-fg-sub">{incident.oilTpCd ?? '—'}</td>
|
||||||
<td className="px-4 py-3 text-sm text-fg font-mono text-right font-semibold">
|
<td className="px-4 py-3 text-body-2 text-fg font-mono text-right font-semibold">
|
||||||
{incident.spilQty != null ? incident.spilQty.toFixed(2) : '—'}
|
{incident.spilQty != null ? incident.spilQty.toFixed(2) : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-fg-sub">{incident.acdntTpCd}</td>
|
<td className="px-4 py-3 text-body-2 text-fg-sub">{incident.acdntTpCd}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="px-2 py-1 text-xs font-semibold rounded-md bg-[rgba(168,85,247,0.15)] text-purple-400">
|
<span className="px-2 py-1 text-caption font-semibold rounded-md bg-[rgba(168,85,247,0.15)] text-purple-400">
|
||||||
{incident.phaseCd}
|
{incident.phaseCd}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-fg-sub">{incident.analystNm ?? '—'}</td>
|
<td className="px-4 py-3 text-body-2 text-fg-sub">{incident.analystNm ?? '—'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@ -215,7 +215,7 @@ export function IncidentsLeftPanel({
|
|||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="px-4 py-3 border-b border-stroke shrink-0">
|
<div className="px-4 py-3 border-b border-stroke shrink-0">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<span className="absolute left-[10px] top-1/2 -translate-y-1/2 text-xs">🔍</span>
|
<span className="absolute left-[10px] top-1/2 -translate-y-1/2 text-caption">🔍</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="사고명, 선박명 검색..."
|
placeholder="사고명, 선박명 검색..."
|
||||||
@ -224,7 +224,7 @@ export function IncidentsLeftPanel({
|
|||||||
setSearchTerm(e.target.value);
|
setSearchTerm(e.target.value);
|
||||||
resetPage();
|
resetPage();
|
||||||
}}
|
}}
|
||||||
className="w-full py-2 pr-3 pl-8 bg-bg-base border border-stroke text-xs outline-none"
|
className="w-full py-2 pr-3 pl-8 bg-bg-base border border-stroke text-caption outline-none"
|
||||||
style={{ borderRadius: 'var(--radius-sm)' }}
|
style={{ borderRadius: 'var(--radius-sm)' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -257,11 +257,11 @@ export function IncidentsLeftPanel({
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={resetPage}
|
onClick={resetPage}
|
||||||
className="text-label-2 font-semibold cursor-pointer whitespace-nowrap text-white border-none"
|
className="rounded-sm text-label-2 font-semibold cursor-pointer whitespace-nowrap text-color-accent"
|
||||||
style={{
|
style={{
|
||||||
padding: '5px 12px',
|
padding: '5px 12px',
|
||||||
background: 'linear-gradient(135deg,var(--color-accent),var(--color-info))',
|
border: '1px solid rgba(6,182,212,.3)',
|
||||||
borderRadius: 'var(--radius-sm)',
|
background: 'rgba(6,182,212,.08)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
조회
|
조회
|
||||||
@ -356,7 +356,7 @@ export function IncidentsLeftPanel({
|
|||||||
setSelectedStatus(s.id);
|
setSelectedStatus(s.id);
|
||||||
resetPage();
|
resetPage();
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-1 text-caption font-semibold cursor-pointer"
|
className="flex items-center gap-1 text-caption cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
padding: '4px 10px',
|
padding: '4px 10px',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
@ -442,7 +442,7 @@ export function IncidentsLeftPanel({
|
|||||||
>
|
>
|
||||||
{/* Row 1: name + status */}
|
{/* Row 1: name + status */}
|
||||||
<div className="flex items-center justify-between mb-[5px]">
|
<div className="flex items-center justify-between mb-[5px]">
|
||||||
<div className="flex items-center gap-1.5 text-xs font-bold">
|
<div className="flex items-center gap-1.5 text-caption">
|
||||||
<span
|
<span
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
style={{
|
style={{
|
||||||
@ -456,7 +456,7 @@ export function IncidentsLeftPanel({
|
|||||||
{inc.name}
|
{inc.name}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className="shrink-0 text-caption font-semibold"
|
className="shrink-0 text-caption"
|
||||||
style={{
|
style={{
|
||||||
padding: '2px 10px',
|
padding: '2px 10px',
|
||||||
borderRadius: '10px',
|
borderRadius: '10px',
|
||||||
@ -470,9 +470,9 @@ export function IncidentsLeftPanel({
|
|||||||
{/* Row 2: meta */}
|
{/* Row 2: meta */}
|
||||||
<div className="flex items-center gap-2 text-caption text-fg-disabled mb-[5px]">
|
<div className="flex items-center gap-2 text-caption text-fg-disabled mb-[5px]">
|
||||||
<span>
|
<span>
|
||||||
📅 {inc.date} {inc.time}
|
{inc.date} {inc.time}
|
||||||
</span>
|
</span>
|
||||||
<span>🏛 {inc.office}</span>
|
<span> {inc.office}</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Row 3: tags + buttons */}
|
{/* Row 3: tags + buttons */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@ -492,12 +492,12 @@ export function IncidentsLeftPanel({
|
|||||||
)}
|
)}
|
||||||
{inc.oilType && (
|
{inc.oilType && (
|
||||||
<span
|
<span
|
||||||
className="text-caption font-medium text-color-warning"
|
className="text-caption font-medium text-fg-sub"
|
||||||
style={{
|
style={{
|
||||||
padding: '2px 8px',
|
padding: '2px 8px',
|
||||||
borderRadius: '3px',
|
borderRadius: '3px',
|
||||||
background: 'rgba(249,115,22,0.08)',
|
background: 'rgba(100,116,139,0.08)',
|
||||||
border: '1px solid rgba(249,115,22,0.2)',
|
border: '1px solid rgba(100,116,139,0.2)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{inc.oilType}
|
{inc.oilType}
|
||||||
@ -505,12 +505,12 @@ export function IncidentsLeftPanel({
|
|||||||
)}
|
)}
|
||||||
{inc.prediction && (
|
{inc.prediction && (
|
||||||
<span
|
<span
|
||||||
className="text-caption font-medium text-color-success"
|
className="text-caption font-medium text-fg-sub"
|
||||||
style={{
|
style={{
|
||||||
padding: '2px 8px',
|
padding: '2px 8px',
|
||||||
borderRadius: '3px',
|
borderRadius: '3px',
|
||||||
background: 'rgba(34,197,94,0.08)',
|
background: 'rgba(100,116,139,0.08)',
|
||||||
border: '1px solid rgba(34,197,94,0.2)',
|
border: '1px solid rgba(100,116,139,0.2)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{inc.prediction}
|
{inc.prediction}
|
||||||
@ -535,7 +535,7 @@ export function IncidentsLeftPanel({
|
|||||||
transition: '0.15s',
|
transition: '0.15s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
🌤
|
기상정보
|
||||||
</button>
|
</button>
|
||||||
{(inc.mediaCount ?? 0) > 0 && (
|
{(inc.mediaCount ?? 0) > 0 && (
|
||||||
<button
|
<button
|
||||||
@ -555,7 +555,7 @@ export function IncidentsLeftPanel({
|
|||||||
transition: '0.15s',
|
transition: '0.15s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
📹 <span className="text-caption">{inc.mediaCount}</span>
|
<span className="text-caption">{inc.mediaCount}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{inc.hasImgAnalysis && (
|
{inc.hasImgAnalysis && (
|
||||||
@ -720,13 +720,13 @@ const WeatherPopup = forwardRef<
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-sm">🌤</span>
|
<span className="text-body-2">🌤</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-label-2 font-bold">{data?.locNm || '기상정보 없음'}</div>
|
<div className="text-label-2 font-bold">{data?.locNm || '기상정보 없음'}</div>
|
||||||
<div className="text-fg-disabled font-mono text-caption">{data?.obsDtm || '-'}</div>
|
<div className="text-fg-disabled font-mono text-caption">{data?.obsDtm || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span onClick={onClose} className="cursor-pointer text-fg-disabled text-sm p-0.5">
|
<span onClick={onClose} className="cursor-pointer text-fg-disabled text-body-2 p-0.5">
|
||||||
✕
|
✕
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -761,7 +761,7 @@ const WeatherPopup = forwardRef<
|
|||||||
border: '1px solid rgba(59,130,246,0.1)',
|
border: '1px solid rgba(59,130,246,0.1)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-xs">⬆</span>
|
<span className="text-caption">⬆</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-fg-disabled text-caption">고조 (만조)</div>
|
<div className="text-fg-disabled text-caption">고조 (만조)</div>
|
||||||
<div className="font-bold font-mono text-caption text-color-info">
|
<div className="font-bold font-mono text-caption text-color-info">
|
||||||
@ -773,7 +773,7 @@ const WeatherPopup = forwardRef<
|
|||||||
className="flex-1 flex items-center gap-1.5 px-2 py-1.5 rounded-md"
|
className="flex-1 flex items-center gap-1.5 px-2 py-1.5 rounded-md"
|
||||||
style={{ background: 'rgba(6,182,212,0.06)', border: '1px solid rgba(6,182,212,0.1)' }}
|
style={{ background: 'rgba(6,182,212,0.06)', border: '1px solid rgba(6,182,212,0.1)' }}
|
||||||
>
|
>
|
||||||
<span className="text-xs">⬇</span>
|
<span className="text-caption">⬇</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-fg-disabled text-caption">저조 (간조)</div>
|
<div className="text-fg-disabled text-caption">저조 (간조)</div>
|
||||||
<div className="text-color-accent font-bold font-mono text-caption">
|
<div className="text-color-accent font-bold font-mono text-caption">
|
||||||
@ -791,7 +791,7 @@ const WeatherPopup = forwardRef<
|
|||||||
{forecast.map((f, i) => (
|
{forecast.map((f, i) => (
|
||||||
<div key={i} className="text-center">
|
<div key={i} className="text-center">
|
||||||
<div>{f.hour}</div>
|
<div>{f.hour}</div>
|
||||||
<div className="text-xs my-0.5">{f.icon}</div>
|
<div className="text-caption my-0.5">{f.icon}</div>
|
||||||
<div className="font-semibold">{f.temp}</div>
|
<div className="font-semibold">{f.temp}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -50,31 +50,8 @@ interface AnalysisItem {
|
|||||||
checked: boolean;
|
checked: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 카테고리별 고유 색상 (목록 순서 인덱스 기반 — 중복 없음) ── */
|
/* ── 카테고리 → 이모지 매핑 (prediction LeftPanel의 CATEGORY_ICON_MAP 기반, 미사용 보존) ── */
|
||||||
const CATEGORY_PALETTE: [number, number, number][] = [
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
[239, 68, 68], // red
|
|
||||||
[249, 115, 22], // orange
|
|
||||||
[234, 179, 8], // yellow
|
|
||||||
[132, 204, 22], // lime
|
|
||||||
[20, 184, 166], // teal
|
|
||||||
[6, 182, 212], // cyan
|
|
||||||
[59, 130, 246], // blue
|
|
||||||
[99, 102, 241], // indigo
|
|
||||||
[168, 85, 247], // purple
|
|
||||||
[236, 72, 153], // pink
|
|
||||||
[244, 63, 94], // rose
|
|
||||||
[16, 185, 129], // emerald
|
|
||||||
[14, 165, 233], // sky
|
|
||||||
[139, 92, 246], // violet
|
|
||||||
[217, 119, 6], // amber
|
|
||||||
[45, 212, 191], // turquoise
|
|
||||||
];
|
|
||||||
|
|
||||||
function getCategoryColor(index: number): [number, number, number] {
|
|
||||||
return CATEGORY_PALETTE[index % CATEGORY_PALETTE.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 카테고리 → 이모지 매핑 (prediction LeftPanel의 CATEGORY_ICON_MAP 기반) ── */
|
|
||||||
const CATEGORY_ICON: Record<string, string> = {
|
const CATEGORY_ICON: Record<string, string> = {
|
||||||
어장정보: '🐟',
|
어장정보: '🐟',
|
||||||
양식장: '🦪',
|
양식장: '🦪',
|
||||||
@ -140,8 +117,20 @@ function getActiveModels(p: PredictionAnalysis): string {
|
|||||||
|
|
||||||
/* ── HNS/구난 섹션 (미개발, 고정 구조만 유지) ────── */
|
/* ── HNS/구난 섹션 (미개발, 고정 구조만 유지) ────── */
|
||||||
const STATIC_SECTIONS = [
|
const STATIC_SECTIONS = [
|
||||||
{ key: 'hns', icon: '🧪', title: 'HNS 대기확산', color: '#a855f7', colorRgb: '168,85,247' },
|
{
|
||||||
{ key: 'rsc', icon: '🚨', title: '긴급구난', color: '#06b6d4', colorRgb: '6,182,212' },
|
key: 'hns',
|
||||||
|
icon: '🧪',
|
||||||
|
title: 'HNS 대기확산',
|
||||||
|
color: 'var(--color-accent)',
|
||||||
|
colorRgb: '6,182,212',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rsc',
|
||||||
|
icon: '🚨',
|
||||||
|
title: '긴급구난',
|
||||||
|
color: 'var(--color-accent)',
|
||||||
|
colorRgb: '6,182,212',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/* ── Component ───────────────────────────────────── */
|
/* ── Component ───────────────────────────────────── */
|
||||||
@ -292,7 +281,7 @@ export function IncidentsRightPanel({
|
|||||||
key: 'oil',
|
key: 'oil',
|
||||||
icon: '🛢',
|
icon: '🛢',
|
||||||
title: '유출유 확산예측',
|
title: '유출유 확산예측',
|
||||||
color: '#f97316',
|
color: 'var(--color-accent)',
|
||||||
colorRgb: '249,115,22',
|
colorRgb: '249,115,22',
|
||||||
totalLabel: `전체 ${predItems.length}건`,
|
totalLabel: `전체 ${predItems.length}건`,
|
||||||
items: predItems.map((p) => {
|
items: predItems.map((p) => {
|
||||||
@ -310,7 +299,7 @@ export function IncidentsRightPanel({
|
|||||||
|
|
||||||
if (!incident) {
|
if (!incident) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center bg-bg-surface border-l border-stroke w-[280px] min-w-[280px]">
|
<div className="flex flex-col items-center justify-center bg-bg-surface border-l border-stroke w-full h-full">
|
||||||
<div className="text-center text-fg-disabled text-label-2">
|
<div className="text-center text-fg-disabled text-label-2">
|
||||||
<div className="text-[32px] mb-2 opacity-30">📊</div>
|
<div className="text-[32px] mb-2 opacity-30">📊</div>
|
||||||
좌측에서 사고를 선택하면
|
좌측에서 사고를 선택하면
|
||||||
@ -325,9 +314,9 @@ export function IncidentsRightPanel({
|
|||||||
<div className="flex flex-col bg-bg-surface border-l border-stroke overflow-hidden h-full w-[280px] min-w-[280px]">
|
<div className="flex flex-col bg-bg-surface border-l border-stroke overflow-hidden h-full w-[280px] min-w-[280px]">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-[14px] py-2.5 border-b border-stroke shrink-0">
|
<div className="px-[14px] py-2.5 border-b border-stroke shrink-0">
|
||||||
<div className="text-xs font-bold mb-0.5">🔬 통합분석 조회</div>
|
<div className="text-caption font-bold mb-0.5">🔬 통합분석 조회</div>
|
||||||
<div className="text-caption text-fg-disabled">
|
<div className="text-caption text-fg-disabled">
|
||||||
선택: <b className="text-color-accent">{incident.name}</b>
|
선택: <span className="text-fg-disabled">{incident.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -344,22 +333,19 @@ export function IncidentsRightPanel({
|
|||||||
<div className="bg-bg-elevated border border-stroke rounded-md p-2.5">
|
<div className="bg-bg-elevated border border-stroke rounded-md p-2.5">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-sm">{sec.icon}</span>
|
{/* <span className="text-body-2">{sec.icon}</span> */}
|
||||||
<span className="text-xs font-bold" style={{ color: sec.color }}>
|
<span className="text-caption">{sec.title}</span>
|
||||||
{sec.title}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="text-caption font-semibold cursor-pointer"
|
className="text-caption font-semibold cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
padding: '3px 10px',
|
padding: '3px 10px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
background: `rgba(${sec.colorRgb},0.1)`,
|
border: '1px solid var(--stroke-default)',
|
||||||
border: `1px solid rgba(${sec.colorRgb},0.25)`,
|
|
||||||
color: sec.color,
|
color: sec.color,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
📋 조회
|
조회
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@ -374,8 +360,7 @@ export function IncidentsRightPanel({
|
|||||||
className="flex items-center gap-1.5"
|
className="flex items-center gap-1.5"
|
||||||
style={{
|
style={{
|
||||||
padding: '5px 8px',
|
padding: '5px 8px',
|
||||||
background: `rgba(${sec.colorRgb},0.06)`,
|
border: '1px solid var(--stroke-default)',
|
||||||
border: `1px solid rgba(${sec.colorRgb},0.15)`,
|
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -415,22 +400,19 @@ export function IncidentsRightPanel({
|
|||||||
<div key={sec.key} className="bg-bg-elevated border border-stroke rounded-md p-2.5">
|
<div key={sec.key} className="bg-bg-elevated border border-stroke rounded-md p-2.5">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-sm">{sec.icon}</span>
|
{/* <span className="text-body-2">{sec.icon}</span> */}
|
||||||
<span className="text-xs font-bold" style={{ color: sec.color }}>
|
<span className="text-caption">{sec.title}</span>
|
||||||
{sec.title}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="text-caption font-semibold cursor-pointer"
|
className="text-caption font-semibold cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
padding: '3px 10px',
|
padding: '3px 10px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
background: `rgba(${sec.colorRgb},0.1)`,
|
border: '1px solid var(--stroke-default)',
|
||||||
border: `1px solid rgba(${sec.colorRgb},0.25)`,
|
|
||||||
color: sec.color,
|
color: sec.color,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
📋 조회
|
조회
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-caption text-fg-disabled text-center py-1.5">준비 중입니다</div>
|
<div className="text-caption text-fg-disabled text-center py-1.5">준비 중입니다</div>
|
||||||
@ -443,8 +425,8 @@ export function IncidentsRightPanel({
|
|||||||
{/* 민감자원 */}
|
{/* 민감자원 */}
|
||||||
<div className="bg-bg-elevated border border-stroke rounded-md p-2.5">
|
<div className="bg-bg-elevated border border-stroke rounded-md p-2.5">
|
||||||
<div className="flex items-center gap-1.5 mb-2">
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
<span className="text-sm">🐟</span>
|
{/* <span className="text-body-2">🐟</span> */}
|
||||||
<span className="text-xs font-bold text-color-success">민감자원</span>
|
<span className="text-caption">민감자원</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-[3px]">
|
<div className="flex flex-col gap-[3px]">
|
||||||
{sensCategories.length === 0 ? (
|
{sensCategories.length === 0 ? (
|
||||||
@ -452,38 +434,33 @@ export function IncidentsRightPanel({
|
|||||||
해당 사고 영역의 민감자원이 없습니다
|
해당 사고 영역의 민감자원이 없습니다
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
sensCategories.map((cat, i) => {
|
sensCategories.map((cat) => {
|
||||||
const icon = CATEGORY_ICON[cat.category] ?? '🌊';
|
|
||||||
const areaLabel =
|
const areaLabel =
|
||||||
cat.totalArea != null
|
cat.totalArea != null
|
||||||
? `${cat.totalArea.toLocaleString('ko-KR', { maximumFractionDigits: 0 })}ha`
|
? `${cat.totalArea.toLocaleString('ko-KR', { maximumFractionDigits: 0 })}ha`
|
||||||
: `${cat.count}개소`;
|
: `${cat.count}개소`;
|
||||||
const [r, g, b] = getCategoryColor(i);
|
|
||||||
const hex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={cat.category}
|
key={cat.category}
|
||||||
className="flex items-center cursor-pointer text-caption gap-[5px] rounded-[3px]"
|
className="flex items-center cursor-pointer text-caption gap-[5px]"
|
||||||
style={{ padding: '4px 6px', background: `rgba(${r},${g},${b},0.06)` }}
|
style={{ padding: '3px 0' }}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={checkedSensCategories.has(cat.category)}
|
checked={checkedSensCategories.has(cat.category)}
|
||||||
onChange={() => toggleSensCategory(cat.category)}
|
onChange={() => toggleSensCategory(cat.category)}
|
||||||
style={{ accentColor: hex }}
|
style={{ accentColor: 'var(--color-accent)' }}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
width: 8,
|
width: 6,
|
||||||
height: 8,
|
height: 6,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: hex,
|
background: 'var(--color-accent)',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
border: `1px solid rgba(${r},${g},${b},0.45)`,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span>{icon}</span>
|
|
||||||
<span className="flex-1">{cat.category}</span>
|
<span className="flex-1">{cat.category}</span>
|
||||||
<span className="text-fg-disabled font-mono shrink-0">({areaLabel})</span>
|
<span className="text-fg-disabled font-mono shrink-0">({areaLabel})</span>
|
||||||
</label>
|
</label>
|
||||||
@ -496,10 +473,9 @@ export function IncidentsRightPanel({
|
|||||||
{/* 근처 방제자원 */}
|
{/* 근처 방제자원 */}
|
||||||
<div className="bg-bg-elevated border border-stroke rounded-md p-2.5">
|
<div className="bg-bg-elevated border border-stroke rounded-md p-2.5">
|
||||||
<div className="flex items-center gap-1.5 mb-2">
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
<span className="text-sm">🛡</span>
|
<span className="text-caption font-bold text-color-accent">근처 방제자원</span>
|
||||||
<span className="text-xs font-bold text-color-boom">근처 방제자원</span>
|
|
||||||
{nearbyOrgs.length > 0 && (
|
{nearbyOrgs.length > 0 && (
|
||||||
<span className="ml-auto text-caption font-mono text-color-boom">
|
<span className="ml-auto text-caption font-mono text-color-accent">
|
||||||
{nearbyOrgs.length}개
|
{nearbyOrgs.length}개
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -519,21 +495,18 @@ export function IncidentsRightPanel({
|
|||||||
반경 내 방제자원 없음
|
반경 내 방제자원 없음
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-[3px] max-h-[200px] overflow-y-auto">
|
<div className="flex flex-col max-h-[200px] overflow-y-auto">
|
||||||
{nearbyOrgs.map((org) => (
|
{nearbyOrgs.map((org) => (
|
||||||
<div
|
<div
|
||||||
key={org.orgSn}
|
key={org.orgSn}
|
||||||
className="flex items-start gap-1.5 rounded-[3px] px-[6px] py-[5px]"
|
className="flex items-start gap-1.5 px-[2px] py-[5px]"
|
||||||
style={{
|
style={{ borderBottom: '1px solid var(--stroke-default)' }}
|
||||||
background: 'rgba(245,158,11,0.05)',
|
|
||||||
border: '1px solid rgba(245,158,11,0.08)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1 mb-[2px]">
|
<div className="flex items-center gap-1 mb-[2px]">
|
||||||
<span
|
<span
|
||||||
className="text-caption px-[4px] py-[1px] rounded-[2px] font-bold shrink-0"
|
className="text-caption px-[4px] py-[1px] rounded-[2px] font-bold shrink-0 text-color-accent"
|
||||||
style={{ background: 'rgba(245,158,11,0.15)', color: '#f59e0b' }}
|
style={{ background: 'rgba(6,182,212,0.1)' }}
|
||||||
>
|
>
|
||||||
{org.orgTp}
|
{org.orgTp}
|
||||||
</span>
|
</span>
|
||||||
@ -544,7 +517,7 @@ export function IncidentsRightPanel({
|
|||||||
{org.totalAssets > 0 ? ` · 장비 ${org.totalAssets}개` : ''}
|
{org.totalAssets > 0 ? ` · 장비 ${org.totalAssets}개` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-caption font-mono text-color-boom shrink-0">
|
<span className="text-caption font-mono text-color-accent shrink-0">
|
||||||
{org.distanceNm.toFixed(1)} nm
|
{org.distanceNm.toFixed(1)} nm
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -553,10 +526,10 @@ export function IncidentsRightPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Radius slider */}
|
{/* Radius slider */}
|
||||||
<div className="mt-2 pt-2" style={{ borderTop: '1px solid rgba(245,158,11,0.1)' }}>
|
<div className="mt-2 pt-2" style={{ borderTop: '1px solid var(--stroke-default)' }}>
|
||||||
<div className="flex items-center justify-between mb-[5px]">
|
<div className="flex items-center justify-between mb-[5px]">
|
||||||
<span className="text-caption text-fg-disabled">탐색 반경</span>
|
<span className="text-caption text-fg-disabled">탐색 반경</span>
|
||||||
<span className="text-caption font-bold font-mono text-color-boom">
|
<span className="text-caption font-bold font-mono text-color-accent">
|
||||||
{nearbyRadius} nm
|
{nearbyRadius} nm
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -572,7 +545,7 @@ export function IncidentsRightPanel({
|
|||||||
height: '4px',
|
height: '4px',
|
||||||
background: 'var(--stroke-default)',
|
background: 'var(--stroke-default)',
|
||||||
borderRadius: '2px',
|
borderRadius: '2px',
|
||||||
accentColor: '#f59e0b',
|
accentColor: 'var(--color-accent)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -605,7 +578,8 @@ export function IncidentsRightPanel({
|
|||||||
color: isActive ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
color: isActive ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{v.icon} {v.label}
|
{/* {v.icon} */}
|
||||||
|
{v.label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -627,9 +601,7 @@ export function IncidentsRightPanel({
|
|||||||
className="w-full text-label-2 font-bold cursor-pointer"
|
className="w-full text-label-2 font-bold cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
padding: '8px',
|
padding: '8px',
|
||||||
background: analysisActive
|
background: analysisActive ? 'rgba(239,68,68,0.1)' : 'rgba(6,182,212,0.1)',
|
||||||
? 'linear-gradient(135deg,rgba(239,68,68,0.15),rgba(239,68,68,0.1))'
|
|
||||||
: 'linear-gradient(135deg,rgba(6,182,212,0.15),rgba(59,130,246,0.1))',
|
|
||||||
border: analysisActive
|
border: analysisActive
|
||||||
? '1px solid rgba(239,68,68,0.3)'
|
? '1px solid rgba(239,68,68,0.3)'
|
||||||
: '1px solid rgba(6,182,212,0.3)',
|
: '1px solid rgba(6,182,212,0.3)',
|
||||||
|
|||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -112,7 +112,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
|||||||
<button
|
<button
|
||||||
key={page}
|
key={page}
|
||||||
onClick={() => setCurrentPage(page as number)}
|
onClick={() => setCurrentPage(page as number)}
|
||||||
className={`px-3 py-1 text-title-3 font-medium rounded transition-colors ${
|
className={`px-3 py-1 text-body-2 font-medium rounded transition-colors ${
|
||||||
currentPage === page ? 'bg-color-accent text-bg-0' : 'text-fg-sub hover:bg-bg-elevated'
|
currentPage === page ? 'bg-color-accent text-bg-0' : 'text-fg-sub hover:bg-bg-elevated'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -127,8 +127,8 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
|||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-stroke">
|
<div className="flex items-center justify-between px-5 py-4 border-b border-stroke">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-heading-3 font-bold text-fg">유출유 확산 예측 목록</h1>
|
<h1 className="text-heading-3 text-fg">유출유 확산 예측 목록</h1>
|
||||||
<p className="text-title-3 text-fg-disabled mt-1">총 {analyses.length}건</p>
|
<p className="text-body-2 text-fg-disabled mt-1">총 {analyses.length}건</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@ -137,12 +137,12 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
|||||||
placeholder="검색..."
|
placeholder="검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="w-64 px-4 py-2 text-title-3 bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none"
|
className="w-64 px-4 py-2 text-body-2 bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => onTabChange('analysis')}
|
onClick={() => onTabChange('analysis')}
|
||||||
className="px-4 py-2 text-title-3 font-semibold rounded-sm cursor-pointer text-color-accent"
|
className="px-4 py-2 text-body-2 font-semibold rounded-sm cursor-pointer text-color-accent"
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid rgba(6,182,212,.3)',
|
border: '1px solid rgba(6,182,212,.3)',
|
||||||
background: 'rgba(6,182,212,.08)',
|
background: 'rgba(6,182,212,.08)',
|
||||||
@ -156,7 +156,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
|||||||
{/* 테이블 */}
|
{/* 테이블 */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-20 text-fg-disabled text-title-3">로딩 중...</div>
|
<div className="text-center py-20 text-fg-disabled text-body-2">로딩 중...</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="sticky top-0 bg-bg-surface border-b border-stroke z-10">
|
<thead className="sticky top-0 bg-bg-surface border-b border-stroke z-10">
|
||||||
@ -208,14 +208,14 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
|||||||
key={analysis.predRunSn ?? analysis.acdntSn}
|
key={analysis.predRunSn ?? analysis.acdntSn}
|
||||||
className="hover:bg-bg-elevated transition-colors cursor-pointer group"
|
className="hover:bg-bg-elevated transition-colors cursor-pointer group"
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 text-title-3 text-fg-sub font-mono">
|
<td className="px-4 py-3 text-body-2 text-fg-sub font-mono">
|
||||||
{analysis.acdntSn}
|
{analysis.acdntSn}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-2 h-2 rounded-full bg-color-danger animate-pulse" />
|
<span className="w-2 h-2 rounded-full bg-color-danger animate-pulse" />
|
||||||
<span
|
<span
|
||||||
className="text-title-3 font-medium text-color-accent hover:underline transition-all cursor-pointer"
|
className="text-body-2 font-medium text-color-accent hover:underline transition-all cursor-pointer"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (onSelectAnalysis) {
|
if (onSelectAnalysis) {
|
||||||
@ -227,7 +227,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-title-3 text-fg-sub font-mono">
|
<td className="px-4 py-3 text-body-2 text-fg-sub font-mono">
|
||||||
{analysis.occurredAt
|
{analysis.occurredAt
|
||||||
? new Date(analysis.occurredAt).toLocaleString('ko-KR', {
|
? new Date(analysis.occurredAt).toLocaleString('ko-KR', {
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
@ -237,7 +237,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
|||||||
})
|
})
|
||||||
: '—'}
|
: '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-title-3 text-fg-sub font-mono">
|
<td className="px-4 py-3 text-body-2 text-fg-sub font-mono">
|
||||||
{analysis.runDtm
|
{analysis.runDtm
|
||||||
? new Date(analysis.runDtm).toLocaleString('ko-KR', {
|
? new Date(analysis.runDtm).toLocaleString('ko-KR', {
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
@ -247,11 +247,11 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
|||||||
})
|
})
|
||||||
: '—'}
|
: '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-title-3 text-fg-sub font-mono">
|
<td className="px-4 py-3 text-body-2 text-fg-sub font-mono">
|
||||||
{analysis.duration}
|
{analysis.duration}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-title-3 text-fg-sub">{analysis.oilType}</td>
|
<td className="px-4 py-3 text-body-2 text-fg-sub">{analysis.oilType}</td>
|
||||||
<td className="px-4 py-3 text-title-3 text-fg font-mono text-right font-medium">
|
<td className="px-4 py-3 text-body-2 text-fg font-mono text-right font-medium">
|
||||||
{analysis.volume != null
|
{analysis.volume != null
|
||||||
? analysis.volume >= 0.01
|
? analysis.volume >= 0.01
|
||||||
? analysis.volume.toFixed(2)
|
? analysis.volume.toFixed(2)
|
||||||
@ -268,8 +268,8 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
|||||||
<td className="px-4 py-3 text-center">
|
<td className="px-4 py-3 text-center">
|
||||||
{getStatusBadge(analysis.backtrackStatus)}
|
{getStatusBadge(analysis.backtrackStatus)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-title-3 text-fg-sub">{analysis.analyst}</td>
|
<td className="px-4 py-3 text-body-2 text-fg-sub">{analysis.analyst}</td>
|
||||||
<td className="px-4 py-3 text-title-3 text-fg-sub">{analysis.officeName}</td>
|
<td className="px-4 py-3 text-body-2 text-fg-sub">{analysis.officeName}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -277,7 +277,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && analyses.length === 0 && (
|
{!loading && analyses.length === 0 && (
|
||||||
<div className="text-center py-20 text-fg-disabled text-title-3">
|
<div className="text-center py-20 text-fg-disabled text-body-2">
|
||||||
분석 데이터가 없습니다.
|
분석 데이터가 없습니다.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -288,14 +288,14 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
|||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(1)}
|
onClick={() => setCurrentPage(1)}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
className="px-3 py-1 text-title-3 font-medium text-fg-sub hover:bg-bg-elevated rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-3 py-1 text-body-2 font-medium text-fg-sub hover:bg-bg-elevated rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
≪
|
≪
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
className="px-3 py-1 text-title-3 font-medium text-fg-sub hover:bg-bg-elevated rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-3 py-1 text-body-2 font-medium text-fg-sub hover:bg-bg-elevated rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
‹
|
‹
|
||||||
</button>
|
</button>
|
||||||
@ -305,14 +305,14 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
|||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
|
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
className="px-3 py-1 text-title-3 font-medium text-fg-sub hover:bg-bg-elevated rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-3 py-1 text-body-2 font-medium text-fg-sub hover:bg-bg-elevated rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
›
|
›
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(totalPages)}
|
onClick={() => setCurrentPage(totalPages)}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
className="px-3 py-1 text-title-3 font-medium text-fg-sub hover:bg-bg-elevated rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-3 py-1 text-body-2 font-medium text-fg-sub hover:bg-bg-elevated rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
≫
|
≫
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -68,7 +68,7 @@ export function BacktrackModal({
|
|||||||
border: '1px solid var(--bd)',
|
border: '1px solid var(--bd)',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
color: 'var(--t1)',
|
color: 'var(--t1)',
|
||||||
fontSize: '11px',
|
fontSize: 'var(--font-size-caption)',
|
||||||
fontFamily: 'var(--fM)',
|
fontFamily: 'var(--fM)',
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
opacity: inputDisabled ? 0.6 : 1,
|
opacity: inputDisabled ? 0.6 : 1,
|
||||||
@ -108,9 +108,8 @@ export function BacktrackModal({
|
|||||||
borderRadius: '10px',
|
borderRadius: '10px',
|
||||||
background: 'linear-gradient(135deg, rgba(168,85,247,0.2), rgba(6,182,212,0.2))',
|
background: 'linear-gradient(135deg, rgba(168,85,247,0.2), rgba(6,182,212,0.2))',
|
||||||
border: '1px solid rgba(168,85,247,0.3)',
|
border: '1px solid rgba(168,85,247,0.3)',
|
||||||
fontSize: '18px',
|
|
||||||
}}
|
}}
|
||||||
className="flex items-center justify-center"
|
className="flex items-center justify-center text-title-1"
|
||||||
>
|
>
|
||||||
🔍
|
🔍
|
||||||
</div>
|
</div>
|
||||||
@ -127,7 +126,7 @@ export function BacktrackModal({
|
|||||||
height: '32px',
|
height: '32px',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
background: 'var(--bg-card)',
|
background: 'var(--bg-card)',
|
||||||
fontSize: '14px',
|
fontSize: 'var(--font-size-body-2)',
|
||||||
}}
|
}}
|
||||||
className="border border-stroke text-fg-disabled cursor-pointer flex items-center justify-center"
|
className="border border-stroke text-fg-disabled cursor-pointer flex items-center justify-center"
|
||||||
>
|
>
|
||||||
@ -245,7 +244,7 @@ export function BacktrackModal({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="text-caption text-fg-disabled mb-1">분석 대상 선박</div>
|
<div className="text-caption text-fg-disabled mb-1">분석 대상 선박</div>
|
||||||
<div className="text-sm font-bold text-color-tertiary font-mono">
|
<div className="text-body-2 font-bold text-color-tertiary font-mono">
|
||||||
{conditions.totalVessels}척{' '}
|
{conditions.totalVessels}척{' '}
|
||||||
<span className="text-caption font-medium text-fg-disabled">(AIS 수신)</span>
|
<span className="text-caption font-medium text-fg-disabled">(AIS 수신)</span>
|
||||||
</div>
|
</div>
|
||||||
@ -371,7 +370,7 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
|
|||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: `${vessel.color}20`,
|
background: `${vessel.color}20`,
|
||||||
border: `2px solid ${vessel.color}`,
|
border: `2px solid ${vessel.color}`,
|
||||||
fontSize: '12px',
|
fontSize: 'var(--font-size-caption)',
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
color: vessel.color,
|
color: vessel.color,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -1222,7 +1222,7 @@ function FieldApplicationPanel() {
|
|||||||
style={{ background: step.bg, border: `1px solid ${step.bd}` }}
|
style={{ background: step.bg, border: `1px solid ${step.bd}` }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="min-w-[36px] h-[36px] rounded-[9px] flex items-center justify-center font-extrabold text-sm flex-shrink-0"
|
className="min-w-[36px] h-[36px] rounded-[9px] flex items-center justify-center font-extrabold text-body-2 flex-shrink-0"
|
||||||
style={{
|
style={{
|
||||||
background: step.numBg,
|
background: step.numBg,
|
||||||
border: `1px solid ${step.numBd}`,
|
border: `1px solid ${step.numBd}`,
|
||||||
|
|||||||
@ -130,7 +130,7 @@ export function LeftPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-80 min-w-[320px] bg-bg-surface border-r border-stroke flex flex-col">
|
<div className="w-full min-w-0 h-full bg-bg-surface border-r border-stroke flex flex-col overflow-hidden">
|
||||||
{/* Scrollable Content */}
|
{/* Scrollable Content */}
|
||||||
<div
|
<div
|
||||||
className="flex-1 overflow-y-scroll scrollbar-thin scrollbar-thumb-border-light scrollbar-track-transparent"
|
className="flex-1 overflow-y-scroll scrollbar-thin scrollbar-thumb-border-light scrollbar-track-transparent"
|
||||||
|
|||||||
@ -166,6 +166,8 @@ export const ALL_MODELS: PredictionModel[] = ['KOSPS', 'POSEIDON', 'OpenDrift'];
|
|||||||
|
|
||||||
export function OilSpillView() {
|
export function OilSpillView() {
|
||||||
const { activeSubTab, setActiveSubTab } = useSubMenu('prediction');
|
const { activeSubTab, setActiveSubTab } = useSubMenu('prediction');
|
||||||
|
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||||
|
const [rightCollapsed, setRightCollapsed] = useState(false);
|
||||||
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set());
|
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set());
|
||||||
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null);
|
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null);
|
||||||
const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined);
|
const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined);
|
||||||
@ -1129,88 +1131,132 @@ export function OilSpillView() {
|
|||||||
<div className="relative flex flex-1 overflow-hidden">
|
<div className="relative flex flex-1 overflow-hidden">
|
||||||
{/* Left Sidebar */}
|
{/* Left Sidebar */}
|
||||||
{activeSubTab === 'analysis' && (
|
{activeSubTab === 'analysis' && (
|
||||||
<LeftPanel
|
<div className="shrink-0 overflow-hidden" style={{ width: leftCollapsed ? 0 : 320 }}>
|
||||||
selectedAnalysis={selectedAnalysis}
|
<LeftPanel
|
||||||
enabledLayers={enabledLayers}
|
selectedAnalysis={selectedAnalysis}
|
||||||
onToggleLayer={handleToggleLayer}
|
enabledLayers={enabledLayers}
|
||||||
accidentTime={accidentTime}
|
onToggleLayer={handleToggleLayer}
|
||||||
onAccidentTimeChange={(v) => {
|
accidentTime={accidentTime}
|
||||||
setAccidentTime(v);
|
onAccidentTimeChange={(v) => {
|
||||||
setValidationErrors((prev) => {
|
setAccidentTime(v);
|
||||||
const n = new Set(prev);
|
setValidationErrors((prev) => {
|
||||||
n.delete('accidentTime');
|
const n = new Set(prev);
|
||||||
return n;
|
n.delete('accidentTime');
|
||||||
});
|
return n;
|
||||||
}}
|
});
|
||||||
incidentCoord={incidentCoord}
|
}}
|
||||||
onCoordChange={(v) => {
|
incidentCoord={incidentCoord}
|
||||||
setIncidentCoord(v);
|
onCoordChange={(v) => {
|
||||||
setValidationErrors((prev) => {
|
setIncidentCoord(v);
|
||||||
const n = new Set(prev);
|
setValidationErrors((prev) => {
|
||||||
n.delete('coord');
|
const n = new Set(prev);
|
||||||
return n;
|
n.delete('coord');
|
||||||
});
|
return n;
|
||||||
}}
|
});
|
||||||
isSelectingLocation={isSelectingLocation}
|
}}
|
||||||
onMapSelectClick={() => setIsSelectingLocation((prev) => !prev)}
|
isSelectingLocation={isSelectingLocation}
|
||||||
onRunSimulation={handleRunSimulation}
|
onMapSelectClick={() => setIsSelectingLocation((prev) => !prev)}
|
||||||
isRunningSimulation={isRunningSimulation}
|
onRunSimulation={handleRunSimulation}
|
||||||
selectedModels={selectedModels}
|
isRunningSimulation={isRunningSimulation}
|
||||||
onModelsChange={(v) => {
|
selectedModels={selectedModels}
|
||||||
setSelectedModels(v);
|
onModelsChange={(v) => {
|
||||||
setValidationErrors((prev) => {
|
setSelectedModels(v);
|
||||||
const n = new Set(prev);
|
setValidationErrors((prev) => {
|
||||||
n.delete('models');
|
const n = new Set(prev);
|
||||||
return n;
|
n.delete('models');
|
||||||
});
|
return n;
|
||||||
}}
|
});
|
||||||
visibleModels={visibleModels}
|
}}
|
||||||
onVisibleModelsChange={setVisibleModels}
|
visibleModels={visibleModels}
|
||||||
hasResults={oilTrajectory.length > 0}
|
onVisibleModelsChange={setVisibleModels}
|
||||||
predictionTime={predictionTime}
|
hasResults={oilTrajectory.length > 0}
|
||||||
onPredictionTimeChange={setPredictionTime}
|
predictionTime={predictionTime}
|
||||||
spillType={spillType}
|
onPredictionTimeChange={setPredictionTime}
|
||||||
onSpillTypeChange={setSpillType}
|
spillType={spillType}
|
||||||
oilType={oilType}
|
onSpillTypeChange={setSpillType}
|
||||||
onOilTypeChange={setOilType}
|
oilType={oilType}
|
||||||
spillAmount={spillAmount}
|
onOilTypeChange={setOilType}
|
||||||
onSpillAmountChange={setSpillAmount}
|
spillAmount={spillAmount}
|
||||||
incidentName={incidentName}
|
onSpillAmountChange={setSpillAmount}
|
||||||
onIncidentNameChange={(v) => {
|
incidentName={incidentName}
|
||||||
setIncidentName(v);
|
onIncidentNameChange={(v) => {
|
||||||
setValidationErrors((prev) => {
|
setIncidentName(v);
|
||||||
const n = new Set(prev);
|
setValidationErrors((prev) => {
|
||||||
n.delete('incidentName');
|
const n = new Set(prev);
|
||||||
return n;
|
n.delete('incidentName');
|
||||||
});
|
return n;
|
||||||
}}
|
});
|
||||||
spillUnit={spillUnit}
|
}}
|
||||||
onSpillUnitChange={setSpillUnit}
|
spillUnit={spillUnit}
|
||||||
boomLines={boomLines}
|
onSpillUnitChange={setSpillUnit}
|
||||||
onBoomLinesChange={setBoomLines}
|
boomLines={boomLines}
|
||||||
oilTrajectory={oilTrajectory}
|
onBoomLinesChange={setBoomLines}
|
||||||
algorithmSettings={algorithmSettings}
|
oilTrajectory={oilTrajectory}
|
||||||
onAlgorithmSettingsChange={setAlgorithmSettings}
|
algorithmSettings={algorithmSettings}
|
||||||
isDrawingBoom={isDrawingBoom}
|
onAlgorithmSettingsChange={setAlgorithmSettings}
|
||||||
onDrawingBoomChange={setIsDrawingBoom}
|
isDrawingBoom={isDrawingBoom}
|
||||||
drawingPoints={drawingPoints}
|
onDrawingBoomChange={setIsDrawingBoom}
|
||||||
onDrawingPointsChange={setDrawingPoints}
|
drawingPoints={drawingPoints}
|
||||||
containmentResult={containmentResult}
|
onDrawingPointsChange={setDrawingPoints}
|
||||||
onContainmentResultChange={setContainmentResult}
|
containmentResult={containmentResult}
|
||||||
layerOpacity={layerOpacity}
|
onContainmentResultChange={setContainmentResult}
|
||||||
onLayerOpacityChange={setLayerOpacity}
|
layerOpacity={layerOpacity}
|
||||||
layerBrightness={layerBrightness}
|
onLayerOpacityChange={setLayerOpacity}
|
||||||
onLayerBrightnessChange={setLayerBrightness}
|
layerBrightness={layerBrightness}
|
||||||
layerColors={layerColors}
|
onLayerBrightnessChange={setLayerBrightness}
|
||||||
onLayerColorChange={(id, color) => setLayerColors((prev) => ({ ...prev, [id]: color }))}
|
layerColors={layerColors}
|
||||||
sensitiveResources={sensitiveResourceCategories}
|
onLayerColorChange={(id, color) => setLayerColors((prev) => ({ ...prev, [id]: color }))}
|
||||||
onImageAnalysisResult={handleImageAnalysisResult}
|
sensitiveResources={sensitiveResourceCategories}
|
||||||
validationErrors={validationErrors}
|
onImageAnalysisResult={handleImageAnalysisResult}
|
||||||
/>
|
validationErrors={validationErrors}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Center - Map/Content Area */}
|
{/* Center - Map/Content Area */}
|
||||||
<div className="flex-1 relative overflow-hidden">
|
<div className="flex-1 relative overflow-hidden">
|
||||||
|
{/* Left panel toggle button */}
|
||||||
|
{activeSubTab === 'analysis' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setLeftCollapsed((v) => !v)}
|
||||||
|
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||||
|
style={{
|
||||||
|
left: 0,
|
||||||
|
width: 18,
|
||||||
|
height: 40,
|
||||||
|
background: 'var(--bg-elevated)',
|
||||||
|
border: '1px solid var(--stroke-default)',
|
||||||
|
borderLeft: 'none',
|
||||||
|
borderRadius: '0 6px 6px 0',
|
||||||
|
color: 'var(--fg-sub)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{leftCollapsed ? '▶' : '◀'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Right panel toggle button */}
|
||||||
|
{activeSubTab === 'analysis' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setRightCollapsed((v) => !v)}
|
||||||
|
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||||
|
style={{
|
||||||
|
right: 0,
|
||||||
|
width: 18,
|
||||||
|
height: 40,
|
||||||
|
background: 'var(--bg-elevated)',
|
||||||
|
border: '1px solid var(--stroke-default)',
|
||||||
|
borderRight: 'none',
|
||||||
|
borderRadius: '6px 0 0 6px',
|
||||||
|
color: 'var(--fg-sub)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rightCollapsed ? '◀' : '▶'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeSubTab === 'list' ? (
|
{activeSubTab === 'list' ? (
|
||||||
<AnalysisListTable
|
<AnalysisListTable
|
||||||
onTabChange={setActiveSubTab}
|
onTabChange={setActiveSubTab}
|
||||||
@ -1332,7 +1378,7 @@ export function OilSpillView() {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '14px',
|
fontSize: 'var(--font-size-body-2)',
|
||||||
transition: '0.2s',
|
transition: '0.2s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -1358,7 +1404,7 @@ export function OilSpillView() {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '14px',
|
fontSize: 'var(--font-size-body-2)',
|
||||||
transition: '0.2s',
|
transition: '0.2s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -1394,7 +1440,7 @@ export function OilSpillView() {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '12px',
|
fontSize: 'var(--font-size-caption)',
|
||||||
transition: '0.2s',
|
transition: '0.2s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -1415,7 +1461,7 @@ export function OilSpillView() {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '11px',
|
fontSize: 'var(--font-size-caption)',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontFamily: 'var(--font-mono)',
|
fontFamily: 'var(--font-mono)',
|
||||||
transition: '0.2s',
|
transition: '0.2s',
|
||||||
@ -1439,7 +1485,7 @@ export function OilSpillView() {
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: `${pos}%`,
|
left: `${pos}%`,
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
fontSize: '10px',
|
fontSize: 'var(--font-size-caption)',
|
||||||
fontFamily: 'var(--font-mono)',
|
fontFamily: 'var(--font-mono)',
|
||||||
color: isActive ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
color: isActive ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||||||
fontWeight: isActive ? 600 : 400,
|
fontWeight: isActive ? 600 : 400,
|
||||||
@ -1526,7 +1572,7 @@ export function OilSpillView() {
|
|||||||
top: '-18px',
|
top: '-18px',
|
||||||
left: `${bm.pos}%`,
|
left: `${bm.pos}%`,
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
fontSize: '12px',
|
fontSize: 'var(--font-size-caption)',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
filter: 'drop-shadow(0 0 4px rgba(245,158,11,0.5))',
|
filter: 'drop-shadow(0 0 4px rgba(245,158,11,0.5))',
|
||||||
}}
|
}}
|
||||||
@ -1569,7 +1615,7 @@ export function OilSpillView() {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: '14px',
|
fontSize: 'var(--font-size-body-2)',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: 'var(--color-accent)',
|
color: 'var(--color-accent)',
|
||||||
fontFamily: 'var(--font-mono)',
|
fontFamily: 'var(--font-mono)',
|
||||||
@ -1610,7 +1656,7 @@ export function OilSpillView() {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '5px',
|
gap: '5px',
|
||||||
fontSize: '11px',
|
fontSize: 'var(--font-size-caption)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-fg-disabled">{s.label}</span>
|
<span className="text-fg-disabled">{s.label}</span>
|
||||||
@ -1655,44 +1701,46 @@ export function OilSpillView() {
|
|||||||
|
|
||||||
{/* Right Panel */}
|
{/* Right Panel */}
|
||||||
{activeSubTab === 'analysis' && (
|
{activeSubTab === 'analysis' && (
|
||||||
<RightPanel
|
<div className="shrink-0 overflow-hidden" style={{ width: rightCollapsed ? 0 : 300 }}>
|
||||||
onOpenBacktrack={handleOpenBacktrack}
|
<RightPanel
|
||||||
onOpenRecalc={() => {
|
onOpenBacktrack={handleOpenBacktrack}
|
||||||
if (!selectedAnalysis) {
|
onOpenRecalc={() => {
|
||||||
alert('선택된 사고가 없습니다.\n분석 목록에서 사고를 선택해주세요.');
|
if (!selectedAnalysis) {
|
||||||
return;
|
alert('선택된 사고가 없습니다.\n분석 목록에서 사고를 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRecalcModalOpen(true);
|
||||||
|
}}
|
||||||
|
onOpenReport={handleOpenReport}
|
||||||
|
detail={analysisDetail}
|
||||||
|
summary={
|
||||||
|
stepSummariesByModel[windHydrModel]?.[currentStep] ??
|
||||||
|
summaryByModel[windHydrModel] ??
|
||||||
|
simulationSummary
|
||||||
}
|
}
|
||||||
setRecalcModalOpen(true);
|
boomBlockedVolume={boomBlockedVolume}
|
||||||
}}
|
displayControls={displayControls}
|
||||||
onOpenReport={handleOpenReport}
|
onDisplayControlsChange={setDisplayControls}
|
||||||
detail={analysisDetail}
|
windHydrModel={windHydrModel}
|
||||||
summary={
|
windHydrModelOptions={windHydrModelOptions}
|
||||||
stepSummariesByModel[windHydrModel]?.[currentStep] ??
|
onWindHydrModelChange={setWindHydrModel}
|
||||||
summaryByModel[windHydrModel] ??
|
analysisTab={analysisTab}
|
||||||
simulationSummary
|
onSwitchAnalysisTab={setAnalysisTab}
|
||||||
}
|
drawAnalysisMode={drawAnalysisMode}
|
||||||
boomBlockedVolume={boomBlockedVolume}
|
analysisPolygonPoints={analysisPolygonPoints}
|
||||||
displayControls={displayControls}
|
circleRadiusNm={circleRadiusNm}
|
||||||
onDisplayControlsChange={setDisplayControls}
|
onCircleRadiusChange={setCircleRadiusNm}
|
||||||
windHydrModel={windHydrModel}
|
analysisResult={analysisResult}
|
||||||
windHydrModelOptions={windHydrModelOptions}
|
incidentCoord={incidentCoord}
|
||||||
onWindHydrModelChange={setWindHydrModel}
|
centerPoints={centerPoints}
|
||||||
analysisTab={analysisTab}
|
predictionTime={predictionTime}
|
||||||
onSwitchAnalysisTab={setAnalysisTab}
|
onStartPolygonDraw={handleStartPolygonDraw}
|
||||||
drawAnalysisMode={drawAnalysisMode}
|
onRunPolygonAnalysis={handleRunPolygonAnalysis}
|
||||||
analysisPolygonPoints={analysisPolygonPoints}
|
onRunCircleAnalysis={handleRunCircleAnalysis}
|
||||||
circleRadiusNm={circleRadiusNm}
|
onCancelAnalysis={handleCancelAnalysis}
|
||||||
onCircleRadiusChange={setCircleRadiusNm}
|
onClearAnalysis={handleClearAnalysis}
|
||||||
analysisResult={analysisResult}
|
/>
|
||||||
incidentCoord={incidentCoord}
|
</div>
|
||||||
centerPoints={centerPoints}
|
|
||||||
predictionTime={predictionTime}
|
|
||||||
onStartPolygonDraw={handleStartPolygonDraw}
|
|
||||||
onRunPolygonAnalysis={handleRunPolygonAnalysis}
|
|
||||||
onRunCircleAnalysis={handleRunCircleAnalysis}
|
|
||||||
onCancelAnalysis={handleCancelAnalysis}
|
|
||||||
onClearAnalysis={handleClearAnalysis}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 확산 예측 실행 중 로딩 오버레이 */}
|
{/* 확산 예측 실행 중 로딩 오버레이 */}
|
||||||
|
|||||||
@ -159,7 +159,7 @@ export function RecalcModal({
|
|||||||
borderRadius: '10px',
|
borderRadius: '10px',
|
||||||
background: 'linear-gradient(135deg, rgba(249,115,22,0.2), rgba(6,182,212,0.2))',
|
background: 'linear-gradient(135deg, rgba(249,115,22,0.2), rgba(6,182,212,0.2))',
|
||||||
border: '1px solid rgba(249,115,22,0.3)',
|
border: '1px solid rgba(249,115,22,0.3)',
|
||||||
fontSize: '16px',
|
fontSize: 'var(--font-size-body-1)',
|
||||||
}}
|
}}
|
||||||
className="flex items-center justify-center"
|
className="flex items-center justify-center"
|
||||||
>
|
>
|
||||||
@ -178,7 +178,7 @@ export function RecalcModal({
|
|||||||
height: '28px',
|
height: '28px',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
background: 'var(--bg-card)',
|
background: 'var(--bg-card)',
|
||||||
fontSize: '12px',
|
fontSize: 'var(--font-size-caption)',
|
||||||
}}
|
}}
|
||||||
className="border border-stroke text-fg-disabled cursor-pointer flex items-center justify-center"
|
className="border border-stroke text-fg-disabled cursor-pointer flex items-center justify-center"
|
||||||
>
|
>
|
||||||
@ -321,7 +321,7 @@ export function RecalcModal({
|
|||||||
}
|
}
|
||||||
toggleModel(model);
|
toggleModel(model);
|
||||||
}}
|
}}
|
||||||
style={{ fontSize: '11px', padding: '5px 10px' }}
|
style={{ fontSize: 'var(--font-size-caption)', padding: '5px 10px' }}
|
||||||
>
|
>
|
||||||
<span className="prd-md" style={{ background: color }} />
|
<span className="prd-md" style={{ background: color }} />
|
||||||
{model}
|
{model}
|
||||||
|
|||||||
@ -117,7 +117,7 @@ export function RightPanel({
|
|||||||
}, [incidentCoord, centerPoints, summary, predictionTime]);
|
}, [incidentCoord, centerPoints, summary, predictionTime]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-[300px] min-w-[300px] bg-bg-surface border-l border-stroke flex flex-col">
|
<div className="w-full min-w-0 h-full bg-bg-surface border-l border-stroke flex flex-col overflow-hidden">
|
||||||
{/* Tab Header */}
|
{/* Tab Header */}
|
||||||
<div className="flex border-b border-stroke">
|
<div className="flex border-b border-stroke">
|
||||||
<button className="flex-1 py-3 text-center text-label-1 font-medium text-color-accent border-b-2 border-color-accent transition-all font-korean">
|
<button className="flex-1 py-3 text-center text-label-1 font-medium text-color-accent border-b-2 border-color-accent transition-all font-korean">
|
||||||
@ -580,7 +580,7 @@ export function RightPanel({
|
|||||||
|
|
||||||
{/* Bottom Action Buttons */}
|
{/* Bottom Action Buttons */}
|
||||||
<div className="flex gap-1.5 p-3 border-t border-stroke">
|
<div className="flex gap-1.5 p-3 border-t border-stroke">
|
||||||
<button className="flex-1 py-2 px-1 rounded-sm text-label-2 font-medium border border-color-accent text-color-accent font-korean hover:bg-[rgba(6,182,212,0.08)] transition-colors">
|
<button className="flex-1 py-2 px-1 rounded-sm text-label-2 font-medium border border-stroke font-korean hover:bg-[rgba(6,182,212,0.08)] transition-colors">
|
||||||
저장
|
저장
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@ -597,7 +597,7 @@ export function RightPanel({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onOpenBacktrack}
|
onClick={onOpenBacktrack}
|
||||||
className="flex-1 py-2 px-1 rounded-sm text-label-2 font-medium border border-[var(--color-tertiary)] text-[var(--color-tertiary)] font-korean hover:bg-[rgba(168,85,247,0.08)] transition-colors"
|
className="flex-1 py-2 px-1 rounded-sm text-label-2 font-medium border border-stroke font-korean hover:bg-[rgba(168,85,247,0.08)] transition-colors"
|
||||||
>
|
>
|
||||||
역추적
|
역추적
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1120,7 +1120,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
{activeSections.length === 0 && (
|
{activeSections.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-fg-disabled">
|
<div className="flex flex-col items-center justify-center py-20 text-fg-disabled">
|
||||||
<div className="text-4xl mb-4">📋</div>
|
<div className="text-4xl mb-4">📋</div>
|
||||||
<p className="text-sm font-korean">왼쪽에서 보고서에 포함할 섹션을 선택하세요</p>
|
<p className="text-body-2 font-korean">
|
||||||
|
왼쪽에서 보고서에 포함할 섹션을 선택하세요
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -171,13 +171,13 @@ export function ReportsView() {
|
|||||||
{filteredReports.length === 0 ? (
|
{filteredReports.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-fg-disabled">
|
<div className="flex flex-col items-center justify-center py-20 text-fg-disabled">
|
||||||
<div className="text-4xl mb-4">📄</div>
|
<div className="text-4xl mb-4">📄</div>
|
||||||
<p className="text-sm font-korean mb-2">저장된 보고서가 없습니다</p>
|
<p className="text-body-2 font-korean mb-2">저장된 보고서가 없습니다</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setView({ screen: 'templates' });
|
setView({ screen: 'templates' });
|
||||||
setActiveSubTab('template');
|
setActiveSubTab('template');
|
||||||
}}
|
}}
|
||||||
className="px-4 py-2 text-xs font-semibold rounded-md border border-color-accent text-color-accent hover:bg-[color-mix(in_srgb,var(--color-accent)_10%,transparent)] transition-all font-korean mt-4"
|
className="px-4 py-2 text-caption font-semibold rounded-md border border-color-accent text-color-accent hover:bg-[color-mix(in_srgb,var(--color-accent)_10%,transparent)] transition-all font-korean mt-4"
|
||||||
>
|
>
|
||||||
템플릿에서 작성
|
템플릿에서 작성
|
||||||
</button>
|
</button>
|
||||||
@ -376,7 +376,7 @@ export function ReportsView() {
|
|||||||
<td className="px-3 py-3 text-center">
|
<td className="px-3 py-3 text-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(report.id)}
|
onClick={() => handleDelete(report.id)}
|
||||||
className="w-7 h-7 rounded flex items-center justify-center text-color-danger hover:bg-[color-mix(in_srgb,var(--color-danger)_10%,transparent)] transition-all text-sm"
|
className="w-7 h-7 rounded flex items-center justify-center text-color-danger hover:bg-[color-mix(in_srgb,var(--color-danger)_10%,transparent)] transition-all text-body-2"
|
||||||
>
|
>
|
||||||
🗑
|
🗑
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
|
import { Map, Marker, Popup } from '@vis.gl/react-maplibre';
|
||||||
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
|
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
|
||||||
import { fetchRescueOps, fetchRescueScenarios } from '../services/rescueApi';
|
import { fetchRescueOps, fetchRescueScenarios } from '../services/rescueApi';
|
||||||
import type { RescueOpsItem, RescueScenarioItem } from '../services/rescueApi';
|
import type { RescueOpsItem, RescueScenarioItem } from '../services/rescueApi';
|
||||||
|
|
||||||
@ -103,6 +106,295 @@ interface ChartDataItem {
|
|||||||
severity: Severity;
|
severity: Severity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── 시나리오 관리 요건 ─── */
|
||||||
|
const SCENARIO_MGMT_GUIDELINES = [
|
||||||
|
'긴급구난 R&D 분석 결과는 시간 단계별 시나리오의 형태로 관리되어야 함',
|
||||||
|
'각 시나리오는 사고 발생 시점부터 구난 진행 단계별 상태 변화를 포함하여야 함',
|
||||||
|
'시나리오별 분석 결과는 사고 단위로 기존 사고 정보와 연계되어 관리되어야 함',
|
||||||
|
'동일 사고에 대해 복수 시나리오(시간대, 조건별)가 존재할 경우, 상호 비교·검토가 되어야 함',
|
||||||
|
'시나리오별 분석결과는 긴급구난 대응 판단을 지원할 수 있도록 요약 정보 형태로 제공되어야 함',
|
||||||
|
'시나리오 관리 기능은 기존 통합지원시스템의 흐름과 연계되어 실질적인 구난 대응 업무에 활용 가능하도록 반영되어야 함',
|
||||||
|
'긴급구난 시나리오 관리 기능 구현 시 1차 구축 완료된 GIS기능을 활용하여 구축하여 재개발하거나 중복구현하지 않도록 함',
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ─── Mock 시나리오 (API 미연결 시 폴백) — 긴급구난 모델 이론 기반 10개 ─── */
|
||||||
|
const MOCK_SCENARIOS: RescueScenarioItem[] = [
|
||||||
|
{
|
||||||
|
scenarioSn: 1, rescueOpsSn: 1, timeStep: 'T+0h',
|
||||||
|
scenarioDtm: '2024-10-27T01:30:00.000Z', svrtCd: 'CRITICAL',
|
||||||
|
gmM: 0.8, listDeg: 15.0, trimM: 2.5, buoyancyPct: 30.0, oilRateLpm: 100.0, bmRatioPct: 92.0,
|
||||||
|
description: '좌현 35° 충돌로 No.1P 화물탱크 파공. 벙커C유 유출 개시. 손상복원성 분석: 초기 GM 0.8m으로 IMO 기준(1.0m) 미달, 복원력 위험 판정.',
|
||||||
|
compartments: [
|
||||||
|
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||||
|
{ name: '#1 Port Tank', status: 'BREACHED', color: 'var(--red)' },
|
||||||
|
{ name: '#2 Port Tank', status: 'RISK', color: 'var(--orange)' },
|
||||||
|
{ name: 'Engine Room', status: 'INTACT', color: 'var(--green)' },
|
||||||
|
{ name: '#3 Stbd Tank', status: 'INTACT', color: 'var(--green)' },
|
||||||
|
],
|
||||||
|
assessment: [
|
||||||
|
{ label: '복원력', value: '위험 (GM 0.8m < IMO 1.0m)', color: 'var(--red)' },
|
||||||
|
{ label: '유출 위험', value: '활발 유출중 (100 L/min)', color: 'var(--red)' },
|
||||||
|
{ label: '선체 강도', value: 'BM 92% (경계)', color: 'var(--orange)' },
|
||||||
|
{ label: '승선인원', value: '15/20 확인, 5명 수색중', color: 'var(--red)' },
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{ time: '10:30', text: '충돌 발생, VHF Ch.16 조난 통보', color: 'var(--red)' },
|
||||||
|
{ time: '10:32', text: 'EPIRB 자동 발신 확인', color: 'var(--red)' },
|
||||||
|
{ time: '10:35', text: '해경 3009함 출동 지시', color: 'var(--orange)' },
|
||||||
|
{ time: '10:42', text: '인근 선박 구조 활동 개시', color: 'var(--cyan)' },
|
||||||
|
],
|
||||||
|
sortOrd: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenarioSn: 2, rescueOpsSn: 1, timeStep: 'T+30m',
|
||||||
|
scenarioDtm: '2024-10-27T02:00:00.000Z', svrtCd: 'CRITICAL',
|
||||||
|
gmM: 0.7, listDeg: 17.0, trimM: 2.8, buoyancyPct: 28.0, oilRateLpm: 120.0, bmRatioPct: 90.0,
|
||||||
|
description: '잠수사 수중 조사 결과 좌현 No.1P 파공 크기 1.2m×0.8m 확인. Bernoulli 유입률 모델 적용: 수두차 4.5m 기준 유입률 약 2.1㎥/min.',
|
||||||
|
compartments: [
|
||||||
|
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||||
|
{ name: '#1 Port Tank', status: 'BREACHED', color: 'var(--red)' },
|
||||||
|
{ name: '#2 Port Tank', status: 'RISK', color: 'var(--orange)' },
|
||||||
|
{ name: 'Engine Room', status: 'INTACT', color: 'var(--green)' },
|
||||||
|
{ name: '#3 Stbd Tank', status: 'INTACT', color: 'var(--green)' },
|
||||||
|
],
|
||||||
|
assessment: [
|
||||||
|
{ label: '복원력', value: '악화 (GM 0.7m, GZ 커브 감소)', color: 'var(--red)' },
|
||||||
|
{ label: '유출 위험', value: '증가 (120 L/min)', color: 'var(--red)' },
|
||||||
|
{ label: '선체 강도', value: 'BM 90% — 종강도 모니터링 개시', color: 'var(--orange)' },
|
||||||
|
{ label: '승선인원', value: '15명 퇴선, 5명 수색중', color: 'var(--red)' },
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{ time: '10:50', text: '잠수사 투입, 수중 손상 조사 개시', color: 'var(--cyan)' },
|
||||||
|
{ time: '10:55', text: '파공 규모 확인: 1.2m×0.8m', color: 'var(--red)' },
|
||||||
|
{ time: '11:00', text: '손상복원성 재계산 — IMO 기준 위험', color: 'var(--red)' },
|
||||||
|
{ time: '11:00', text: '유출유 방제선 배치 요청', color: 'var(--orange)' },
|
||||||
|
],
|
||||||
|
sortOrd: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenarioSn: 3, rescueOpsSn: 1, timeStep: 'T+1h',
|
||||||
|
scenarioDtm: '2024-10-27T02:30:00.000Z', svrtCd: 'CRITICAL',
|
||||||
|
gmM: 0.65, listDeg: 18.5, trimM: 3.0, buoyancyPct: 26.0, oilRateLpm: 135.0, bmRatioPct: 89.0,
|
||||||
|
description: '해경 3009함 현장 도착, SAR 작전 개시. Leeway 표류 예측 모델: 풍속 8m/s, 해류 2.5kn NE — 실종자 표류 반경 1.2nm. GZ 최대 복원력 각도 25°로 감소.',
|
||||||
|
compartments: [
|
||||||
|
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||||
|
{ name: '#1 Port Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||||
|
{ name: '#2 Port Tank', status: 'FLOODING', color: 'var(--red)' },
|
||||||
|
{ name: 'Engine Room', status: 'INTACT', color: 'var(--green)' },
|
||||||
|
{ name: '#3 Stbd Tank', status: 'INTACT', color: 'var(--green)' },
|
||||||
|
],
|
||||||
|
assessment: [
|
||||||
|
{ label: '복원력', value: '한계 접근 (GM 0.65m)', color: 'var(--red)' },
|
||||||
|
{ label: '유출 위험', value: '파공 확대 우려 (135 L/min)', color: 'var(--red)' },
|
||||||
|
{ label: '선체 강도', value: 'BM 89% — Hogging 증가', color: 'var(--orange)' },
|
||||||
|
{ label: '인명구조', value: '실종 5명 수색중, 표류 1.2nm', color: 'var(--red)' },
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{ time: '11:10', text: '해경 3009함 현장 도착, SAR 구역 설정', color: 'var(--cyan)' },
|
||||||
|
{ time: '11:15', text: 'Leeway 표류 예측 모델 적용', color: 'var(--cyan)' },
|
||||||
|
{ time: '11:20', text: '회전익 항공기 수색 개시', color: 'var(--cyan)' },
|
||||||
|
{ time: '11:30', text: '#2 Port Tank 2차 침수 징후', color: 'var(--red)' },
|
||||||
|
],
|
||||||
|
sortOrd: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenarioSn: 4, rescueOpsSn: 1, timeStep: 'T+2h',
|
||||||
|
scenarioDtm: '2024-10-27T03:30:00.000Z', svrtCd: 'CRITICAL',
|
||||||
|
gmM: 0.5, listDeg: 20.0, trimM: 3.5, buoyancyPct: 22.0, oilRateLpm: 160.0, bmRatioPct: 86.0,
|
||||||
|
description: '격벽 관통으로 #2 Port Tank 침수 확대. 자유표면효과(FSE) 보정: GM_fluid = 0.5m. 종강도: Sagging 모멘트 86%. 침몰 위험 단계 진입.',
|
||||||
|
compartments: [
|
||||||
|
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||||
|
{ name: '#1 Port Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||||
|
{ name: '#2 Port Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||||
|
{ name: 'Engine Room', status: 'RISK', color: 'var(--orange)' },
|
||||||
|
{ name: '#3 Stbd Tank', status: 'INTACT', color: 'var(--green)' },
|
||||||
|
],
|
||||||
|
assessment: [
|
||||||
|
{ label: '복원력', value: '위기 (GM 0.5m, FSE 보정)', color: 'var(--red)' },
|
||||||
|
{ label: '유출 위험', value: '최대치 접근 (160 L/min)', color: 'var(--red)' },
|
||||||
|
{ label: '선체 강도', value: 'BM 86% — Sagging 경고', color: 'var(--red)' },
|
||||||
|
{ label: '승선인원', value: '실종 3명 발견, 2명 수색', color: 'var(--orange)' },
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{ time: '12:00', text: '#2 Port Tank 격벽 관통 침수', color: 'var(--red)' },
|
||||||
|
{ time: '12:10', text: '자유표면효과(FSE) 보정 재계산', color: 'var(--red)' },
|
||||||
|
{ time: '12:15', text: '긴급 Counter-Flooding 검토', color: 'var(--orange)' },
|
||||||
|
{ time: '12:30', text: '실종자 3명 추가 발견 구조', color: 'var(--green)' },
|
||||||
|
],
|
||||||
|
sortOrd: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenarioSn: 5, rescueOpsSn: 1, timeStep: 'T+3h',
|
||||||
|
scenarioDtm: '2024-10-27T04:30:00.000Z', svrtCd: 'HIGH',
|
||||||
|
gmM: 0.55, listDeg: 16.0, trimM: 3.2, buoyancyPct: 25.0, oilRateLpm: 140.0, bmRatioPct: 87.0,
|
||||||
|
description: 'Counter-Flooding 실시: #3 Stbd Tank에 평형수 280톤 주입, 횡경사 20°→16° 교정. 종강도: 중량 재배분으로 BM 87% 유지.',
|
||||||
|
compartments: [
|
||||||
|
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||||
|
{ name: '#1 Port Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||||
|
{ name: '#2 Port Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||||
|
{ name: 'Engine Room', status: 'RISK', color: 'var(--orange)' },
|
||||||
|
{ name: '#3 Stbd Tank', status: 'BALLASTED', color: 'var(--orange)' },
|
||||||
|
],
|
||||||
|
assessment: [
|
||||||
|
{ label: '복원력', value: '개선 중 (GM 0.55m, 경사 16°)', color: 'var(--orange)' },
|
||||||
|
{ label: '유출 위험', value: '감소 추세 (140 L/min)', color: 'var(--orange)' },
|
||||||
|
{ label: '선체 강도', value: 'BM 87% — Counter-Flooding 평가', color: 'var(--orange)' },
|
||||||
|
{ label: '구조 상황', value: '실종 2명 수색 지속', color: 'var(--orange)' },
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{ time: '12:45', text: 'Counter-Flooding — #3 Stbd 주입 개시', color: 'var(--orange)' },
|
||||||
|
{ time: '13:00', text: '평형수 280톤 주입, 경사 교정 진행', color: 'var(--cyan)' },
|
||||||
|
{ time: '13:15', text: '종강도 재계산 — 허용 범위 내', color: 'var(--cyan)' },
|
||||||
|
{ time: '13:30', text: '횡경사 16° 안정화 확인', color: 'var(--green)' },
|
||||||
|
],
|
||||||
|
sortOrd: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenarioSn: 6, rescueOpsSn: 1, timeStep: 'T+6h',
|
||||||
|
scenarioDtm: '2024-10-27T07:30:00.000Z', svrtCd: 'HIGH',
|
||||||
|
gmM: 0.7, listDeg: 12.0, trimM: 2.5, buoyancyPct: 32.0, oilRateLpm: 80.0, bmRatioPct: 90.0,
|
||||||
|
description: '수중패치 설치, 유입률 감소. GM 0.7m 회복. Trim/Stability Booklet 기준 예인 가능 최소 조건(GM≥0.5m, List≤15°) 충족.',
|
||||||
|
compartments: [
|
||||||
|
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||||
|
{ name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' },
|
||||||
|
{ name: '#2 Port Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||||
|
{ name: 'Engine Room', status: 'INTACT', color: 'var(--green)' },
|
||||||
|
{ name: '#3 Stbd Tank', status: 'BALLASTED', color: 'var(--orange)' },
|
||||||
|
],
|
||||||
|
assessment: [
|
||||||
|
{ label: '복원력', value: '개선 (GM 0.7m, 예인 가능)', color: 'var(--orange)' },
|
||||||
|
{ label: '유출 위험', value: '수중패치 효과 (80 L/min)', color: 'var(--orange)' },
|
||||||
|
{ label: '선체 강도', value: 'BM 90% — 안정 범위', color: 'var(--green)' },
|
||||||
|
{ label: '구조 상황', value: '전원 구조 완료', color: 'var(--green)' },
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{ time: '14:00', text: '수중패치 설치 작업 개시', color: 'var(--cyan)' },
|
||||||
|
{ time: '14:30', text: '수중패치 설치 완료', color: 'var(--green)' },
|
||||||
|
{ time: '15:00', text: '해상크레인 도착, 잔류유 이적 준비', color: 'var(--cyan)' },
|
||||||
|
{ time: '16:30', text: '잔류유 1차 이적 완료 (45kL)', color: 'var(--green)' },
|
||||||
|
],
|
||||||
|
sortOrd: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenarioSn: 7, rescueOpsSn: 1, timeStep: 'T+8h',
|
||||||
|
scenarioDtm: '2024-10-27T09:30:00.000Z', svrtCd: 'MEDIUM',
|
||||||
|
gmM: 0.8, listDeg: 10.0, trimM: 2.0, buoyancyPct: 38.0, oilRateLpm: 55.0, bmRatioPct: 91.0,
|
||||||
|
description: '오일붐 2중 전개, 유회수기 3대 가동. GNOME 확산 모델: 12시간 후 확산 면적 2.3km² 예측. 기계적 회수율 35%.',
|
||||||
|
compartments: [
|
||||||
|
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||||
|
{ name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' },
|
||||||
|
{ name: '#2 Port Tank', status: 'SEALED', color: 'var(--orange)' },
|
||||||
|
{ name: 'Engine Room', status: 'INTACT', color: 'var(--green)' },
|
||||||
|
{ name: '#3 Stbd Tank', status: 'BALLASTED', color: 'var(--orange)' },
|
||||||
|
],
|
||||||
|
assessment: [
|
||||||
|
{ label: '복원력', value: '안정 (GM 0.8m)', color: 'var(--orange)' },
|
||||||
|
{ label: '유출 위험', value: '방제 진행 (55 L/min, 회수 35%)', color: 'var(--orange)' },
|
||||||
|
{ label: '선체 강도', value: 'BM 91%', color: 'var(--green)' },
|
||||||
|
{ label: '방제 현황', value: '오일붐 2중, 유회수기 3대', color: 'var(--cyan)' },
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{ time: '17:00', text: '오일붐 1차 전개 (500m)', color: 'var(--cyan)' },
|
||||||
|
{ time: '17:30', text: '오일붐 2차 전개 (이중 방어선)', color: 'var(--cyan)' },
|
||||||
|
{ time: '17:45', text: '유회수기 3대 배치·가동', color: 'var(--cyan)' },
|
||||||
|
{ time: '18:30', text: 'GNOME 확산 예측 갱신', color: 'var(--orange)' },
|
||||||
|
],
|
||||||
|
sortOrd: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenarioSn: 8, rescueOpsSn: 1, timeStep: 'T+12h',
|
||||||
|
scenarioDtm: '2024-10-27T13:30:00.000Z', svrtCd: 'MEDIUM',
|
||||||
|
gmM: 0.9, listDeg: 8.0, trimM: 1.5, buoyancyPct: 45.0, oilRateLpm: 30.0, bmRatioPct: 94.0,
|
||||||
|
description: '예인 개시. 예인 저항 Rt=1/2·ρ·Cd·A·V² 기반 4,000HP급 배정. 목포항 42nm, 예인 속도 3kn, ETA 14h.',
|
||||||
|
compartments: [
|
||||||
|
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||||
|
{ name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' },
|
||||||
|
{ name: '#2 Port Tank', status: 'SEALED', color: 'var(--orange)' },
|
||||||
|
{ name: 'Engine Room', status: 'INTACT', color: 'var(--green)' },
|
||||||
|
{ name: '#3 Stbd Tank', status: 'BALLASTED', color: 'var(--orange)' },
|
||||||
|
],
|
||||||
|
assessment: [
|
||||||
|
{ label: '복원력', value: '안정 (GM 0.9m)', color: 'var(--orange)' },
|
||||||
|
{ label: '유출 위험', value: '억제 중 (30 L/min)', color: 'var(--green)' },
|
||||||
|
{ label: '선체 강도', value: 'BM 94%', color: 'var(--green)' },
|
||||||
|
{ label: '예인 상태', value: '목포항, ETA 14h, 3kn', color: 'var(--cyan)' },
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{ time: '18:00', text: '예인 접속, 예인삭 250m 전개', color: 'var(--cyan)' },
|
||||||
|
{ time: '18:30', text: '예인 개시 (목포항 방향)', color: 'var(--cyan)' },
|
||||||
|
{ time: '20:00', text: '야간 감시 체제 전환', color: 'var(--orange)' },
|
||||||
|
{ time: '22:30', text: '예인 진행률 30%, 선체 안정', color: 'var(--green)' },
|
||||||
|
],
|
||||||
|
sortOrd: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenarioSn: 9, rescueOpsSn: 1, timeStep: 'T+18h',
|
||||||
|
scenarioDtm: '2024-10-27T19:30:00.000Z', svrtCd: 'MEDIUM',
|
||||||
|
gmM: 1.0, listDeg: 5.0, trimM: 1.0, buoyancyPct: 55.0, oilRateLpm: 15.0, bmRatioPct: 96.0,
|
||||||
|
description: '예인 진행률 65%. 파랑 응답 분석(RAO): 유의파고 1.2m, 주기 6s — 횡동요 ±3° 안전 범위. 잔류 유출률 15 L/min.',
|
||||||
|
compartments: [
|
||||||
|
{ name: '#1 FP Tank', status: 'FLOODED', color: 'var(--red)' },
|
||||||
|
{ name: '#1 Port Tank', status: 'PATCHED', color: 'var(--orange)' },
|
||||||
|
{ name: '#2 Port Tank', status: 'SEALED', color: 'var(--orange)' },
|
||||||
|
{ name: 'Engine Room', status: 'INTACT', color: 'var(--green)' },
|
||||||
|
{ name: '#3 Stbd Tank', status: 'STABLE', color: 'var(--green)' },
|
||||||
|
],
|
||||||
|
assessment: [
|
||||||
|
{ label: '복원력', value: '양호 (GM 1.0m, IMO 충족)', color: 'var(--green)' },
|
||||||
|
{ label: '유출 위험', value: '미량 유출 (15 L/min)', color: 'var(--green)' },
|
||||||
|
{ label: '선체 강도', value: 'BM 96% 정상', color: 'var(--green)' },
|
||||||
|
{ label: '예인 상태', value: '진행률 65%, ETA 5.5h', color: 'var(--cyan)' },
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{ time: '00:00', text: '야간 예인 정상 진행', color: 'var(--green)' },
|
||||||
|
{ time: '02:00', text: '파랑 응답 분석 — 안전 확인', color: 'var(--green)' },
|
||||||
|
{ time: '03:00', text: '잔류유 유출률 15 L/min', color: 'var(--green)' },
|
||||||
|
{ time: '04:30', text: '목포항 VTS 통보, 입항 협의', color: 'var(--cyan)' },
|
||||||
|
],
|
||||||
|
sortOrd: 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenarioSn: 10, rescueOpsSn: 1, timeStep: 'T+24h',
|
||||||
|
scenarioDtm: '2024-10-28T01:30:00.000Z', svrtCd: 'RESOLVED',
|
||||||
|
gmM: 1.2, listDeg: 3.0, trimM: 0.5, buoyancyPct: 75.0, oilRateLpm: 5.0, bmRatioPct: 98.0,
|
||||||
|
description: '목포항 접안 완료. 잔류유 전량 이적(120kL). 최종 GM 1.2m IMO 충족, BM 98% 정상. 방제 총 회수량 85kL (회수율 71%). 상황 종료.',
|
||||||
|
compartments: [
|
||||||
|
{ name: '#1 FP Tank', status: 'SEALED', color: 'var(--orange)' },
|
||||||
|
{ name: '#1 Port Tank', status: 'SEALED', color: 'var(--orange)' },
|
||||||
|
{ name: '#2 Port Tank', status: 'SEALED', color: 'var(--orange)' },
|
||||||
|
{ name: 'Engine Room', status: 'INTACT', color: 'var(--green)' },
|
||||||
|
{ name: '#3 Stbd Tank', status: 'STABLE', color: 'var(--green)' },
|
||||||
|
],
|
||||||
|
assessment: [
|
||||||
|
{ label: '복원력', value: '안전 (GM 1.2m)', color: 'var(--green)' },
|
||||||
|
{ label: '유출 위험', value: '차단 완료', color: 'var(--green)' },
|
||||||
|
{ label: '선체 강도', value: 'BM 98% 정상', color: 'var(--green)' },
|
||||||
|
{ label: '최종 상태', value: '접안 완료, 상황 종료', color: 'var(--green)' },
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{ time: '06:00', text: '목포항 접근, 도선사 대기', color: 'var(--cyan)' },
|
||||||
|
{ time: '08:00', text: '도선사 승선, 접안 개시', color: 'var(--cyan)' },
|
||||||
|
{ time: '09:30', text: '접안 완료, 잔류유 이적선 접현', color: 'var(--green)' },
|
||||||
|
{ time: '10:30', text: '잔류유 전량 이적, 상황 종료', color: 'var(--green)' },
|
||||||
|
],
|
||||||
|
sortOrd: 10,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOCK_OPS: RescueOpsItem[] = [
|
||||||
|
{
|
||||||
|
rescueOpsSn: 1, acdntSn: 1, opsCd: 'RSC-2026-001', acdntTpCd: 'collision',
|
||||||
|
vesselNm: 'M/V SEA GUARDIAN', commanderNm: null,
|
||||||
|
lon: 126.25, lat: 37.467, locDc: '37°28\'N, 126°15\'E',
|
||||||
|
depthM: 25.0, currentDc: '2.5kn NE',
|
||||||
|
gmM: 0.8, listDeg: 15.0, trimM: 2.5, buoyancyPct: 30.0,
|
||||||
|
oilRateLpm: 100.0, bmRatioPct: 92.0,
|
||||||
|
totalCrew: 20, survivors: 15, missing: 5,
|
||||||
|
hydroData: null, gmdssData: null,
|
||||||
|
sttsCd: 'ACTIVE', regDtm: '2024-10-27T01:30:00.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════════
|
||||||
RescueScenarioView
|
RescueScenarioView
|
||||||
═══════════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════════ */
|
||||||
@ -116,14 +408,15 @@ export function RescueScenarioView() {
|
|||||||
const [sortBy, setSortBy] = useState<'time' | 'risk'>('time');
|
const [sortBy, setSortBy] = useState<'time' | 'risk'>('time');
|
||||||
const [detailView, setDetailView] = useState<DetailView>(0);
|
const [detailView, setDetailView] = useState<DetailView>(0);
|
||||||
const [newScnModalOpen, setNewScnModalOpen] = useState(false);
|
const [newScnModalOpen, setNewScnModalOpen] = useState(false);
|
||||||
|
const [guideOpen, setGuideOpen] = useState(false);
|
||||||
|
|
||||||
const loadScenarios = useCallback(async (opsSn: number) => {
|
const loadScenarios = useCallback(async (opsSn: number) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const items = await fetchRescueScenarios(opsSn);
|
const items = await fetchRescueScenarios(opsSn);
|
||||||
setApiScenarios(items);
|
setApiScenarios(items.length > 0 ? items : MOCK_SCENARIOS);
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.error('[rescue] 시나리오 조회 실패:', err);
|
setApiScenarios(MOCK_SCENARIOS);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -132,14 +425,17 @@ export function RescueScenarioView() {
|
|||||||
const loadOps = useCallback(async () => {
|
const loadOps = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const items = await fetchRescueOps();
|
const items = await fetchRescueOps();
|
||||||
setOps(items);
|
|
||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
|
setOps(items);
|
||||||
loadScenarios(items[0].rescueOpsSn);
|
loadScenarios(items[0].rescueOpsSn);
|
||||||
} else {
|
} else {
|
||||||
|
setOps(MOCK_OPS);
|
||||||
|
setApiScenarios(MOCK_SCENARIOS);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.error('[rescue] 구난 작전 목록 조회 실패:', err);
|
setOps(MOCK_OPS);
|
||||||
|
setApiScenarios(MOCK_SCENARIOS);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [loadScenarios]);
|
}, [loadScenarios]);
|
||||||
@ -229,9 +525,35 @@ export function RescueScenarioView() {
|
|||||||
>
|
>
|
||||||
+ 신규 시나리오
|
+ 신규 시나리오
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setGuideOpen((v) => !v)}
|
||||||
|
className="cursor-pointer whitespace-nowrap font-semibold text-label-2 px-[14px] py-1.5 rounded-sm"
|
||||||
|
style={{
|
||||||
|
border: '1px solid rgba(6,182,212,.15)',
|
||||||
|
background: guideOpen ? 'rgba(6,182,212,.12)' : 'transparent',
|
||||||
|
color: guideOpen ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{guideOpen ? '▴ 관리 요건' : '▾ 관리 요건'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── 시나리오 관리 요건 가이드라인 ── */}
|
||||||
|
{guideOpen && (
|
||||||
|
<div className="border-b border-stroke px-5 py-3 bg-bg-surface shrink-0">
|
||||||
|
<p className="text-label-1 font-bold mb-2">시나리오 관리 요건</p>
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{SCENARIO_MGMT_GUIDELINES.map((g, i) => (
|
||||||
|
<li key={i} className="text-caption text-fg-sub leading-relaxed flex gap-1.5">
|
||||||
|
<span className="text-color-accent shrink-0">{i + 1}.</span>
|
||||||
|
<span>{g}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Content: Left List + Right Detail ── */}
|
{/* ── Content: Left List + Right Detail ── */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* ═══ LEFT: 시나리오 목록 ═══ */}
|
{/* ═══ LEFT: 시나리오 목록 ═══ */}
|
||||||
@ -376,7 +698,7 @@ export function RescueScenarioView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* View content */}
|
{/* View content */}
|
||||||
<div className="flex-1 overflow-y-auto scrollbar-thin">
|
<div className={`flex-1 ${detailView === 2 ? 'flex flex-col overflow-hidden' : 'overflow-y-auto scrollbar-thin'}`}>
|
||||||
{/* ─── VIEW 0: 시나리오 상세 ─── */}
|
{/* ─── VIEW 0: 시나리오 상세 ─── */}
|
||||||
{detailView === 0 && selected && (
|
{detailView === 0 && selected && (
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
@ -536,37 +858,14 @@ export function RescueScenarioView() {
|
|||||||
|
|
||||||
{/* ─── VIEW 2: 지도 오버레이 ─── */}
|
{/* ─── VIEW 2: 지도 오버레이 ─── */}
|
||||||
{detailView === 2 && (
|
{detailView === 2 && (
|
||||||
<div className="p-5">
|
<ScenarioMapOverlay
|
||||||
<div className="bg-bg-card border border-stroke rounded-[10px] p-5 text-center">
|
ops={ops}
|
||||||
<div className="text-[32px] opacity-30 mb-2.5">🗺</div>
|
selectedIncident={selectedIncident}
|
||||||
<div className="text-title-4 font-bold mb-1.5">GIS 기반 시나리오 비교</div>
|
scenarios={scenarios}
|
||||||
<div className="text-label-2 text-fg-disabled leading-relaxed mb-4">
|
selectedId={selectedId}
|
||||||
선택된 시나리오의 침수 구역을 지도 위에 오버레이하여 비교합니다.
|
checked={checked}
|
||||||
</div>
|
onSelectScenario={setSelectedId}
|
||||||
<div className="flex gap-2 justify-center flex-wrap">
|
/>
|
||||||
{scenarios.map((sc) => (
|
|
||||||
<div
|
|
||||||
key={sc.id}
|
|
||||||
className="px-3 py-1.5 rounded-md text-caption"
|
|
||||||
style={{
|
|
||||||
border: `1px solid ${SEV_STYLE[sc.severity].color}40`,
|
|
||||||
background: SEV_STYLE[sc.severity].bg,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="font-bold" style={{ color: SEV_STYLE[sc.severity].color }}>
|
|
||||||
{sc.id}
|
|
||||||
</span>
|
|
||||||
<span className="text-fg-sub ml-1.5">{sc.name}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 p-[30px] bg-bg-base rounded-md border border-dashed border-stroke">
|
|
||||||
<div className="text-label-2 text-fg-disabled">
|
|
||||||
지도 뷰 영역 — 구난 분석 지도와 연동하여 침수 구역 오버레이 표시
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -578,6 +877,310 @@ export function RescueScenarioView() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══ 지도 오버레이 ═══ */
|
||||||
|
interface ScenarioMapOverlayProps {
|
||||||
|
ops: RescueOpsItem[];
|
||||||
|
selectedIncident: number;
|
||||||
|
scenarios: RescueScenario[];
|
||||||
|
selectedId: string;
|
||||||
|
checked: Set<string>;
|
||||||
|
onSelectScenario: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScenarioMapOverlay({
|
||||||
|
ops,
|
||||||
|
selectedIncident,
|
||||||
|
scenarios,
|
||||||
|
selectedId,
|
||||||
|
checked,
|
||||||
|
onSelectScenario,
|
||||||
|
}: ScenarioMapOverlayProps) {
|
||||||
|
const [popupId, setPopupId] = useState<string | null>(null);
|
||||||
|
const baseMapStyle = useBaseMapStyle();
|
||||||
|
|
||||||
|
const currentOp = ops[selectedIncident] ?? null;
|
||||||
|
const center = useMemo<[number, number]>(
|
||||||
|
() =>
|
||||||
|
currentOp?.lon != null && currentOp?.lat != null
|
||||||
|
? [currentOp.lon, currentOp.lat]
|
||||||
|
: [126.25, 37.467],
|
||||||
|
[currentOp],
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibleScenarios = useMemo(
|
||||||
|
() => scenarios.filter((s) => checked.has(s.id)),
|
||||||
|
[scenarios, checked],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selected = scenarios.find((s) => s.id === selectedId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 overflow-hidden">
|
||||||
|
{/* 시나리오 선택 바 */}
|
||||||
|
<div className="flex items-center gap-1.5 px-4 py-2 border-b border-stroke bg-bg-surface shrink-0 overflow-x-auto">
|
||||||
|
<span className="text-caption text-fg-disabled shrink-0">시나리오:</span>
|
||||||
|
{visibleScenarios.map((sc) => {
|
||||||
|
const sev = SEV_STYLE[sc.severity];
|
||||||
|
const isActive = selectedId === sc.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={sc.id}
|
||||||
|
onClick={() => onSelectScenario(sc.id)}
|
||||||
|
className="cursor-pointer shrink-0 px-2 py-1 rounded text-caption font-semibold transition-all"
|
||||||
|
style={{
|
||||||
|
border: `1.5px solid ${isActive ? sev.color : sev.color + '40'}`,
|
||||||
|
background: isActive ? sev.bg : 'transparent',
|
||||||
|
color: isActive ? sev.color : 'var(--fg-disabled)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sc.id} {sc.timeStep}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 지도 영역 */}
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Map
|
||||||
|
initialViewState={{ longitude: center[0], latitude: center[1], zoom: 11 }}
|
||||||
|
mapStyle={baseMapStyle}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
attributionControl={false}
|
||||||
|
>
|
||||||
|
{/* 사고 위치 마커 */}
|
||||||
|
{currentOp && currentOp.lon != null && currentOp.lat != null && (
|
||||||
|
<Marker longitude={currentOp.lon} latitude={currentOp.lat} anchor="center">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'rgba(239,68,68,0.25)',
|
||||||
|
border: '2px solid var(--color-danger)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--color-danger)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Marker>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 시나리오별 마커 — 사고 지점 주변에 시간 순서대로 배치 */}
|
||||||
|
{visibleScenarios.map((sc, idx) => {
|
||||||
|
const angle = (idx / visibleScenarios.length) * Math.PI * 2 - Math.PI / 2;
|
||||||
|
const radius = 0.015 + idx * 0.003;
|
||||||
|
const lng = center[0] + Math.cos(angle) * radius;
|
||||||
|
const lat = center[1] + Math.sin(angle) * radius * 0.8;
|
||||||
|
const sev = SEV_STYLE[sc.severity];
|
||||||
|
const isActive = selectedId === sc.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
key={sc.id}
|
||||||
|
longitude={lng}
|
||||||
|
latitude={lat}
|
||||||
|
anchor="center"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.originalEvent.stopPropagation();
|
||||||
|
onSelectScenario(sc.id);
|
||||||
|
setPopupId(popupId === sc.id ? null : sc.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="cursor-pointer flex items-center justify-center transition-transform"
|
||||||
|
style={{
|
||||||
|
width: isActive ? 36 : 28,
|
||||||
|
height: isActive ? 36 : 28,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: sev.bg,
|
||||||
|
border: `2px solid ${sev.color}`,
|
||||||
|
transform: isActive ? 'scale(1.15)' : 'scale(1)',
|
||||||
|
boxShadow: isActive ? `0 0 12px ${sev.color}60` : 'none',
|
||||||
|
zIndex: isActive ? 10 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-bold font-mono"
|
||||||
|
style={{ fontSize: isActive ? 11 : 9, color: sev.color }}
|
||||||
|
>
|
||||||
|
{sc.timeStep.replace('T+', '')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 팝업 — 클릭한 시나리오 정보 표출 */}
|
||||||
|
{popupId &&
|
||||||
|
(() => {
|
||||||
|
const sc = visibleScenarios.find((s) => s.id === popupId);
|
||||||
|
if (!sc) return null;
|
||||||
|
const idx = visibleScenarios.indexOf(sc);
|
||||||
|
const angle = (idx / visibleScenarios.length) * Math.PI * 2 - Math.PI / 2;
|
||||||
|
const radius = 0.015 + idx * 0.003;
|
||||||
|
const lng = center[0] + Math.cos(angle) * radius;
|
||||||
|
const lat = center[1] + Math.sin(angle) * radius * 0.8;
|
||||||
|
const sev = SEV_STYLE[sc.severity];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popup
|
||||||
|
longitude={lng}
|
||||||
|
latitude={lat}
|
||||||
|
anchor="bottom"
|
||||||
|
closeOnClick={false}
|
||||||
|
onClose={() => setPopupId(null)}
|
||||||
|
maxWidth="320px"
|
||||||
|
className="rescue-map-popup"
|
||||||
|
>
|
||||||
|
<div style={{ padding: '8px 4px', minWidth: 260, background: 'var(--bg-card)', color: 'var(--fg)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
|
||||||
|
<span style={{ fontWeight: 800, fontFamily: 'monospace', color: sev.color, fontSize: 13 }}>
|
||||||
|
{sc.id}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 12 }}>{sc.timeStep}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: 'auto',
|
||||||
|
padding: '1px 6px',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
background: sev.bg,
|
||||||
|
color: sev.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sev.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--fg-sub)', lineHeight: 1.5, marginBottom: 6 }}>
|
||||||
|
{sc.description}
|
||||||
|
</div>
|
||||||
|
{/* KPI */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 4, marginBottom: 6 }}>
|
||||||
|
{[
|
||||||
|
{ label: 'GM', value: `${sc.gm}m`, color: gmColor(parseFloat(sc.gm)) },
|
||||||
|
{ label: '횡경사', value: `${sc.list}°`, color: listColor(parseFloat(sc.list)) },
|
||||||
|
{ label: '부력', value: `${sc.buoyancy}%`, color: buoyColor(sc.buoyancy) },
|
||||||
|
{ label: '유출', value: sc.oilRate.split(' ')[0], color: oilColor(parseFloat(sc.oilRate)) },
|
||||||
|
].map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.label}
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '3px 2px',
|
||||||
|
borderRadius: 3,
|
||||||
|
background: 'var(--bg-base)',
|
||||||
|
fontSize: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: 'var(--fg-disabled)' }}>{m.label}</div>
|
||||||
|
<div style={{ fontWeight: 700, fontFamily: 'monospace', color: m.color }}>{m.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* 구획 상태 */}
|
||||||
|
{sc.compartments.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--fg-disabled)', marginBottom: 3 }}>
|
||||||
|
구획 상태
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
|
||||||
|
{sc.compartments.map((c) => (
|
||||||
|
<span
|
||||||
|
key={c.name}
|
||||||
|
style={{
|
||||||
|
fontSize: 9,
|
||||||
|
padding: '1px 5px',
|
||||||
|
borderRadius: 3,
|
||||||
|
border: `1px solid ${c.color}40`,
|
||||||
|
color: c.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.name}: {c.status}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Map>
|
||||||
|
|
||||||
|
{/* 좌측 하단 — 선택된 시나리오 요약 오버레이 */}
|
||||||
|
{selected && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-3 left-3 z-10 rounded-lg border border-stroke overflow-hidden"
|
||||||
|
style={{ background: 'rgba(15,23,42,0.92)', width: 280, backdropFilter: 'blur(8px)' }}
|
||||||
|
>
|
||||||
|
<div className="px-3 py-2 border-b border-stroke flex items-center gap-2">
|
||||||
|
<span className="font-bold font-mono text-color-accent text-label-2">{selected.id}</span>
|
||||||
|
<span className="text-caption font-bold">{selected.timeStep}</span>
|
||||||
|
<span
|
||||||
|
className="ml-auto px-1.5 py-0.5 rounded-md text-caption font-bold"
|
||||||
|
style={{ background: SEV_STYLE[selected.severity].bg, color: SEV_STYLE[selected.severity].color }}
|
||||||
|
>
|
||||||
|
{SEV_STYLE[selected.severity].label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<div className="grid grid-cols-4 gap-1 font-mono text-caption mb-2">
|
||||||
|
{[
|
||||||
|
{ label: 'GM', value: `${selected.gm}m`, color: gmColor(parseFloat(selected.gm)) },
|
||||||
|
{ label: '횡경사', value: `${selected.list}°`, color: listColor(parseFloat(selected.list)) },
|
||||||
|
{ label: '부력', value: `${selected.buoyancy}%`, color: buoyColor(selected.buoyancy) },
|
||||||
|
{ label: '유출', value: selected.oilRate.split(' ')[0], color: oilColor(parseFloat(selected.oilRate)) },
|
||||||
|
].map((m) => (
|
||||||
|
<div key={m.label} className="text-center p-1 bg-bg-base rounded">
|
||||||
|
<div className="text-fg-disabled" style={{ fontSize: 9 }}>{m.label}</div>
|
||||||
|
<div className="font-bold" style={{ color: m.color, fontSize: 11 }}>{m.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-caption text-fg-sub leading-relaxed" style={{ fontSize: 10 }}>
|
||||||
|
{selected.description.slice(0, 120)}
|
||||||
|
{selected.description.length > 120 ? '...' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 우측 상단 — 범례 */}
|
||||||
|
<div
|
||||||
|
className="absolute top-3 right-3 z-10 rounded-lg border border-stroke px-3 py-2"
|
||||||
|
style={{ background: 'rgba(15,23,42,0.88)', backdropFilter: 'blur(8px)' }}
|
||||||
|
>
|
||||||
|
<div className="text-caption font-bold text-fg-disabled mb-1.5">시나리오 범례</div>
|
||||||
|
{(['CRITICAL', 'HIGH', 'MEDIUM', 'RESOLVED'] as Severity[]).map((sev) => (
|
||||||
|
<div key={sev} className="flex items-center gap-1.5 mb-0.5">
|
||||||
|
<span
|
||||||
|
className="inline-block rounded-full"
|
||||||
|
style={{ width: 8, height: 8, background: SEV_COLOR[sev] }}
|
||||||
|
/>
|
||||||
|
<span className="text-caption text-fg-sub">{SEV_STYLE[sev].label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-1.5 mt-1 pt-1 border-t border-stroke">
|
||||||
|
<span
|
||||||
|
className="inline-block rounded-full"
|
||||||
|
style={{ width: 8, height: 8, background: 'var(--color-danger)', border: '1px solid var(--color-danger)' }}
|
||||||
|
/>
|
||||||
|
<span className="text-caption text-fg-sub">사고 위치</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ═══ 신규 시나리오 생성 모달 ═══ */
|
/* ═══ 신규 시나리오 생성 모달 ═══ */
|
||||||
function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: () => void }) {
|
function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: () => void }) {
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
@ -1048,14 +1651,14 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-5 py-2.5 rounded-md border border-stroke bg-bg-card text-fg-sub text-xs font-semibold cursor-pointer"
|
className="px-5 py-2.5 rounded-md border border-stroke bg-bg-card text-fg-sub text-caption font-semibold cursor-pointer"
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</button>
|
</button>
|
||||||
{done ? (
|
{done ? (
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-7 py-2.5 rounded-md border-none text-static-white text-xs font-bold cursor-pointer bg-color-success"
|
className="px-7 py-2.5 rounded-md border-none text-static-white text-caption font-bold cursor-pointer bg-color-success"
|
||||||
>
|
>
|
||||||
✅ 생성 완료 — 닫기
|
✅ 생성 완료 — 닫기
|
||||||
</button>
|
</button>
|
||||||
@ -1063,7 +1666,7 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
|
|||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className={`px-7 py-2.5 rounded-md border-none text-xs font-bold ${
|
className={`px-7 py-2.5 rounded-md border-none text-caption font-bold ${
|
||||||
submitting
|
submitting
|
||||||
? 'bg-bg-card text-fg-disabled cursor-wait'
|
? 'bg-bg-card text-fg-disabled cursor-wait'
|
||||||
: 'bg-color-navy text-static-white cursor-pointer hover:bg-color-navy-hover'
|
: 'bg-color-navy text-static-white cursor-pointer hover:bg-color-navy-hover'
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
import { Fragment, useState, useEffect, useCallback } from 'react';
|
import { Fragment, useState, useEffect, useCallback } from 'react';
|
||||||
import { useSubMenu } from '@common/hooks/useSubMenu';
|
import { useSubMenu } from '@common/hooks/useSubMenu';
|
||||||
|
import { MapView } from '@common/components/map/MapView';
|
||||||
import { RescueTheoryView } from './RescueTheoryView';
|
import { RescueTheoryView } from './RescueTheoryView';
|
||||||
import { RescueScenarioView } from './RescueScenarioView';
|
import { RescueScenarioView } from './RescueScenarioView';
|
||||||
import { fetchRescueOps } from '../services/rescueApi';
|
import { fetchRescueOps } from '../services/rescueApi';
|
||||||
import type { RescueOpsItem } from '../services/rescueApi';
|
import type { RescueOpsItem } from '../services/rescueApi';
|
||||||
|
import { fetchIncidentsRaw } from '@tabs/incidents/services/incidentsApi';
|
||||||
|
import type { IncidentListItem } from '@tabs/incidents/services/incidentsApi';
|
||||||
|
|
||||||
/* ─── Types ─── */
|
/* ─── Types ─── */
|
||||||
type AccidentType =
|
type AccidentType =
|
||||||
@ -221,12 +224,145 @@ function TopInfoBar({ activeType }: { activeType: AccidentType }) {
|
|||||||
function LeftPanel({
|
function LeftPanel({
|
||||||
activeType,
|
activeType,
|
||||||
onTypeChange,
|
onTypeChange,
|
||||||
|
incidents,
|
||||||
|
selectedAcdnt,
|
||||||
|
onSelectAcdnt,
|
||||||
}: {
|
}: {
|
||||||
activeType: AccidentType;
|
activeType: AccidentType;
|
||||||
onTypeChange: (t: AccidentType) => void;
|
onTypeChange: (t: AccidentType) => void;
|
||||||
|
incidents: IncidentListItem[];
|
||||||
|
selectedAcdnt: IncidentListItem | null;
|
||||||
|
onSelectAcdnt: (item: IncidentListItem | null) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [acdntName, setAcdntName] = useState('');
|
||||||
|
const [acdntDate, setAcdntDate] = useState('');
|
||||||
|
const [acdntTime, setAcdntTime] = useState('');
|
||||||
|
const [acdntLat, setAcdntLat] = useState('');
|
||||||
|
const [acdntLon, setAcdntLon] = useState('');
|
||||||
|
const [showList, setShowList] = useState(false);
|
||||||
|
|
||||||
|
// 사고 선택 시 필드 자동 채움
|
||||||
|
const handlePickIncident = (item: IncidentListItem) => {
|
||||||
|
onSelectAcdnt(item);
|
||||||
|
setAcdntName(item.acdntNm);
|
||||||
|
const dt = new Date(item.occrnDtm);
|
||||||
|
setAcdntDate(
|
||||||
|
`${dt.getFullYear()}. ${String(dt.getMonth() + 1).padStart(2, '0')}. ${String(dt.getDate()).padStart(2, '0')}.`,
|
||||||
|
);
|
||||||
|
setAcdntTime(
|
||||||
|
`${dt.getHours() >= 12 ? '오후' : '오전'} ${String(dt.getHours() % 12 || 12).padStart(2, '0')}:${String(dt.getMinutes()).padStart(2, '0')}`,
|
||||||
|
);
|
||||||
|
setAcdntLat(String(item.lat));
|
||||||
|
setAcdntLon(String(item.lng));
|
||||||
|
setShowList(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-[208px] min-w-[208px] bg-bg-base border-r border-stroke flex flex-col overflow-y-auto scrollbar-thin p-2 gap-0.5">
|
<div className="w-[208px] min-w-[208px] bg-bg-base border-r border-stroke flex flex-col overflow-y-auto scrollbar-thin p-2 gap-0.5">
|
||||||
|
{/* ── 사고 기본정보 ── */}
|
||||||
|
<div className="text-caption font-bold text-fg-disabled font-korean mb-0.5 tracking-wider">
|
||||||
|
사고 기본정보
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 사고명 직접 입력 */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="사고명 직접 입력"
|
||||||
|
value={acdntName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setAcdntName(e.target.value);
|
||||||
|
if (selectedAcdnt) onSelectAcdnt(null);
|
||||||
|
}}
|
||||||
|
className="w-full px-2 py-1.5 text-caption bg-bg-card border border-stroke rounded font-korean placeholder:text-fg-disabled/50 text-fg focus:border-[rgba(6,182,212,0.5)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 또는 사고 리스트에서 선택 */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowList(!showList)}
|
||||||
|
className="w-full px-2 py-1.5 text-caption bg-bg-card border border-stroke rounded font-korean text-left cursor-pointer hover:border-[var(--stroke-light)] flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span className={selectedAcdnt ? 'text-fg' : 'text-fg-disabled/50'}>
|
||||||
|
{selectedAcdnt ? selectedAcdnt.acdntCd : '또는 사고 리스트에서 선택'}
|
||||||
|
</span>
|
||||||
|
<span className="text-fg-disabled text-[10px]">{showList ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
{showList && (
|
||||||
|
<div className="absolute left-0 right-0 top-full mt-0.5 z-30 bg-bg-card border border-stroke rounded shadow-lg max-h-[200px] overflow-y-auto scrollbar-thin">
|
||||||
|
{incidents.length === 0 && (
|
||||||
|
<div className="px-2 py-3 text-caption text-fg-disabled text-center font-korean">
|
||||||
|
사고 데이터 없음
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{incidents.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.acdntSn}
|
||||||
|
onClick={() => handlePickIncident(item)}
|
||||||
|
className="w-full text-left px-2 py-1.5 text-caption font-korean hover:bg-bg-surface cursor-pointer border-b border-stroke last:border-b-0"
|
||||||
|
>
|
||||||
|
<div className="text-fg font-semibold truncate">{item.acdntNm}</div>
|
||||||
|
<div className="text-fg-disabled text-[10px]">
|
||||||
|
{item.acdntCd} · {item.regionNm}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 사고 발생 일시 */}
|
||||||
|
<div className="text-[10px] text-fg-disabled font-korean mt-1">사고 발생 일시</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="2026. 04. 11."
|
||||||
|
value={acdntDate}
|
||||||
|
onChange={(e) => setAcdntDate(e.target.value)}
|
||||||
|
className="flex-1 min-w-0 px-1.5 py-1 text-caption bg-bg-card border border-stroke rounded font-mono text-fg placeholder:text-fg-disabled/50 focus:border-[rgba(6,182,212,0.5)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="오후 03:42"
|
||||||
|
value={acdntTime}
|
||||||
|
onChange={(e) => setAcdntTime(e.target.value)}
|
||||||
|
className="flex-1 min-w-0 px-1.5 py-1 text-caption bg-bg-card border border-stroke rounded font-mono text-fg placeholder:text-fg-disabled/50 focus:border-[rgba(6,182,212,0.5)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 위도 / 경도 */}
|
||||||
|
<div className="flex gap-1 mt-0.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="위도"
|
||||||
|
value={acdntLat}
|
||||||
|
onChange={(e) => setAcdntLat(e.target.value)}
|
||||||
|
className="flex-1 min-w-0 px-1.5 py-1 text-caption bg-bg-card border border-stroke rounded font-mono text-fg placeholder:text-fg-disabled/50 focus:border-[rgba(6,182,212,0.5)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="경도"
|
||||||
|
value={acdntLon}
|
||||||
|
onChange={(e) => setAcdntLon(e.target.value)}
|
||||||
|
className="flex-1 min-w-0 px-1.5 py-1 text-caption bg-bg-card border border-stroke rounded font-mono text-fg placeholder:text-fg-disabled/50 focus:border-[rgba(6,182,212,0.5)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="shrink-0 px-1.5 py-1 text-[10px] font-bold rounded cursor-pointer"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(239,68,68,0.15)',
|
||||||
|
color: 'var(--color-danger)',
|
||||||
|
border: '1px solid rgba(239,68,68,0.3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
지도
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-fg-disabled font-korean text-center mb-1">
|
||||||
|
지도에서 위치를 선택하세요
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구분선 */}
|
||||||
|
<div className="border-t border-stroke my-1" />
|
||||||
|
|
||||||
{/* 사고유형 제목 */}
|
{/* 사고유형 제목 */}
|
||||||
<div className="text-caption font-bold text-fg-disabled font-korean mb-0.5 tracking-wider">
|
<div className="text-caption font-bold text-fg-disabled font-korean mb-0.5 tracking-wider">
|
||||||
사고 유형 (INCIDENT TYPE)
|
사고 유형 (INCIDENT TYPE)
|
||||||
@ -290,191 +426,6 @@ function LeftPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ─── 중앙 지도 영역 ─── */
|
/* ─── 중앙 지도 영역 ─── */
|
||||||
function CenterMap({ activeType }: { activeType: AccidentType }) {
|
|
||||||
const d = rscTypeData[activeType];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex-1 relative overflow-hidden bg-bg-base">
|
|
||||||
{/* 해양 배경 그라데이션 */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
'radial-gradient(ellipse at 30% 40%, rgba(6,90,130,.25) 0%, transparent 60%), radial-gradient(ellipse at 70% 60%, rgba(8,60,100,.2) 0%, transparent 50%), linear-gradient(180deg, #0a1628, #0d1f35 50%, #091520)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* 격자 */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0"
|
|
||||||
style={{
|
|
||||||
backgroundImage:
|
|
||||||
'linear-gradient(rgba(30,60,100,.12) 1px, transparent 1px), linear-gradient(90deg, rgba(30,60,100,.12) 1px, transparent 1px)',
|
|
||||||
backgroundSize: '80px 80px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* 해안선 힌트 */}
|
|
||||||
<div
|
|
||||||
className="absolute right-0 top-0 w-[55%] h-full"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
'linear-gradient(225deg, rgba(30,50,70,.55), rgba(20,35,50,.25) 35%, transparent 55%)',
|
|
||||||
clipPath:
|
|
||||||
'polygon(60% 0%, 65% 5%, 70% 12%, 72% 20%, 68% 30%, 65% 40%, 60% 50%, 55% 58%, 50% 65%, 45% 72%, 42% 80%, 48% 88%, 100% 100%, 100% 0%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 사고 해역 정보 */}
|
|
||||||
<div className="absolute top-2.5 left-2.5 z-20 bg-[var(--dropdown-bg)] border border-stroke rounded-md px-3 py-2 backdrop-blur-sm font-mono text-caption text-fg-disabled">
|
|
||||||
<div className="text-label-2 font-bold text-fg font-korean mb-1">사고 해역 정보</div>
|
|
||||||
<div className="grid gap-x-1.5 gap-y-px" style={{ gridTemplateColumns: '32px 1fr' }}>
|
|
||||||
<span>위치</span>
|
|
||||||
<b className="text-fg">37°28'N, 126°15'E</b>
|
|
||||||
<span>수심</span>
|
|
||||||
<b className="text-fg">45m</b>
|
|
||||||
<span>조류</span>
|
|
||||||
<b className="text-fg">2.5 knots NE</b>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 선박 모형 */}
|
|
||||||
<div className="absolute z-[15] top-[42%] left-[46%] -rotate-[15deg]">
|
|
||||||
<div
|
|
||||||
className="relative w-[72px] h-5"
|
|
||||||
style={{
|
|
||||||
background: 'linear-gradient(90deg, #4a3728, #6b4c33)',
|
|
||||||
borderRadius: '3px 10px 10px 3px',
|
|
||||||
border: '1px solid rgba(255,150,50,.4)',
|
|
||||||
boxShadow: '0 0 18px rgba(255,100,0,.2)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute w-0.5 h-[7px] bg-fg-disabled rounded-[1px]"
|
|
||||||
style={{ top: '-3px', left: '60%' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-caption text-center mt-0.5 font-mono text-[rgba(255,200,150,0.5)]">
|
|
||||||
M/V SEA GUARDIAN
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 예측 구역 원 */}
|
|
||||||
<div
|
|
||||||
className="absolute z-[5] rounded-full"
|
|
||||||
style={{
|
|
||||||
top: '32%',
|
|
||||||
left: '32%',
|
|
||||||
width: '220px',
|
|
||||||
height: '220px',
|
|
||||||
background:
|
|
||||||
'radial-gradient(circle, rgba(6,182,212,.07), rgba(6,182,212,.02) 50%, transparent 70%)',
|
|
||||||
border: '1.5px dashed rgba(6,182,212,.2)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* 구역 라벨 */}
|
|
||||||
<div className="absolute z-[6] text-caption font-korean font-semibold tracking-wider uppercase whitespace-pre-line text-[rgba(6,182,212,0.45)] top-1/2 left-[36%]">
|
|
||||||
{d.zone.replace('\\n', '\n')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SAR 자산 */}
|
|
||||||
<div className="absolute z-10 text-caption font-mono text-[rgba(200,220,255,0.35)] top-[10%] left-[42%]">
|
|
||||||
ETA 5 MIN ─
|
|
||||||
</div>
|
|
||||||
<div className="absolute z-10 text-caption font-mono text-[rgba(200,220,255,0.35)] top-[14%] left-[56%]">
|
|
||||||
ETA 15 MIN ─
|
|
||||||
</div>
|
|
||||||
<div className="absolute z-[12] text-title-3 opacity-60 top-[7%] left-[52%] -rotate-[30deg]">
|
|
||||||
🚁
|
|
||||||
</div>
|
|
||||||
<div className="absolute z-[12] text-caption font-mono text-[rgba(200,220,255,0.45)] top-[20%] left-[60%]">
|
|
||||||
6M
|
|
||||||
</div>
|
|
||||||
<div className="absolute z-[12] text-label-2 opacity-45 top-[28%] left-[54%]">🚢</div>
|
|
||||||
|
|
||||||
{/* 환경 민감 구역 */}
|
|
||||||
<div className="absolute z-10 px-3.5 py-2 rounded bg-[rgba(34,197,94,0.06)] border border-[rgba(34,197,94,0.2)] bottom-[6%] right-[6%]">
|
|
||||||
<div className="text-caption font-bold font-serif uppercase tracking-wider leading-snug text-[rgba(34,197,94,0.55)]">
|
|
||||||
ENVIRONMENTALLY SENSITIVE
|
|
||||||
<br />
|
|
||||||
AREA: AQUACULTURE FARM
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 지도 컨트롤 */}
|
|
||||||
<div className="absolute top-2.5 right-2.5 z-20 flex flex-col gap-0.5">
|
|
||||||
{['🗺', '🔍', '📐'].map((ico, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
className="w-7 h-7 bg-[rgba(13,17,23,0.85)] border border-stroke rounded text-fg-disabled text-label-1 flex items-center justify-center cursor-pointer hover:text-fg"
|
|
||||||
>
|
|
||||||
{ico}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 스케일 바 */}
|
|
||||||
<div className="absolute bottom-2.5 left-2.5 z-20 bg-[rgba(13,17,23,0.8)] border border-stroke rounded px-2.5 py-1 text-caption text-fg-disabled font-mono">
|
|
||||||
<div
|
|
||||||
className="w-[70px] h-0.5 mb-0.5"
|
|
||||||
style={{ background: 'linear-gradient(90deg, #e4e8f1 50%, var(--stroke-default) 50%)' }}
|
|
||||||
/>
|
|
||||||
5 km · Zoom: 100%
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 사고 유형 표시 */}
|
|
||||||
{/* <div className="absolute bottom-2.5 right-2.5 z-20 bg-[rgba(13,17,23,0.85)] border border-stroke rounded px-3 py-1.5">
|
|
||||||
<div className="text-caption text-fg-disabled font-korean">현재 사고 유형</div>
|
|
||||||
<div className="text-label-2 font-bold font-korean text-color-accent">
|
|
||||||
{at.icon} {at.label} ({at.eng})
|
|
||||||
</div>
|
|
||||||
</div> */}
|
|
||||||
|
|
||||||
{/* 타임라인 시뮬레이션 컨트롤 */}
|
|
||||||
<div className="absolute bottom-2.5 left-1/2 -translate-x-1/2 z-20 bg-[rgba(13,17,23,0.9)] border border-stroke rounded-md px-4 py-2 flex items-center gap-4 backdrop-blur-sm">
|
|
||||||
<div className="text-caption font-bold text-fg font-korean whitespace-nowrap">TIMELINE</div>
|
|
||||||
<div className="flex items-center gap-1.5 text-caption text-fg-disabled font-mono">
|
|
||||||
<span>[-6h]</span>
|
|
||||||
<span className="font-bold text-fg">[NOW]</span>
|
|
||||||
<span>[+6H]</span>
|
|
||||||
<span>[+12H]</span>
|
|
||||||
<span>[+24H]</span>
|
|
||||||
</div>
|
|
||||||
<div className="relative w-24 h-1.5 bg-bg-surface-hover rounded-sm">
|
|
||||||
<div
|
|
||||||
className="absolute rounded-full border-2 border-bg-0 bg-color-accent"
|
|
||||||
style={{
|
|
||||||
left: '35%',
|
|
||||||
top: '-3px',
|
|
||||||
width: '12px',
|
|
||||||
height: '12px',
|
|
||||||
boxShadow: '0 0 8px rgba(6,182,212,.4)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<button className="w-6 h-6 bg-bg-card border border-stroke rounded-full text-fg-disabled text-label-2 flex items-center justify-center cursor-pointer hover:text-fg">
|
|
||||||
⏮
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="w-8 h-8 rounded-full text-color-accent text-title-4 flex items-center justify-center cursor-pointer hover:brightness-125"
|
|
||||||
style={{
|
|
||||||
background: 'color-mix(in srgb, var(--color-accent) 15%, transparent)',
|
|
||||||
border: '1px solid color-mix(in srgb, var(--color-accent) 30%, transparent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
▶
|
|
||||||
</button>
|
|
||||||
<button className="w-6 h-6 bg-bg-card border border-stroke rounded-full text-fg-disabled text-label-2 flex items-center justify-center cursor-pointer hover:text-fg">
|
|
||||||
⏭
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="text-caption text-fg-disabled font-mono whitespace-nowrap">
|
|
||||||
<b className="text-color-accent">10:45</b> KST
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── 오른쪽 분석 패널 ─── */
|
/* ─── 오른쪽 분석 패널 ─── */
|
||||||
function RightPanel({
|
function RightPanel({
|
||||||
activeAnalysis,
|
activeAnalysis,
|
||||||
@ -1572,6 +1523,44 @@ export function RescueView() {
|
|||||||
const { activeSubTab } = useSubMenu('rescue');
|
const { activeSubTab } = useSubMenu('rescue');
|
||||||
const [activeType, setActiveType] = useState<AccidentType>('collision');
|
const [activeType, setActiveType] = useState<AccidentType>('collision');
|
||||||
const [activeAnalysis, setActiveAnalysis] = useState<AnalysisTab>('rescue');
|
const [activeAnalysis, setActiveAnalysis] = useState<AnalysisTab>('rescue');
|
||||||
|
const [incidents, setIncidents] = useState<IncidentListItem[]>([]);
|
||||||
|
const [selectedAcdnt, setSelectedAcdnt] = useState<IncidentListItem | null>(null);
|
||||||
|
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null);
|
||||||
|
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchIncidentsRaw()
|
||||||
|
.then((items) => setIncidents(items))
|
||||||
|
.catch(() => setIncidents([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 지도 클릭 시 좌표 선택
|
||||||
|
const handleMapClick = useCallback((lon: number, lat: number) => {
|
||||||
|
setIncidentCoord({ lon, lat });
|
||||||
|
setIsSelectingLocation(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 사고 선택 시 사고유형 자동 매핑
|
||||||
|
const handleSelectAcdnt = useCallback(
|
||||||
|
(item: IncidentListItem | null) => {
|
||||||
|
setSelectedAcdnt(item);
|
||||||
|
if (item) {
|
||||||
|
const typeMap: Record<string, AccidentType> = {
|
||||||
|
collision: 'collision',
|
||||||
|
grounding: 'grounding',
|
||||||
|
turning: 'turning',
|
||||||
|
capsizing: 'capsizing',
|
||||||
|
sharpTurn: 'sharpTurn',
|
||||||
|
flooding: 'flooding',
|
||||||
|
sinking: 'sinking',
|
||||||
|
};
|
||||||
|
const mapped = typeMap[item.acdntTpCd];
|
||||||
|
if (mapped) setActiveType(mapped);
|
||||||
|
setIncidentCoord({ lon: item.lng, lat: item.lat });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
if (activeSubTab === 'list') {
|
if (activeSubTab === 'list') {
|
||||||
return (
|
return (
|
||||||
@ -1596,8 +1585,23 @@ export function RescueView() {
|
|||||||
|
|
||||||
{/* 3단 레이아웃: 사고유형 | 지도 | 분석 패널 */}
|
{/* 3단 레이아웃: 사고유형 | 지도 | 분석 패널 */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<LeftPanel activeType={activeType} onTypeChange={setActiveType} />
|
<LeftPanel
|
||||||
<CenterMap activeType={activeType} />
|
activeType={activeType}
|
||||||
|
onTypeChange={setActiveType}
|
||||||
|
incidents={incidents}
|
||||||
|
selectedAcdnt={selectedAcdnt}
|
||||||
|
onSelectAcdnt={handleSelectAcdnt}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 relative overflow-hidden">
|
||||||
|
<MapView
|
||||||
|
incidentCoord={incidentCoord ?? undefined}
|
||||||
|
isSelectingLocation={isSelectingLocation}
|
||||||
|
onMapClick={handleMapClick}
|
||||||
|
oilTrajectory={[]}
|
||||||
|
enabledLayers={new Set()}
|
||||||
|
showOverlays={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<RightPanel
|
<RightPanel
|
||||||
activeAnalysis={activeAnalysis}
|
activeAnalysis={activeAnalysis}
|
||||||
onAnalysisChange={setActiveAnalysis}
|
onAnalysisChange={setActiveAnalysis}
|
||||||
|
|||||||
@ -3,8 +3,8 @@ function DistributionView() {
|
|||||||
<div className="flex w-full h-full bg-bg-base items-center justify-center">
|
<div className="flex w-full h-full bg-bg-base items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-3xl opacity-20 mb-3">🗺</div>
|
<div className="text-3xl opacity-20 mb-3">🗺</div>
|
||||||
<div className="text-sm font-bold text-fg-sub font-korean mb-1">해양오염분포도</div>
|
<div className="text-body-2 font-bold text-fg-sub font-korean mb-1">해양오염분포도</div>
|
||||||
<div className="text-xs text-fg-disabled font-korean">
|
<div className="text-caption text-fg-disabled font-korean">
|
||||||
해양오염 분포도 기능이 준비 중입니다.
|
해양오염 분포도 기능이 준비 중입니다.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -16,6 +16,8 @@ import ScatRightPanel from './ScatRightPanel';
|
|||||||
// ═══ Main PreScatView ═══
|
// ═══ Main PreScatView ═══
|
||||||
|
|
||||||
export function PreScatView() {
|
export function PreScatView() {
|
||||||
|
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||||
|
const [rightCollapsed, setRightCollapsed] = useState(false);
|
||||||
const [segments, setSegments] = useState<ScatSegment[]>([]);
|
const [segments, setSegments] = useState<ScatSegment[]>([]);
|
||||||
const [zones, setZones] = useState<ApiZoneItem[]>([]);
|
const [zones, setZones] = useState<ApiZoneItem[]>([]);
|
||||||
const [jurisdictions, setJurisdictions] = useState<string[]>([]);
|
const [jurisdictions, setJurisdictions] = useState<string[]>([]);
|
||||||
@ -175,13 +177,13 @@ export function PreScatView() {
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full h-full bg-bg-base items-center justify-center flex-col gap-3">
|
<div className="flex w-full h-full bg-bg-base items-center justify-center flex-col gap-3">
|
||||||
<div className="text-color-danger text-sm font-korean">{error}</div>
|
<div className="text-color-danger text-body-2 font-korean">{error}</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
}}
|
}}
|
||||||
className="px-4 py-1.5 bg-color-accent text-white text-xs rounded font-korean"
|
className="px-4 py-1.5 bg-color-accent text-white text-caption rounded font-korean"
|
||||||
>
|
>
|
||||||
다시 시도
|
다시 시도
|
||||||
</button>
|
</button>
|
||||||
@ -192,36 +194,77 @@ export function PreScatView() {
|
|||||||
if (loading || !selectedSeg) {
|
if (loading || !selectedSeg) {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full h-full bg-bg-base items-center justify-center">
|
<div className="flex w-full h-full bg-bg-base items-center justify-center">
|
||||||
<div className="text-fg-sub text-sm font-korean">SCAT 데이터 로딩 중...</div>
|
<div className="text-fg-sub text-body-2 font-korean">SCAT 데이터 로딩 중...</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full h-full bg-bg-base overflow-hidden">
|
<div className="flex w-full h-full bg-bg-base overflow-hidden">
|
||||||
<ScatLeftPanel
|
{/* Left Panel */}
|
||||||
segments={segments}
|
<div className="shrink-0 overflow-hidden" style={{ width: leftCollapsed ? 0 : 340 }}>
|
||||||
zones={zones}
|
<ScatLeftPanel
|
||||||
jurisdictions={jurisdictions}
|
segments={segments}
|
||||||
offices={offices}
|
zones={zones}
|
||||||
selectedOffice={selectedOffice}
|
jurisdictions={jurisdictions}
|
||||||
onOfficeChange={setSelectedOffice}
|
offices={offices}
|
||||||
selectedSeg={selectedSeg}
|
selectedOffice={selectedOffice}
|
||||||
onSelectSeg={setSelectedSeg}
|
onOfficeChange={setSelectedOffice}
|
||||||
onOpenPopup={handleOpenPopup}
|
selectedSeg={selectedSeg}
|
||||||
jurisdictionFilter={jurisdictionFilter}
|
onSelectSeg={setSelectedSeg}
|
||||||
onJurisdictionChange={setJurisdictionFilter}
|
onOpenPopup={handleOpenPopup}
|
||||||
areaFilter={areaFilter}
|
jurisdictionFilter={jurisdictionFilter}
|
||||||
onAreaChange={setAreaFilter}
|
onJurisdictionChange={setJurisdictionFilter}
|
||||||
phaseFilter={phaseFilter}
|
areaFilter={areaFilter}
|
||||||
onPhaseChange={setPhaseFilter}
|
onAreaChange={setAreaFilter}
|
||||||
statusFilter={statusFilter}
|
phaseFilter={phaseFilter}
|
||||||
onStatusChange={setStatusFilter}
|
onPhaseChange={setPhaseFilter}
|
||||||
searchTerm={searchTerm}
|
statusFilter={statusFilter}
|
||||||
onSearchChange={setSearchTerm}
|
onStatusChange={setStatusFilter}
|
||||||
/>
|
searchTerm={searchTerm}
|
||||||
|
onSearchChange={setSearchTerm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 relative overflow-hidden">
|
||||||
|
{/* Left panel toggle button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setLeftCollapsed((v) => !v)}
|
||||||
|
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||||
|
style={{
|
||||||
|
left: 0,
|
||||||
|
width: 18,
|
||||||
|
height: 40,
|
||||||
|
background: 'var(--bg-elevated)',
|
||||||
|
border: '1px solid var(--stroke-default)',
|
||||||
|
borderLeft: 'none',
|
||||||
|
borderRadius: '0 6px 6px 0',
|
||||||
|
color: 'var(--fg-sub)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{leftCollapsed ? '▶' : '◀'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Right panel toggle button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setRightCollapsed((v) => !v)}
|
||||||
|
className="absolute z-[500] top-1/2 -translate-y-1/2 flex items-center justify-center text-[10px]"
|
||||||
|
style={{
|
||||||
|
right: 0,
|
||||||
|
width: 18,
|
||||||
|
height: 40,
|
||||||
|
background: 'var(--bg-elevated)',
|
||||||
|
border: '1px solid var(--stroke-default)',
|
||||||
|
borderRight: 'none',
|
||||||
|
borderRadius: '6px 0 0 6px',
|
||||||
|
color: 'var(--fg-sub)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rightCollapsed ? '◀' : '▶'}
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="flex-1 relative">
|
|
||||||
<ScatMap
|
<ScatMap
|
||||||
segments={segments}
|
segments={segments}
|
||||||
zones={zones}
|
zones={zones}
|
||||||
@ -237,7 +280,10 @@ export function PreScatView() {
|
|||||||
/> */}
|
/> */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScatRightPanel detail={panelDetail} loading={panelLoading} />
|
{/* Right Panel */}
|
||||||
|
<div className="shrink-0 overflow-hidden" style={{ width: rightCollapsed ? 0 : 280 }}>
|
||||||
|
<ScatRightPanel detail={panelDetail} loading={panelLoading} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{popupData && (
|
{popupData && (
|
||||||
<ScatPopup data={popupData} segCode={selectedSeg.code} onClose={handleClosePopup} />
|
<ScatPopup data={popupData} segCode={selectedSeg.code} onClose={handleClosePopup} />
|
||||||
|
|||||||
@ -156,7 +156,7 @@ function ScatLeftPanel({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-[340px] min-w-[340px] bg-bg-surface border-r border-stroke flex flex-col overflow-hidden">
|
<div className="w-full h-full min-w-0 bg-bg-surface border-r border-stroke flex flex-col overflow-hidden">
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="p-3.5 border-b border-stroke">
|
<div className="p-3.5 border-b border-stroke">
|
||||||
<div className="flex items-center gap-1.5 text-caption font-bold uppercase tracking-wider text-fg mb-3">
|
<div className="flex items-center gap-1.5 text-caption font-bold uppercase tracking-wider text-fg mb-3">
|
||||||
@ -269,7 +269,11 @@ function ScatLeftPanel({
|
|||||||
rowCount={filtered.length}
|
rowCount={filtered.length}
|
||||||
rowHeight={88}
|
rowHeight={88}
|
||||||
overscanCount={5}
|
overscanCount={5}
|
||||||
style={{ height: listHeight }}
|
style={{
|
||||||
|
height: listHeight,
|
||||||
|
scrollbarWidth: 'thin',
|
||||||
|
scrollbarColor: 'var(--stroke-default) transparent',
|
||||||
|
}}
|
||||||
rowComponent={SegRow}
|
rowComponent={SegRow}
|
||||||
rowProps={{
|
rowProps={{
|
||||||
filtered,
|
filtered,
|
||||||
|
|||||||
@ -310,7 +310,7 @@ function ScatMap({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right info cards */}
|
{/* Right info cards */}
|
||||||
<div className="absolute top-3.5 right-3.5 w-[260px] flex flex-col gap-2 z-[1000] max-h-[calc(100%-100px)] overflow-y-auto scrollbar-thin">
|
<div className="absolute top-3.5 right-8 w-[260px] flex flex-col gap-2 z-[1000] max-h-[calc(100%-100px)] overflow-y-auto scrollbar-thin">
|
||||||
{/* ESI Legend */}
|
{/* ESI Legend */}
|
||||||
<div className="bg-[color-mix(in_srgb,var(--bg-base)_92%,transparent)] backdrop-blur-xl border border-stroke rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
|
<div className="bg-[color-mix(in_srgb,var(--bg-base)_92%,transparent)] backdrop-blur-xl border border-stroke rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
|
||||||
<div className="text-caption font-bold uppercase tracking-wider text-fg-disabled mb-2.5">
|
<div className="text-caption font-bold uppercase tracking-wider text-fg-disabled mb-2.5">
|
||||||
|
|||||||
@ -182,7 +182,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
|||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => setPopTab(i)}
|
onClick={() => setPopTab(i)}
|
||||||
className={`px-5 py-3 text-xs font-semibold font-korean border-b-2 transition-colors cursor-pointer ${
|
className={`px-5 py-3 text-caption font-semibold font-korean border-b-2 transition-colors cursor-pointer ${
|
||||||
popTab === i
|
popTab === i
|
||||||
? 'text-color-success border-status-green'
|
? 'text-color-success border-status-green'
|
||||||
: 'text-fg-disabled border-transparent hover:text-fg-sub'
|
: 'text-fg-disabled border-transparent hover:text-fg-sub'
|
||||||
@ -204,7 +204,9 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
|||||||
{/* Skeleton */}
|
{/* Skeleton */}
|
||||||
{!imgLoaded && !imgError && (
|
{!imgLoaded && !imgError && (
|
||||||
<div className="w-full aspect-video bg-bg-card animate-pulse flex items-center justify-center">
|
<div className="w-full aspect-video bg-bg-card animate-pulse flex items-center justify-center">
|
||||||
<span className="text-fg-disabled text-xs font-korean">사진 로딩 중...</span>
|
<span className="text-fg-disabled text-caption font-korean">
|
||||||
|
사진 로딩 중...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<img
|
<img
|
||||||
@ -216,7 +218,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
|||||||
onError={() => setImgError(true)}
|
onError={() => setImgError(true)}
|
||||||
/>
|
/>
|
||||||
{imgError && (
|
{imgError && (
|
||||||
<div className="w-full aspect-video flex flex-col items-center justify-center text-fg-disabled text-xs font-korean">
|
<div className="w-full aspect-video flex flex-col items-center justify-center text-fg-disabled text-caption font-korean">
|
||||||
{/* <span className="text-[40px]">📷</span> */}
|
{/* <span className="text-[40px]">📷</span> */}
|
||||||
<span>사진 없음</span>
|
<span>사진 없음</span>
|
||||||
</div>
|
</div>
|
||||||
@ -266,7 +268,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
|||||||
].map(([k, v, cls], i) => (
|
].map(([k, v, cls], i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="flex justify-between py-1.5 text-xs border-b border-stroke last:border-b-0"
|
className="flex justify-between py-1.5 text-caption border-b border-stroke last:border-b-0"
|
||||||
>
|
>
|
||||||
<span className="text-fg-sub font-korean">{k}</span>
|
<span className="text-fg-sub font-korean">{k}</span>
|
||||||
<span className={`text-fg font-korean ${cls}`}>{v}</span>
|
<span className={`text-fg font-korean ${cls}`}>{v}</span>
|
||||||
@ -282,7 +284,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
|||||||
{data.sensitive.map((s, i) => (
|
{data.sensitive.map((s, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="flex justify-between py-1.5 text-xs border-b border-stroke last:border-b-0"
|
className="flex justify-between py-1.5 text-caption border-b border-stroke last:border-b-0"
|
||||||
>
|
>
|
||||||
<span className="text-fg-sub font-korean">{s.t}</span>
|
<span className="text-fg-sub font-korean">{s.t}</span>
|
||||||
<span className="text-fg font-korean">{s.v}</span>
|
<span className="text-fg font-korean">{s.v}</span>
|
||||||
@ -344,7 +346,9 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
|||||||
<div className="w-full aspect-[4/3] bg-bg-base border border-stroke rounded-md mb-4 overflow-hidden relative">
|
<div className="w-full aspect-[4/3] bg-bg-base border border-stroke rounded-md mb-4 overflow-hidden relative">
|
||||||
{!mapLoaded && (
|
{!mapLoaded && (
|
||||||
<div className="absolute inset-0 bg-bg-card animate-pulse flex items-center justify-center z-10">
|
<div className="absolute inset-0 bg-bg-card animate-pulse flex items-center justify-center z-10">
|
||||||
<span className="text-fg-disabled text-xs font-korean">지도 로딩 중...</span>
|
<span className="text-fg-disabled text-caption font-korean">
|
||||||
|
지도 로딩 중...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<PopupMap
|
<PopupMap
|
||||||
@ -390,7 +394,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
|||||||
].map(([k, v], i) => (
|
].map(([k, v], i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="flex justify-between py-1.5 text-xs border-b border-stroke last:border-b-0"
|
className="flex justify-between py-1.5 text-caption border-b border-stroke last:border-b-0"
|
||||||
>
|
>
|
||||||
<span className="text-fg-disabled font-korean">{k}</span>
|
<span className="text-fg-disabled font-korean">{k}</span>
|
||||||
<span className="text-color-success font-mono font-medium">{v}</span>
|
<span className="text-color-success font-mono font-medium">{v}</span>
|
||||||
@ -413,7 +417,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
|||||||
].map(([k, v], i) => (
|
].map(([k, v], i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="flex justify-between py-1.5 text-xs border-b border-stroke last:border-b-0"
|
className="flex justify-between py-1.5 text-caption border-b border-stroke last:border-b-0"
|
||||||
>
|
>
|
||||||
<span className="text-fg-disabled font-korean">{k}</span>
|
<span className="text-fg-disabled font-korean">{k}</span>
|
||||||
<span className="text-fg font-medium font-korean">{v}</span>
|
<span className="text-fg font-medium font-korean">{v}</span>
|
||||||
@ -441,7 +445,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
|||||||
].map((h, i) => (
|
].map((h, i) => (
|
||||||
<div key={i} className="bg-bg-card border border-stroke rounded-md p-4">
|
<div key={i} className="bg-bg-card border border-stroke rounded-md p-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs font-bold font-mono">{h.date}</span>
|
<span className="text-caption font-bold font-mono">{h.date}</span>
|
||||||
<span
|
<span
|
||||||
className={`text-caption font-bold px-2 py-0.5 rounded-lg ${
|
className={`text-caption font-bold px-2 py-0.5 rounded-lg ${
|
||||||
h.type === 'Pre-SCAT'
|
h.type === 'Pre-SCAT'
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export default function ScatRightPanel({
|
|||||||
|
|
||||||
if (!detail && !loading) {
|
if (!detail && !loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center bg-bg-surface border-l border-stroke w-[280px] min-w-[280px] h-full">
|
<div className="flex flex-col items-center justify-center bg-bg-surface border-l border-stroke w-full h-full">
|
||||||
<div className="text-3xl mb-2">🏖️</div>
|
<div className="text-3xl mb-2">🏖️</div>
|
||||||
<div className="text-center text-fg-disabled text-label-2 leading-relaxed">
|
<div className="text-center text-fg-disabled text-label-2 leading-relaxed">
|
||||||
좌측 목록에서 구간을
|
좌측 목록에서 구간을
|
||||||
@ -37,7 +37,7 @@ export default function ScatRightPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col bg-bg-surface border-l border-stroke overflow-hidden h-full w-[280px] min-w-[280px]">
|
<div className="flex flex-col bg-bg-surface border-l border-stroke overflow-hidden h-full w-full">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="px-3.5 py-2.5 border-b border-stroke shrink-0">
|
<div className="px-3.5 py-2.5 border-b border-stroke shrink-0">
|
||||||
{detail ? (
|
{detail ? (
|
||||||
@ -49,12 +49,12 @@ export default function ScatRightPanel({
|
|||||||
{detail.esi}
|
{detail.esi}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-xs font-bold truncate">{detail.name}</div>
|
<div className="text-caption font-bold truncate">{detail.name}</div>
|
||||||
<div className="text-caption text-fg-disabled">{detail.code}</div>
|
<div className="text-caption text-fg-disabled">{detail.code}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-xs text-fg-disabled">로딩 중...</div>
|
<div className="text-caption text-fg-disabled">로딩 중...</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -67,38 +67,38 @@ function ScatTimeline({ segments, currentIdx, onSeek }: ScatTimelineProps) {
|
|||||||
<div className="flex gap-1 flex-shrink-0">
|
<div className="flex gap-1 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => onSeek(0)}
|
onClick={() => onSeek(0)}
|
||||||
className="w-[34px] h-[34px] rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-bg-surface-hover text-sm"
|
className="w-[34px] h-[34px] rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-bg-surface-hover text-body-2"
|
||||||
>
|
>
|
||||||
⏮
|
⏮
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onSeek(Math.max(0, currentIdx - 1))}
|
onClick={() => onSeek(Math.max(0, currentIdx - 1))}
|
||||||
className="w-[34px] h-[34px] rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-bg-surface-hover text-sm"
|
className="w-[34px] h-[34px] rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-bg-surface-hover text-body-2"
|
||||||
>
|
>
|
||||||
◀
|
◀
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
className={`w-[34px] h-[34px] rounded-sm border flex items-center justify-center cursor-pointer text-sm ${playing ? 'bg-color-success text-black border-status-green' : 'border-stroke bg-bg-card text-fg-sub hover:bg-bg-surface-hover'}`}
|
className={`w-[34px] h-[34px] rounded-sm border flex items-center justify-center cursor-pointer text-body-2 ${playing ? 'bg-color-success text-black border-status-green' : 'border-stroke bg-bg-card text-fg-sub hover:bg-bg-surface-hover'}`}
|
||||||
>
|
>
|
||||||
{playing ? '⏸' : '▶'}
|
{playing ? '⏸' : '▶'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onSeek(Math.min(total - 1, currentIdx + 1))}
|
onClick={() => onSeek(Math.min(total - 1, currentIdx + 1))}
|
||||||
className="w-[34px] h-[34px] rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-bg-surface-hover text-sm"
|
className="w-[34px] h-[34px] rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-bg-surface-hover text-body-2"
|
||||||
>
|
>
|
||||||
▶
|
▶
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onSeek(total - 1)}
|
onClick={() => onSeek(total - 1)}
|
||||||
className="w-[34px] h-[34px] rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-bg-surface-hover text-sm"
|
className="w-[34px] h-[34px] rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-bg-surface-hover text-body-2"
|
||||||
>
|
>
|
||||||
⏭
|
⏭
|
||||||
</button>
|
</button>
|
||||||
<div className="w-2" />
|
<div className="w-2" />
|
||||||
<button
|
<button
|
||||||
onClick={cycleSpeed}
|
onClick={cycleSpeed}
|
||||||
className="w-[34px] h-[34px] rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-bg-surface-hover text-xs font-mono font-bold"
|
className="w-[34px] h-[34px] rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-bg-surface-hover text-caption font-mono font-bold"
|
||||||
>
|
>
|
||||||
{speed}×
|
{speed}×
|
||||||
</button>
|
</button>
|
||||||
@ -162,7 +162,7 @@ function ScatTimeline({ segments, currentIdx, onSeek }: ScatTimelineProps) {
|
|||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[210px]">
|
<div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[210px]">
|
||||||
<span className="text-sm font-semibold text-color-success font-mono">
|
<span className="text-body-2 font-semibold text-color-success font-mono">
|
||||||
구간 {displaySegs[currentIdx]?.code || 'S-001'} / {total}개
|
구간 {displaySegs[currentIdx]?.code || 'S-001'} / {total}개
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-3.5">
|
<div className="flex gap-3.5">
|
||||||
|
|||||||
@ -3,8 +3,8 @@ function SurveyView() {
|
|||||||
<div className="flex w-full h-full bg-bg-base items-center justify-center">
|
<div className="flex w-full h-full bg-bg-base items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-3xl opacity-20 mb-3">📋</div>
|
<div className="text-3xl opacity-20 mb-3">📋</div>
|
||||||
<div className="text-sm font-bold text-fg-sub font-korean mb-1">해안오염 조사 평가</div>
|
<div className="text-body-2 font-bold text-fg-sub font-korean mb-1">해안오염 조사 평가</div>
|
||||||
<div className="text-xs text-fg-disabled font-korean">
|
<div className="text-caption text-fg-disabled font-korean">
|
||||||
해안오염 조사 및 평가 기능이 준비 중입니다.
|
해안오염 조사 및 평가 기능이 준비 중입니다.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -33,11 +33,11 @@ export function WeatherMapControls({ center, zoom }: WeatherMapControlsProps) {
|
|||||||
<div key={tooltip} className="relative group">
|
<div key={tooltip} className="relative group">
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-stroke rounded-sm text-fg-sub flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all text-base"
|
className="w-[38px] h-[38px] bg-bg-surface border border-stroke rounded-sm shadow-md text-fg-sub flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all text-base"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-2 px-2 py-1 text-xs bg-bg-base text-fg border border-stroke rounded whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity z-20">
|
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-2 px-2 py-1 text-caption bg-bg-base text-fg border border-stroke rounded whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity z-20">
|
||||||
{tooltip}
|
{tooltip}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -33,26 +33,26 @@ interface WeatherMapOverlayProps {
|
|||||||
selectedStationId: string | null;
|
selectedStationId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 풍속에 따른 hex 색상 반환
|
// 풍속에 따른 색상 반환
|
||||||
function getWindHexColor(speed: number, isSelected: boolean): string {
|
function getWindHexColor(speed: number, isSelected: boolean): string {
|
||||||
if (isSelected) return '#06b6d4';
|
if (isSelected) return 'var(--color-accent)';
|
||||||
if (speed > 10) return '#ef4444';
|
if (speed > 10) return 'var(--color-danger)';
|
||||||
if (speed > 7) return '#f59e0b';
|
if (speed > 7) return 'var(--color-caution)';
|
||||||
return '#3b82f6';
|
return 'var(--color-info)';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 파고에 따른 hex 색상 반환
|
// 파고에 따른 색상 반환
|
||||||
function getWaveHexColor(height: number): string {
|
function getWaveHexColor(height: number): string {
|
||||||
if (height > 2.5) return '#ef4444';
|
if (height > 2.5) return 'var(--color-danger)';
|
||||||
if (height > 1.5) return '#f59e0b';
|
if (height > 1.5) return 'var(--color-caution)';
|
||||||
return '#3b82f6';
|
return 'var(--color-info)';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 수온에 따른 hex 색상 반환
|
// 수온에 따른 색상 반환
|
||||||
function getTempHexColor(temp: number): string {
|
function getTempHexColor(temp: number): string {
|
||||||
if (temp > 8) return '#ef4444';
|
if (temp > 8) return 'var(--color-danger)';
|
||||||
if (temp > 6) return '#f59e0b';
|
if (temp > 6) return 'var(--color-caution)';
|
||||||
return '#3b82f6';
|
return 'var(--color-info)';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -91,15 +91,17 @@ export function WeatherMapOverlay({
|
|||||||
width={24}
|
width={24}
|
||||||
height={24}
|
height={24}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
style={{ filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))' }}
|
style={{ filter: 'drop-shadow(0 2px 6px rgba(0,0,0,0.5))' }}
|
||||||
>
|
>
|
||||||
{/* 위쪽이 바람 방향을 나타내는 삼각형 */}
|
{/* 흰 외곽선 레이어 */}
|
||||||
<polygon points="12,2 4,22 12,16 20,22" fill={color} opacity="0.9" />
|
<polygon points="12,2 4,22 12,16 20,22" fill="white" opacity="0.9" />
|
||||||
|
{/* 색상 레이어 */}
|
||||||
|
<polygon points="12,3 5,21 12,15.5 19,21" fill={color} opacity="0.95" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
style={{ color, textShadow: '0 1px 3px rgba(0,0,0,0.8)' }}
|
className="text-caption font-bold leading-none px-1 py-px rounded-sm bg-bg-base/80"
|
||||||
className="text-xs font-bold leading-none"
|
style={{ color }}
|
||||||
>
|
>
|
||||||
{station.wind.speed.toFixed(1)}
|
{station.wind.speed.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
@ -138,7 +140,7 @@ export function WeatherMapOverlay({
|
|||||||
textShadow: '1px 1px 3px rgba(0,0,0,0.7)',
|
textShadow: '1px 1px 3px rgba(0,0,0,0.7)',
|
||||||
borderBottom: `1px solid ${isSelected ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.3)'}`,
|
borderBottom: `1px solid ${isSelected ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.3)'}`,
|
||||||
}}
|
}}
|
||||||
className="text-center text-xs font-bold pb-1 mb-0.5"
|
className="text-center text-caption font-bold pb-1 mb-0.5"
|
||||||
>
|
>
|
||||||
{station.name}
|
{station.name}
|
||||||
</div>
|
</div>
|
||||||
@ -150,7 +152,7 @@ export function WeatherMapOverlay({
|
|||||||
🌡️
|
🌡️
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-0.5">
|
<div className="flex items-baseline gap-0.5">
|
||||||
<span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
|
<span className="text-body-2 font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
|
||||||
{station.temperature.current.toFixed(1)}
|
{station.temperature.current.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-caption text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>°C</span>
|
<span className="text-caption text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>°C</span>
|
||||||
@ -164,7 +166,7 @@ export function WeatherMapOverlay({
|
|||||||
🌊
|
🌊
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-0.5">
|
<div className="flex items-baseline gap-0.5">
|
||||||
<span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
|
<span className="text-body-2 font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
|
||||||
{station.wave.height.toFixed(1)}
|
{station.wave.height.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-caption text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m</span>
|
<span className="text-caption text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m</span>
|
||||||
@ -178,7 +180,7 @@ export function WeatherMapOverlay({
|
|||||||
💨
|
💨
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-0.5">
|
<div className="flex items-baseline gap-0.5">
|
||||||
<span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
|
<span className="text-body-2 font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
|
||||||
{station.wind.speed.toFixed(1)}
|
{station.wind.speed.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-caption text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m/s</span>
|
<span className="text-caption text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m/s</span>
|
||||||
@ -205,7 +207,6 @@ export function useWeatherDeckLayers(
|
|||||||
onStationClick: (station: WeatherStation) => void,
|
onStationClick: (station: WeatherStation) => void,
|
||||||
): Layer[] {
|
): Layer[] {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const result: Layer[] = [];
|
const result: Layer[] = [];
|
||||||
|
|
||||||
// 파고 분포 ScatterplotLayer (Circle 대체, 반경 = 파고 * 15km)
|
// 파고 분포 ScatterplotLayer (Circle 대체, 반경 = 파고 * 15km)
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
interface WeatherData {
|
interface WeatherData {
|
||||||
stationName: string;
|
stationName: string;
|
||||||
location: { lat: number; lon: number };
|
location: { lat: number; lon: number };
|
||||||
@ -46,20 +48,14 @@ interface WeatherRightPanelProps {
|
|||||||
weatherData: WeatherData | null;
|
weatherData: WeatherData | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 풍속 등급 색상 */
|
/** 풍속 텍스트 색상 (2단계 — danger | accent) */
|
||||||
function windColor(speed: number): string {
|
function windTextColor(speed: number): string {
|
||||||
if (speed >= 14) return '#ef4444';
|
return speed >= 10 ? 'var(--color-danger)' : 'var(--color-accent)';
|
||||||
if (speed >= 10) return '#f97316';
|
|
||||||
if (speed >= 6) return '#eab308';
|
|
||||||
return '#22c55e';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 파고 등급 색상 */
|
/** 파고 텍스트 색상 (2단계 — danger | accent) */
|
||||||
function waveColor(height: number): string {
|
function waveTextColor(height: number): string {
|
||||||
if (height >= 3) return '#ef4444';
|
return height >= 2 ? 'var(--color-danger)' : 'var(--color-accent)';
|
||||||
if (height >= 2) return '#f97316';
|
|
||||||
if (height >= 1) return '#eab308';
|
|
||||||
return '#22c55e';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 풍향 텍스트 */
|
/** 풍향 텍스트 */
|
||||||
@ -86,13 +82,38 @@ function windDirText(deg: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col bg-bg-surface border-l border-stroke w-8 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(false)}
|
||||||
|
className="flex-1 flex flex-col items-center justify-start pt-3 gap-1 text-fg-sub hover:text-fg transition-colors"
|
||||||
|
title="패널 펼치기"
|
||||||
|
>
|
||||||
|
<span className="text-heading-2 leading-none">‹</span>
|
||||||
|
<span className="text-subtitle text-fg-disabled [writing-mode:vertical-rl]">펼치기</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!weatherData) {
|
if (!weatherData) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col bg-bg-surface border-l border-stroke overflow-hidden w-[320px] shrink-0">
|
<div className="flex flex-col bg-bg-surface border-l border-stroke overflow-hidden w-[320px] shrink-0">
|
||||||
<div className="p-6 text-center">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-stroke bg-bg-elevated">
|
||||||
<p className="text-fg-disabled text-title-4 font-korean">
|
<p className="text-fg-disabled text-title-4 font-korean">
|
||||||
지도에서 해양 지점을 클릭하세요
|
지도에서 해양 지점을 클릭하세요
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(true)}
|
||||||
|
className="shrink-0 flex items-center gap-0.5 text-fg-sub hover:text-fg transition-colors ml-2"
|
||||||
|
title="패널 접기"
|
||||||
|
>
|
||||||
|
<span className="text-subtitle text-fg-disabled">접기</span>
|
||||||
|
<span className="text-heading-2 leading-none">›</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -109,18 +130,30 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
|||||||
<div className="flex flex-col bg-bg-surface border-l border-stroke overflow-hidden w-[320px] shrink-0">
|
<div className="flex flex-col bg-bg-surface border-l border-stroke overflow-hidden w-[320px] shrink-0">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="px-4 py-3 border-b border-stroke bg-bg-elevated">
|
<div className="px-4 py-3 border-b border-stroke bg-bg-elevated">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-start justify-between">
|
||||||
<span className="text-title-4 font-bold text-color-accent font-korean">
|
<div className="flex-1 min-w-0">
|
||||||
📍 {weatherData.stationName}
|
<div className="flex items-center gap-2 mb-1">
|
||||||
</span>
|
<span className="text-title-4 font-bold text-color-accent font-korean truncate">
|
||||||
<span className="px-1.5 py-px text-label-2 rounded bg-[rgba(6,182,212,0.15)] text-color-accent font-bold">
|
📍 {weatherData.stationName}
|
||||||
기상예보관
|
</span>
|
||||||
</span>
|
<span className="px-1.5 py-px text-label-2 rounded bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)] text-color-accent font-bold shrink-0">
|
||||||
|
기상예보관
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-label-2 text-fg-disabled font-mono">
|
||||||
|
{weatherData.location.lat.toFixed(2)}°N, {weatherData.location.lon.toFixed(2)}°E ·{' '}
|
||||||
|
{weatherData.currentTime}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(true)}
|
||||||
|
className="shrink-0 flex items-center gap-0.5 text-fg-sub hover:text-fg transition-colors ml-2 mt-0.5"
|
||||||
|
title="패널 접기"
|
||||||
|
>
|
||||||
|
<span className="text-subtitle text-fg-disabled">접기</span>
|
||||||
|
<span className="text-heading-2 leading-none">›</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-label-2 text-fg-disabled font-mono">
|
|
||||||
{weatherData.location.lat.toFixed(2)}°N, {weatherData.location.lon.toFixed(2)}°E ·{' '}
|
|
||||||
{weatherData.currentTime}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 스크롤 콘텐츠 */}
|
{/* 스크롤 콘텐츠 */}
|
||||||
@ -131,13 +164,13 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
|||||||
{/* ── 핵심 지표 3칸 카드 ── */}
|
{/* ── 핵심 지표 3칸 카드 ── */}
|
||||||
<div className="grid grid-cols-3 gap-1 px-3 py-2.5">
|
<div className="grid grid-cols-3 gap-1 px-3 py-2.5">
|
||||||
<div className="text-center py-2.5 bg-bg-base border border-stroke rounded-md">
|
<div className="text-center py-2.5 bg-bg-base border border-stroke rounded-md">
|
||||||
<div className="text-[20px] font-bold font-mono" style={{ color: windColor(wSpd) }}>
|
<div className="text-[20px] font-bold font-mono" style={{ color: windTextColor(wSpd) }}>
|
||||||
{wSpd.toFixed(1)}
|
{wSpd.toFixed(1)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-label-2 text-fg-disabled font-korean">풍속 (m/s)</div>
|
<div className="text-label-2 text-fg-disabled font-korean">풍속 (m/s)</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center py-2.5 bg-bg-base border border-stroke rounded-md">
|
<div className="text-center py-2.5 bg-bg-base border border-stroke rounded-md">
|
||||||
<div className="text-[20px] font-bold font-mono" style={{ color: waveColor(wHgt) }}>
|
<div className="text-[20px] font-bold font-mono" style={{ color: waveTextColor(wHgt) }}>
|
||||||
{wHgt.toFixed(1)}
|
{wHgt.toFixed(1)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-label-2 text-fg-disabled font-korean">파고 (m)</div>
|
<div className="text-label-2 text-fg-disabled font-korean">파고 (m)</div>
|
||||||
@ -152,9 +185,7 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
|||||||
|
|
||||||
{/* ── 바람 상세 ── */}
|
{/* ── 바람 상세 ── */}
|
||||||
<div className="px-3 py-2 border-b border-stroke">
|
<div className="px-3 py-2 border-b border-stroke">
|
||||||
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">
|
<div className="text-label-2 text-fg-disabled font-korean mb-2">🌬️ 바람 현황</div>
|
||||||
🌬️ 바람 현황
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
{/* 풍향 컴파스 */}
|
{/* 풍향 컴파스 */}
|
||||||
<div className="relative w-[50px] h-[50px] shrink-0">
|
<div className="relative w-[50px] h-[50px] shrink-0">
|
||||||
@ -202,11 +233,11 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
|||||||
y1="25"
|
y1="25"
|
||||||
x2={25 + 14 * Math.sin((wind.direction * Math.PI) / 180)}
|
x2={25 + 14 * Math.sin((wind.direction * Math.PI) / 180)}
|
||||||
y2={25 - 14 * Math.cos((wind.direction * Math.PI) / 180)}
|
y2={25 - 14 * Math.cos((wind.direction * Math.PI) / 180)}
|
||||||
stroke={windColor(wSpd)}
|
stroke="var(--color-accent)"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
/>
|
/>
|
||||||
<circle cx="25" cy="25" r="3" fill={windColor(wSpd)} />
|
<circle cx="25" cy="25" r="3" fill="var(--color-accent)" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 grid grid-cols-2 gap-x-3 gap-y-1.5 text-label-2">
|
<div className="flex-1 grid grid-cols-2 gap-x-3 gap-y-1.5 text-label-2">
|
||||||
@ -222,19 +253,13 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-fg-disabled">1k 최고</span>
|
<span className="text-fg-disabled">1k 최고</span>
|
||||||
<span
|
<span className="font-mono text-title-4 text-fg">
|
||||||
className="font-mono text-title-4"
|
|
||||||
style={{ color: windColor(wind.speed_1k) }}
|
|
||||||
>
|
|
||||||
{Number(wind.speed_1k).toFixed(1)}
|
{Number(wind.speed_1k).toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-fg-disabled">3k 평균</span>
|
<span className="text-fg-disabled">3k 평균</span>
|
||||||
<span
|
<span className="font-mono text-title-4 text-fg">
|
||||||
className="font-mono text-title-4"
|
|
||||||
style={{ color: windColor(wind.speed_3k) }}
|
|
||||||
>
|
|
||||||
{Number(wind.speed_3k).toFixed(1)}
|
{Number(wind.speed_3k).toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -248,11 +273,8 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1 h-[5px] bg-bg-card rounded-full overflow-hidden">
|
<div className="flex-1 h-[5px] bg-bg-card rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full transition-all"
|
className="h-full rounded-full transition-all bg-color-accent"
|
||||||
style={{
|
style={{ width: `${Math.min((wSpd / 20) * 100, 100)}%` }}
|
||||||
width: `${Math.min((wSpd / 20) * 100, 100)}%`,
|
|
||||||
background: windColor(wSpd),
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-label-2 font-mono text-fg-disabled shrink-0">
|
<span className="text-label-2 font-mono text-fg-disabled shrink-0">
|
||||||
@ -263,24 +285,20 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
|||||||
|
|
||||||
{/* ── 파도 상세 ── */}
|
{/* ── 파도 상세 ── */}
|
||||||
<div className="px-3 py-2 border-b border-stroke">
|
<div className="px-3 py-2 border-b border-stroke">
|
||||||
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">🌊 파도</div>
|
<div className="text-label-2 text-fg-disabled font-korean mb-2">🌊 파도</div>
|
||||||
<div className="grid grid-cols-4 gap-1">
|
<div className="grid grid-cols-4 gap-1">
|
||||||
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||||||
<div className="text-title-3 font-bold font-mono" style={{ color: waveColor(wHgt) }}>
|
<div className="text-title-3 font-bold font-mono text-fg">{wHgt.toFixed(1)}m</div>
|
||||||
{wHgt.toFixed(1)}m
|
|
||||||
</div>
|
|
||||||
<div className="text-caption text-fg-disabled">유의파고</div>
|
<div className="text-caption text-fg-disabled">유의파고</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||||||
<div className="text-title-3 font-bold font-mono text-color-danger">
|
<div className="text-title-3 font-bold font-mono text-fg">
|
||||||
{wave.maxHeight.toFixed(1)}m
|
{wave.maxHeight.toFixed(1)}m
|
||||||
</div>
|
</div>
|
||||||
<div className="text-caption text-fg-disabled">최고파고</div>
|
<div className="text-caption text-fg-disabled">최고파고</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||||||
<div className="text-title-3 font-bold font-mono text-color-accent">
|
<div className="text-title-3 font-bold font-mono text-fg">{wave.period}s</div>
|
||||||
{wave.period}s
|
|
||||||
</div>
|
|
||||||
<div className="text-caption text-fg-disabled">주기</div>
|
<div className="text-caption text-fg-disabled">주기</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||||||
@ -295,7 +313,7 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
|||||||
className="h-full rounded-full transition-all"
|
className="h-full rounded-full transition-all"
|
||||||
style={{
|
style={{
|
||||||
width: `${Math.min((wHgt / 5) * 100, 100)}%`,
|
width: `${Math.min((wHgt / 5) * 100, 100)}%`,
|
||||||
background: waveColor(wHgt),
|
background: 'var(--color-accent)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -307,14 +325,10 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
|||||||
|
|
||||||
{/* ── 수온/공기 ── */}
|
{/* ── 수온/공기 ── */}
|
||||||
<div className="px-3 py-2 border-b border-stroke">
|
<div className="px-3 py-2 border-b border-stroke">
|
||||||
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">
|
<div className="text-label-2 text-fg-disabled font-korean mb-2">🌡️ 수온 · 공기</div>
|
||||||
🌡️ 수온 · 공기
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-1">
|
<div className="grid grid-cols-3 gap-1">
|
||||||
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||||||
<div className="text-title-3 font-bold font-mono text-color-accent">
|
<div className="text-title-3 font-bold font-mono text-fg">{wTemp.toFixed(1)}°</div>
|
||||||
{wTemp.toFixed(1)}°
|
|
||||||
</div>
|
|
||||||
<div className="text-caption text-fg-disabled">수온</div>
|
<div className="text-caption text-fg-disabled">수온</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||||||
@ -332,9 +346,7 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
|||||||
|
|
||||||
{/* ── 시간별 예보 ── */}
|
{/* ── 시간별 예보 ── */}
|
||||||
<div className="px-3 py-2 border-b border-stroke">
|
<div className="px-3 py-2 border-b border-stroke">
|
||||||
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">
|
<div className="text-label-2 text-fg-disabled font-korean mb-2">⏰ 시간별 예보</div>
|
||||||
⏰ 시간별 예보
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-5 gap-1">
|
<div className="grid grid-cols-5 gap-1">
|
||||||
{forecast.map((f, i) => (
|
{forecast.map((f, i) => (
|
||||||
<div
|
<div
|
||||||
@ -353,9 +365,7 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
|||||||
{/* ── 천문/조석 ── */}
|
{/* ── 천문/조석 ── */}
|
||||||
{astronomy && (
|
{astronomy && (
|
||||||
<div className="px-3 py-2 border-b border-stroke">
|
<div className="px-3 py-2 border-b border-stroke">
|
||||||
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">
|
<div className="text-label-2 text-fg-disabled font-korean mb-2">☀️ 천문 · 조석</div>
|
||||||
☀️ 천문 · 조석
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 gap-1">
|
<div className="grid grid-cols-4 gap-1">
|
||||||
{[
|
{[
|
||||||
{ icon: '🌅', label: '일출', value: astronomy.sunrise },
|
{ icon: '🌅', label: '일출', value: astronomy.sunrise },
|
||||||
@ -371,7 +381,7 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-1.5 px-2 py-1 bg-bg-base border border-stroke rounded text-label-2">
|
<div className="flex items-center gap-2 mt-1.5 px-2 py-1 bg-bg-base border border-stroke rounded text-label-2">
|
||||||
<span className="text-sm">🌓</span>
|
<span className="text-body-2">🌓</span>
|
||||||
<span className="text-fg-disabled">{astronomy.moonPhase}</span>
|
<span className="text-fg-disabled">{astronomy.moonPhase}</span>
|
||||||
<span className="ml-auto text-fg font-mono">조차 {astronomy.tidalRange}m</span>
|
<span className="ml-auto text-fg font-mono">조차 {astronomy.tidalRange}m</span>
|
||||||
</div>
|
</div>
|
||||||
@ -381,17 +391,21 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
|||||||
{/* ── 날씨 특보 ── */}
|
{/* ── 날씨 특보 ── */}
|
||||||
{alert && (
|
{alert && (
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">
|
<div className="text-label-2 text-fg-disabled font-korean mb-2">🚨 날씨 특보</div>
|
||||||
🚨 날씨 특보
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
className="px-2.5 py-2 rounded border"
|
className="px-2.5 py-2 rounded border"
|
||||||
style={{ background: 'rgba(239,68,68,.06)', borderColor: 'rgba(239,68,68,.2)' }}
|
style={{
|
||||||
|
background: 'color-mix(in srgb, var(--color-danger) 6%, transparent)',
|
||||||
|
borderColor: 'color-mix(in srgb, var(--color-danger) 20%, transparent)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 text-label-2">
|
<div className="flex items-center gap-2 text-label-2">
|
||||||
<span
|
<span
|
||||||
className="px-1.5 py-px rounded text-label-2 font-bold"
|
className="px-1.5 py-px rounded text-label-2 font-bold"
|
||||||
style={{ background: 'rgba(239,68,68,.15)', color: 'var(--color-danger)' }}
|
style={{
|
||||||
|
background: 'color-mix(in srgb, var(--color-danger) 15%, transparent)',
|
||||||
|
color: 'var(--color-danger)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
주의
|
주의
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -90,7 +90,6 @@ const WEATHER_MAP_CENTER: [number, number] = [127.8, 36.5]; // [lng, lat]
|
|||||||
const WEATHER_MAP_ZOOM = 7;
|
const WEATHER_MAP_ZOOM = 7;
|
||||||
|
|
||||||
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록)
|
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
function DeckGLOverlay({ layers }: { layers: Layer[] }) {
|
function DeckGLOverlay({ layers }: { layers: Layer[] }) {
|
||||||
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
|
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
|
||||||
overlay.setProps({ layers });
|
overlay.setProps({ layers });
|
||||||
@ -178,7 +177,7 @@ function WeatherMapInner({
|
|||||||
{/* 핀 꼬리 */}
|
{/* 핀 꼬리 */}
|
||||||
<div className="w-px h-3 bg-color-accent" />
|
<div className="w-px h-3 bg-color-accent" />
|
||||||
{/* 좌표 라벨 */}
|
{/* 좌표 라벨 */}
|
||||||
<div className="px-2 py-1 bg-bg-base/90 border border-color-accent rounded text-caption text-color-accent whitespace-nowrap backdrop-blur-sm">
|
<div className="px-2 py-1 bg-bg-base border border-color-accent rounded text-caption text-color-accent whitespace-nowrap shadow-md">
|
||||||
{clickedLocation.lat.toFixed(3)}°N {clickedLocation.lon.toFixed(3)}°E
|
{clickedLocation.lat.toFixed(3)}°N {clickedLocation.lon.toFixed(3)}°E
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -295,13 +294,13 @@ export function WeatherView() {
|
|||||||
{/* Main Map Area */}
|
{/* Main Map Area */}
|
||||||
<div className="flex-1 relative flex flex-col overflow-hidden">
|
<div className="flex-1 relative flex flex-col overflow-hidden">
|
||||||
{/* Tab Navigation */}
|
{/* Tab Navigation */}
|
||||||
<div className="flex items-center border-b border-stroke bg-bg-surface shrink-0">
|
<div className="flex items-center border-b border-stroke bg-bg-surface shrink-0 pt-2 pb-2">
|
||||||
<div className="flex items-center gap-2 px-6">
|
<div className="flex items-center gap-2 px-6">
|
||||||
{(['0', '3', '6', '9'] as TimeOffset[]).map((offset) => (
|
{(['0', '3', '6', '9'] as TimeOffset[]).map((offset) => (
|
||||||
<button
|
<button
|
||||||
key={offset}
|
key={offset}
|
||||||
onClick={() => setTimeOffset(offset)}
|
onClick={() => setTimeOffset(offset)}
|
||||||
className={`px-3 py-2 text-xs font-semibold rounded transition-all ${
|
className={`px-3 py-2 text-caption font-semibold rounded transition-all ${
|
||||||
timeOffset === offset
|
timeOffset === offset
|
||||||
? 'bg-color-accent text-bg-0'
|
? 'bg-color-accent text-bg-0'
|
||||||
: 'bg-bg-card border border-stroke text-fg-sub hover:bg-bg-surface-hover'
|
: 'bg-bg-card border border-stroke text-fg-sub hover:bg-bg-surface-hover'
|
||||||
@ -312,7 +311,7 @@ export function WeatherView() {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="flex items-center gap-2 ml-4">
|
<div className="flex items-center gap-2 ml-4">
|
||||||
<span className="text-xs text-fg-disabled">
|
<span className="text-caption text-fg-disabled">
|
||||||
{lastUpdate
|
{lastUpdate
|
||||||
? `마지막 업데이트: ${lastUpdate.toLocaleTimeString('ko-KR', {
|
? `마지막 업데이트: ${lastUpdate.toLocaleTimeString('ko-KR', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
@ -323,7 +322,7 @@ export function WeatherView() {
|
|||||||
{loading && (
|
{loading && (
|
||||||
<div className="w-4 h-4 border-2 border-color-accent border-t-transparent rounded-full animate-spin" />
|
<div className="w-4 h-4 border-2 border-color-accent border-t-transparent rounded-full animate-spin" />
|
||||||
)}
|
)}
|
||||||
{error && <span className="text-xs text-color-danger">⚠️ {error}</span>}
|
{error && <span className="text-caption text-color-danger">⚠️ {error}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -356,10 +355,7 @@ export function WeatherView() {
|
|||||||
</Map>
|
</Map>
|
||||||
|
|
||||||
{/* 레이어 컨트롤 */}
|
{/* 레이어 컨트롤 */}
|
||||||
<div
|
<div className="absolute top-4 left-4 bg-bg-surface border border-stroke rounded-md shadow-md z-10 px-2.5 py-1.5">
|
||||||
className="absolute top-4 left-4 bg-bg-surface/85 border border-stroke rounded-md backdrop-blur-sm z-10"
|
|
||||||
style={{ padding: '6px 10px' }}
|
|
||||||
>
|
|
||||||
<div className="text-caption font-semibold text-fg mb-1.5 font-korean">기상 레이어</div>
|
<div className="text-caption font-semibold text-fg mb-1.5 font-korean">기상 레이어</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||||
@ -420,17 +416,12 @@ export function WeatherView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 범례 */}
|
{/* 범례 */}
|
||||||
<div
|
<div className="absolute bottom-4 left-4 bg-bg-surface border border-stroke rounded-md shadow-md z-10 px-2.5 py-1.5 max-w-[180px]">
|
||||||
className="absolute bottom-4 left-4 bg-bg-surface/85 border border-stroke rounded-md backdrop-blur-sm z-10"
|
<div className="text-caption text-fg mb-1.5 font-korean">기상 범례</div>
|
||||||
style={{ padding: '6px 10px', maxWidth: 180 }}
|
<div className="flex flex-col gap-1.5 text-[8px]">
|
||||||
>
|
|
||||||
<div className="text-caption font-semibold text-fg mb-1.5 font-korean">기상 범례</div>
|
|
||||||
<div className="flex flex-col gap-1.5" style={{ fontSize: 8 }}>
|
|
||||||
{/* 바람 */}
|
{/* 바람 */}
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>
|
<div className="text-fg-sub mb-0.5">바람 (m/s)</div>
|
||||||
바람 (m/s)
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-px h-[6px] rounded-sm overflow-hidden mb-0.5">
|
<div className="flex items-center gap-px h-[6px] rounded-sm overflow-hidden mb-0.5">
|
||||||
<div className="flex-1 h-full" style={{ background: '#6271b7' }} />
|
<div className="flex-1 h-full" style={{ background: '#6271b7' }} />
|
||||||
<div className="flex-1 h-full" style={{ background: '#39a0f6' }} />
|
<div className="flex-1 h-full" style={{ background: '#39a0f6' }} />
|
||||||
@ -441,7 +432,7 @@ export function WeatherView() {
|
|||||||
<div className="flex-1 h-full" style={{ background: '#f05421' }} />
|
<div className="flex-1 h-full" style={{ background: '#f05421' }} />
|
||||||
<div className="flex-1 h-full" style={{ background: '#b41e46' }} />
|
<div className="flex-1 h-full" style={{ background: '#b41e46' }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-fg-disabled" style={{ fontSize: 7 }}>
|
<div className="flex justify-between text-fg-disabled text-[7px]">
|
||||||
<span>3</span>
|
<span>3</span>
|
||||||
<span>5</span>
|
<span>5</span>
|
||||||
<span>7</span>
|
<span>7</span>
|
||||||
@ -453,16 +444,14 @@ export function WeatherView() {
|
|||||||
</div>
|
</div>
|
||||||
{/* 해류 */}
|
{/* 해류 */}
|
||||||
<div className="pt-1 border-t border-stroke">
|
<div className="pt-1 border-t border-stroke">
|
||||||
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>
|
<div className="text-fg-sub mb-0.5">해류 (m/s)</div>
|
||||||
해류 (m/s)
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-px h-[6px] rounded-sm overflow-hidden mb-0.5">
|
<div className="flex items-center gap-px h-[6px] rounded-sm overflow-hidden mb-0.5">
|
||||||
<div className="flex-1 h-full" style={{ background: 'rgb(59, 130, 246)' }} />
|
<div className="flex-1 h-full" style={{ background: 'rgb(59, 130, 246)' }} />
|
||||||
<div className="flex-1 h-full" style={{ background: 'rgb(6, 182, 212)' }} />
|
<div className="flex-1 h-full" style={{ background: 'rgb(6, 182, 212)' }} />
|
||||||
<div className="flex-1 h-full" style={{ background: 'rgb(34, 197, 94)' }} />
|
<div className="flex-1 h-full" style={{ background: 'rgb(34, 197, 94)' }} />
|
||||||
<div className="flex-1 h-full" style={{ background: 'rgb(249, 115, 22)' }} />
|
<div className="flex-1 h-full" style={{ background: 'rgb(249, 115, 22)' }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-fg-disabled" style={{ fontSize: 7 }}>
|
<div className="flex justify-between text-fg-disabled text-[7px]">
|
||||||
<span>0.2</span>
|
<span>0.2</span>
|
||||||
<span>0.4</span>
|
<span>0.4</span>
|
||||||
<span>0.6</span>
|
<span>0.6</span>
|
||||||
@ -471,23 +460,18 @@ export function WeatherView() {
|
|||||||
</div>
|
</div>
|
||||||
{/* 파고 */}
|
{/* 파고 */}
|
||||||
<div className="pt-1 border-t border-stroke">
|
<div className="pt-1 border-t border-stroke">
|
||||||
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>
|
<div className="text-fg-sub mb-0.5">파고 (m)</div>
|
||||||
파고 (m)
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
<div className="w-2 h-2 rounded-full bg-color-info" />
|
||||||
<span className="text-fg-disabled"><1.5 낮음</span>
|
<span className="text-fg-disabled"><1.5 낮음</span>
|
||||||
<div className="w-2 h-2 rounded-full bg-orange-500 ml-1" />
|
<div className="w-2 h-2 rounded-full bg-color-warning ml-1" />
|
||||||
<span className="text-fg-disabled">~2.5</span>
|
<span className="text-fg-disabled">~2.5</span>
|
||||||
<div className="w-2 h-2 rounded-full bg-red-500 ml-1" />
|
<div className="w-2 h-2 rounded-full bg-color-danger ml-1" />
|
||||||
<span className="text-fg-disabled">>2.5</span>
|
<span className="text-fg-disabled">>2.5</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="mt-1 pt-1 border-t border-stroke text-fg-disabled text-[7px] font-korean">
|
||||||
className="mt-1 pt-1 border-t border-stroke text-fg-disabled font-korean"
|
|
||||||
style={{ fontSize: 7 }}
|
|
||||||
>
|
|
||||||
💡 지도 클릭 → 기상 예보 확인
|
💡 지도 클릭 → 기상 예보 확인
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
불러오는 중...
Reference in New Issue
Block a user