Compare commits

..

101 커밋

작성자 SHA1 메시지 날짜
b669b25f6e Merge pull request 'release: 2026-04-17 (320건 커밋)' (#190) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 38s
2026-04-17 13:42:36 +09:00
3b24e68547 Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-17)' (#189) from release/2026-04-17-notes into develop 2026-04-17 13:37:56 +09:00
04a205c9ec docs: 릴리즈 노트 정리 (2026-04-17) 2026-04-17 13:36:21 +09:00
c7c7bcdc45 Merge pull request 'fix: 빌드 에러 수정 및 color 토큰 Definition 팔레트 마이그레이션' (#188) from feature/fix-build-error into develop 2026-04-17 13:28:35 +09:00
846c63eae9 Merge develop into feature/fix-build-error
# Conflicts:
#	docs/RELEASE-NOTES.md
2026-04-17 13:27:53 +09:00
7de0b008c4 docs: 릴리즈 노트 업데이트 2026-04-17 13:22:25 +09:00
d07bd3e0f1 refactor(design): color 토큰 Definition 팔레트로 마이그레이션
- bg/stroke/fg grayscale을 쿨톤으로 전환 (#121418, #24272D 등)
- Primary #0099DD + Red #D61111 + Yellow #FEDA4A 적용
- ColorPaletteContent 디자인 페이지 값 동기화
- CLAUDE.md 완료된 진행 중 작업 섹션 제거
2026-04-17 13:20:28 +09:00
9a85cb545c Merge pull request 'feat(hns): HNS 물질 DB 데이터 확장 및 임포트 스크립트 개선' (#187) from feature/hns-substance-db-expansion into develop 2026-04-17 11:12:13 +09:00
4a730d1582 docs: 릴리즈 노트 업데이트 2026-04-17 11:10:44 +09:00
1980463904 Merge remote-tracking branch 'origin/develop' into feature/hns-substance-db-expansion 2026-04-17 11:08:47 +09:00
26b86a5a4b feat(hns): HNS 물질 DB 데이터 확장 및 임포트 스크립트 개선 2026-04-17 11:00:46 +09:00
2ee4df5afb Merge pull request 'fix: 빌드 에러 수정 - 타입 import 정리 및 미사용 코드 제거' (#186) from feature/fix-build-error into develop 2026-04-17 10:58:42 +09:00
784b36e69b docs: 릴리즈 노트 업데이트 2026-04-17 10:56:45 +09:00
c5dc5c60c5 fix: 빌드 에러 수정 - 타입 import 정리 및 미사용 코드 제거 2026-04-17 10:53:57 +09:00
9f4a578af3 Merge remote-tracking branch 'origin/develop' into feature/hns-substance-db-expansion
# Conflicts:
#	frontend/src/common/types/hns.ts
#	frontend/src/components/hns/components/HNSSubstanceView.tsx
2026-04-17 09:49:04 +09:00
1a31795970 feat(hns): HNS 물질 DB 확장 및 데이터 구조 개선 2026-04-17 09:38:06 +09:00
e31cb9b764 Merge pull request 'fix(incidents): MPA 리팩토링 누락 imports 정리' (#185) from bugfix/incidents-view-refactor-leftovers into develop 2026-04-17 08:16:02 +09:00
c46bf50348 docs: 릴리즈 노트 업데이트 2026-04-17 08:15:06 +09:00
13bda6d15b fix(incidents): MPA 리팩토링 누락 imports 정리
- IncidentsView: SplitPanelContent 중복 import 제거 (contents/ 스텁 미사용)
- predictionApi: fetchOilSpillSummary 이관 (PredictionInterface의 api 미임포트로 사실상 동작불가 함수였음)
- AnalysisSelectModal, hnsDispersionLayers: @tabs/→@components/+@interfaces/, @common/components/→@components/common/
2026-04-17 08:14:21 +09:00
650bb2b035 Merge pull request 'docs: MPA 컴포넌트 구조 반영 (tabs/ → components/ 경로 정정)' (#184) from docs/update-paths-mpa into develop 2026-04-17 07:35:21 +09:00
749453b9d1 docs: 릴리즈 노트 업데이트 2026-04-17 07:34:53 +09:00
0a9a5f433e docs: MPA 컴포넌트 구조 반영 (tabs/ → components/ 경로 정정) 2026-04-17 07:34:41 +09:00
3525e22590 Merge pull request 'release: 2026-04-17 (5건 커밋)' (#183) from develop into main
Some checks failed
Build and Deploy Wing-Demo / build-and-deploy (push) Failing after 19s
2026-04-17 07:23:52 +09:00
ee90b2efdb Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-17)' (#182) from release/2026-04-17-notes into develop 2026-04-17 07:23:31 +09:00
bdb9f95b96 docs: 릴리즈 노트 정리 (2026-04-17) 2026-04-17 07:23:17 +09:00
5987dcb991 Merge pull request 'docs: CLAUDE.md 절대 지침 추가(develop 최신화·디자인 시스템 준수)' (#181) from docs/claude-md-absolute-rules into develop 2026-04-17 07:20:59 +09:00
798002580f docs: 릴리즈 노트 업데이트 2026-04-17 07:20:12 +09:00
0c61041974 docs: CLAUDE.md 절대 지침 추가(develop 최신화·디자인 시스템 준수) 2026-04-17 07:19:26 +09:00
ea208cbf52 Merge pull request 'release: 2026-04-16 (294건 커밋)' (#180) from develop into main
Some checks failed
Build and Deploy Wing-Demo / build-and-deploy (push) Failing after 20s
2026-04-16 18:37:58 +09:00
c219adf2d9 Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-16)' (#179) from release/2026-04-16-notes into develop 2026-04-16 18:36:53 +09:00
2f5d2fdb1b docs: 릴리즈 노트 정리 (2026-04-16)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 18:35:42 +09:00
cd4717f303 docs: 릴리즈 노트 정리 (2026-04-16)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 18:33:49 +09:00
7a5028226b Merge pull request 'refactor(mpa): 탭 디렉토리를 MPA 컴포넌트 구조로 재편' (#178) from feature/mpa-develop into develop 2026-04-16 18:14:46 +09:00
a6b0e92a8e Merge branch 'develop' into feature/mpa-develop 2026-04-16 18:13:54 +09:00
765d3fb9d2 docs: 릴리즈 노트 업데이트 2026-04-16 18:04:23 +09:00
38d931db65 refactor(mpa): 탭 디렉토리를 MPA 컴포넌트 구조로 재편
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 17:38:49 +09:00
28544d5c8f Merge pull request 'feat(incidents): 통합 분석 패널 분할 뷰 및 유출유 확산 요약 API 추가' (#177) from feature/integrated-analysis-split-view into develop 2026-04-16 15:27:25 +09:00
10510a0410 docs: 릴리즈 노트 업데이트 2026-04-16 15:26:11 +09:00
1da2553694 feat(incidents): 통합 분석 패널 분할 뷰 및 유출유 확산 요약 API 추가
- Incidents 통합 분석 시 이전 분석 결과를 분할 화면으로 표출
- 유출유/HNS/구난 분석 선택 모달(AnalysisSelectModal) 추가
- prediction /analyses/:acdntSn/oil-summary API 신규 (primary + byModel)
- HNS 분석 생성 시 acdntSn 연결 지원
- GSC 사고 목록 응답에 acdntSn 노출
- 민감자원 누적/카테고리 관리 및 HNS 확산 레이어 유틸(hnsDispersionLayers) 추가
2026-04-16 15:24:06 +09:00
7fa3fa6a2e feat(hns): AEGL 등농도선 및 자동 줌/동적 도메인 추가
- 등농도선(marching squares) 레이어 추가 — AEGL-1/2/3 경계선 PathLayer 표출
- 풍속 기반 sim 도메인 동적 산정 (L = 10~50km)
- 히트맵 영역 기준 지도 자동 fit-bounds
- 분석 복원 시 spilUnitCd로 연속/순간 유출 분기
- admin 패널 전반 디자인 토큰 정리 (color-danger, accent rgba)
2026-04-16 10:30:42 +09:00
1f66723060 feat(incidents): 통합 분석 패널 HNS/구난 연동 및 사고 목록 wing.ACDNT 전환
- 우측 패널에 HNS 대기확산/긴급구난 완료 이력 목록 및 체크박스 연동
- incidents 목록에 hasHnsCompleted/hasRescueCompleted 플래그 추가
- hns/rescue 목록 API에 acdntSn 필터 추가
- /gsc/accidents 셀렉트박스 소스를 gsc.tgs_acdnt_info → wing.ACDNT 로 전환
- gsc → wing.ACDNT 동기화 마이그레이션 032 추가
2026-04-15 17:31:28 +09:00
2d6827c0a9 Merge branch 'develop' into feature/mpa-develop 2026-04-15 16:50:30 +09:00
2082e9a79b refactor(map): TimelineControl 분리 및 aerial/hns 컴포넌트 개선 2026-04-15 16:49:00 +09:00
988cc47e9f Merge pull request 'release: 2026-04-15' (#176) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 38s
2026-04-15 14:49:19 +09:00
0daae3c807 Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-15)' (#175) from release/2026-04-15-notes into develop 2026-04-15 14:48:12 +09:00
fa5c7f518f docs: 릴리즈 노트 정리 (2026-04-15) 2026-04-15 14:47:13 +09:00
72ead1140f Merge pull request 'feat(vessels): 실시간 선박 신호 지도 표출 및 폴링 스케줄러 추가' (#174) from feature/ship-signal-map into develop 2026-04-15 14:44:43 +09:00
938665e323 docs: 릴리즈 노트 업데이트 2026-04-15 14:43:28 +09:00
29c5293ce7 feat(vessels): 실시간 선박 신호 지도 표출 및 폴링 스케줄러 추가
- 백엔드 vessels 라우터/서비스/스케줄러 추가 (1분 주기 한국 해역 폴링)
- 공통 useVesselSignals 훅 + vesselApi/vesselSignalClient 서비스 추가
- MapView에 VesselLayer/VesselInteraction/MapBoundsTracker 통합 (호버·팝업·상세 모달)
- OilSpill/HNS/Rescue/Incidents 뷰에 선박 신호 연동
- vesselMockData 정리, aerial IMAGE_API_URL 기본값 변경
2026-04-15 14:40:28 +09:00
ae0a17990b Merge pull request 'feat(prediction): GSC 외부 사고 목록 API 연동 및 셀렉트박스 자동 채움 (prediction/hns/rescue)' (#173) from feature/prediction-gsc-accident-select into develop 2026-04-15 08:25:35 +09:00
6b19d34e5b chore: 팀 워크플로우 설정 업데이트
- settings.json Bash 권한 항목 세분화
- workflow-version.json 적용일 갱신 (2026-04-14)
2026-04-15 08:22:57 +09:00
679649ab8c docs: 릴리즈 노트 업데이트 2026-04-15 08:16:09 +09:00
279dcbc0e1 chore: develop 머지 충돌 해결 2026-04-15 08:13:12 +09:00
2fe9deeabe Merge pull request 'refactor(map): MapView ������Ʈ �и� �� ��ü �� ������ �ý��� ��ū ����' (#172) from feature/mpa-develop into develop 2026-04-14 17:33:31 +09:00
388116aa88 docs: 릴리즈 노트 업데이트 2026-04-14 17:32:08 +09:00
3eb66e2e54 refactor(map): MapView 컴포넌트 분리 및 전체 탭 디자인 시스템 토큰 적용 2026-04-14 17:20:01 +09:00
15ca946a00 feat(prediction): GSC 외부 사고 목록 API 연동 및 셀렉트박스 자동 채움 (prediction/hns/rescue) 2026-04-14 17:11:38 +09:00
20d5c08bc7 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
2026-04-14 13:09:56 +09:00
8a0e5daf60 Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-14)' (#170) from release/2026-04-14-notes into develop 2026-04-14 13:08:53 +09:00
c12e747b14 docs: 릴리즈 노트 정리 (2026-04-14) 2026-04-14 13:07:30 +09:00
69b01fca9e Merge pull request 'feat(design): 디자인 시스템 폰트 업스케일 및 전체 탭 토큰 적용' (#169) from feature/design-system-etc into develop 2026-04-14 11:23:10 +09:00
fef7583eb5 chore: develop 머지 충돌 해결 2026-04-14 11:22:19 +09:00
f47aeef3ce docs: 릴리즈 노트 업데이트 2026-04-14 11:15:30 +09:00
8093727efe Merge pull request 'feat: admin-deidentify 기능 develop 머지' (#168) from merge/admin-deidentify-test into develop 2026-04-14 11:10:28 +09:00
547e83e617 refactor(design): 폰트 업스케일 토큰 적용 및 전체 탭 디자인 시스템 색상·폰트 통일 2026-04-14 11:05:28 +09:00
af4ab9dd80 docs: 릴리즈 노트 업데이트 2026-04-14 11:01:18 +09:00
28931d9a5e Merge remote-tracking branch 'origin/feature/admin-deidentify' into merge/admin-deidentify-test 2026-04-14 10:43:56 +09:00
ad24445101 Merge pull request 'release: 2026-04-13 (8�� Ŀ��)' (#167) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 38s
2026-04-13 16:53:17 +09:00
4f5260ae12 Merge pull request 'docs: ������ ��Ʈ ���� (2026-04-13)' (#166) from release/2026-04-13-notes into develop 2026-04-13 16:52:32 +09:00
9630b1daac docs: 릴리즈 노트 정리 (2026-04-13) 2026-04-13 16:51:42 +09:00
bf0de764c6 Merge pull request 'feat(incidents): �̹��� �м� ���� ��ȭ �� ���� �˾� ������' (#165) from feature/hns into develop 2026-04-13 16:49:15 +09:00
965b238b08 docs: 릴리즈 노트 업데이트 2026-04-13 16:43:33 +09:00
2640d882da feat(incidents): 이미지 분석 연동 강화 및 사고 팝업 리뉴얼
- 사고별 이미지 분석 API 및 항공 미디어 조회 연동
- 사고 마커 팝업 디자인 개선, 필터링된 사고만 지도 표시
- 이미지 분석 시 사고명 파라미터 지원, 기본 예측시간 6시간으로 변경
- 유출량 정밀도 NUMERIC(14,10) 확대 (migration 031)
- OpenDrift 유종 매핑 수정 (원유, 등유)
2026-04-13 16:41:56 +09:00
5de10662a7 feat(design): HNS·사건사고·확산예측·SCAT·기상 탭 디자인 시스템 토큰 적용 2026-04-09 18:13:10 +09:00
cc3e0c5596 Merge pull request 'release: 2026-04-09 (247건 커밋)' (#163) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 37s
2026-04-09 14:57:13 +09:00
5b36ea3991 Merge pull request 'release: 2026-04-02 (229건 커밋)' (#159) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 35s
2026-04-02 16:54:56 +09:00
5489bb0db5 Merge pull request 'release: 2026-04-01 (229건 커밋)' (#156) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 38s
2026-04-01 19:15:00 +09:00
42d749426e Merge pull request 'release: 2026-04-01 (226�� Ŀ��)' (#154) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 35s
2026-04-01 09:26:16 +09:00
7a8e2ddea1 Merge pull request 'release: 2026-04-01.2 (3�� Ŀ��)' (#152) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 35s
2026-04-01 09:03:08 +09:00
625b15e395 Merge pull request 'release: 2026-04-01 (5�� Ŀ��)' (#150) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 35s
2026-04-01 08:38:15 +09:00
c40711cae1 Merge pull request 'release: 2026-03-31 (6�� Ŀ��)' (#147) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 36s
2026-03-31 18:06:09 +09:00
2c0f43962b Merge pull request 'release: 2026-03-31 (202건 커밋)' (#144) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 37s
2026-03-31 15:13:25 +09:00
71cdc634c6 Merge pull request 'release: 2026-03-30 (202건 커밋)' (#141) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 39s
2026-03-30 15:12:52 +09:00
d71c43ae5a Merge pull request 'release: 2026-03-27.3 (5건 커밋)' (#138) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 33s
2026-03-27 17:46:46 +09:00
29477e4e2a Merge pull request 'release: 2026-03-27.2 (192�� Ŀ��)' (#135) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 33s
2026-03-27 15:40:30 +09:00
94e0837072 Merge pull request 'release: 2026-03-27 (187�� Ŀ��)' (#131) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 39s
2026-03-27 15:21:34 +09:00
ebe76176e3 Merge pull request 'release: 2026-03-26 (5건 커밋)' (#128) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 38s
2026-03-26 14:19:33 +09:00
bc7e966cb1 Merge pull request 'release: 2026-03-25 (177�� Ŀ��)' (#125) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 34s
2026-03-25 18:29:39 +09:00
6c68d04fc3 Merge pull request 'release: 2026-03-25 (11건 커밋)' (#122) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 34s
2026-03-25 16:11:07 +09:00
a55d3c18c2 Merge pull request 'release: 2026-03-24 (160건 커밋)' (#118) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 36s
2026-03-24 18:57:51 +09:00
84fa49189c Merge pull request 'release: 2026-03-20.2 (129건 커밋)' (#111) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 35s
2026-03-20 15:24:43 +09:00
5f622c7520 Merge pull request 'release: 2026-03-20 (124건 커밋)' (#108) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 34s
2026-03-20 10:38:45 +09:00
7cef385c3a Merge pull request 'release: 2026-03-19 (26건 커밋)' (#105) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 33s
2026-03-19 18:13:18 +09:00
36829b9ff4 Merge pull request 'release: 2026-03-19 (8건 커밋)' (#102) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 33s
2026-03-19 13:25:27 +09:00
16db2e1925 Merge pull request 'release: 2026-03-18 (92�� Ŀ��)' (#99) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 1m27s
2026-03-18 18:18:29 +09:00
6b9ed4e06e Merge pull request 'release: 2026-03-17 (3건 커밋)' (#96) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 1m19s
2026-03-17 18:42:00 +09:00
99c2e8d6ae Merge pull request 'release: 2026-03-16 (81건 커밋)' (#93) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 44s
2026-03-16 18:35:59 +09:00
3ad24a6e1a Merge pull request 'release: 2026-03-13 (51건 커밋)' (#90) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 39s
2026-03-13 14:58:34 +09:00
714bac9f24 Merge pull request 'release: 2026-03-11.2 (12건 커밋)' (#85) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 38s
2026-03-11 18:37:35 +09:00
ecca827098 Merge pull request 'release: 2026-03-11 (13건 커밋)' (#82) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 36s
2026-03-11 12:55:33 +09:00
dc4be29cfc Merge pull request 'develop' (#71) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 45s
Reviewed-on: #71
2026-03-06 07:38:45 +09:00
403개의 변경된 파일62528개의 추가작업 그리고 100148개의 파일을 삭제

파일 보기

@ -5,7 +5,30 @@
}, },
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(*)" "Bash(*)",
"Bash(npm run *)",
"Bash(npm install *)",
"Bash(npm test *)",
"Bash(npx *)",
"Bash(node *)",
"Bash(git status)",
"Bash(git diff *)",
"Bash(git log *)",
"Bash(git branch *)",
"Bash(git checkout *)",
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git pull *)",
"Bash(git fetch *)",
"Bash(git merge *)",
"Bash(git stash *)",
"Bash(git remote *)",
"Bash(git config *)",
"Bash(git rev-parse *)",
"Bash(git show *)",
"Bash(git tag *)",
"Bash(curl -s *)",
"Bash(fnm *)"
], ],
"deny": [ "deny": [
"Bash(git push --force*)", "Bash(git push --force*)",
@ -61,5 +84,8 @@
] ]
} }
] ]
},
"enabledPlugins": {
"frontend-design@claude-plugins-official": true
} }
} }

파일 보기

@ -1,6 +1,6 @@
{ {
"applied_global_version": "1.6.1", "applied_global_version": "1.6.1",
"applied_date": "2026-03-31", "applied_date": "2026-04-17",
"project_type": "react-ts", "project_type": "react-ts",
"gitea_url": "https://gitea.gc-si.dev", "gitea_url": "https://gitea.gc-si.dev",
"custom_pre_commit": true "custom_pre_commit": true

파일 보기

@ -3,6 +3,7 @@
# commit-msg hook # commit-msg hook
# Conventional Commits 형식 검증 (한/영 혼용 지원) # Conventional Commits 형식 검증 (한/영 혼용 지원)
#============================================================================== #==============================================================================
export LC_ALL=en_US.UTF-8 2>/dev/null || export LC_ALL=C.UTF-8 2>/dev/null || true
COMMIT_MSG_FILE="$1" COMMIT_MSG_FILE="$1"
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE") COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")

파일 보기

@ -0,0 +1,17 @@
# Records run_in_terminal and appmod-* tool calls as JSONL for the extension to process.
$raw = [Console]::In.ReadToEnd()
if ($raw -notmatch '"tool_name"\s*:\s*"([^"]+)"') { exit 0 }
$toolName = $Matches[1]
if ($toolName -ne 'run_in_terminal' -and $toolName -notlike 'appmod-*') { exit 0 }
if ($raw -notmatch '"session_id"\s*:\s*"([^"]+)"') { exit 0 }
$sessionId = $Matches[1]
$hooksDir = '.github\java-upgrade\hooks'
if (-not (Test-Path $hooksDir)) { New-Item -ItemType Directory -Path $hooksDir -Force | Out-Null }
$line = ($raw -replace '[\r\n]+', ' ').Trim() + "`n"
[System.IO.File]::AppendAllText("$hooksDir\$sessionId.json", $line, [System.Text.UTF8Encoding]::new($false))

파일 보기

@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Records run_in_terminal and appmod-* tool calls as JSONL for the extension to process.
INPUT=$(cat)
TOOL_NAME="${INPUT#*\"tool_name\":\"}"
TOOL_NAME="${TOOL_NAME%%\"*}"
case "$TOOL_NAME" in
run_in_terminal|appmod-*) ;;
*) exit 0 ;;
esac
case "$INPUT" in
*'"session_id":"'*) ;;
*) exit 0 ;;
esac
SESSION_ID="${INPUT#*\"session_id\":\"}"
SESSION_ID="${SESSION_ID%%\"*}"
[ -z "$SESSION_ID" ] && exit 0
HOOKS_DIR=".github/java-upgrade/hooks"
mkdir -p "$HOOKS_DIR"
LINE=$(printf '%s' "$INPUT" | tr -d '\r\n')
printf '%s\n' "$LINE" >> "$HOOKS_DIR/${SESSION_ID}.json"

6
.gitignore vendored
파일 보기

@ -79,6 +79,9 @@ prediction/image/**/*.pth
frontend/public/hns-manual/pages/ frontend/public/hns-manual/pages/
frontend/public/hns-manual/images/ frontend/public/hns-manual/images/
# HNS import pipeline outputs (local, 1회성 생성물)
backend/scripts/hns-import/out/
# Claude Code (team workflow tracked, override global gitignore) # Claude Code (team workflow tracked, override global gitignore)
!.claude/ !.claude/
.claude/settings.local.json .claude/settings.local.json
@ -103,3 +106,6 @@ frontend/public/hns-manual/images/
# mcp # mcp
.mcp.json .mcp.json
# python
.venv

파일 보기

@ -54,7 +54,7 @@ wing/
│ │ ├── types/ backtrack, boomLine, hns, navigation │ │ ├── types/ backtrack, boomLine, hns, navigation
│ │ ├── utils/ coordinates, geo, sanitize, cn.ts │ │ ├── utils/ coordinates, geo, sanitize, cn.ts
│ │ └── data/ layerData.ts (UI 레이어 트리) │ │ └── data/ layerData.ts (UI 레이어 트리)
│ └── tabs/ 탭 단위 패키지 (@tabs/ alias) │ └── components/ 탭 단위 패키지 (@components/ alias)
│ ├── prediction/ 확산 예측 (OilSpillView, 역추적, 오일붐) │ ├── prediction/ 확산 예측 (OilSpillView, 역추적, 오일붐)
│ ├── hns/ HNS 분석 (시나리오, 물질 DB, 재계산) │ ├── hns/ HNS 분석 (시나리오, 물질 DB, 재계산)
│ ├── rescue/ 구조 시나리오 │ ├── rescue/ 구조 시나리오
@ -96,7 +96,7 @@ wing/
### Path Alias ### Path Alias
- `@common/*` -> `src/common/*` (공통 모듈) - `@common/*` -> `src/common/*` (공통 모듈)
- `@tabs/*` -> `src/tabs/*` (탭 패키지) - `@components/*` -> `src/components/*` (탭 패키지)
## 팀 컨벤션 ## 팀 컨벤션
@ -107,6 +107,8 @@ wing/
- `naming.md` -- 네이밍 규칙 - `naming.md` -- 네이밍 규칙
- `testing.md` -- 테스트 규칙 - `testing.md` -- 테스트 규칙
- `subagent-policy.md` -- 서브에이전트 활용 정책 - `subagent-policy.md` -- 서브에이전트 활용 정책
- `design-system.md` -- AI 에이전트 UI 디자인 시스템 규칙 (영문, 실사용)
- `design-system-ko.md` -- 디자인 시스템 규칙 (한국어 참고용)
## 개발 문서 (docs/) ## 개발 문서 (docs/)
@ -125,25 +127,6 @@ wing/
- API 인터페이스 변경 시 `memory/api-types.md` 갱신 - API 인터페이스 변경 시 `memory/api-types.md` 갱신
- 개별 탭 개발자는 공통 가이드를 참조하여 연동 구현 - 개별 탭 개발자는 공통 가이드를 참조하여 연동 구현
## 진행 중 작업 (완료 후 삭제)
### 디자인 시스템 폰트+색상 통일 작업
compact 후 반드시 `memory/design-system-work.md`를 읽고 작업 상태(완료/미완료 컴포넌트)를 확인할 것.
**색상 규칙:**
- 하드코딩 색상(`#ef4444`, `#a855f7` 등) → CSS 변수 전환
- `rgba(59,130,246,...)` 등 비-accent 계열 → `rgba(6,182,212,...)` (accent cyan)
- 시맨틱 컬러(`color-accent`, `color-info`, `color-caution` 등)는 다양하게 사용 가능하되, 강조 색상은 **최대 2가지**로 제한
- `linear-gradient` → 단색으로 단순화
- 장식용 `border-top`, `border-left` → 제거 여부를 유저에게 확인 후 진행
**폰트 규칙:**
- 하드코딩 `fontSize`/`fontWeight` → Tailwind 토큰 (`text-title-2`, `text-caption` 등)
- `fontFamily: monospace``var(--font-mono)`
- `fontFamily: sans-serif` / `'Noto Sans KR'``var(--font-korean)`
- 인라인 `style={{ fontSize, padding }}` → Tailwind 클래스 전환 (가능한 범위)
## 환경 설정 ## 환경 설정
- Node.js 20 (`.node-version`, fnm 사용) - Node.js 20 (`.node-version`, fnm 사용)

파일 보기

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

파일 보기

@ -22,6 +22,7 @@
"pg": "^8.19.0" "pg": "^8.19.0"
}, },
"devDependencies": { "devDependencies": {
"@anthropic-ai/sdk": "^0.89.0",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.10", "@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
@ -34,6 +35,37 @@
"typescript": "^5.7.3" "typescript": "^5.7.3"
} }
}, },
"node_modules/@anthropic-ai/sdk": {
"version": "0.89.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.89.0.tgz",
"integrity": "sha512-nyGau0zex62EpU91hsHa0zod973YEoiMgzWZ9hC55WdiOLrE4AGpcg4wXI7lFqtvMLqMcLfewQU9sHgQB6psow==",
"dev": true,
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3", "version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
@ -1689,6 +1721,20 @@
"bignumber.js": "^9.0.0" "bignumber.js": "^9.0.0"
} }
}, },
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/jsonwebtoken": { "node_modules/jsonwebtoken": {
"version": "9.0.3", "version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@ -2618,6 +2664,13 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"dev": true,
"license": "MIT"
},
"node_modules/tsx": { "node_modules/tsx": {
"version": "4.21.0", "version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",

파일 보기

@ -23,6 +23,7 @@
"pg": "^8.19.0" "pg": "^8.19.0"
}, },
"devDependencies": { "devDependencies": {
"@anthropic-ai/sdk": "^0.89.0",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.10", "@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",

파일 보기

@ -0,0 +1,140 @@
# HNS 물질 Import 파이프라인
`C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm` 외부 자료를 `HNS_SUBSTANCE` DB로 변환하는 1회성 파이프라인.
## 파이프라인 구조
```
[Excel xlsm] [PDF 물질정보집 (193종)]
├─ (1) extract-excel.py (2b) extract-pdf.py
│ → out/base.json → out/pdf-data.json
└─ (2a) extract-images.py ──────────────────────────┐
→ out/images/*.png │
↓ │
(3) ocr-images.ts │
→ out/ocr.json │
↓ ↓
(4) merge-data.ts ←──────────────┘
→ frontend/src/data/hnsSubstanceData.json
(5) tsx src/db/seedHns.ts
→ HNS_SUBSTANCE 테이블
```
**병합 우선순위**: `pdf-data.json` > `base.json` > `ocr.json`
## 전제 조건
- Python 3.9+ with `openpyxl`, `PyMuPDF(fitz)`
- Node.js 20
- `ANTHROPIC_API_KEY` 환경변수 (Claude Vision API, OCR 실행 시에만 필요)
- Excel 원본: `C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm`
- PDF 원본: `C:\Projects\MeterialDB\해상화학사고_대응_물질정보집.pdf`
## 실행 순서
### 1) Excel 메타 시트 파싱
```bash
cd backend
python scripts/hns-import/extract-excel.py
```
- 입력: `C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm`
- 처리 시트: `화물적부도 화물코드`(1,345개), `동의어`(215개), `IBC CODE`(분류)
- 출력: `scripts/hns-import/out/base.json`
### 2a) 이미지 225개 추출 (선택 — OCR 실행 시만 필요)
```bash
python scripts/hns-import/extract-images.py
```
- 출력: `out/images/{nameKr}.png`, `out/image-map.json`
### 2b) PDF 물질정보집 파싱 ★ 권장
```bash
python scripts/hns-import/extract-pdf.py
```
- 입력: `C:\Projects\MeterialDB\해상화학사고_대응_물질정보집.pdf`
- 해양경찰청 발행 193종, 텍스트 직접 추출 (OCR 불필요)
- 출력: `scripts/hns-import/out/pdf-data.json`
### 3) Claude Vision OCR (선택 — pdf-data.json 없을 때 보조)
```bash
export ANTHROPIC_API_KEY="sk-ant-..."
cd backend
npx tsx scripts/hns-import/ocr-images.ts
```
- 이미지 한 장당 Claude API 1회 호출, 동시 5개 병렬
- 출력: `out/ocr.json` `{ [nameKr]: OcrResult }`
### 4) 최종 JSON 병합
```bash
cd backend
npx tsx scripts/hns-import/merge-data.ts
```
- 입력: `out/base.json` + `out/pdf-data.json` + `out/ocr.json` (없으면 건너뜀)
- 출력: `frontend/src/data/hnsSubstanceData.json` (전량 덮어쓰기)
### 5) DB 재시드
```bash
cd backend
npx tsx src/db/seedHns.ts
```
- 기존 `DELETE FROM HNS_SUBSTANCE` → 새 514종 INSERT
## 빠른 재실행 (PDF 추출 → 병합 → 시드)
```bash
cd backend
python scripts/hns-import/extract-pdf.py && \
npx tsx scripts/hns-import/merge-data.ts && \
npx tsx src/db/seedHns.ts
```
## 현재 데이터 현황 (2024-04 기준)
| 항목 | 이전 (OCR) | 현재 (PDF) |
|------|-----------|-----------|
| 총 물질 수 | 514종 | 514종 |
| 상세정보 보유 (인화점 있음) | 152종 | 195종 |
| NFPA 코드 있음 | ~150종 | 201종 |
| CAS 번호 있음 | ~380종 | 504종 |
| 해양거동 있음 | ~0종 | 206종 |
| 한국어 유사명 있음 | ~200종 | 449종 |
## 재실행 안내
- `out/` 디렉토리는 `.gitignore` 처리되어 커밋되지 않음
- OCR 결과는 비결정적이므로 재실행 시 약간 달라질 수 있음
- PDF 추출은 결정적(동일 입력 → 동일 출력)이므로 재실행 안전
## 알려진 이슈
### 1) PDF 매칭 실패 35종
PDF 국문명과 base.json 국문명이 달라 매칭되지 않는 항목이 35개 존재.
`out/pdf-unmatched.json`에서 목록 확인 가능. 해당 항목은 OCR 데이터로 보조.
**원인:**
- 영문제품명이 국문명으로 등록된 경우 (예: `DER 383 Epoxy resin``디이알 383`)
- 동일 CAS 충돌 (예: `컨덴세이트``나프타`가 같은 CAS)
- 표기 차이 (예: `아이소파-G``아이소파 G`)
### 2) 2열 레이아웃 파싱 노이즈 (약 9건)
PDF 물질특성 블록이 2열로 구성되어 있어, 일부 항목에서 비중 값이 온도값으로 오추출될 수 있음.
영향 범위 최소 (벤젠 등 9종, 값이 100 이상이면 의심).
### 3) SEBC/CAS/UN 번호 varchar 길이 초과
`base.json` 생성 시 Excel에서 복수 CAS/UN 번호를 줄바꿈으로 결합해 저장하여, `HNS_SUBSTANCE` 테이블의 `VARCHAR(20)` 등 제약을 초과했음. 현재는 [`seedHns.ts`](../../../backend/src/db/seedHns.ts) 의 `firstToken()` 헬퍼로 첫 토큰만 검색 컬럼에 저장하고 원본 전체는 `DATA` JSONB에 보존.

파일 보기

@ -0,0 +1,236 @@
"""Excel 메타 시트 → base.json 변환.
처리 시트:
- 화물적부도 화물코드: 1,345 기본 레코드
- 동의어: 215 / 유사명
- IBC CODE: IMO IBC 분류
출력: HNSSearchSubstance 스키마(frontend/src/common/types/hns.ts) 맞춘 JSON 배열.
"""
from __future__ import annotations
import io
import json
import os
import re
import sys
from collections import defaultdict
from pathlib import Path
import openpyxl
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
SCRIPT_DIR = Path(__file__).parent.resolve()
OUT_DIR = SCRIPT_DIR / 'out'
OUT_DIR.mkdir(exist_ok=True)
SOURCE_XLSX = Path(os.environ.get(
'HNS_SOURCE_XLSX',
r'C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm',
))
def norm_key(s: str | None) -> str:
if not s:
return ''
return re.sub(r'\s+', '', str(s)).strip().lower()
def split_synonyms(raw: str | None) -> str:
if not raw:
return ''
# 원본은 "·" 또는 "/" 구분, 개행 포함
parts = re.split(r'[·/\n]+', str(raw))
cleaned = [p.strip() for p in parts if p and p.strip()]
return ' / '.join(cleaned)
def clean_text(v) -> str:
if v is None:
return ''
return str(v).strip()
def main() -> None:
print(f'[읽기] {SOURCE_XLSX}')
if not SOURCE_XLSX.exists():
raise SystemExit(f'소스 파일 없음: {SOURCE_XLSX}')
wb = openpyxl.load_workbook(SOURCE_XLSX, read_only=True, data_only=True, keep_vba=False)
# ────────── 화물적부도 화물코드 ──────────
ws = wb['화물적부도 화물코드']
rows = list(ws.iter_rows(values_only=True))
# 헤더 row6: 연번, 약자/제품명, 영어명, 영문명 동의어, 국문명, 국문명 동의어, 주요 사용처, UN번호, CAS번호
cargo_rows = [r for r in rows[6:] if r[0] is not None and isinstance(r[0], (int, float))]
print(f'[화물적부도] 데이터 행 {len(cargo_rows)}')
# ────────── 동의어 시트 ──────────
ws_syn = wb['동의어']
syn_rows = list(ws_syn.iter_rows(values_only=True))
# 헤더 row2: 연번, 국문명, 영문명, cas, un, 한글 유사명, 영문 유사명
syn_map: dict[str, dict] = {}
for r in syn_rows[2:]:
if not r or r[0] is None:
continue
name_kr = clean_text(r[1])
cas = clean_text(r[3])
if not name_kr and not cas:
continue
key = norm_key(name_kr) or norm_key(cas)
syn_map[key] = {
'synonymsKr': split_synonyms(r[5]) if len(r) > 5 else '',
'synonymsEn': split_synonyms(r[6]) if len(r) > 6 else '',
}
print(f'[동의어] {len(syn_map)}')
# ────────── IBC CODE 시트 ──────────
ws_ibc = wb['IBC CODE']
ibc_map: dict[str, dict] = {}
for i, r in enumerate(ws_ibc.iter_rows(values_only=True)):
if i < 2:
continue # header 2 rows
if not r or not r[0]:
continue
name_en = clean_text(r[0])
key = norm_key(name_en)
if not key:
continue
ibc_map[key] = {
'ibcHazard': clean_text(r[2]), # 위험성 S/P
'ibcShipType': clean_text(r[3]), # 선박형식
'ibcTankType': clean_text(r[4]), # 탱크형식
'ibcDetection': clean_text(r[10]) if len(r) > 10 else '', # 탐지장치
'ibcFireFighting': clean_text(r[12]) if len(r) > 12 else '', # 화재대응
'ibcMinRequirement': clean_text(r[14]) if len(r) > 14 else '', # 구체적운영상 요건
}
print(f'[IBC CODE] {len(ibc_map)}')
wb.close()
# ────────── 통합 레코드 생성 ──────────
# 동일 CAS/국문명 기준으로 cargoCodes 그룹화
groups: dict[str, list] = defaultdict(list)
for r in cargo_rows:
_, abbr, name_en, syn_en, name_kr, syn_kr, usage, un, cas = r[:9]
# 그룹 키: CAS 우선, 없으면 국문명
cas_s = clean_text(cas)
group_key = cas_s if cas_s else norm_key(name_kr)
groups[group_key].append({
'abbreviation': clean_text(abbr),
'nameKr': clean_text(name_kr),
'nameEn': clean_text(name_en),
'synonymsKr': split_synonyms(syn_kr),
'synonymsEn': split_synonyms(syn_en),
'usage': clean_text(usage),
'unNumber': clean_text(un),
'casNumber': cas_s,
})
records: list[dict] = []
next_id = 1
for group_key, entries in groups.items():
# 대표 레코드: 가장 먼저 등장 (동의어 필드가 있는 걸 우선)
primary = max(entries, key=lambda e: (bool(e['synonymsKr']), bool(e['synonymsEn']), len(e['nameKr'])))
name_kr_key = norm_key(primary['nameKr'])
name_en_key = norm_key(primary['nameEn'])
# 동의어 병합
syn_extra = syn_map.get(name_kr_key, {})
synonyms_kr = ' / '.join(filter(None, [primary['synonymsKr'], syn_extra.get('synonymsKr', '')]))
synonyms_en = ' / '.join(filter(None, [primary['synonymsEn'], syn_extra.get('synonymsEn', '')]))
# IBC 병합 (영문명 기준)
ibc = ibc_map.get(name_en_key, {})
# cargoCodes 집계
cargo_codes = [
{
'code': e['abbreviation'],
'name': e['nameEn'] or e['nameKr'],
'company': '국제공통',
'source': '적부도',
}
for e in entries
if e['abbreviation']
]
record = {
'id': next_id,
'abbreviation': primary['abbreviation'],
'nameKr': primary['nameKr'],
'nameEn': primary['nameEn'],
'synonymsKr': synonyms_kr,
'synonymsEn': synonyms_en,
'unNumber': primary['unNumber'],
'casNumber': primary['casNumber'],
'transportMethod': '',
'sebc': '',
# 물리·화학 (OCR 단계에서 채움)
'usage': primary['usage'],
'state': '',
'color': '',
'odor': '',
'flashPoint': '',
'autoIgnition': '',
'boilingPoint': '',
'density': '',
'solubility': '',
'vaporPressure': '',
'vaporDensity': '',
'explosionRange': '',
# 위험도
'nfpa': {'health': 0, 'fire': 0, 'reactivity': 0, 'special': ''},
'hazardClass': '',
'ergNumber': '',
'idlh': '',
'aegl2': '',
'erpg2': '',
# 방제
'responseDistanceFire': '',
'responseDistanceSpillDay': '',
'responseDistanceSpillNight': '',
'marineResponse': '',
'ppeClose': '',
'ppeFar': '',
# MSDS
'msds': {
'hazard': '',
'firstAid': '',
'fireFighting': '',
'spillResponse': '',
'exposure': '',
'regulation': '',
},
# IBC
'ibcHazard': ibc.get('ibcHazard', ''),
'ibcShipType': ibc.get('ibcShipType', ''),
'ibcTankType': ibc.get('ibcTankType', ''),
'ibcDetection': ibc.get('ibcDetection', ''),
'ibcFireFighting': ibc.get('ibcFireFighting', ''),
'ibcMinRequirement': ibc.get('ibcMinRequirement', ''),
# EmS (OCR에서 채움)
'emsCode': '',
'emsFire': '',
'emsSpill': '',
'emsFirstAid': '',
# cargoCodes / portFrequency
'cargoCodes': cargo_codes,
'portFrequency': [],
}
records.append(record)
next_id += 1
print(f'[통합] 그룹화 결과 {len(records)}종 (화물적부도 {len(cargo_rows)}행 기준)')
# 저장
out_path = OUT_DIR / 'base.json'
with open(out_path, 'w', encoding='utf-8') as f:
json.dump(records, f, ensure_ascii=False, indent=2)
print(f'[완료] {out_path} ({out_path.stat().st_size / 1024:.0f} KB)')
if __name__ == '__main__':
main()

파일 보기

@ -0,0 +1,170 @@
"""물질별 시트에서 메인 카드 이미지(100KB+) 추출.
엑셀 워크시트 drawing image 관계 체인을 추적해
물질 시트의 핵심 이미지만 out/images/{nameKr}.png 저장.
동시에 out/image-map.json 생성 (파일명 시트명/국문명 매핑).
"""
from __future__ import annotations
import io
import json
import os
import re
import sys
import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
SCRIPT_DIR = Path(__file__).parent.resolve()
OUT_DIR = SCRIPT_DIR / 'out'
IMG_DIR = OUT_DIR / 'images'
IMG_DIR.mkdir(parents=True, exist_ok=True)
SOURCE_XLSX = Path(os.environ.get(
'HNS_SOURCE_XLSX',
r'C:\Projects\MeterialDB\유해물질 화물적부도 검색툴.xlsm',
))
NS = {
'm': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
'pr': 'http://schemas.openxmlformats.org/package/2006/relationships',
}
# 메타 시트(데이터 시트)는 스킵
SKIP_SHEETS = {
'화물적부도 화물코드',
'항구별 코드',
'동의어',
'IBC CODE',
'경계선',
}
# 지침서 번호 시트(115~171) 패턴: 순수 숫자
SKIP_PATTERN = re.compile(r'^\d{3}$')
# 최소 이미지 크기 (주요 카드만 대상, 작은 아이콘 제외)
MIN_IMAGE_SIZE = 50_000 # 50 KB
def safe_filename(name: str) -> str:
name = name.strip().rstrip(',').strip()
name = re.sub(r'[<>:"/\\|?*]', '_', name)
return name
def norm_path(p: str) -> str:
return os.path.normpath(p).replace(os.sep, '/')
def main() -> None:
print(f'[읽기] {SOURCE_XLSX}')
if not SOURCE_XLSX.exists():
raise SystemExit(f'소스 파일 없음: {SOURCE_XLSX}')
image_map: dict[str, dict] = {}
saved = 0
skipped = 0
missing = 0
with zipfile.ZipFile(SOURCE_XLSX) as z:
# 1) workbook.xml → sheet 목록
with z.open('xl/workbook.xml') as f:
wb_root = ET.parse(f).getroot()
sheets = []
for s in wb_root.findall('m:sheets/m:sheet', NS):
sheets.append({
'name': s.get('name'),
'rid': s.get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id'),
})
with z.open('xl/_rels/workbook.xml.rels') as f:
rels_root = ET.parse(f).getroot()
rid_target = {r.get('Id'): r.get('Target') for r in rels_root.findall('pr:Relationship', NS)}
for s in sheets:
s['target'] = rid_target.get(s['rid'])
print(f'[시트] 총 {len(sheets)}')
for s in sheets:
name = s['name']
if name in SKIP_SHEETS or SKIP_PATTERN.match(name or ''):
skipped += 1
continue
sheet_file = 'xl/' + s['target']
rels_file = os.path.dirname(sheet_file) + '/_rels/' + os.path.basename(sheet_file) + '.rels'
try:
with z.open(rels_file) as f:
srels = ET.parse(f).getroot()
except KeyError:
missing += 1
continue
# 시트 → drawing
drawing_rel = None
for r in srels.findall('pr:Relationship', NS):
t = r.get('Target') or ''
if 'drawing' in (r.get('Type') or '').lower() and 'drawings/' in t:
drawing_rel = t
break
if not drawing_rel:
missing += 1
continue
drawing_path = norm_path(os.path.join(os.path.dirname(sheet_file), drawing_rel))
drawing_rels_path = os.path.dirname(drawing_path) + '/_rels/' + os.path.basename(drawing_path) + '.rels'
try:
with z.open(drawing_rels_path) as f:
drels = ET.parse(f).getroot()
except KeyError:
missing += 1
continue
# drawing → images
image_paths: list[str] = []
for r in drels.findall('pr:Relationship', NS):
t = r.get('Target') or ''
if 'image' in t.lower():
img_path = norm_path(os.path.join(os.path.dirname(drawing_path), t))
image_paths.append(img_path)
if not image_paths:
missing += 1
continue
# 가장 큰 이미지 선택 (실제 카드 이미지는 100KB+, 아이콘은 수 KB)
sized = [(z.getinfo(p).file_size, p) for p in image_paths]
sized.sort(reverse=True)
largest_size, largest_path = sized[0]
if largest_size < MIN_IMAGE_SIZE:
missing += 1
continue
# 저장
safe = safe_filename(name)
ext = os.path.splitext(largest_path)[1].lower() or '.png'
out_name = f'{safe}{ext}'
out_path = IMG_DIR / out_name
with z.open(largest_path) as fin, open(out_path, 'wb') as fout:
fout.write(fin.read())
image_map[out_name] = {
'sheetName': name,
'nameKr': safe,
'source': largest_path,
'sizeBytes': largest_size,
}
saved += 1
if saved % 25 == 0:
print(f' {saved}개 저장 완료')
print(f'\n[결과] 저장 {saved} / 스킵(메타) {skipped} / 이미지없음 {missing}')
map_path = OUT_DIR / 'image-map.json'
with open(map_path, 'w', encoding='utf-8') as f:
json.dump(image_map, f, ensure_ascii=False, indent=2)
print(f'[완료] 매핑 파일: {map_path}')
if __name__ == '__main__':
main()

파일 보기

@ -0,0 +1,707 @@
"""PDF 물질정보집 → pdf-data.json 변환.
원본: C:\\Projects\\MeterialDB\\해상화학사고_대응_물질정보집.pdf
해양경찰청 발행 193 물질 정보
PDF 구조:
- 페이지 1-21: 표지/머리말/목차
- 페이지 22-407: 193 × 2페이지 물질 카드
- 요약 카드 (홀수 순서): 인화점·발화점·증기압·증기밀도·폭발범위·NFPA·해양거동
- 상세 카드 (짝수 순서): 유사명·CAS·UN·GHS분류·물질특성·인체유해성·응급조치
- 물질 NO(1-193) 0-인덱스 시작 페이지: 21 + (NO-1) * 2
출력: out/pdf-data.json
{ [nameKr]: OcrResult } merge-data.ts 동일한 구조
"""
from __future__ import annotations
import io
import json
import os
import re
import sys
from pathlib import Path
import fitz # PyMuPDF
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
SCRIPT_DIR = Path(__file__).parent.resolve()
OUT_DIR = SCRIPT_DIR / 'out'
OUT_DIR.mkdir(exist_ok=True)
PDF_PATH = Path(os.environ.get(
'HNS_PDF_PATH',
r'C:\Projects\MeterialDB\해상화학사고_대응_물질정보집.pdf',
))
# 전각 문자 → 반각 변환 테이블
_FULLWIDTH = str.maketrans(
'()tC°℃ ,',
'()tC℃℃ ,',
)
def clean(s: str) -> str:
"""텍스트 정리."""
if not s:
return ''
s = s.translate(_FULLWIDTH)
# 온도 기호 통일: 仁/七/부/사 → ℃ (OCR 오인식)
s = re.sub(r'(?<=[0-9])\s*[仁七부사   ](?=\s|$|이|이하)', '', s)
s = re.sub(r'(?<=[0-9])\s*[tT](?=\s|$|이|이하)', '', s)
s = re.sub(r'\s+', ' ', s)
return s.strip()
def norm_key(s: str) -> str:
"""정규화 키: 공백/특수문자 제거 + 소문자."""
if not s:
return ''
return re.sub(r'[\s,./\-_()\[\]··]+', '', s).lower()
def normalize_cas(raw: str) -> str:
"""CAS 번호 정규화: OCR 노이즈 제거 후 X-XX-X 형식 반환."""
if not raw:
return ''
# 혼합물
if '혼합물' in raw:
return ''
# 특수 대시 → -
s = raw.replace('', '-').replace('', '-').replace('', '-')
# OCR 오인식: 이,이, 공백 등 → 0
s = re.sub(r'[이oO]', '0', s)
s = re.sub(r'["\'\s ]', '', s) # 잡자 제거
# CAS 포맷 검증 후 반환
m = re.match(r'^(\d{2,7}-\d{2}-\d)$', s)
if m:
return m.group(1).lstrip('0') or '0' # 앞자리 0 제거
# 완전히 일치 안 하면 숫자+대시만 남기고 검증
s2 = re.sub(r'[^0-9\-]', '', s)
m2 = re.match(r'^(\d{2,7}-\d{2}-\d)$', s2)
if m2:
return m2.group(1).lstrip('0') or '0'
return ''
def find_cas_in_text(text: str) -> str:
"""텍스트에서 CAS 번호 패턴 검색."""
# 표준 CAS 패턴: 숫자-숫자2자리-숫자1자리
candidates = re.findall(r'\b(\d{1,7}[\-—-\s]{1,2}\d{2}[\-—-\s]{1,2}\d)\b', text)
for c in candidates:
cas = normalize_cas(c)
if cas and len(cas) >= 5:
return cas
return ''
def parse_nfpa(text: str) -> dict | None:
"""NFPA 코드 파싱: '건강 : 3 화재 : 0 반응 : 1' 형태."""
m = re.search(r'건강\s*[:]\s*(\d)\s*화재\s*[:]\s*(\d)\s*반응\s*[:]\s*(\d)', text)
if m:
return {
'health': int(m.group(1)),
'fire': int(m.group(2)),
'reactivity': int(m.group(3)),
'special': '',
}
# 대안 패턴: 줄바꿈 포함
m2 = re.search(r'건강\s*[:]\s*(\d).*?화재\s*[:]\s*(\d).*?반응\s*[:]\s*(\d)', text, re.DOTALL)
if m2:
return {
'health': int(m2.group(1)),
'fire': int(m2.group(2)),
'reactivity': int(m2.group(3)),
'special': '',
}
return None
def extract_field_after(text: str, label: str, max_chars: int = 80) -> str:
"""레이블 직후 값 추출 (단순 패턴)."""
idx = text.find(label)
if idx < 0:
return ''
snippet = text[idx + len(label): idx + len(label) + max_chars + 50]
# 첫 비공백 줄 추출
lines = snippet.split('\n')
for line in lines:
v = clean(line)
if v and v not in (':', '', ''):
return v[:max_chars]
return ''
def parse_summary_card(text: str, index_entry: dict) -> dict:
"""요약 카드(첫 번째 페이지) 파싱."""
result: dict = {}
# 인화점
m = re.search(r'인화점\s*\n([^\n화발증폭위※]+)', text)
if m:
val = clean(m.group(1))
if val and '위험' not in val and len(val) < 40:
result['flashPoint'] = val
# 발화점
m = re.search(r'발화점\s*\n([^\n화발증폭위※인]+)', text)
if m:
val = clean(m.group(1))
if val and len(val) < 40:
result['autoIgnition'] = val
# 증기압 (요약 카드에서는 값이 더 명확하게 나옴)
m = re.search(r'(?:증기압|흥기압)\s*\n?([^\n증기밀도폭발인화발화]+)', text)
if m:
val = clean(m.group(1))
# 파편화된 텍스트 제거
if val and re.search(r'\d', val) and len(val) < 60:
result['vaporPressure'] = val
# 증기밀도 숫자값
m = re.search(r'증기밀도\s*\n?([0-9][^\n]{0,20})', text)
if m:
val = clean(m.group(1))
if val and len(val) < 20:
result['vaporDensity'] = val
# 폭발범위 (2열 레이아웃으로 값이 레이블에서 멀리 떨어질 수 있어 전문 탐색도 병행)
m = re.search(r'폭발범위\s*\n([^\n위험인화발화※]+)', text)
if m:
val = clean(m.group(1))
if val and '%' in val and len(val) < 30:
result['explosionRange'] = val
# 2열 레이아웃 폴백: 텍스트 전체에서 "숫자~숫자%" 패턴 검색
if not result.get('explosionRange'):
m = re.search(r'(\d+[\.,]?\d*\s*~\s*\d+[\.,]?\d*\s*%)', text)
if m:
result['explosionRange'] = clean(m.group(1))
# 화재시 대피거리
m = re.search(r'화재시\s*대피거리\s*\n?([^\n]+)', text)
if m:
val = clean(m.group(1))
if val:
result['responseDistanceFire'] = val
# 해양거동
m = re.search(r'해양거동\s*\n([^\n상온이격방호방제]+)', text)
if not m:
m = re.search(r'해양거동\s+([^\n]+)', text)
if m:
val = clean(m.group(1))
if val and len(val) < 80:
result['marineResponse'] = val
# 상온상태
m = re.search(r'상온상태\s*\n([^\n이격방호비중색상휘발냄새]+)', text)
if m:
val = clean(m.group(1))
if val and len(val) < 60:
result['state'] = val
# 냄새
m = re.search(r'냄새\s*\n([^\n이격방호색상비중상온휘발]+)', text)
if m:
val = clean(m.group(1))
if val and len(val) < 60:
result['odor'] = val
# 비중
m = re.search(r'비중\s*\n[^\n]*\n([0-9][^\n]{0,25})', text)
if not m:
m = re.search(r'비중\s*\n([0-9][^\n]{0,25})', text)
if m:
val = clean(m.group(1))
if val and len(val) < 30:
result['density'] = val
# 색상
m = re.search(r'색상\s*\n([^\n이격방호냄새비중상온휘발]+)', text)
if m:
val = clean(m.group(1))
if val and len(val) < 40:
result['color'] = val
# 이격거리 / 방호거리 거리 숫자 추출
m_hot = re.search(r'(?:이격거리|Hot\s*Zone).*?\n([^\n방호거리]+(?:\d+m|반경[^\n]+))', text, re.IGNORECASE)
if m_hot:
result['responseDistanceSpillDay'] = clean(m_hot.group(1))
m_warm = re.search(r'(?:방호거리|Warm\s*Zone).*?\n([^\n이격거리]+(?:\d+m|방향[^\n]+))', text, re.IGNORECASE)
if m_warm:
result['responseDistanceSpillNight'] = clean(m_warm.group(1))
return result
def parse_detail_card(text: str) -> dict:
"""상세 카드(두 번째 페이지) 파싱."""
result: dict = {}
# ── nameKr 헤더에서 추출 ──────────────────────────────────────────
# 형식: "001 과산화수소" or "0이 과산화수소"
first_lines = text.strip().split('\n')[:4]
for line in first_lines:
line = line.strip()
# 숫자/OCR숫자로 시작하고 뒤에 한글이 오는 패턴
m = re.match(r'^[0-9이이아오-]{2,3}\s+([\w\s\-,./()]+)$', line)
if m:
candidate = clean(m.group(1).strip())
if candidate and re.search(r'[가-힣A-Za-z]', candidate):
result['nameKr'] = candidate
break
# ── 분류 ──────────────────────────────────────────────────────────
m = re.search(r'(?:유해액체물질|위험물질|석유\s*및|해양환경관리법)[^\n]{0,60}', text)
if m:
result['hazardClass'] = clean(m.group(0))
# ── 물질요약 ───────────────────────────────────────────────────────
# 물질요약 레이블 이후 ~ 유사명/CAS 번호 전까지
m = re.search(r'(?:물질요약|= *닐으서|진 O야)(.*?)(?=유사명|CAS|$)', text, re.DOTALL)
if not m:
# 분류값 이후 ~ 유사명 전
m = re.search(r'(?:유해액체물질|석유 및)[^\n]*\n(.*?)(?=유사명|CAS)', text, re.DOTALL)
if m:
summary = re.sub(r'\s+', ' ', m.group(1)).strip()
if summary and len(summary) > 15:
result['materialSummary'] = summary[:500]
# ── 유사명 ─────────────────────────────────────────────────────────
m = re.search(r'유사명\s*\n?(.*?)(?=CAS|UN\s*번호|\d{4,7}-\d{2}-\d|분자식|$)', text, re.DOTALL)
if m:
synonyms_raw = re.sub(r'\s+', ' ', m.group(1)).strip()
# CAS 번호 형태면 제외
if synonyms_raw and not re.match(r'^\d{4,7}-\d{2}-\d', synonyms_raw) and len(synonyms_raw) < 300:
result['synonymsKr'] = synonyms_raw
# ── CAS 번호 ────────────────────────────────────────────────────────
# 1순위: "CAS번호" / "CAS 번호" 직후 줄
m = re.search(r'CAS\s*번호\s*\n\s*([^\n분자NFPA용도인화발화물질]+)', text)
if not m:
m = re.search(r'CAS\s*번호\s*([0-9][^\n분자NFPA용도인화발화물질]{4,20})', text)
if m:
cas = normalize_cas(m.group(1).strip().split()[0])
if cas:
result['casNumber'] = cas
# 2순위: 텍스트 전체에서 CAS 패턴 검색
if not result.get('casNumber'):
cas = find_cas_in_text(text)
if cas:
result['casNumber'] = cas
# ── UN 번호 ─────────────────────────────────────────────────────────
# NFPA 코드 이후 줄에 있는 4자리 숫자
m = re.search(r'(?:UN\s*번호|UN번호)\s*\n?\s*([0-9]{3,4})', text)
if not m:
# NFPA 다음 4자리 숫자
m = re.search(r'반응\s*[:]\s*\d\s*\n\s*([0-9]{3,4})\s*\n', text)
if m:
result['unNumber'] = m.group(1).strip()
# ── NFPA 코드 ───────────────────────────────────────────────────────
nfpa = parse_nfpa(text)
if nfpa:
result['nfpa'] = nfpa
# ── 용도 ────────────────────────────────────────────────────────────
m = re.search(r'용도\s*\n(.*?)(?=물질특성|인체\s*유해|인체유해|흡입노출|보호복|초동|$)', text, re.DOTALL)
if m:
usage = re.sub(r'\s+', ' ', m.group(1)).strip()
# GHS 마크(특수문자 블록) 제거
usage = re.sub(r'<[^>]*>|[♦◆◇△▲▼▽★☆■□●○◐◑]+', '', usage).strip()
if usage and len(usage) < 200:
result['usage'] = usage
# ── 물질특성 블록 ───────────────────────────────────────────────────
props_start = text.find('물질특성')
props_end = text.find('인체 유해성')
if props_end < 0:
props_end = text.find('인체유해성')
if props_end < 0:
props_end = text.find('흡입노출')
props_text = text[props_start:props_end] if 0 <= props_start < props_end else text
# 인화점 (상세) — 단일 알파벳(X/O 등 위험도 마크) 제외, 숫자 포함 값만 허용
m = re.search(r'인화점\s+([^\n발화끓는수용상온]+)', props_text)
if not m:
m = re.search(r'인화점\s*\n\s*([^\n발화끓는수용상온]+)', props_text)
if m:
val = clean(m.group(1))
if val and len(val) < 40 and (re.search(r'\d', val) or re.search(r'없음|해당없음|N/A', val)):
result['flashPoint'] = val
# 발화점 (상세) — 숫자 포함 값만 허용
m = re.search(r'발화점\s+([^\n인화끓는수용상온]+)', props_text)
if not m:
m = re.search(r'발화점\s*\n\s*([^\n인화끓는수용상온]+)', props_text)
if m:
val = clean(m.group(1))
if val and len(val) < 40 and (re.search(r'\d', val) or re.search(r'없음|해당없음|N/A', val)):
result['autoIgnition'] = val
# 끓는점
m = re.search(r'끓는점\s+([^\n인화발화수용상온]+)', props_text)
if not m:
m = re.search(r'끓는점\s*\n\s*([^\n인화발화수용상온]+)', props_text)
if m:
val = clean(m.group(1))
if val and len(val) < 40:
result['boilingPoint'] = val
# 수용해도
m = re.search(r'수용해도\s+([^\n인화발화끓는상온]+)', props_text)
if not m:
m = re.search(r'수용해도\s*\n\s*([^\n인화발화끓는상온]+)', props_text)
if m:
val = clean(m.group(1))
if val and len(val) < 50:
result['solubility'] = val
# 상온상태 (상세)
m = re.search(r'상온상태\s+([^\n색상냄새비중증기인화발화]+)', props_text)
if not m:
m = re.search(r'상온상태\s*\n\s*([^\n색상냄새비중증기인화발화]+)', props_text)
if m:
val = clean(m.group(1)).strip('()')
if val and len(val) < 60:
result['state'] = val
# 색상 (상세)
m = re.search(r'색상\s+([^\n상온냄새비중증기인화발화]+)', props_text)
if not m:
m = re.search(r'색상\s*\n\s*([^\n상온냄새비중증기인화발화]+)', props_text)
if m:
val = clean(m.group(1))
if val and len(val) < 40:
result['color'] = val
# 냄새 (상세)
m = re.search(r'냄새\s+([^\n상온색상비중증기인화발화]+)', props_text)
if not m:
m = re.search(r'냄새\s*\n\s*([^\n상온색상비중증기인화발화]+)', props_text)
if m:
val = clean(m.group(1))
if val and len(val) < 60:
result['odor'] = val
# 비중 (상세)
m = re.search(r'비중\s+([0-9][^\n증기점도휘발]{0,25})', props_text)
if not m:
m = re.search(r'비중\s*\n\s*([0-9][^\n증기점도휘발]{0,25})', props_text)
if m:
val = clean(m.group(1))
if val and len(val) < 30:
result['density'] = val
# 증기압 (상세)
m = re.search(r'증기압\s+([^\n증기밀도점도휘발]{3,40})', props_text)
if not m:
m = re.search(r'증기압\s*\n\s*([^\n증기밀도점도휘발]{3,40})', props_text)
if m:
val = clean(m.group(1))
if val and re.search(r'\d', val):
result['vaporPressure'] = val
# 증기밀도 (상세)
m = re.search(r'증기밀도\s+([0-9,\.][^\n]{0,15})', props_text)
if not m:
m = re.search(r'증기밀도\s*\n\s*([0-9,\.][^\n]{0,15})', props_text)
if m:
val = clean(m.group(1))
if val and len(val) < 20:
result['vaporDensity'] = val
# 점도
m = re.search(r'점도\s+([0-9][^\n]{0,25})', props_text)
if not m:
m = re.search(r'점도\s*\n\s*([0-9][^\n]{0,25})', props_text)
if m:
val = clean(m.group(1))
if val and len(val) < 30:
result['viscosity'] = val
# ── 인체유해성 블록 ─────────────────────────────────────────────────
hazard_start = max(text.find('인체 유해성'), text.find('인체유해성'))
if hazard_start < 0:
hazard_start = text.find('급성독성')
response_start = text.find('초동대응')
hazard_text = text[hazard_start:response_start] if 0 <= hazard_start < response_start else ''
# IDLH
m = re.search(r'I?DLH[^\n]{0,20}\n?\s*([0-9][^\n]{0,20})', hazard_text or text)
if m:
val = clean(m.group(1))
if val and re.search(r'\d', val):
result['idlh'] = val
# TWA
m = re.search(r'TWA[^\n]{0,20}\n?\s*([0-9][^\n]{0,20})', hazard_text or text)
if m:
val = clean(m.group(1))
if val and re.search(r'\d', val):
result['twa'] = val
# ── 응급조치 ─────────────────────────────────────────────────────────
fa_start = text.find('흡입노출')
fa_end = text.find('초동대응')
if fa_start >= 0:
fa_text = text[fa_start: fa_end if fa_end > fa_start else fa_start + 600]
fa = re.sub(r'\s+', ' ', fa_text).strip()
result['msds'] = {
'firstAid': fa[:600],
'spillResponse': '',
'hazard': '',
'fireFighting': '',
'exposure': '',
'regulation': '',
}
# ── 초동대응 - 이격거리/방호거리 (상세카드에서) ─────────────────────
m = re.search(r'초기\s*이격거리[^\n]{0,10}m[^\n]{0,5}\n?\s*([0-9]+)', text)
if m:
result['responseDistanceSpillDay'] = m.group(1) + 'm'
m = re.search(r'방호거리[^\n]{0,10}m[^\n]{0,5}\n?\s*([0-9]+)', text)
if m:
result['responseDistanceSpillNight'] = m.group(1) + 'm'
m = re.search(r'화재\s*시\s*대피거리[^\n]{0,10}m[^\n]{0,5}\n?\s*([0-9]+)', text)
if m:
result['responseDistanceFire'] = m.group(1) + 'm'
# ── GHS 분류 ─────────────────────────────────────────────────────────
ghs_items = re.findall(
r'(?:인화성[^\n(]{2,40}|급성독성[^\n(]{2,40}|피부부식[^\n(]{2,40}|'
r'\s*손상[^\n(]{2,40}|발암성[^\n(]{2,40}|생식독성[^\n(]{2,40}|'
r'수생환경[^\n(]{2,40}|특정표적[^\n(]{2,40}|흡인유해[^\n(]{2,40})',
text,
)
if ghs_items:
result['ghsClass'] = ' / '.join(clean(g) for g in ghs_items[:6])
return result
def parse_index_pages(pdf: fitz.Document) -> dict[int, dict]:
"""목차 페이지(4-21)에서 NO → {nameKr, nameEn, casNumber} 매핑 구축."""
index: dict[int, dict] = {}
for page_idx in range(3, 21):
page = pdf[page_idx]
text = page.get_text()
lines = [ln.strip() for ln in text.split('\n') if ln.strip()]
for i, line in enumerate(lines):
if not re.match(r'^\d{1,3}$', line):
continue
no = int(line)
if not (1 <= no <= 193):
continue
if no in index:
continue
# 탐색 창: NO 앞 1~4줄
cas, name_en, name_kr = '', '', ''
if i >= 1:
# CAS 줄: 숫자-숫자-숫자 패턴 (OCR 노이즈 허용)
raw_cas = lines[i - 1]
cas = normalize_cas(raw_cas) if re.match(r'^[0-9이이아oO-\-—-"\'\. ]{5,30}$|혼합물', raw_cas) else ''
if not cas and '혼합물' in raw_cas:
cas = '혼합물'
if cas or '혼합물' in (lines[i - 1] if i >= 1 else ''):
if i >= 2:
name_en = lines[i - 2]
if i >= 3:
name_kr = lines[i - 3]
elif i >= 2:
# CAS가 없는 경우(매칭 실패) - 줄 이동해서 재탐색
raw_cas2 = lines[i - 2] if i >= 2 else ''
cas = normalize_cas(raw_cas2) if re.match(r'^[0-9이이아oO-\-—-"\'\. ]{5,30}$|혼합물', raw_cas2) else ''
if cas or '혼합물' in raw_cas2:
name_en = lines[i - 1] if i >= 1 else ''
# name_kr는 찾기 어려움
if not name_kr and i >= 3:
# 이름이 공백/짧으면 더 위 줄에서 찾기
for j in range(3, min(6, i + 1)):
cand = lines[i - j]
if re.search(r'[가-힣]', cand) and len(cand) > 1:
name_kr = cand
break
index[no] = {
'no': no,
'nameKr': name_kr,
'nameEn': name_en,
'casNumber': cas if cas != '혼합물' else '',
}
return index
def extract_name_from_summary(text: str) -> tuple[str, str]:
"""요약 카드에서 nameKr, nameEn 추출."""
name_kr, name_en = '', ''
lines = text.strip().split('\n')
# 1~6번 줄에서 한글 이름 탐색 (헤더 "해상화학사고 대응 물질정보집" 이후)
found_header = False
for line in lines:
line = line.strip()
if not line:
continue
# 제목 줄 건너뜀
if '해상화학사고' in line or '대응' in line or '물질정보집' in line:
found_header = True
continue
# 3자리 번호 줄 건너뜀
if re.match(r'^\d{1,3}$', line):
continue
# 한글이 있으면 nameKr 후보
if re.search(r'[가-힣]', line) and len(line) > 1 and '위험' not in line and '분류' not in line:
if not name_kr:
name_kr = clean(line)
# 영문명: (영문명) 형태
m_en = re.search(r'[(]([A-Za-z][^)]{3,60})[)]', line)
if m_en and not name_en:
name_en = clean(m_en.group(1))
if name_kr and name_en:
break
return name_kr, name_en
def parse_substance(pdf: fitz.Document, no: int, index_entry: dict) -> dict | None:
"""물질 번호 no에 해당하는 2페이지를 파싱하여 통합 레코드 반환."""
start_idx = 21 + (no - 1) * 2
if start_idx + 1 >= pdf.page_count:
return None
summary_text = pdf[start_idx].get_text()
detail_text = pdf[start_idx + 1].get_text()
summary = parse_summary_card(summary_text, index_entry)
detail = parse_detail_card(detail_text)
# nameKr 결정 우선순위: 인덱스 > 상세카드 헤더 > 요약카드
name_kr = index_entry.get('nameKr', '')
if not name_kr:
name_kr = detail.get('nameKr', '')
if not name_kr:
name_kr, _ = extract_name_from_summary(summary_text)
# nameEn
name_en = index_entry.get('nameEn', '')
# 통합: detail 우선, 없으면 summary
merged: dict = {
'nameKr': name_kr,
'nameEn': name_en,
}
for key in ['casNumber', 'unNumber', 'usage', 'synonymsKr',
'flashPoint', 'autoIgnition', 'boilingPoint', 'density', 'solubility',
'vaporPressure', 'vaporDensity', 'volatility', 'explosionRange',
'state', 'color', 'odor', 'viscosity', 'idlh', 'twa',
'responseDistanceFire', 'responseDistanceSpillDay', 'responseDistanceSpillNight',
'marineResponse', 'hazardClass', 'ghsClass', 'materialSummary', 'msds']:
detail_val = detail.get(key)
summary_val = summary.get(key)
if detail_val:
merged[key] = detail_val
elif summary_val:
merged[key] = summary_val
# CAS: 인덱스 우선
if index_entry.get('casNumber') and not merged.get('casNumber'):
merged['casNumber'] = index_entry['casNumber']
# NFPA: detail 우선
if 'nfpa' in detail:
merged['nfpa'] = detail['nfpa']
if 'msds' not in merged:
merged['msds'] = {
'firstAid': '', 'spillResponse': '', 'hazard': '',
'fireFighting': '', 'exposure': '', 'regulation': '',
}
merged['_no'] = no
merged['_pageIdx'] = start_idx
return merged
def main() -> None:
if not PDF_PATH.exists():
raise SystemExit(f'PDF 파일 없음: {PDF_PATH}')
print(f'[읽기] {PDF_PATH}')
pdf = fitz.open(str(PDF_PATH))
print(f'[PDF] 총 {pdf.page_count}페이지')
# 1. 인덱스 파싱
print('[인덱스] 목차 페이지 파싱 중...')
index = parse_index_pages(pdf)
print(f'[인덱스] {len(index)}개 항목 발견')
# 2. 물질 카드 파싱
results: dict[str, dict] = {}
failed: list[int] = []
for no in range(1, 194):
entry = index.get(no, {'no': no, 'nameKr': '', 'nameEn': '', 'casNumber': ''})
try:
rec = parse_substance(pdf, no, entry)
if rec:
name_kr = rec.get('nameKr', '')
if name_kr:
key = name_kr
if key in results:
key = f'{name_kr}_{no}'
results[key] = rec
else:
print(f' [경고] NO={no} nameKr 없음 - 건너뜀')
failed.append(no)
except Exception as e:
print(f' [오류] NO={no}: {e}')
failed.append(no)
pdf.close()
# 3. 저장
out_path = OUT_DIR / 'pdf-data.json'
with open(out_path, 'w', encoding='utf-8') as f:
json.dump(results, f, ensure_ascii=False, indent=2)
size_kb = out_path.stat().st_size / 1024
print(f'\n[완료] {out_path} ({size_kb:.0f} KB, {len(results)}종)')
if failed:
print(f'[경고] 파싱 실패 {len(failed)}종: {failed}')
# 4. 통계
with_flash = sum(1 for v in results.values() if v.get('flashPoint'))
with_nfpa = sum(1 for v in results.values() if v.get('nfpa'))
with_cas = sum(1 for v in results.values() if v.get('casNumber'))
with_syn = sum(1 for v in results.values() if v.get('synonymsKr'))
print(f'[통계] 인화점: {with_flash}종, NFPA: {with_nfpa}종, CAS: {with_cas}종, 유사명: {with_syn}')
# 5. 샘플 출력
print('\n[샘플] 주요 항목:')
sample_keys = ['과산화수소', '나프탈렌', '벤젠', '톨루엔']
for k in sample_keys:
if k in results:
v = results[k]
print(f' {k}: fp={v.get("flashPoint","")} nfpa={v.get("nfpa")} cas={v.get("casNumber","")}')
if __name__ == '__main__':
main()

파일 보기

@ -0,0 +1,23 @@
"""배치 JSON을 ocr.json에 병합."""
import json
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent.resolve()
OUT_DIR = SCRIPT_DIR / 'out'
OCR_PATH = OUT_DIR / 'ocr.json'
BATCH_PATH = OUT_DIR / 'batch.json'
with open(OCR_PATH, encoding='utf-8') as f:
ocr = json.load(f)
with open(BATCH_PATH, encoding='utf-8') as f:
batch = json.load(f)
added = [k for k in batch if k not in ocr]
updated = [k for k in batch if k in ocr]
ocr.update(batch)
with open(OCR_PATH, 'w', encoding='utf-8') as f:
json.dump(ocr, f, ensure_ascii=False, indent=2)
print(f'merged: +{len(added)} added, ~{len(updated)} updated, total {len(ocr)}')

파일 보기

@ -0,0 +1,362 @@
/**
* base.json + pdf-data.json + ocr.json frontend/src/data/hnsSubstanceData.json
*
* 우선순위: pdf-data (PDF , ) > base.json > ocr.json ( OCR, )
* :
* 1. CAS ( )
* 2. (nameKr)
* 3. (synonymsKr)
*/
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT_DIR = resolve(__dirname, 'out');
const BASE_PATH = resolve(OUT_DIR, 'base.json');
const PDF_PATH = resolve(OUT_DIR, 'pdf-data.json');
const OCR_PATH = resolve(OUT_DIR, 'ocr.json');
const TARGET_PATH = resolve(__dirname, '../../../frontend/src/data/hnsSubstanceData.json');
function normalizeName(s: string | undefined): string {
if (!s) return '';
return s
.replace(/\s+/g, '')
.replace(/[,.·/\-_()[\]]/g, '')
.toLowerCase();
}
function normalizeCas(s: string | undefined): string {
if (!s) return '';
// 앞자리 0 제거 후 정규화
return s
.replace(/[^0-9\-]/g, '')
.replace(/^0+/, '')
.trim();
}
interface NfpaBlock {
health: number;
fire: number;
reactivity: number;
special: string;
}
interface MsdsBlock {
hazard: string;
firstAid: string;
fireFighting: string;
spillResponse: string;
exposure: string;
regulation: string;
}
interface BaseRecord {
id: number;
abbreviation: string;
nameKr: string;
nameEn: string;
synonymsEn: string;
synonymsKr: string;
unNumber: string;
casNumber: string;
transportMethod: string;
sebc: string;
usage: string;
state: string;
color: string;
odor: string;
flashPoint: string;
autoIgnition: string;
boilingPoint: string;
density: string;
solubility: string;
vaporPressure: string;
vaporDensity: string;
explosionRange: string;
nfpa: NfpaBlock;
hazardClass: string;
ergNumber: string;
idlh: string;
aegl2: string;
erpg2: string;
responseDistanceFire: string;
responseDistanceSpillDay: string;
responseDistanceSpillNight: string;
marineResponse: string;
ppeClose: string;
ppeFar: string;
msds: MsdsBlock;
ibcHazard: string;
ibcShipType: string;
ibcTankType: string;
ibcDetection: string;
ibcFireFighting: string;
ibcMinRequirement: string;
emsCode: string;
emsFire: string;
emsSpill: string;
emsFirstAid: string;
cargoCodes: Array<{ code: string; name: string; company: string; source: string }>;
portFrequency: Array<{ port: string; portCode: string; lastImport: string; frequency: string }>;
}
interface PdfResult {
[key: string]: unknown;
casNumber?: string;
nameKr?: string;
nfpa?: Partial<NfpaBlock>;
msds?: Partial<MsdsBlock>;
}
interface OcrResult {
[key: string]: unknown;
}
function firstString(...values: Array<unknown>): string {
for (const v of values) {
if (typeof v === 'string' && v.trim().length > 0) return v.trim();
}
return '';
}
function pickNfpa(source: PdfResult | OcrResult): NfpaBlock | null {
const n = source.nfpa as Partial<NfpaBlock> | undefined;
if (!n || typeof n !== 'object') return null;
const h = Number(n.health);
const f = Number(n.fire);
const r = Number(n.reactivity);
if ([h, f, r].some((x) => !Number.isFinite(x))) return null;
return {
health: h,
fire: f,
reactivity: r,
special: typeof n.special === 'string' ? n.special : '',
};
}
function pickMsds(
pdf: PdfResult | undefined,
ocr: OcrResult | undefined,
base: MsdsBlock,
): MsdsBlock {
const p = (pdf?.msds ?? {}) as Partial<MsdsBlock>;
const o = (ocr?.msds ?? {}) as Partial<MsdsBlock>;
return {
hazard: firstString(base.hazard, p.hazard, o.hazard),
firstAid: firstString(base.firstAid, p.firstAid, o.firstAid),
fireFighting: firstString(base.fireFighting, p.fireFighting, o.fireFighting),
spillResponse: firstString(base.spillResponse, p.spillResponse, o.spillResponse),
exposure: firstString(base.exposure, p.exposure, o.exposure),
regulation: firstString(base.regulation, p.regulation, o.regulation),
};
}
function merge(
base: BaseRecord,
pdf: PdfResult | undefined,
ocr: OcrResult | undefined,
): BaseRecord {
const nfpaFromPdf = pdf ? pickNfpa(pdf) : null;
const nfpaFromOcr = ocr ? pickNfpa(ocr) : null;
// pdf NFPA 우선, 없으면 ocr, 없으면 base
const nfpa = nfpaFromPdf ?? nfpaFromOcr ?? base.nfpa;
return {
...base,
// pdf > base > ocr 우선순위
unNumber: firstString(pdf?.unNumber, base.unNumber, ocr?.unNumber),
casNumber: firstString(pdf?.casNumber, base.casNumber, ocr?.casNumber),
synonymsKr: firstString(pdf?.synonymsKr, base.synonymsKr, ocr?.synonymsKr),
transportMethod: firstString(base.transportMethod, pdf?.transportMethod, ocr?.transportMethod),
sebc: firstString(base.sebc, pdf?.sebc, ocr?.sebc),
usage: firstString(pdf?.usage, base.usage, ocr?.usage),
state: firstString(pdf?.state, base.state, ocr?.state),
color: firstString(pdf?.color, base.color, ocr?.color),
odor: firstString(pdf?.odor, base.odor, ocr?.odor),
flashPoint: firstString(pdf?.flashPoint, base.flashPoint, ocr?.flashPoint),
autoIgnition: firstString(pdf?.autoIgnition, base.autoIgnition, ocr?.autoIgnition),
boilingPoint: firstString(pdf?.boilingPoint, base.boilingPoint, ocr?.boilingPoint),
density: firstString(pdf?.density, base.density, ocr?.density),
solubility: firstString(pdf?.solubility, base.solubility, ocr?.solubility),
vaporPressure: firstString(pdf?.vaporPressure, base.vaporPressure, ocr?.vaporPressure),
vaporDensity: firstString(pdf?.vaporDensity, base.vaporDensity, ocr?.vaporDensity),
explosionRange: firstString(pdf?.explosionRange, base.explosionRange, ocr?.explosionRange),
nfpa,
hazardClass: firstString(pdf?.hazardClass, base.hazardClass, ocr?.hazardClass),
ergNumber: firstString(base.ergNumber, pdf?.ergNumber, ocr?.ergNumber),
idlh: firstString(pdf?.idlh, base.idlh, ocr?.idlh),
aegl2: firstString(base.aegl2, pdf?.aegl2, ocr?.aegl2),
erpg2: firstString(base.erpg2, pdf?.erpg2, ocr?.erpg2),
responseDistanceFire: firstString(pdf?.responseDistanceFire, base.responseDistanceFire, ocr?.responseDistanceFire),
responseDistanceSpillDay: firstString(pdf?.responseDistanceSpillDay, base.responseDistanceSpillDay, ocr?.responseDistanceSpillDay),
responseDistanceSpillNight: firstString(pdf?.responseDistanceSpillNight, base.responseDistanceSpillNight, ocr?.responseDistanceSpillNight),
marineResponse: firstString(pdf?.marineResponse, base.marineResponse, ocr?.marineResponse),
ppeClose: firstString(base.ppeClose, pdf?.ppeClose, ocr?.ppeClose),
ppeFar: firstString(base.ppeFar, pdf?.ppeFar, ocr?.ppeFar),
msds: pickMsds(pdf, ocr, base.msds),
emsCode: firstString(base.emsCode, pdf?.emsCode, ocr?.emsCode),
emsFire: firstString(base.emsFire, pdf?.emsFire, ocr?.emsFire),
emsSpill: firstString(base.emsSpill, pdf?.emsSpill, ocr?.emsSpill),
emsFirstAid: firstString(base.emsFirstAid, pdf?.emsFirstAid, ocr?.emsFirstAid),
};
}
function main() {
if (!existsSync(BASE_PATH)) {
console.error(`base.json 없음: ${BASE_PATH}`);
console.error('→ extract-excel.py 를 먼저 실행하세요.');
process.exit(1);
}
const base: BaseRecord[] = JSON.parse(readFileSync(BASE_PATH, 'utf-8'));
// PDF 데이터 로드
const pdfRaw: Record<string, PdfResult> = existsSync(PDF_PATH)
? JSON.parse(readFileSync(PDF_PATH, 'utf-8'))
: {};
// OCR 데이터 로드
const ocr: Record<string, OcrResult> = existsSync(OCR_PATH)
? JSON.parse(readFileSync(OCR_PATH, 'utf-8'))
: {};
console.log(
`[입력] base ${base.length}종, pdf ${Object.keys(pdfRaw).length}종, ocr ${Object.keys(ocr).length}`,
);
// ── PDF 인덱스 구축 ─────────────────────────────────────────────────
// 1) nameKr 정규화 인덱스
const pdfByName = new Map<string, PdfResult>();
// 2) CAS 번호 인덱스
const pdfByCas = new Map<string, PdfResult>();
for (const [key, value] of Object.entries(pdfRaw)) {
const normKey = normalizeName(key);
if (normKey) pdfByName.set(normKey, value);
const cas = normalizeCas(value.casNumber);
if (cas) {
if (!pdfByCas.has(cas)) pdfByCas.set(cas, value);
}
}
// ── OCR 인덱스 구축 ─────────────────────────────────────────────────
const ocrByName = new Map<string, OcrResult>();
const ocrNormToOrig = new Map<string, string>();
for (const [key, value] of Object.entries(ocr)) {
const normKey = normalizeName(key);
if (normKey) {
ocrByName.set(normKey, value);
ocrNormToOrig.set(normKey, key);
}
}
// ── 병합 ──────────────────────────────────────────────────────────
let pdfMatchedByName = 0;
let pdfMatchedByCas = 0;
let pdfMatchedBySynonym = 0;
let ocrMatched = 0;
const pdfUnmatched = new Set(Object.keys(pdfRaw));
const ocrUnmatched = new Set(ocrByName.keys());
const merged = base.map((record) => {
let pdfResult: PdfResult | undefined;
let ocrResult: OcrResult | undefined;
// ── PDF 매칭 ────────────────────────────────────────────────────
// 1. CAS 번호 매칭 (가장 정확)
const baseCas = normalizeCas(record.casNumber);
if (baseCas) {
pdfResult = pdfByCas.get(baseCas);
if (pdfResult) {
pdfMatchedByCas++;
const origKey = pdfResult.nameKr as string | undefined;
if (origKey) pdfUnmatched.delete(origKey);
}
}
// 2. nameKr 정규화 매칭
if (!pdfResult) {
const normKr = normalizeName(record.nameKr);
pdfResult = pdfByName.get(normKr);
if (pdfResult) {
pdfMatchedByName++;
const origKey = pdfResult.nameKr as string | undefined;
if (origKey) pdfUnmatched.delete(origKey);
}
}
// 3. synonymsKr 동의어 매칭
if (!pdfResult && record.synonymsKr) {
const synonyms = record.synonymsKr.split(' / ');
for (const syn of synonyms) {
const normSyn = normalizeName(syn);
if (!normSyn) continue;
pdfResult = pdfByName.get(normSyn);
if (pdfResult) {
pdfMatchedBySynonym++;
const origKey = pdfResult.nameKr as string | undefined;
if (origKey) pdfUnmatched.delete(origKey);
break;
}
}
}
// ── OCR 매칭 (PDF 없는 경우 보조) ────────────────────────────────
const normKr = normalizeName(record.nameKr);
const ocrByNameResult = ocrByName.get(normKr);
if (ocrByNameResult) {
ocrResult = ocrByNameResult;
ocrMatched++;
ocrUnmatched.delete(normKr);
}
if (!ocrResult && record.synonymsKr) {
const synonyms = record.synonymsKr.split(' / ');
for (const syn of synonyms) {
const normSyn = normalizeName(syn);
if (!normSyn) continue;
const synOcrResult = ocrByName.get(normSyn);
if (synOcrResult) {
ocrResult = synOcrResult;
ocrMatched++;
ocrUnmatched.delete(normSyn);
break;
}
}
}
return merge(record, pdfResult, ocrResult);
});
// ── 통계 출력 ──────────────────────────────────────────────────────
const pdfTotal = pdfMatchedByCas + pdfMatchedByName + pdfMatchedBySynonym;
console.log(
`[PDF 매칭] 총 ${pdfTotal}종 (CAS: ${pdfMatchedByCas}, 국문명: ${pdfMatchedByName}, 동의어: ${pdfMatchedBySynonym})`,
);
console.log(`[OCR 매칭] ${ocrMatched}`);
if (pdfUnmatched.size > 0) {
const unmatchedList = Array.from(pdfUnmatched).sort();
const unmatchedPath = resolve(OUT_DIR, 'pdf-unmatched.json');
writeFileSync(
unmatchedPath,
JSON.stringify({ count: unmatchedList.length, keys: unmatchedList }, null, 2),
'utf-8',
);
console.warn(
`[경고] PDF 매칭 실패 ${unmatchedList.length}개 → ${unmatchedPath}`,
);
unmatchedList.slice(0, 10).forEach((k) => console.warn(` - ${k}`));
if (unmatchedList.length > 10) console.warn(` ... +${unmatchedList.length - 10}`);
}
writeFileSync(TARGET_PATH, JSON.stringify(merged, null, 2), 'utf-8');
const sizeKb = (JSON.stringify(merged).length / 1024).toFixed(0);
console.log(`[완료] ${TARGET_PATH} (${sizeKb} KB, ${merged.length}종)`);
console.log(` 상세 정보 보유: ${merged.filter((r) => r.flashPoint).length}`);
console.log(` NFPA 있음: ${merged.filter((r) => r.nfpa.health || r.nfpa.fire || r.nfpa.reactivity).length}`);
}
main();

파일 보기

@ -0,0 +1,300 @@
/**
* Claude Vision API HNS JSON .
*
* 입력: out/images/*.png (222)
* 출력: out/ocr.json { [nameKr]: Partial<HNSSearchSubstance> }
*
* 환경변수: ANTHROPIC_API_KEY
* 모델: claude-sonnet-4-5 (Vision + )
* 동시성: 5, 3
*
* ocr.json .
*/
import 'dotenv/config';
import Anthropic from '@anthropic-ai/sdk';
import { readFileSync, readdirSync, writeFileSync, existsSync } from 'node:fs';
import { resolve, dirname, basename, extname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT_DIR = resolve(__dirname, 'out');
const IMG_DIR = process.env.HNS_OCR_IMG_DIR
? resolve(process.env.HNS_OCR_IMG_DIR)
: resolve(OUT_DIR, 'images');
const OCR_PATH = process.env.HNS_OCR_OUT
? resolve(process.env.HNS_OCR_OUT)
: resolve(OUT_DIR, 'ocr.json');
const FAIL_PATH = process.env.HNS_OCR_FAIL
? resolve(process.env.HNS_OCR_FAIL)
: resolve(OUT_DIR, 'ocr-failures.json');
const OCR_LIMIT = process.env.HNS_OCR_LIMIT ? parseInt(process.env.HNS_OCR_LIMIT, 10) : undefined;
const OCR_ONLY = process.env.HNS_OCR_ONLY
? process.env.HNS_OCR_ONLY.split(',').map((s) => s.trim()).filter(Boolean)
: undefined;
const CONCURRENCY = 5;
const MAX_RETRIES = 3;
const MODEL = process.env.HNS_OCR_MODEL ?? 'claude-sonnet-4-5';
const SYSTEM_PROMPT = `당신은 한국 해양 방제용 HNS 비상대응 카드 이미지를 구조화 JSON으로 추출하는 전문 파서입니다.
릿 :
- 상단: 국문명,
- 물질특성: CAS번호, UN번호, , , (///), , //, //, //, /, NFPA (//), GHS , ERG
- 대응방법: 주요 (PPE /), (EmS F-x), (EmS S-x), ,
- 인체유해성: TWA / STEL / AEGL-2 / IDLH, /// ·
JSON **** . "" null.
(: "80℃", "2,410 mmHg (25℃)").
NFPA // 0~4 . special ( ).
** JSON ** ( ).
:
{
"transportMethod": "",
"state": "",
"color": "",
"odor": "",
"flashPoint": "",
"autoIgnition": "",
"boilingPoint": "",
"density": "",
"solubility": "",
"vaporPressure": "",
"vaporDensity": "",
"explosionRange": "",
"nfpa": { "health": 0, "fire": 0, "reactivity": 0, "special": "" },
"hazardClass": "",
"ergNumber": "",
"idlh": "",
"aegl2": "",
"erpg2": "",
"twa": "",
"stel": "",
"responseDistanceFire": "",
"responseDistanceSpillDay": "",
"responseDistanceSpillNight": "",
"marineResponse": "",
"ppeClose": "",
"ppeFar": "",
"msds": {
"hazard": "",
"firstAid": "",
"fireFighting": "",
"spillResponse": "",
"exposure": "",
"regulation": ""
},
"emsCode": "",
"emsFire": "",
"emsSpill": "",
"emsFirstAid": "",
"sebc": ""
}`;
interface OcrResult {
transportMethod?: string;
state?: string;
color?: string;
odor?: string;
flashPoint?: string;
autoIgnition?: string;
boilingPoint?: string;
density?: string;
solubility?: string;
vaporPressure?: string;
vaporDensity?: string;
explosionRange?: string;
nfpa?: { health: number; fire: number; reactivity: number; special: string };
hazardClass?: string;
ergNumber?: string;
idlh?: string;
aegl2?: string;
erpg2?: string;
twa?: string;
stel?: string;
responseDistanceFire?: string;
responseDistanceSpillDay?: string;
responseDistanceSpillNight?: string;
marineResponse?: string;
ppeClose?: string;
ppeFar?: string;
msds?: {
hazard?: string;
firstAid?: string;
fireFighting?: string;
spillResponse?: string;
exposure?: string;
regulation?: string;
};
emsCode?: string;
emsFire?: string;
emsSpill?: string;
emsFirstAid?: string;
sebc?: string;
}
function loadExisting<T>(path: string, fallback: T): T {
if (!existsSync(path)) return fallback;
try {
return JSON.parse(readFileSync(path, 'utf-8'));
} catch {
return fallback;
}
}
function extractJson(text: string): OcrResult | null {
const cleaned = text.replace(/```json\s*/gi, '').replace(/```\s*$/g, '').trim();
const firstBrace = cleaned.indexOf('{');
const lastBrace = cleaned.lastIndexOf('}');
if (firstBrace < 0 || lastBrace < 0) return null;
try {
return JSON.parse(cleaned.slice(firstBrace, lastBrace + 1));
} catch {
return null;
}
}
async function callVision(client: Anthropic, imagePath: string): Promise<OcrResult> {
const imageData = readFileSync(imagePath).toString('base64');
const ext = extname(imagePath).slice(1).toLowerCase();
const mediaType = (ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : 'image/png') as
| 'image/png'
| 'image/jpeg';
const response = await client.messages.create({
model: MODEL,
max_tokens: 4096,
system: [
{
type: 'text',
text: SYSTEM_PROMPT,
cache_control: { type: 'ephemeral' },
},
],
messages: [
{
role: 'user',
content: [
{
type: 'image',
source: { type: 'base64', media_type: mediaType, data: imageData },
},
{
type: 'text',
text: '이 HNS 비상대응 카드 이미지에서 모든 필드를 추출해 JSON으로 반환하세요.',
},
],
},
],
});
const textBlock = response.content.find((b) => b.type === 'text');
if (!textBlock || textBlock.type !== 'text') {
throw new Error('응답에 텍스트 블록 없음');
}
const result = extractJson(textBlock.text);
if (!result) {
throw new Error(`JSON 파싱 실패: ${textBlock.text.slice(0, 200)}`);
}
return result;
}
async function processWithRetry(
client: Anthropic,
imagePath: string,
nameKr: string,
): Promise<OcrResult> {
let lastErr: unknown;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
return await callVision(client, imagePath);
} catch (err) {
lastErr = err;
const wait = 1000 * Math.pow(2, attempt - 1);
console.warn(`[${nameKr}] 시도 ${attempt} 실패, ${wait}ms 후 재시도: ${String(err).slice(0, 120)}`);
await new Promise((r) => setTimeout(r, wait));
}
}
throw lastErr;
}
async function runPool<T>(items: T[], worker: (item: T, idx: number) => Promise<void>) {
let cursor = 0;
const workers = Array.from({ length: CONCURRENCY }, async () => {
while (cursor < items.length) {
const idx = cursor++;
await worker(items[idx], idx);
}
});
await Promise.all(workers);
}
async function main() {
if (!process.env.ANTHROPIC_API_KEY) {
console.error('ANTHROPIC_API_KEY 환경변수가 없습니다.');
process.exit(1);
}
const client = new Anthropic();
if (!existsSync(IMG_DIR)) {
console.error(`이미지 디렉토리 없음: ${IMG_DIR}`);
process.exit(1);
}
const allImages = readdirSync(IMG_DIR).filter((f) => /\.(png|jpg|jpeg)$/i.test(f));
const images = OCR_ONLY
? allImages.filter((f) => OCR_ONLY.includes(basename(f, extname(f))))
: allImages;
const existing: Record<string, OcrResult> = loadExisting(OCR_PATH, {});
const failures: Record<string, string> = loadExisting(FAIL_PATH, {});
let pending = images.filter((f) => {
const nameKr = basename(f, extname(f));
return !(nameKr in existing);
});
if (OCR_LIMIT && Number.isFinite(OCR_LIMIT)) {
pending = pending.slice(0, OCR_LIMIT);
}
console.log(`[OCR] 전체 ${allImages.length}개 중 대상 ${images.length}개, 이미 처리 ${Object.keys(existing).length}개, 이번 실행 ${pending.length}`);
console.log(`[모델] ${MODEL}, 동시 ${CONCURRENCY}, 재시도 최대 ${MAX_RETRIES}`);
console.log(`[출력] ${OCR_PATH}`);
let done = 0;
let failed = 0;
await runPool(pending, async (file, idx) => {
const nameKr = basename(file, extname(file));
const path = resolve(IMG_DIR, file);
try {
const result = await processWithRetry(client, path, nameKr);
existing[nameKr] = result;
delete failures[nameKr];
done++;
if (done % 10 === 0 || done === pending.length) {
writeFileSync(OCR_PATH, JSON.stringify(existing, null, 2), 'utf-8');
console.log(` 진행 ${done}/${pending.length} (실패 ${failed}) - 중간 저장`);
}
} catch (err) {
failed++;
failures[nameKr] = String(err).slice(0, 500);
console.error(`[실패] ${nameKr}: ${String(err).slice(0, 200)}`);
}
});
writeFileSync(OCR_PATH, JSON.stringify(existing, null, 2), 'utf-8');
writeFileSync(FAIL_PATH, JSON.stringify(failures, null, 2), 'utf-8');
console.log(`\n[완료] 성공 ${Object.keys(existing).length} / 실패 ${Object.keys(failures).length}`);
console.log(` OCR 결과: ${OCR_PATH}`);
if (Object.keys(failures).length > 0) {
console.log(` 실패 목록: ${FAIL_PATH} (재실행하면 실패분만 재시도)`);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

파일 보기

@ -0,0 +1,324 @@
"""로컬 EasyOCR 기반 HNS 카드 이미지 파싱.
전용 venv(.venv) 설치된 easyocr을 사용한다.
1. 이미지 EasyOCR (bbox, text, conf) 리스트
2. y좌표로 그룹화 x좌표 정렬
3. 레이블 키워드 기반 필드 매핑 (정규식)
4. 결과를 out/ocr.json 누적 저장 (재실행 가능)
실행:
cd backend/scripts/hns-import
source .venv/Scripts/activate # Windows Git Bash
python ocr-local.py [--limit N] [--only 벤젠,톨루엔,...]
"""
from __future__ import annotations
import argparse
import io
import json
import os
import re
import sys
from pathlib import Path
from typing import Any
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
SCRIPT_DIR = Path(__file__).parent.resolve()
OUT_DIR = SCRIPT_DIR / 'out'
IMG_DIR = OUT_DIR / 'images'
OCR_PATH_DEFAULT = OUT_DIR / 'ocr.json'
FAIL_PATH_DEFAULT = OUT_DIR / 'ocr-failures.json'
# ────────── 필드 레이블 패턴 (EasyOCR 오인식 변형 포함) ──────────
# 각 필드의 후보 레이블 문자열(공백 제거 후 비교). 한글 OCR이 종종 비슷한 글자로 오인식되므로
# 대표적인 변형도 함께 등록 (예: "인화점" ↔ "인회점", "끓는점" ↔ "꿈는점" ↔ "끝는점").
LABEL_CANDIDATES: dict[str, list[str]] = {
'casNumber': ['CAS번호', 'CASNO', 'CAS'],
'unNumber': ['UN번호', 'UNNO', 'UN'],
'transportMethod': ['운송방법', '운승방벌', '운송방벌', '운송방립', '운송'],
'usage': ['용도'],
'state': ['성상', '상태', '형태'],
'color': ['색상', ''],
'odor': ['냄새'],
'flashPoint': ['인화점', '인회점', '인하점', '인호점'],
'autoIgnition': ['발화점', '발회점', '발하점'],
'boilingPoint': ['끓는점', '꿈는점', '끝는점', '끊는점'],
'density': ['비중'],
'solubility': ['용해도', '용해'],
'vaporPressure': ['증기압', '증기압력'],
'vaporDensity': ['증기밀도'],
'explosionRange': ['폭발범위', '곡발범위', '폭범위', '폭발한계'],
'idlh': ['IDLH'],
'aegl2': ['AEGL-2', 'AEGL2'],
'erpg2': ['ERPG-2', 'ERPG2'],
'twa': ['TWA'],
'stel': ['STEL'],
'ergNumber': ['ERG번호', 'ERG'],
'hazardClass': ['위험분류', '위험', '분류'],
'synonymsKr': ['유사명'],
'responseDistanceFire': ['대피거리', '머피거리'],
'ppeClose': ['근거리(레벨A)', '근거리레벨A', '근거리', '레벨A'],
'ppeFar': ['원거리(레벨C)', '원거리레벨C', '원거리', '레벨C'],
'emsFire': ['화재(F-E)', '화재(F-C)', '화재(F-D)', '화재대응'],
'emsSpill': ['유출(S-U)', '유출(S-O)', '유출(S-D)', '해상유출'],
'marineResponse': ['해상대응', '해상'],
}
def _norm_label(s: str) -> str:
"""공백/특수문자 제거 후 비교용 정규화."""
return re.sub(r'[\s,.·()\[\]:;\'"-]+', '', s).strip()
LABEL_INDEX: dict[str, str] = {}
for _field, _candidates in LABEL_CANDIDATES.items():
for _cand in _candidates:
LABEL_INDEX[_norm_label(_cand)] = _field
# NFPA 셀 값(한 자릿수 0~4) 추출용
NFPA_VALUE_RE = re.compile(r'^[0-4]$')
def group_rows(items: list[dict], y_tolerance_ratio: float = 0.6) -> list[list[dict]]:
"""텍스트 조각들을 y 좌표 기준으로 행 단위로 그룹화 (글자 높이 비례 허용치)."""
if not items:
return []
heights = [it['y1'] - it['y0'] for it in items]
median_h = sorted(heights)[len(heights) // 2]
y_tol = max(8, median_h * y_tolerance_ratio)
sorted_items = sorted(items, key=lambda it: it['cy'])
rows: list[list[dict]] = []
for it in sorted_items:
if rows and abs(it['cy'] - rows[-1][-1]['cy']) <= y_tol:
rows[-1].append(it)
else:
rows.append([it])
for row in rows:
row.sort(key=lambda it: it['cx'])
return rows
def _match_label(text: str) -> str | None:
key = _norm_label(text)
if not key:
return None
# 정확 일치 우선
if key in LABEL_INDEX:
return LABEL_INDEX[key]
# 접두 일치 (OCR이 뒤에 잡티를 붙이는 경우)
for cand_key, field in LABEL_INDEX.items():
if len(cand_key) >= 2 and key.startswith(cand_key):
return field
return None
def parse_card(items: list[dict]) -> dict[str, Any]:
"""OCR 결과 목록을 필드 dict로 변환."""
rows = group_rows(items)
result: dict[str, Any] = {}
# 1) 행 내 "레이블 → 값" 쌍 추출
# 같은 행에서 레이블 바로 뒤의 첫 non-label 텍스트를 값으로 사용.
for row in rows:
# 여러 레이블이 같은 행에 있을 수 있음 (2컬럼 표 구조)
idx = 0
while idx < len(row):
field = _match_label(row[idx]['text'])
if field:
# 다음 non-label 조각을 값으로 취함
value_parts: list[str] = []
j = idx + 1
while j < len(row):
nxt = row[j]
if _match_label(nxt['text']):
break
value_parts.append(nxt['text'])
j += 1
if value_parts and field not in result:
value = ' '.join(value_parts).strip()
if value and value not in ('-', '', 'N/A'):
result[field] = value
idx = j
else:
idx += 1
# 2) NFPA 추출: "NFPA" 단어 주변의 0~4 숫자 3개
nfpa_idx_row: int | None = None
for ri, row in enumerate(rows):
for cell in row:
if re.search(r'NFPA', cell['text']):
nfpa_idx_row = ri
break
if nfpa_idx_row is not None:
break
if nfpa_idx_row is not None:
# 해당 행 + 다음 2개 행에서 0~4 숫자 수집
candidates: list[int] = []
for ri in range(nfpa_idx_row, min(nfpa_idx_row + 3, len(rows))):
for cell in rows[ri]:
m = NFPA_VALUE_RE.match(cell['text'].strip())
if m:
candidates.append(int(cell['text'].strip()))
if len(candidates) >= 3:
break
if len(candidates) >= 3:
break
if len(candidates) >= 3:
result['nfpa'] = {
'health': candidates[0],
'fire': candidates[1],
'reactivity': candidates[2],
'special': '',
}
# 3) EmS 코드 (F-x / S-x 패턴)
all_text = ' '.join(cell['text'] for row in rows for cell in row)
f_match = re.search(r'F\s*-\s*([A-Z])', all_text)
s_match = re.search(r'S\s*-\s*([A-Z])', all_text)
if f_match or s_match:
parts = []
if f_match:
parts.append(f'F-{f_match.group(1)}')
if s_match:
parts.append(f'S-{s_match.group(1)}')
if parts:
result['emsCode'] = ', '.join(parts)
# 4) ERG 번호 (3자리 숫자, P 접미사 가능, "ERG" 키워드 근처)
erg_match = re.search(r'ERG[^\d]{0,10}(\d{3}P?)', all_text)
if erg_match:
result['ergNumber'] = erg_match.group(1)
# 5) EmS F-x / S-x 코드 뒤의 본문 (생략 - 이미지 내 텍스트 밀도가 낮아 행 단위로 이미 잡힘)
return result
def _preprocess_image(pil_img, upscale: float = 2.5):
"""한글 OCR 정확도 향상을 위한 업스케일 + 샤프닝 + 대비 향상."""
from PIL import Image, ImageEnhance, ImageFilter
import numpy as np
if pil_img.mode != 'RGB':
pil_img = pil_img.convert('RGB')
# 1) 업스케일 (LANCZOS)
w, h = pil_img.size
pil_img = pil_img.resize((int(w * upscale), int(h * upscale)), Image.LANCZOS)
# 2) 대비 향상
pil_img = ImageEnhance.Contrast(pil_img).enhance(1.3)
# 3) 샤프닝
pil_img = pil_img.filter(ImageFilter.UnsharpMask(radius=1.5, percent=150, threshold=2))
return np.array(pil_img)
def run_ocr(image_path: Path, reader, upscale: float = 2.5) -> list[dict]:
# OpenCV가 Windows에서 한글 경로를 못 읽으므로 PIL로 로드 후 전처리
from PIL import Image
with Image.open(image_path) as pil:
img = _preprocess_image(pil, upscale=upscale)
raw = reader.readtext(img, detail=1, paragraph=False)
items: list[dict] = []
for bbox, text, conf in raw:
if not text or not str(text).strip():
continue
xs = [p[0] for p in bbox]
ys = [p[1] for p in bbox]
items.append({
'text': str(text).strip(),
'cx': sum(xs) / 4.0,
'cy': sum(ys) / 4.0,
'x0': min(xs),
'x1': max(xs),
'y0': min(ys),
'y1': max(ys),
'conf': float(conf),
})
return items
def load_json(path: Path, fallback):
if not path.exists():
return fallback
try:
return json.loads(path.read_text(encoding='utf-8'))
except Exception:
return fallback
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument('--limit', type=int, default=None)
parser.add_argument('--only', type=str, default=None,
help='파이프(|)로 구분된 물질명 리스트')
parser.add_argument('--img-dir', type=Path, default=IMG_DIR)
parser.add_argument('--out', type=Path, default=OCR_PATH_DEFAULT)
parser.add_argument('--fail', type=Path, default=FAIL_PATH_DEFAULT)
parser.add_argument('--debug', action='store_true',
help='파싱 중간 결과(row 단위) 함께 출력')
args = parser.parse_args()
import easyocr # noqa: WPS433
print('[로딩] EasyOCR 모델 (ko + en)... (최초 실행 시 수 분 소요)')
reader = easyocr.Reader(['ko', 'en'], gpu=False, verbose=False)
print('[로딩] 완료')
images = sorted([p for p in args.img_dir.iterdir() if p.suffix.lower() in {'.png', '.jpg', '.jpeg'}])
if args.only:
only_set = {s.strip() for s in args.only.split('|') if s.strip()}
images = [p for p in images if p.stem in only_set]
existing: dict[str, Any] = load_json(args.out, {})
failures: dict[str, str] = load_json(args.fail, {})
pending = [p for p in images if p.stem not in existing]
if args.limit:
pending = pending[: args.limit]
print(f'[대상] {len(images)}개 중 대기 {len(pending)}개, 이미 처리 {len(existing)}')
ok = 0
fail = 0
for i, path in enumerate(pending, start=1):
name = path.stem
try:
items = run_ocr(path, reader)
parsed = parse_card(items)
if args.debug:
print(f'\n--- {name} (텍스트 {len(items)}개) ---')
for row in group_rows(items):
print(' |', ''.join(f'{c["text"]}' for c in row))
print(f' → parsed: {parsed}')
existing[name] = parsed
if name in failures:
del failures[name]
ok += 1
except Exception as e: # noqa: BLE001
failures[name] = f'{type(e).__name__}: {e}'[:500]
fail += 1
print(f'[실패] {name}: {e}')
if i % 10 == 0 or i == len(pending):
args.out.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding='utf-8')
args.fail.write_text(json.dumps(failures, ensure_ascii=False, indent=2), encoding='utf-8')
print(f' 진행 {i}/{len(pending)} (성공 {ok}, 실패 {fail}) - 중간 저장')
args.out.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding='utf-8')
args.fail.write_text(json.dumps(failures, ensure_ascii=False, indent=2), encoding='utf-8')
print(f'\n[완료] 성공 {ok} / 실패 {fail}')
print(f' 결과: {args.out}')
if failures:
print(f' 실패 목록: {args.fail}')
if __name__ == '__main__':
main()

파일 보기

@ -0,0 +1,29 @@
# extract-excel.py 용
openpyxl>=3.1.0
# ocr-local.py 용 (EasyOCR 기반 로컬 OCR, 대안 파이프라인)
easyocr==1.7.2
filelock==3.19.1
fsspec==2025.10.0
ImageIO==2.37.2
Jinja2==3.1.6
lazy-loader==0.5
MarkupSafe==3.0.3
mpmath==1.3.0
networkx==3.2.1
ninja==1.13.0
numpy==2.0.2
opencv-python-headless==4.13.0.92
packaging==26.1
pillow==11.3.0
pyclipper==1.3.0.post6
python-bidi==0.6.7
PyYAML==6.0.3
scikit-image==0.24.0
scipy==1.13.1
shapely==2.0.7
sympy==1.14.0
tifffile==2024.8.30
torch==2.8.0
torchvision==0.23.0
typing_extensions==4.15.0

파일 보기

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

파일 보기

@ -368,7 +368,7 @@ export async function updateSatRequestStatus(sn: number, sttsCd: string): Promis
// OIL INFERENCE (GPU 서버 프록시) // OIL INFERENCE (GPU 서버 프록시)
// ============================================================ // ============================================================
const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://localhost:5001'; const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://211.208.115.83:5001';
const OIL_INFERENCE_URL = process.env.OIL_INFERENCE_URL || 'http://localhost:8090'; const OIL_INFERENCE_URL = process.env.OIL_INFERENCE_URL || 'http://localhost:8090';
const INFERENCE_TIMEOUT_MS = 10_000; const INFERENCE_TIMEOUT_MS = 10_000;

파일 보기

@ -24,8 +24,14 @@ async function seedHnsSubstances() {
let inserted = 0 let inserted = 0
// varchar 길이 제한에 맞춰 첫 번째 토큰만 검색 컬럼에 저장 (원본은 DATA JSONB에 보존)
const firstToken = (v: unknown, max: number): string | null => {
if (v == null) return null
const s = String(v).split(/[\n,;/]/)[0].trim()
return s ? s.slice(0, max) : null
}
for (const s of HNS_SEARCH_DB) { for (const s of HNS_SEARCH_DB) {
// 검색용 컬럼 추출, 나머지는 DATA JSONB로 저장
const { abbreviation, nameKr, nameEn, unNumber, casNumber, sebc, ...detailData } = s const { abbreviation, nameKr, nameEn, unNumber, casNumber, sebc, ...detailData } = s
await client.query( await client.query(
@ -39,7 +45,16 @@ async function seedHnsSubstances() {
CAS_NO = EXCLUDED.CAS_NO, CAS_NO = EXCLUDED.CAS_NO,
SEBC = EXCLUDED.SEBC, SEBC = EXCLUDED.SEBC,
DATA = EXCLUDED.DATA`, DATA = EXCLUDED.DATA`,
[s.id, abbreviation, nameKr, nameEn, unNumber, casNumber, sebc, JSON.stringify(detailData)] [
s.id,
firstToken(abbreviation, 50),
firstToken(nameKr, 200) ?? '',
firstToken(nameEn, 200),
firstToken(unNumber, 10),
firstToken(casNumber, 20),
firstToken(sebc, 50),
JSON.stringify(detailData),
]
) )
inserted++ inserted++

파일 보기

@ -0,0 +1,20 @@
import { Router } from 'express';
import { requireAuth } from '../auth/authMiddleware.js';
import { listGscAccidents } from './gscAccidentsService.js';
const router = Router();
// ============================================================
// GET /api/gsc/accidents — 외부 수집 사고 목록 (최신 20건)
// ============================================================
router.get('/', requireAuth, async (_req, res) => {
try {
const accidents = await listGscAccidents(20);
res.json(accidents);
} catch (err) {
console.error('[gsc] 사고 목록 조회 오류:', err);
res.status(500).json({ error: '사고 목록 조회 중 오류가 발생했습니다.' });
}
});
export default router;

파일 보기

@ -0,0 +1,44 @@
import { wingPool } from '../db/wingDb.js';
export interface GscAccidentListItem {
acdntSn: number;
acdntMngNo: string;
pollNm: string;
pollDate: string | null;
lat: number | null;
lon: number | null;
}
export async function listGscAccidents(limit = 20): Promise<GscAccidentListItem[]> {
const sql = `
SELECT
ACDNT_SN AS "acdntSn",
ACDNT_CD AS "acdntMngNo",
ACDNT_NM AS "pollNm",
to_char(OCCRN_DTM, 'YYYY-MM-DD"T"HH24:MI') AS "pollDate",
LAT AS "lat",
LNG AS "lon"
FROM wing.ACDNT
WHERE ACDNT_NM IS NOT NULL
ORDER BY OCCRN_DTM DESC NULLS LAST
LIMIT $1
`;
const result = await wingPool.query<{
acdntSn: number;
acdntMngNo: string;
pollNm: string;
pollDate: string | null;
lat: string | null;
lon: string | null;
}>(sql, [limit]);
return result.rows.map((row) => ({
acdntSn: row.acdntSn,
acdntMngNo: row.acdntMngNo,
pollNm: row.pollNm,
pollDate: row.pollDate,
lat: row.lat != null ? Number(row.lat) : null,
lon: row.lon != null ? Number(row.lon) : null,
}));
}

파일 보기

@ -12,11 +12,13 @@ const router = express.Router()
// GET /api/hns/analyses — 분석 목록 // GET /api/hns/analyses — 분석 목록
router.get('/analyses', requireAuth, requirePermission('hns', 'READ'), async (req, res) => { router.get('/analyses', requireAuth, requirePermission('hns', 'READ'), async (req, res) => {
try { try {
const { status, substance, search } = req.query const { status, substance, search, acdntSn } = req.query
const acdntSnNum = acdntSn ? parseInt(acdntSn as string, 10) : undefined
const items = await listAnalyses({ const items = await listAnalyses({
status: status as string | undefined, status: status as string | undefined,
substance: substance as string | undefined, substance: substance as string | undefined,
search: search as string | undefined, search: search as string | undefined,
acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined,
}) })
res.json(items) res.json(items)
} catch (err) { } catch (err) {
@ -48,13 +50,15 @@ router.get('/analyses/:sn', requireAuth, requirePermission('hns', 'READ'), async
// POST /api/hns/analyses — 분석 생성 // POST /api/hns/analyses — 분석 생성
router.post('/analyses', requireAuth, requirePermission('hns', 'CREATE'), async (req, res) => { router.post('/analyses', requireAuth, requirePermission('hns', 'CREATE'), async (req, res) => {
try { try {
const { anlysNm, acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm } = req.body const { anlysNm, acdntSn, acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm } = req.body
if (!anlysNm) { if (!anlysNm) {
res.status(400).json({ error: '분석명은 필수입니다.' }) res.status(400).json({ error: '분석명은 필수입니다.' })
return return
} }
const acdntSnNum = acdntSn != null ? parseInt(String(acdntSn), 10) : undefined
const result = await createAnalysis({ const result = await createAnalysis({
anlysNm, acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm, anlysNm, acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined,
acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm,
}) })
res.status(201).json(result) res.status(201).json(result)
} catch (err) { } catch (err) {

파일 보기

@ -94,6 +94,7 @@ export async function searchSubstances(params: HnsSearchParams) {
interface HnsAnalysisItem { interface HnsAnalysisItem {
hnsAnlysSn: number hnsAnlysSn: number
acdntSn: number | null
anlysNm: string anlysNm: string
acdntDtm: string | null acdntDtm: string | null
locNm: string | null locNm: string | null
@ -118,11 +119,13 @@ interface ListAnalysesInput {
status?: string status?: string
substance?: string substance?: string
search?: string search?: string
acdntSn?: number
} }
function rowToAnalysis(r: Record<string, unknown>): HnsAnalysisItem { function rowToAnalysis(r: Record<string, unknown>): HnsAnalysisItem {
return { return {
hnsAnlysSn: r.hns_anlys_sn as number, hnsAnlysSn: r.hns_anlys_sn as number,
acdntSn: (r.acdnt_sn as number) ?? null,
anlysNm: r.anlys_nm as string, anlysNm: r.anlys_nm as string,
acdntDtm: r.acdnt_dtm as string | null, acdntDtm: r.acdnt_dtm as string | null,
locNm: r.loc_nm as string | null, locNm: r.loc_nm as string | null,
@ -146,7 +149,7 @@ function rowToAnalysis(r: Record<string, unknown>): HnsAnalysisItem {
export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysisItem[]> { export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysisItem[]> {
const conditions: string[] = ["USE_YN = 'Y'"] const conditions: string[] = ["USE_YN = 'Y'"]
const params: string[] = [] const params: (string | number)[] = []
let idx = 1 let idx = 1
if (input.status) { if (input.status) {
@ -162,9 +165,13 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysi
params.push(input.search) params.push(input.search)
idx++ idx++
} }
if (input.acdntSn != null) {
conditions.push(`ACDNT_SN = $${idx++}`)
params.push(input.acdntSn)
}
const { rows } = await wingPool.query( const { rows } = await wingPool.query(
`SELECT HNS_ANLYS_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT, `SELECT HNS_ANLYS_SN, ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD, SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
WIND_SPD, WIND_DIR, EXEC_STTS_CD, RISK_CD, ANALYST_NM, WIND_SPD, WIND_DIR, EXEC_STTS_CD, RISK_CD, ANALYST_NM,
RSLT_DATA, REG_DTM RSLT_DATA, REG_DTM
@ -179,7 +186,7 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysi
export async function getAnalysis(sn: number): Promise<HnsAnalysisItem | null> { export async function getAnalysis(sn: number): Promise<HnsAnalysisItem | null> {
const { rows } = await wingPool.query( const { rows } = await wingPool.query(
`SELECT HNS_ANLYS_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT, `SELECT HNS_ANLYS_SN, ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD, SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD, WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD,
EXEC_STTS_CD, RISK_CD, ANALYST_NM, EXEC_STTS_CD, RISK_CD, ANALYST_NM,
@ -194,6 +201,7 @@ export async function getAnalysis(sn: number): Promise<HnsAnalysisItem | null> {
export async function createAnalysis(input: { export async function createAnalysis(input: {
anlysNm: string anlysNm: string
acdntSn?: number
acdntDtm?: string acdntDtm?: string
locNm?: string locNm?: string
lon?: number lon?: number
@ -213,21 +221,21 @@ export async function createAnalysis(input: {
}): Promise<{ hnsAnlysSn: number }> { }): Promise<{ hnsAnlysSn: number }> {
const { rows } = await wingPool.query( const { rows } = await wingPool.query(
`INSERT INTO HNS_ANALYSIS ( `INSERT INTO HNS_ANALYSIS (
ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT, ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
GEOM, LOC_DC, GEOM, LOC_DC,
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD, SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD, WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD,
ANALYST_NM, EXEC_STTS_CD ANALYST_NM, EXEC_STTS_CD
) VALUES ( ) VALUES (
$1, $2, $3, $4::numeric, $5::numeric, $1, $2, $3, $4, $5::numeric, $6::numeric,
CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($4::double precision, $5::double precision), 4326) END, CASE WHEN $5 IS NOT NULL AND $6 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($5::double precision, $6::double precision), 4326) END,
CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN $4::text || ' + ' || $5::text END, CASE WHEN $5 IS NOT NULL AND $6 IS NOT NULL THEN $5::text || ' + ' || $6::text END,
$6, $7, $8, $9, $10, $11, $7, $8, $9, $10, $11, $12,
$12, $13, $14, $15, $16, $13, $14, $15, $16, $17,
$17, 'PENDING' $18, 'PENDING'
) RETURNING HNS_ANLYS_SN`, ) RETURNING HNS_ANLYS_SN`,
[ [
input.anlysNm, input.acdntDtm || null, input.locNm || null, input.lon || null, input.lat || null, input.acdntSn || null, input.anlysNm, input.acdntDtm || null, input.locNm || null, input.lon || null, input.lat || null,
input.sbstNm || null, input.spilQty || null, input.spilUnitCd || 'KL', input.sbstNm || null, input.spilQty || null, input.spilUnitCd || 'KL',
input.fcstHr || null, input.algoCd || null, input.critMdlCd || null, input.fcstHr || null, input.algoCd || null, input.critMdlCd || null,
input.windSpd || null, input.windDir || null, input.temp || null, input.humid || null, input.atmStblCd || null, input.windSpd || null, input.windDir || null, input.temp || null, input.humid || null, input.atmStblCd || null,

파일 보기

@ -7,6 +7,7 @@ import {
getIncidentWeather, getIncidentWeather,
saveIncidentWeather, saveIncidentWeather,
getIncidentMedia, getIncidentMedia,
getIncidentImageAnalysis,
} from './incidentsService.js'; } from './incidentsService.js';
const router = Router(); const router = Router();
@ -133,4 +134,26 @@ router.get('/:sn/media', requireAuth, async (req, res) => {
} }
}); });
// ============================================================
// GET /api/incidents/:sn/image-analysis — 이미지 분석 데이터
// ============================================================
router.get('/:sn/image-analysis', requireAuth, async (req, res) => {
try {
const sn = parseInt(req.params.sn as string, 10);
if (isNaN(sn)) {
res.status(400).json({ error: '유효하지 않은 사고 번호입니다.' });
return;
}
const data = await getIncidentImageAnalysis(sn);
if (!data) {
res.status(404).json({ error: '이미지 분석 데이터가 없습니다.' });
return;
}
res.json(data);
} catch (err) {
console.error('[incidents] 이미지 분석 데이터 조회 오류:', err);
res.status(500).json({ error: '이미지 분석 데이터 조회 중 오류가 발생했습니다.' });
}
});
export default router; export default router;

파일 보기

@ -24,7 +24,11 @@ interface IncidentListItem {
spilQty: number | null; spilQty: number | null;
spilUnitCd: string | null; spilUnitCd: string | null;
fcstHr: number | null; fcstHr: number | null;
hasPredCompleted: boolean;
hasHnsCompleted: boolean;
hasRescueCompleted: boolean;
mediaCnt: number; mediaCnt: number;
hasImgAnalysis: boolean;
} }
interface PredExecItem { interface PredExecItem {
@ -111,11 +115,29 @@ export async function listIncidents(filters: {
a.LAT, a.LNG, a.LOC_DC, a.OCCRN_DTM, a.REGION_NM, a.OFFICE_NM, a.LAT, a.LNG, a.LOC_DC, a.OCCRN_DTM, a.REGION_NM, a.OFFICE_NM,
a.SVRT_CD, a.VESSEL_TP, a.PHASE_CD, a.ANALYST_NM, a.SVRT_CD, a.VESSEL_TP, a.PHASE_CD, a.ANALYST_NM,
s.OIL_TP_CD, s.SPIL_QTY, s.SPIL_UNIT_CD, s.FCST_HR, s.OIL_TP_CD, s.SPIL_QTY, s.SPIL_UNIT_CD, s.FCST_HR,
COALESCE(s.HAS_IMG_ANALYSIS, FALSE) AS has_img_analysis,
EXISTS (
SELECT 1 FROM wing.PRED_EXEC pe
WHERE pe.ACDNT_SN = a.ACDNT_SN AND pe.EXEC_STTS_CD = 'COMPLETED'
) AS has_pred_completed,
EXISTS (
SELECT 1 FROM wing.HNS_ANALYSIS h
WHERE h.ACDNT_SN = a.ACDNT_SN
AND h.EXEC_STTS_CD = 'COMPLETED'
AND h.USE_YN = 'Y'
) AS has_hns_completed,
EXISTS (
SELECT 1 FROM wing.RESCUE_OPS r
WHERE r.ACDNT_SN = a.ACDNT_SN
AND r.STTS_CD = 'RESOLVED'
AND r.USE_YN = 'Y'
) AS has_rescue_completed,
COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0) COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0)
+ COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt + COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt
FROM wing.ACDNT a FROM wing.ACDNT a
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR,
IMG_RSLT_DATA IS NOT NULL AS HAS_IMG_ANALYSIS
FROM wing.SPIL_DATA FROM wing.SPIL_DATA
WHERE ACDNT_SN = a.ACDNT_SN WHERE ACDNT_SN = a.ACDNT_SN
ORDER BY SPIL_DATA_SN ORDER BY SPIL_DATA_SN
@ -148,7 +170,11 @@ export async function listIncidents(filters: {
spilQty: r.spil_qty != null ? parseFloat(r.spil_qty as string) : null, spilQty: r.spil_qty != null ? parseFloat(r.spil_qty as string) : null,
spilUnitCd: (r.spil_unit_cd as string) ?? null, spilUnitCd: (r.spil_unit_cd as string) ?? null,
fcstHr: (r.fcst_hr as number) ?? null, fcstHr: (r.fcst_hr as number) ?? null,
hasPredCompleted: r.has_pred_completed as boolean,
hasHnsCompleted: r.has_hns_completed as boolean,
hasRescueCompleted: r.has_rescue_completed as boolean,
mediaCnt: Number(r.media_cnt), mediaCnt: Number(r.media_cnt),
hasImgAnalysis: (r.has_img_analysis as boolean) ?? false,
})); }));
} }
@ -162,11 +188,29 @@ export async function getIncident(acdntSn: number): Promise<IncidentDetail | nul
a.LAT, a.LNG, a.LOC_DC, a.OCCRN_DTM, a.REGION_NM, a.OFFICE_NM, a.LAT, a.LNG, a.LOC_DC, a.OCCRN_DTM, a.REGION_NM, a.OFFICE_NM,
a.SVRT_CD, a.VESSEL_TP, a.PHASE_CD, a.ANALYST_NM, a.SVRT_CD, a.VESSEL_TP, a.PHASE_CD, a.ANALYST_NM,
s.OIL_TP_CD, s.SPIL_QTY, s.SPIL_UNIT_CD, s.FCST_HR, s.OIL_TP_CD, s.SPIL_QTY, s.SPIL_UNIT_CD, s.FCST_HR,
COALESCE(s.HAS_IMG_ANALYSIS, FALSE) AS has_img_analysis,
EXISTS (
SELECT 1 FROM wing.PRED_EXEC pe
WHERE pe.ACDNT_SN = a.ACDNT_SN AND pe.EXEC_STTS_CD = 'COMPLETED'
) AS has_pred_completed,
EXISTS (
SELECT 1 FROM wing.HNS_ANALYSIS h
WHERE h.ACDNT_SN = a.ACDNT_SN
AND h.EXEC_STTS_CD = 'COMPLETED'
AND h.USE_YN = 'Y'
) AS has_hns_completed,
EXISTS (
SELECT 1 FROM wing.RESCUE_OPS r
WHERE r.ACDNT_SN = a.ACDNT_SN
AND r.STTS_CD = 'RESOLVED'
AND r.USE_YN = 'Y'
) AS has_rescue_completed,
COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0) COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0)
+ COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt + COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt
FROM wing.ACDNT a FROM wing.ACDNT a
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR,
IMG_RSLT_DATA IS NOT NULL AS HAS_IMG_ANALYSIS
FROM wing.SPIL_DATA FROM wing.SPIL_DATA
WHERE ACDNT_SN = a.ACDNT_SN WHERE ACDNT_SN = a.ACDNT_SN
ORDER BY SPIL_DATA_SN ORDER BY SPIL_DATA_SN
@ -205,7 +249,11 @@ export async function getIncident(acdntSn: number): Promise<IncidentDetail | nul
spilQty: r.spil_qty != null ? parseFloat(r.spil_qty as string) : null, spilQty: r.spil_qty != null ? parseFloat(r.spil_qty as string) : null,
spilUnitCd: (r.spil_unit_cd as string) ?? null, spilUnitCd: (r.spil_unit_cd as string) ?? null,
fcstHr: (r.fcst_hr as number) ?? null, fcstHr: (r.fcst_hr as number) ?? null,
hasPredCompleted: r.has_pred_completed as boolean,
hasHnsCompleted: r.has_hns_completed as boolean,
hasRescueCompleted: r.has_rescue_completed as boolean,
mediaCnt: Number(r.media_cnt), mediaCnt: Number(r.media_cnt),
hasImgAnalysis: (r.has_img_analysis as boolean) ?? false,
predictions, predictions,
weather, weather,
media, media,
@ -419,3 +467,21 @@ export async function getIncidentMedia(acdntSn: number): Promise<MediaInfo | nul
cctvMeta: (r.cctv_meta as Record<string, unknown>) ?? null, cctvMeta: (r.cctv_meta as Record<string, unknown>) ?? null,
}; };
} }
// ============================================================
// 이미지 분석 데이터 조회
// ============================================================
export async function getIncidentImageAnalysis(acdntSn: number): Promise<Record<string, unknown> | null> {
const sql = `
SELECT IMG_RSLT_DATA
FROM wing.SPIL_DATA
WHERE ACDNT_SN = $1 AND IMG_RSLT_DATA IS NOT NULL
ORDER BY SPIL_DATA_SN
LIMIT 1
`;
const { rows } = await wingPool.query(sql, [acdntSn]);
if (rows.length === 0) return null;
return (rows[0] as Record<string, unknown>).img_rslt_data as Record<string, unknown>;
}

파일 보기

@ -74,7 +74,7 @@ function parseMeta(metaStr: string): { lat: number; lon: number; occurredAt: str
return { lat, lon, occurredAt }; return { lat, lon, occurredAt };
} }
export async function analyzeImageFile(imageBuffer: Buffer, originalName: string): Promise<ImageAnalyzeResult> { export async function analyzeImageFile(imageBuffer: Buffer, originalName: string, acdntNmOverride?: string): Promise<ImageAnalyzeResult> {
const fileId = crypto.randomUUID(); const fileId = crypto.randomUUID();
// camTy는 현재 "mx15hdi"로 하드코딩한다. // camTy는 현재 "mx15hdi"로 하드코딩한다.
@ -122,7 +122,7 @@ export async function analyzeImageFile(imageBuffer: Buffer, originalName: string
const volume = firstOil?.volume ?? 0; const volume = firstOil?.volume ?? 0;
// ACDNT INSERT // ACDNT INSERT
const acdntNm = `이미지분석_${new Date().toISOString().slice(0, 16).replace('T', ' ')}`; const acdntNm = acdntNmOverride?.trim() || `이미지분석_${new Date().toISOString().slice(0, 16).replace('T', ' ')}`;
const acdntRes = await wingPool.query( const acdntRes = await wingPool.query(
`INSERT INTO wing.ACDNT `INSERT INTO wing.ACDNT
(ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, OCCRN_DTM, LAT, LNG, ACDNT_STTS_CD, USE_YN, REG_DTM) (ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, OCCRN_DTM, LAT, LNG, ACDNT_STTS_CD, USE_YN, REG_DTM)
@ -145,7 +145,7 @@ export async function analyzeImageFile(imageBuffer: Buffer, originalName: string
await wingPool.query( await wingPool.query(
`INSERT INTO wing.SPIL_DATA `INSERT INTO wing.SPIL_DATA
(ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, IMG_RSLT_DATA, REG_DTM) (ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, IMG_RSLT_DATA, REG_DTM)
VALUES ($1, $2, $3, 'KL', 'CONTINUOUS', 48, $4, NOW())`, VALUES ($1, $2, $3, 'KL', 'CONTINUOUS', 6, $4, NOW())`,
[ [
acdntSn, acdntSn,
OIL_DB_CODE_MAP[oilType] ?? 'BUNKER_C', OIL_DB_CODE_MAP[oilType] ?? 'BUNKER_C',

파일 보기

@ -5,6 +5,7 @@ import {
createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory, createBacktrack, saveBoomLine, listBoomLines, getAnalysisTrajectory,
getSensitiveResourcesByAcdntSn, getSensitiveResourcesGeoJsonByAcdntSn, getSensitiveResourcesByAcdntSn, getSensitiveResourcesGeoJsonByAcdntSn,
getPredictionParticlesGeojsonByAcdntSn, getSensitivityEvaluationGeojsonByAcdntSn, getPredictionParticlesGeojsonByAcdntSn, getSensitivityEvaluationGeojsonByAcdntSn,
getOilSpillSummary,
} from './predictionService.js'; } from './predictionService.js';
import { analyzeImageFile } from './imageAnalyzeService.js'; import { analyzeImageFile } from './imageAnalyzeService.js';
import { isValidNumber } from '../middleware/security.js'; import { isValidNumber } from '../middleware/security.js';
@ -70,6 +71,27 @@ router.get('/analyses/:acdntSn/trajectory', requireAuth, requirePermission('pred
} }
}); });
// GET /api/prediction/analyses/:acdntSn/oil-summary — 유출유 확산 요약 (분할 패널용)
router.get('/analyses/:acdntSn/oil-summary', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
try {
const acdntSn = parseInt(req.params.acdntSn as string, 10);
if (!isValidNumber(acdntSn, 1, 999999)) {
res.status(400).json({ error: '유효하지 않은 사고 번호' });
return;
}
const predRunSn = req.query.predRunSn ? parseInt(req.query.predRunSn as string, 10) : undefined;
const result = await getOilSpillSummary(acdntSn, predRunSn);
if (!result) {
res.json({ primary: null, byModel: {} });
return;
}
res.json(result);
} catch (err) {
console.error('[prediction] oil-summary 조회 오류:', err);
res.status(500).json({ error: 'oil-summary 조회 실패' });
}
});
// GET /api/prediction/analyses/:acdntSn/sensitive-resources — 예측 영역 내 민감자원 집계 // GET /api/prediction/analyses/:acdntSn/sensitive-resources — 예측 영역 내 민감자원 집계
router.get('/analyses/:acdntSn/sensitive-resources', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => { router.get('/analyses/:acdntSn/sensitive-resources', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
try { try {
@ -230,7 +252,8 @@ router.post(
res.status(400).json({ error: '이미지 파일이 필요합니다' }); res.status(400).json({ error: '이미지 파일이 필요합니다' });
return; return;
} }
const result = await analyzeImageFile(req.file.buffer, req.file.originalname); const acdntNm = typeof req.body?.acdntNm === 'string' ? req.body.acdntNm : undefined;
const result = await analyzeImageFile(req.file.buffer, req.file.originalname, acdntNm);
res.json(result); res.json(result);
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof Error) { if (err instanceof Error) {

파일 보기

@ -1,6 +1,16 @@
import { wingPool } from '../db/wingDb.js'; import { wingPool } from '../db/wingDb.js';
import { runBacktrackAnalysis } from './backtrackAnalysisService.js'; import { runBacktrackAnalysis } from './backtrackAnalysisService.js';
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
interface PredictionAnalysis { interface PredictionAnalysis {
acdntSn: number; acdntSn: number;
acdntNm: string; acdntNm: string;
@ -812,3 +822,116 @@ export async function listBoomLines(acdntSn: number): Promise<BoomLineItem[]> {
regDtm: String(r['reg_dtm'] ?? ''), regDtm: String(r['reg_dtm'] ?? ''),
})); }));
} }
// ── 유출유 확산 요약 (통합조회 분할 패널용) ──────────────
export interface OilSpillSummary {
model: string;
forecastDurationHr: number | null;
maxSpreadDistanceKm: number | null;
coastArrivalTimeHr: number | null;
affectedCoastlineKm: number | null;
weatheringRatePct: number | null;
remainingVolumeKl: number | null;
}
export interface OilSpillSummaryResponse {
primary: OilSpillSummary;
byModel: Record<string, OilSpillSummary>;
}
export async function getOilSpillSummary(acdntSn: number, predRunSn?: number): Promise<OilSpillSummaryResponse | null> {
const baseSql = `
SELECT pe.ALGO_CD, pe.RSLT_DATA,
sd.FCST_HR,
ST_Y(a.LOC_GEOM) AS spil_lat,
ST_X(a.LOC_GEOM) AS spil_lon
FROM wing.PRED_EXEC pe
LEFT JOIN wing.SPIL_DATA sd ON sd.ACDNT_SN = pe.ACDNT_SN
LEFT JOIN wing.ACDNT a ON a.ACDNT_SN = pe.ACDNT_SN
WHERE pe.ACDNT_SN = $1
AND pe.ALGO_CD IN ('OPENDRIFT', 'POSEIDON')
AND pe.EXEC_STTS_CD = 'COMPLETED'
AND pe.RSLT_DATA IS NOT NULL
`;
const sql = predRunSn != null
? baseSql + ' AND pe.PRED_RUN_SN = $2 ORDER BY pe.CMPL_DTM DESC'
: baseSql + ' ORDER BY pe.CMPL_DTM DESC';
const params = predRunSn != null ? [acdntSn, predRunSn] : [acdntSn];
const { rows } = await wingPool.query(sql, params);
if (rows.length === 0) return null;
const byModel: Record<string, OilSpillSummary> = {};
// OpenDrift 우선, 없으면 POSEIDON
const opendriftRow = (rows as Array<Record<string, unknown>>).find((r) => r['algo_cd'] === 'OPENDRIFT');
const poseidonRow = (rows as Array<Record<string, unknown>>).find((r) => r['algo_cd'] === 'POSEIDON');
const primaryRow = opendriftRow ?? poseidonRow ?? null;
for (const row of rows as Array<Record<string, unknown>>) {
const rsltData = row['rslt_data'] as TrajectoryTimeStep[] | null;
if (!rsltData || rsltData.length === 0) continue;
const algoCd = String(row['algo_cd'] ?? '');
const modelName = ALGO_CD_TO_MODEL[algoCd] ?? algoCd;
const fcstHr = row['fcst_hr'] != null ? Number(row['fcst_hr']) : null;
const spilLat = row['spil_lat'] != null ? Number(row['spil_lat']) : null;
const spilLon = row['spil_lon'] != null ? Number(row['spil_lon']) : null;
const totalSteps = rsltData.length;
const lastStep = rsltData[totalSteps - 1];
// 최대 확산거리 — 사고 위치 또는 첫 파티클 위치를 원점으로 사용
let maxDist: number | null = null;
const originLat = spilLat ?? rsltData[0]?.particles[0]?.lat ?? null;
const originLon = spilLon ?? rsltData[0]?.particles[0]?.lon ?? null;
if (originLat != null && originLon != null) {
let maxVal = 0;
for (const step of rsltData) {
for (const p of step.particles) {
const d = haversineKm(originLat, originLon, p.lat, p.lon);
if (d > maxVal) maxVal = d;
}
}
maxDist = maxVal;
}
// 해안 도달 시간 (stranded===1 최초 등장 step)
let coastArrivalHr: number | null = null;
for (let i = 0; i < totalSteps; i++) {
if (rsltData[i].particles.some((p) => p.stranded === 1)) {
coastArrivalHr = fcstHr != null && totalSteps > 1
? parseFloat(((i / (totalSteps - 1)) * fcstHr).toFixed(1))
: i;
break;
}
}
// 풍화율
const totalVol = lastStep.remaining_volume_m3 + lastStep.weathered_volume_m3 + lastStep.beached_volume_m3;
const weatheringPct = totalVol > 0
? parseFloat(((lastStep.weathered_volume_m3 / totalVol) * 100).toFixed(1))
: null;
byModel[modelName] = {
model: modelName,
forecastDurationHr: fcstHr,
maxSpreadDistanceKm: maxDist != null ? parseFloat(maxDist.toFixed(1)) : null,
coastArrivalTimeHr: coastArrivalHr,
affectedCoastlineKm: lastStep.pollution_coast_length_m != null
? parseFloat((lastStep.pollution_coast_length_m / 1000).toFixed(1))
: null,
weatheringRatePct: weatheringPct,
remainingVolumeKl: lastStep.remaining_volume_m3 != null
? parseFloat(lastStep.remaining_volume_m3.toFixed(1))
: null,
};
}
if (!primaryRow) return null;
const primaryAlgo = String(primaryRow['algo_cd'] ?? '');
const primaryModel = ALGO_CD_TO_MODEL[primaryAlgo] ?? primaryAlgo;
return {
primary: byModel[primaryModel] ?? Object.values(byModel)[0],
byModel,
};
}

파일 보기

@ -10,11 +10,13 @@ const router = express.Router();
// ============================================================ // ============================================================
router.get('/ops', requireAuth, requirePermission('rescue', 'READ'), async (req, res) => { router.get('/ops', requireAuth, requirePermission('rescue', 'READ'), async (req, res) => {
try { try {
const { sttsCd, acdntTpCd, search } = req.query; const { sttsCd, acdntTpCd, search, acdntSn } = req.query;
const acdntSnNum = acdntSn ? parseInt(acdntSn as string, 10) : undefined;
const items = await listOps({ const items = await listOps({
sttsCd: sttsCd as string | undefined, sttsCd: sttsCd as string | undefined,
acdntTpCd: acdntTpCd as string | undefined, acdntTpCd: acdntTpCd as string | undefined,
search: search as string | undefined, search: search as string | undefined,
acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined,
}); });
res.json(items); res.json(items);
} catch (err) { } catch (err) {

파일 보기

@ -59,6 +59,7 @@ interface ListOpsInput {
sttsCd?: string; sttsCd?: string;
acdntTpCd?: string; acdntTpCd?: string;
search?: string; search?: string;
acdntSn?: number;
} }
// ============================================================ // ============================================================
@ -82,6 +83,10 @@ export async function listOps(input?: ListOpsInput): Promise<RescueOpsListItem[]
conditions.push(`VESSEL_NM ILIKE '%' || $${idx++} || '%'`); conditions.push(`VESSEL_NM ILIKE '%' || $${idx++} || '%'`);
params.push(input.search); params.push(input.search);
} }
if (input?.acdntSn != null) {
conditions.push(`ACDNT_SN = $${idx++}`);
params.push(input.acdntSn);
}
const where = 'WHERE ' + conditions.join(' AND '); const where = 'WHERE ' + conditions.join(' AND ');

파일 보기

@ -20,7 +20,7 @@ const POLL_TIMEOUT_MS = 30 * 60 * 1000 // 30분
const OIL_TYPE_MAP: Record<string, string> = { const OIL_TYPE_MAP: Record<string, string> = {
'벙커C유': 'GENERIC BUNKER C', '벙커C유': 'GENERIC BUNKER C',
'경유': 'GENERIC DIESEL', '경유': 'GENERIC DIESEL',
'원유': 'WEST TEXAS INTERMEDIATE (WTI)', '원유': 'WEST TEXAS INTERMEDIATE',
'중유': 'GENERIC HEAVY FUEL OIL', '중유': 'GENERIC HEAVY FUEL OIL',
'등유': 'FUEL OIL NO.1 (KEROSENE) ', '등유': 'FUEL OIL NO.1 (KEROSENE) ',
'휘발유': 'GENERIC GASOLINE', '휘발유': 'GENERIC GASOLINE',

파일 보기

@ -19,12 +19,15 @@ import hnsRouter from './hns/hnsRouter.js'
import reportsRouter from './reports/reportsRouter.js' import reportsRouter from './reports/reportsRouter.js'
import assetsRouter from './assets/assetsRouter.js' import assetsRouter from './assets/assetsRouter.js'
import incidentsRouter from './incidents/incidentsRouter.js' import incidentsRouter from './incidents/incidentsRouter.js'
import gscAccidentsRouter from './gsc/gscAccidentsRouter.js'
import scatRouter from './scat/scatRouter.js' import scatRouter from './scat/scatRouter.js'
import predictionRouter from './prediction/predictionRouter.js' import predictionRouter from './prediction/predictionRouter.js'
import aerialRouter from './aerial/aerialRouter.js' import aerialRouter from './aerial/aerialRouter.js'
import rescueRouter from './rescue/rescueRouter.js' import rescueRouter from './rescue/rescueRouter.js'
import mapBaseRouter from './map-base/mapBaseRouter.js' import mapBaseRouter from './map-base/mapBaseRouter.js'
import monitorRouter from './monitor/monitorRouter.js' import monitorRouter from './monitor/monitorRouter.js'
import vesselRouter from './vessels/vesselRouter.js'
import { startVesselScheduler } from './vessels/vesselScheduler.js'
import { import {
sanitizeBody, sanitizeBody,
sanitizeQuery, sanitizeQuery,
@ -168,6 +171,7 @@ app.use('/api/hns', hnsRouter)
app.use('/api/reports', reportsRouter) app.use('/api/reports', reportsRouter)
app.use('/api/assets', assetsRouter) app.use('/api/assets', assetsRouter)
app.use('/api/incidents', incidentsRouter) app.use('/api/incidents', incidentsRouter)
app.use('/api/gsc/accidents', gscAccidentsRouter)
app.use('/api/scat', scatRouter) app.use('/api/scat', scatRouter)
app.use('/api/prediction', predictionRouter) app.use('/api/prediction', predictionRouter)
app.use('/api/aerial', aerialRouter) app.use('/api/aerial', aerialRouter)
@ -175,6 +179,7 @@ app.use('/api/rescue', rescueRouter)
app.use('/api/map-base', mapBaseRouter) app.use('/api/map-base', mapBaseRouter)
app.use('/api/monitor', monitorRouter) app.use('/api/monitor', monitorRouter)
app.use('/api/tiles', tilesRouter) app.use('/api/tiles', tilesRouter)
app.use('/api/vessels', vesselRouter)
// 헬스 체크 // 헬스 체크
app.get('/health', (_req, res) => { app.get('/health', (_req, res) => {
@ -210,6 +215,9 @@ app.use((err: Error, _req: express.Request, res: express.Response, _next: expres
app.listen(PORT, async () => { app.listen(PORT, async () => {
console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`) console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`)
// 선박 신호 스케줄러 시작 (한국 전 해역 1분 폴링)
startVesselScheduler()
// wing DB 연결 확인 (wing + auth 스키마 통합) // wing DB 연결 확인 (wing + auth 스키마 통합)
const connected = await testWingDbConnection() const connected = await testWingDbConnection()
if (connected) { if (connected) {

파일 보기

@ -0,0 +1,33 @@
import { Router } from 'express';
import { getVesselsInBounds, getCacheStatus } from './vesselService.js';
import type { BoundingBox } from './vesselTypes.js';
const vesselRouter = Router();
// POST /api/vessels/in-area
// 현재 뷰포트 bbox 안의 선박 목록 반환 (메모리 캐시에서 필터링)
vesselRouter.post('/in-area', (req, res) => {
const { bounds } = req.body as { bounds?: BoundingBox };
if (
!bounds ||
typeof bounds.minLon !== 'number' ||
typeof bounds.minLat !== 'number' ||
typeof bounds.maxLon !== 'number' ||
typeof bounds.maxLat !== 'number'
) {
res.status(400).json({ error: '유효한 bounds 정보가 필요합니다.' });
return;
}
const vessels = getVesselsInBounds(bounds);
res.json(vessels);
});
// GET /api/vessels/status — 캐시 상태 확인 (디버그용)
vesselRouter.get('/status', (_req, res) => {
const status = getCacheStatus();
res.json(status);
});
export default vesselRouter;

파일 보기

@ -0,0 +1,96 @@
import { updateVesselCache } from './vesselService.js';
import type { VesselPosition } from './vesselTypes.js';
const VESSEL_TRACK_API_URL =
process.env.VESSEL_TRACK_API_URL ?? 'https://guide.gc-si.dev/signal-batch';
const POLL_INTERVAL_MS = 60_000;
// 개별 쿠키 환경변수를 조합하여 Cookie 헤더 문자열 생성
function buildVesselCookie(): string {
const entries: [string, string | undefined][] = [
['apt.uid', process.env.VESSEL_COOKIE_APT_UID],
['g_state', process.env.VESSEL_COOKIE_G_STATE],
['gc_proxy_auth', process.env.VESSEL_COOKIE_GC_PROXY_AUTH],
['GC_SESSION', process.env.VESSEL_COOKIE_GC_SESSION],
// 기존 단일 쿠키 변수 폴백 (레거시 지원)
];
const parts = entries
.filter(([, v]) => v)
.map(([k, v]) => `${k}=${v}`);
// 기존 VESSEL_TRACK_COOKIE 폴백 (단일 문자열로 설정된 경우)
if (parts.length === 0 && process.env.VESSEL_TRACK_COOKIE) {
return process.env.VESSEL_TRACK_COOKIE;
}
return parts.join('; ');
}
// 한국 전 해역 고정 폴리곤 (124~132°E, 32~38°N)
const KOREA_WATERS_POLYGON = [
[120, 31],
[132, 31],
[132, 41],
[120, 41],
[120, 31],
];
let intervalId: ReturnType<typeof setInterval> | null = null;
async function pollVesselSignals(): Promise<void> {
const url = `${VESSEL_TRACK_API_URL}/api/v1/vessels/recent-positions-detail`;
const body = {
minutes: 5,
coordinates: KOREA_WATERS_POLYGON,
polygonFilter: true,
};
const cookie = buildVesselCookie();
const requestHeaders: Record<string, string> = {
'Content-Type': 'application/json',
...(cookie ? { Cookie: cookie } : {}),
};
try {
const res = await fetch(url, {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
console.error(`[vesselScheduler] 선박 신호 API 오류: ${res.status}`, text.substring(0, 200));
return;
}
const contentType = res.headers.get('content-type') ?? '';
if (!contentType.includes('application/json')) {
const text = await res.text().catch(() => '');
console.error('[vesselScheduler] 선박 신호 API가 JSON이 아닌 응답 반환:', text);
return;
}
const data = (await res.json()) as VesselPosition[];
updateVesselCache(data);
} catch (err) {
console.error('[vesselScheduler] 선박 신호 폴링 실패:', err);
}
}
export function startVesselScheduler(): void {
if (intervalId !== null) return;
// 서버 시작 시 즉시 1회 실행 후 주기적 폴링
pollVesselSignals();
intervalId = setInterval(pollVesselSignals, POLL_INTERVAL_MS);
console.log('[vesselScheduler] 선박 신호 스케줄러 시작 (1분 간격)');
}
export function stopVesselScheduler(): void {
if (intervalId !== null) {
clearInterval(intervalId);
intervalId = null;
console.log('[vesselScheduler] 선박 신호 스케줄러 중지');
}
}

파일 보기

@ -0,0 +1,55 @@
import type { VesselPosition, BoundingBox } from './vesselTypes.js';
const VESSEL_TTL_MS = 10 * 60 * 1000; // 10분
const cachedVessels = new Map<string, VesselPosition>();
let lastUpdated: Date | null = null;
// lastUpdate가 TTL을 초과한 선박을 캐시에서 제거.
// lastUpdate 파싱이 불가능한 경우 보수적으로 유지한다.
function evictStale(): void {
const now = Date.now();
for (const [mmsi, vessel] of cachedVessels) {
const ts = Date.parse(vessel.lastUpdate);
if (Number.isNaN(ts)) continue;
if (now - ts > VESSEL_TTL_MS) {
cachedVessels.delete(mmsi);
}
}
}
export function updateVesselCache(vessels: VesselPosition[]): void {
for (const vessel of vessels) {
if (!vessel.mmsi) continue;
cachedVessels.set(vessel.mmsi, vessel);
}
evictStale();
lastUpdated = new Date();
}
export function getVesselsInBounds(bounds: BoundingBox): VesselPosition[] {
const result: VesselPosition[] = [];
for (const v of cachedVessels.values()) {
if (
v.lon >= bounds.minLon &&
v.lon <= bounds.maxLon &&
v.lat >= bounds.minLat &&
v.lat <= bounds.maxLat
) {
result.push(v);
}
}
return result;
}
export function getCacheStatus(): {
count: number;
bangjeCount: number;
lastUpdated: Date | null;
} {
let bangjeCount = 0;
for (const v of cachedVessels.values()) {
if (v.shipNm && v.shipNm.toUpperCase().includes('BANGJE')) bangjeCount++;
}
return { count: cachedVessels.size, bangjeCount, lastUpdated };
}

파일 보기

@ -0,0 +1,26 @@
export interface VesselPosition {
mmsi: string;
imo?: number;
lon: number;
lat: number;
sog?: number;
cog?: number;
heading?: number;
shipNm?: string;
shipTy?: string;
shipKindCode?: string;
nationalCode?: string;
lastUpdate: string;
status?: string;
destination?: string;
length?: number;
width?: number;
draught?: number;
}
export interface BoundingBox {
minLon: number;
minLat: number;
maxLon: number;
maxLat: number;
}

파일 보기

@ -293,7 +293,7 @@ CREATE TABLE SPIL_DATA (
SPIL_DATA_SN SERIAL NOT NULL, -- 유출정보순번 SPIL_DATA_SN SERIAL NOT NULL, -- 유출정보순번
ACDNT_SN INTEGER NOT NULL, -- 사고순번 ACDNT_SN INTEGER NOT NULL, -- 사고순번
OIL_TP_CD VARCHAR(50) NOT NULL, -- 유종코드 OIL_TP_CD VARCHAR(50) NOT NULL, -- 유종코드
SPIL_QTY NUMERIC(12,2), -- 유출량 SPIL_QTY NUMERIC(14,10), -- 유출량
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL', -- 유출단위코드 SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL', -- 유출단위코드
SPIL_TP_CD VARCHAR(20), -- 유출유형코드 SPIL_TP_CD VARCHAR(20), -- 유출유형코드
SPIL_LOC_GEOM GEOMETRY(Point, 4326), -- 유출위치지오메트리 SPIL_LOC_GEOM GEOMETRY(Point, 4326), -- 유출위치지오메트리

파일 보기

@ -40,7 +40,7 @@ CREATE TABLE IF NOT EXISTS SPIL_DATA (
SPIL_DATA_SN SERIAL NOT NULL, SPIL_DATA_SN SERIAL NOT NULL,
ACDNT_SN INTEGER NOT NULL, ACDNT_SN INTEGER NOT NULL,
OIL_TP_CD VARCHAR(50) NOT NULL, OIL_TP_CD VARCHAR(50) NOT NULL,
SPIL_QTY NUMERIC(12,2), SPIL_QTY NUMERIC(14,10),
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL', SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL',
SPIL_TP_CD VARCHAR(20), SPIL_TP_CD VARCHAR(20),
FCST_HR INTEGER, FCST_HR INTEGER,

파일 보기

@ -21,7 +21,7 @@ CREATE TABLE IF NOT EXISTS HNS_ANALYSIS (
SBST_NM VARCHAR(100), SBST_NM VARCHAR(100),
UN_NO VARCHAR(10), UN_NO VARCHAR(10),
CAS_NO VARCHAR(20), CAS_NO VARCHAR(20),
SPIL_QTY NUMERIC(10,2), SPIL_QTY NUMERIC(14,10),
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL', SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL',
SPIL_TP_CD VARCHAR(20), SPIL_TP_CD VARCHAR(20),
FCST_HR INTEGER, FCST_HR INTEGER,

파일 보기

@ -0,0 +1,7 @@
-- 031: 유출량(SPIL_QTY) 소수점 정밀도 확대
-- 이미지 분석 결과로 1e-7 수준의 매우 작은 유출량을 저장할 수 있도록
-- NUMERIC(12,2) / NUMERIC(10,2) → NUMERIC(14,10) 으로 변경
-- 정수부 최대 4자리, 소수부 10자리
ALTER TABLE wing.SPIL_DATA ALTER COLUMN SPIL_QTY TYPE NUMERIC(14,10);
ALTER TABLE wing.HNS_ANALY ALTER COLUMN SPIL_QTY TYPE NUMERIC(14,10);

파일 보기

@ -0,0 +1,118 @@
-- ============================================================
-- 032: gsc.tgs_acdnt_info → wing.ACDNT 동기화 (2026-04-10 이후)
-- ------------------------------------------------------------
-- 목적
-- 3개 예측 탭(유출유확산예측 / HNS 대기확산 / 긴급구난)의 사고
-- 선택 셀렉트박스에 노출되는 gsc 사고 레코드를 wing.ACDNT에
-- 이관하여 wing 운영 로직과 동일한 사고 마스터를 공유한다.
--
-- 필터 정책 (backend/src/gsc/gscAccidentsService.ts 의 listGscAccidents 와 동일)
-- - acdnt_asort_code IN (12개 코드)
-- - acdnt_title IS NOT NULL
-- - 좌표(tgs_acdnt_lc.la, lo) 존재
-- - rcept_dt >= '2026-04-10' (본 이관 추가 조건)
--
-- ACDNT_CD 생성 규칙
-- 'INC-YYYY-NNNN' (YYYY = rcept_dt 의 연도, NNNN = 해당 연도 내 순번 4자리)
-- 기존 wing.ACDNT 에 이미 부여된 'INC-YYYY-NNNN' 중 같은 연도의 최대 순번을
-- 구해 이어서 증가시킨다.
--
-- 중복 방지
-- (ACDNT_NM = acdnt_title, OCCRN_DTM = rcept_dt) 조합이 이미 존재하면 제외.
-- acdnt_mng_no 를 별도 컬럼으로 보관하지 않으므로 이 조합을 자연 키로 사용.
--
-- ACDNT_TP_CD
-- gsc.tcm_code.code_nm 으로 치환 (JOIN: tcm_code.code = acdnt_asort_code)
-- 매핑 누락 시 원본 코드값으로 폴백.
--
-- 사전 확인 쿼리 (실행 전 참고)
-- SELECT COUNT(DISTINCT a.acdnt_mng_no)
-- FROM gsc.tgs_acdnt_info a JOIN gsc.tgs_acdnt_lc b USING (acdnt_mng_no)
-- WHERE a.acdnt_asort_code = ANY(ARRAY[
-- '055001001','055001002','055001003','055001004','055001005','055001006',
-- '055003001','055003002','055003003','055003004','055003005','055004003'
-- ]::varchar[])
-- AND a.acdnt_title IS NOT NULL
-- AND a.rcept_dt >= '2026-04-10';
-- ============================================================
WITH src AS (
SELECT DISTINCT ON (a.acdnt_mng_no)
a.acdnt_mng_no,
a.acdnt_title,
a.acdnt_asort_code,
a.rcept_dt,
b.la,
b.lo
FROM gsc.tgs_acdnt_info AS a
JOIN gsc.tgs_acdnt_lc AS b ON a.acdnt_mng_no = b.acdnt_mng_no
WHERE a.acdnt_asort_code = ANY(ARRAY[
'055001001','055001002','055001003','055001004','055001005','055001006',
'055003001','055003002','055003003','055003004','055003005','055004003'
]::varchar[])
AND a.acdnt_title IS NOT NULL
AND a.rcept_dt >= '2026-04-10'::timestamptz
AND b.la IS NOT NULL AND b.lo IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM wing.ACDNT w
WHERE w.ACDNT_NM = a.acdnt_title
AND w.OCCRN_DTM = a.rcept_dt
)
ORDER BY a.acdnt_mng_no, b.acdnt_lc_sn ASC
),
numbered AS (
SELECT
src.*,
EXTRACT(YEAR FROM src.rcept_dt)::int AS yr,
ROW_NUMBER() OVER (
PARTITION BY EXTRACT(YEAR FROM src.rcept_dt)
ORDER BY src.rcept_dt ASC, src.acdnt_mng_no ASC
) AS rn_in_year
FROM src
),
year_max AS (
SELECT
(split_part(ACDNT_CD, '-', 2))::int AS yr,
MAX((split_part(ACDNT_CD, '-', 3))::int) AS max_seq
FROM wing.ACDNT
WHERE ACDNT_CD ~ '^INC-[0-9]{4}-[0-9]+$'
GROUP BY split_part(ACDNT_CD, '-', 2)
)
INSERT INTO wing.ACDNT (
ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, ACDNT_STTS_CD,
LAT, LNG, LOC_GEOM, LOC_DC, OCCRN_DTM, REG_DTM, MDFCN_DTM
)
SELECT
'INC-' || lpad(n.yr::text, 4, '0') || '-' ||
lpad((COALESCE(ym.max_seq, 0) + n.rn_in_year)::text, 4, '0') AS ACDNT_CD,
n.acdnt_title AS ACDNT_NM,
COALESCE(c.code_nm, n.acdnt_asort_code) AS ACDNT_TP_CD,
'ACTIVE' AS ACDNT_STTS_CD,
n.la::numeric AS LAT,
n.lo::numeric AS LNG,
ST_SetSRID(ST_MakePoint(n.lo::float8, n.la::float8), 4326) AS LOC_GEOM,
NULL AS LOC_DC,
n.rcept_dt AS OCCRN_DTM,
NOW(), NOW()
FROM numbered n
LEFT JOIN year_max ym ON ym.yr = n.yr
LEFT JOIN gsc.tcm_code c ON c.code = n.acdnt_asort_code
ORDER BY n.rcept_dt ASC, n.acdnt_mng_no ASC;
-- ============================================================
-- 사후 검증 (필요 시 주석 해제 실행)
-- SELECT COUNT(*) FROM wing.ACDNT WHERE OCCRN_DTM >= '2026-04-10';
--
-- SELECT ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, ST_AsText(LOC_GEOM), OCCRN_DTM
-- FROM wing.ACDNT
-- WHERE OCCRN_DTM >= '2026-04-10'
-- ORDER BY ACDNT_CD DESC
-- LIMIT 20;
--
-- SELECT ACDNT_TP_CD, COUNT(*)
-- FROM wing.ACDNT
-- WHERE OCCRN_DTM >= '2026-04-10'
-- GROUP BY 1
-- ORDER BY 2 DESC;
-- ============================================================

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -4,6 +4,66 @@
## [Unreleased] ## [Unreleased]
## [2026-04-17]
### 추가
- HNS: 물질 DB 데이터 확장 및 데이터 구조 개선 (PDF 추출 스크립트, 병합 스크립트 개선, 물질 상세 패널 업데이트)
### 변경
- 디자인 시스템: color 토큰 Definition 팔레트로 마이그레이션 (bg/stroke/fg 쿨톤 전환, Primary #0099DD 적용)
### 수정
- 빌드 에러 수정 - 타입 import 정리 및 미사용 코드 제거
## [2026-04-16]
### 추가
- HNS: AEGL 등농도선 표출 및 자동 줌·동적 도메인 기능 추가
- 사건사고: 통합 분석 패널 HNS/구난 연동 및 사고 목록을 wing.ACDNT 기반으로 전환
- 사건사고: 통합 분석 패널 분할 뷰 및 이전 분석 결과 비교 표출 + 분석 선택 모달 추가
- 확산예측: 유출유 확산 요약 API 신규 (`/analyses/:acdntSn/oil-summary`, primary + byModel)
- HNS: 분석 생성 시 `acdntSn` 연결 지원
- GSC: 사고 목록 응답에 `acdntSn` 노출 및 민감자원 누적 카테고리 관리 + HNS 확산 레이어 유틸 추가
### 변경
- 탭 디렉토리를 MPA 컴포넌트 구조로 재편 (src/tabs → src/components, src/interfaces, src/types)
- TimelineControl 분리 및 aerial/hns 컴포넌트 개선
## [2026-04-15]
### 추가
- 확산예측·HNS 대기확산·긴급구난: GSC 외부 사고 목록 API 연동 및 셀렉트박스 자동 채움 (사고명·발생시각·위경도 자동 입력 + 지도 이동)
- 실시간 선박 신호 지도 표출: 한국 해역 1분 주기 폴링 스케줄러, 호버 툴팁·클릭 팝업·상세 모달 제공 (확산예측·HNS·긴급구난·사건사고 탭 연동)
### 변경
- MapView 컴포넌트 분리 및 전체 탭 디자인 시스템 토큰 적용
- aerial 이미지 분석 API 기본 URL 변경
## [2026-04-14]
### 추가
- 디자인 시스템: HNS·사건사고·확산예측·SCAT·기상 탭 디자인 시스템 토큰 전면 적용
- 관리자: 비식별화조치 메뉴 및 패널 추가
- 긴급구난/예측도 OSM 지도 적용 및 관리자 패널 추가
### 변경
- 디자인 시스템: 폰트 업스케일 토큰 값 변경 및 전체 탭 색상·폰트 통일
## [2026-04-13]
### 추가
- 사고별 이미지 분석 데이터 조회 API 추가
- 사고 리스트에 항공 미디어 연동 및 이미지 분석 뱃지 표시
- 사고 마커 클릭 팝업 디자인 리뉴얼
- 지도에 필터링된 사고만 표시되도록 개선
### 변경
- 이미지 분석 시 사고명 파라미터 지원
- 기본 예측시간 48시간 → 6시간으로 변경
- 유출량(SPIL_QTY) 정밀도 NUMERIC(14,10)으로 확대
- OpenDrift 유종 매핑 수정 (원유, 등유)
- 소량 유출량 과학적 표기법으로 표시
## [2026-04-09] ## [2026-04-09]
### 추가 ### 추가

파일 보기

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

파일 보기

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

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

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -0,0 +1,79 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import {
createVesselSignalClient,
type VesselSignalClient,
} from '@common/services/vesselSignalClient';
import {
getInitialVesselSnapshot,
isVesselInitEnabled,
} from '@common/services/vesselApi';
import type { VesselPosition, MapBounds } from '@/types/vessel';
/**
*
*
* (VITE_VESSEL_SIGNAL_MODE=polling):
* - 60 REST API(/api/vessels/in-area) bbox
*
* (VITE_VESSEL_SIGNAL_MODE=websocket):
* - WebSocket (VITE_VESSEL_WS_URL)
* - bbox로
*
* @param mapBounds MapView의 onBoundsChange로 bbox
* @returns
*/
export function useVesselSignals(mapBounds: MapBounds | null): VesselPosition[] {
const [vessels, setVessels] = useState<VesselPosition[]>([]);
const boundsRef = useRef<MapBounds | null>(mapBounds);
const clientRef = useRef<VesselSignalClient | null>(null);
useEffect(() => {
boundsRef.current = mapBounds;
}, [mapBounds]);
const getViewportBounds = useCallback(() => boundsRef.current, []);
useEffect(() => {
const client = createVesselSignalClient();
clientRef.current = client;
// 운영 환경: 로그인/새로고침 직후 최근 10분치 스냅샷을 먼저 1회 로드.
// 이후 WebSocket 수신이 시작되면 최신 신호로 갱신된다.
// VITE_VESSEL_INIT_ENABLED=true 일 때만 활성화(기본 비활성).
if (isVesselInitEnabled()) {
getInitialVesselSnapshot()
.then((initial) => {
const bounds = boundsRef.current;
const filtered = bounds
? initial.filter(
(v) =>
v.lon >= bounds.minLon &&
v.lon <= bounds.maxLon &&
v.lat >= bounds.minLat &&
v.lat <= bounds.maxLat,
)
: initial;
// WS 첫 메시지가 먼저 도착해 이미 채워졌다면 덮어쓰지 않음
setVessels((prev) => (prev.length === 0 ? filtered : prev));
})
.catch((e) => console.warn('[useVesselSignals] 초기 스냅샷 실패', e));
}
client.start(setVessels, getViewportBounds);
return () => {
client.stop();
clientRef.current = null;
};
}, [getViewportBounds]);
// mapBounds가 바뀔 때마다(최초 채워질 때 + 이후 뷰포트 이동/줌마다) 즉시 1회 새로고침.
// MapView의 onBoundsChange는 moveend/zoomend에서만 호출되므로 드래그 중 스팸은 없다.
// 이후에도 60초 인터벌 폴링은 백그라운드에서 계속 동작.
useEffect(() => {
if (mapBounds && clientRef.current) {
clientRef.current.refresh();
}
}, [mapBounds]);
return vessels;
}

파일 보기

@ -1,613 +1,4 @@
export interface Vessel { // Deprecated: Mock 선박 데이터는 제거되었습니다.
mmsi: number; // 실제 선박 신호는 @common/hooks/useVesselSignals + @components/common/map/VesselLayer 를 사용합니다.
imo: string; // 범례는 @components/common/map/VesselLayer 의 VESSEL_LEGEND 를 import 하세요.
name: string; export {};
typS: string;
flag: string;
status: string;
speed: number;
heading: number;
lat: number;
lng: number;
draft: number;
depart: string;
arrive: string;
etd: string;
eta: string;
gt: string;
dwt: string;
loa: string;
beam: string;
built: string;
yard: string;
callSign: string;
cls: string;
cargo: string;
color: string;
markerType: string;
}
export const VESSEL_TYPE_COLORS: Record<string, string> = {
Tanker: '#ef4444',
Chemical: '#ef4444',
Cargo: '#22c55e',
Bulk: '#22c55e',
Container: '#3b82f6',
Passenger: '#a855f7',
Fishing: '#f97316',
Tug: '#06b6d4',
Navy: '#6b7280',
Sailing: '#fbbf24',
};
export const VESSEL_LEGEND = [
{ type: 'Tanker', color: '#ef4444' },
{ type: 'Cargo', color: '#22c55e' },
{ type: 'Container', color: '#3b82f6' },
{ type: 'Fishing', color: '#f97316' },
{ type: 'Passenger', color: '#a855f7' },
{ type: 'Tug', color: '#06b6d4' },
];
export const mockVessels: Vessel[] = [
{
mmsi: 440123456,
imo: '9812345',
name: 'HANKUK CHEMI',
typS: 'Tanker',
flag: '🇰🇷',
status: '항해중',
speed: 8.2,
heading: 330,
lat: 34.6,
lng: 127.5,
draft: 5.8,
depart: '여수항',
arrive: '부산항',
etd: '2026-02-25 08:00',
eta: '2026-02-25 18:30',
gt: '29,246',
dwt: '49,999',
loa: '183.0m',
beam: '32.2m',
built: '2018',
yard: '현대미포조선',
callSign: 'HLKC',
cls: '한국선급(KR)',
cargo: 'BUNKER-C · 1,200kL · IMO Class 3',
color: '#ef4444',
markerType: 'tanker',
},
{
mmsi: 440234567,
imo: '9823456',
name: 'DONG-A GLAUCOS',
typS: 'Cargo',
flag: '🇰🇷',
status: '항해중',
speed: 11.4,
heading: 245,
lat: 34.78,
lng: 127.8,
draft: 7.2,
depart: '울산항',
arrive: '광양항',
etd: '2026-02-25 06:30',
eta: '2026-02-25 16:00',
gt: '12,450',
dwt: '18,800',
loa: '144.0m',
beam: '22.6m',
built: '2015',
yard: 'STX조선',
callSign: 'HLDG',
cls: '한국선급(KR)',
cargo: '철강재 · 4,500t',
color: '#22c55e',
markerType: 'cargo',
},
{
mmsi: 440345678,
imo: '9834567',
name: 'HMM ALGECIRAS',
typS: 'Container',
flag: '🇰🇷',
status: '항해중',
speed: 18.5,
heading: 195,
lat: 35.0,
lng: 128.8,
draft: 14.5,
depart: '부산항',
arrive: '싱가포르',
etd: '2026-02-25 04:00',
eta: '2026-03-02 08:00',
gt: '228,283',
dwt: '223,092',
loa: '399.9m',
beam: '61.0m',
built: '2020',
yard: '대우조선해양',
callSign: 'HLHM',
cls: "Lloyd's Register",
cargo: '컨테이너 · 16,420 TEU',
color: '#3b82f6',
markerType: 'container',
},
{
mmsi: 355678901,
imo: '9756789',
name: 'STELLAR DAISY',
typS: 'Tanker',
flag: '🇵🇦',
status: '⚠ 사고(좌초)',
speed: 0.0,
heading: 0,
lat: 34.72,
lng: 127.72,
draft: 8.1,
depart: '여수항',
arrive: '—',
etd: '2026-01-18 12:00',
eta: '—',
gt: '35,120',
dwt: '58,000',
loa: '190.0m',
beam: '34.0m',
built: '2012',
yard: 'CSBC Taiwan',
callSign: '3FZA7',
cls: 'NK',
cargo: 'BUNKER-C · 150kL 유출 · ⚠ 사고선박',
color: '#ef4444',
markerType: 'tanker',
},
{
mmsi: 440456789,
imo: '—',
name: '제72 금양호',
typS: 'Fishing',
flag: '🇰🇷',
status: '조업중',
speed: 4.1,
heading: 120,
lat: 34.55,
lng: 127.35,
draft: 2.1,
depart: '여수 국동항',
arrive: '여수 국동항',
etd: '2026-02-25 04:30',
eta: '2026-02-25 18:00',
gt: '78',
dwt: '—',
loa: '24.5m',
beam: '6.2m',
built: '2008',
yard: '통영조선',
callSign: '—',
cls: '한국선급',
cargo: '어획물',
color: '#f97316',
markerType: 'fishing',
},
{
mmsi: 440567890,
imo: '9867890',
name: 'PAN OCEAN GLORY',
typS: 'Bulk',
flag: '🇰🇷',
status: '항해중',
speed: 12.8,
heading: 170,
lat: 35.6,
lng: 126.4,
draft: 10.3,
depart: '군산항',
arrive: '포항항',
etd: '2026-02-25 07:00',
eta: '2026-02-26 04:00',
gt: '43,800',
dwt: '76,500',
loa: '229.0m',
beam: '32.3m',
built: '2019',
yard: '현대삼호중공업',
callSign: 'HLPO',
cls: '한국선급(KR)',
cargo: '석탄 · 65,000t',
color: '#22c55e',
markerType: 'cargo',
},
{
mmsi: 440678901,
imo: '—',
name: '여수예인1호',
typS: 'Tug',
flag: '🇰🇷',
status: '방제지원',
speed: 6.3,
heading: 355,
lat: 34.68,
lng: 127.6,
draft: 3.2,
depart: '여수항',
arrive: '사고현장',
etd: '2026-01-18 16:30',
eta: '—',
gt: '280',
dwt: '—',
loa: '32.0m',
beam: '9.5m',
built: '2016',
yard: '삼성중공업',
callSign: 'HLYT',
cls: '한국선급',
cargo: '방제장비 · 오일붐 500m',
color: '#06b6d4',
markerType: 'tug',
},
{
mmsi: 235012345,
imo: '9456789',
name: 'QUEEN MARY',
typS: 'Passenger',
flag: '🇬🇧',
status: '항해중',
speed: 15.2,
heading: 10,
lat: 33.8,
lng: 127.0,
draft: 8.5,
depart: '상하이',
arrive: '부산항',
etd: '2026-02-24 18:00',
eta: '2026-02-26 06:00',
gt: '148,528',
dwt: '18,000',
loa: '345.0m',
beam: '41.0m',
built: '2004',
yard: "Chantiers de l'Atlantique",
callSign: 'GBQM2',
cls: "Lloyd's Register",
cargo: '승객 2,620명',
color: '#a855f7',
markerType: 'passenger',
},
{
mmsi: 353012345,
imo: '9811000',
name: 'EVER GIVEN',
typS: 'Container',
flag: '🇹🇼',
status: '항해중',
speed: 14.7,
heading: 220,
lat: 35.2,
lng: 129.2,
draft: 15.7,
depart: '부산항',
arrive: '카오슝',
etd: '2026-02-25 02:00',
eta: '2026-02-28 14:00',
gt: '220,940',
dwt: '199,629',
loa: '400.0m',
beam: '59.0m',
built: '2018',
yard: '今治造船',
callSign: 'BIXE9',
cls: 'ABS',
cargo: '컨테이너 · 14,800 TEU',
color: '#3b82f6',
markerType: 'container',
},
{
mmsi: 440789012,
imo: '—',
name: '제85 대성호',
typS: 'Fishing',
flag: '🇰🇷',
status: '조업중',
speed: 3.8,
heading: 85,
lat: 34.4,
lng: 126.3,
draft: 1.8,
depart: '목포항',
arrive: '목포항',
etd: '2026-02-25 03:00',
eta: '2026-02-25 17:00',
gt: '65',
dwt: '—',
loa: '22.0m',
beam: '5.8m',
built: '2010',
yard: '목포조선',
callSign: '—',
cls: '한국선급',
cargo: '어획물',
color: '#f97316',
markerType: 'fishing',
},
{
mmsi: 440890123,
imo: '9878901',
name: 'SK INNOVATION',
typS: 'Chemical',
flag: '🇰🇷',
status: '항해중',
speed: 9.6,
heading: 340,
lat: 35.8,
lng: 126.6,
draft: 6.5,
depart: '대산항',
arrive: '여수항',
etd: '2026-02-25 10:00',
eta: '2026-02-26 02:00',
gt: '11,200',
dwt: '16,800',
loa: '132.0m',
beam: '20.4m',
built: '2020',
yard: '현대미포조선',
callSign: 'HLSK',
cls: '한국선급(KR)',
cargo: '톨루엔 · 8,500kL · IMO Class 3',
color: '#ef4444',
markerType: 'tanker',
},
{
mmsi: 440901234,
imo: '9889012',
name: 'KOREA EXPRESS',
typS: 'Cargo',
flag: '🇰🇷',
status: '항해중',
speed: 10.1,
heading: 190,
lat: 36.2,
lng: 128.5,
draft: 6.8,
depart: '동해항',
arrive: '포항항',
etd: '2026-02-25 09:00',
eta: '2026-02-25 15:00',
gt: '8,500',
dwt: '12,000',
loa: '118.0m',
beam: '18.2m',
built: '2014',
yard: '대한조선',
callSign: 'HLKE',
cls: '한국선급',
cargo: '일반화물',
color: '#22c55e',
markerType: 'cargo',
},
{
mmsi: 440012345,
imo: '—',
name: 'ROKS SEJONG',
typS: 'Navy',
flag: '🇰🇷',
status: '작전중',
speed: 16.0,
heading: 270,
lat: 35.3,
lng: 129.5,
draft: 6.3,
depart: '부산 해군기지',
arrive: '—',
etd: '—',
eta: '—',
gt: '7,600',
dwt: '—',
loa: '165.9m',
beam: '21.4m',
built: '2008',
yard: '현대중공업',
callSign: 'HLNS',
cls: '군용',
cargo: '군사작전',
color: '#6b7280',
markerType: 'military',
},
{
mmsi: 440023456,
imo: '—',
name: '군산예인3호',
typS: 'Tug',
flag: '🇰🇷',
status: '대기중',
speed: 5.5,
heading: 140,
lat: 35.9,
lng: 126.9,
draft: 2.8,
depart: '군산항',
arrive: '군산항',
etd: '—',
eta: '—',
gt: '180',
dwt: '—',
loa: '28.0m',
beam: '8.2m',
built: '2019',
yard: '통영조선',
callSign: 'HLGS',
cls: '한국선급',
cargo: '—',
color: '#06b6d4',
markerType: 'tug',
},
{
mmsi: 440034567,
imo: '—',
name: 'JEJU WIND',
typS: 'Sailing',
flag: '🇰🇷',
status: '항해중',
speed: 6.8,
heading: 290,
lat: 33.35,
lng: 126.65,
draft: 2.5,
depart: '제주항',
arrive: '제주항',
etd: '2026-02-25 10:00',
eta: '2026-02-25 16:00',
gt: '45',
dwt: '—',
loa: '18.0m',
beam: '5.0m',
built: '2022',
yard: '제주요트',
callSign: '—',
cls: '—',
cargo: '—',
color: '#fbbf24',
markerType: 'sail',
},
{
mmsi: 440045678,
imo: '—',
name: '제33 삼양호',
typS: 'Fishing',
flag: '🇰🇷',
status: '조업중',
speed: 2.4,
heading: 55,
lat: 35.1,
lng: 127.4,
draft: 1.6,
depart: '통영항',
arrive: '통영항',
etd: '2026-02-25 05:00',
eta: '2026-02-25 19:00',
gt: '52',
dwt: '—',
loa: '20.0m',
beam: '5.4m',
built: '2006',
yard: '거제조선',
callSign: '—',
cls: '한국선급',
cargo: '어획물',
color: '#f97316',
markerType: 'fishing',
},
{
mmsi: 255012345,
imo: '9703291',
name: 'MSC OSCAR',
typS: 'Container',
flag: '🇨🇭',
status: '항해중',
speed: 17.3,
heading: 355,
lat: 34.1,
lng: 128.1,
draft: 14.0,
depart: '카오슝',
arrive: '부산항',
etd: '2026-02-23 08:00',
eta: '2026-02-25 22:00',
gt: '197,362',
dwt: '199,272',
loa: '395.4m',
beam: '59.0m',
built: '2015',
yard: '대우조선해양',
callSign: '9HA4713',
cls: 'DNV',
cargo: '컨테이너 · 18,200 TEU',
color: '#3b82f6',
markerType: 'container',
},
{
mmsi: 440056789,
imo: '9890567',
name: 'SAEHAN PIONEER',
typS: 'Tanker',
flag: '🇰🇷',
status: '항해중',
speed: 7.9,
heading: 310,
lat: 34.9,
lng: 127.1,
draft: 5.2,
depart: '여수항',
arrive: '대산항',
etd: '2026-02-25 11:00',
eta: '2026-02-26 08:00',
gt: '8,900',
dwt: '14,200',
loa: '120.0m',
beam: '18.0m',
built: '2017',
yard: '현대미포조선',
callSign: 'HLSP',
cls: '한국선급(KR)',
cargo: '경유 · 10,000kL',
color: '#ef4444',
markerType: 'tanker',
},
{
mmsi: 440067890,
imo: '9891678',
name: 'DONGHAE STAR',
typS: 'Cargo',
flag: '🇰🇷',
status: '항해중',
speed: 11.0,
heading: 155,
lat: 37.55,
lng: 129.3,
draft: 6.0,
depart: '속초항',
arrive: '동해항',
etd: '2026-02-25 12:00',
eta: '2026-02-25 16:30',
gt: '6,200',
dwt: '8,500',
loa: '105.0m',
beam: '16.5m',
built: '2013',
yard: '대한조선',
callSign: 'HLDS',
cls: '한국선급',
cargo: '일반화물 · 목재',
color: '#22c55e',
markerType: 'cargo',
},
{
mmsi: 440078901,
imo: '—',
name: '제18 한라호',
typS: 'Fishing',
flag: '🇰🇷',
status: '귀항중',
speed: 3.2,
heading: 70,
lat: 33.3,
lng: 126.3,
draft: 1.9,
depart: '서귀포항',
arrive: '서귀포항',
etd: '2026-02-25 04:00',
eta: '2026-02-25 15:00',
gt: '58',
dwt: '—',
loa: '21.0m',
beam: '5.6m',
built: '2011',
yard: '제주조선',
callSign: '—',
cls: '한국선급',
cargo: '어획물 · 갈치/고등어',
color: '#f97316',
markerType: 'fishing',
},
];

파일 보기

@ -0,0 +1,35 @@
import { api } from './api';
import type { VesselPosition, MapBounds } from '@/types/vessel';
export async function getVesselsInArea(bounds: MapBounds): Promise<VesselPosition[]> {
const res = await api.post<VesselPosition[]>('/vessels/in-area', { bounds });
return res.data;
}
/**
* / 1 API.
* REST 10 .
* URL은 VITE_VESSEL_INIT_API_URL ( URL로 ).
*/
export async function getInitialVesselSnapshot(): Promise<VesselPosition[]> {
const url = import.meta.env.VITE_VESSEL_INIT_API_URL as string | undefined;
if (!url) return [];
const res = await fetch(url, { method: 'GET' });
if (!res.ok) throw new Error(`vessel init snapshot ${res.status}`);
return (await res.json()) as VesselPosition[];
}
export function isVesselInitEnabled(): boolean {
return import.meta.env.VITE_VESSEL_INIT_ENABLED === 'true';
}
export interface VesselCacheStatus {
count: number;
bangjeCount: number;
lastUpdated: string | null;
}
export async function getVesselCacheStatus(): Promise<VesselCacheStatus> {
const res = await api.get<VesselCacheStatus>('/vessels/status');
return res.data;
}

파일 보기

@ -0,0 +1,125 @@
import type { VesselPosition, MapBounds } from '@/types/vessel';
import { getVesselsInArea } from './vesselApi';
export interface VesselSignalClient {
start(
onVessels: (vessels: VesselPosition[]) => void,
getViewportBounds: () => MapBounds | null,
): void;
stop(): void;
/**
* 1 . bbox로 REST ,
* WebSocket no-op( push에 ).
*/
refresh(): void;
}
// 개발환경: setInterval(60s) → 백엔드 REST API 호출
class PollingVesselClient implements VesselSignalClient {
private intervalId: ReturnType<typeof setInterval> | null = null;
private onVessels: ((vessels: VesselPosition[]) => void) | null = null;
private getViewportBounds: (() => MapBounds | null) | null = null;
private async poll(): Promise<void> {
const bounds = this.getViewportBounds?.();
if (!bounds || !this.onVessels) return;
try {
const vessels = await getVesselsInArea(bounds);
this.onVessels(vessels);
} catch {
// 폴링 실패 시 무시 (다음 인터벌에 재시도)
}
}
start(
onVessels: (vessels: VesselPosition[]) => void,
getViewportBounds: () => MapBounds | null,
): void {
this.onVessels = onVessels;
this.getViewportBounds = getViewportBounds;
// 즉시 1회 실행 후 60초 간격으로 반복
this.poll();
this.intervalId = setInterval(() => this.poll(), 60_000);
}
stop(): void {
if (this.intervalId !== null) {
clearInterval(this.intervalId);
this.intervalId = null;
}
this.onVessels = null;
this.getViewportBounds = null;
}
refresh(): void {
this.poll();
}
}
// 운영환경: 실시간 WebSocket 서버에 직접 연결
class DirectWebSocketVesselClient implements VesselSignalClient {
private ws: WebSocket | null = null;
private readonly wsUrl: string;
constructor(wsUrl: string) {
this.wsUrl = wsUrl;
}
start(
onVessels: (vessels: VesselPosition[]) => void,
getViewportBounds: () => MapBounds | null,
): void {
this.ws = new WebSocket(this.wsUrl);
this.ws.onmessage = (event) => {
try {
const allVessels = JSON.parse(event.data as string) as VesselPosition[];
const bounds = getViewportBounds();
if (!bounds) {
onVessels(allVessels);
return;
}
const filtered = allVessels.filter(
(v) =>
v.lon >= bounds.minLon &&
v.lon <= bounds.maxLon &&
v.lat >= bounds.minLat &&
v.lat <= bounds.maxLat,
);
onVessels(filtered);
} catch {
// 파싱 실패 무시
}
};
this.ws.onerror = () => {
console.error('[vesselSignalClient] WebSocket 연결 오류');
};
this.ws.onclose = () => {
console.warn('[vesselSignalClient] WebSocket 연결 종료');
};
}
stop(): void {
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
refresh(): void {
// 운영 WS 모드에선 서버 push에 의존하므로 별도 새로고침 동작 없음
}
}
export function createVesselSignalClient(): VesselSignalClient {
if (import.meta.env.VITE_VESSEL_SIGNAL_MODE === 'websocket') {
const wsUrl = import.meta.env.VITE_VESSEL_WS_URL as string;
return new DirectWebSocketVesselClient(wsUrl);
}
return new PollingVesselClient();
}

파일 보기

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

파일 보기

@ -31,25 +31,25 @@
@layer base { @layer base {
:root { :root {
/* bg — Background */ /* bg — Background */
--bg-base: #0a0e1a; --bg-base: #121418;
--bg-surface: #0f1524; --bg-surface: #1B1E23;
--bg-elevated: #121929; --bg-elevated: #24272D;
--bg-card: #1a2236; --bg-card: #24272D;
--bg-surface-hover: #1e2844; --bg-surface-hover: #3A3F49;
/* stroke — Border */ /* stroke — Border */
--stroke-default: #1e2a42; --stroke-default: #24272D;
--stroke-light: #2a3a5c; --stroke-light: #1B1E23;
/* fg — Foreground */ /* fg — Foreground */
--fg-default: #edf0f7; --fg-default: #F8F9FC;
--fg-sub: #c0c8dc; --fg-sub: #B9C1C9;
--fg-disabled: #9ba3b8; --fg-disabled: #808892;
/* color — Palette */ /* color — Palette */
--color-info: #3b82f6; --color-info: #0099DD;
--color-accent: #06b6d4; --color-accent: #0099DD;
--color-accent-muted: #0e7490; --color-accent-muted: #007AB1;
--color-danger: #ef4444; --color-danger: #D61111;
--color-warning: #f97316; --color-warning: #f97316;
--color-caution: #eab308; --color-caution: #FEDA4A;
--color-success: #22c55e; --color-success: #22c55e;
--color-tertiary: #a855f7; --color-tertiary: #a855f7;
--color-boom: #f59e0b; --color-boom: #f59e0b;
@ -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;
@ -111,29 +111,34 @@
--static-black: #131415; --static-black: #131415;
--static-white: #ffffff; --static-white: #ffffff;
/* Gray */ /* Gray (Definition cool-tone, 15 steps) */
--gray-100: #f1f5f9; --gray-0: #FFFFFF;
--gray-200: #e2e8f0; --gray-50: #F8F9FC;
--gray-300: #cbd5e1; --gray-100: #F3F6FB;
--gray-400: #94a3b8; --gray-200: #EBEFF5;
--gray-500: #64748b; --gray-250: #E1E6EC;
--gray-600: #475569; --gray-300: #D6DBE1;
--gray-700: #334155; --gray-400: #B9C1C9;
--gray-800: #1e293b; --gray-500: #808892;
--gray-900: #0f172a; --gray-550: #6C747E;
--gray-1000: #020617; --gray-600: #565B64;
--gray-700: #3A3F49;
--gray-800: #24272D;
--gray-850: #1B1E23;
--gray-900: #121418;
--gray-1000: #000000;
/* Blue */ /* Blue (Primary Blue-Cyan, hue ~200°) */
--blue-100: #dbeafe; --blue-100: #E6F4FB;
--blue-200: #bfdbfe; --blue-200: #B3E0F5;
--blue-300: #93c5fd; --blue-300: #80CCEE;
--blue-400: #60a5fa; --blue-400: #4DB8E8;
--blue-500: #3b82f6; --blue-500: #0099DD;
--blue-600: #2563eb; --blue-600: #007AB1;
--blue-700: #1d4ed8; --blue-700: #005C85;
--blue-800: #1e40af; --blue-800: #003D59;
--blue-900: #1e3a8a; --blue-900: #001F2D;
--blue-1000: #172554; --blue-1000: #001520;
/* Green */ /* Green */
--green-100: #dcfce7; --green-100: #dcfce7;
@ -152,7 +157,7 @@
--yellow-200: #fef08a; --yellow-200: #fef08a;
--yellow-300: #fde047; --yellow-300: #fde047;
--yellow-400: #facc15; --yellow-400: #facc15;
--yellow-500: #eab308; --yellow-500: #FEDA4A;
--yellow-600: #ca8a04; --yellow-600: #ca8a04;
--yellow-700: #a16207; --yellow-700: #a16207;
--yellow-800: #854d0e; --yellow-800: #854d0e;
@ -160,12 +165,12 @@
--yellow-1000: #422006; --yellow-1000: #422006;
/* Red */ /* Red */
--red-100: #fee2e2; --red-100: #7A2D2D;
--red-200: #fecaca; --red-200: #fecaca;
--red-300: #fca5a5; --red-300: #fca5a5;
--red-400: #f87171; --red-400: #f87171;
--red-500: #ef4444; --red-500: #DE4141;
--red-600: #dc2626; --red-600: #D61111;
--red-700: #b91c1c; --red-700: #b91c1c;
--red-800: #991b1b; --red-800: #991b1b;
--red-900: #7f1d1d; --red-900: #7f1d1d;
@ -189,19 +194,19 @@
/* ── Light theme overrides ── */ /* ── Light theme overrides ── */
[data-theme='light'] { [data-theme='light'] {
--bg-base: #f8fafc; --bg-base: #FFFFFF;
--bg-surface: #ffffff; --bg-surface: #FFFFFF;
--bg-elevated: #f1f5f9; --bg-elevated: #F3F6FB;
--bg-card: #ffffff; --bg-card: #FFFFFF;
--bg-surface-hover: #e2e8f0; --bg-surface-hover: #EBEFF5;
--stroke-default: #cbd5e1; --stroke-default: #B9C1C9;
--stroke-light: #e2e8f0; --stroke-light: #E1E6EC;
--fg-default: #0f172a; --fg-default: #121418;
--fg-sub: #475569; --fg-sub: #24272D;
--fg-disabled: #94a3b8; --fg-disabled: #808892;
--hover-overlay: rgba(0, 0, 0, 0.04); --hover-overlay: rgba(0, 0, 0, 0.04);
--dropdown-bg: rgba(255, 255, 255, 0.97); --dropdown-bg: rgba(255, 255, 255, 0.97);
--color-accent-muted: #0891b2; --color-accent-muted: #007AB1;
--color-navy: #1d4ed8; --color-navy: #1d4ed8;
--color-navy-hover: #2563eb; --color-navy-hover: #2563eb;
} }

파일 보기

@ -4,6 +4,21 @@
z-index: 500; z-index: 500;
} }
/* 사고 팝업 — @layer 밖에 위치해야 MapLibre 기본 스타일을 덮어씀 */
.incident-popup .maplibregl-popup-content {
background: transparent;
border-radius: 0;
padding: 0;
box-shadow: none;
border: none;
}
.incident-popup .maplibregl-popup-tip {
border-top-color: var(--bg-elevated);
border-bottom-color: var(--bg-elevated);
border-left-color: transparent;
border-right-color: transparent;
}
@layer components { @layer components {
/* ═══ CCTV 지도 팝업 (어두운 톤) ═══ */ /* ═══ CCTV 지도 팝업 (어두운 톤) ═══ */
.cctv-dark-popup .maplibregl-popup-content { .cctv-dark-popup .maplibregl-popup-content {
@ -30,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;
@ -63,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;
} }
@ -88,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;
} }
@ -96,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;
} }
@ -176,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);
} }
@ -263,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;
@ -294,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.75rem;
font-weight: 600; font-weight: 600;
font-family: var(--font-korean); font-family: var(--font-korean);
cursor: pointer; cursor: pointer;
@ -313,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);
} */ } */
@ -329,7 +360,7 @@
width: 100%; width: 100%;
padding: 10px; padding: 10px;
border-radius: 6px; border-radius: 6px;
font-size: 12px; font-size: 0.8125rem;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
border: none; border: none;
@ -355,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.75rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
@ -380,7 +411,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: 12px; font-size: 0.8125rem;
font-weight: 600; font-weight: 600;
transition: all 0.15s; transition: all 0.15s;
background: rgba(15, 21, 36, 0.75); background: rgba(15, 21, 36, 0.75);
@ -419,7 +450,7 @@
border-radius: 6px; border-radius: 6px;
padding: 5px 14px; padding: 5px 14px;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.6875rem; font-size: 0.75rem;
color: rgba(255, 255, 255, 0.7); color: rgba(255, 255, 255, 0.7);
font-weight: 400; font-weight: 400;
z-index: 20; z-index: 20;
@ -460,7 +491,7 @@
} }
.wii-value { .wii-value {
font-size: 0.6875rem; font-size: 0.75rem;
font-weight: 400; font-weight: 400;
color: #ffffff; color: #ffffff;
font-family: var(--font-mono); font-family: var(--font-mono);
@ -507,7 +538,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 1rem;
transition: 0.2s; transition: 0.2s;
} }
@ -536,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);
} }
@ -590,7 +621,7 @@
position: absolute; position: absolute;
top: -18px; top: -18px;
transform: translateX(-50%); transform: translateX(-50%);
font-size: 12px; font-size: 0.8125rem;
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));
} }
@ -605,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);
@ -641,7 +672,7 @@
} }
.tlct { .tlct {
font-size: 14px; font-size: 1rem;
font-weight: 600; font-weight: 600;
color: var(--color-accent); color: var(--color-accent);
font-family: var(--font-mono); font-family: var(--font-mono);
@ -656,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 {
@ -684,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);
@ -735,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);
} }
@ -810,7 +841,7 @@
} }
.layer-icon { .layer-icon {
font-size: 14px; font-size: 1rem;
flex-shrink: 0; flex-shrink: 0;
} }
@ -820,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);
} }
@ -841,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);
@ -872,10 +903,10 @@
background: var(--bg-base); background: var(--bg-base);
border: 1px solid var(--stroke-default); border: 1px solid var(--stroke-default);
border-radius: 4px; border-radius: 4px;
color: var(--color-accent); color: var(--color-default);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: 0.75rem;
font-weight: 600; font-weight: 400;
text-align: right; text-align: right;
outline: none; outline: none;
transition: border-color 0.2s; transition: border-color 0.2s;
@ -911,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);
} }
@ -921,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);
@ -1084,7 +1115,7 @@
cursor: pointer; cursor: pointer;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
transition: background 0.15s; transition: background 0.15s;
font-size: 12px; font-size: 0.8125rem;
font-weight: 700; font-weight: 700;
color: var(--fg-default); color: var(--fg-default);
font-family: var(--font-korean); font-family: var(--font-korean);
@ -1108,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);
@ -1137,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);
@ -1161,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);
@ -1184,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,
@ -1200,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);
@ -1314,7 +1345,7 @@
} }
.lyr-ccustom label { .lyr-ccustom label {
font-size: 9px; font-size: 0.75rem;
color: var(--fg-disabled); color: var(--fg-disabled);
font-family: var(--font-korean); font-family: var(--font-korean);
} }
@ -1338,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.75rem;
font-weight: 700; font-weight: 700;
color: var(--fg-disabled); color: var(--fg-disabled);
font-family: var(--font-korean); font-family: var(--font-korean);
@ -1355,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;
@ -1380,7 +1411,7 @@
cursor: pointer; cursor: pointer;
} }
.lyr-style-val { .lyr-style-val {
font-size: 9px; font-size: 0.75rem;
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;

파일 보기

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

파일 보기

@ -3,7 +3,7 @@ import type {
BoomLineCoord, BoomLineCoord,
AlgorithmSettings, AlgorithmSettings,
ContainmentResult, ContainmentResult,
} from '../types/boomLine'; } from '@/types/boomLine';
const DEG2RAD = Math.PI / 180; const DEG2RAD = Math.PI / 180;
const RAD2DEG = 180 / Math.PI; const RAD2DEG = 180 / Math.PI;
@ -217,8 +217,6 @@ export function generateAIBoomLines(
const totalDist = haversineDistance(incident, centroid); const totalDist = haversineDistance(incident, centroid);
// 입자 분산 폭 계산 (최종 시간 기준) // 입자 분산 폭 계산 (최종 시간 기준)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const perpBearing = (mainBearing + 90) % 360;
let maxSpread = 0; let maxSpread = 0;
for (const p of finalPoints) { for (const p of finalPoints) {
const bearing = computeBearing(incident, p); const bearing = computeBearing(incident, p);

파일 보기

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

파일 보기

@ -31,45 +31,6 @@ export function stripHtmlTags(html: string): string {
return html.replace(/<[^>]*>/g, ''); return html.replace(/<[^>]*>/g, '');
} }
/**
* HTML
* /
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const ALLOWED_TAGS = new Set([
'b',
'i',
'u',
'strong',
'em',
'br',
'p',
'span',
'div',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'ul',
'ol',
'li',
'a',
'img',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
'sup',
'sub',
'hr',
'blockquote',
'pre',
'code',
]);
const DANGEROUS_ATTRS = /\s*on\w+\s*=|javascript\s*:|vbscript\s*:|expression\s*\(/gi; const DANGEROUS_ATTRS = /\s*on\w+\s*=|javascript\s*:|vbscript\s*:|expression\s*\(/gi;

파일 보기

@ -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"

파일 보기

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

파일 보기

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

파일 보기

@ -1,10 +1,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { import { fetchBoardPosts, adminDeleteBoardPost } from '@components/board/services/boardApi';
fetchBoardPosts, import type { BoardPostItem, BoardListResponse } from '@interfaces/board/BoardInterface';
adminDeleteBoardPost,
type BoardPostItem,
type BoardListResponse,
} from '@tabs/board/services/boardApi';
// ─── 상수 ────────────────────────────────────────────────── // ─── 상수 ──────────────────────────────────────────────────
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
@ -118,21 +114,21 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
return ( return (
<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">
<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>
{/* 카테고리 탭 + 검색 */} {/* 카테고리 탭 + 검색 */}
<div className="flex items-center gap-3 px-5 py-2 border-b border-stroke-1"> <div className="flex items-center gap-3 px-5 py-2 border-b border-stroke">
<div className="flex gap-1"> <div className="flex gap-1">
{CATEGORY_TABS.map((tab) => ( {CATEGORY_TABS.map((tab) => (
<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-[rgba(6,182,212,0.15)] text-color-accent 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 +142,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 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 text-fg-sub hover:bg-bg-card"
> >
</button> </button>
@ -158,11 +154,11 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
</div> </div>
{/* 액션 바 */} {/* 액션 바 */}
<div className="flex items-center gap-2 px-5 py-2 border-b border-stroke-1"> <div className="flex items-center gap-2 px-5 py-2 border-b border-stroke">
<button <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-[rgba(239,68,68,0.15)] text-color-danger hover:bg-[rgba(239,68,68,0.25)] disabled:opacity-40 disabled:cursor-not-allowed"
> >
{deleting ? '삭제 중...' : `선택 삭제 (${selected.size})`} {deleting ? '삭제 중...' : `선택 삭제 (${selected.size})`}
</button> </button>
@ -170,9 +166,9 @@ 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 text-fg-disabled">
<th className="w-8 py-2 text-center"> <th className="w-8 py-2 text-center">
<input <input
type="checkbox" type="checkbox"
@ -218,11 +214,11 @@ export default function BoardMgmtPanel({ initialCategory = '' }: BoardMgmtPanelP
{/* 페이지네이션 */} {/* 페이지네이션 */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-center gap-1 py-2 border-t border-stroke-1"> <div className="flex items-center justify-center gap-1 py-2 border-t border-stroke">
<button <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"
> >
&lt; &lt;
</button> </button>
@ -234,9 +230,9 @@ 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-[rgba(6,182,212,0.15)] text-color-accent font-medium'
: 'text-fg-disabled hover:bg-bg-elevated' : 'text-fg-disabled hover:bg-bg-elevated'
}`} }`}
> >
@ -247,7 +243,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"
> >
&gt; &gt;
</button> </button>
@ -266,7 +262,7 @@ interface PostRowProps {
function PostRow({ post, checked, onToggle }: PostRowProps) { function PostRow({ post, checked, onToggle }: PostRowProps) {
return ( return (
<tr className="border-b border-stroke-1 hover:bg-bg-surface/50 transition-colors"> <tr className="border-b border-stroke hover:bg-bg-surface/50 transition-colors">
<td className="py-2 text-center"> <td className="py-2 text-center">
<input type="checkbox" checked={checked} onChange={onToggle} className="accent-blue-500" /> <input type="checkbox" checked={checked} onChange={onToggle} className="accent-blue-500" />
</td> </td>
@ -275,17 +271,17 @@ function PostRow({ post, checked, onToggle }: PostRowProps) {
<span <span
className={`inline-block px-2 py-0.5 rounded-full text-caption font-medium ${ className={`inline-block px-2 py-0.5 rounded-full text-caption font-medium ${
post.categoryCd === 'NOTICE' post.categoryCd === 'NOTICE'
? 'bg-red-500/15 text-red-400' ? 'bg-[rgba(6,182,212,0.08)] text-fg-sub'
: post.categoryCd === 'QNA' : post.categoryCd === 'QNA'
? 'bg-purple-500/15 text-purple-400' ? 'bg-[rgba(6,182,212,0.08)] text-fg-sub'
: 'bg-blue-500/15 text-blue-400' : 'bg-[rgba(6,182,212,0.08)] text-fg-sub'
}`} }`}
> >
{CATEGORY_LABELS[post.categoryCd] ?? post.categoryCd} {CATEGORY_LABELS[post.categoryCd] ?? post.categoryCd}
</span> </span>
</td> </td>
<td className="py-2 pl-3 text-fg truncate max-w-[300px]"> <td className="py-2 pl-3 text-fg truncate max-w-[300px]">
{post.pinnedYn === 'Y' && <span className="text-caption text-orange-400 mr-1">[]</span>} {post.pinnedYn === 'Y' && <span className="text-caption text-color-accent mr-1">[]</span>}
{post.title} {post.title}
</td> </td>
<td className="py-2 text-center text-fg-sub">{post.authorName}</td> <td className="py-2 text-center text-fg-sub">{post.authorName}</td>

파일 보기

@ -1,7 +1,8 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { fetchOrganizations } from '@tabs/assets/services/assetsApi'; import { fetchOrganizations } from '@components/assets/services/assetsApi';
import type { AssetOrgCompat } from '@tabs/assets/services/assetsApi'; import type { AssetOrgCompat } from '@interfaces/assets/AssetsInterface';
import { typeTagCls } from '@tabs/assets/components/assetTypes'; import { typeTagCls } from '@components/assets/components/assetTypes';
/* eslint-disable react-refresh/only-export-components */
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
@ -98,14 +99,16 @@ 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-title-1 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 +120,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 +132,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 +149,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 +163,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 +220,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>
@ -339,16 +342,11 @@ function CleanupEquipPanel() {
<button <button
key={p} key={p}
onClick={() => setCurrentPage(p)} onClick={() => setCurrentPage(p)}
className="px-2.5 py-1 text-label-2 border rounded transition-colors" className={`px-2.5 py-1 text-label-2 border rounded transition-colors ${
style={
p === safePage p === safePage
? { ? 'border-color-accent text-color-accent bg-[rgba(6,182,212,0.08)]'
borderColor: 'var(--color-accent)', : 'border-stroke text-fg-sub'
color: 'var(--color-accent)', }`}
background: 'rgba(6,182,212,0.1)',
}
: { borderColor: 'var(--border)', color: 'var(--text-2)' }
}
> >
{p} {p}
</button> </button>

파일 보기

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

파일 보기

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

파일 보기

@ -5,7 +5,7 @@ import { GeoJsonLayer } from '@deck.gl/layers';
import type { Layer } from '@deck.gl/core'; import type { Layer } from '@deck.gl/core';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
import { S57EncOverlay } from '@common/components/map/S57EncOverlay'; import { S57EncOverlay } from '@components/common/map/S57EncOverlay';
import { useMapStore } from '@common/store/mapStore'; import { useMapStore } from '@common/store/mapStore';
const MAP_CENTER: [number, number] = [127.5, 36.0]; const MAP_CENTER: [number, number] = [127.5, 36.0];
@ -119,7 +119,7 @@ const DispersingZonePanel = () => {
const isConsider = zone === 'consider'; const isConsider = zone === 'consider';
const showLayer = isConsider ? showConsider : showRestrict; const showLayer = isConsider ? showConsider : showRestrict;
const setShowLayer = isConsider ? setShowConsider : setShowRestrict; const setShowLayer = isConsider ? setShowConsider : setShowRestrict;
const swatchColor = isConsider ? 'bg-blue-500' : 'bg-red-500'; const swatchColor = isConsider ? 'bg-color-info' : 'bg-color-danger';
const isExpanded = expandedZone === zone; const isExpanded = expandedZone === zone;
return ( return (
@ -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) => {
@ -195,11 +197,11 @@ const DispersingZonePanel = () => {
{/* 범례 */} {/* 범례 */}
<div className="absolute bottom-4 left-4 bg-bg-surface border border-stroke rounded-lg px-3 py-2 flex flex-col gap-1.5"> <div className="absolute bottom-4 left-4 bg-bg-surface border border-stroke rounded-lg px-3 py-2 flex flex-col gap-1.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-sm bg-blue-500 opacity-80" /> <span className="w-3 h-3 rounded-sm bg-color-info opacity-80" />
<span className="text-label-2 text-fg-sub font-korean"></span> <span className="text-label-2 text-fg-sub font-korean"></span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-sm bg-red-500 opacity-80" /> <span className="w-3 h-3 rounded-sm bg-color-danger opacity-80" />
<span className="text-label-2 text-fg-sub font-korean"></span> <span className="text-label-2 text-fg-sub font-korean"></span>
</div> </div>
</div> </div>
@ -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
@ -229,7 +229,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
{/* 레이어코드 */} {/* 레이어코드 */}
<div> <div>
<label className={labelCls}> <label className={labelCls}>
<span className="text-red-400">*</span> <span className="text-color-danger">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -243,7 +243,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
{/* 레이어명 */} {/* 레이어명 */}
<div> <div>
<label className={labelCls}> <label className={labelCls}>
<span className="text-red-400">*</span> <span className="text-color-danger">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -261,7 +261,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
{/* 레이어전체명 */} {/* 레이어전체명 */}
<div> <div>
<label className={labelCls}> <label className={labelCls}>
<span className="text-red-400">*</span> <span className="text-color-danger">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -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>
@ -321,7 +321,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
{/* 에러 */} {/* 에러 */}
{formError && ( {formError && (
<div className="px-6 pb-2"> <div className="px-6 pb-2">
<p className="text-label-2 text-red-400 font-korean">{formError}</p> <p className="text-label-2 text-color-danger font-korean">{formError}</p>
</div> </div>
)} )}
{/* 버튼 */} {/* 버튼 */}
@ -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>
@ -448,12 +448,12 @@ const LayerPanel = () => {
<div className="px-6 py-4 border-b border-stroke shrink-0"> <div className="px-6 py-4 border-b border-stroke shrink-0">
<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-title-1 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-color-danger bg-[rgba(239,68,68,0.08)] 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>
{/* 등록일시 */} {/* 등록일시 */}
@ -598,7 +598,7 @@ const LayerPanel = () => {
item.useYn === 'Y' && item.parentUseYn !== 'N' item.useYn === 'Y' && item.parentUseYn !== 'N'
? 'bg-color-accent' ? 'bg-color-accent'
: item.useYn === 'Y' && item.parentUseYn === 'N' : item.useYn === 'Y' && item.parentUseYn === 'N'
? 'bg-[rgba(6,182,212,0.4)]' ? 'bg-[rgba(6,182,212,0.3)]'
: 'bg-[rgba(255,255,255,0.08)] border border-stroke' : 'bg-[rgba(255,255,255,0.08)] border border-stroke'
}`} }`}
> >
@ -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-[rgba(239,68,68,0.15)] text-color-danger hover:bg-[rgba(239,68,68,0.25)] font-korean whitespace-nowrap"
> >
</button> </button>

파일 보기

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { api } from '@common/services/api'; import { api } from '@common/services/api';
import { useMapStore } from '@common/store/mapStore'; import { useMapStore } from '@common/store/mapStore';
/* eslint-disable react-refresh/only-export-components */
// ─── 타입 ───────────────────────────────────────────────── // ─── 타입 ─────────────────────────────────────────────────
interface MapBaseItem { interface MapBaseItem {
@ -78,7 +79,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">
@ -101,21 +102,21 @@ function MapBaseModal({
{/* 지도 이름 */} {/* 지도 이름 */}
<div> <div>
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5"> <label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
<span className="text-red-400">*</span> <span className="text-color-danger">*</span>
</label> </label>
<input <input
type="text" type="text"
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>
{/* 지도 키 */} {/* 지도 키 */}
<div> <div>
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5"> <label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
<span className="text-red-400">*</span> <span className="text-color-danger">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -123,7 +124,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 +136,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 +157,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 +171,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>
@ -184,7 +185,7 @@ function MapBaseModal({
type="button" type="button"
onClick={() => setField('useYn', form.useYn === 'Y' ? 'N' : 'Y')} onClick={() => setField('useYn', form.useYn === 'Y' ? 'N' : 'Y')}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
form.useYn === 'Y' ? 'bg-color-accent' : 'bg-border' form.useYn === 'Y' ? 'bg-color-accent' : 'bg-bg-elevated'
}`} }`}
> >
<span <span
@ -193,14 +194,14 @@ 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>
</div> </div>
{/* 에러 */} {/* 에러 */}
{modalError && <p className="text-label-2 text-red-400 font-korean">{modalError}</p>} {modalError && <p className="text-label-2 text-color-danger font-korean">{modalError}</p>}
</div> </div>
{/* 모달 푸터 */} {/* 모달 푸터 */}
@ -208,14 +209,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>
@ -349,12 +350,12 @@ 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-title-1 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>
@ -365,8 +366,8 @@ function MapBasePanel() {
<div <div
className={`mx-6 mt-2 px-3 py-2 text-label-2 rounded-md font-korean ${ className={`mx-6 mt-2 px-3 py-2 text-label-2 rounded-md font-korean ${
message.type === 'success' message.type === 'success'
? 'text-green-400 bg-[rgba(74,222,128,0.08)] border border-[rgba(74,222,128,0.2)]' ? 'text-color-success bg-[rgba(34,197,94,0.08)] border border-[rgba(34,197,94,0.2)]'
: 'text-red-400 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)]' : 'text-color-danger bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)]'
}`} }`}
> >
{message.text} {message.text}
@ -375,7 +376,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>
@ -419,7 +420,7 @@ function MapBasePanel() {
<button <button
type="button" type="button"
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${ className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
item.useYn === 'Y' ? 'bg-color-accent' : 'bg-border' item.useYn === 'Y' ? 'bg-color-accent' : 'bg-bg-elevated'
}`} }`}
> >
<span <span
@ -433,7 +434,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 +442,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-[rgba(239,68,68,0.15)] text-color-danger hover:bg-[rgba(239,68,68,0.25)]"
> >
</button> </button>
@ -452,7 +453,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 +465,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"
> >
&lt; &lt;
</button> </button>
@ -476,9 +477,9 @@ 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-[rgba(6,182,212,0.15)] text-color-accent font-medium'
: 'text-fg-disabled hover:bg-bg-elevated' : 'text-fg-disabled hover:bg-bg-elevated'
}`} }`}
> >
@ -489,7 +490,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"
> >
&gt; &gt;
</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>
); );
} }
@ -135,15 +135,15 @@ function MenusPanel() {
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke"> <div className="flex items-center 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-title-1 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-color-success text-caption"></span>;
} }
if (row.lastStatus === 'FAILED') { if (row.lastStatus === 'FAILED') {
return ( return (
<span className="text-red-400 text-xs"> <span className="text-color-danger 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-color-accent 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-color-accent 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,31 +76,31 @@ 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-color-accent animate-pulse" />
... ...
</span> </span>
); );
} }
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-[rgba(239,68,68,0.08)] text-color-danger">
<span className="w-1.5 h-1.5 rounded-full bg-red-400" /> <span className="w-1.5 h-1.5 rounded-full bg-color-danger" />
</span> </span>
); );
} }
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-[rgba(234,179,8,0.08)] text-color-caution">
<span className="w-1.5 h-1.5 rounded-full bg-yellow-400" /> <span className="w-1.5 h-1.5 rounded-full bg-color-caution" />
({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-[rgba(34,197,94,0.08)] text-color-success">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" /> <span className="w-1.5 h-1.5 rounded-full bg-color-success" />
</span> </span>
); );
@ -118,13 +118,13 @@ 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) => (
<th <th
key={h} key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap" className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
> >
{h} {h}
</th> </th>
@ -134,7 +134,7 @@ function ForecastTable({ rows, loading }: { rows: NumericalDataStatus[]; loading
<tbody> <tbody>
{loading && rows.length === 0 {loading && rows.length === 0
? Array.from({ length: 6 }).map((_, i) => ( ? Array.from({ length: 6 }).map((_, i) => (
<tr key={i} className="border-b border-stroke-1 animate-pulse"> <tr key={i} className="border-b border-stroke animate-pulse">
{TABLE_HEADERS.map((_, j) => ( {TABLE_HEADERS.map((_, j) => (
<td key={j} className="px-3 py-2"> <td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-elevated rounded w-16" /> <div className="h-3 bg-bg-elevated rounded w-16" />
@ -143,7 +143,7 @@ function ForecastTable({ rows, loading }: { rows: NumericalDataStatus[]; loading
</tr> </tr>
)) ))
: rows.map((row) => ( : rows.map((row) => (
<tr key={row.jobName} className="border-b border-stroke-1 hover:bg-bg-surface/50"> <tr key={row.jobName} className="border-b border-stroke hover:bg-bg-surface/50">
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap"> <td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">
{row.modelName} {row.modelName}
</td> </td>
@ -192,11 +192,11 @@ export default function MonitorForecastPanel() {
return ( return (
<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 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' : ''}`}
@ -229,14 +229,14 @@ export default function MonitorForecastPanel() {
</div> </div>
{/* 탭 */} {/* 탭 */}
<div className="flex gap-0 border-b border-stroke-1 shrink-0 px-5"> <div className="flex gap-0 border-b border-stroke shrink-0 px-5">
{TABS.map((tab) => ( {TABS.map((tab) => (
<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-color-accent text-color-accent'
: 'border-transparent text-t3 hover:text-t2' : 'border-transparent text-t3 hover:text-t2'
}`} }`}
> >
@ -246,9 +246,11 @@ export default function MonitorForecastPanel() {
</div> </div>
{/* 상태 표시줄 */} {/* 상태 표시줄 */}
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base"> <div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke bg-bg-base">
<StatusBadge loading={loading} errorCount={errorCount} total={totalCount} /> <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>
{/* 테이블 */} {/* 테이블 */}

파일 보기

@ -1,18 +1,17 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { import { getRecentObservation, OBS_STATION_CODES } from '@components/weather/services/khoaApi';
getRecentObservation,
OBS_STATION_CODES,
type RecentObservation,
} from '@tabs/weather/services/khoaApi';
import { import {
getUltraShortForecast, getUltraShortForecast,
getMarineForecast, getMarineForecast,
convertToGridCoords, convertToGridCoords,
getCurrentBaseDateTime, getCurrentBaseDateTime,
MARINE_REGIONS, MARINE_REGIONS,
type WeatherForecastData, } from '@components/weather/services/weatherApi';
type MarineWeatherData, import type {
} from '@tabs/weather/services/weatherApi'; RecentObservation,
WeatherForecastData,
MarineWeatherData,
} from '@interfaces/weather/WeatherInterface';
const KEY_TO_NAME: Record<string, string> = { const KEY_TO_NAME: Record<string, string> = {
incheon: '인천', incheon: '인천',
@ -84,31 +83,31 @@ 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-color-accent animate-pulse" />
... ...
</span> </span>
); );
} }
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-[rgba(239,68,68,0.08)] text-color-danger">
<span className="w-1.5 h-1.5 rounded-full bg-red-400" /> <span className="w-1.5 h-1.5 rounded-full bg-color-danger" />
</span> </span>
); );
} }
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-[rgba(234,179,8,0.08)] text-color-caution">
<span className="w-1.5 h-1.5 rounded-full bg-yellow-400" /> <span className="w-1.5 h-1.5 rounded-full bg-color-caution" />
({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-[rgba(34,197,94,0.08)] text-color-success">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" /> <span className="w-1.5 h-1.5 rounded-full bg-color-success" />
</span> </span>
); );
@ -130,13 +129,13 @@ 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) => (
<th <th
key={h} key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap" className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
> >
{h} {h}
</th> </th>
@ -146,7 +145,7 @@ function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) {
<tbody> <tbody>
{loading && rows.length === 0 {loading && rows.length === 0
? Array.from({ length: 5 }).map((_, i) => ( ? Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-stroke-1 animate-pulse"> <tr key={i} className="border-b border-stroke animate-pulse">
{headers.map((_, j) => ( {headers.map((_, j) => (
<td key={j} className="px-3 py-2"> <td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-elevated rounded w-12" /> <div className="h-3 bg-bg-elevated rounded w-12" />
@ -157,7 +156,7 @@ function KhoaTable({ rows, loading }: { rows: KhoaRow[]; loading: boolean }) {
: rows.map((row) => ( : rows.map((row) => (
<tr <tr
key={row.stationName} key={row.stationName}
className="border-b border-stroke-1 hover:bg-bg-surface/50" className="border-b border-stroke hover:bg-bg-surface/50"
> >
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap"> <td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">
{row.stationName} {row.stationName}
@ -172,11 +171,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-color-danger text-caption"></span>
) : row.data ? ( ) : row.data ? (
<span className="text-emerald-400 text-xs"></span> <span className="text-color-success text-caption"></span>
) : ( ) : (
<span className="text-t3 text-xs">-</span> <span className="text-t3 text-caption">-</span>
)} )}
</td> </td>
</tr> </tr>
@ -201,13 +200,13 @@ 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) => (
<th <th
key={h} key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap" className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
> >
{h} {h}
</th> </th>
@ -217,7 +216,7 @@ function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolea
<tbody> <tbody>
{loading && rows.length === 0 {loading && rows.length === 0
? Array.from({ length: 3 }).map((_, i) => ( ? Array.from({ length: 3 }).map((_, i) => (
<tr key={i} className="border-b border-stroke-1 animate-pulse"> <tr key={i} className="border-b border-stroke animate-pulse">
{headers.map((_, j) => ( {headers.map((_, j) => (
<td key={j} className="px-3 py-2"> <td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-elevated rounded w-12" /> <div className="h-3 bg-bg-elevated rounded w-12" />
@ -228,7 +227,7 @@ function KmaUltraTable({ rows, loading }: { rows: KmaUltraRow[]; loading: boolea
: rows.map((row) => ( : rows.map((row) => (
<tr <tr
key={row.stationName} key={row.stationName}
className="border-b border-stroke-1 hover:bg-bg-surface/50" className="border-b border-stroke hover:bg-bg-surface/50"
> >
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap"> <td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">
{row.stationName} {row.stationName}
@ -241,11 +240,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-color-danger text-caption"></span>
) : row.data ? ( ) : row.data ? (
<span className="text-emerald-400 text-xs"></span> <span className="text-color-success text-caption"></span>
) : ( ) : (
<span className="text-t3 text-xs">-</span> <span className="text-t3 text-caption">-</span>
)} )}
</td> </td>
</tr> </tr>
@ -261,13 +260,13 @@ 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) => (
<th <th
key={h} key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap" className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
> >
{h} {h}
</th> </th>
@ -277,7 +276,7 @@ function MarineTable({ rows, loading }: { rows: MarineRow[]; loading: boolean })
<tbody> <tbody>
{loading && rows.length === 0 {loading && rows.length === 0
? Array.from({ length: 4 }).map((_, i) => ( ? Array.from({ length: 4 }).map((_, i) => (
<tr key={i} className="border-b border-stroke-1 animate-pulse"> <tr key={i} className="border-b border-stroke animate-pulse">
{headers.map((_, j) => ( {headers.map((_, j) => (
<td key={j} className="px-3 py-2"> <td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-elevated rounded w-14" /> <div className="h-3 bg-bg-elevated rounded w-14" />
@ -286,7 +285,7 @@ function MarineTable({ rows, loading }: { rows: MarineRow[]; loading: boolean })
</tr> </tr>
)) ))
: rows.map((row) => ( : rows.map((row) => (
<tr key={row.regId} className="border-b border-stroke-1 hover:bg-bg-surface/50"> <tr key={row.regId} className="border-b border-stroke hover:bg-bg-surface/50">
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.name}</td> <td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">{row.name}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.waveHeight)}</td> <td className="px-3 py-2 text-t2">{fmt(row.data?.waveHeight)}</td>
<td className="px-3 py-2 text-t2">{fmt(row.data?.windSpeed)}</td> <td className="px-3 py-2 text-t2">{fmt(row.data?.windSpeed)}</td>
@ -294,11 +293,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-color-danger text-caption"></span>
) : row.data ? ( ) : row.data ? (
<span className="text-emerald-400 text-xs"></span> <span className="text-color-success text-caption"></span>
) : ( ) : (
<span className="text-t3 text-xs">-</span> <span className="text-t3 text-caption">-</span>
)} )}
</td> </td>
</tr> </tr>
@ -440,11 +439,11 @@ export default function MonitorRealtimePanel() {
return ( return (
<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 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 +455,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' : ''}`}
@ -477,14 +476,14 @@ export default function MonitorRealtimePanel() {
</div> </div>
{/* 탭 */} {/* 탭 */}
<div className="flex gap-0 border-b border-stroke-1 shrink-0 px-5"> <div className="flex gap-0 border-b border-stroke shrink-0 px-5">
{TABS.map((tab) => ( {TABS.map((tab) => (
<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-color-accent text-color-accent'
: 'border-transparent text-t3 hover:text-t2' : 'border-transparent text-t3 hover:text-t2'
}`} }`}
> >
@ -494,9 +493,9 @@ export default function MonitorRealtimePanel() {
</div> </div>
{/* 상태 표시줄 */} {/* 상태 표시줄 */}
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base"> <div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke bg-bg-base">
<StatusBadge loading={isLoading} errorCount={errorCount} total={totalCount} /> <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,8 +300,8 @@ 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-color-accent animate-pulse" />
... ...
</span> </span>
); );
@ -309,23 +309,23 @@ 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-[rgba(239,68,68,0.08)] text-color-danger">
<span className="w-1.5 h-1.5 rounded-full bg-red-400" /> <span className="w-1.5 h-1.5 rounded-full bg-color-danger" />
OFF OFF
</span> </span>
); );
} }
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-[rgba(234,179,8,0.08)] text-color-caution">
<span className="w-1.5 h-1.5 rounded-full bg-yellow-400" /> <span className="w-1.5 h-1.5 rounded-full bg-color-caution" />
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-[rgba(34,197,94,0.08)] text-color-success">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" /> <span className="w-1.5 h-1.5 rounded-full bg-color-success" />
</span> </span>
); );
@ -342,7 +342,7 @@ function ConnectionBadge({
if (isNormal) { if (isNormal) {
return ( return (
<div className="flex flex-col items-start gap-0.5"> <div className="flex flex-col items-start gap-0.5">
<span className="inline-flex items-center px-2 py-0.5 rounded text-label-2 font-semibold bg-blue-600 text-white"> <span className="inline-flex items-center px-2 py-0.5 rounded text-label-2 font-semibold bg-color-accent text-white">
ON ON
</span> </span>
{lastMessageTime && <span className="text-caption text-t3">{lastMessageTime}</span>} {lastMessageTime && <span className="text-caption text-t3">{lastMessageTime}</span>}
@ -351,7 +351,7 @@ function ConnectionBadge({
} }
return ( return (
<div className="flex flex-col items-start gap-0.5"> <div className="flex flex-col items-start gap-0.5">
<span className="inline-flex items-center px-2 py-0.5 rounded text-label-2 font-semibold bg-orange-500 text-white"> <span className="inline-flex items-center px-2 py-0.5 rounded text-label-2 font-semibold bg-color-warning text-white">
OFF OFF
</span> </span>
{lastMessageTime && <span className="text-caption text-t3">{lastMessageTime}</span>} {lastMessageTime && <span className="text-caption text-t3">{lastMessageTime}</span>}
@ -376,13 +376,13 @@ 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) => (
<th <th
key={h} key={h}
className="px-3 py-2 text-left font-medium border-b border-stroke-1 whitespace-nowrap" className="px-3 py-2 text-left font-medium border-b border-stroke whitespace-nowrap"
> >
{h} {h}
</th> </th>
@ -392,7 +392,7 @@ function VesselTable({ rows, loading }: { rows: VesselMonitorRow[]; loading: boo
<tbody> <tbody>
{loading && rows.length === 0 {loading && rows.length === 0
? Array.from({ length: 8 }).map((_, i) => ( ? Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b border-stroke-1 animate-pulse"> <tr key={i} className="border-b border-stroke animate-pulse">
{HEADERS.map((_, j) => ( {HEADERS.map((_, j) => (
<td key={j} className="px-3 py-2"> <td key={j} className="px-3 py-2">
<div className="h-3 bg-bg-elevated rounded w-14" /> <div className="h-3 bg-bg-elevated rounded w-14" />
@ -403,7 +403,7 @@ function VesselTable({ rows, loading }: { rows: VesselMonitorRow[]; loading: boo
: rows.map((row, idx) => ( : rows.map((row, idx) => (
<tr <tr
key={`${row.institutionCode}-${row.systemName}`} key={`${row.institutionCode}-${row.systemName}`}
className="border-b border-stroke-1 hover:bg-bg-surface/50" className="border-b border-stroke hover:bg-bg-surface/50"
> >
<td className="px-3 py-2 text-t2 text-center">{idx + 1}</td> <td className="px-3 py-2 text-t2 text-center">{idx + 1}</td>
<td className="px-3 py-2 font-medium text-t1 whitespace-nowrap"> <td className="px-3 py-2 font-medium text-t1 whitespace-nowrap">
@ -461,11 +461,11 @@ export default function MonitorVesselPanel() {
return ( return (
<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 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' : ''}`}
@ -498,9 +498,9 @@ export default function MonitorVesselPanel() {
</div> </div>
{/* 상태 표시줄 */} {/* 상태 표시줄 */}
<div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke-1 bg-bg-base"> <div className="flex items-center gap-3 px-5 py-2 shrink-0 border-b border-stroke bg-bg-base">
<StatusBadge loading={loading} onCount={onCount} total={rows.length} /> <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>

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -135,8 +135,8 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
<div className="px-6 py-4 border-b border-stroke shrink-0"> <div className="px-6 py-4 border-b border-stroke shrink-0">
<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-title-1 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-color-danger bg-[rgba(239,68,68,0.08)] 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>
); );
@ -63,8 +63,8 @@ function SettingsPanel() {
return ( return (
<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-title-1 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>
@ -87,9 +87,9 @@ function SettingsPanel() {
<div className="text-title-4 font-semibold text-fg font-korean"> </div> <div className="text-title-4 font-semibold text-fg font-korean"> </div>
<p className="text-label-2 text-fg-disabled mt-1 font-korean leading-relaxed"> <p className="text-label-2 text-fg-disabled mt-1 font-korean leading-relaxed">
{' '} {' '}
<span className="text-green-400 font-semibold">ACTIVE</span> . <span className="text-color-success font-semibold">ACTIVE</span> .
{' '} {' '}
<span className="text-yellow-400 font-semibold">PENDING</span> <span className="text-color-caution font-semibold">PENDING</span>
. .
</p> </p>
</div> </div>
@ -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>
@ -152,8 +152,8 @@ function SettingsPanel() {
</div> </div>
<p className="text-label-2 text-fg-disabled font-korean leading-relaxed mb-3"> <p className="text-label-2 text-fg-disabled font-korean leading-relaxed mb-3">
Google {' '} Google {' '}
<span className="text-green-400 font-semibold">ACTIVE</span> . <span className="text-color-success font-semibold">ACTIVE</span> .
<span className="text-yellow-400 font-semibold">PENDING</span> <span className="text-color-caution font-semibold">PENDING</span>
. (,) . . (,) .
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
@ -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,31 +220,31 @@ 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">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className={`w-2 h-2 rounded-full ${settings?.autoApprove ? 'bg-green-400' : 'bg-yellow-400'}`} className={`w-2 h-2 rounded-full ${settings?.autoApprove ? 'bg-color-success' : 'bg-color-caution'}`}
/> />
<span className="text-fg-sub"> <span className="text-fg-sub">
{' '} {' '}
{settings?.autoApprove ? ( {settings?.autoApprove ? (
<span className="text-green-400 font-semibold"> </span> <span className="text-color-success font-semibold"> </span>
) : ( ) : (
<span className="text-yellow-400 font-semibold"> </span> <span className="text-color-caution font-semibold"> </span>
)} )}
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className={`w-2 h-2 rounded-full ${settings?.defaultRole ? 'bg-green-400' : 'bg-fg-disabled'}`} className={`w-2 h-2 rounded-full ${settings?.defaultRole ? 'bg-color-success' : 'bg-fg-disabled'}`}
/> />
<span className="text-fg-sub"> <span className="text-fg-sub">
{' '} {' '}
{settings?.defaultRole ? ( {settings?.defaultRole ? (
<span className="text-green-400 font-semibold"></span> <span className="text-color-success font-semibold"></span>
) : ( ) : (
<span className="text-fg-disabled font-semibold"></span> <span className="text-fg-disabled font-semibold"></span>
)} )}
@ -252,12 +252,12 @@ function SettingsPanel() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className={`w-2 h-2 rounded-full ${oauthSettings?.autoApproveDomains ? 'bg-blue-400' : 'bg-fg-disabled'}`} className={`w-2 h-2 rounded-full ${oauthSettings?.autoApproveDomains ? 'bg-color-info' : 'bg-fg-disabled'}`}
/> />
<span className="text-fg-sub"> <span className="text-fg-sub">
Google OAuth {' '} Google OAuth {' '}
{oauthSettings?.autoApproveDomains ? ( {oauthSettings?.autoApproveDomains ? (
<span className="text-blue-400 font-semibold font-mono"> <span className="text-color-info font-semibold font-mono">
{oauthSettings.autoApproveDomains} {oauthSettings.autoApproveDomains}
</span> </span>
) : ( ) : (

파일 보기

@ -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>

파일 보기

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

파일 보기

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

파일 보기

@ -1,7 +1,7 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { fetchOrganizations } from '@tabs/assets/services/assetsApi'; import { fetchOrganizations } from '@components/assets/services/assetsApi';
import type { AssetOrgCompat } from '@tabs/assets/services/assetsApi'; import type { AssetOrgCompat } from '@interfaces/assets/AssetsInterface';
import { typeTagCls } from '@tabs/assets/components/assetTypes'; import { typeTagCls } from '@components/assets/components/assetTypes';
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
@ -89,8 +89,8 @@ 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-title-1 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>
@ -327,16 +327,11 @@ function VesselMaterialsPanel() {
<button <button
key={p} key={p}
onClick={() => setCurrentPage(p)} onClick={() => setCurrentPage(p)}
className="px-2.5 py-1 text-label-2 border rounded transition-colors" className={`px-2.5 py-1 text-label-2 border rounded transition-colors ${
style={
p === safePage p === safePage
? { ? 'border-color-accent text-color-accent bg-[rgba(6,182,212,0.08)]'
borderColor: 'var(--color-accent)', : 'border-stroke text-fg-sub'
color: 'var(--color-accent)', }`}
background: 'rgba(6,182,212,0.1)',
}
: { borderColor: 'var(--border)', color: 'var(--text-2)' }
}
> >
{p} {p}
</button> </button>

파일 보기

@ -10,18 +10,10 @@ interface SignalSlot {
} }
// ─── 상수 ────────────────────────────────────────────────── // ─── 상수 ──────────────────────────────────────────────────
const SOURCE_COLORS: Record<SignalSource, string> = {
VTS: '#3b82f6',
'VTS-AIS': '#a855f7',
'V-PASS': '#22c55e',
'E-NAVI': '#f97316',
'S&P AIS': '#ec4899',
};
const STATUS_COLOR: Record<string, string> = { const STATUS_COLOR: Record<string, string> = {
ok: '#22c55e', ok: 'var(--color-success)',
warn: '#eab308', warn: 'var(--color-caution)',
error: '#ef4444', error: 'var(--color-danger)',
none: 'rgba(255,255,255,0.06)', none: 'rgba(255,255,255,0.06)',
}; };
@ -141,18 +133,18 @@ export default function VesselSignalPanel() {
return ( return (
<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">
<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 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 text-fg-sub hover:bg-bg-card"
> >
</button> </button>
@ -163,7 +155,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">
@ -172,7 +164,6 @@ export default function VesselSignalPanel() {
{/* 시간축 높이 맞춤 빈칸 */} {/* 시간축 높이 맞춤 빈칸 */}
<div className="h-5 mb-3" /> <div className="h-5 mb-3" />
{SIGNAL_SOURCES.map((src) => { {SIGNAL_SOURCES.map((src) => {
const c = SOURCE_COLORS[src];
const st = stats.find((s) => s.src === src)!; const st = stats.find((s) => s.src === src)!;
return ( return (
<div <div
@ -180,10 +171,10 @@ export default function VesselSignalPanel() {
className="flex flex-col justify-center mb-4" className="flex flex-col justify-center mb-4"
style={{ height: 20 }} style={{ height: 20 }}
> >
<span className="text-label-1 font-semibold leading-tight" style={{ color: c }}> <span className="text-label-1 font-semibold leading-tight text-fg">
{src} {src}
</span> </span>
<span className="text-caption font-mono text-text-4 mt-0.5">{st.rate}%</span> <span className="text-caption font-mono text-fg-sub mt-0.5">{st.rate}%</span>
</div> </div>
); );
})} })}

파일 보기

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

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