generated from gc/template-java-maven
feat(design): 디자인 시스템 적용 (CSS 토큰, Button/Badge, 차트, 다크모드) (#48)
- 디자인 시스템 가이드 문서 11개 생성 (docs/design/) - CSS 변수 토큰 시스템 (@theme + :root/.dark 전환) - cn() 유틸리티 (clsx + tailwind-merge) - Button/Badge 공통 컴포넌트 (variant/size, 다크모드 대응) - 하드코딩 Tailwind 색상 → CSS 변수 토큰 리팩토링 (30개 파일) - 차트 팔레트 다크모드 색상 업데이트 (CHART_COLORS_HEX) - 버튼 다크모드 채도/대비 강화 (primary-600 기반) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
2a8723419d
커밋
c2a71c1b77
31
CLAUDE.md
31
CLAUDE.md
@ -77,6 +77,37 @@ snp-connection-monitoring/
|
|||||||
- Git 워크플로우: `.claude/rules/git-workflow.md` 참조
|
- Git 워크플로우: `.claude/rules/git-workflow.md` 참조
|
||||||
- 팀 정책: `.claude/rules/team-policy.md` 참조
|
- 팀 정책: `.claude/rules/team-policy.md` 참조
|
||||||
|
|
||||||
|
## 디자인 시스템
|
||||||
|
|
||||||
|
`docs/design/` 디렉토리에 프론트엔드 디자인 시스템 가이드가 있다.
|
||||||
|
|
||||||
|
| 문서 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| `docs/design/colors.md` | 브랜드 컬러 스케일, CSS 변수 토큰, 시멘틱 컬러, 차트 팔레트 |
|
||||||
|
| `docs/design/typography.md` | 폰트 패밀리, 타입 스케일(8단계), 한글 규칙 |
|
||||||
|
| `docs/design/spacing.md` | 4px 기반 스케일, 컴포넌트별 여백, 12컬럼 그리드 |
|
||||||
|
| `docs/design/components.md` | Button, Badge, Card, Input, Modal, Toast 스펙 |
|
||||||
|
| `docs/design/icons.md` | Lucide React, 사이즈/색상 규칙 |
|
||||||
|
| `docs/design/motion.md` | Duration, Easing, prefers-reduced-motion |
|
||||||
|
| `docs/design/code-conventions.md` | 파일명, Tailwind 클래스 순서, cn(), forwardRef |
|
||||||
|
| `docs/design/do-dont.md` | 올바른 사용법과 금지 패턴 |
|
||||||
|
|
||||||
|
### 프롬프트 템플릿
|
||||||
|
|
||||||
|
| 문서 | 용도 |
|
||||||
|
|------|------|
|
||||||
|
| `docs/design/prompts/new-component.md` | 새 컴포넌트 생성 요청 템플릿 |
|
||||||
|
| `docs/design/prompts/review-checklist.md` | 디자인 리뷰 체크리스트 (9개 카테고리) |
|
||||||
|
| `docs/design/prompts/refactor-style.md` | 스타일 리팩토링 요청 템플릿 |
|
||||||
|
|
||||||
|
### 핵심 규칙
|
||||||
|
|
||||||
|
- **CSS 변수 토큰** 사용 — HEX 하드코딩 금지 (`var(--color-*)`)
|
||||||
|
- **cn() 유틸리티** 사용 — `src/utils/cn.ts` (clsx + tailwind-merge)
|
||||||
|
- **Lucide React** 아이콘만 사용 — 다른 라이브러리 혼용 금지
|
||||||
|
- **4px 스페이싱 스케일** — 임의 px 값 금지
|
||||||
|
- **차트 팔레트** — `src/constants/chart.ts`의 `CHART_COLORS` 상수 사용
|
||||||
|
|
||||||
## 의존성 관리
|
## 의존성 관리
|
||||||
|
|
||||||
- Maven: Nexus 프록시 레포지토리 (`.mvn/settings.xml`)
|
- Maven: Nexus 프록시 레포지토리 (`.mvn/settings.xml`)
|
||||||
|
|||||||
499
claude-code-design-system-prompt.md
Normal file
499
claude-code-design-system-prompt.md
Normal file
@ -0,0 +1,499 @@
|
|||||||
|
# SNP Connection Monitoring — 디자인 시스템 적용 프롬프트
|
||||||
|
|
||||||
|
아래 프롬프트를 Claude Code에 붙여넣어 실행하세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
```
|
||||||
|
해양 데이터 시스템(SNP Connection Monitoring) 프로젝트에 디자인 시스템을 적용해줘.
|
||||||
|
프로젝트 규칙(.claude/rules/)과 CLAUDE.md를 반드시 읽고 따라줘.
|
||||||
|
|
||||||
|
## 0. 사전 준비
|
||||||
|
|
||||||
|
- develop 브랜치에서 `feature/design-system` 브랜치를 생성해서 작업
|
||||||
|
- 커밋 메시지는 Conventional Commits 형식 (프로젝트 git-workflow.md 참조)
|
||||||
|
|
||||||
|
## 1. 디자인 가이드 문서 생성
|
||||||
|
|
||||||
|
`docs/design/` 디렉토리 아래에 아래 파일들을 생성해줘.
|
||||||
|
|
||||||
|
### 1-1. docs/design/colors.md
|
||||||
|
|
||||||
|
해양 데이터 시스템 컬러 시스템. 내용:
|
||||||
|
|
||||||
|
**브랜드 컬러 원본:**
|
||||||
|
| 역할 | HEX | 설명 |
|
||||||
|
|------|-----|------|
|
||||||
|
| Primary | #6D94C5 | Slate Blue — 신뢰, 전문성 |
|
||||||
|
| Secondary | #CBDCEB | Powder Blue — 부드러운 보조 |
|
||||||
|
| Accent | #E8DFCA | Warm Sand — 따뜻한 포인트 |
|
||||||
|
| Background | #F5EFE6 | Warm Cream — 배경색 |
|
||||||
|
|
||||||
|
**브랜드 컬러 스케일 (50~950):**
|
||||||
|
|
||||||
|
Primary (Slate Blue, hue≈213°):
|
||||||
|
- 50: #F5F7F9, 100: #E5EBF2, 200: #D2DAE5, 300: #B7C5D7, 400: #8AA5C7
|
||||||
|
- 500: #6D94C5, 600: #507FB9, 700: #3B669C, 800: #2D507B, 900: #1B2C41, 950: #0E1A2B
|
||||||
|
|
||||||
|
Secondary (Powder Blue, hue≈210°):
|
||||||
|
- 50: #F5F7F9, 100: #E6EDF5, 200: #D1DAE5, 300: #B7C8D7, 400: #89AAC8
|
||||||
|
- 500: #CBDCEB, 600: #6B9AC8, 700: #4E88BB, 800: #396E9D, 900: #1B2F41, 950: #0E1A2B
|
||||||
|
|
||||||
|
Accent (Warm Sand, hue≈38°):
|
||||||
|
- 50: #F9F8F6, 100: #F2EDE4, 200: #E4E0D7, 300: #D5CDB9, 400: #C4B48C
|
||||||
|
- 500: #E8DFCA, 600: #B59854, 700: #9A7E3E, 800: #786230, 900: #3F351D, 950: #2B2312
|
||||||
|
|
||||||
|
**디자인 토큰 (CSS 변수 — Light / Dark):**
|
||||||
|
|
||||||
|
| 토큰 | Light | Dark |
|
||||||
|
|------|-------|------|
|
||||||
|
| --color-primary | #507FB9 | #B7C5D7 |
|
||||||
|
| --color-primary-hover | #3B669C | #D2DAE5 |
|
||||||
|
| --color-primary-active | #2D507B | #8AA5C7 |
|
||||||
|
| --color-primary-subtle | #F5F7F9 | #1B2C41 |
|
||||||
|
| --color-primary-text | #2D507B | #D2DAE5 |
|
||||||
|
| --color-secondary | #CBDCEB | #B7C8D7 |
|
||||||
|
| --color-secondary-hover | #89AAC8 | #D1DAE5 |
|
||||||
|
| --color-secondary-active | #4E88BB | #89AAC8 |
|
||||||
|
| --color-secondary-subtle | #F5F7F9 | #1B2F41 |
|
||||||
|
| --color-secondary-text | #396E9D | #D1DAE5 |
|
||||||
|
| --color-accent | #E8DFCA | #D5CDB9 |
|
||||||
|
| --color-accent-hover | #C4B48C | #E4E0D7 |
|
||||||
|
| --color-accent-active | #B59854 | #C4B48C |
|
||||||
|
| --color-accent-subtle | #F9F8F6 | #3F351D |
|
||||||
|
| --color-accent-text | #786230 | #E4E0D7 |
|
||||||
|
|
||||||
|
**시멘틱 컬러 (Light / Dark):**
|
||||||
|
|
||||||
|
| 역할 | Light | Dark |
|
||||||
|
|------|-------|------|
|
||||||
|
| Success | #059669 | #34D399 |
|
||||||
|
| Warning | #D97706 | #FBBF24 |
|
||||||
|
| Danger | #DC2626 | #FCA5A5 |
|
||||||
|
| Info | #0284C7 | #38BDF8 |
|
||||||
|
|
||||||
|
**뉴트럴 (Blue-tinted warm gray):**
|
||||||
|
|
||||||
|
| 토큰 | Light | Dark |
|
||||||
|
|------|-------|------|
|
||||||
|
| --color-bg-base | #F5EFE6 | #131416 |
|
||||||
|
| --color-bg-surface | #FFFFFF | #1E2023 |
|
||||||
|
| --color-bg-elevated | #FFFFFF | #282A2E |
|
||||||
|
| --color-border | #D5D2CC | #3A3C41 |
|
||||||
|
| --color-border-strong | #9198A1 | #5C6570 |
|
||||||
|
| --color-text-primary | #1E2329 | #F4F5F5 |
|
||||||
|
| --color-text-secondary | #5C6570 | #9198A1 |
|
||||||
|
| --color-text-tertiary | #9198A1 | #5C6570 |
|
||||||
|
|
||||||
|
**다크모드 토큰 사용 가이드:**
|
||||||
|
다크모드 시멘틱 토큰(--color-primary, --color-secondary, --color-accent)은 텍스트/아이콘용 밝은 톤이다.
|
||||||
|
버튼/뱃지처럼 배경색이 필요한 인터랙티브 요소는 다크모드에서 스케일 값(600~700)을 직접 사용해야 한다.
|
||||||
|
- 텍스트/아이콘: `text-[--color-primary]` → 다크모드에서 자동으로 밝은 톤 적용 (OK)
|
||||||
|
- 버튼 배경: `bg-[--color-primary]` → 다크모드에서 `dark:bg-[--color-primary-600]`으로 오버라이드 (필수)
|
||||||
|
|
||||||
|
**차트 팔레트 (Cool+Warm 교차):**
|
||||||
|
|
||||||
|
Light:
|
||||||
|
1. #507FB9 (Primary 600)
|
||||||
|
2. #B59854 (Accent 600)
|
||||||
|
3. #4E88BB (Secondary 700)
|
||||||
|
4. #9A7E3E (Accent 700)
|
||||||
|
5. #3B669C (Primary 700)
|
||||||
|
6. #C4B48C (Accent 400)
|
||||||
|
|
||||||
|
Dark (채도 강화 — 400~500 스케일 사용):
|
||||||
|
1. #6D94C5 (Primary 500)
|
||||||
|
2. #C4B48C (Accent 400)
|
||||||
|
3. #6B9AC8 (Secondary 600)
|
||||||
|
4. #B59854 (Accent 600)
|
||||||
|
5. #8AA5C7 (Primary 400)
|
||||||
|
6. #D5CDB9 (Accent 300)
|
||||||
|
|
||||||
|
### 1-2. docs/design/typography.md
|
||||||
|
|
||||||
|
타이포그래피 시스템:
|
||||||
|
- 기본 폰트: Pretendard (한글), Inter (영문/숫자)
|
||||||
|
- 8단계 타입 스케일: Display 48px, H1 36px, H2 30px, H3 24px, Body-lg 18px, Body 16px, Caption 14px, Overline 12px
|
||||||
|
- line-height: heading 1.2~1.3, body 1.5~1.6
|
||||||
|
- font-weight: 300(light), 400(regular), 500(medium), 600(semibold), 700(bold)
|
||||||
|
- 한국어 word-break: keep-all 규칙
|
||||||
|
|
||||||
|
### 1-3. docs/design/spacing.md
|
||||||
|
|
||||||
|
스페이싱 시스템:
|
||||||
|
- 4px 기반 스케일: 0(0px), 0.5(2px), 1(4px), 1.5(6px), 2(8px), 3(12px), 4(16px), 5(20px), 6(24px), 8(32px), 10(40px), 12(48px), 16(64px), 20(80px), 24(96px)
|
||||||
|
- 컴포넌트별 내부 여백: Button(px-4 py-2), Card(p-4~p-6), Input(px-3 py-2), Modal(p-6)
|
||||||
|
- 반응형 브레이크포인트: sm 640px, md 768px, lg 1024px, xl 1280px, 2xl 1536px
|
||||||
|
- 그리드: 12컬럼, gap-4~gap-6
|
||||||
|
|
||||||
|
### 1-4. docs/design/components.md
|
||||||
|
|
||||||
|
컴포넌트 스펙:
|
||||||
|
|
||||||
|
**Button 변형 (Light 모드):**
|
||||||
|
- primary: bg-[--color-primary] text-white hover:bg-[--color-primary-hover]
|
||||||
|
- secondary: bg-[--color-secondary] text-[--color-secondary-text] hover:bg-[--color-secondary-hover]
|
||||||
|
- accent: bg-[--color-accent] text-[--color-accent-text] hover:bg-[--color-accent-hover]
|
||||||
|
- outline: border border-[--color-border-strong] text-[--color-text-primary] hover:bg-[--color-primary-subtle]
|
||||||
|
- ghost: text-[--color-text-secondary] hover:bg-[--color-primary-subtle]
|
||||||
|
- danger: bg-danger-600 text-white hover:bg-danger-700
|
||||||
|
|
||||||
|
**Button 변형 (Dark 모드) — 채도/대비 강화:**
|
||||||
|
다크모드에서 시멘틱 토큰(--color-primary 등)은 연한 파스텔 톤이라 버튼 배경으로 부적합.
|
||||||
|
스케일 값(600~700)을 직접 사용하여 선명한 대비를 확보한다.
|
||||||
|
- primary: dark:bg-[--color-primary-600] dark:text-white dark:hover:bg-[--color-primary-500]
|
||||||
|
- secondary: dark:bg-[--color-secondary-700] dark:text-white dark:hover:bg-[--color-secondary-600]
|
||||||
|
- accent: dark:bg-[--color-accent-600] dark:text-white dark:hover:bg-[--color-accent-500]
|
||||||
|
- outline: dark:border-[--color-primary-400] dark:text-[--color-primary-300] dark:hover:bg-primary-600/15
|
||||||
|
- ghost: dark:text-[--color-primary-300] dark:hover:bg-primary-600/12
|
||||||
|
- danger: dark:bg-[#EF4444] dark:text-white dark:hover:bg-[#DC2626]
|
||||||
|
|
||||||
|
**Button 사이즈:** sm(h-8 px-3 text-sm), md(h-10 px-4 text-sm), lg(h-12 px-6 text-base)
|
||||||
|
|
||||||
|
**Badge 변형:** default, primary, secondary, accent, success, warning, danger
|
||||||
|
**Card:** bg-[--color-bg-surface] border border-[--color-border] rounded-lg shadow-sm
|
||||||
|
**Input:** border-[--color-border] focus:ring-2 focus:ring-[--color-primary]/30 focus:border-[--color-primary]
|
||||||
|
**Modal:** bg-[--color-bg-surface] rounded-xl shadow-2xl, overlay bg-black/50
|
||||||
|
**Toast:** success(green), error(red), warning(amber), info(blue) — 시멘틱 컬러 사용
|
||||||
|
|
||||||
|
### 1-5. docs/design/icons.md
|
||||||
|
|
||||||
|
아이콘 시스템:
|
||||||
|
- Lucide React 사용
|
||||||
|
- 사이즈: xs(14px), sm(16px), md(20px), lg(24px), xl(32px)
|
||||||
|
- 색상: 시멘틱 토큰 사용 (text-[--color-text-secondary] 기본)
|
||||||
|
- 인터랙티브 아이콘: hover 상태 시 text-[--color-primary]
|
||||||
|
|
||||||
|
### 1-6. docs/design/motion.md
|
||||||
|
|
||||||
|
모션 시스템:
|
||||||
|
- Duration: fast(100ms), normal(200ms), slow(300ms), slower(500ms)
|
||||||
|
- Easing: ease-out(진입), ease-in(퇴장), ease-in-out(이동)
|
||||||
|
- 패턴: fade-in, slide-up, scale, collapse
|
||||||
|
- prefers-reduced-motion: reduce 시 애니메이션 비활성화
|
||||||
|
|
||||||
|
### 1-7. docs/design/code-conventions.md
|
||||||
|
|
||||||
|
코드 컨벤션:
|
||||||
|
- 파일명: PascalCase(컴포넌트), camelCase(유틸/훅), kebab-case(CSS)
|
||||||
|
- Tailwind 클래스 순서: layout → sizing → spacing → border → bg → text → effect → transition
|
||||||
|
- cn() 유틸리티 사용 (clsx + twMerge)
|
||||||
|
- import 순서: react → 외부 → 내부(@/) → 타입 → 스타일
|
||||||
|
- forwardRef 패턴: DOM 래핑 컴포넌트에 적용
|
||||||
|
|
||||||
|
### 1-8. docs/design/do-dont.md
|
||||||
|
|
||||||
|
Do/Don't 가이드:
|
||||||
|
- DO: CSS 변수 토큰 사용 (bg-[--color-primary])
|
||||||
|
- DON'T: 하드코딩 HEX (#6D94C5, #507FB9, #5C6570, #9198A1, #D5D2CC, #1E2329 등)
|
||||||
|
- DO: 시멘틱 컬러로 상태 표현
|
||||||
|
- DON'T: 임의 색상으로 상태 표현
|
||||||
|
- DO: spacing 스케일 사용 (p-4, gap-6)
|
||||||
|
- DON'T: 임의 px값 (p-[13px])
|
||||||
|
- DO: cn() 유틸리티로 조건부 클래스
|
||||||
|
- DON'T: 삼항연산자로 className 문자열 조합
|
||||||
|
- DO: 다크모드 버튼에 스케일 값 사용 (dark:bg-[--color-primary-600])
|
||||||
|
- DON'T: 다크모드 버튼에 시멘틱 토큰 그대로 사용 (dark:bg-[--color-primary] → 연한 파스텔톤으로 구분 안됨)
|
||||||
|
|
||||||
|
### 1-9. docs/design/prompts/new-component.md
|
||||||
|
|
||||||
|
새 컴포넌트 생성 프롬프트 템플릿:
|
||||||
|
```
|
||||||
|
[컴포넌트명] 컴포넌트를 만들어줘.
|
||||||
|
- docs/design/ 가이드 참조
|
||||||
|
- Props interface 정의
|
||||||
|
- 변형(variant), 사이즈(size) 지원
|
||||||
|
- 다크모드 대응 (CSS 변수 토큰)
|
||||||
|
- forwardRef 적용 (DOM 래핑 시)
|
||||||
|
- cn() 유틸리티 사용
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1-10. docs/design/prompts/review-checklist.md
|
||||||
|
|
||||||
|
디자인 리뷰 체크리스트 (9개 카테고리):
|
||||||
|
1. 컬러: CSS 변수 토큰 사용 여부, 하드코딩 HEX 없는지
|
||||||
|
2. 타이포그래피: 타입 스케일 준수
|
||||||
|
3. 스페이싱: 4px 스케일 준수
|
||||||
|
4. 반응형: 브레이크포인트 대응
|
||||||
|
5. 다크모드: .dark 클래스 + CSS 변수 동작
|
||||||
|
6. 접근성: color contrast 4.5:1 이상
|
||||||
|
7. 컴포넌트: variant/size 시스템 준수
|
||||||
|
8. 모션: duration/easing 스케일 준수
|
||||||
|
9. 코드: cn(), forwardRef, import 순서
|
||||||
|
|
||||||
|
### 1-11. docs/design/prompts/refactor-style.md
|
||||||
|
|
||||||
|
스타일 리팩토링 프롬프트 템플릿:
|
||||||
|
```
|
||||||
|
[파일/컴포넌트] 스타일을 디자인 시스템으로 리팩토링해줘.
|
||||||
|
- 하드코딩 색상 → CSS 변수 토큰
|
||||||
|
- Tailwind 기본 색상(bg-gray-*, text-blue-*) → 커스텀 토큰
|
||||||
|
- spacing 임의값 → 스케일 값
|
||||||
|
- docs/design/ 가이드 기준으로 검증
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. frontend/src/index.css 수정
|
||||||
|
|
||||||
|
현재 내용 유지하면서, `@import 'tailwindcss';` 뒤에 `@theme` 블록과 다크모드 블록을 추가해줘:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* === Brand: Primary (Slate Blue) === */
|
||||||
|
--color-primary-50: #F5F7F9;
|
||||||
|
--color-primary-100: #E5EBF2;
|
||||||
|
--color-primary-200: #D2DAE5;
|
||||||
|
--color-primary-300: #B7C5D7;
|
||||||
|
--color-primary-400: #8AA5C7;
|
||||||
|
--color-primary-500: #6D94C5;
|
||||||
|
--color-primary-600: #507FB9;
|
||||||
|
--color-primary-700: #3B669C;
|
||||||
|
--color-primary-800: #2D507B;
|
||||||
|
--color-primary-900: #1B2C41;
|
||||||
|
--color-primary-950: #0E1A2B;
|
||||||
|
|
||||||
|
/* === Brand: Secondary (Powder Blue) === */
|
||||||
|
--color-secondary-50: #F5F7F9;
|
||||||
|
--color-secondary-100: #E6EDF5;
|
||||||
|
--color-secondary-200: #D1DAE5;
|
||||||
|
--color-secondary-300: #B7C8D7;
|
||||||
|
--color-secondary-400: #89AAC8;
|
||||||
|
--color-secondary-500: #CBDCEB;
|
||||||
|
--color-secondary-600: #6B9AC8;
|
||||||
|
--color-secondary-700: #4E88BB;
|
||||||
|
--color-secondary-800: #396E9D;
|
||||||
|
--color-secondary-900: #1B2F41;
|
||||||
|
--color-secondary-950: #0E1A2B;
|
||||||
|
|
||||||
|
/* === Brand: Accent (Warm Sand) === */
|
||||||
|
--color-accent-50: #F9F8F6;
|
||||||
|
--color-accent-100: #F2EDE4;
|
||||||
|
--color-accent-200: #E4E0D7;
|
||||||
|
--color-accent-300: #D5CDB9;
|
||||||
|
--color-accent-400: #C4B48C;
|
||||||
|
--color-accent-500: #E8DFCA;
|
||||||
|
--color-accent-600: #B59854;
|
||||||
|
--color-accent-700: #9A7E3E;
|
||||||
|
--color-accent-800: #786230;
|
||||||
|
--color-accent-900: #3F351D;
|
||||||
|
--color-accent-950: #2B2312;
|
||||||
|
|
||||||
|
/* === Semantic === */
|
||||||
|
--color-success: #059669;
|
||||||
|
--color-warning: #D97706;
|
||||||
|
--color-danger: #DC2626;
|
||||||
|
--color-info: #0284C7;
|
||||||
|
|
||||||
|
/* === Neutral (Light defaults) === */
|
||||||
|
--color-bg-base: #F5EFE6;
|
||||||
|
--color-bg-surface: #FFFFFF;
|
||||||
|
--color-bg-elevated: #FFFFFF;
|
||||||
|
--color-border: #D5D2CC;
|
||||||
|
--color-border-strong: #9198A1;
|
||||||
|
--color-text-primary: #1E2329;
|
||||||
|
--color-text-secondary: #5C6570;
|
||||||
|
--color-text-tertiary: #9198A1;
|
||||||
|
|
||||||
|
/* === Design Tokens (Light defaults) === */
|
||||||
|
--color-primary: #507FB9;
|
||||||
|
--color-primary-hover: #3B669C;
|
||||||
|
--color-primary-active: #2D507B;
|
||||||
|
--color-primary-subtle: #F5F7F9;
|
||||||
|
--color-primary-text: #2D507B;
|
||||||
|
--color-secondary: #CBDCEB;
|
||||||
|
--color-secondary-hover: #89AAC8;
|
||||||
|
--color-secondary-active: #4E88BB;
|
||||||
|
--color-secondary-subtle: #F5F7F9;
|
||||||
|
--color-secondary-text: #396E9D;
|
||||||
|
--color-accent: #E8DFCA;
|
||||||
|
--color-accent-hover: #C4B48C;
|
||||||
|
--color-accent-active: #B59854;
|
||||||
|
--color-accent-subtle: #F9F8F6;
|
||||||
|
--color-accent-text: #786230;
|
||||||
|
|
||||||
|
/* === Chart Palette === */
|
||||||
|
--color-chart-1: #507FB9;
|
||||||
|
--color-chart-2: #B59854;
|
||||||
|
--color-chart-3: #4E88BB;
|
||||||
|
--color-chart-4: #9A7E3E;
|
||||||
|
--color-chart-5: #3B669C;
|
||||||
|
--color-chart-6: #C4B48C;
|
||||||
|
|
||||||
|
/* === Typography === */
|
||||||
|
--font-sans: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', Consolas, Monaco, 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Dark Mode === */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not(.light) {
|
||||||
|
--color-success: #34D399;
|
||||||
|
--color-warning: #FBBF24;
|
||||||
|
--color-danger: #FCA5A5;
|
||||||
|
--color-info: #38BDF8;
|
||||||
|
|
||||||
|
--color-bg-base: #131416;
|
||||||
|
--color-bg-surface: #1E2023;
|
||||||
|
--color-bg-elevated: #282A2E;
|
||||||
|
--color-border: #3A3C41;
|
||||||
|
--color-border-strong: #5C6570;
|
||||||
|
--color-text-primary: #F4F5F5;
|
||||||
|
--color-text-secondary: #9198A1;
|
||||||
|
--color-text-tertiary: #5C6570;
|
||||||
|
|
||||||
|
--color-primary: #B7C5D7;
|
||||||
|
--color-primary-hover: #D2DAE5;
|
||||||
|
--color-primary-active: #8AA5C7;
|
||||||
|
--color-primary-subtle: #1B2C41;
|
||||||
|
--color-primary-text: #D2DAE5;
|
||||||
|
--color-secondary: #B7C8D7;
|
||||||
|
--color-secondary-hover: #D1DAE5;
|
||||||
|
--color-secondary-active: #89AAC8;
|
||||||
|
--color-secondary-subtle: #1B2F41;
|
||||||
|
--color-secondary-text: #D1DAE5;
|
||||||
|
--color-accent: #D5CDB9;
|
||||||
|
--color-accent-hover: #E4E0D7;
|
||||||
|
--color-accent-active: #C4B48C;
|
||||||
|
--color-accent-subtle: #3F351D;
|
||||||
|
--color-accent-text: #E4E0D7;
|
||||||
|
|
||||||
|
--color-chart-1: #6D94C5;
|
||||||
|
--color-chart-2: #C4B48C;
|
||||||
|
--color-chart-3: #6B9AC8;
|
||||||
|
--color-chart-4: #B59854;
|
||||||
|
--color-chart-5: #8AA5C7;
|
||||||
|
--color-chart-6: #D5CDB9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--color-success: #34D399;
|
||||||
|
--color-warning: #FBBF24;
|
||||||
|
--color-danger: #FCA5A5;
|
||||||
|
--color-info: #38BDF8;
|
||||||
|
|
||||||
|
--color-bg-base: #131416;
|
||||||
|
--color-bg-surface: #1E2023;
|
||||||
|
--color-bg-elevated: #282A2E;
|
||||||
|
--color-border: #3A3C41;
|
||||||
|
--color-border-strong: #5C6570;
|
||||||
|
--color-text-primary: #F4F5F5;
|
||||||
|
--color-text-secondary: #9198A1;
|
||||||
|
--color-text-tertiary: #5C6570;
|
||||||
|
|
||||||
|
--color-primary: #B7C5D7;
|
||||||
|
--color-primary-hover: #D2DAE5;
|
||||||
|
--color-primary-active: #8AA5C7;
|
||||||
|
--color-primary-subtle: #1B2C41;
|
||||||
|
--color-primary-text: #D2DAE5;
|
||||||
|
--color-secondary: #B7C8D7;
|
||||||
|
--color-secondary-hover: #D1DAE5;
|
||||||
|
--color-secondary-active: #89AAC8;
|
||||||
|
--color-secondary-subtle: #1B2F41;
|
||||||
|
--color-secondary-text: #D1DAE5;
|
||||||
|
--color-accent: #D5CDB9;
|
||||||
|
--color-accent-hover: #E4E0D7;
|
||||||
|
--color-accent-active: #C4B48C;
|
||||||
|
--color-accent-subtle: #3F351D;
|
||||||
|
--color-accent-text: #E4E0D7;
|
||||||
|
|
||||||
|
--color-chart-1: #6D94C5;
|
||||||
|
--color-chart-2: #C4B48C;
|
||||||
|
--color-chart-3: #6B9AC8;
|
||||||
|
--color-chart-4: #B59854;
|
||||||
|
--color-chart-5: #8AA5C7;
|
||||||
|
--color-chart-6: #D5CDB9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode date input calendar icon */
|
||||||
|
.dark input[type="date"]::-webkit-calendar-picker-indicator {
|
||||||
|
filter: invert(1);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. cn() 유틸리티 생성
|
||||||
|
|
||||||
|
`frontend/src/utils/cn.ts` 파일을 생성:
|
||||||
|
```ts
|
||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
|
||||||
|
```
|
||||||
|
|
||||||
|
clsx와 tailwind-merge 패키지가 없으면 설치해줘:
|
||||||
|
```bash
|
||||||
|
cd frontend && npm install clsx tailwind-merge
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 차트 팔레트 상수 파일 생성
|
||||||
|
|
||||||
|
`frontend/src/constants/chart.ts`:
|
||||||
|
```ts
|
||||||
|
export const CHART_COLORS = [
|
||||||
|
'var(--color-chart-1)',
|
||||||
|
'var(--color-chart-2)',
|
||||||
|
'var(--color-chart-3)',
|
||||||
|
'var(--color-chart-4)',
|
||||||
|
'var(--color-chart-5)',
|
||||||
|
'var(--color-chart-6)',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** CSS computed value가 필요한 경우 (Recharts 등) */
|
||||||
|
export const CHART_COLORS_HEX = {
|
||||||
|
light: ['#507FB9', '#B59854', '#4E88BB', '#9A7E3E', '#3B669C', '#C4B48C'],
|
||||||
|
dark: ['#6D94C5', '#C4B48C', '#6B9AC8', '#B59854', '#8AA5C7', '#D5CDB9'],
|
||||||
|
} as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. CLAUDE.md에 디자인 가이드 참조 추가
|
||||||
|
|
||||||
|
CLAUDE.md의 `## 팀 규칙` 섹션 아래에 다음을 추가:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 디자인 시스템
|
||||||
|
|
||||||
|
- 컬러 시스템: `docs/design/colors.md`
|
||||||
|
- 타이포그래피: `docs/design/typography.md`
|
||||||
|
- 스페이싱/레이아웃: `docs/design/spacing.md`
|
||||||
|
- 컴포넌트 스펙: `docs/design/components.md`
|
||||||
|
- 아이콘: `docs/design/icons.md`
|
||||||
|
- 모션: `docs/design/motion.md`
|
||||||
|
- 코드 컨벤션: `docs/design/code-conventions.md`
|
||||||
|
- Do/Don't: `docs/design/do-dont.md`
|
||||||
|
|
||||||
|
### 프롬프트 템플릿
|
||||||
|
- 새 컴포넌트: `docs/design/prompts/new-component.md`
|
||||||
|
- 리뷰 체크리스트: `docs/design/prompts/review-checklist.md`
|
||||||
|
- 스타일 리팩토링: `docs/design/prompts/refactor-style.md`
|
||||||
|
|
||||||
|
### 핵심 규칙
|
||||||
|
- 색상은 반드시 CSS 변수 토큰 사용 (하드코딩 HEX 금지)
|
||||||
|
- 브랜드 컬러: Primary #6D94C5(Slate Blue), Secondary #CBDCEB(Powder Blue), Accent #E8DFCA(Warm Sand), BG #F5EFE6(Warm Cream)
|
||||||
|
- 다크모드: `.dark` 클래스 + CSS 변수 자동 전환
|
||||||
|
- 차트: CHART_COLORS_HEX 상수 사용 (Cool+Warm 교차 배치)
|
||||||
|
- Tailwind 기본 색상(gray-*, blue-* 등) 대신 커스텀 토큰 사용
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 빌드 검증
|
||||||
|
|
||||||
|
모든 파일 생성 후:
|
||||||
|
1. `cd frontend && npm run build` 로 프론트엔드 빌드 성공 확인
|
||||||
|
2. 에러 없으면 커밋:
|
||||||
|
- 1차 커밋: `docs(design): 디자인 시스템 가이드 문서 추가`
|
||||||
|
- 2차 커밋: `feat(frontend): 디자인 토큰 및 테마 시스템 적용`
|
||||||
|
|
||||||
|
## 주의사항
|
||||||
|
|
||||||
|
- 기존 컴포넌트의 하드코딩 색상(bg-gray-900, text-blue-500 등)은 이번 작업에서는 변경하지 않는다 (별도 리팩토링 예정)
|
||||||
|
- ThemeContext.tsx는 이미 존재하므로 수정하지 않는다
|
||||||
|
- `@theme` 블록은 반드시 `@import 'tailwindcss';` 뒤, `@custom-variant` 뒤에 위치해야 한다
|
||||||
|
- npm 레지스트리는 frontend/.npmrc에 설정된 Nexus 프록시를 사용한다
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**사용법:** 위의 ` ``` ` 블록 안의 전체 내용을 복사하여 Claude Code에 프롬프트로 입력하세요.
|
||||||
670
design-system-preview.html
Normal file
670
design-system-preview.html
Normal file
@ -0,0 +1,670 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>SNP Connection Monitoring — 디자인 시스템 프리뷰</title>
|
||||||
|
<style>
|
||||||
|
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css');
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Brand Scales */
|
||||||
|
--color-primary-50: #F5F7F9; --color-primary-100: #E5EBF2;
|
||||||
|
--color-primary-200: #D2DAE5; --color-primary-300: #B7C5D7;
|
||||||
|
--color-primary-400: #8AA5C7; --color-primary-500: #6D94C5;
|
||||||
|
--color-primary-600: #507FB9; --color-primary-700: #3B669C;
|
||||||
|
--color-primary-800: #2D507B; --color-primary-900: #1B2C41;
|
||||||
|
--color-primary-950: #0E1A2B;
|
||||||
|
|
||||||
|
--color-secondary-50: #F5F7F9; --color-secondary-100: #E6EDF5;
|
||||||
|
--color-secondary-200: #D1DAE5; --color-secondary-300: #B7C8D7;
|
||||||
|
--color-secondary-400: #89AAC8; --color-secondary-500: #CBDCEB;
|
||||||
|
--color-secondary-600: #6B9AC8; --color-secondary-700: #4E88BB;
|
||||||
|
--color-secondary-800: #396E9D; --color-secondary-900: #1B2F41;
|
||||||
|
--color-secondary-950: #0E1A2B;
|
||||||
|
|
||||||
|
--color-accent-50: #F9F8F6; --color-accent-100: #F2EDE4;
|
||||||
|
--color-accent-200: #E4E0D7; --color-accent-300: #D5CDB9;
|
||||||
|
--color-accent-400: #C4B48C; --color-accent-500: #E8DFCA;
|
||||||
|
--color-accent-600: #B59854; --color-accent-700: #9A7E3E;
|
||||||
|
--color-accent-800: #786230; --color-accent-900: #3F351D;
|
||||||
|
--color-accent-950: #2B2312;
|
||||||
|
|
||||||
|
/* Semantic Tokens - Light */
|
||||||
|
--color-primary: #507FB9;
|
||||||
|
--color-primary-hover: #3B669C;
|
||||||
|
--color-primary-active: #2D507B;
|
||||||
|
--color-primary-subtle: #F5F7F9;
|
||||||
|
--color-primary-text: #2D507B;
|
||||||
|
--color-secondary: #CBDCEB;
|
||||||
|
--color-secondary-hover: #89AAC8;
|
||||||
|
--color-secondary-active: #4E88BB;
|
||||||
|
--color-secondary-subtle: #F5F7F9;
|
||||||
|
--color-secondary-text: #396E9D;
|
||||||
|
--color-accent: #E8DFCA;
|
||||||
|
--color-accent-hover: #C4B48C;
|
||||||
|
--color-accent-active: #B59854;
|
||||||
|
--color-accent-subtle: #F9F8F6;
|
||||||
|
--color-accent-text: #786230;
|
||||||
|
|
||||||
|
--color-success: #059669; --color-warning: #D97706;
|
||||||
|
--color-danger: #DC2626; --color-info: #0284C7;
|
||||||
|
|
||||||
|
--color-bg-base: #F5EFE6; --color-bg-surface: #FFFFFF;
|
||||||
|
--color-bg-elevated: #FFFFFF;
|
||||||
|
--color-border: #D5D2CC; --color-border-strong: #9198A1;
|
||||||
|
--color-text-primary: #1E2329; --color-text-secondary: #5C6570;
|
||||||
|
--color-text-tertiary: #9198A1;
|
||||||
|
|
||||||
|
--color-chart-1: #507FB9; --color-chart-2: #B59854;
|
||||||
|
--color-chart-3: #4E88BB; --color-chart-4: #9A7E3E;
|
||||||
|
--color-chart-5: #3B669C; --color-chart-6: #C4B48C;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--color-primary: #B7C5D7; --color-primary-hover: #D2DAE5;
|
||||||
|
--color-primary-active: #8AA5C7; --color-primary-subtle: #1B2C41;
|
||||||
|
--color-primary-text: #D2DAE5;
|
||||||
|
--color-secondary: #B7C8D7; --color-secondary-hover: #D1DAE5;
|
||||||
|
--color-secondary-active: #89AAC8; --color-secondary-subtle: #1B2F41;
|
||||||
|
--color-secondary-text: #D1DAE5;
|
||||||
|
--color-accent: #D5CDB9; --color-accent-hover: #E4E0D7;
|
||||||
|
--color-accent-active: #C4B48C; --color-accent-subtle: #3F351D;
|
||||||
|
--color-accent-text: #E4E0D7;
|
||||||
|
|
||||||
|
--color-success: #34D399; --color-warning: #FBBF24;
|
||||||
|
--color-danger: #FCA5A5; --color-info: #38BDF8;
|
||||||
|
|
||||||
|
--color-bg-base: #131416; --color-bg-surface: #1E2023;
|
||||||
|
--color-bg-elevated: #282A2E;
|
||||||
|
--color-border: #3A3C41; --color-border-strong: #5C6570;
|
||||||
|
--color-text-primary: #F4F5F5; --color-text-secondary: #9198A1;
|
||||||
|
--color-text-tertiary: #5C6570;
|
||||||
|
|
||||||
|
--color-chart-1: #6D94C5; --color-chart-2: #C4B48C;
|
||||||
|
--color-chart-3: #6B9AC8; --color-chart-4: #B59854;
|
||||||
|
--color-chart-5: #8AA5C7; --color-chart-6: #D5CDB9;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||||
|
background: var(--color-bg-base);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
transition: background 0.3s, color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Layout === */
|
||||||
|
.app { display: flex; min-height: 100vh; }
|
||||||
|
.sidebar {
|
||||||
|
width: 260px; background: var(--color-bg-surface);
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-secondary); padding: 24px 16px;
|
||||||
|
display: flex; flex-direction: column; gap: 8px;
|
||||||
|
flex-shrink: 0; transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.sidebar-logo {
|
||||||
|
font-size: 18px; font-weight: 700; color: var(--color-text-primary);
|
||||||
|
padding: 8px 12px 20px; border-bottom: 1px solid var(--color-border);
|
||||||
|
margin-bottom: 12px; display: flex; align-items: center; gap: 10px;
|
||||||
|
}
|
||||||
|
.sidebar-logo svg { flex-shrink: 0; }
|
||||||
|
.sidebar-logo .logo-rect { fill: var(--color-primary); transition: fill 0.3s; }
|
||||||
|
.sidebar-item {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 10px 12px; border-radius: 8px; font-size: 14px;
|
||||||
|
cursor: pointer; transition: all 0.15s; color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.sidebar-item:hover { background: var(--color-primary-subtle); color: var(--color-primary-text); }
|
||||||
|
.sidebar-item.active { background: var(--color-primary); color: #fff; font-weight: 500; }
|
||||||
|
.dark .sidebar-item.active { background: var(--color-primary-subtle); color: var(--color-primary-text); }
|
||||||
|
.sidebar-section { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-tertiary); padding: 16px 12px 6px; font-weight: 600; }
|
||||||
|
|
||||||
|
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.header {
|
||||||
|
background: var(--color-bg-surface); border-bottom: 1px solid var(--color-border);
|
||||||
|
padding: 16px 32px; display: flex; align-items: center; justify-content: space-between;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.header h1 { font-size: 20px; font-weight: 600; }
|
||||||
|
.content { padding: 32px; overflow-y: auto; flex: 1; background: var(--color-bg-base); }
|
||||||
|
|
||||||
|
/* === Cards === */
|
||||||
|
.card {
|
||||||
|
background: var(--color-bg-surface); border: 1px solid var(--color-border);
|
||||||
|
border-radius: 12px; padding: 24px; transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.06); }
|
||||||
|
.card-title { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
|
||||||
|
.card-subtitle { font-size: 12px; color: var(--color-text-tertiary); margin-bottom: 16px; }
|
||||||
|
.card-value { font-size: 32px; font-weight: 700; letter-spacing: -0.02em; }
|
||||||
|
.card-change { font-size: 13px; margin-top: 8px; display: flex; align-items: center; gap: 4px; }
|
||||||
|
.card-change.up { color: var(--color-success); }
|
||||||
|
.card-change.down { color: var(--color-danger); }
|
||||||
|
|
||||||
|
/* === Stat Grid === */
|
||||||
|
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 28px; }
|
||||||
|
|
||||||
|
/* === Chart Area === */
|
||||||
|
.chart-grid { display: grid; grid-template-columns: 2fr 1fr; gap: 20px; margin-bottom: 28px; }
|
||||||
|
.chart-container { height: 260px; position: relative; display: flex; align-items: flex-end; gap: 6px; padding-top: 24px; }
|
||||||
|
.chart-bar {
|
||||||
|
flex: 1; border-radius: 6px 6px 0 0; transition: all 0.3s; cursor: pointer;
|
||||||
|
position: relative; min-width: 20px;
|
||||||
|
}
|
||||||
|
.chart-bar:hover { opacity: 0.85; transform: translateY(-2px); }
|
||||||
|
.chart-label { position: absolute; bottom: -22px; left: 50%; transform: translateX(-50%); font-size: 11px; color: var(--color-text-tertiary); white-space: nowrap; }
|
||||||
|
|
||||||
|
/* === Donut Chart === */
|
||||||
|
.donut-wrap { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20px; }
|
||||||
|
.donut { position: relative; width: 160px; height: 160px; }
|
||||||
|
.donut svg { transform: rotate(-90deg); }
|
||||||
|
.donut-center {
|
||||||
|
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.donut-center .value { font-size: 28px; font-weight: 700; }
|
||||||
|
.donut-center .label { font-size: 12px; color: var(--color-text-tertiary); }
|
||||||
|
.donut-legend { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; }
|
||||||
|
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--color-text-secondary); }
|
||||||
|
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||||
|
|
||||||
|
/* === Buttons === */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 8px 16px; border-radius: 8px; font-size: 14px;
|
||||||
|
font-weight: 500; border: none; cursor: pointer;
|
||||||
|
transition: all 0.15s; font-family: inherit;
|
||||||
|
}
|
||||||
|
.btn-primary { background: var(--color-primary); color: #fff; }
|
||||||
|
.btn-primary:hover { background: var(--color-primary-hover); }
|
||||||
|
.btn-secondary { background: var(--color-secondary); color: var(--color-secondary-text); }
|
||||||
|
.btn-secondary:hover { background: var(--color-secondary-hover); }
|
||||||
|
.btn-accent { background: var(--color-accent); color: var(--color-accent-text); }
|
||||||
|
.btn-accent:hover { background: var(--color-accent-hover); }
|
||||||
|
.btn-outline { background: transparent; color: var(--color-text-primary); border: 1px solid var(--color-border-strong); }
|
||||||
|
.btn-outline:hover { background: var(--color-primary-subtle); }
|
||||||
|
.btn-ghost { background: transparent; color: var(--color-text-secondary); }
|
||||||
|
.btn-ghost:hover { background: var(--color-primary-subtle); }
|
||||||
|
.btn-danger { background: var(--color-danger); color: #fff; }
|
||||||
|
.btn-danger:hover { opacity: 0.9; }
|
||||||
|
.btn-sm { padding: 6px 12px; font-size: 13px; }
|
||||||
|
.btn-lg { padding: 12px 24px; font-size: 16px; }
|
||||||
|
|
||||||
|
/* Dark mode: 버튼 채도/대비 강화 */
|
||||||
|
.dark .btn-primary { background: var(--color-primary-600); color: #fff; }
|
||||||
|
.dark .btn-primary:hover { background: var(--color-primary-500); }
|
||||||
|
.dark .btn-secondary { background: var(--color-secondary-700); color: #fff; }
|
||||||
|
.dark .btn-secondary:hover { background: var(--color-secondary-600); }
|
||||||
|
.dark .btn-accent { background: var(--color-accent-600); color: #fff; }
|
||||||
|
.dark .btn-accent:hover { background: var(--color-accent-500); }
|
||||||
|
.dark .btn-outline { border-color: var(--color-primary-400); color: var(--color-primary-300); }
|
||||||
|
.dark .btn-outline:hover { background: rgba(80,127,185,0.15); border-color: var(--color-primary-300); }
|
||||||
|
.dark .btn-ghost { color: var(--color-primary-300); }
|
||||||
|
.dark .btn-ghost:hover { background: rgba(80,127,185,0.12); }
|
||||||
|
.dark .btn-danger { background: #EF4444; color: #fff; }
|
||||||
|
.dark .btn-danger:hover { background: #DC2626; }
|
||||||
|
|
||||||
|
/* === Badges === */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
padding: 2px 10px; border-radius: 9999px; font-size: 12px; font-weight: 500;
|
||||||
|
}
|
||||||
|
.badge-default { background: var(--color-bg-elevated); color: var(--color-text-secondary); border: 1px solid var(--color-border); }
|
||||||
|
.badge-primary { background: var(--color-primary-subtle); color: var(--color-primary-text); }
|
||||||
|
.badge-secondary { background: var(--color-secondary-subtle); color: var(--color-secondary-text); }
|
||||||
|
.badge-accent { background: var(--color-accent-subtle); color: var(--color-accent-text); }
|
||||||
|
.badge-success { background: #ecfdf5; color: var(--color-success); }
|
||||||
|
.badge-warning { background: #fffbeb; color: var(--color-warning); }
|
||||||
|
.badge-danger { background: #fef2f2; color: var(--color-danger); }
|
||||||
|
.badge-info { background: #f0f9ff; color: var(--color-info); }
|
||||||
|
.dark .badge-success { background: rgba(52,211,153,0.1); }
|
||||||
|
.dark .badge-warning { background: rgba(251,191,36,0.1); }
|
||||||
|
.dark .badge-danger { background: rgba(252,165,165,0.1); }
|
||||||
|
.dark .badge-info { background: rgba(56,189,248,0.1); }
|
||||||
|
|
||||||
|
/* === Table === */
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||||
|
th {
|
||||||
|
text-align: left; padding: 12px 16px; font-weight: 600; font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary); text-transform: uppercase; letter-spacing: 0.03em;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
td { padding: 14px 16px; border-bottom: 1px solid var(--color-border); }
|
||||||
|
tr:hover td { background: var(--color-primary-subtle); }
|
||||||
|
|
||||||
|
/* === Input === */
|
||||||
|
.input-group { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.input-label { font-size: 13px; font-weight: 500; color: var(--color-text-secondary); }
|
||||||
|
.input {
|
||||||
|
padding: 10px 14px; border: 1px solid var(--color-border); border-radius: 8px;
|
||||||
|
font-size: 14px; font-family: inherit; background: var(--color-bg-surface);
|
||||||
|
color: var(--color-text-primary); transition: all 0.15s; outline: none;
|
||||||
|
}
|
||||||
|
.input:focus { border-color: var(--color-primary); box-shadow: 0 0 0 3px rgba(80,127,185,0.15); }
|
||||||
|
.input::placeholder { color: var(--color-text-tertiary); }
|
||||||
|
|
||||||
|
/* === Theme Toggle === */
|
||||||
|
.theme-toggle {
|
||||||
|
background: var(--color-bg-elevated); border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px; padding: 8px 14px; cursor: pointer;
|
||||||
|
display: flex; align-items: center; gap: 6px; font-size: 13px;
|
||||||
|
color: var(--color-text-secondary); font-family: inherit; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.theme-toggle:hover { border-color: var(--color-border-strong); }
|
||||||
|
|
||||||
|
/* === Color Swatch === */
|
||||||
|
.swatch-grid { display: grid; grid-template-columns: repeat(11, 1fr); gap: 4px; }
|
||||||
|
.swatch {
|
||||||
|
aspect-ratio: 1; border-radius: 8px; display: flex; align-items: flex-end;
|
||||||
|
justify-content: center; padding: 4px; font-size: 10px; font-weight: 500;
|
||||||
|
}
|
||||||
|
.swatch-row-label { font-size: 13px; font-weight: 600; margin-bottom: 8px; margin-top: 20px; color: var(--color-text-secondary); }
|
||||||
|
|
||||||
|
/* === Section === */
|
||||||
|
.section { margin-bottom: 40px; }
|
||||||
|
.section-title { font-size: 16px; font-weight: 600; margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.section-title::before { content: ''; width: 4px; height: 20px; background: var(--color-primary); border-radius: 2px; }
|
||||||
|
.component-row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 16px; }
|
||||||
|
|
||||||
|
/* === Status Dot === */
|
||||||
|
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||||
|
.status-dot.online { background: var(--color-success); }
|
||||||
|
.status-dot.warning { background: var(--color-warning); }
|
||||||
|
.status-dot.offline { background: var(--color-danger); }
|
||||||
|
.status-dot.maintenance { background: var(--color-info); }
|
||||||
|
|
||||||
|
/* === Toast === */
|
||||||
|
.toast {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 14px 18px; border-radius: 10px; font-size: 14px;
|
||||||
|
border-left: 4px solid;
|
||||||
|
}
|
||||||
|
.toast-success { background: #ecfdf5; border-color: var(--color-success); color: #065f46; }
|
||||||
|
.toast-warning { background: #fffbeb; border-color: var(--color-warning); color: #92400e; }
|
||||||
|
.toast-danger { background: #fef2f2; border-color: var(--color-danger); color: #991b1b; }
|
||||||
|
.toast-info { background: #f0f9ff; border-color: var(--color-info); color: #075985; }
|
||||||
|
.dark .toast-success { background: rgba(52,211,153,0.08); color: var(--color-success); }
|
||||||
|
.dark .toast-warning { background: rgba(251,191,36,0.08); color: var(--color-warning); }
|
||||||
|
.dark .toast-danger { background: rgba(252,165,165,0.08); color: var(--color-danger); }
|
||||||
|
.dark .toast-info { background: rgba(56,189,248,0.08); color: var(--color-info); }
|
||||||
|
.toast-grid { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
|
||||||
|
/* === Responsive === */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.stat-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.chart-grid { grid-template-columns: 1fr; }
|
||||||
|
.sidebar { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app" id="app">
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<nav class="sidebar">
|
||||||
|
<div class="sidebar-logo">
|
||||||
|
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
|
||||||
|
<rect class="logo-rect" width="28" height="28" rx="8" fill="#507FB9"/>
|
||||||
|
<path d="M7 18 L14 10 L21 18" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M7 22 L21 22" stroke="white" stroke-width="2" stroke-linecap="round" opacity="0.6"/>
|
||||||
|
</svg>
|
||||||
|
SNP Connection
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section">모니터링</div>
|
||||||
|
<div class="sidebar-item active">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
||||||
|
대시보드
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-item">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
||||||
|
서비스 상태
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-item">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/></svg>
|
||||||
|
API 통계
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section">관리</div>
|
||||||
|
<div class="sidebar-item">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
|
사용자 관리
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-item">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
|
||||||
|
서비스 설정
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-item">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||||
|
요청 로그
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="main">
|
||||||
|
<header class="header">
|
||||||
|
<h1>대시보드</h1>
|
||||||
|
<div style="display:flex;gap:10px;align-items:center;">
|
||||||
|
<div class="input-group" style="flex-direction:row;align-items:center;gap:8px;">
|
||||||
|
<input type="text" class="input" placeholder="검색..." style="width:220px;padding:8px 12px;">
|
||||||
|
</div>
|
||||||
|
<button class="theme-toggle" onclick="toggleTheme()">
|
||||||
|
<span id="theme-icon">🌙</span>
|
||||||
|
<span id="theme-label">다크 모드</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
|
||||||
|
<!-- Stat Cards -->
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">총 API 요청</div>
|
||||||
|
<div class="card-subtitle">오늘</div>
|
||||||
|
<div class="card-value" style="color:var(--color-primary);">12,847</div>
|
||||||
|
<div class="card-change up">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 15l-6-6-6 6"/></svg>
|
||||||
|
+12.5% vs 어제
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">활성 서비스</div>
|
||||||
|
<div class="card-subtitle">연결된 서비스</div>
|
||||||
|
<div class="card-value" style="color:var(--color-success);">24<span style="font-size:16px;color:var(--color-text-tertiary);font-weight:400;"> / 26</span></div>
|
||||||
|
<div class="card-change down">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M6 9l6 6 6-6"/></svg>
|
||||||
|
2개 서비스 점검중
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">평균 응답 시간</div>
|
||||||
|
<div class="card-subtitle">최근 1시간</div>
|
||||||
|
<div class="card-value" style="color:var(--color-secondary-text);">142<span style="font-size:16px;color:var(--color-text-tertiary);font-weight:400;">ms</span></div>
|
||||||
|
<div class="card-change up">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 15l-6-6-6 6"/></svg>
|
||||||
|
-8.2% 개선
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">에러율</div>
|
||||||
|
<div class="card-subtitle">최근 24시간</div>
|
||||||
|
<div class="card-value" style="color:var(--color-warning);">0.34<span style="font-size:16px;color:var(--color-text-tertiary);font-weight:400;">%</span></div>
|
||||||
|
<div class="card-change up">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 15l-6-6-6 6"/></svg>
|
||||||
|
정상 범위
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts Row -->
|
||||||
|
<div class="chart-grid">
|
||||||
|
<div class="card">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
|
||||||
|
<div>
|
||||||
|
<div class="card-title">시간대별 API 요청 추이</div>
|
||||||
|
<div class="card-subtitle">최근 12시간</div>
|
||||||
|
</div>
|
||||||
|
<div class="component-row" style="margin-bottom:0;">
|
||||||
|
<button class="btn btn-ghost btn-sm" style="opacity:0.5;">일간</button>
|
||||||
|
<button class="btn btn-primary btn-sm">시간</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" style="opacity:0.5;">분</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container" style="padding-bottom:28px;">
|
||||||
|
<div class="chart-bar" style="height:45%;background:var(--color-chart-1);"><span class="chart-label">00</span></div>
|
||||||
|
<div class="chart-bar" style="height:35%;background:var(--color-chart-1);"><span class="chart-label">01</span></div>
|
||||||
|
<div class="chart-bar" style="height:25%;background:var(--color-chart-1);"><span class="chart-label">02</span></div>
|
||||||
|
<div class="chart-bar" style="height:20%;background:var(--color-chart-1);"><span class="chart-label">03</span></div>
|
||||||
|
<div class="chart-bar" style="height:18%;background:var(--color-chart-1);"><span class="chart-label">04</span></div>
|
||||||
|
<div class="chart-bar" style="height:30%;background:var(--color-chart-1);"><span class="chart-label">05</span></div>
|
||||||
|
<div class="chart-bar" style="height:55%;background:var(--color-chart-1);"><span class="chart-label">06</span></div>
|
||||||
|
<div class="chart-bar" style="height:75%;background:var(--color-chart-1);"><span class="chart-label">07</span></div>
|
||||||
|
<div class="chart-bar" style="height:90%;background:var(--color-chart-1);"><span class="chart-label">08</span></div>
|
||||||
|
<div class="chart-bar" style="height:95%;background:var(--color-chart-2);"><span class="chart-label">09</span></div>
|
||||||
|
<div class="chart-bar" style="height:85%;background:var(--color-chart-1);"><span class="chart-label">10</span></div>
|
||||||
|
<div class="chart-bar" style="height:80%;background:var(--color-chart-1);"><span class="chart-label">11</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">서비스별 요청 비율</div>
|
||||||
|
<div class="card-subtitle">전체 트래픽 기준</div>
|
||||||
|
<div class="donut-wrap" style="margin-top:12px;">
|
||||||
|
<div class="donut">
|
||||||
|
<svg width="160" height="160" viewBox="0 0 160 160">
|
||||||
|
<circle cx="80" cy="80" r="60" fill="none" stroke="var(--color-border)" stroke-width="24"/>
|
||||||
|
<circle cx="80" cy="80" r="60" fill="none" stroke="var(--color-chart-1)" stroke-width="24" stroke-dasharray="131 246" stroke-dashoffset="0"/>
|
||||||
|
<circle cx="80" cy="80" r="60" fill="none" stroke="var(--color-chart-2)" stroke-width="24" stroke-dasharray="83 294" stroke-dashoffset="-131"/>
|
||||||
|
<circle cx="80" cy="80" r="60" fill="none" stroke="var(--color-chart-3)" stroke-width="24" stroke-dasharray="60 317" stroke-dashoffset="-214"/>
|
||||||
|
<circle cx="80" cy="80" r="60" fill="none" stroke="var(--color-chart-4)" stroke-width="24" stroke-dasharray="38 339" stroke-dashoffset="-274"/>
|
||||||
|
<circle cx="80" cy="80" r="60" fill="none" stroke="var(--color-chart-6)" stroke-width="24" stroke-dasharray="65 312" stroke-dashoffset="-312"/>
|
||||||
|
</svg>
|
||||||
|
<div class="donut-center">
|
||||||
|
<div class="value">26</div>
|
||||||
|
<div class="label">서비스</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="donut-legend">
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:var(--color-chart-1);"></div>AIS 34.8%</div>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:var(--color-chart-2);"></div>기상 22.0%</div>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:var(--color-chart-3);"></div>항로 15.9%</div>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:var(--color-chart-4);"></div>조류 10.1%</div>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:var(--color-chart-6);"></div>기타 17.2%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="card section">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<div class="card-title">서비스 연결 현황</div>
|
||||||
|
<div class="card-subtitle">모니터링 대상 서비스 목록</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
서비스 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>서비스명</th><th>상태</th><th>API 키</th><th>요청수</th><th>응답시간</th><th>마지막 응답</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:500;">AIS 선박위치 API</td>
|
||||||
|
<td><span class="badge badge-success"><span class="status-dot online"></span> 정상</span></td>
|
||||||
|
<td><code style="font-size:12px;background:var(--color-primary-subtle);padding:2px 8px;border-radius:4px;color:var(--color-primary-text);">snp-ais-****-7f2a</code></td>
|
||||||
|
<td>4,521</td>
|
||||||
|
<td>89ms</td>
|
||||||
|
<td style="color:var(--color-text-tertiary);">방금 전</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:500;">해양기상 데이터</td>
|
||||||
|
<td><span class="badge badge-success"><span class="status-dot online"></span> 정상</span></td>
|
||||||
|
<td><code style="font-size:12px;background:var(--color-primary-subtle);padding:2px 8px;border-radius:4px;color:var(--color-primary-text);">snp-wth-****-3e8b</code></td>
|
||||||
|
<td>2,847</td>
|
||||||
|
<td>156ms</td>
|
||||||
|
<td style="color:var(--color-text-tertiary);">3초 전</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:500;">항로 정보 서비스</td>
|
||||||
|
<td><span class="badge badge-warning"><span class="status-dot warning"></span> 지연</span></td>
|
||||||
|
<td><code style="font-size:12px;background:var(--color-primary-subtle);padding:2px 8px;border-radius:4px;color:var(--color-primary-text);">snp-rte-****-9d1c</code></td>
|
||||||
|
<td>2,054</td>
|
||||||
|
<td>342ms</td>
|
||||||
|
<td style="color:var(--color-text-tertiary);">12초 전</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:500;">조류/해류 API</td>
|
||||||
|
<td><span class="badge badge-info"><span class="status-dot maintenance"></span> 점검</span></td>
|
||||||
|
<td><code style="font-size:12px;background:var(--color-primary-subtle);padding:2px 8px;border-radius:4px;color:var(--color-primary-text);">snp-crt-****-5a7e</code></td>
|
||||||
|
<td>1,302</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td style="color:var(--color-text-tertiary);">10분 전</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:500;">CCTV 영상 서비스</td>
|
||||||
|
<td><span class="badge badge-danger"><span class="status-dot offline"></span> 장애</span></td>
|
||||||
|
<td><code style="font-size:12px;background:var(--color-primary-subtle);padding:2px 8px;border-radius:4px;color:var(--color-primary-text);">snp-cam-****-2b4f</code></td>
|
||||||
|
<td>123</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td style="color:var(--color-text-tertiary);">45분 전</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Components Showcase -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">컴포넌트 시스템</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:24px;">
|
||||||
|
<div style="font-size:13px;font-weight:600;color:var(--color-text-secondary);margin-bottom:10px;">Buttons</div>
|
||||||
|
<div class="component-row">
|
||||||
|
<button class="btn btn-primary">Primary</button>
|
||||||
|
<button class="btn btn-secondary">Secondary</button>
|
||||||
|
<button class="btn btn-accent">Accent</button>
|
||||||
|
<button class="btn btn-outline">Outline</button>
|
||||||
|
<button class="btn btn-ghost">Ghost</button>
|
||||||
|
<button class="btn btn-danger">Danger</button>
|
||||||
|
</div>
|
||||||
|
<div class="component-row">
|
||||||
|
<button class="btn btn-primary btn-sm">Small</button>
|
||||||
|
<button class="btn btn-primary">Medium</button>
|
||||||
|
<button class="btn btn-primary btn-lg">Large</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:24px;">
|
||||||
|
<div style="font-size:13px;font-weight:600;color:var(--color-text-secondary);margin-bottom:10px;">Badges</div>
|
||||||
|
<div class="component-row">
|
||||||
|
<span class="badge badge-default">Default</span>
|
||||||
|
<span class="badge badge-primary">Primary</span>
|
||||||
|
<span class="badge badge-secondary">Secondary</span>
|
||||||
|
<span class="badge badge-accent">Accent</span>
|
||||||
|
<span class="badge badge-success">Success</span>
|
||||||
|
<span class="badge badge-warning">Warning</span>
|
||||||
|
<span class="badge badge-danger">Danger</span>
|
||||||
|
<span class="badge badge-info">Info</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:24px;">
|
||||||
|
<div style="font-size:13px;font-weight:600;color:var(--color-text-secondary);margin-bottom:10px;">Inputs</div>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:16px;max-width:720px;">
|
||||||
|
<div class="input-group">
|
||||||
|
<label class="input-label">서비스명</label>
|
||||||
|
<input type="text" class="input" placeholder="서비스명 입력">
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label class="input-label">API 엔드포인트</label>
|
||||||
|
<input type="text" class="input" value="https://api.example.com/v1">
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label class="input-label">타임아웃 (ms)</label>
|
||||||
|
<input type="number" class="input" placeholder="5000">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style="font-size:13px;font-weight:600;color:var(--color-text-secondary);margin-bottom:10px;">Toasts</div>
|
||||||
|
<div class="toast-grid" style="max-width:480px;">
|
||||||
|
<div class="toast toast-success">✓ 서비스가 정상적으로 등록되었습니다.</div>
|
||||||
|
<div class="toast toast-warning">⚠ 응답 시간이 임계값을 초과했습니다.</div>
|
||||||
|
<div class="toast toast-danger">✕ 서비스 연결에 실패했습니다.</div>
|
||||||
|
<div class="toast toast-info">ℹ 시스템 점검이 예정되어 있습니다.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Color Palette -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">컬러 팔레트</div>
|
||||||
|
|
||||||
|
<div class="swatch-row-label">Primary — Slate Blue</div>
|
||||||
|
<div class="swatch-grid">
|
||||||
|
<div class="swatch" style="background:#F5F7F9;color:#1B2C41;">50</div>
|
||||||
|
<div class="swatch" style="background:#E5EBF2;color:#1B2C41;">100</div>
|
||||||
|
<div class="swatch" style="background:#D2DAE5;color:#1B2C41;">200</div>
|
||||||
|
<div class="swatch" style="background:#B7C5D7;color:#1B2C41;">300</div>
|
||||||
|
<div class="swatch" style="background:#8AA5C7;color:#fff;">400</div>
|
||||||
|
<div class="swatch" style="background:#6D94C5;color:#fff;">500</div>
|
||||||
|
<div class="swatch" style="background:#507FB9;color:#fff;">600</div>
|
||||||
|
<div class="swatch" style="background:#3B669C;color:#fff;">700</div>
|
||||||
|
<div class="swatch" style="background:#2D507B;color:#fff;">800</div>
|
||||||
|
<div class="swatch" style="background:#1B2C41;color:#fff;">900</div>
|
||||||
|
<div class="swatch" style="background:#0E1A2B;color:#fff;">950</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="swatch-row-label">Secondary — Powder Blue</div>
|
||||||
|
<div class="swatch-grid">
|
||||||
|
<div class="swatch" style="background:#F5F7F9;color:#1B2F41;">50</div>
|
||||||
|
<div class="swatch" style="background:#E6EDF5;color:#1B2F41;">100</div>
|
||||||
|
<div class="swatch" style="background:#D1DAE5;color:#1B2F41;">200</div>
|
||||||
|
<div class="swatch" style="background:#B7C8D7;color:#1B2F41;">300</div>
|
||||||
|
<div class="swatch" style="background:#89AAC8;color:#fff;">400</div>
|
||||||
|
<div class="swatch" style="background:#CBDCEB;color:#396E9D;">500</div>
|
||||||
|
<div class="swatch" style="background:#6B9AC8;color:#fff;">600</div>
|
||||||
|
<div class="swatch" style="background:#4E88BB;color:#fff;">700</div>
|
||||||
|
<div class="swatch" style="background:#396E9D;color:#fff;">800</div>
|
||||||
|
<div class="swatch" style="background:#1B2F41;color:#fff;">900</div>
|
||||||
|
<div class="swatch" style="background:#0E1A2B;color:#fff;">950</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="swatch-row-label">Accent — Warm Sand</div>
|
||||||
|
<div class="swatch-grid">
|
||||||
|
<div class="swatch" style="background:#F9F8F6;color:#3F351D;">50</div>
|
||||||
|
<div class="swatch" style="background:#F2EDE4;color:#3F351D;">100</div>
|
||||||
|
<div class="swatch" style="background:#E4E0D7;color:#3F351D;">200</div>
|
||||||
|
<div class="swatch" style="background:#D5CDB9;color:#3F351D;">300</div>
|
||||||
|
<div class="swatch" style="background:#C4B48C;color:#fff;">400</div>
|
||||||
|
<div class="swatch" style="background:#E8DFCA;color:#786230;">500</div>
|
||||||
|
<div class="swatch" style="background:#B59854;color:#fff;">600</div>
|
||||||
|
<div class="swatch" style="background:#9A7E3E;color:#fff;">700</div>
|
||||||
|
<div class="swatch" style="background:#786230;color:#fff;">800</div>
|
||||||
|
<div class="swatch" style="background:#3F351D;color:#fff;">900</div>
|
||||||
|
<div class="swatch" style="background:#2B2312;color:#fff;">950</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="swatch-row-label">Chart Palette — Cool + Warm 교차</div>
|
||||||
|
<div style="display:flex;gap:6px;margin-top:4px;">
|
||||||
|
<div style="width:60px;height:60px;border-radius:10px;background:var(--color-chart-1);display:flex;align-items:center;justify-content:center;color:#fff;font-size:11px;font-weight:600;">1</div>
|
||||||
|
<div style="width:60px;height:60px;border-radius:10px;background:var(--color-chart-2);display:flex;align-items:center;justify-content:center;color:#fff;font-size:11px;font-weight:600;">2</div>
|
||||||
|
<div style="width:60px;height:60px;border-radius:10px;background:var(--color-chart-3);display:flex;align-items:center;justify-content:center;color:#fff;font-size:11px;font-weight:600;">3</div>
|
||||||
|
<div style="width:60px;height:60px;border-radius:10px;background:var(--color-chart-4);display:flex;align-items:center;justify-content:center;color:#fff;font-size:11px;font-weight:600;">4</div>
|
||||||
|
<div style="width:60px;height:60px;border-radius:10px;background:var(--color-chart-5);display:flex;align-items:center;justify-content:center;color:#fff;font-size:11px;font-weight:600;">5</div>
|
||||||
|
<div style="width:60px;height:60px;border-radius:10px;background:var(--color-chart-6);display:flex;align-items:center;justify-content:center;color:#786230;font-size:11px;font-weight:600;">6</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleTheme() {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const icon = document.getElementById('theme-icon');
|
||||||
|
const label = document.getElementById('theme-label');
|
||||||
|
root.classList.toggle('dark');
|
||||||
|
if (root.classList.contains('dark')) {
|
||||||
|
icon.textContent = '☀️';
|
||||||
|
label.textContent = '라이트 모드';
|
||||||
|
} else {
|
||||||
|
icon.textContent = '🌙';
|
||||||
|
label.textContent = '다크 모드';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
212
docs/design/code-conventions.md
Normal file
212
docs/design/code-conventions.md
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
# 코드 컨벤션
|
||||||
|
|
||||||
|
프론트엔드 디자인 시스템 적용을 위한 코드 작성 규칙.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 네이밍
|
||||||
|
|
||||||
|
| 유형 | 규칙 | 예시 |
|
||||||
|
|------|------|------|
|
||||||
|
| 컴포넌트 | PascalCase | `Button.tsx`, `UserCard.tsx` |
|
||||||
|
| 훅 | camelCase, `use` 접두사 | `useModal.ts`, `useTheme.ts` |
|
||||||
|
| 유틸리티 | camelCase | `cn.ts`, `formatDate.ts` |
|
||||||
|
| 상수 | camelCase | `chart.ts`, `routes.ts` |
|
||||||
|
| 타입 | camelCase | `user.ts`, `apihub.ts` |
|
||||||
|
| 스타일 | camelCase | `index.css` |
|
||||||
|
|
||||||
|
컴포넌트 파일 당 하나의 `export default` 컴포넌트.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tailwind 클래스 순서
|
||||||
|
|
||||||
|
가독성을 위해 다음 순서로 클래스를 작성한다. Prettier + prettier-plugin-tailwindcss로 자동 정렬 권장.
|
||||||
|
|
||||||
|
1. **레이아웃** — `flex`, `grid`, `block`, `hidden`, `relative`, `fixed`, `z-*`
|
||||||
|
2. **크기** — `w-*`, `h-*`, `min-w-*`, `max-w-*`, `size-*`
|
||||||
|
3. **여백** — `m-*`, `mx-*`, `my-*`, `p-*`, `px-*`, `py-*`, `gap-*`
|
||||||
|
4. **테두리** — `border*`, `rounded-*`, `ring-*`, `outline-*`
|
||||||
|
5. **배경** — `bg-*`, `shadow-*`, `backdrop-*`
|
||||||
|
6. **텍스트** — `text-*`, `font-*`, `leading-*`, `tracking-*`, `truncate`
|
||||||
|
7. **색상** — `text-[var(*)]`, `bg-[var(*)]`
|
||||||
|
8. **상호작용** — `cursor-*`, `select-*`, `pointer-events-*`
|
||||||
|
9. **트랜지션** — `transition-*`, `duration-*`, `ease-*`, `animate-*`
|
||||||
|
10. **반응형** — `sm:*`, `md:*`, `lg:*`, `xl:*`
|
||||||
|
11. **다크모드** — `dark:*`
|
||||||
|
12. **상태** — `hover:*`, `focus:*`, `active:*`, `disabled:*`, `aria-*:`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 올바른 예
|
||||||
|
<div className="flex items-center justify-between w-full p-4 border border-[var(--color-border)] rounded-lg bg-[var(--color-bg-surface)] text-sm text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-bg-elevated)]">
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## cn() 유틸리티
|
||||||
|
|
||||||
|
`clsx` + `tailwind-merge` 조합. 조건부 클래스와 클래스 충돌 해결에 사용한다.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/utils/cn.ts
|
||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 사용 패턴
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 기본 사용
|
||||||
|
<div className={cn('p-4 rounded-lg', className)} />
|
||||||
|
|
||||||
|
// 조건부 클래스
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-2 rounded-lg font-medium transition-colors',
|
||||||
|
variant === 'primary' && 'bg-[var(--color-primary)] text-white',
|
||||||
|
variant === 'ghost' && 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-surface)]',
|
||||||
|
disabled && 'opacity-50 cursor-not-allowed',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// 클래스 충돌 해결 (twMerge)
|
||||||
|
cn('p-4', 'p-6') // → 'p-6'
|
||||||
|
cn('text-sm', 'text-base') // → 'text-base'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 규칙
|
||||||
|
|
||||||
|
- 모든 컴포넌트의 루트 엘리먼트에 `className` prop을 전달할 수 있도록 `cn()`으로 병합.
|
||||||
|
- variant별 클래스는 객체 맵으로 분리.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const variants = {
|
||||||
|
primary: 'bg-[var(--color-primary)] text-white',
|
||||||
|
ghost: 'text-[var(--color-text-secondary)]',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type Variant = keyof typeof variants;
|
||||||
|
|
||||||
|
<div className={cn(baseClass, variants[variant], className)} />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Import 순서
|
||||||
|
|
||||||
|
ESLint `import/order` 규칙 기준.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 1. React
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
// 2. 외부 라이브러리
|
||||||
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
// 3. 내부 모듈 (절대 경로)
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { CHART_COLORS } from '@/constants/chart';
|
||||||
|
import type { ButtonProps } from '@/types/ui';
|
||||||
|
|
||||||
|
// 4. 상대 경로 컴포넌트
|
||||||
|
import Badge from './Badge';
|
||||||
|
|
||||||
|
// 5. 스타일 (필요 시)
|
||||||
|
import styles from './Button.module.css';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## forwardRef
|
||||||
|
|
||||||
|
외부에서 DOM 요소에 직접 접근이 필요한 컴포넌트(Input, Button 등)에 적용.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { forwardRef } from 'react';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
|
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ label, error, className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{label && (
|
||||||
|
<label className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-surface)]',
|
||||||
|
'px-3 py-2 text-base text-[var(--color-text-primary)]',
|
||||||
|
'placeholder:text-[var(--color-text-tertiary)]',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
error && 'border-[var(--color-danger)] focus:ring-[var(--color-danger)]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-[var(--color-danger)]">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export default Input;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Props 타입 정의
|
||||||
|
|
||||||
|
- Props 타입은 `interface`로 정의. 이름은 `{ComponentName}Props`.
|
||||||
|
- HTML 속성 확장 시 `extends React.HTMLAttributes<HTMLElement>` 사용.
|
||||||
|
- 선택적 `className` prop 항상 포함.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
variant?: 'default' | 'elevated' | 'flat';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSS 변수 사용
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// DO: CSS 변수 토큰 사용
|
||||||
|
<div className="bg-[var(--color-bg-surface)] text-[var(--color-text-primary)]" />
|
||||||
|
|
||||||
|
// DO NOT: HEX 직접 사용
|
||||||
|
<div className="bg-white text-gray-900" />
|
||||||
|
|
||||||
|
// DO NOT: Tailwind 기본 색상으로 브랜드 컬러 표현
|
||||||
|
<div className="bg-blue-500 text-blue-900" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 컴포넌트 파일 구조
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 1. imports
|
||||||
|
// 2. 타입 정의
|
||||||
|
// 3. 상수 (variants 맵 등)
|
||||||
|
// 4. 컴포넌트 함수
|
||||||
|
// 5. export default
|
||||||
|
```
|
||||||
157
docs/design/colors.md
Normal file
157
docs/design/colors.md
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
# 컬러 시스템
|
||||||
|
|
||||||
|
해양 데이터 시스템을 위한 컬러 시스템. 신뢰감과 전문성을 기반으로 하며, 해양·항법 데이터의 정확성을 시각적으로 표현한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 브랜드 컬러 원본
|
||||||
|
|
||||||
|
| 역할 | HEX | 설명 |
|
||||||
|
|------|-----|------|
|
||||||
|
| Primary | #6D94C5 | Slate Blue — 신뢰, 전문성 |
|
||||||
|
| Secondary | #CBDCEB | Powder Blue — 부드러운 보조 |
|
||||||
|
| Accent | #E8DFCA | Warm Sand — 따뜻한 포인트 |
|
||||||
|
| Background | #F5EFE6 | Warm Cream — 배경색 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 브랜드 컬러 스케일
|
||||||
|
|
||||||
|
각 브랜드 컬러는 50(가장 밝음)에서 950(가장 어두움)까지 11단계 스케일로 확장된다.
|
||||||
|
|
||||||
|
### Primary (Slate Blue, hue ≈ 213°)
|
||||||
|
|
||||||
|
| Token | HEX | 용도 예시 |
|
||||||
|
|-------|-----|-----------|
|
||||||
|
| primary-50 | #F5F7F9 | subtle 배경 |
|
||||||
|
| primary-100 | #E5EBF2 | hover 배경 |
|
||||||
|
| primary-200 | #D2DAE5 | 비활성 테두리 |
|
||||||
|
| primary-300 | #B7C5D7 | 비활성 텍스트 |
|
||||||
|
| primary-400 | #8AA5C7 | 보조 아이콘 |
|
||||||
|
| primary-500 | #6D94C5 | 브랜드 원본 |
|
||||||
|
| primary-600 | #507FB9 | 기본 버튼 배경 (Light) |
|
||||||
|
| primary-700 | #3B669C | hover 상태 |
|
||||||
|
| primary-800 | #2D507B | active 상태 |
|
||||||
|
| primary-900 | #1B2C41 | 다크 배경 subtle |
|
||||||
|
| primary-950 | #0E1A2B | 다크 배경 강조 |
|
||||||
|
|
||||||
|
### Secondary (Powder Blue, hue ≈ 210°)
|
||||||
|
|
||||||
|
| Token | HEX | 용도 예시 |
|
||||||
|
|-------|-----|-----------|
|
||||||
|
| secondary-50 | #F5F7F9 | subtle 배경 |
|
||||||
|
| secondary-100 | #E6EDF5 | hover 배경 |
|
||||||
|
| secondary-200 | #D1DAE5 | 비활성 테두리 |
|
||||||
|
| secondary-300 | #B7C8D7 | 비활성 텍스트 |
|
||||||
|
| secondary-400 | #89AAC8 | 보조 아이콘 |
|
||||||
|
| secondary-500 | #CBDCEB | 브랜드 원본 |
|
||||||
|
| secondary-600 | #6B9AC8 | 보조 버튼 (Light) |
|
||||||
|
| secondary-700 | #4E88BB | hover 상태 |
|
||||||
|
| secondary-800 | #396E9D | active 상태 |
|
||||||
|
| secondary-900 | #1B2F41 | 다크 배경 subtle |
|
||||||
|
| secondary-950 | #0E1A2B | 다크 배경 강조 |
|
||||||
|
|
||||||
|
### Accent (Warm Sand, hue ≈ 38°)
|
||||||
|
|
||||||
|
| Token | HEX | 용도 예시 |
|
||||||
|
|-------|-----|-----------|
|
||||||
|
| accent-50 | #F9F8F6 | subtle 배경 |
|
||||||
|
| accent-100 | #F2EDE4 | hover 배경 |
|
||||||
|
| accent-200 | #E4E0D7 | 비활성 테두리 |
|
||||||
|
| accent-300 | #D5CDB9 | 비활성 텍스트 |
|
||||||
|
| accent-400 | #C4B48C | 보조 아이콘 |
|
||||||
|
| accent-500 | #E8DFCA | 브랜드 원본 |
|
||||||
|
| accent-600 | #B59854 | 포인트 버튼 (Light) |
|
||||||
|
| accent-700 | #9A7E3E | hover 상태 |
|
||||||
|
| accent-800 | #786230 | active 상태 |
|
||||||
|
| accent-900 | #3F351D | 다크 배경 subtle |
|
||||||
|
| accent-950 | #2B2312 | 다크 배경 강조 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 디자인 토큰 (CSS 변수)
|
||||||
|
|
||||||
|
컴포넌트에서 직접 HEX를 사용하지 않고 반드시 토큰을 사용한다.
|
||||||
|
|
||||||
|
### Primary 토큰
|
||||||
|
|
||||||
|
| CSS 변수 | Light | Dark |
|
||||||
|
|----------|-------|------|
|
||||||
|
| `--color-primary` | #507FB9 | #B7C5D7 |
|
||||||
|
| `--color-primary-hover` | #3B669C | #D2DAE5 |
|
||||||
|
| `--color-primary-active` | #2D507B | #8AA5C7 |
|
||||||
|
| `--color-primary-subtle` | #F5F7F9 | #1B2C41 |
|
||||||
|
| `--color-primary-text` | #2D507B | #D2DAE5 |
|
||||||
|
|
||||||
|
### Secondary 토큰
|
||||||
|
|
||||||
|
| CSS 변수 | Light | Dark |
|
||||||
|
|----------|-------|------|
|
||||||
|
| `--color-secondary` | #CBDCEB | #B7C8D7 |
|
||||||
|
| `--color-secondary-hover` | #89AAC8 | #D1DAE5 |
|
||||||
|
| `--color-secondary-active` | #4E88BB | #89AAC8 |
|
||||||
|
| `--color-secondary-subtle` | #F5F7F9 | #1B2F41 |
|
||||||
|
| `--color-secondary-text` | #396E9D | #D1DAE5 |
|
||||||
|
|
||||||
|
### Accent 토큰
|
||||||
|
|
||||||
|
| CSS 변수 | Light | Dark |
|
||||||
|
|----------|-------|------|
|
||||||
|
| `--color-accent` | #E8DFCA | #D5CDB9 |
|
||||||
|
| `--color-accent-hover` | #C4B48C | #E4E0D7 |
|
||||||
|
| `--color-accent-active` | #B59854 | #C4B48C |
|
||||||
|
| `--color-accent-subtle` | #F9F8F6 | #3F351D |
|
||||||
|
| `--color-accent-text` | #786230 | #E4E0D7 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 시멘틱 컬러
|
||||||
|
|
||||||
|
상태를 나타내는 컬러. 의미가 명확하므로 브랜드 컬러와 혼용하지 않는다.
|
||||||
|
|
||||||
|
| 역할 | CSS 변수 | Light | Dark |
|
||||||
|
|------|----------|-------|------|
|
||||||
|
| Success | `--color-success` | #059669 | #34D399 |
|
||||||
|
| Warning | `--color-warning` | #D97706 | #FBBF24 |
|
||||||
|
| Danger | `--color-danger` | #DC2626 | #FCA5A5 |
|
||||||
|
| Info | `--color-info` | #0284C7 | #38BDF8 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 뉴트럴 (Surface & Text)
|
||||||
|
|
||||||
|
| 역할 | CSS 변수 | Light | Dark | 설명 |
|
||||||
|
|------|----------|-------|------|------|
|
||||||
|
| 기본 배경 | `--color-bg-base` | #F5EFE6 | #131416 | 페이지 최외곽 |
|
||||||
|
| 카드 배경 | `--color-bg-surface` | #FFFFFF | #1E2023 | 카드, 패널 |
|
||||||
|
| 모달 배경 | `--color-bg-elevated` | #FFFFFF | #282A2E | 모달, 드롭다운 |
|
||||||
|
| 기본 테두리 | `--color-border` | #D5D2CC | #3A3C41 | 카드, 인풋 |
|
||||||
|
| 강조 테두리 | `--color-border-strong` | #9198A1 | #5C6570 | 포커스, 구분선 |
|
||||||
|
| 본문 텍스트 | `--color-text-primary` | #1E2329 | #F4F5F5 | 제목, 본문 |
|
||||||
|
| 보조 텍스트 | `--color-text-secondary` | #5C6570 | #9198A1 | 설명, 레이블 |
|
||||||
|
| 약한 텍스트 | `--color-text-tertiary` | #9198A1 | #5C6570 | placeholder |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 차트 팔레트
|
||||||
|
|
||||||
|
데이터 시각화 전용. Cool(Blue 계열)과 Warm(Sand 계열)을 교차하여 구분성을 높인다.
|
||||||
|
|
||||||
|
| 순서 | CSS 변수 | Light HEX | Dark HEX |
|
||||||
|
|------|----------|-----------|----------|
|
||||||
|
| 1번 | `--color-chart-1` | #507FB9 | #6D94C5 |
|
||||||
|
| 2번 | `--color-chart-2` | #B59854 | #C4B48C |
|
||||||
|
| 3번 | `--color-chart-3` | #4E88BB | #6B9AC8 |
|
||||||
|
| 4번 | `--color-chart-4` | #9A7E3E | #B59854 |
|
||||||
|
| 5번 | `--color-chart-5` | #3B669C | #8AA5C7 |
|
||||||
|
| 6번 | `--color-chart-6` | #C4B48C | #D5CDB9 |
|
||||||
|
|
||||||
|
차트에서는 `CHART_COLORS` 상수(`src/constants/chart.ts`)를 사용한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 사용 규칙
|
||||||
|
|
||||||
|
- 컴포넌트에서 HEX 직접 사용 금지. 반드시 CSS 변수 토큰 사용.
|
||||||
|
- 시멘틱 컬러(success, warning, danger, info)를 브랜드 컬러 대용으로 사용 금지.
|
||||||
|
- 다크 모드는 `.dark` 클래스 또는 `prefers-color-scheme: dark` 미디어 쿼리로 자동 전환.
|
||||||
236
docs/design/components.md
Normal file
236
docs/design/components.md
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
# 컴포넌트 스펙
|
||||||
|
|
||||||
|
모든 컴포넌트는 `cn()` 유틸리티(`src/utils/cn.ts`)를 사용하고, CSS 변수 토큰을 통해 색상을 지정한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Button
|
||||||
|
|
||||||
|
### 변형 (Variant)
|
||||||
|
|
||||||
|
| Variant | 배경 | 텍스트 | 테두리 | 용도 |
|
||||||
|
|---------|------|--------|--------|------|
|
||||||
|
| `primary` | `--color-primary` | white | none | 주요 CTA |
|
||||||
|
| `secondary` | `--color-secondary` | `--color-secondary-text` | none | 보조 액션 |
|
||||||
|
| `accent` | `--color-accent` | `--color-accent-text` | none | 포인트 액션 |
|
||||||
|
| `outline` | transparent | `--color-primary` | `--color-primary` | 조용한 강조 |
|
||||||
|
| `ghost` | transparent | `--color-text-secondary` | none | 최소 강조 |
|
||||||
|
| `danger` | `--color-danger` | white | none | 삭제, 경고 |
|
||||||
|
|
||||||
|
### 사이즈
|
||||||
|
|
||||||
|
| Size | padding | font-size | height | border-radius |
|
||||||
|
|------|---------|-----------|--------|---------------|
|
||||||
|
| `sm` | `px-3 py-1.5` | `text-sm` | 32px | `rounded-md` |
|
||||||
|
| `md` | `px-4 py-2` | `text-base` | 40px | `rounded-lg` |
|
||||||
|
| `lg` | `px-6 py-3` | `text-lg` | 48px | `rounded-lg` |
|
||||||
|
|
||||||
|
### 상태
|
||||||
|
|
||||||
|
- **disabled**: `opacity-50 cursor-not-allowed pointer-events-none`
|
||||||
|
- **loading**: 스피너 아이콘 표시, 텍스트 유지, disabled 처리
|
||||||
|
- **hover**: 각 variant에 맞는 `-hover` 토큰 적용
|
||||||
|
- **active**: 각 variant에 맞는 `-active` 토큰 적용
|
||||||
|
- **focus-visible**: `ring-2 ring-[var(--color-primary)] ring-offset-2`
|
||||||
|
|
||||||
|
### 구현 예시
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const buttonVariants = {
|
||||||
|
primary: 'bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary-hover)] active:bg-[var(--color-primary-active)]',
|
||||||
|
secondary: 'bg-[var(--color-secondary)] text-[var(--color-secondary-text)] hover:bg-[var(--color-secondary-hover)]',
|
||||||
|
accent: 'bg-[var(--color-accent)] text-[var(--color-accent-text)] hover:bg-[var(--color-accent-hover)]',
|
||||||
|
outline: 'border border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary-subtle)]',
|
||||||
|
ghost: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-surface)] hover:text-[var(--color-text-primary)]',
|
||||||
|
danger: 'bg-[var(--color-danger)] text-white hover:opacity-90',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Badge
|
||||||
|
|
||||||
|
상태, 카테고리, 태그 표시에 사용한다.
|
||||||
|
|
||||||
|
### 변형
|
||||||
|
|
||||||
|
| Variant | 배경 | 텍스트 |
|
||||||
|
|---------|------|--------|
|
||||||
|
| `default` | `--color-bg-elevated` | `--color-text-primary` |
|
||||||
|
| `primary` | `--color-primary-subtle` | `--color-primary-text` |
|
||||||
|
| `success` | #DCFCE7 | #166534 |
|
||||||
|
| `warning` | #FEF9C3 | #854D0E |
|
||||||
|
| `danger` | #FEE2E2 | #991B1B |
|
||||||
|
| `info` | #E0F2FE | #075985 |
|
||||||
|
|
||||||
|
### 스펙
|
||||||
|
|
||||||
|
- 패딩: `px-2 py-0.5`
|
||||||
|
- 폰트: `text-xs font-medium`
|
||||||
|
- 모양: `rounded-full` (pill) 또는 `rounded-md` (square)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Card
|
||||||
|
|
||||||
|
콘텐츠를 담는 기본 컨테이너.
|
||||||
|
|
||||||
|
### 구조
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-surface)] shadow-sm">
|
||||||
|
<!-- Card Header -->
|
||||||
|
<div class="border-b border-[var(--color-border)] px-6 py-4">
|
||||||
|
<h3 class="text-base font-semibold text-[var(--color-text-primary)]">제목</h3>
|
||||||
|
<p class="text-sm text-[var(--color-text-secondary)]">부제목</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card Body -->
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- 콘텐츠 -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card Footer (선택) -->
|
||||||
|
<div class="border-t border-[var(--color-border)] px-6 py-4">
|
||||||
|
<!-- 액션 버튼 등 -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 변형
|
||||||
|
|
||||||
|
| Variant | 설명 |
|
||||||
|
|---------|------|
|
||||||
|
| `default` | border + shadow-sm |
|
||||||
|
| `elevated` | shadow-md, 모달 내부 카드 |
|
||||||
|
| `flat` | border만, shadow 없음 |
|
||||||
|
| `interactive` | hover 시 shadow-md + scale-[1.01] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Input
|
||||||
|
|
||||||
|
### 기본 스펙
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label class="text-sm font-medium text-[var(--color-text-primary)]">레이블</label>
|
||||||
|
<input
|
||||||
|
class="
|
||||||
|
w-full rounded-lg border border-[var(--color-border)]
|
||||||
|
bg-[var(--color-bg-surface)] px-3 py-2
|
||||||
|
text-base text-[var(--color-text-primary)]
|
||||||
|
placeholder:text-[var(--color-text-tertiary)]
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
"
|
||||||
|
placeholder="입력하세요"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-[var(--color-text-tertiary)]">도움말 텍스트</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 상태
|
||||||
|
|
||||||
|
| 상태 | 테두리 색상 |
|
||||||
|
|------|-------------|
|
||||||
|
| 기본 | `--color-border` |
|
||||||
|
| 포커스 | `--color-primary` (ring) |
|
||||||
|
| 오류 | `--color-danger` |
|
||||||
|
| 성공 | `--color-success` |
|
||||||
|
| 비활성 | `--color-border` + opacity-50 |
|
||||||
|
|
||||||
|
### 사이즈
|
||||||
|
|
||||||
|
| Size | padding | font-size | height |
|
||||||
|
|------|---------|-----------|--------|
|
||||||
|
| `sm` | `px-3 py-1.5` | `text-sm` | 32px |
|
||||||
|
| `md` | `px-3 py-2` | `text-base` | 40px |
|
||||||
|
| `lg` | `px-4 py-3` | `text-lg` | 48px |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modal
|
||||||
|
|
||||||
|
### 구조
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<!-- Dialog -->
|
||||||
|
<div class="
|
||||||
|
relative w-full max-w-md mx-4 rounded-2xl
|
||||||
|
bg-[var(--color-bg-elevated)] shadow-xl
|
||||||
|
">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-[var(--color-border)] px-6 py-4">
|
||||||
|
<h2 class="text-lg font-semibold text-[var(--color-text-primary)]">제목</h2>
|
||||||
|
<button class="...">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="px-6 py-6">...</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex justify-end gap-3 border-t border-[var(--color-border)] px-6 py-4">
|
||||||
|
<Button variant="ghost">취소</Button>
|
||||||
|
<Button variant="primary">확인</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 사이즈
|
||||||
|
|
||||||
|
| Size | max-width |
|
||||||
|
|------|-----------|
|
||||||
|
| `sm` | `max-w-sm` (384px) |
|
||||||
|
| `md` | `max-w-md` (448px) |
|
||||||
|
| `lg` | `max-w-lg` (512px) |
|
||||||
|
| `xl` | `max-w-xl` (576px) |
|
||||||
|
|
||||||
|
### 접근성
|
||||||
|
|
||||||
|
- `role="dialog"`, `aria-modal="true"`, `aria-labelledby` 필수
|
||||||
|
- 열릴 때 첫 번째 포커스 가능 요소로 포커스 이동
|
||||||
|
- Escape 키로 닫기
|
||||||
|
- Backdrop 클릭 시 닫기 (선택적)
|
||||||
|
- 열린 동안 body 스크롤 잠금
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Toast
|
||||||
|
|
||||||
|
### 위치
|
||||||
|
|
||||||
|
`fixed bottom-4 right-4 z-[60]` (Modal보다 위)
|
||||||
|
|
||||||
|
### 변형
|
||||||
|
|
||||||
|
| Variant | 아이콘 | 색상 |
|
||||||
|
|---------|--------|------|
|
||||||
|
| `success` | CheckCircle | `--color-success` |
|
||||||
|
| `warning` | AlertTriangle | `--color-warning` |
|
||||||
|
| `error` | XCircle | `--color-danger` |
|
||||||
|
| `info` | Info | `--color-info` |
|
||||||
|
|
||||||
|
### 구조
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="
|
||||||
|
flex items-start gap-3 w-80 rounded-xl p-4
|
||||||
|
bg-[var(--color-bg-elevated)] shadow-lg
|
||||||
|
border border-[var(--color-border)]
|
||||||
|
">
|
||||||
|
<!-- 아이콘 -->
|
||||||
|
<!-- 텍스트 -->
|
||||||
|
<!-- 닫기 버튼 -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 동작
|
||||||
|
|
||||||
|
- 기본 자동 닫힘: 4000ms
|
||||||
|
- 진입: `translate-y-2 opacity-0` → `translate-y-0 opacity-100`
|
||||||
|
- 퇴장: `translate-y-0 opacity-100` → `translate-y-2 opacity-0`
|
||||||
|
- 트랜지션: `duration-200 ease-out`
|
||||||
212
docs/design/do-dont.md
Normal file
212
docs/design/do-dont.md
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
# Do & Don't
|
||||||
|
|
||||||
|
디자인 시스템 적용 시 자주 발생하는 실수와 올바른 방법.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSS 변수 토큰
|
||||||
|
|
||||||
|
### 색상
|
||||||
|
|
||||||
|
**DO** — CSS 변수 토큰 사용
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] border border-[var(--color-border)]" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**DON'T** — HEX 하드코딩
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 다크 모드 전환 불가, 토큰 변경 시 전부 수정 필요
|
||||||
|
<div className="bg-white text-gray-900 border border-gray-200" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**DON'T** — Tailwind 기본 팔레트로 브랜드 컬러 표현
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 브랜드 컬러(#6D94C5)와 blue-500(#3B82F6)은 다른 색상
|
||||||
|
<div className="bg-blue-500 text-blue-900" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 시멘틱 컬러
|
||||||
|
|
||||||
|
**DO** — 의미에 맞는 시멘틱 컬러 사용
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 성공 상태
|
||||||
|
<Badge className="bg-[var(--color-success)]/10 text-[var(--color-success)]">완료</Badge>
|
||||||
|
|
||||||
|
// 오류 메시지
|
||||||
|
<p className="text-[var(--color-danger)] text-sm">{errorMessage}</p>
|
||||||
|
|
||||||
|
// 경고 알림
|
||||||
|
<div className="border-l-4 border-[var(--color-warning)] bg-[var(--color-warning)]/10 p-4" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**DON'T** — 브랜드 컬러를 시멘틱 용도로 사용
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Primary 컬러는 성공/오류를 표현하지 않음
|
||||||
|
<p className="text-[var(--color-primary)]">저장에 실패했습니다</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
**DON'T** — 시멘틱 컬러를 장식 목적으로 사용
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// danger 컬러는 위험/오류 상황 전용
|
||||||
|
<Badge className="bg-[var(--color-danger)]">인기</Badge>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spacing 스케일
|
||||||
|
|
||||||
|
**DO** — 4px 단위 스케일 사용
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="p-4 gap-2 mt-6" /> // 16px / 8px / 24px
|
||||||
|
<div className="px-3 py-1.5" /> // 12px / 6px (sm 버튼)
|
||||||
|
```
|
||||||
|
|
||||||
|
**DON'T** — 임의의 px 값
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="p-[13px] gap-[7px] mt-[22px]" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**DON'T** — 인라인 스타일로 여백 지정
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div style={{ padding: '13px', marginTop: '22px' }} />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## cn() 유틸리티
|
||||||
|
|
||||||
|
**DO** — 조건부 클래스에 `cn()` 사용
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-2 rounded-lg font-medium transition-colors',
|
||||||
|
isActive ? 'bg-[var(--color-primary)] text-white' : 'text-[var(--color-text-secondary)]',
|
||||||
|
disabled && 'opacity-50 cursor-not-allowed',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**DON'T** — 템플릿 리터럴로 조건부 클래스 조합
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Tailwind 클래스 충돌 가능, purge 미적용 위험
|
||||||
|
<button className={`px-4 py-2 ${isActive ? 'bg-blue-500' : ''} ${disabled ? 'opacity-50' : ''}`} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**DON'T** — 동적 클래스 문자열 조합
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Tailwind는 전체 클래스 문자열을 스캔 — 동적 조합 시 purge에서 제거될 수 있음
|
||||||
|
const color = 'primary';
|
||||||
|
<div className={`text-[var(--color-${color})]`} /> // 위험
|
||||||
|
```
|
||||||
|
|
||||||
|
**DO** — 객체 맵 사용
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const colorMap = {
|
||||||
|
primary: 'text-[var(--color-primary)]',
|
||||||
|
danger: 'text-[var(--color-danger)]',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
<div className={cn(colorMap[color])} />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 컴포넌트 Props
|
||||||
|
|
||||||
|
**DO** — className prop 허용 + cn() 병합
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const Card = ({ className, children }: CardProps) => (
|
||||||
|
<div className={cn('rounded-xl border border-[var(--color-border)] p-6', className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**DON'T** — className prop 미제공 (재사용성 저하)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const Card = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div className="rounded-xl border border-[var(--color-border)] p-6">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 다크 모드
|
||||||
|
|
||||||
|
**DO** — CSS 변수 토큰 사용으로 자동 전환
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// --color-bg-surface는 Light: #FFF, Dark: #1E2023으로 자동 전환
|
||||||
|
<div className="bg-[var(--color-bg-surface)]" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**DON'T** — `dark:` 클래스로 각각 지정 (토큰이 있는 색상에)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 중복 관리 부담, 토큰과 불일치 가능
|
||||||
|
<div className="bg-white dark:bg-gray-900" />
|
||||||
|
```
|
||||||
|
|
||||||
|
단, CSS 변수 토큰이 없는 케이스(투명도, 그라디언트 등)에는 `dark:` 사용 허용.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 타이포그래피
|
||||||
|
|
||||||
|
**DO** — 타입 스케일 클래스 사용
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<h1 className="text-4xl font-bold leading-tight text-[var(--color-text-primary)]">페이지 제목</h1>
|
||||||
|
<p className="text-base leading-relaxed text-[var(--color-text-secondary)]">설명 텍스트</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
**DON'T** — 임의 폰트 크기
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<h1 className="text-[22px] font-bold">페이지 제목</h1>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 아이콘
|
||||||
|
|
||||||
|
**DO** — Lucide React + 색상 토큰
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
<ChevronRight className="size-5 text-[var(--color-text-secondary)]" aria-hidden="true" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**DON'T** — 다른 라이브러리 혼용
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Font Awesome, Material Icons 등 혼용 금지
|
||||||
|
import { FaChevronRight } from 'react-icons/fa';
|
||||||
|
```
|
||||||
|
|
||||||
|
**DON'T** — 아이콘에 color prop으로 HEX 지정
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ChevronRight color="#6D94C5" /> // CSS 변수 토큰을 사용할 수 없음
|
||||||
|
```
|
||||||
150
docs/design/icons.md
Normal file
150
docs/design/icons.md
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
# 아이콘
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 라이브러리
|
||||||
|
|
||||||
|
**Lucide React** 사용. 일관된 선 굵기(strokeWidth=1.5)와 스타일로 전체 UI의 시각적 통일성을 유지한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install lucide-react
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ChevronRight, Search, Bell } from 'lucide-react';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 사이즈
|
||||||
|
|
||||||
|
| 이름 | px | Tailwind 클래스 | 용도 |
|
||||||
|
|------|----|----------------|------|
|
||||||
|
| xs | 12px | `size-3` | 배지 내부, 인라인 보조 |
|
||||||
|
| sm | 16px | `size-4` | 버튼 내부, 레이블 |
|
||||||
|
| md | 20px | `size-5` | 기본 UI 아이콘 |
|
||||||
|
| lg | 24px | `size-6` | 네비게이션, 강조 |
|
||||||
|
| xl | 32px | `size-8` | 상태 표시, 빈 상태 일러스트 |
|
||||||
|
|
||||||
|
기본값은 `md` (20px). 별도 지정이 없으면 `size-5` 사용.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## strokeWidth
|
||||||
|
|
||||||
|
| 컨텍스트 | strokeWidth |
|
||||||
|
|----------|-------------|
|
||||||
|
| 기본 UI | 1.5 (Lucide 기본값) |
|
||||||
|
| 굵은 강조 | 2 |
|
||||||
|
| 가는 장식 | 1 |
|
||||||
|
|
||||||
|
기본값 그대로 사용. 의도적인 경우에만 override.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 색상 규칙
|
||||||
|
|
||||||
|
아이콘에 직접 color HEX를 지정하지 않는다. 부모 텍스트 색상을 상속하거나 CSS 변수를 사용한다.
|
||||||
|
|
||||||
|
| 상황 | 클래스 |
|
||||||
|
|------|--------|
|
||||||
|
| 본문과 함께 | `text-[var(--color-text-primary)]` 상속 |
|
||||||
|
| 보조 아이콘 | `text-[var(--color-text-secondary)]` |
|
||||||
|
| 비활성 | `text-[var(--color-text-tertiary)]` |
|
||||||
|
| 강조 (Primary) | `text-[var(--color-primary)]` |
|
||||||
|
| 성공 | `text-[var(--color-success)]` |
|
||||||
|
| 경고 | `text-[var(--color-warning)]` |
|
||||||
|
| 오류 | `text-[var(--color-danger)]` |
|
||||||
|
| 정보 | `text-[var(--color-info)]` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 사용 패턴
|
||||||
|
|
||||||
|
### 버튼 내 아이콘
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<button class="flex items-center gap-2 ...">
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
다음
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 텍스트와 인라인 정렬
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<span class="flex items-center gap-1.5 text-sm text-[var(--color-text-secondary)]">
|
||||||
|
<Clock className="size-4 shrink-0" />
|
||||||
|
2시간 전
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 아이콘 전용 버튼
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<button
|
||||||
|
aria-label="알림"
|
||||||
|
class="rounded-lg p-2 hover:bg-[var(--color-bg-surface)] text-[var(--color-text-secondary)]"
|
||||||
|
>
|
||||||
|
<Bell className="size-5" />
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 상태 아이콘
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<CheckCircle className="size-5 text-[var(--color-success)]" />
|
||||||
|
<AlertTriangle className="size-5 text-[var(--color-warning)]" />
|
||||||
|
<XCircle className="size-5 text-[var(--color-danger)]" />
|
||||||
|
<Info className="size-5 text-[var(--color-info)]" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 접근성
|
||||||
|
|
||||||
|
- 장식용 아이콘: `aria-hidden="true"` 필수
|
||||||
|
- 의미 있는 아이콘: `aria-label` 또는 시각적으로 숨긴 텍스트 제공
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 장식용
|
||||||
|
<ChevronRight aria-hidden="true" className="size-4" />
|
||||||
|
|
||||||
|
// 의미 있는 아이콘 버튼
|
||||||
|
<button aria-label="삭제">
|
||||||
|
<Trash2 className="size-5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 자주 쓰는 아이콘 목록
|
||||||
|
|
||||||
|
| 용도 | 아이콘 |
|
||||||
|
|------|--------|
|
||||||
|
| 검색 | `Search` |
|
||||||
|
| 알림 | `Bell` |
|
||||||
|
| 설정 | `Settings` |
|
||||||
|
| 사용자 | `User`, `Users` |
|
||||||
|
| 메뉴 | `Menu`, `MoreVertical`, `MoreHorizontal` |
|
||||||
|
| 닫기 | `X` |
|
||||||
|
| 확인 | `Check`, `CheckCircle` |
|
||||||
|
| 오류 | `XCircle`, `AlertCircle` |
|
||||||
|
| 경고 | `AlertTriangle` |
|
||||||
|
| 정보 | `Info` |
|
||||||
|
| 편집 | `Pencil`, `Edit2` |
|
||||||
|
| 삭제 | `Trash2` |
|
||||||
|
| 추가 | `Plus`, `PlusCircle` |
|
||||||
|
| 이동 | `ChevronRight`, `ChevronDown`, `ArrowRight` |
|
||||||
|
| 새로고침 | `RefreshCw` |
|
||||||
|
| 다운로드 | `Download` |
|
||||||
|
| 업로드 | `Upload` |
|
||||||
|
| 링크 | `ExternalLink` |
|
||||||
|
| 복사 | `Copy` |
|
||||||
|
| 필터 | `Filter`, `SlidersHorizontal` |
|
||||||
|
| 정렬 | `ArrowUpDown` |
|
||||||
|
| 로그 | `FileText`, `ClipboardList` |
|
||||||
|
| 차트 | `BarChart2`, `LineChart`, `PieChart` |
|
||||||
|
| 서버 | `Server`, `Database` |
|
||||||
|
| API | `Zap`, `Code2` |
|
||||||
|
| 시간 | `Clock`, `Calendar` |
|
||||||
152
docs/design/motion.md
Normal file
152
docs/design/motion.md
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
# 모션 & 애니메이션
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 원칙
|
||||||
|
|
||||||
|
- 모션은 UI의 상태 변화를 명확히 전달하기 위한 수단이다.
|
||||||
|
- 불필요한 애니메이션은 사용하지 않는다.
|
||||||
|
- 모든 모션은 `prefers-reduced-motion` 미디어 쿼리를 존중한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Duration (지속 시간)
|
||||||
|
|
||||||
|
| 이름 | 값 | Tailwind | 용도 |
|
||||||
|
|------|----|----------|------|
|
||||||
|
| instant | 0ms | — | 즉각 반응 (포커스, 선택) |
|
||||||
|
| fast | 100ms | `duration-100` | 버튼 hover, 색상 전환 |
|
||||||
|
| normal | 200ms | `duration-200` | 패널 열기, 드롭다운 |
|
||||||
|
| slow | 300ms | `duration-300` | 모달, 사이드바 슬라이드 |
|
||||||
|
| slower | 500ms | `duration-500` | 페이지 전환, 스켈레톤 |
|
||||||
|
|
||||||
|
기본값: `duration-200`. 특별한 이유 없이 300ms를 초과하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Easing (가속도 곡선)
|
||||||
|
|
||||||
|
| 이름 | 값 | Tailwind | 용도 |
|
||||||
|
|------|----|----------|------|
|
||||||
|
| ease-out | `cubic-bezier(0, 0, 0.2, 1)` | `ease-out` | 진입 애니메이션 (요소 나타남) |
|
||||||
|
| ease-in | `cubic-bezier(0.4, 0, 1, 1)` | `ease-in` | 퇴장 애니메이션 (요소 사라짐) |
|
||||||
|
| ease-in-out | `cubic-bezier(0.4, 0, 0.2, 1)` | `ease-in-out` | 상태 전환 (toggle, expand) |
|
||||||
|
| spring | `cubic-bezier(0.34, 1.56, 0.64, 1)` | — | 강조 효과 (알림, 배지) |
|
||||||
|
|
||||||
|
기본 조합: 진입 `ease-out`, 퇴장 `ease-in`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 패턴
|
||||||
|
|
||||||
|
### Hover / Active
|
||||||
|
|
||||||
|
버튼, 카드, 링크 등 상호작용 요소.
|
||||||
|
|
||||||
|
```css
|
||||||
|
transition-colors duration-100 ease-out
|
||||||
|
```
|
||||||
|
|
||||||
|
포인터 커서 피드백:
|
||||||
|
```css
|
||||||
|
active:scale-[0.98] transition-transform duration-75
|
||||||
|
```
|
||||||
|
|
||||||
|
### 진입 (Fade + Slide)
|
||||||
|
|
||||||
|
모달, 드롭다운, Toast 등 요소가 나타날 때.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 아래에서 위로 */
|
||||||
|
translate-y-2 opacity-0 → translate-y-0 opacity-100
|
||||||
|
transition-[transform,opacity] duration-200 ease-out
|
||||||
|
|
||||||
|
/* 위에서 아래로 (드롭다운) */
|
||||||
|
-translate-y-2 opacity-0 → translate-y-0 opacity-100
|
||||||
|
transition-[transform,opacity] duration-200 ease-out
|
||||||
|
```
|
||||||
|
|
||||||
|
### 퇴장 (Fade + Slide)
|
||||||
|
|
||||||
|
```css
|
||||||
|
translate-y-0 opacity-100 → translate-y-2 opacity-0
|
||||||
|
transition-[transform,opacity] duration-150 ease-in
|
||||||
|
```
|
||||||
|
|
||||||
|
### 사이드바 / 패널 슬라이드
|
||||||
|
|
||||||
|
```css
|
||||||
|
-translate-x-full → translate-x-0
|
||||||
|
transition-transform duration-300 ease-out
|
||||||
|
```
|
||||||
|
|
||||||
|
### 모달 배경 (Backdrop)
|
||||||
|
|
||||||
|
```css
|
||||||
|
opacity-0 → opacity-100
|
||||||
|
transition-opacity duration-200 ease-out
|
||||||
|
```
|
||||||
|
|
||||||
|
### 스켈레톤 로딩
|
||||||
|
|
||||||
|
```css
|
||||||
|
animate-pulse
|
||||||
|
```
|
||||||
|
|
||||||
|
### 회전 (로딩 스피너)
|
||||||
|
|
||||||
|
```css
|
||||||
|
animate-spin
|
||||||
|
```
|
||||||
|
|
||||||
|
### 토글 (Accordion, Expand)
|
||||||
|
|
||||||
|
높이 애니메이션은 `max-height` 트릭을 사용한다.
|
||||||
|
|
||||||
|
```css
|
||||||
|
max-h-0 overflow-hidden → max-h-screen
|
||||||
|
transition-[max-height] duration-300 ease-in-out
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## prefers-reduced-motion
|
||||||
|
|
||||||
|
모든 모션은 사용자의 시스템 설정을 존중해야 한다.
|
||||||
|
|
||||||
|
### Tailwind 설정
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- 기본 패턴 -->
|
||||||
|
<div class="transition-opacity duration-200 motion-reduce:transition-none motion-reduce:duration-0">
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS 전역 설정 (권장)
|
||||||
|
|
||||||
|
```css
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### React Hook
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
|
const duration = prefersReducedMotion ? 0 : 200;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 금지 사항
|
||||||
|
|
||||||
|
- 3초 이상의 루프 애니메이션 (사용자 주의 분산)
|
||||||
|
- 화면 전체를 덮는 플래시/깜박임 효과
|
||||||
|
- 스크롤에 연동된 복잡한 패럴랙스
|
||||||
|
- `animation-duration: 0` 직접 설정 (prefers-reduced-motion 우회 금지)
|
||||||
115
docs/design/prompts/new-component.md
Normal file
115
docs/design/prompts/new-component.md
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
# 새 컴포넌트 생성 프롬프트 템플릿
|
||||||
|
|
||||||
|
아래 템플릿을 복사하여 Claude에게 컴포넌트 생성을 요청할 때 사용한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 기본 템플릿
|
||||||
|
|
||||||
|
```
|
||||||
|
[파일] frontend/src/components/{경로}/{ComponentName}.tsx
|
||||||
|
|
||||||
|
[계약]
|
||||||
|
interface {ComponentName}Props {
|
||||||
|
// Props 정의
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
[제약]
|
||||||
|
- docs/design/code-conventions.md 의 규칙 준수
|
||||||
|
- CSS 변수 토큰 사용 (HEX 하드코딩 금지)
|
||||||
|
- cn() 유틸리티 사용 (src/utils/cn.ts)
|
||||||
|
- Lucide React 아이콘만 사용
|
||||||
|
- any 타입 금지, strict 모드
|
||||||
|
- forwardRef 적용 (DOM 접근 필요한 경우)
|
||||||
|
- prefers-reduced-motion 고려
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 실제 사용 예시
|
||||||
|
|
||||||
|
### 버튼 컴포넌트
|
||||||
|
|
||||||
|
```
|
||||||
|
[파일] frontend/src/components/ui/Button.tsx
|
||||||
|
|
||||||
|
[계약]
|
||||||
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'accent' | 'outline' | 'ghost' | 'danger';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
loading?: boolean;
|
||||||
|
leftIcon?: React.ReactNode;
|
||||||
|
rightIcon?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
[참조] docs/design/components.md (Button 섹션)
|
||||||
|
|
||||||
|
[제약]
|
||||||
|
- CSS 변수 토큰 사용
|
||||||
|
- cn() 사용
|
||||||
|
- forwardRef 적용
|
||||||
|
- disabled + loading 상태 처리
|
||||||
|
- focus-visible ring 적용
|
||||||
|
```
|
||||||
|
|
||||||
|
### 데이터 테이블 컴포넌트
|
||||||
|
|
||||||
|
```
|
||||||
|
[파일] frontend/src/components/ui/DataTable.tsx
|
||||||
|
|
||||||
|
[계약]
|
||||||
|
interface Column<T> {
|
||||||
|
key: keyof T;
|
||||||
|
header: string;
|
||||||
|
width?: string;
|
||||||
|
render?: (value: T[keyof T], row: T) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataTableProps<T> {
|
||||||
|
columns: Column<T>[];
|
||||||
|
data: T[];
|
||||||
|
loading?: boolean;
|
||||||
|
emptyMessage?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
[제약]
|
||||||
|
- CSS 변수 토큰 사용
|
||||||
|
- 로딩 시 스켈레톤 표시
|
||||||
|
- 빈 상태 메시지 표시
|
||||||
|
- 반응형 (모바일에서 가로 스크롤)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 상태 Badge
|
||||||
|
|
||||||
|
```
|
||||||
|
[파일] frontend/src/components/ui/StatusBadge.tsx
|
||||||
|
|
||||||
|
[계약]
|
||||||
|
type StatusType = 'success' | 'warning' | 'danger' | 'info' | 'default';
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
status: StatusType;
|
||||||
|
label: string;
|
||||||
|
dot?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
[참조] docs/design/components.md (Badge 섹션)
|
||||||
|
|
||||||
|
[제약]
|
||||||
|
- 시멘틱 컬러 사용 (--color-success 등)
|
||||||
|
- dot prop이 true이면 상태 점 표시
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 체크리스트 (컴포넌트 생성 전)
|
||||||
|
|
||||||
|
- [ ] 기존 컴포넌트로 해결 가능한지 확인 (`src/components/` 탐색)
|
||||||
|
- [ ] Props 인터페이스 설계 완료
|
||||||
|
- [ ] 담당 디자인 문서 섹션 참조 (`docs/design/components.md`)
|
||||||
|
- [ ] 파일 경로 결정 (`ui/` vs 도메인 폴더)
|
||||||
|
- [ ] 접근성 요구사항 파악 (aria, focus, keyboard)
|
||||||
124
docs/design/prompts/refactor-style.md
Normal file
124
docs/design/prompts/refactor-style.md
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
# 스타일 리팩토링 프롬프트 템플릿
|
||||||
|
|
||||||
|
기존 컴포넌트의 스타일을 디자인 시스템에 맞게 리팩토링할 때 사용한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 기본 템플릿
|
||||||
|
|
||||||
|
```
|
||||||
|
[파일] frontend/src/components/{경로}/{ComponentName}.tsx (또는 pages/...)
|
||||||
|
|
||||||
|
[계약]
|
||||||
|
- 기존 Props 인터페이스 유지 (변경 금지)
|
||||||
|
- 기존 비즈니스 로직 유지 (변경 금지)
|
||||||
|
- className prop 추가 허용
|
||||||
|
|
||||||
|
[참조]
|
||||||
|
- docs/design/colors.md
|
||||||
|
- docs/design/components.md
|
||||||
|
- docs/design/code-conventions.md
|
||||||
|
|
||||||
|
[제약]
|
||||||
|
- 하드코딩 색상(HEX, Tailwind 기본 팔레트)을 CSS 변수 토큰으로 교체
|
||||||
|
- 임의 px 여백을 4px 스케일 Tailwind 클래스로 교체
|
||||||
|
- cn() 유틸리티 도입 (src/utils/cn.ts)
|
||||||
|
- Lucide React 외 아이콘 라이브러리 제거
|
||||||
|
- any 타입 금지
|
||||||
|
- console.log 제거
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 실제 사용 예시
|
||||||
|
|
||||||
|
### 색상 토큰 교체
|
||||||
|
|
||||||
|
```
|
||||||
|
[파일] frontend/src/pages/apihub/ApiHubServicePage.tsx
|
||||||
|
|
||||||
|
[계약]
|
||||||
|
- 기존 Props 및 로직 유지
|
||||||
|
- API 호출 로직 변경 금지
|
||||||
|
|
||||||
|
[참조] docs/design/colors.md
|
||||||
|
|
||||||
|
[제약]
|
||||||
|
- bg-white → bg-[var(--color-bg-surface)]
|
||||||
|
- bg-gray-50 → bg-[var(--color-bg-base)]
|
||||||
|
- text-gray-900 → text-[var(--color-text-primary)]
|
||||||
|
- text-gray-600 → text-[var(--color-text-secondary)]
|
||||||
|
- text-gray-400 → text-[var(--color-text-tertiary)]
|
||||||
|
- border-gray-200 → border-[var(--color-border)]
|
||||||
|
- border-gray-400 → border-[var(--color-border-strong)]
|
||||||
|
- bg-blue-600 → bg-[var(--color-primary)]
|
||||||
|
- text-blue-600 → text-[var(--color-primary)]
|
||||||
|
- text-green-* → text-[var(--color-success)]
|
||||||
|
- text-red-* → text-[var(--color-danger)]
|
||||||
|
- text-yellow-* → text-[var(--color-warning)]
|
||||||
|
```
|
||||||
|
|
||||||
|
### cn() 도입
|
||||||
|
|
||||||
|
```
|
||||||
|
[파일] frontend/src/components/SomeComponent.tsx
|
||||||
|
|
||||||
|
[계약]
|
||||||
|
- 기존 Props 유지
|
||||||
|
|
||||||
|
[제약]
|
||||||
|
- 조건부 className 처리를 cn() 유틸리티로 통일
|
||||||
|
- className prop 노출 추가
|
||||||
|
- 클래스 충돌 제거
|
||||||
|
```
|
||||||
|
|
||||||
|
### 컴포넌트 분리 없는 스타일 정리
|
||||||
|
|
||||||
|
```
|
||||||
|
[파일] frontend/src/layouts/ApiHubLayout.tsx
|
||||||
|
|
||||||
|
[계약]
|
||||||
|
- 레이아웃 구조 유지
|
||||||
|
- 자식 컴포넌트 렌더링 방식 유지
|
||||||
|
|
||||||
|
[참조]
|
||||||
|
- docs/design/spacing.md
|
||||||
|
- docs/design/typography.md
|
||||||
|
|
||||||
|
[제약]
|
||||||
|
- 임의 px 여백 제거
|
||||||
|
- 타입 스케일 적용
|
||||||
|
- CSS 변수 토큰 적용
|
||||||
|
- 기존 기능/라우팅 변경 금지
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 리팩토링 전 확인 사항
|
||||||
|
|
||||||
|
- [ ] 기존 컴포넌트가 어떤 역할을 하는지 파악했다
|
||||||
|
- [ ] 변경하면 안 되는 Props/로직 범위를 파악했다
|
||||||
|
- [ ] 영향받는 다른 컴포넌트가 없는지 확인했다
|
||||||
|
- [ ] 적용할 CSS 변수 토큰 매핑을 준비했다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 색상 매핑 빠른 참조
|
||||||
|
|
||||||
|
| 기존 Tailwind | 대체 토큰 |
|
||||||
|
|---------------|-----------|
|
||||||
|
| `bg-white` | `bg-[var(--color-bg-surface)]` |
|
||||||
|
| `bg-gray-50` | `bg-[var(--color-bg-base)]` |
|
||||||
|
| `bg-gray-100` | `bg-[var(--color-bg-elevated)]` |
|
||||||
|
| `text-gray-900` | `text-[var(--color-text-primary)]` |
|
||||||
|
| `text-gray-600` | `text-[var(--color-text-secondary)]` |
|
||||||
|
| `text-gray-400` | `text-[var(--color-text-tertiary)]` |
|
||||||
|
| `border-gray-200` | `border-[var(--color-border)]` |
|
||||||
|
| `border-gray-400` | `border-[var(--color-border-strong)]` |
|
||||||
|
| `bg-blue-500`, `bg-blue-600` | `bg-[var(--color-primary)]` |
|
||||||
|
| `text-blue-600` | `text-[var(--color-primary)]` |
|
||||||
|
| `hover:bg-blue-700` | `hover:bg-[var(--color-primary-hover)]` |
|
||||||
|
| `bg-green-*` / `text-green-*` | `bg/text-[var(--color-success)]` |
|
||||||
|
| `bg-red-*` / `text-red-*` | `bg/text-[var(--color-danger)]` |
|
||||||
|
| `bg-yellow-*` / `text-yellow-*` | `bg/text-[var(--color-warning)]` |
|
||||||
|
| `bg-sky-*` / `text-sky-*` | `bg/text-[var(--color-info)]` |
|
||||||
97
docs/design/prompts/review-checklist.md
Normal file
97
docs/design/prompts/review-checklist.md
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
# 디자인 리뷰 체크리스트
|
||||||
|
|
||||||
|
컴포넌트 구현 완료 후, MR 생성 전에 아래 9개 카테고리를 점검한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 컬러 토큰
|
||||||
|
|
||||||
|
- [ ] HEX 색상 코드가 직접 사용되지 않았다
|
||||||
|
- [ ] Tailwind 기본 팔레트(gray-*, blue-* 등)를 브랜드 컬러 대용으로 사용하지 않았다
|
||||||
|
- [ ] 모든 색상이 CSS 변수 토큰(`var(--color-*)`)을 통해 지정되었다
|
||||||
|
- [ ] 시멘틱 컬러(success, warning, danger, info)가 의미에 맞게 사용되었다
|
||||||
|
- [ ] 시멘틱 컬러를 장식 목적으로 사용하지 않았다
|
||||||
|
|
||||||
|
## 2. 다크 모드
|
||||||
|
|
||||||
|
- [ ] CSS 변수 토큰을 사용하므로 다크 모드가 자동으로 전환된다
|
||||||
|
- [ ] 토큰이 없는 케이스(그라디언트, 특수 색상 등)에만 `dark:` 클래스를 사용했다
|
||||||
|
- [ ] `dark:` 클래스가 CSS 변수 토큰과 충돌하지 않는다
|
||||||
|
- [ ] 이미지, SVG 등 미디어가 다크 모드에서 적절히 표시된다
|
||||||
|
|
||||||
|
## 3. 타이포그래피
|
||||||
|
|
||||||
|
- [ ] 폰트 크기가 타입 스케일(`text-sm`, `text-base`, `text-lg` 등)을 사용한다
|
||||||
|
- [ ] 임의의 px 폰트 크기(`text-[22px]` 등)를 사용하지 않았다
|
||||||
|
- [ ] 텍스트 색상이 CSS 변수 토큰(`--color-text-primary` 등)을 사용한다
|
||||||
|
- [ ] 한글 텍스트에 `word-break: keep-all`이 적용되었다 (본문, 설명)
|
||||||
|
|
||||||
|
## 4. 스페이싱
|
||||||
|
|
||||||
|
- [ ] 여백이 4px 단위 스케일(`p-2`, `p-4`, `gap-3` 등)을 사용한다
|
||||||
|
- [ ] 임의의 px 여백(`p-[13px]`)을 사용하지 않았다
|
||||||
|
- [ ] 인라인 스타일로 여백을 지정하지 않았다
|
||||||
|
|
||||||
|
## 5. 컴포넌트 API
|
||||||
|
|
||||||
|
- [ ] Props 인터페이스가 `interface {Name}Props`로 정의되었다
|
||||||
|
- [ ] `className` prop이 허용되고 `cn()`으로 병합된다
|
||||||
|
- [ ] `cn()` 유틸리티(`src/utils/cn.ts`)를 사용한다
|
||||||
|
- [ ] DOM 접근이 필요한 경우 `forwardRef`가 적용되었다
|
||||||
|
- [ ] `any` 타입이 사용되지 않았다
|
||||||
|
|
||||||
|
## 6. 아이콘
|
||||||
|
|
||||||
|
- [ ] Lucide React 아이콘만 사용했다 (다른 라이브러리 혼용 없음)
|
||||||
|
- [ ] 아이콘 크기가 사이즈 스케일(`size-4`, `size-5`, `size-6` 등)을 사용한다
|
||||||
|
- [ ] 아이콘 색상이 텍스트 색상 상속 또는 CSS 변수 토큰을 사용한다
|
||||||
|
- [ ] 장식용 아이콘에 `aria-hidden="true"`가 적용되었다
|
||||||
|
- [ ] 의미 있는 아이콘 버튼에 `aria-label`이 있다
|
||||||
|
|
||||||
|
## 7. 접근성
|
||||||
|
|
||||||
|
- [ ] 모든 인터랙티브 요소가 키보드로 접근 가능하다
|
||||||
|
- [ ] `focus-visible` 스타일이 적용되었다
|
||||||
|
- [ ] ARIA 속성이 올바르게 사용되었다 (`role`, `aria-label`, `aria-expanded` 등)
|
||||||
|
- [ ] 색상만으로 정보를 전달하지 않는다 (아이콘, 텍스트 보조)
|
||||||
|
- [ ] 이미지에 `alt` 텍스트가 있다
|
||||||
|
|
||||||
|
## 8. 모션
|
||||||
|
|
||||||
|
- [ ] 트랜지션 지속 시간이 200ms 이하이다 (특별한 경우 300ms)
|
||||||
|
- [ ] 진입/퇴장 애니메이션에 올바른 easing이 적용되었다 (진입: `ease-out`, 퇴장: `ease-in`)
|
||||||
|
- [ ] `motion-reduce:transition-none` 또는 `motion-reduce:duration-0`이 적용되었다
|
||||||
|
- [ ] 불필요한 애니메이션이 없다
|
||||||
|
|
||||||
|
## 9. 코드 품질
|
||||||
|
|
||||||
|
- [ ] `console.log`가 없다
|
||||||
|
- [ ] 사용하지 않는 import가 없다
|
||||||
|
- [ ] 매직 넘버/문자열이 상수로 추출되었다
|
||||||
|
- [ ] `tsc --noEmit` 통과
|
||||||
|
- [ ] 컴포넌트에 `displayName`이 설정되었다 (forwardRef 사용 시)
|
||||||
|
- [ ] Import 순서가 컨벤션을 따른다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 리뷰 요청 템플릿
|
||||||
|
|
||||||
|
MR 본문에 아래 내용을 포함한다.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 디자인 리뷰
|
||||||
|
|
||||||
|
### 컬러 토큰
|
||||||
|
- [ ] CSS 변수 토큰 사용
|
||||||
|
- [ ] 다크 모드 자동 전환
|
||||||
|
|
||||||
|
### 접근성
|
||||||
|
- [ ] 키보드 접근성
|
||||||
|
- [ ] ARIA 속성
|
||||||
|
|
||||||
|
### 모션
|
||||||
|
- [ ] prefers-reduced-motion 처리
|
||||||
|
|
||||||
|
### 자체 검증
|
||||||
|
- tsc --noEmit: 통과 / 실패
|
||||||
|
```
|
||||||
137
docs/design/spacing.md
Normal file
137
docs/design/spacing.md
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
# 스페이싱 & 레이아웃
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 기본 스케일 (4px 기반)
|
||||||
|
|
||||||
|
모든 여백과 크기는 4px 단위를 기준으로 한다.
|
||||||
|
|
||||||
|
| Token | px | rem | Tailwind | 용도 |
|
||||||
|
|-------|----|-----|----------|------|
|
||||||
|
| space-0 | 0 | 0 | `p-0`, `m-0` | 초기화 |
|
||||||
|
| space-1 | 4px | 0.25rem | `p-1`, `m-1` | 아이콘 내부 간격 |
|
||||||
|
| space-2 | 8px | 0.5rem | `p-2`, `m-2` | 배지 내부 패딩 |
|
||||||
|
| space-3 | 12px | 0.75rem | `p-3`, `m-3` | 버튼 수직 패딩 |
|
||||||
|
| space-4 | 16px | 1rem | `p-4`, `m-4` | 카드 내부 패딩 (sm) |
|
||||||
|
| space-5 | 20px | 1.25rem | `p-5`, `m-5` | 인풋 내부 패딩 |
|
||||||
|
| space-6 | 24px | 1.5rem | `p-6`, `m-6` | 카드 내부 패딩 (md) |
|
||||||
|
| space-8 | 32px | 2rem | `p-8`, `m-8` | 섹션 내부 패딩 |
|
||||||
|
| space-10 | 40px | 2.5rem | `p-10`, `m-10` | 섹션 상하 여백 |
|
||||||
|
| space-12 | 48px | 3rem | `p-12`, `m-12` | 페이지 상하 패딩 |
|
||||||
|
| space-16 | 64px | 4rem | `p-16`, `m-16` | 섹션 간 여백 |
|
||||||
|
| space-20 | 80px | 5rem | `p-20`, `m-20` | 페이지 섹션 간격 |
|
||||||
|
| space-24 | 96px | 6rem | `p-24`, `m-24` | 히어로 여백 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 컴포넌트별 내부 여백
|
||||||
|
|
||||||
|
### Button
|
||||||
|
|
||||||
|
| 사이즈 | padding | font-size | height |
|
||||||
|
|--------|---------|-----------|--------|
|
||||||
|
| sm | `px-3 py-1.5` | `text-sm` | 32px |
|
||||||
|
| md | `px-4 py-2` | `text-base` | 40px |
|
||||||
|
| lg | `px-6 py-3` | `text-lg` | 48px |
|
||||||
|
|
||||||
|
### Input
|
||||||
|
|
||||||
|
| 사이즈 | padding | font-size | height |
|
||||||
|
|--------|---------|-----------|--------|
|
||||||
|
| sm | `px-3 py-1.5` | `text-sm` | 32px |
|
||||||
|
| md | `px-3 py-2` | `text-base` | 40px |
|
||||||
|
| lg | `px-4 py-3` | `text-lg` | 48px |
|
||||||
|
|
||||||
|
### Card
|
||||||
|
|
||||||
|
| 패딩 | 클래스 | 용도 |
|
||||||
|
|------|--------|------|
|
||||||
|
| compact | `p-4` | 데이터 테이블 행 |
|
||||||
|
| default | `p-6` | 일반 카드 |
|
||||||
|
| spacious | `p-8` | 주요 콘텐츠 카드 |
|
||||||
|
|
||||||
|
### Badge
|
||||||
|
|
||||||
|
패딩: `px-2 py-0.5` / 폰트: `text-xs font-medium`
|
||||||
|
|
||||||
|
### Modal
|
||||||
|
|
||||||
|
| 사이즈 | width | padding |
|
||||||
|
|--------|-------|---------|
|
||||||
|
| sm | `max-w-sm` | `p-6` |
|
||||||
|
| md | `max-w-md` | `p-6` |
|
||||||
|
| lg | `max-w-lg` | `p-8` |
|
||||||
|
| xl | `max-w-xl` | `p-8` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 반응형 브레이크포인트
|
||||||
|
|
||||||
|
Tailwind CSS 4 기본 브레이크포인트 사용.
|
||||||
|
|
||||||
|
| 이름 | 최소 너비 | 용도 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| xs | — | 기본 (모바일 우선) |
|
||||||
|
| sm | 640px | 큰 모바일, 세로 태블릿 |
|
||||||
|
| md | 768px | 태블릿 |
|
||||||
|
| lg | 1024px | 데스크톱 |
|
||||||
|
| xl | 1280px | 와이드 데스크톱 |
|
||||||
|
| 2xl | 1536px | 울트라 와이드 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12컬럼 그리드
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- 페이지 컨테이너 -->
|
||||||
|
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
|
||||||
|
<!-- 12컬럼 그리드 -->
|
||||||
|
<div class="grid grid-cols-12 gap-4 lg:gap-6">
|
||||||
|
|
||||||
|
<!-- 사이드바 (3컬럼) -->
|
||||||
|
<aside class="col-span-12 lg:col-span-3">...</aside>
|
||||||
|
|
||||||
|
<!-- 메인 콘텐츠 (9컬럼) -->
|
||||||
|
<main class="col-span-12 lg:col-span-9">...</main>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 자주 쓰는 레이아웃 패턴
|
||||||
|
|
||||||
|
| 패턴 | 클래스 |
|
||||||
|
|------|--------|
|
||||||
|
| 전체 너비 | `col-span-12` |
|
||||||
|
| 절반 | `col-span-12 md:col-span-6` |
|
||||||
|
| 1/3 | `col-span-12 md:col-span-4` |
|
||||||
|
| 2/3 | `col-span-12 md:col-span-8` |
|
||||||
|
| 사이드바 | `col-span-12 lg:col-span-3` |
|
||||||
|
| 콘텐츠 | `col-span-12 lg:col-span-9` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gap (간격)
|
||||||
|
|
||||||
|
| 컨텍스트 | 클래스 |
|
||||||
|
|----------|--------|
|
||||||
|
| 인라인 아이템 (버튼 그룹) | `gap-2` |
|
||||||
|
| 폼 필드 | `gap-4` |
|
||||||
|
| 카드 그리드 | `gap-4 lg:gap-6` |
|
||||||
|
| 섹션 간 | `gap-8 lg:gap-12` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 스택 레이아웃
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- 수직 스택 -->
|
||||||
|
<div class="flex flex-col gap-4">...</div>
|
||||||
|
|
||||||
|
<!-- 수평 스택 -->
|
||||||
|
<div class="flex items-center gap-2">...</div>
|
||||||
|
|
||||||
|
<!-- 정렬 포함 -->
|
||||||
|
<div class="flex items-center justify-between gap-4">...</div>
|
||||||
|
```
|
||||||
90
docs/design/typography.md
Normal file
90
docs/design/typography.md
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
# 타이포그래피
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 폰트 패밀리
|
||||||
|
|
||||||
|
| 역할 | 폰트 | 적용 범위 |
|
||||||
|
|------|------|-----------|
|
||||||
|
| 기본 (한글) | Pretendard Variable / Pretendard | 모든 UI 텍스트 |
|
||||||
|
| 기본 (영문/숫자) | Inter | 영문 전용 컨텍스트 |
|
||||||
|
| 코드 | JetBrains Mono / Fira Code | 코드 블록, API Key 등 |
|
||||||
|
|
||||||
|
CSS 변수:
|
||||||
|
```css
|
||||||
|
--font-sans: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont,
|
||||||
|
system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo',
|
||||||
|
'Noto Sans KR', 'Malgun Gothic', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', Consolas, Monaco, 'Courier New', monospace;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 타입 스케일
|
||||||
|
|
||||||
|
8단계 스케일. `rem` 단위 기준 (root 16px).
|
||||||
|
|
||||||
|
| 단계 | 이름 | px | rem | line-height | weight | 용도 |
|
||||||
|
|------|------|----|-----|-------------|--------|------|
|
||||||
|
| 1 | Display | 48px | 3rem | 1.2 | 700 | 히어로, 랜딩 |
|
||||||
|
| 2 | H1 | 36px | 2.25rem | 1.2 | 700 | 페이지 제목 |
|
||||||
|
| 3 | H2 | 30px | 1.875rem | 1.25 | 600 | 섹션 제목 |
|
||||||
|
| 4 | H3 | 24px | 1.5rem | 1.3 | 600 | 카드 제목 |
|
||||||
|
| 5 | Body-lg | 18px | 1.125rem | 1.6 | 400 | 강조 본문 |
|
||||||
|
| 6 | Body | 16px | 1rem | 1.6 | 400 | 기본 본문 |
|
||||||
|
| 7 | Caption | 14px | 0.875rem | 1.5 | 400 | 레이블, 보조 |
|
||||||
|
| 8 | Overline | 12px | 0.75rem | 1.5 | 500 | 카테고리, 태그 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Font Weight
|
||||||
|
|
||||||
|
| 값 | 이름 | 사용처 |
|
||||||
|
|----|------|--------|
|
||||||
|
| 300 | Light | 큰 디스플레이 텍스트 부제목 |
|
||||||
|
| 400 | Regular | 기본 본문 |
|
||||||
|
| 500 | Medium | 캡션, Overline, 강조 레이블 |
|
||||||
|
| 600 | SemiBold | H2, H3, 버튼 |
|
||||||
|
| 700 | Bold | Display, H1, 강조 수치 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tailwind 클래스 매핑
|
||||||
|
|
||||||
|
| 단계 | Tailwind 클래스 |
|
||||||
|
|------|----------------|
|
||||||
|
| Display | `text-5xl font-bold leading-tight` |
|
||||||
|
| H1 | `text-4xl font-bold leading-tight` |
|
||||||
|
| H2 | `text-3xl font-semibold leading-snug` |
|
||||||
|
| H3 | `text-2xl font-semibold leading-snug` |
|
||||||
|
| Body-lg | `text-lg font-normal leading-relaxed` |
|
||||||
|
| Body | `text-base font-normal leading-relaxed` |
|
||||||
|
| Caption | `text-sm font-normal leading-normal` |
|
||||||
|
| Overline | `text-xs font-medium leading-normal tracking-wide uppercase` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 한글 타이포그래피 규칙
|
||||||
|
|
||||||
|
- `word-break: keep-all` 적용 — 단어 중간에서 줄바꿈 방지
|
||||||
|
- 한글 사용 시 `letter-spacing` 조정 금지 (Pretendard 기본값 유지)
|
||||||
|
- 본문 최대 너비: `max-w-prose` (65ch) — 가독성 확보
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 전역 적용 권장 */
|
||||||
|
p, li, dd {
|
||||||
|
word-break: keep-all;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 색상 규칙
|
||||||
|
|
||||||
|
| 상황 | 클래스 |
|
||||||
|
|------|--------|
|
||||||
|
| 제목, 본문 | `text-[var(--color-text-primary)]` |
|
||||||
|
| 보조 설명 | `text-[var(--color-text-secondary)]` |
|
||||||
|
| placeholder, 비활성 | `text-[var(--color-text-tertiary)]` |
|
||||||
|
| 링크 | `text-[var(--color-primary)] hover:text-[var(--color-primary-hover)]` |
|
||||||
|
| 위험/오류 | `text-[var(--color-danger)]` |
|
||||||
14
frontend/package-lock.json
generated
14
frontend/package-lock.json
generated
@ -8,10 +8,12 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.13.0",
|
||||||
"recharts": "^3.8.1"
|
"recharts": "^3.8.1",
|
||||||
|
"tailwind-merge": "^3.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
@ -4040,6 +4042,16 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tailwind-merge": {
|
||||||
|
"version": "3.5.0",
|
||||||
|
"resolved": "https://nexus.gc-si.dev/repository/npm-public/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
|
||||||
|
"integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/dcastil"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.2.2",
|
"version": "4.2.2",
|
||||||
"resolved": "https://nexus.gc-si.dev/repository/npm-public/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
"resolved": "https://nexus.gc-si.dev/repository/npm-public/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||||
|
|||||||
@ -11,10 +11,12 @@
|
|||||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\""
|
"format": "prettier --write \"src/**/*.{ts,tsx,css}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.13.0",
|
||||||
"recharts": "^3.8.1"
|
"recharts": "^3.8.1",
|
||||||
|
"tailwind-merge": "^3.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useState, useEffect, useMemo } from 'react';
|
|||||||
import { createKeyRequest } from '../services/apiKeyService';
|
import { createKeyRequest } from '../services/apiKeyService';
|
||||||
import { getCatalog } from '../services/apiHubService';
|
import { getCatalog } from '../services/apiHubService';
|
||||||
import type { ServiceCatalog } from '../types/apihub';
|
import type { ServiceCatalog } from '../types/apihub';
|
||||||
|
import Button from './ui/Button';
|
||||||
|
|
||||||
interface ApiKeyRequestModalProps {
|
interface ApiKeyRequestModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -148,14 +149,14 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
|||||||
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
|
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
>
|
>
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
<div className="bg-[var(--color-bg-surface)] rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--color-border)]">
|
||||||
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">API 사용 신청</h2>
|
<h2 className="text-lg font-bold text-[var(--color-text-primary)]">API 사용 신청</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
@ -166,24 +167,24 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
|||||||
{/* 성공 메시지 */}
|
{/* 성공 메시지 */}
|
||||||
{success ? (
|
{success ? (
|
||||||
<div className="px-6 py-10 text-center">
|
<div className="px-6 py-10 text-center">
|
||||||
<div className="bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-700 rounded-lg p-6">
|
<div className="bg-[var(--color-success-subtle)] border border-[var(--color-success-border)] rounded-lg p-6">
|
||||||
<h3 className="text-lg font-semibold text-green-800 dark:text-green-300 mb-2">신청이 완료되었습니다</h3>
|
<h3 className="text-lg font-semibold text-[var(--color-success-text-strong)] mb-2">신청이 완료되었습니다</h3>
|
||||||
<p className="text-green-700 dark:text-green-400 text-sm">관리자 승인 후 API Key가 생성됩니다.</p>
|
<p className="text-[var(--color-success-text)] text-sm">관리자 승인 후 API Key가 생성됩니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-5">
|
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-5">
|
||||||
{/* 에러 메시지 */}
|
{/* 에러 메시지 */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 text-red-700 dark:text-red-400 rounded-lg text-sm">
|
<div className="p-3 bg-[var(--color-danger-subtle)] border border-[var(--color-danger-border)] text-[var(--color-danger-text)] rounded-lg text-sm">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* API 선택 (도메인 기반 체크박스 트리) */}
|
{/* API 선택 (도메인 기반 체크박스 트리) */}
|
||||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
<div className="border border-[var(--color-border)] rounded-lg overflow-hidden">
|
||||||
<div className="flex items-center justify-between px-4 py-2.5 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-700">
|
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--color-bg-base)] border-b border-[var(--color-border)]">
|
||||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">
|
||||||
API 선택
|
API 선택
|
||||||
{selectedApiIds.size > 0 && (
|
{selectedApiIds.size > 0 && (
|
||||||
<span className="ml-2 text-xs font-normal text-indigo-500">{selectedApiIds.size}건 선택</span>
|
<span className="ml-2 text-xs font-normal text-indigo-500">{selectedApiIds.size}건 선택</span>
|
||||||
@ -194,12 +195,12 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
|||||||
value={apiSearch}
|
value={apiSearch}
|
||||||
onChange={(e) => setApiSearch(e.target.value)}
|
onChange={(e) => setApiSearch(e.target.value)}
|
||||||
placeholder="API 검색..."
|
placeholder="API 검색..."
|
||||||
className="bg-white dark:bg-gray-600 border border-gray-200 dark:border-gray-500 rounded-lg px-2.5 py-1 text-xs text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:outline-none w-40"
|
className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-lg px-2.5 py-1 text-xs text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] focus:ring-1 focus:ring-[var(--color-primary)] focus:outline-none w-40"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-56 overflow-y-auto">
|
<div className="max-h-56 overflow-y-auto">
|
||||||
{domainGroups.length === 0 ? (
|
{domainGroups.length === 0 ? (
|
||||||
<p className="text-xs text-gray-400 text-center py-6">검색 결과가 없습니다</p>
|
<p className="text-xs text-[var(--color-text-tertiary)] text-center py-6">검색 결과가 없습니다</p>
|
||||||
) : domainGroups.map(([domain, apis]) => {
|
) : domainGroups.map(([domain, apis]) => {
|
||||||
const isExpanded = expandedDomains.has(domain);
|
const isExpanded = expandedDomains.has(domain);
|
||||||
const allSelected = apis.every((a) => selectedApiIds.has(a.apiId));
|
const allSelected = apis.every((a) => selectedApiIds.has(a.apiId));
|
||||||
@ -207,10 +208,10 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
|||||||
return (
|
return (
|
||||||
<div key={domain}>
|
<div key={domain}>
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700/50"
|
className="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-[var(--color-bg-base)] border-b border-[var(--color-border)]"
|
||||||
onClick={() => setExpandedDomains((prev) => { const n = new Set(prev); n.has(domain) ? n.delete(domain) : n.add(domain); return n; })}
|
onClick={() => setExpandedDomains((prev) => { const n = new Set(prev); n.has(domain) ? n.delete(domain) : n.add(domain); return n; })}
|
||||||
>
|
>
|
||||||
<svg className={`h-3.5 w-3.5 text-gray-400 transition-transform ${isExpanded ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
<svg className={`h-3.5 w-3.5 text-[var(--color-text-tertiary)] transition-transform ${isExpanded ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={allSelected}
|
checked={allSelected}
|
||||||
@ -225,13 +226,13 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">{domain}</span>
|
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{domain}</span>
|
||||||
<span className="text-xs text-gray-400">{apis.length}</span>
|
<span className="text-xs text-[var(--color-text-tertiary)]">{apis.length}</span>
|
||||||
</div>
|
</div>
|
||||||
{isExpanded && apis.map((a) => (
|
{isExpanded && apis.map((a) => (
|
||||||
<div
|
<div
|
||||||
key={a.apiId}
|
key={a.apiId}
|
||||||
className={`flex items-center gap-2 pl-12 pr-4 py-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/30 border-b border-gray-50 dark:border-gray-700/30 ${selectedApiIds.has(a.apiId) ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''}`}
|
className={`flex items-center gap-2 pl-12 pr-4 py-2 cursor-pointer hover:bg-[var(--color-bg-base)] border-b border-[var(--color-border)] ${selectedApiIds.has(a.apiId) ? 'bg-[var(--color-primary-subtle)]' : ''}`}
|
||||||
onClick={() => setSelectedApiIds((prev) => { const n = new Set(prev); n.has(a.apiId) ? n.delete(a.apiId) : n.add(a.apiId); return n; })}
|
onClick={() => setSelectedApiIds((prev) => { const n = new Set(prev); n.has(a.apiId) ? n.delete(a.apiId) : n.add(a.apiId); return n; })}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@ -241,7 +242,7 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-900 dark:text-gray-100 truncate">{a.apiName}</span>
|
<span className="text-sm text-[var(--color-text-primary)] truncate">{a.apiName}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -252,7 +253,7 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
|||||||
|
|
||||||
{/* Key Name */}
|
{/* Key Name */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
Key Name <span className="text-red-500">*</span>
|
Key Name <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -261,25 +262,25 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
|||||||
onChange={(e) => setKeyName(e.target.value)}
|
onChange={(e) => setKeyName(e.target.value)}
|
||||||
required
|
required
|
||||||
placeholder="API Key 이름을 입력하세요"
|
placeholder="API Key 이름을 입력하세요"
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 사용 목적 */}
|
{/* 사용 목적 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">사용 목적</label>
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">사용 목적</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={purpose}
|
value={purpose}
|
||||||
onChange={(e) => setPurpose(e.target.value)}
|
onChange={(e) => setPurpose(e.target.value)}
|
||||||
rows={2}
|
rows={2}
|
||||||
placeholder="사용 목적을 입력하세요"
|
placeholder="사용 목적을 입력하세요"
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 사용 기간 */}
|
{/* 사용 기간 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
사용 기간 <span className="text-red-500">*</span>
|
사용 기간 <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
@ -289,21 +290,21 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => handlePresetPeriod(m)}
|
onClick={() => handlePresetPeriod(m)}
|
||||||
disabled={isPermanent || periodMode === 'custom'}
|
disabled={isPermanent || periodMode === 'custom'}
|
||||||
className={`px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 ${isPermanent || periodMode === 'custom' ? 'opacity-40 cursor-not-allowed' : 'hover:bg-blue-50 dark:hover:bg-gray-600'}`}
|
className={`px-3 py-1.5 text-sm rounded-lg border border-[var(--color-border-strong)] text-[var(--color-text-primary)] bg-[var(--color-bg-surface)] ${isPermanent || periodMode === 'custom' ? 'opacity-40 cursor-not-allowed' : 'hover:bg-[var(--color-primary-subtle)]'}`}
|
||||||
>
|
>
|
||||||
{m}개월
|
{m}개월
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<span className="text-gray-400 dark:text-gray-600 mx-1">|</span>
|
<span className="text-[var(--color-text-tertiary)] mx-1">|</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handlePermanent}
|
onClick={handlePermanent}
|
||||||
className={`px-3 py-1.5 text-sm rounded-lg border font-medium ${isPermanent ? 'bg-indigo-600 text-white border-indigo-600' : 'text-indigo-600 border-indigo-300 dark:border-indigo-500 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/30'}`}
|
className={`px-3 py-1.5 text-sm rounded-lg border font-medium ${isPermanent ? 'bg-indigo-600 text-white border-indigo-600' : 'text-indigo-600 border-indigo-300 hover:bg-indigo-50'}`}
|
||||||
>
|
>
|
||||||
영구
|
영구
|
||||||
</button>
|
</button>
|
||||||
<span className="text-gray-400 dark:text-gray-600 mx-1">|</span>
|
<span className="text-[var(--color-text-tertiary)] mx-1">|</span>
|
||||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none">
|
<label className="flex items-center gap-2 text-sm text-[var(--color-text-primary)] cursor-pointer select-none">
|
||||||
직접 선택
|
직접 선택
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -311,15 +312,15 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
|||||||
setPeriodMode(periodMode === 'custom' ? 'preset' : 'custom');
|
setPeriodMode(periodMode === 'custom' ? 'preset' : 'custom');
|
||||||
setIsPermanent(false);
|
setIsPermanent(false);
|
||||||
}}
|
}}
|
||||||
className={`relative w-10 h-5 rounded-full transition-colors ${periodMode === 'custom' && !isPermanent ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'}`}
|
className={`relative w-10 h-5 rounded-full transition-colors ${periodMode === 'custom' && !isPermanent ? 'bg-[var(--color-primary)] dark:bg-[var(--color-primary-600)]' : 'bg-[var(--color-border-strong)]'}`}
|
||||||
>
|
>
|
||||||
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${periodMode === 'custom' && !isPermanent ? 'translate-x-5' : ''}`} />
|
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${periodMode === 'custom' && !isPermanent ? 'translate-x-5' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{isPermanent ? (
|
{isPermanent ? (
|
||||||
<div className="flex items-center gap-2 px-3 py-2 bg-indigo-50 dark:bg-indigo-900/30 border border-indigo-200 dark:border-indigo-700 rounded-lg">
|
<div className="flex items-center gap-2 px-3 py-2 bg-indigo-50 border border-indigo-200 rounded-lg">
|
||||||
<span className="text-indigo-700 dark:text-indigo-300 text-sm font-medium">영구 사용 (만료일 없음)</span>
|
<span className="text-indigo-700 text-sm font-medium">영구 사용 (만료일 없음)</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -328,15 +329,15 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
|||||||
value={fromDate}
|
value={fromDate}
|
||||||
onChange={(e) => setFromDate(e.target.value)}
|
onChange={(e) => setFromDate(e.target.value)}
|
||||||
readOnly={periodMode !== 'custom'}
|
readOnly={periodMode !== 'custom'}
|
||||||
className={`border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none ${periodMode !== 'custom' ? 'bg-gray-50 text-gray-500 dark:bg-gray-700 dark:text-gray-400' : 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'}`}
|
className={`border border-[var(--color-border-strong)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none ${periodMode !== 'custom' ? 'bg-[var(--color-bg-base)] text-[var(--color-text-secondary)]' : 'bg-[var(--color-bg-surface)] text-[var(--color-text-primary)]'}`}
|
||||||
/>
|
/>
|
||||||
<span className="text-gray-500 dark:text-gray-400">~</span>
|
<span className="text-[var(--color-text-secondary)]">~</span>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={toDate}
|
value={toDate}
|
||||||
onChange={(e) => setToDate(e.target.value)}
|
onChange={(e) => setToDate(e.target.value)}
|
||||||
readOnly={periodMode !== 'custom'}
|
readOnly={periodMode !== 'custom'}
|
||||||
className={`border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none ${periodMode !== 'custom' ? 'bg-gray-50 text-gray-500 dark:bg-gray-700 dark:text-gray-400' : 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'}`}
|
className={`border border-[var(--color-border-strong)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none ${periodMode !== 'custom' ? 'bg-[var(--color-bg-base)] text-[var(--color-text-secondary)]' : 'bg-[var(--color-bg-surface)] text-[var(--color-text-primary)]'}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -344,7 +345,7 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
|||||||
|
|
||||||
{/* 서비스 IP */}
|
{/* 서비스 IP */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
서비스 IP <span className="text-red-500">*</span>
|
서비스 IP <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -353,22 +354,22 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
|||||||
onChange={(e) => setServiceIp(e.target.value)}
|
onChange={(e) => setServiceIp(e.target.value)}
|
||||||
required
|
required
|
||||||
placeholder="예: 192.168.1.100"
|
placeholder="예: 192.168.1.100"
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">발급받은 API Key로 프록시 서버에 요청하는 서비스 IP</p>
|
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">발급받은 API Key로 프록시 서버에 요청하는 서비스 IP</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 서비스 용도 + 하루 예상 요청량 */}
|
{/* 서비스 용도 + 하루 예상 요청량 */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
서비스 용도 <span className="text-red-500">*</span>
|
서비스 용도 <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={servicePurpose}
|
value={servicePurpose}
|
||||||
onChange={(e) => setServicePurpose(e.target.value)}
|
onChange={(e) => setServicePurpose(e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">선택하세요</option>
|
<option value="">선택하세요</option>
|
||||||
<option value="로컬 환경">로컬 환경</option>
|
<option value="로컬 환경">로컬 환경</option>
|
||||||
@ -378,14 +379,14 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
하루 예상 요청량 <span className="text-red-500">*</span>
|
하루 예상 요청량 <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={dailyEstimate}
|
value={dailyEstimate}
|
||||||
onChange={(e) => setDailyEstimate(e.target.value)}
|
onChange={(e) => setDailyEstimate(e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">선택하세요</option>
|
<option value="">선택하세요</option>
|
||||||
<option value="100">100 이하</option>
|
<option value="100">100 이하</option>
|
||||||
@ -400,20 +401,19 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
|||||||
|
|
||||||
{/* 하단 버튼 */}
|
{/* 하단 버튼 */}
|
||||||
<div className="flex justify-end gap-3 pt-2">
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="outline"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 text-sm font-medium transition-colors"
|
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-300 text-white text-sm font-medium transition-colors"
|
|
||||||
>
|
>
|
||||||
{isSubmitting ? '신청 중...' : '신청하기'}
|
{isSubmitting ? '신청 중...' : '신청하기'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -39,15 +39,15 @@ const DateRangeFilter = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">기간:</span>
|
<span className="text-sm font-medium text-[var(--color-text-primary)]">기간:</span>
|
||||||
{presets.map((preset) => (
|
{presets.map((preset) => (
|
||||||
<button
|
<button
|
||||||
key={preset.days}
|
key={preset.days}
|
||||||
onClick={() => onPreset(preset.days)}
|
onClick={() => onPreset(preset.days)}
|
||||||
className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
||||||
isPresetActive(startDate, endDate, preset.days)
|
isPresetActive(startDate, endDate, preset.days)
|
||||||
? 'bg-blue-600 text-white'
|
? 'bg-[var(--color-primary)] text-white dark:bg-[var(--color-primary-600)]'
|
||||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
|
: 'bg-[var(--color-bg-base)] text-[var(--color-text-primary)] hover:bg-[var(--color-border)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{preset.label}
|
{preset.label}
|
||||||
@ -57,14 +57,14 @@ const DateRangeFilter = ({
|
|||||||
type="date"
|
type="date"
|
||||||
value={startDate}
|
value={startDate}
|
||||||
onChange={(e) => onStartDateChange(e.target.value)}
|
onChange={(e) => onStartDateChange(e.target.value)}
|
||||||
className="px-3 py-1.5 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm"
|
className="px-3 py-1.5 rounded border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] text-sm"
|
||||||
/>
|
/>
|
||||||
<span className="text-gray-500 dark:text-gray-400">~</span>
|
<span className="text-[var(--color-text-secondary)]">~</span>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={endDate}
|
value={endDate}
|
||||||
onChange={(e) => onEndDateChange(e.target.value)}
|
onChange={(e) => onEndDateChange(e.target.value)}
|
||||||
className="px-3 py-1.5 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm"
|
className="px-3 py-1.5 rounded border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import Button from './ui/Button';
|
||||||
|
|
||||||
interface RoleGuardProps {
|
interface RoleGuardProps {
|
||||||
allowedRoles: string[];
|
allowedRoles: string[];
|
||||||
@ -13,23 +14,20 @@ const RoleGuard = ({ allowedRoles, children }: RoleGuardProps) => {
|
|||||||
if (!user || !allowedRoles.includes(user.role)) {
|
if (!user || !allowedRoles.includes(user.role)) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto flex flex-col items-center justify-center py-32">
|
<div className="max-w-7xl mx-auto flex flex-col items-center justify-center py-32">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 shadow-lg p-10 text-center max-w-md">
|
<div className="bg-[var(--color-bg-surface)] rounded-2xl border border-[var(--color-border)] shadow-lg p-10 text-center max-w-md">
|
||||||
<div className="w-16 h-16 mx-auto mb-5 rounded-full bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700/30 flex items-center justify-center">
|
<div className="w-16 h-16 mx-auto mb-5 rounded-full bg-[var(--color-danger-subtle)] border border-[var(--color-danger-border)] flex items-center justify-center">
|
||||||
<svg className="w-8 h-8 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
|
<svg className="w-8 h-8 text-[var(--color-danger-text)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-2">접근 권한이 없습니다</h2>
|
<h2 className="text-xl font-bold text-[var(--color-text-primary)] mb-2">접근 권한이 없습니다</h2>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
<p className="text-sm text-[var(--color-text-secondary)] mb-6">
|
||||||
이 페이지는 <span className="font-semibold text-gray-700 dark:text-gray-300">{allowedRoles.join(', ')}</span> 권한이 필요합니다.
|
이 페이지는 <span className="font-semibold text-[var(--color-text-primary)]">{allowedRoles.join(', ')}</span> 권한이 필요합니다.
|
||||||
<br />현재 권한: <span className="font-semibold text-gray-700 dark:text-gray-300">{user?.role || '-'}</span>
|
<br />현재 권한: <span className="font-semibold text-[var(--color-text-primary)]">{user?.role || '-'}</span>
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Button onClick={() => navigate('/dashboard')}>
|
||||||
onClick={() => navigate('/dashboard')}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2.5 rounded-lg text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
대시보드로 돌아가기
|
대시보드로 돌아가기
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
34
frontend/src/components/ui/Badge.tsx
Normal file
34
frontend/src/components/ui/Badge.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { cn } from '../../utils/cn';
|
||||||
|
|
||||||
|
export type BadgeVariant = 'default' | 'primary' | 'secondary' | 'accent' | 'success' | 'warning' | 'danger' | 'info';
|
||||||
|
|
||||||
|
interface BadgeProps {
|
||||||
|
variant?: BadgeVariant;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantStyles = {
|
||||||
|
default: 'bg-[var(--color-bg-base)] text-[var(--color-text-secondary)]',
|
||||||
|
primary: 'bg-[var(--color-primary-subtle)] text-[var(--color-primary-text)]',
|
||||||
|
secondary: 'bg-[var(--color-secondary-subtle)] text-[var(--color-secondary-text)]',
|
||||||
|
accent: 'bg-[var(--color-accent-subtle)] text-[var(--color-accent-text)]',
|
||||||
|
success: 'bg-green-50 text-green-700 dark:bg-green-500/15 dark:text-green-400',
|
||||||
|
warning: 'bg-amber-50 text-amber-700 dark:bg-amber-500/15 dark:text-amber-400',
|
||||||
|
danger: 'bg-red-50 text-red-700 dark:bg-red-500/15 dark:text-red-400',
|
||||||
|
info: 'bg-blue-50 text-blue-700 dark:bg-blue-500/15 dark:text-blue-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeStyles = {
|
||||||
|
sm: 'px-1.5 py-0.5 text-[10px]',
|
||||||
|
md: 'px-2 py-0.5 text-xs',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Badge = ({ variant = 'default', size = 'md', className, children }: BadgeProps) => (
|
||||||
|
<span className={cn('inline-flex items-center rounded-full font-medium', variantStyles[variant], sizeStyles[size], className)}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Badge;
|
||||||
55
frontend/src/components/ui/Button.tsx
Normal file
55
frontend/src/components/ui/Button.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { forwardRef } from 'react';
|
||||||
|
import { cn } from '../../utils/cn';
|
||||||
|
|
||||||
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'accent' | 'outline' | 'ghost' | 'danger';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantStyles = {
|
||||||
|
primary:
|
||||||
|
'bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary-hover)] active:bg-[var(--color-primary-active)]' +
|
||||||
|
' dark:bg-[var(--color-primary-600)] dark:hover:bg-[var(--color-primary-500)]',
|
||||||
|
secondary:
|
||||||
|
'bg-[var(--color-secondary)] text-[var(--color-secondary-text)] hover:bg-[var(--color-secondary-hover)] active:bg-[var(--color-secondary-active)]' +
|
||||||
|
' dark:bg-[var(--color-secondary-700)] dark:text-white dark:hover:bg-[var(--color-secondary-600)]',
|
||||||
|
accent:
|
||||||
|
'bg-[var(--color-accent)] text-[var(--color-accent-text)] hover:bg-[var(--color-accent-hover)] active:bg-[var(--color-accent-active)]' +
|
||||||
|
' dark:bg-[var(--color-accent-600)] dark:text-white dark:hover:bg-[var(--color-accent-500)]',
|
||||||
|
outline:
|
||||||
|
'border border-[var(--color-border-strong)] text-[var(--color-text-primary)] hover:bg-[var(--color-primary-subtle)] bg-transparent' +
|
||||||
|
' dark:border-[var(--color-primary-400)] dark:text-[var(--color-primary-300)] dark:hover:bg-[var(--color-primary-600)]/15',
|
||||||
|
ghost:
|
||||||
|
'text-[var(--color-text-secondary)] hover:bg-[var(--color-primary-subtle)] bg-transparent' +
|
||||||
|
' dark:text-[var(--color-primary-300)] dark:hover:bg-[var(--color-primary-600)]/12',
|
||||||
|
danger:
|
||||||
|
'bg-[var(--color-danger)] text-white hover:opacity-90' +
|
||||||
|
' dark:bg-red-500 dark:hover:bg-red-600 dark:hover:opacity-100',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeStyles = {
|
||||||
|
sm: 'h-8 px-3 text-xs gap-1.5',
|
||||||
|
md: 'h-10 px-4 text-sm gap-2',
|
||||||
|
lg: 'h-12 px-6 text-base gap-2',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ variant = 'primary', size = 'md', className, disabled, children, ...props }, ref) => (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/30',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
variantStyles[variant],
|
||||||
|
sizeStyles[size],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
export default Button;
|
||||||
13
frontend/src/constants/chart.ts
Normal file
13
frontend/src/constants/chart.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
export const CHART_COLORS = [
|
||||||
|
'var(--color-chart-1)',
|
||||||
|
'var(--color-chart-2)',
|
||||||
|
'var(--color-chart-3)',
|
||||||
|
'var(--color-chart-4)',
|
||||||
|
'var(--color-chart-5)',
|
||||||
|
'var(--color-chart-6)',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const CHART_COLORS_HEX = {
|
||||||
|
light: ['#507FB9', '#B59854', '#4E88BB', '#9A7E3E', '#3B669C', '#C4B48C'],
|
||||||
|
dark: ['#6D94C5', '#C4B48C', '#6B9AC8', '#B59854', '#8AA5C7', '#D5CDB9'],
|
||||||
|
} as const;
|
||||||
@ -2,6 +2,174 @@
|
|||||||
|
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* === Brand Scales (고정, 테마 불변) === */
|
||||||
|
|
||||||
|
/* Primary (Slate Blue) 50~950 */
|
||||||
|
--color-primary-50: #F5F7F9;
|
||||||
|
--color-primary-100: #E5EBF2;
|
||||||
|
--color-primary-200: #D2DAE5;
|
||||||
|
--color-primary-300: #B7C5D7;
|
||||||
|
--color-primary-400: #8AA5C7;
|
||||||
|
--color-primary-500: #6D94C5;
|
||||||
|
--color-primary-600: #507FB9;
|
||||||
|
--color-primary-700: #3B669C;
|
||||||
|
--color-primary-800: #2D507B;
|
||||||
|
--color-primary-900: #1B2C41;
|
||||||
|
--color-primary-950: #0E1A2B;
|
||||||
|
|
||||||
|
/* Secondary (Powder Blue) 50~950 */
|
||||||
|
--color-secondary-50: #F5F7F9;
|
||||||
|
--color-secondary-100: #E6EDF5;
|
||||||
|
--color-secondary-200: #D1DAE5;
|
||||||
|
--color-secondary-300: #B7C8D7;
|
||||||
|
--color-secondary-400: #89AAC8;
|
||||||
|
--color-secondary-500: #CBDCEB;
|
||||||
|
--color-secondary-600: #6B9AC8;
|
||||||
|
--color-secondary-700: #4E88BB;
|
||||||
|
--color-secondary-800: #396E9D;
|
||||||
|
--color-secondary-900: #1B2F41;
|
||||||
|
--color-secondary-950: #0E1A2B;
|
||||||
|
|
||||||
|
/* Accent (Warm Sand) 50~950 */
|
||||||
|
--color-accent-50: #F9F8F6;
|
||||||
|
--color-accent-100: #F2EDE4;
|
||||||
|
--color-accent-200: #E4E0D7;
|
||||||
|
--color-accent-300: #D5CDB9;
|
||||||
|
--color-accent-400: #C4B48C;
|
||||||
|
--color-accent-500: #E8DFCA;
|
||||||
|
--color-accent-600: #B59854;
|
||||||
|
--color-accent-700: #9A7E3E;
|
||||||
|
--color-accent-800: #786230;
|
||||||
|
--color-accent-900: #3F351D;
|
||||||
|
--color-accent-950: #2B2312;
|
||||||
|
|
||||||
|
/* Chart Palette (고정) */
|
||||||
|
--color-chart-1: #507FB9;
|
||||||
|
--color-chart-2: #B59854;
|
||||||
|
--color-chart-3: #4E88BB;
|
||||||
|
--color-chart-4: #9A7E3E;
|
||||||
|
--color-chart-5: #3B669C;
|
||||||
|
--color-chart-6: #C4B48C;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-sans: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', Consolas, Monaco, 'Courier New', monospace;
|
||||||
|
|
||||||
|
/* === 테마 전환 변수 (Light 기본값) === */
|
||||||
|
--color-success: #059669;
|
||||||
|
--color-warning: #D97706;
|
||||||
|
--color-danger: #DC2626;
|
||||||
|
--color-info: #0284C7;
|
||||||
|
|
||||||
|
--color-bg-base: #F5EFE6;
|
||||||
|
--color-bg-surface: #FFFFFF;
|
||||||
|
--color-bg-elevated: #FFFFFF;
|
||||||
|
--color-border: #D5D2CC;
|
||||||
|
--color-border-strong: #9198A1;
|
||||||
|
--color-text-primary: #1E2329;
|
||||||
|
--color-text-secondary: #5C6570;
|
||||||
|
--color-text-tertiary: #9198A1;
|
||||||
|
|
||||||
|
--color-primary: #507FB9;
|
||||||
|
--color-primary-hover: #3B669C;
|
||||||
|
--color-primary-active: #2D507B;
|
||||||
|
--color-primary-subtle: #F5F7F9;
|
||||||
|
--color-primary-text: #2D507B;
|
||||||
|
--color-secondary: #CBDCEB;
|
||||||
|
--color-secondary-hover: #89AAC8;
|
||||||
|
--color-secondary-active: #4E88BB;
|
||||||
|
--color-secondary-subtle: #F5F7F9;
|
||||||
|
--color-secondary-text: #396E9D;
|
||||||
|
--color-accent: #E8DFCA;
|
||||||
|
--color-accent-hover: #C4B48C;
|
||||||
|
--color-accent-active: #B59854;
|
||||||
|
--color-accent-subtle: #F9F8F6;
|
||||||
|
--color-accent-text: #786230;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Dark Theme (.dark 클래스 기반) === */
|
||||||
|
.dark {
|
||||||
|
--color-success: #34D399;
|
||||||
|
--color-warning: #FBBF24;
|
||||||
|
--color-danger: #FCA5A5;
|
||||||
|
--color-info: #38BDF8;
|
||||||
|
|
||||||
|
--color-bg-base: #131416;
|
||||||
|
--color-bg-surface: #1E2023;
|
||||||
|
--color-bg-elevated: #282A2E;
|
||||||
|
--color-border: #3A3C41;
|
||||||
|
--color-border-strong: #5C6570;
|
||||||
|
--color-text-primary: #F4F5F5;
|
||||||
|
--color-text-secondary: #9198A1;
|
||||||
|
--color-text-tertiary: #5C6570;
|
||||||
|
|
||||||
|
--color-primary: #B7C5D7;
|
||||||
|
--color-primary-hover: #D2DAE5;
|
||||||
|
--color-primary-active: #8AA5C7;
|
||||||
|
--color-primary-subtle: #1B2C41;
|
||||||
|
--color-primary-text: #D2DAE5;
|
||||||
|
--color-secondary: #B7C8D7;
|
||||||
|
--color-secondary-hover: #D1DAE5;
|
||||||
|
--color-secondary-active: #89AAC8;
|
||||||
|
--color-secondary-subtle: #1B2F41;
|
||||||
|
--color-secondary-text: #D1DAE5;
|
||||||
|
--color-accent: #D5CDB9;
|
||||||
|
--color-accent-hover: #E4E0D7;
|
||||||
|
--color-accent-active: #C4B48C;
|
||||||
|
--color-accent-subtle: #3F351D;
|
||||||
|
--color-accent-text: #E4E0D7;
|
||||||
|
|
||||||
|
--color-chart-1: #6D94C5;
|
||||||
|
--color-chart-2: #C4B48C;
|
||||||
|
--color-chart-3: #6B9AC8;
|
||||||
|
--color-chart-4: #B59854;
|
||||||
|
--color-chart-5: #8AA5C7;
|
||||||
|
--color-chart-6: #D5CDB9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Dark Theme (OS 기본 설정 기반) === */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not(.light) {
|
||||||
|
--color-success: #34D399;
|
||||||
|
--color-warning: #FBBF24;
|
||||||
|
--color-danger: #FCA5A5;
|
||||||
|
--color-info: #38BDF8;
|
||||||
|
|
||||||
|
--color-bg-base: #131416;
|
||||||
|
--color-bg-surface: #1E2023;
|
||||||
|
--color-bg-elevated: #282A2E;
|
||||||
|
--color-border: #3A3C41;
|
||||||
|
--color-border-strong: #5C6570;
|
||||||
|
--color-text-primary: #F4F5F5;
|
||||||
|
--color-text-secondary: #9198A1;
|
||||||
|
--color-text-tertiary: #5C6570;
|
||||||
|
|
||||||
|
--color-primary: #B7C5D7;
|
||||||
|
--color-primary-hover: #D2DAE5;
|
||||||
|
--color-primary-active: #8AA5C7;
|
||||||
|
--color-primary-subtle: #1B2C41;
|
||||||
|
--color-primary-text: #D2DAE5;
|
||||||
|
--color-secondary: #B7C8D7;
|
||||||
|
--color-secondary-hover: #D1DAE5;
|
||||||
|
--color-secondary-active: #89AAC8;
|
||||||
|
--color-secondary-subtle: #1B2F41;
|
||||||
|
--color-secondary-text: #D1DAE5;
|
||||||
|
--color-accent: #D5CDB9;
|
||||||
|
--color-accent-hover: #E4E0D7;
|
||||||
|
--color-accent-active: #C4B48C;
|
||||||
|
--color-accent-subtle: #3F351D;
|
||||||
|
--color-accent-text: #E4E0D7;
|
||||||
|
|
||||||
|
--color-chart-1: #6D94C5;
|
||||||
|
--color-chart-2: #C4B48C;
|
||||||
|
--color-chart-3: #6B9AC8;
|
||||||
|
--color-chart-4: #B59854;
|
||||||
|
--color-chart-5: #8AA5C7;
|
||||||
|
--color-chart-6: #D5CDB9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Dark mode date input calendar icon */
|
/* Dark mode date input calendar icon */
|
||||||
.dark input[type="date"]::-webkit-calendar-picker-indicator {
|
.dark input[type="date"]::-webkit-calendar-picker-indicator {
|
||||||
filter: invert(1);
|
filter: invert(1);
|
||||||
|
|||||||
@ -11,8 +11,8 @@ import {
|
|||||||
getSummary, getHourlyTrend, getServiceRatio,
|
getSummary, getHourlyTrend, getServiceRatio,
|
||||||
getErrorTrend, getTopApis, getRecentLogs, getHeartbeat,
|
getErrorTrend, getTopApis, getRecentLogs, getHeartbeat,
|
||||||
} from '../services/dashboardService';
|
} from '../services/dashboardService';
|
||||||
|
import { CHART_COLORS_HEX } from '../constants/chart';
|
||||||
const PIE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
|
import { useTheme } from '../hooks/useTheme';
|
||||||
|
|
||||||
const SERVICE_TAG_STYLES = [
|
const SERVICE_TAG_STYLES = [
|
||||||
'bg-blue-100 text-blue-700',
|
'bg-blue-100 text-blue-700',
|
||||||
@ -48,6 +48,8 @@ const truncate = (str: string, max: number): string => {
|
|||||||
|
|
||||||
const DashboardPage = () => {
|
const DashboardPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const chartColors = CHART_COLORS_HEX[theme];
|
||||||
|
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
const [heartbeat, setHeartbeat] = useState<HeartbeatStatus[]>([]);
|
const [heartbeat, setHeartbeat] = useState<HeartbeatStatus[]>([]);
|
||||||
@ -112,16 +114,16 @@ const DashboardPage = () => {
|
|||||||
serviceNames.forEach((name, i) => {
|
serviceNames.forEach((name, i) => {
|
||||||
map[name] = {
|
map[name] = {
|
||||||
tag: SERVICE_TAG_STYLES[i % SERVICE_TAG_STYLES.length],
|
tag: SERVICE_TAG_STYLES[i % SERVICE_TAG_STYLES.length],
|
||||||
bar: PIE_COLORS[i % PIE_COLORS.length],
|
bar: chartColors[i % chartColors.length],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return map;
|
return map;
|
||||||
}, [topApis]);
|
}, [topApis, chartColors]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="text-gray-500 dark:text-gray-400">Loading...</div>
|
<div className="text-[var(--color-text-secondary)]">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -130,35 +132,35 @@ const DashboardPage = () => {
|
|||||||
<div>
|
<div>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Dashboard</h1>
|
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">Dashboard</h1>
|
||||||
{lastUpdated && (
|
{lastUpdated && (
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">마지막 갱신: {lastUpdated}</span>
|
<span className="text-sm text-[var(--color-text-secondary)]">마지막 갱신: {lastUpdated}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 1: Summary Cards */}
|
{/* Row 1: Summary Cards */}
|
||||||
{stats && (
|
{stats && (
|
||||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">오늘 총 요청</p>
|
<p className="text-sm text-[var(--color-text-secondary)]">오늘 총 요청</p>
|
||||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{stats.totalRequests.toLocaleString()}</p>
|
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{stats.totalRequests.toLocaleString()}</p>
|
||||||
<p className={`text-sm ${stats.changePercent > 0 ? 'text-green-600' : stats.changePercent < 0 ? 'text-red-600' : 'text-gray-500 dark:text-gray-400'}`}>
|
<p className={`text-sm ${stats.changePercent > 0 ? 'text-green-600' : stats.changePercent < 0 ? 'text-red-600' : 'text-[var(--color-text-secondary)]'}`}>
|
||||||
{stats.changePercent > 0 ? '\u25B2' : stats.changePercent < 0 ? '\u25BC' : ''} 전일 대비 {stats.changePercent.toFixed(2)}%
|
{stats.changePercent > 0 ? '\u25B2' : stats.changePercent < 0 ? '\u25BC' : ''} 전일 대비 {stats.changePercent.toFixed(2)}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">성공률</p>
|
<p className="text-sm text-[var(--color-text-secondary)]">성공률</p>
|
||||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{stats.successRate.toFixed(1)}%</p>
|
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{stats.successRate.toFixed(1)}%</p>
|
||||||
<p className="text-sm text-red-500">실패 {stats.failureCount}건</p>
|
<p className="text-sm text-red-500">실패 {stats.failureCount}건</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">평균 응답 시간</p>
|
<p className="text-sm text-[var(--color-text-secondary)]">평균 응답 시간</p>
|
||||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{stats.avgResponseTime.toFixed(0)}ms</p>
|
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{stats.avgResponseTime.toFixed(0)}ms</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">API 요청 사용자</p>
|
<p className="text-sm text-[var(--color-text-secondary)]">API 요청 사용자</p>
|
||||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{stats.activeUserCount}</p>
|
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{stats.activeUserCount}</p>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">오늘</p>
|
<p className="text-sm text-[var(--color-text-secondary)]">오늘</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -175,7 +177,7 @@ const DashboardPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={svc.serviceId}
|
key={svc.serviceId}
|
||||||
className={`flex-1 bg-white dark:bg-gray-800 rounded-lg shadow p-4 border-l-4 ${borderColor} cursor-pointer hover:shadow-md transition-shadow`}
|
className={`flex-1 bg-[var(--color-bg-surface)] rounded-lg shadow p-4 border-l-4 ${borderColor} cursor-pointer hover:shadow-md transition-shadow`}
|
||||||
onClick={() => navigate('/monitoring/service-status')}
|
onClick={() => navigate('/monitoring/service-status')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
@ -184,14 +186,14 @@ const DashboardPage = () => {
|
|||||||
isUp ? 'bg-green-500' : isDown ? 'bg-red-500' : 'bg-gray-400'
|
isUp ? 'bg-green-500' : isDown ? 'bg-red-500' : 'bg-gray-400'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<span className="font-medium text-gray-900 dark:text-gray-100">{svc.serviceName}</span>
|
<span className="font-medium text-[var(--color-text-primary)]">{svc.serviceName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className={`font-medium ${isUp ? 'text-green-600' : isDown ? 'text-red-600' : 'text-gray-500 dark:text-gray-400'}`}>
|
<span className={`font-medium ${isUp ? 'text-green-600' : isDown ? 'text-red-600' : 'text-[var(--color-text-secondary)]'}`}>
|
||||||
{isUp ? 'Operational' : isDown ? 'Down' : 'Unknown'}
|
{isUp ? 'Operational' : isDown ? 'Down' : 'Unknown'}
|
||||||
</span>
|
</span>
|
||||||
{svc.healthCheckedAt && (
|
{svc.healthCheckedAt && (
|
||||||
<span className="text-gray-400 dark:text-gray-500 text-xs">{svc.healthCheckedAt}</span>
|
<span className="text-[var(--color-text-tertiary)] text-xs">{svc.healthCheckedAt}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -199,8 +201,8 @@ const DashboardPage = () => {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-4">
|
||||||
<p className="text-gray-500 dark:text-gray-400 text-sm">등록된 서비스가 없습니다</p>
|
<p className="text-[var(--color-text-secondary)] text-sm">등록된 서비스가 없습니다</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -208,8 +210,8 @@ const DashboardPage = () => {
|
|||||||
{/* Row 3: Charts 2x2 */}
|
{/* Row 3: Charts 2x2 */}
|
||||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||||
{/* Chart 1: Hourly Trend */}
|
{/* Chart 1: Hourly Trend */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">시간별 요청 추이</h3>
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">시간별 요청 추이</h3>
|
||||||
{hourlyTrend.length > 0 ? (
|
{hourlyTrend.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<LineChart data={hourlyTrend}>
|
<LineChart data={hourlyTrend}>
|
||||||
@ -218,18 +220,18 @@ const DashboardPage = () => {
|
|||||||
<YAxis />
|
<YAxis />
|
||||||
<Tooltip labelFormatter={(h) => `${h}시`} />
|
<Tooltip labelFormatter={(h) => `${h}시`} />
|
||||||
<Legend />
|
<Legend />
|
||||||
<Line type="monotone" dataKey="successCount" stroke="#3b82f6" name="성공" />
|
<Line type="monotone" dataKey="successCount" stroke={chartColors[0]} name="성공" />
|
||||||
<Line type="monotone" dataKey="failureCount" stroke="#ef4444" name="실패" />
|
<Line type="monotone" dataKey="failureCount" stroke="#ef4444" name="실패" />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-400 dark:text-gray-500 text-center py-20">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart 2: Service Ratio */}
|
{/* Chart 2: Service Ratio */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">서비스별 요청 비율</h3>
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">서비스별 요청 비율</h3>
|
||||||
{serviceRatio.length > 0 ? (
|
{serviceRatio.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
@ -241,7 +243,7 @@ const DashboardPage = () => {
|
|||||||
outerRadius={100}
|
outerRadius={100}
|
||||||
>
|
>
|
||||||
{serviceRatio.map((_, idx) => (
|
{serviceRatio.map((_, idx) => (
|
||||||
<Cell key={idx} fill={PIE_COLORS[idx % PIE_COLORS.length]} />
|
<Cell key={idx} fill={chartColors[idx % chartColors.length]} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
@ -249,13 +251,13 @@ const DashboardPage = () => {
|
|||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-400 dark:text-gray-500 text-center py-20">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart 3: Error Trend */}
|
{/* Chart 3: Error Trend */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">에러율 추이</h3>
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">에러율 추이</h3>
|
||||||
{errorTrendPivoted.data.length > 0 ? (
|
{errorTrendPivoted.data.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<AreaChart data={errorTrendPivoted.data}>
|
<AreaChart data={errorTrendPivoted.data}>
|
||||||
@ -270,83 +272,83 @@ const DashboardPage = () => {
|
|||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey={name}
|
dataKey={name}
|
||||||
stackId="1"
|
stackId="1"
|
||||||
stroke={PIE_COLORS[idx % PIE_COLORS.length]}
|
stroke={chartColors[idx % chartColors.length]}
|
||||||
fill={PIE_COLORS[idx % PIE_COLORS.length]}
|
fill={chartColors[idx % chartColors.length]}
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-400 dark:text-gray-500 text-center py-20">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart 4: Top APIs */}
|
{/* Chart 4: Top APIs */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">상위 호출 API</h3>
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">상위 호출 API</h3>
|
||||||
{topApis.length > 0 ? (
|
{topApis.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{topApis.map((api, idx) => {
|
{topApis.map((api, idx) => {
|
||||||
const maxCount = topApis[0]?.count || 1;
|
const maxCount = topApis[0]?.count || 1;
|
||||||
const pct = (api.count / maxCount) * 100;
|
const pct = (api.count / maxCount) * 100;
|
||||||
const colors = topApiServiceColorMap[api.serviceName] || { tag: SERVICE_TAG_STYLES[0], bar: PIE_COLORS[0] };
|
const colors = topApiServiceColorMap[api.serviceName] || { tag: SERVICE_TAG_STYLES[0], bar: chartColors[0] };
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="flex items-center gap-3">
|
<div key={idx} className="flex items-center gap-3">
|
||||||
<span className="text-xs text-gray-400 w-5 text-right">{idx + 1}</span>
|
<span className="text-xs text-[var(--color-text-tertiary)] w-5 text-right">{idx + 1}</span>
|
||||||
<span className={`shrink-0 px-1.5 py-0.5 rounded text-xs font-medium ${colors.tag}`}>
|
<span className={`shrink-0 px-1.5 py-0.5 rounded text-xs font-medium ${colors.tag}`}>
|
||||||
{api.serviceName}
|
{api.serviceName}
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0 text-sm text-gray-900 dark:text-gray-100 w-48 truncate" title={api.apiName}>
|
<span className="shrink-0 text-sm text-[var(--color-text-primary)] w-48 truncate" title={api.apiName}>
|
||||||
{api.apiName}
|
{api.apiName}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 bg-gray-100 dark:bg-gray-700 rounded-full h-5 relative">
|
<div className="flex-1 bg-[var(--color-bg-base)] rounded-full h-5 relative">
|
||||||
<div
|
<div
|
||||||
className="h-5 rounded-full"
|
className="h-5 rounded-full"
|
||||||
style={{ width: `${pct}%`, backgroundColor: colors.bar }}
|
style={{ width: `${pct}%`, backgroundColor: colors.bar }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 w-12 text-right">{api.count}</span>
|
<span className="text-sm font-medium text-[var(--color-text-primary)] w-12 text-right">{api.count}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-400 dark:text-gray-500 text-center py-20">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 4: Recent Logs */}
|
{/* Row 4: Recent Logs */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow mb-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow mb-6">
|
||||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
<div className="p-6 border-b border-[var(--color-border)]">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">최근 요청 로그</h3>
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">최근 요청 로그</h3>
|
||||||
</div>
|
</div>
|
||||||
{recentLogs.length > 0 ? (
|
{recentLogs.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">시간</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">시간</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">서비스</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">서비스</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">사용자</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">사용자</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">URL</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">URL</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">상태</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">상태</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">응답시간</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">응답시간</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{recentLogs.slice(0, 5).map((log) => (
|
{recentLogs.slice(0, 5).map((log) => (
|
||||||
<tr
|
<tr
|
||||||
key={log.logId}
|
key={log.logId}
|
||||||
className="hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
|
className="hover:bg-[var(--color-bg-base)] cursor-pointer"
|
||||||
onClick={() => navigate(`/monitoring/request-logs/${log.logId}`)}
|
onClick={() => navigate(`/monitoring/request-logs/${log.logId}`)}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400 whitespace-nowrap">{log.requestedAt}</td>
|
<td className="px-4 py-3 text-[var(--color-text-tertiary)] whitespace-nowrap">{log.requestedAt}</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{log.serviceName ?? '-'}</td>
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">{log.serviceName ?? '-'}</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{log.userName ?? '-'}</td>
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">{log.userName ?? '-'}</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100" title={log.requestUrl}>
|
<td className="px-4 py-3 text-[var(--color-text-primary)]" title={log.requestUrl}>
|
||||||
{truncate(log.requestUrl, 40)}
|
{truncate(log.requestUrl, 40)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
@ -354,7 +356,7 @@ const DashboardPage = () => {
|
|||||||
{log.requestStatus}
|
{log.requestStatus}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
|
<td className="px-4 py-3 text-[var(--color-text-tertiary)]">
|
||||||
{log.responseTime !== null ? `${log.responseTime}ms` : '-'}
|
{log.responseTime !== null ? `${log.responseTime}ms` : '-'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -362,17 +364,17 @@ const DashboardPage = () => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-3 border-t border-gray-200 dark:border-gray-700 text-center">
|
<div className="px-6 py-3 border-t border-[var(--color-border)] text-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/monitoring/request-logs')}
|
onClick={() => navigate('/monitoring/request-logs')}
|
||||||
className="text-sm text-blue-600 hover:text-blue-800 font-medium"
|
className="text-sm text-[var(--color-primary)] hover:text-[var(--color-primary-hover)] font-medium"
|
||||||
>
|
>
|
||||||
더보기 →
|
더보기 →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-400 dark:text-gray-500 text-center py-8">요청 로그가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-8">요청 로그가 없습니다</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,16 +1,15 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import Button from '../components/ui/Button';
|
||||||
|
|
||||||
const NotFoundPage = () => {
|
const NotFoundPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[60vh] flex-col items-center justify-center text-center">
|
<div className="flex min-h-[60vh] flex-col items-center justify-center text-center">
|
||||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100">404 - Page Not Found</h1>
|
<h1 className="text-4xl font-bold text-[var(--color-text-primary)]">404 - Page Not Found</h1>
|
||||||
<p className="mt-3 text-gray-600 dark:text-gray-400">요청하신 페이지를 찾을 수 없습니다.</p>
|
<p className="mt-3 text-[var(--color-text-secondary)]">요청하신 페이지를 찾을 수 없습니다.</p>
|
||||||
<Link
|
<Button className="mt-6" onClick={() => navigate('/dashboard')}>
|
||||||
to="/dashboard"
|
|
||||||
className="mt-6 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
Dashboard로 이동
|
Dashboard로 이동
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -17,21 +17,23 @@ import {
|
|||||||
getDomains,
|
getDomains,
|
||||||
} from '../../services/serviceService';
|
} from '../../services/serviceService';
|
||||||
import type { ApiDomainInfo } from '../../types/apihub';
|
import type { ApiDomainInfo } from '../../types/apihub';
|
||||||
|
import Badge from '../../components/ui/Badge';
|
||||||
|
import Button from '../../components/ui/Button';
|
||||||
|
|
||||||
const METHOD_COLOR: Record<string, string> = {
|
const METHOD_CLASS: Record<string, string> = {
|
||||||
GET: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
GET: 'bg-green-50 text-green-700 dark:bg-green-500/15 dark:text-green-400',
|
||||||
POST: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
POST: 'bg-blue-50 text-blue-700 dark:bg-blue-500/15 dark:text-blue-400',
|
||||||
PUT: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
|
PUT: 'bg-amber-50 text-amber-700 dark:bg-amber-500/15 dark:text-amber-400',
|
||||||
DELETE: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
|
DELETE: 'bg-red-50 text-red-700 dark:bg-red-500/15 dark:text-red-400',
|
||||||
};
|
};
|
||||||
|
|
||||||
const INPUT_CLS =
|
const INPUT_CLS =
|
||||||
'w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none text-sm';
|
'w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none text-sm';
|
||||||
|
|
||||||
const TABLE_INPUT_CLS =
|
const TABLE_INPUT_CLS =
|
||||||
'w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none';
|
'w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none';
|
||||||
|
|
||||||
const LABEL_CLS = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1';
|
const LABEL_CLS = 'block text-sm font-medium text-[var(--color-text-primary)] mb-1';
|
||||||
|
|
||||||
const ApiEditPage = () => {
|
const ApiEditPage = () => {
|
||||||
const { serviceId: serviceIdStr, apiId: apiIdStr } = useParams<{
|
const { serviceId: serviceIdStr, apiId: apiIdStr } = useParams<{
|
||||||
@ -318,7 +320,7 @@ const ApiEditPage = () => {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
<div className="w-8 h-8 border-4 border-[var(--color-primary)] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -327,10 +329,10 @@ const ApiEditPage = () => {
|
|||||||
if (notFound) {
|
if (notFound) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto text-center py-20">
|
<div className="max-w-5xl mx-auto text-center py-20">
|
||||||
<p className="text-gray-500 dark:text-gray-400 text-lg">API를 찾을 수 없습니다.</p>
|
<p className="text-[var(--color-text-secondary)] text-lg">API를 찾을 수 없습니다.</p>
|
||||||
<Link
|
<Link
|
||||||
to="/admin/apis"
|
to="/admin/apis"
|
||||||
className="mt-4 inline-block text-blue-600 dark:text-blue-400 hover:underline text-sm"
|
className="mt-4 inline-block text-[var(--color-primary)] hover:underline text-sm"
|
||||||
>
|
>
|
||||||
API 목록으로 돌아가기
|
API 목록으로 돌아가기
|
||||||
</Link>
|
</Link>
|
||||||
@ -342,12 +344,12 @@ const ApiEditPage = () => {
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto py-10">
|
<div className="max-w-5xl mx-auto py-10">
|
||||||
<div className="p-4 bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-lg text-sm">
|
<div className="p-4 bg-red-50 text-red-700 rounded-lg text-sm">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
to="/admin/apis"
|
to="/admin/apis"
|
||||||
className="mt-4 inline-block text-blue-600 dark:text-blue-400 hover:underline text-sm"
|
className="mt-4 inline-block text-[var(--color-primary)] hover:underline text-sm"
|
||||||
>
|
>
|
||||||
API 목록으로 돌아가기
|
API 목록으로 돌아가기
|
||||||
</Link>
|
</Link>
|
||||||
@ -358,48 +360,37 @@ const ApiEditPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<nav className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
|
<nav className="flex items-center gap-2 text-sm text-[var(--color-text-secondary)] mb-4">
|
||||||
<Link
|
<Link
|
||||||
to="/admin/apis"
|
to="/admin/apis"
|
||||||
className="hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
className="hover:text-[var(--color-primary)] transition-colors"
|
||||||
>
|
>
|
||||||
API 관리
|
API 관리
|
||||||
</Link>
|
</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-gray-700 dark:text-gray-300">{serviceName || `서비스 #${serviceId}`}</span>
|
<span className="text-[var(--color-text-primary)]">{serviceName || `서비스 #${serviceId}`}</span>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-gray-900 dark:text-gray-100 font-medium">{apiName || `API #${apiId}`}</span>
|
<span className="text-[var(--color-text-primary)] font-medium">{apiName || `API #${apiId}`}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between mb-2">
|
<div className="flex items-start justify-between mb-2">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{apiName}</h1>
|
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">{apiName}</h1>
|
||||||
<div className="flex items-center gap-2 mt-1.5">
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
<span
|
<Badge className={METHOD_CLASS[apiMethod] ?? 'bg-[var(--color-bg-base)] text-[var(--color-text-primary)]'}>
|
||||||
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
|
|
||||||
METHOD_COLOR[apiMethod] ?? 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{apiMethod}
|
{apiMethod}
|
||||||
</span>
|
</Badge>
|
||||||
<span className="font-mono text-sm text-gray-600 dark:text-gray-400">{apiPath}</span>
|
<span className="font-mono text-sm text-[var(--color-text-tertiary)]">{apiPath}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<button
|
<Button onClick={handleDelete} variant="danger">
|
||||||
onClick={handleDelete}
|
|
||||||
className="px-4 py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
삭제
|
삭제
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button onClick={handleSave} disabled={saving} variant="primary">
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving}
|
|
||||||
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
{saving ? '저장 중...' : '저장'}
|
{saving ? '저장 중...' : '저장'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -408,8 +399,8 @@ const ApiEditPage = () => {
|
|||||||
<div
|
<div
|
||||||
className={`mb-4 p-3 rounded-lg text-sm ${
|
className={`mb-4 p-3 rounded-lg text-sm ${
|
||||||
saveMessage.type === 'success'
|
saveMessage.type === 'success'
|
||||||
? 'bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
? 'bg-green-50 text-green-700'
|
||||||
: 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400'
|
: 'bg-red-50 text-red-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{saveMessage.text}
|
{saveMessage.text}
|
||||||
@ -419,8 +410,8 @@ const ApiEditPage = () => {
|
|||||||
{/* Sections */}
|
{/* Sections */}
|
||||||
<div className="space-y-6 mt-6">
|
<div className="space-y-6 mt-6">
|
||||||
{/* Section 1: 기본 정보 */}
|
{/* Section 1: 기본 정보 */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-4">기본 정보</h2>
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-4">기본 정보</h2>
|
||||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<label className={LABEL_CLS}>Method</label>
|
<label className={LABEL_CLS}>Method</label>
|
||||||
@ -483,9 +474,9 @@ const ApiEditPage = () => {
|
|||||||
id="apiIsActive"
|
id="apiIsActive"
|
||||||
checked={apiIsActive}
|
checked={apiIsActive}
|
||||||
onChange={(e) => setApiIsActive(e.target.checked)}
|
onChange={(e) => setApiIsActive(e.target.checked)}
|
||||||
className="w-4 h-4 text-blue-600 rounded border-gray-300 dark:border-gray-600 focus:ring-blue-500"
|
className="w-4 h-4 text-[var(--color-primary)] rounded border-[var(--color-border-strong)] focus:ring-[var(--color-primary)]"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="apiIsActive" className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label htmlFor="apiIsActive" className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||||
Active
|
Active
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@ -503,8 +494,8 @@ const ApiEditPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section 2: API 명세 */}
|
{/* Section 2: API 명세 */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-4">API 명세</h2>
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-4">API 명세</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className={LABEL_CLS}>샘플 URL</label>
|
<label className={LABEL_CLS}>샘플 URL</label>
|
||||||
@ -524,9 +515,9 @@ const ApiEditPage = () => {
|
|||||||
id="authRequired"
|
id="authRequired"
|
||||||
checked={authRequired}
|
checked={authRequired}
|
||||||
onChange={(e) => setAuthRequired(e.target.checked)}
|
onChange={(e) => setAuthRequired(e.target.checked)}
|
||||||
className="w-4 h-4 text-blue-600 rounded border-gray-300 dark:border-gray-600 focus:ring-blue-500"
|
className="w-4 h-4 text-[var(--color-primary)] rounded border-[var(--color-border-strong)] focus:ring-[var(--color-primary)]"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="authRequired" className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label htmlFor="authRequired" className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||||
인증 필요
|
인증 필요
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@ -551,9 +542,9 @@ const ApiEditPage = () => {
|
|||||||
id="deprecated"
|
id="deprecated"
|
||||||
checked={deprecated}
|
checked={deprecated}
|
||||||
onChange={(e) => setDeprecated(e.target.checked)}
|
onChange={(e) => setDeprecated(e.target.checked)}
|
||||||
className="w-4 h-4 text-blue-600 rounded border-gray-300 dark:border-gray-600 focus:ring-blue-500"
|
className="w-4 h-4 text-[var(--color-primary)] rounded border-[var(--color-border-strong)] focus:ring-[var(--color-primary)]"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="deprecated" className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label htmlFor="deprecated" className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||||
Deprecated
|
Deprecated
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@ -593,36 +584,32 @@ const ApiEditPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section 3: 요청인자 */}
|
{/* Section 3: 요청인자 */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">요청인자</h2>
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">요청인자</h2>
|
||||||
<button
|
<Button type="button" onClick={() => addParam()} variant="secondary" size="sm">
|
||||||
type="button"
|
|
||||||
onClick={() => addParam()}
|
|
||||||
className="px-3 py-1.5 rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
행 추가
|
행 추가
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
<tr className="border-b border-[var(--color-border)]">
|
||||||
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 w-10">#</th>
|
<th className="px-2 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] w-10">#</th>
|
||||||
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">인자명</th>
|
<th className="px-2 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)]">인자명</th>
|
||||||
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">의미</th>
|
<th className="px-2 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)]">의미</th>
|
||||||
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">설명</th>
|
<th className="px-2 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)]">설명</th>
|
||||||
<th className="px-2 py-2 text-center text-xs font-medium text-gray-500 dark:text-gray-400 w-14">필수</th>
|
<th className="px-2 py-2 text-center text-xs font-medium text-[var(--color-text-secondary)] w-14">필수</th>
|
||||||
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 w-28">입력유형</th>
|
<th className="px-2 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] w-28">입력유형</th>
|
||||||
<th className="px-2 py-2 w-10" />
|
<th className="px-2 py-2 w-10" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{requestParams.length === 0 ? (
|
{requestParams.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={7}
|
colSpan={7}
|
||||||
className="px-2 py-6 text-center text-gray-400 dark:text-gray-500 text-sm"
|
className="px-2 py-6 text-center text-[var(--color-text-tertiary)] text-sm"
|
||||||
>
|
>
|
||||||
등록된 요청인자가 없습니다
|
등록된 요청인자가 없습니다
|
||||||
</td>
|
</td>
|
||||||
@ -630,7 +617,7 @@ const ApiEditPage = () => {
|
|||||||
) : (
|
) : (
|
||||||
requestParams.map((param, idx) => (
|
requestParams.map((param, idx) => (
|
||||||
<tr key={idx}>
|
<tr key={idx}>
|
||||||
<td className="px-2 py-1.5 text-gray-500 dark:text-gray-400 text-center text-xs">
|
<td className="px-2 py-1.5 text-[var(--color-text-secondary)] text-center text-xs">
|
||||||
{idx + 1}
|
{idx + 1}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5">
|
<td className="px-2 py-1.5">
|
||||||
@ -669,7 +656,7 @@ const ApiEditPage = () => {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={param.required ?? false}
|
checked={param.required ?? false}
|
||||||
onChange={(e) => updateParam(idx, 'required', e.target.checked)}
|
onChange={(e) => updateParam(idx, 'required', e.target.checked)}
|
||||||
className="w-4 h-4 text-blue-600 rounded border-gray-300 dark:border-gray-600 focus:ring-blue-500"
|
className="w-4 h-4 text-[var(--color-primary)] rounded border-[var(--color-border-strong)] focus:ring-[var(--color-primary)]"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5">
|
<td className="px-2 py-1.5">
|
||||||
@ -688,7 +675,7 @@ const ApiEditPage = () => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeParam(idx)}
|
onClick={() => removeParam(idx)}
|
||||||
className="text-red-500 hover:text-red-700 dark:hover:text-red-400 font-bold text-base leading-none"
|
className="text-red-500 hover:text-red-700 font-bold text-base leading-none"
|
||||||
title="삭제"
|
title="삭제"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
@ -703,30 +690,27 @@ const ApiEditPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section 4: 출력결과 */}
|
{/* Section 4: 출력결과 */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">출력결과</h2>
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">출력결과</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowJsonInput((v) => !v)}
|
onClick={() => setShowJsonInput((v) => !v)}
|
||||||
className="px-3 py-1.5 rounded-lg bg-indigo-100 hover:bg-indigo-200 dark:bg-indigo-900 dark:hover:bg-indigo-800 text-indigo-700 dark:text-indigo-300 text-sm font-medium transition-colors"
|
variant="accent"
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
{showJsonInput ? 'JSON 닫기' : 'JSON 파싱'}
|
{showJsonInput ? 'JSON 닫기' : 'JSON 파싱'}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button type="button" onClick={addResponseParam} variant="secondary" size="sm">
|
||||||
type="button"
|
|
||||||
onClick={addResponseParam}
|
|
||||||
className="px-3 py-1.5 rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
행 추가
|
행 추가
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showJsonInput && (
|
{showJsonInput && (
|
||||||
<div className="mb-4 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600">
|
<div className="mb-4 p-4 bg-[var(--color-bg-base)] rounded-lg border border-[var(--color-border)]">
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
|
<p className="text-xs text-[var(--color-text-secondary)] mb-2">
|
||||||
JSON 응답 예시를 붙여넣으면 키를 자동으로 추출합니다. (기존 목록을 대체합니다)
|
JSON 응답 예시를 붙여넣으면 키를 자동으로 추출합니다. (기존 목록을 대체합니다)
|
||||||
</p>
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
@ -739,33 +723,35 @@ const ApiEditPage = () => {
|
|||||||
{jsonError && (
|
{jsonError && (
|
||||||
<p className="mt-1.5 text-xs text-red-500">{jsonError}</p>
|
<p className="mt-1.5 text-xs text-red-500">{jsonError}</p>
|
||||||
)}
|
)}
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={parseJsonToParams}
|
onClick={parseJsonToParams}
|
||||||
disabled={!jsonInput.trim()}
|
disabled={!jsonInput.trim()}
|
||||||
className="mt-2 px-4 py-1.5 rounded-lg bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white text-sm font-medium transition-colors"
|
variant="accent"
|
||||||
|
size="sm"
|
||||||
|
className="mt-2"
|
||||||
>
|
>
|
||||||
파싱
|
파싱
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
<tr className="border-b border-[var(--color-border)]">
|
||||||
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 w-10">#</th>
|
<th className="px-2 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)] w-10">#</th>
|
||||||
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">변수명</th>
|
<th className="px-2 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)]">변수명</th>
|
||||||
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">의미(단위)</th>
|
<th className="px-2 py-2 text-left text-xs font-medium text-[var(--color-text-secondary)]">의미(단위)</th>
|
||||||
<th className="px-2 py-2 w-10" />
|
<th className="px-2 py-2 w-10" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{responseParams.length === 0 ? (
|
{responseParams.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={4}
|
colSpan={4}
|
||||||
className="px-2 py-6 text-center text-gray-400 dark:text-gray-500 text-sm"
|
className="px-2 py-6 text-center text-[var(--color-text-tertiary)] text-sm"
|
||||||
>
|
>
|
||||||
등록된 출력결과가 없습니다
|
등록된 출력결과가 없습니다
|
||||||
</td>
|
</td>
|
||||||
@ -773,7 +759,7 @@ const ApiEditPage = () => {
|
|||||||
) : (
|
) : (
|
||||||
responseParams.map((param, idx) => (
|
responseParams.map((param, idx) => (
|
||||||
<tr key={idx}>
|
<tr key={idx}>
|
||||||
<td className="px-2 py-1.5 text-gray-500 dark:text-gray-400 text-center text-xs">
|
<td className="px-2 py-1.5 text-[var(--color-text-secondary)] text-center text-xs">
|
||||||
{idx + 1}
|
{idx + 1}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5">
|
<td className="px-2 py-1.5">
|
||||||
@ -800,7 +786,7 @@ const ApiEditPage = () => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeResponseParam(idx)}
|
onClick={() => removeResponseParam(idx)}
|
||||||
className="text-red-500 hover:text-red-700 dark:hover:text-red-400 font-bold text-base leading-none"
|
className="text-red-500 hover:text-red-700 font-bold text-base leading-none"
|
||||||
title="삭제"
|
title="삭제"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
|
|||||||
@ -2,12 +2,14 @@ import { useState, useEffect, useMemo } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import type { ServiceInfo, ServiceApi, CreateServiceApiRequest } from '../../types/service';
|
import type { ServiceInfo, ServiceApi, CreateServiceApiRequest } from '../../types/service';
|
||||||
import { getServices, getServiceApis, createServiceApi } from '../../services/serviceService';
|
import { getServices, getServiceApis, createServiceApi } from '../../services/serviceService';
|
||||||
|
import Button from '../../components/ui/Button';
|
||||||
|
import Badge from '../../components/ui/Badge';
|
||||||
|
|
||||||
const METHOD_COLOR: Record<string, string> = {
|
const METHOD_COLOR: Record<string, string> = {
|
||||||
GET: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
GET: 'bg-green-50 text-green-700 dark:bg-green-500/15 dark:text-green-400',
|
||||||
POST: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
POST: 'bg-blue-50 text-blue-700 dark:bg-blue-500/15 dark:text-blue-400',
|
||||||
PUT: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
|
PUT: 'bg-amber-50 text-amber-700 dark:bg-amber-500/15 dark:text-amber-400',
|
||||||
DELETE: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
|
DELETE: 'bg-red-50 text-red-700 dark:bg-red-500/15 dark:text-red-400',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface FlatApi extends ServiceApi {
|
interface FlatApi extends ServiceApi {
|
||||||
@ -148,7 +150,7 @@ const ApisPage = () => {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto text-center py-10 text-gray-500 dark:text-gray-400">
|
<div className="max-w-7xl mx-auto text-center py-10 text-[var(--color-text-secondary)]">
|
||||||
로딩 중...
|
로딩 중...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -158,18 +160,13 @@ const ApisPage = () => {
|
|||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">API 관리</h1>
|
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">API 관리</h1>
|
||||||
<button
|
<Button onClick={handleOpenModal}>API 생성</Button>
|
||||||
onClick={handleOpenModal}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
|
||||||
>
|
|
||||||
API 생성
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Global error */}
|
{/* Global error */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-lg text-sm">
|
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -181,7 +178,7 @@ const ApisPage = () => {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFilterServiceId(e.target.value === 'all' ? 'all' : Number(e.target.value))
|
setFilterServiceId(e.target.value === 'all' ? 'all' : Number(e.target.value))
|
||||||
}
|
}
|
||||||
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="px-3 py-2 rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
|
||||||
>
|
>
|
||||||
<option value="all">전체 서비스</option>
|
<option value="all">전체 서비스</option>
|
||||||
{services.map((svc) => (
|
{services.map((svc) => (
|
||||||
@ -196,18 +193,18 @@ const ApisPage = () => {
|
|||||||
value={searchText}
|
value={searchText}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
placeholder="API명, Path 검색"
|
placeholder="API명, Path 검색"
|
||||||
className="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[200px]"
|
className="px-3 py-2 rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] min-w-[200px]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden text-sm">
|
<div className="flex rounded-lg border border-[var(--color-border-strong)] overflow-hidden text-sm">
|
||||||
{(['all', 'active', 'inactive'] as const).map((v) => (
|
{(['all', 'active', 'inactive'] as const).map((v) => (
|
||||||
<button
|
<button
|
||||||
key={v}
|
key={v}
|
||||||
onClick={() => setFilterActive(v)}
|
onClick={() => setFilterActive(v)}
|
||||||
className={`px-3 py-2 font-medium ${
|
className={`px-3 py-2 font-medium ${
|
||||||
filterActive === v
|
filterActive === v
|
||||||
? 'bg-blue-600 text-white'
|
? 'bg-[var(--color-primary)] text-white dark:bg-[var(--color-primary-600)]'
|
||||||
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
|
: 'bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] hover:bg-[var(--color-bg-base)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{v === 'all' ? '전체' : v === 'active' ? 'Active' : 'Inactive'}
|
{v === 'all' ? '전체' : v === 'active' ? 'Active' : 'Inactive'}
|
||||||
@ -217,62 +214,52 @@ const ApisPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
|
<div className="overflow-x-auto bg-[var(--color-bg-surface)] rounded-lg shadow">
|
||||||
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">서비스</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">서비스</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Method</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Method</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Path</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Path</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">API명</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">API명</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Domain</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Domain</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Section</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Section</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Active</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Active</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">명세</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">명세</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{filteredApis.map((api) => (
|
{filteredApis.map((api) => (
|
||||||
<tr
|
<tr
|
||||||
key={`${api.serviceId}-${api.apiId}`}
|
key={`${api.serviceId}-${api.apiId}`}
|
||||||
onClick={() => navigate(`/admin/apis/${api.serviceId}/${api.apiId}`)}
|
onClick={() => navigate(`/admin/apis/${api.serviceId}/${api.apiId}`)}
|
||||||
className="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
|
className="cursor-pointer hover:bg-[var(--color-bg-base)]"
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">{api.serviceName}</td>
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">{api.serviceName}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span
|
<Badge className={METHOD_COLOR[api.apiMethod] ?? ''}>
|
||||||
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
|
|
||||||
METHOD_COLOR[api.apiMethod] ?? 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{api.apiMethod}
|
{api.apiMethod}
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 font-mono text-gray-800 dark:text-gray-200 truncate max-w-[240px]">
|
<td className="px-4 py-3 font-mono text-[var(--color-text-primary)] truncate max-w-[240px]">
|
||||||
{api.apiPath}
|
{api.apiPath}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{api.apiName}</td>
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">{api.apiName}</td>
|
||||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{api.apiDomain || '-'}</td>
|
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{api.apiDomain || '-'}</td>
|
||||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{api.apiSection || '-'}</td>
|
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{api.apiSection || '-'}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span
|
<Badge variant={api.isActive ? 'success' : 'danger'}>
|
||||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
||||||
api.isActive
|
|
||||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'
|
|
||||||
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{api.isActive ? 'Active' : 'Inactive'}
|
{api.isActive ? 'Active' : 'Inactive'}
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="text-gray-400 dark:text-gray-500">-</span>
|
<span className="text-[var(--color-text-tertiary)]">-</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{filteredApis.length === 0 && (
|
{filteredApis.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
|
<td colSpan={8} className="px-4 py-8 text-center text-[var(--color-text-tertiary)]">
|
||||||
등록된 API가 없습니다
|
등록된 API가 없습니다
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -284,33 +271,30 @@ const ApisPage = () => {
|
|||||||
{/* Create API Modal */}
|
{/* Create API Modal */}
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-lg mx-4">
|
<div className="bg-[var(--color-bg-surface)] rounded-xl shadow-xl w-full max-w-lg mx-4">
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--color-border)]">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">API 생성</h2>
|
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">API 생성</h2>
|
||||||
<button
|
<Button variant="ghost" size="sm" onClick={handleCloseModal} className="text-xl font-bold leading-none px-2">
|
||||||
onClick={handleCloseModal}
|
|
||||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl font-bold leading-none"
|
|
||||||
>
|
|
||||||
×
|
×
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleModalSubmit} className="px-6 py-4 space-y-4">
|
<form onSubmit={handleModalSubmit} className="px-6 py-4 space-y-4">
|
||||||
{modalError && (
|
{modalError && (
|
||||||
<div className="p-3 bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-lg text-sm">
|
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">
|
||||||
{modalError}
|
{modalError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
서비스 <span className="text-red-500">*</span>
|
서비스 <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={modalServiceId}
|
value={modalServiceId}
|
||||||
onChange={(e) => setModalServiceId(Number(e.target.value))}
|
onChange={(e) => setModalServiceId(Number(e.target.value))}
|
||||||
required
|
required
|
||||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full px-3 py-2 rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
|
||||||
>
|
>
|
||||||
{services.map((svc) => (
|
{services.map((svc) => (
|
||||||
<option key={svc.serviceId} value={svc.serviceId}>
|
<option key={svc.serviceId} value={svc.serviceId}>
|
||||||
@ -321,13 +305,13 @@ const ApisPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
Method <span className="text-red-500">*</span>
|
Method <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={modalMethod}
|
value={modalMethod}
|
||||||
onChange={(e) => setModalMethod(e.target.value)}
|
onChange={(e) => setModalMethod(e.target.value)}
|
||||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full px-3 py-2 rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
|
||||||
>
|
>
|
||||||
{['GET', 'POST', 'PUT', 'DELETE'].map((m) => (
|
{['GET', 'POST', 'PUT', 'DELETE'].map((m) => (
|
||||||
<option key={m} value={m}>{m}</option>
|
<option key={m} value={m}>{m}</option>
|
||||||
@ -336,7 +320,7 @@ const ApisPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
API Path <span className="text-red-500">*</span>
|
API Path <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -345,12 +329,12 @@ const ApisPage = () => {
|
|||||||
onChange={(e) => setModalPath(e.target.value)}
|
onChange={(e) => setModalPath(e.target.value)}
|
||||||
required
|
required
|
||||||
placeholder="/api/v1/example"
|
placeholder="/api/v1/example"
|
||||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full px-3 py-2 rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
API명 <span className="text-red-500">*</span>
|
API명 <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -359,13 +343,13 @@ const ApisPage = () => {
|
|||||||
onChange={(e) => setModalName(e.target.value)}
|
onChange={(e) => setModalName(e.target.value)}
|
||||||
required
|
required
|
||||||
placeholder="API 이름"
|
placeholder="API 이름"
|
||||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full px-3 py-2 rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
Domain
|
Domain
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -373,11 +357,11 @@ const ApisPage = () => {
|
|||||||
value={modalDomain}
|
value={modalDomain}
|
||||||
onChange={(e) => setModalDomain(e.target.value)}
|
onChange={(e) => setModalDomain(e.target.value)}
|
||||||
placeholder="도메인 (선택)"
|
placeholder="도메인 (선택)"
|
||||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full px-3 py-2 rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
Section
|
Section
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -385,13 +369,13 @@ const ApisPage = () => {
|
|||||||
value={modalSection}
|
value={modalSection}
|
||||||
onChange={(e) => setModalSection(e.target.value)}
|
onChange={(e) => setModalSection(e.target.value)}
|
||||||
placeholder="섹션 (선택)"
|
placeholder="섹션 (선택)"
|
||||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full px-3 py-2 rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
설명
|
설명
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
@ -399,25 +383,17 @@ const ApisPage = () => {
|
|||||||
onChange={(e) => setModalDescription(e.target.value)}
|
onChange={(e) => setModalDescription(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="API 설명 (선택)"
|
placeholder="API 설명 (선택)"
|
||||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
className="w-full px-3 py-2 rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-2">
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
<button
|
<Button type="button" variant="outline" onClick={handleCloseModal}>
|
||||||
type="button"
|
|
||||||
onClick={handleCloseModal}
|
|
||||||
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
취소
|
취소
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button type="submit" disabled={submitting}>
|
||||||
type="submit"
|
|
||||||
disabled={submitting}
|
|
||||||
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium"
|
|
||||||
>
|
|
||||||
{submitting ? '생성 중...' : '생성'}
|
{submitting ? '생성 중...' : '생성'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import type { ApiDomainInfo } from '../../types/apihub';
|
import type { ApiDomainInfo } from '../../types/apihub';
|
||||||
import type { CreateDomainRequest, UpdateDomainRequest } from '../../services/serviceService';
|
import type { CreateDomainRequest, UpdateDomainRequest } from '../../services/serviceService';
|
||||||
import { getDomains, createDomain, updateDomain, deleteDomain } from '../../services/serviceService';
|
import { getDomains, createDomain, updateDomain, deleteDomain } from '../../services/serviceService';
|
||||||
|
import Button from '../../components/ui/Button';
|
||||||
|
|
||||||
const DEFAULT_ICON_PATHS = ['M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776'];
|
const DEFAULT_ICON_PATHS = ['M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776'];
|
||||||
|
|
||||||
@ -121,43 +122,38 @@ const DomainsPage = () => {
|
|||||||
const previewPath = iconPath.trim() || null;
|
const previewPath = iconPath.trim() || null;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="max-w-7xl mx-auto text-center py-10 text-gray-500 dark:text-gray-400">로딩 중...</div>;
|
return <div className="max-w-7xl mx-auto text-center py-10 text-[var(--color-text-secondary)]">로딩 중...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Domains</h1>
|
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">Domains</h1>
|
||||||
<button
|
<Button onClick={handleOpenCreate}>도메인 추가</Button>
|
||||||
onClick={handleOpenCreate}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
|
||||||
>
|
|
||||||
도메인 추가
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && !isModalOpen && (
|
{error && !isModalOpen && (
|
||||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
|
<div className="overflow-x-auto bg-[var(--color-bg-surface)] rounded-lg shadow">
|
||||||
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">#</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">#</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">아이콘</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">아이콘</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">도메인명</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">도메인명</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">정렬순서</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">정렬순서</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Actions</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{domains.map((domain, index) => (
|
{domains.map((domain, index) => (
|
||||||
<tr key={domain.domainId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
<tr key={domain.domainId} className="hover:bg-[var(--color-bg-base)]">
|
||||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{index + 1}</td>
|
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{index + 1}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5 text-gray-500 dark:text-gray-400"
|
className="h-5 w-5 text-[var(--color-text-secondary)]"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@ -170,29 +166,23 @@ const DomainsPage = () => {
|
|||||||
))}
|
))}
|
||||||
</svg>
|
</svg>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100 font-medium">{domain.domainName}</td>
|
<td className="px-4 py-3 text-[var(--color-text-primary)] font-medium">{domain.domainName}</td>
|
||||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{domain.sortOrder}</td>
|
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{domain.sortOrder}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<Button variant="outline" size="sm" onClick={() => handleOpenEdit(domain)}>
|
||||||
onClick={() => handleOpenEdit(domain)}
|
|
||||||
className="bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-800/40 dark:text-blue-400 px-3 py-1 rounded-lg text-sm font-medium"
|
|
||||||
>
|
|
||||||
수정
|
수정
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant="danger" size="sm" onClick={() => handleDelete(domain)}>
|
||||||
onClick={() => handleDelete(domain)}
|
|
||||||
className="bg-red-100 hover:bg-red-200 text-red-700 dark:bg-red-900/30 dark:hover:bg-red-800/40 dark:text-red-400 px-3 py-1 rounded-lg text-sm font-medium"
|
|
||||||
>
|
|
||||||
삭제
|
삭제
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{domains.length === 0 && (
|
{domains.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
|
<td colSpan={5} className="px-4 py-8 text-center text-[var(--color-text-tertiary)]">
|
||||||
등록된 도메인이 없습니다.
|
등록된 도메인이 없습니다.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -203,9 +193,9 @@ const DomainsPage = () => {
|
|||||||
|
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="px-6 py-4 border-b border-[var(--color-border)]">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||||
{editingDomain ? '도메인 수정' : '도메인 추가'}
|
{editingDomain ? '도메인 수정' : '도메인 추가'}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@ -215,7 +205,7 @@ const DomainsPage = () => {
|
|||||||
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
도메인명
|
도메인명
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -223,13 +213,13 @@ const DomainsPage = () => {
|
|||||||
value={domainName}
|
value={domainName}
|
||||||
onChange={(e) => setDomainName(e.target.value)}
|
onChange={(e) => setDomainName(e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="flex items-center justify-between text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="flex items-center justify-between text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
<span>SVG Path</span>
|
<span>SVG Path</span>
|
||||||
<span className="text-xs font-normal text-gray-400 dark:text-gray-500">
|
<span className="text-xs font-normal text-[var(--color-text-tertiary)]">
|
||||||
참고:
|
참고:
|
||||||
<a href="https://heroicons.com" target="_blank" rel="noopener noreferrer" className="ml-1 text-blue-500 hover:text-blue-600 underline">Heroicons</a>
|
<a href="https://heroicons.com" target="_blank" rel="noopener noreferrer" className="ml-1 text-blue-500 hover:text-blue-600 underline">Heroicons</a>
|
||||||
<a href="https://lucide.dev" target="_blank" rel="noopener noreferrer" className="ml-1 text-blue-500 hover:text-blue-600 underline">Lucide</a>
|
<a href="https://lucide.dev" target="_blank" rel="noopener noreferrer" className="ml-1 text-blue-500 hover:text-blue-600 underline">Lucide</a>
|
||||||
@ -240,16 +230,16 @@ const DomainsPage = () => {
|
|||||||
onChange={(e) => setIconPath(e.target.value)}
|
onChange={(e) => setIconPath(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="M4 6a2 2 0 012-2h8..."
|
placeholder="M4 6a2 2 0 012-2h8..."
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none font-mono text-xs"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-2">
|
||||||
아이콘 미리보기
|
아이콘 미리보기
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center justify-center w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-lg">
|
<div className="flex items-center justify-center w-16 h-16 bg-[var(--color-bg-base)] rounded-lg">
|
||||||
<svg
|
<svg
|
||||||
className="h-8 w-8 text-gray-600 dark:text-gray-300"
|
className="h-8 w-8 text-[var(--color-text-secondary)]"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@ -264,7 +254,7 @@ const DomainsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
정렬순서
|
정렬순서
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -272,24 +262,15 @@ const DomainsPage = () => {
|
|||||||
value={sortOrder}
|
value={sortOrder}
|
||||||
onChange={(e) => setSortOrder(Number(e.target.value))}
|
onChange={(e) => setSortOrder(Number(e.target.value))}
|
||||||
min={0}
|
min={0}
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
|
<div className="px-6 py-4 border-t border-[var(--color-border)] flex justify-end gap-2">
|
||||||
<button
|
<Button type="button" variant="outline" onClick={handleCloseModal}>
|
||||||
type="button"
|
|
||||||
onClick={handleCloseModal}
|
|
||||||
className="bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-lg text-sm font-medium"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button type="submit">Save</Button>
|
||||||
type="submit"
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { getSystemConfig, updateSystemConfig } from '../../services/configService';
|
import { getSystemConfig, updateSystemConfig } from '../../services/configService';
|
||||||
|
import Button from '../../components/ui/Button';
|
||||||
|
|
||||||
const COMMON_SAMPLE_CODE_KEY = 'COMMON_SAMPLE_CODE';
|
const COMMON_SAMPLE_CODE_KEY = 'COMMON_SAMPLE_CODE';
|
||||||
|
|
||||||
const INPUT_CLS =
|
const INPUT_CLS =
|
||||||
'w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none text-sm font-mono';
|
'w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none text-sm font-mono';
|
||||||
|
|
||||||
const SampleCodePage = () => {
|
const SampleCodePage = () => {
|
||||||
const [sampleCode, setSampleCode] = useState('');
|
const [sampleCode, setSampleCode] = useState('');
|
||||||
@ -50,7 +51,7 @@ const SampleCodePage = () => {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
<div className="w-8 h-8 border-4 border-[var(--color-primary)] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -59,18 +60,18 @@ const SampleCodePage = () => {
|
|||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
<div className="flex items-start justify-between mb-6">
|
<div className="flex items-start justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">공통 샘플 코드 관리</h1>
|
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">공통 샘플 코드 관리</h1>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-sm text-[var(--color-text-secondary)] mt-1">
|
||||||
API HUB 상세 페이지에 공통으로 표시되는 샘플 코드를 관리합니다.
|
API HUB 상세 페이지에 공통으로 표시되는 샘플 코드를 관리합니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium transition-colors shrink-0"
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
{saving ? '저장 중...' : '저장'}
|
{saving ? '저장 중...' : '저장'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
@ -85,8 +86,8 @@ const SampleCodePage = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-2">
|
||||||
샘플 코드
|
샘플 코드
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
@ -96,7 +97,7 @@ const SampleCodePage = () => {
|
|||||||
placeholder="API HUB 상세 페이지에 표시할 공통 샘플 코드를 입력하세요."
|
placeholder="API HUB 상세 페이지에 표시할 공통 샘플 코드를 입력하세요."
|
||||||
className={`${INPUT_CLS} resize-y`}
|
className={`${INPUT_CLS} resize-y`}
|
||||||
/>
|
/>
|
||||||
<p className="mt-2 text-xs text-gray-400 dark:text-gray-500">
|
<p className="mt-2 text-xs text-[var(--color-text-tertiary)]">
|
||||||
이 코드는 모든 API 상세 페이지의 '요청 URL 생성' 섹션 하단에 표시됩니다.
|
이 코드는 모든 API 상세 페이지의 '요청 URL 생성' 섹션 하단에 표시됩니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -13,11 +13,13 @@ import {
|
|||||||
deleteService,
|
deleteService,
|
||||||
getServiceApis,
|
getServiceApis,
|
||||||
} from '../../services/serviceService';
|
} from '../../services/serviceService';
|
||||||
|
import Button from '../../components/ui/Button';
|
||||||
|
import Badge from '../../components/ui/Badge';
|
||||||
|
|
||||||
const HEALTH_BADGE: Record<string, { dot: string; bg: string; text: string }> = {
|
const HEALTH_DOT: Record<string, string> = {
|
||||||
UP: { dot: 'bg-green-500', bg: 'bg-green-100', text: 'text-green-800' },
|
UP: 'bg-green-500',
|
||||||
DOWN: { dot: 'bg-red-500', bg: 'bg-red-100', text: 'text-red-800' },
|
DOWN: 'bg-red-500',
|
||||||
UNKNOWN: { dot: 'bg-gray-400', bg: 'bg-gray-100', text: 'text-gray-800' },
|
UNKNOWN: 'bg-gray-400',
|
||||||
};
|
};
|
||||||
|
|
||||||
const METHOD_COLOR: Record<string, string> = {
|
const METHOD_COLOR: Record<string, string> = {
|
||||||
@ -189,103 +191,98 @@ const ServicesPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="max-w-7xl mx-auto text-center py-10 text-gray-500 dark:text-gray-400">로딩 중...</div>;
|
return <div className="max-w-7xl mx-auto text-center py-10 text-[var(--color-text-secondary)]">로딩 중...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Services</h1>
|
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">Services</h1>
|
||||||
<button
|
<Button onClick={handleOpenCreateService}>Create Service</Button>
|
||||||
onClick={handleOpenCreateService}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
|
||||||
>
|
|
||||||
Create Service
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && !isServiceModalOpen && (
|
{error && !isServiceModalOpen && (
|
||||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow mb-6">
|
<div className="overflow-x-auto bg-[var(--color-bg-surface)] rounded-lg shadow mb-6">
|
||||||
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Code</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Code</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Name</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Name</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">URL</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">URL</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Health Status</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Health Status</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Response Time</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Response Time</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Last Checked</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Last Checked</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Active</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Active</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Actions</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{services.map((service) => {
|
{services.map((service) => {
|
||||||
const badge = HEALTH_BADGE[service.healthStatus] || HEALTH_BADGE.UNKNOWN;
|
const dot = HEALTH_DOT[service.healthStatus] ?? HEALTH_DOT.UNKNOWN;
|
||||||
|
const healthVariant =
|
||||||
|
service.healthStatus === 'UP'
|
||||||
|
? 'success'
|
||||||
|
: service.healthStatus === 'DOWN'
|
||||||
|
? 'danger'
|
||||||
|
: 'default';
|
||||||
const isSelected = selectedService?.serviceId === service.serviceId;
|
const isSelected = selectedService?.serviceId === service.serviceId;
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={service.serviceId}
|
key={service.serviceId}
|
||||||
onClick={() => handleSelectService(service)}
|
onClick={() => handleSelectService(service)}
|
||||||
className={`cursor-pointer ${
|
className={`cursor-pointer ${
|
||||||
isSelected ? 'bg-blue-50 dark:bg-blue-900/30' : 'hover:bg-gray-50 dark:hover:bg-gray-700'
|
isSelected ? 'bg-[var(--color-primary-subtle)]' : 'hover:bg-[var(--color-bg-base)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 font-mono text-gray-900 dark:text-gray-100">{service.serviceCode}</td>
|
<td className="px-4 py-3 font-mono text-[var(--color-text-primary)]">{service.serviceCode}</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{service.serviceName}</td>
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">{service.serviceName}</td>
|
||||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 truncate max-w-[200px]">
|
<td className="px-4 py-3 text-[var(--color-text-secondary)] truncate max-w-[200px]">
|
||||||
{service.serviceUrl || '-'}
|
{service.serviceUrl || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span
|
<Badge variant={healthVariant} className="gap-1.5">
|
||||||
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${badge.bg} ${badge.text}`}
|
<span className={`w-2 h-2 rounded-full ${dot}`} />
|
||||||
>
|
|
||||||
<span className={`w-2 h-2 rounded-full ${badge.dot}`} />
|
|
||||||
{service.healthStatus}
|
{service.healthStatus}
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">
|
<td className="px-4 py-3 text-[var(--color-text-secondary)]">
|
||||||
{service.healthResponseTime != null
|
{service.healthResponseTime != null
|
||||||
? `${service.healthResponseTime}ms`
|
? `${service.healthResponseTime}ms`
|
||||||
: '-'}
|
: '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">
|
<td className="px-4 py-3 text-[var(--color-text-secondary)]">
|
||||||
{formatRelativeTime(service.healthCheckedAt)}
|
{formatRelativeTime(service.healthCheckedAt)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span
|
<Badge variant={service.isActive ? 'success' : 'danger'}>
|
||||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
||||||
service.isActive
|
|
||||||
? 'bg-green-100 text-green-800'
|
|
||||||
: 'bg-red-100 text-red-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{service.isActive ? 'Active' : 'Inactive'}
|
{service.isActive ? 'Active' : 'Inactive'}
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleOpenEditService(service);
|
handleOpenEditService(service);
|
||||||
}}
|
}}
|
||||||
className="bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-800/40 dark:text-blue-400 px-3 py-1 rounded-lg text-sm font-medium"
|
|
||||||
>
|
>
|
||||||
수정
|
수정
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleDeleteService(service);
|
handleDeleteService(service);
|
||||||
}}
|
}}
|
||||||
className="bg-red-100 hover:bg-red-200 text-red-700 dark:bg-red-900/30 dark:hover:bg-red-800/40 dark:text-red-400 px-3 py-1 rounded-lg text-sm font-medium"
|
|
||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -293,7 +290,7 @@ const ServicesPage = () => {
|
|||||||
})}
|
})}
|
||||||
{services.length === 0 && (
|
{services.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
|
<td colSpan={8} className="px-4 py-8 text-center text-[var(--color-text-tertiary)]">
|
||||||
등록된 서비스가 없습니다.
|
등록된 서비스가 없습니다.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -303,34 +300,29 @@ const ServicesPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedService && (
|
{selectedService && (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow">
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--color-border)]">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||||
APIs for {selectedService.serviceName}
|
APIs for {selectedService.serviceName}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<Button size="sm" onClick={() => navigate('/admin/apis')}>API 관리</Button>
|
||||||
onClick={() => navigate('/admin/apis')}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded-lg text-sm font-medium"
|
|
||||||
>
|
|
||||||
API 관리
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Method</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Method</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Path</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Path</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Name</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Name</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Domain</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Domain</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Section</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Section</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Description</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Description</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Active</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Active</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{serviceApis.map((api) => (
|
{serviceApis.map((api) => (
|
||||||
<tr key={api.apiId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
<tr key={api.apiId} className="hover:bg-[var(--color-bg-base)]">
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span
|
<span
|
||||||
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
|
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
|
||||||
@ -340,27 +332,21 @@ const ServicesPage = () => {
|
|||||||
{api.apiMethod}
|
{api.apiMethod}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 font-mono text-gray-900 dark:text-gray-100">{api.apiPath}</td>
|
<td className="px-4 py-3 font-mono text-[var(--color-text-primary)]">{api.apiPath}</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{api.apiName}</td>
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">{api.apiName}</td>
|
||||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{api.apiDomain || '-'}</td>
|
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{api.apiDomain || '-'}</td>
|
||||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{api.apiSection || '-'}</td>
|
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{api.apiSection || '-'}</td>
|
||||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{api.description || '-'}</td>
|
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{api.description || '-'}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span
|
<Badge variant={api.isActive ? 'success' : 'danger'}>
|
||||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
||||||
api.isActive
|
|
||||||
? 'bg-green-100 text-green-800'
|
|
||||||
: 'bg-red-100 text-red-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{api.isActive ? 'Active' : 'Inactive'}
|
{api.isActive ? 'Active' : 'Inactive'}
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{serviceApis.length === 0 && (
|
{serviceApis.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
|
<td colSpan={7} className="px-4 py-8 text-center text-[var(--color-text-tertiary)]">
|
||||||
등록된 API가 없습니다.
|
등록된 API가 없습니다.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -373,9 +359,9 @@ const ServicesPage = () => {
|
|||||||
|
|
||||||
{isServiceModalOpen && (
|
{isServiceModalOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="px-6 py-4 border-b border-[var(--color-border)]">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||||
{editingService ? '서비스 수정' : '서비스 생성'}
|
{editingService ? '서비스 수정' : '서비스 생성'}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@ -385,7 +371,7 @@ const ServicesPage = () => {
|
|||||||
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
Service Code
|
Service Code
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -394,11 +380,11 @@ const ServicesPage = () => {
|
|||||||
onChange={(e) => setServiceCode(e.target.value)}
|
onChange={(e) => setServiceCode(e.target.value)}
|
||||||
disabled={!!editingService}
|
disabled={!!editingService}
|
||||||
required
|
required
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:bg-gray-100 disabled:text-gray-500 dark:disabled:bg-gray-600 dark:disabled:text-gray-400"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
Service Name
|
Service Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -406,44 +392,44 @@ const ServicesPage = () => {
|
|||||||
value={serviceName}
|
value={serviceName}
|
||||||
onChange={(e) => setServiceName(e.target.value)}
|
onChange={(e) => setServiceName(e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
Service URL
|
Service URL
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={serviceUrl}
|
value={serviceUrl}
|
||||||
onChange={(e) => setServiceUrl(e.target.value)}
|
onChange={(e) => setServiceUrl(e.target.value)}
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
Description
|
Description
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={serviceDescription}
|
value={serviceDescription}
|
||||||
onChange={(e) => setServiceDescription(e.target.value)}
|
onChange={(e) => setServiceDescription(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
Health Check URL
|
Health Check URL
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={healthCheckUrl}
|
value={healthCheckUrl}
|
||||||
onChange={(e) => setHealthCheckUrl(e.target.value)}
|
onChange={(e) => setHealthCheckUrl(e.target.value)}
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
Health Check Interval (seconds)
|
Health Check Interval (seconds)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -451,7 +437,7 @@ const ServicesPage = () => {
|
|||||||
value={healthCheckInterval}
|
value={healthCheckInterval}
|
||||||
onChange={(e) => setHealthCheckInterval(Number(e.target.value))}
|
onChange={(e) => setHealthCheckInterval(Number(e.target.value))}
|
||||||
min={10}
|
min={10}
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{editingService && (
|
{editingService && (
|
||||||
@ -463,26 +449,17 @@ const ServicesPage = () => {
|
|||||||
onChange={(e) => setServiceIsActive(e.target.checked)}
|
onChange={(e) => setServiceIsActive(e.target.checked)}
|
||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="serviceIsActive" className="text-sm text-gray-700 dark:text-gray-300">
|
<label htmlFor="serviceIsActive" className="text-sm text-[var(--color-text-primary)]">
|
||||||
Active
|
Active
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
|
<div className="px-6 py-4 border-t border-[var(--color-border)] flex justify-end gap-2">
|
||||||
<button
|
<Button type="button" variant="outline" onClick={handleCloseServiceModal}>
|
||||||
type="button"
|
|
||||||
onClick={handleCloseServiceModal}
|
|
||||||
className="bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-lg text-sm font-medium"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button type="submit">Save</Button>
|
||||||
type="submit"
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import type { Tenant, CreateTenantRequest, UpdateTenantRequest } from '../../types/tenant';
|
import type { Tenant, CreateTenantRequest, UpdateTenantRequest } from '../../types/tenant';
|
||||||
import { getTenants, createTenant, updateTenant } from '../../services/tenantService';
|
import { getTenants, createTenant, updateTenant } from '../../services/tenantService';
|
||||||
|
import Button from '../../components/ui/Button';
|
||||||
|
import Badge from '../../components/ui/Badge';
|
||||||
|
|
||||||
const TenantsPage = () => {
|
const TenantsPage = () => {
|
||||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||||
@ -94,70 +96,56 @@ const TenantsPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="max-w-7xl mx-auto text-center py-10 text-gray-500 dark:text-gray-400">로딩 중...</div>;
|
return <div className="max-w-7xl mx-auto text-center py-10 text-[var(--color-text-secondary)]">로딩 중...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Tenants</h1>
|
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">Tenants</h1>
|
||||||
<button
|
<Button onClick={handleOpenCreate}>Create Tenant</Button>
|
||||||
onClick={handleOpenCreate}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
|
||||||
>
|
|
||||||
Create Tenant
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && !isModalOpen && (
|
{error && !isModalOpen && (
|
||||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
|
<div className="overflow-x-auto bg-[var(--color-bg-surface)] rounded-lg shadow">
|
||||||
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Code</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Code</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Name</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Name</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Description</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Description</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Active</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Active</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Created</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Created</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Actions</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{tenants.map((tenant) => (
|
{tenants.map((tenant) => (
|
||||||
<tr key={tenant.tenantId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
<tr key={tenant.tenantId} className="hover:bg-[var(--color-bg-base)]">
|
||||||
<td className="px-4 py-3 font-mono text-gray-900 dark:text-gray-100">{tenant.tenantCode}</td>
|
<td className="px-4 py-3 font-mono text-[var(--color-text-primary)]">{tenant.tenantCode}</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{tenant.tenantName}</td>
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">{tenant.tenantName}</td>
|
||||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{tenant.description || '-'}</td>
|
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{tenant.description || '-'}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span
|
<Badge variant={tenant.isActive ? 'success' : 'danger'}>
|
||||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
||||||
tenant.isActive
|
|
||||||
? 'bg-green-100 text-green-800'
|
|
||||||
: 'bg-red-100 text-red-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tenant.isActive ? 'Active' : 'Inactive'}
|
{tenant.isActive ? 'Active' : 'Inactive'}
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">
|
<td className="px-4 py-3 text-[var(--color-text-secondary)]">
|
||||||
{new Date(tenant.createdAt).toLocaleDateString()}
|
{new Date(tenant.createdAt).toLocaleDateString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<button
|
<Button variant="outline" size="sm" onClick={() => handleOpenEdit(tenant)}>
|
||||||
onClick={() => handleOpenEdit(tenant)}
|
|
||||||
className="bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-800/40 dark:text-blue-400 px-3 py-1 rounded-lg text-sm font-medium"
|
|
||||||
>
|
|
||||||
수정
|
수정
|
||||||
</button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{tenants.length === 0 && (
|
{tenants.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
|
<td colSpan={6} className="px-4 py-8 text-center text-[var(--color-text-tertiary)]">
|
||||||
등록된 테넌트가 없습니다.
|
등록된 테넌트가 없습니다.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -168,9 +156,9 @@ const TenantsPage = () => {
|
|||||||
|
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="px-6 py-4 border-b border-[var(--color-border)]">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||||
{editingTenant ? '테넌트 수정' : '테넌트 생성'}
|
{editingTenant ? '테넌트 수정' : '테넌트 생성'}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@ -180,7 +168,7 @@ const TenantsPage = () => {
|
|||||||
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
Tenant Code
|
Tenant Code
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -189,11 +177,11 @@ const TenantsPage = () => {
|
|||||||
onChange={(e) => setTenantCode(e.target.value)}
|
onChange={(e) => setTenantCode(e.target.value)}
|
||||||
disabled={!!editingTenant}
|
disabled={!!editingTenant}
|
||||||
required
|
required
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:bg-gray-100 disabled:text-gray-500 dark:disabled:bg-gray-600 dark:disabled:text-gray-400"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none disabled:bg-gray-100 disabled:text-gray-500 dark:disabled:bg-gray-600 dark:disabled:text-gray-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
Tenant Name
|
Tenant Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -201,18 +189,18 @@ const TenantsPage = () => {
|
|||||||
value={tenantName}
|
value={tenantName}
|
||||||
onChange={(e) => setTenantName(e.target.value)}
|
onChange={(e) => setTenantName(e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
Description
|
Description
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{editingTenant && (
|
{editingTenant && (
|
||||||
@ -224,26 +212,17 @@ const TenantsPage = () => {
|
|||||||
onChange={(e) => setIsActive(e.target.checked)}
|
onChange={(e) => setIsActive(e.target.checked)}
|
||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="isActive" className="text-sm text-gray-700 dark:text-gray-300">
|
<label htmlFor="isActive" className="text-sm text-[var(--color-text-primary)]">
|
||||||
Active
|
Active
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
|
<div className="px-6 py-4 border-t border-[var(--color-border)] flex justify-end gap-2">
|
||||||
<button
|
<Button type="button" variant="outline" onClick={handleCloseModal}>
|
||||||
type="button"
|
|
||||||
onClick={handleCloseModal}
|
|
||||||
className="bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-lg text-sm font-medium"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button type="submit">Save</Button>
|
||||||
type="submit"
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,12 +3,15 @@ import type { UserDetail, CreateUserRequest, UpdateUserRequest } from '../../typ
|
|||||||
import type { Tenant } from '../../types/tenant';
|
import type { Tenant } from '../../types/tenant';
|
||||||
import { getUsers, createUser, updateUser, deactivateUser } from '../../services/userService';
|
import { getUsers, createUser, updateUser, deactivateUser } from '../../services/userService';
|
||||||
import { getTenants } from '../../services/tenantService';
|
import { getTenants } from '../../services/tenantService';
|
||||||
|
import Button from '../../components/ui/Button';
|
||||||
|
import Badge from '../../components/ui/Badge';
|
||||||
|
import type { BadgeVariant } from '../../components/ui/Badge';
|
||||||
|
|
||||||
const ROLE_BADGE: Record<string, string> = {
|
const ROLE_BADGE_VARIANT: Record<string, BadgeVariant> = {
|
||||||
ADMIN: 'bg-red-100 text-red-800',
|
ADMIN: 'danger',
|
||||||
MANAGER: 'bg-orange-100 text-orange-800',
|
MANAGER: 'warning',
|
||||||
USER: 'bg-blue-100 text-blue-800',
|
USER: 'primary',
|
||||||
VIEWER: 'bg-gray-100 text-gray-800',
|
VIEWER: 'default',
|
||||||
};
|
};
|
||||||
|
|
||||||
const UsersPage = () => {
|
const UsersPage = () => {
|
||||||
@ -135,92 +138,73 @@ const UsersPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="max-w-7xl mx-auto text-center py-10 text-gray-500 dark:text-gray-400">로딩 중...</div>;
|
return <div className="max-w-7xl mx-auto text-center py-10 text-[var(--color-text-secondary)]">로딩 중...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Users</h1>
|
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">Users</h1>
|
||||||
<button
|
<Button onClick={handleOpenCreate}>Create User</Button>
|
||||||
onClick={handleOpenCreate}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
|
||||||
>
|
|
||||||
Create User
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && !isModalOpen && (
|
{error && !isModalOpen && (
|
||||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
|
<div className="overflow-x-auto bg-[var(--color-bg-surface)] rounded-lg shadow">
|
||||||
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Login ID</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Login ID</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Name</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Name</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Email</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Email</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Tenant</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Tenant</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Role</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Role</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Active</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Active</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Last Login</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Last Login</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Actions</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{users.map((user) => (
|
{users.map((user) => (
|
||||||
<tr key={user.userId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
<tr key={user.userId} className="hover:bg-[var(--color-bg-base)]">
|
||||||
<td className="px-4 py-3 font-mono text-gray-900 dark:text-gray-100">{user.loginId}</td>
|
<td className="px-4 py-3 font-mono text-[var(--color-text-primary)]">{user.loginId}</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{user.userName}</td>
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">{user.userName}</td>
|
||||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{user.email || '-'}</td>
|
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{user.email || '-'}</td>
|
||||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{user.tenantName || '-'}</td>
|
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{user.tenantName || '-'}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span
|
<Badge variant={ROLE_BADGE_VARIANT[user.role] ?? 'default'}>
|
||||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
||||||
ROLE_BADGE[user.role] || 'bg-gray-100 text-gray-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{user.role}
|
{user.role}
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span
|
<Badge variant={user.isActive ? 'success' : 'danger'}>
|
||||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
||||||
user.isActive
|
|
||||||
? 'bg-green-100 text-green-800'
|
|
||||||
: 'bg-red-100 text-red-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{user.isActive ? 'Active' : 'Inactive'}
|
{user.isActive ? 'Active' : 'Inactive'}
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">
|
<td className="px-4 py-3 text-[var(--color-text-secondary)]">
|
||||||
{user.lastLoginAt
|
{user.lastLoginAt
|
||||||
? new Date(user.lastLoginAt).toLocaleString()
|
? new Date(user.lastLoginAt).toLocaleString()
|
||||||
: '-'}
|
: '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 space-x-2">
|
<td className="px-4 py-3">
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => handleOpenEdit(user)}
|
<Button variant="outline" size="sm" onClick={() => handleOpenEdit(user)}>
|
||||||
className="bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-800/40 dark:text-blue-400 px-3 py-1 rounded-lg text-sm font-medium"
|
수정
|
||||||
>
|
</Button>
|
||||||
수정
|
{user.isActive && (
|
||||||
</button>
|
<Button variant="danger" size="sm" onClick={() => handleDeactivate(user)}>
|
||||||
{user.isActive && (
|
비활성화
|
||||||
<button
|
</Button>
|
||||||
onClick={() => handleDeactivate(user)}
|
)}
|
||||||
className="bg-red-100 hover:bg-red-200 text-red-700 dark:bg-red-900/30 dark:hover:bg-red-800/40 dark:text-red-400 px-3 py-1 rounded-lg text-sm font-medium"
|
</div>
|
||||||
>
|
|
||||||
비활성화
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{users.length === 0 && (
|
{users.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
|
<td colSpan={8} className="px-4 py-8 text-center text-[var(--color-text-tertiary)]">
|
||||||
등록된 사용자가 없습니다.
|
등록된 사용자가 없습니다.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -231,9 +215,9 @@ const UsersPage = () => {
|
|||||||
|
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="px-6 py-4 border-b border-[var(--color-border)]">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||||
{editingUser ? '사용자 수정' : '사용자 생성'}
|
{editingUser ? '사용자 수정' : '사용자 생성'}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@ -243,52 +227,52 @@ const UsersPage = () => {
|
|||||||
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Login ID</label>
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">Login ID</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={loginId}
|
value={loginId}
|
||||||
onChange={(e) => setLoginId(e.target.value)}
|
onChange={(e) => setLoginId(e.target.value)}
|
||||||
disabled={!!editingUser}
|
disabled={!!editingUser}
|
||||||
required
|
required
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:bg-gray-100 disabled:text-gray-500 dark:disabled:bg-gray-600 dark:disabled:text-gray-400"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">Password</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required={!editingUser}
|
required={!editingUser}
|
||||||
placeholder={editingUser ? '변경 시 입력' : ''}
|
placeholder={editingUser ? '변경 시 입력' : ''}
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">User Name</label>
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">User Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={userName}
|
value={userName}
|
||||||
onChange={(e) => setUserName(e.target.value)}
|
onChange={(e) => setUserName(e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">Email</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tenant</label>
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">Tenant</label>
|
||||||
<select
|
<select
|
||||||
value={tenantId}
|
value={tenantId}
|
||||||
onChange={(e) => setTenantId(e.target.value)}
|
onChange={(e) => setTenantId(e.target.value)}
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">-- 선택 --</option>
|
<option value="">-- 선택 --</option>
|
||||||
{tenants.map((t) => (
|
{tenants.map((t) => (
|
||||||
@ -299,11 +283,11 @@ const UsersPage = () => {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Role</label>
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">Role</label>
|
||||||
<select
|
<select
|
||||||
value={role}
|
value={role}
|
||||||
onChange={(e) => setRole(e.target.value)}
|
onChange={(e) => setRole(e.target.value)}
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="ADMIN">ADMIN</option>
|
<option value="ADMIN">ADMIN</option>
|
||||||
<option value="MANAGER">MANAGER</option>
|
<option value="MANAGER">MANAGER</option>
|
||||||
@ -320,26 +304,17 @@ const UsersPage = () => {
|
|||||||
onChange={(e) => setIsActive(e.target.checked)}
|
onChange={(e) => setIsActive(e.target.checked)}
|
||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="isActive" className="text-sm text-gray-700 dark:text-gray-300">
|
<label htmlFor="isActive" className="text-sm text-[var(--color-text-primary)]">
|
||||||
Active
|
Active
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
|
<div className="px-6 py-4 border-t border-[var(--color-border)] flex justify-end gap-2">
|
||||||
<button
|
<Button type="button" variant="outline" onClick={handleCloseModal}>
|
||||||
type="button"
|
|
||||||
onClick={handleCloseModal}
|
|
||||||
className="bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-lg text-sm font-medium"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button type="submit">Save</Button>
|
||||||
type="submit"
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { getServiceCatalog, getApiHubApiDetail } from '../../services/apiHubServ
|
|||||||
import { getSystemConfig } from '../../services/configService';
|
import { getSystemConfig } from '../../services/configService';
|
||||||
import { useApiRequestBasket } from '../../hooks/useApiRequestBasket';
|
import { useApiRequestBasket } from '../../hooks/useApiRequestBasket';
|
||||||
import ApiKeyRequestModal from '../../components/ApiKeyRequestModal';
|
import ApiKeyRequestModal from '../../components/ApiKeyRequestModal';
|
||||||
|
import Button from '../../components/ui/Button';
|
||||||
|
|
||||||
const COMMON_SAMPLE_CODE_KEY = 'COMMON_SAMPLE_CODE';
|
const COMMON_SAMPLE_CODE_KEY = 'COMMON_SAMPLE_CODE';
|
||||||
|
|
||||||
@ -81,7 +82,7 @@ const ApiHubApiDetailPage = () => {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="text-gray-500 dark:text-gray-400">로딩 중...</div>
|
<div className="text-[var(--color-text-secondary)]">로딩 중...</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -91,11 +92,11 @@ const ApiHubApiDetailPage = () => {
|
|||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/api-hub/services/${serviceId}`)}
|
onClick={() => navigate(`/api-hub/services/${serviceId}`)}
|
||||||
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 mb-4 inline-block"
|
className="text-sm text-[var(--color-primary)] hover:text-[var(--color-primary-hover)] mb-4 inline-block"
|
||||||
>
|
>
|
||||||
← 서비스로
|
← 서비스로
|
||||||
</button>
|
</button>
|
||||||
<div className="text-center py-20 text-gray-500 dark:text-gray-400">
|
<div className="text-center py-20 text-[var(--color-text-secondary)]">
|
||||||
{error ?? 'API를 찾을 수 없습니다'}
|
{error ?? 'API를 찾을 수 없습니다'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -155,26 +156,26 @@ const ApiHubApiDetailPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<nav className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
|
<nav className="flex items-center gap-2 text-sm text-[var(--color-text-secondary)] mb-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/api-hub')}
|
onClick={() => navigate('/api-hub')}
|
||||||
className="hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
className="hover:text-[var(--color-primary)] transition-colors"
|
||||||
>
|
>
|
||||||
API HUB
|
API HUB
|
||||||
</button>
|
</button>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/api-hub/domains/${encodeURIComponent(domainLabel)}`)}
|
onClick={() => navigate(`/api-hub/domains/${encodeURIComponent(domainLabel)}`)}
|
||||||
className="hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
className="hover:text-[var(--color-primary)] transition-colors"
|
||||||
>
|
>
|
||||||
{domainLabel}
|
{domainLabel}
|
||||||
</button>
|
</button>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-gray-900 dark:text-gray-100 font-medium truncate max-w-xs">{api.apiName}</span>
|
<span className="text-[var(--color-text-primary)] font-medium truncate max-w-xs">{api.apiName}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Header Card */}
|
{/* Header Card */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6 border border-gray-100 dark:border-gray-700">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6 border border-[var(--color-border)]">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span
|
<span
|
||||||
@ -182,13 +183,13 @@ const ApiHubApiDetailPage = () => {
|
|||||||
>
|
>
|
||||||
{api.apiMethod}
|
{api.apiMethod}
|
||||||
</span>
|
</span>
|
||||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">{api.apiName}</h1>
|
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">{api.apiName}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
{hasItem(Number(apiId)) ? (
|
{hasItem(Number(apiId)) ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => removeItem(Number(apiId))}
|
onClick={() => removeItem(Number(apiId))}
|
||||||
className="px-4 py-2 rounded-lg border border-indigo-300 dark:border-indigo-600 bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-100 dark:hover:bg-indigo-900/50 text-sm font-medium transition-colors flex items-center gap-1.5"
|
className="px-4 py-2 rounded-lg border border-indigo-300 bg-indigo-50 text-indigo-700 hover:bg-indigo-100 text-sm font-medium transition-colors flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
@ -203,7 +204,7 @@ const ApiHubApiDetailPage = () => {
|
|||||||
setBasketOpen(true);
|
setBasketOpen(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 text-sm font-medium transition-colors flex items-center gap-1.5"
|
className="px-4 py-2 rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] hover:bg-[var(--color-bg-base)] text-sm font-medium transition-colors flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
@ -211,12 +212,9 @@ const ApiHubApiDetailPage = () => {
|
|||||||
신청함에 담기
|
신청함에 담기
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<Button onClick={handleOpenRequest}>
|
||||||
onClick={handleOpenRequest}
|
|
||||||
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
API 사용 신청
|
API 사용 신청
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -225,21 +223,21 @@ const ApiHubApiDetailPage = () => {
|
|||||||
|
|
||||||
{/* 기본 정보 */}
|
{/* 기본 정보 */}
|
||||||
{api.description && (
|
{api.description && (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 border border-gray-100 dark:border-gray-700">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 border border-[var(--color-border)]">
|
||||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-4">기본 정보</h2>
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-4">기본 정보</h2>
|
||||||
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">{api.description}</p>
|
<p className="text-sm text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap">{api.description}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 샘플 URL */}
|
{/* 샘플 URL */}
|
||||||
{spec?.sampleUrl && (
|
{spec?.sampleUrl && (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 border border-gray-100 dark:border-gray-700">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 border border-[var(--color-border)]">
|
||||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-3">샘플 URL</h2>
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-3">샘플 URL</h2>
|
||||||
<a
|
<a
|
||||||
href={spec.sampleUrl}
|
href={spec.sampleUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 break-all underline underline-offset-2 font-mono"
|
className="text-sm text-[var(--color-primary)] hover:text-[var(--color-primary-hover)] break-all underline underline-offset-2 font-mono"
|
||||||
>
|
>
|
||||||
{spec.sampleUrl}
|
{spec.sampleUrl}
|
||||||
</a>
|
</a>
|
||||||
@ -247,15 +245,15 @@ const ApiHubApiDetailPage = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 요청 URL 생성 (아코디언) */}
|
{/* 요청 URL 생성 (아코디언) */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-100 dark:border-gray-700">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow border border-[var(--color-border)]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setUrlGenOpen((v) => !v)}
|
onClick={() => setUrlGenOpen((v) => !v)}
|
||||||
className="w-full flex items-center justify-between px-6 py-4 text-left"
|
className="w-full flex items-center justify-between px-6 py-4 text-left"
|
||||||
>
|
>
|
||||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">요청 URL 생성</h2>
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">요청 URL 생성</h2>
|
||||||
<svg
|
<svg
|
||||||
className={`w-5 h-5 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${urlGenOpen ? 'rotate-180' : ''}`}
|
className={`w-5 h-5 text-[var(--color-text-secondary)] transition-transform duration-200 ${urlGenOpen ? 'rotate-180' : ''}`}
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@ -266,20 +264,20 @@ const ApiHubApiDetailPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{urlGenOpen && (
|
{urlGenOpen && (
|
||||||
<div className="px-6 pb-6 border-t border-gray-100 dark:border-gray-700 pt-4">
|
<div className="px-6 pb-6 border-t border-[var(--color-border)] pt-4">
|
||||||
{/* URL 생성 폼 */}
|
{/* URL 생성 폼 */}
|
||||||
{urlInputParams.length > 0 && (
|
{urlInputParams.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="space-y-3 mb-4">
|
<div className="space-y-3 mb-4">
|
||||||
{urlInputParams.map((p) => {
|
{urlInputParams.map((p) => {
|
||||||
const hasError = validationErrors[p.paramName];
|
const hasError = validationErrors[p.paramName];
|
||||||
const inputCls = `flex-1 border ${hasError ? 'border-red-400 dark:border-red-500' : 'border-gray-300 dark:border-gray-600'} bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-1.5 text-sm focus:ring-1 focus:ring-blue-500 focus:outline-none`;
|
const inputCls = `flex-1 border ${hasError ? 'border-red-400' : 'border-[var(--color-border-strong)]'} bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-1.5 text-sm focus:ring-1 focus:ring-[var(--color-primary)] focus:outline-none`;
|
||||||
const type = (p.inputType || 'TEXT').toUpperCase();
|
const type = (p.inputType || 'TEXT').toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={p.paramName}>
|
<div key={p.paramName}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<label className="w-36 shrink-0 text-sm font-medium text-gray-500 dark:text-gray-400">
|
<label className="w-36 shrink-0 text-sm font-medium text-[var(--color-text-secondary)]">
|
||||||
{p.paramName}
|
{p.paramName}
|
||||||
{p.required && <span className="text-red-500 ml-0.5">*</span>}
|
{p.required && <span className="text-red-500 ml-0.5">*</span>}
|
||||||
</label>
|
</label>
|
||||||
@ -324,31 +322,28 @@ const ApiHubApiDetailPage = () => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button onClick={handleGenerateUrl}>
|
||||||
onClick={handleGenerateUrl}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
URL 생성
|
URL 생성
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 생성된 URL */}
|
{/* 생성된 URL */}
|
||||||
{generatedUrl && (
|
{generatedUrl && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">생성된 요청 URL</p>
|
<p className="text-xs font-medium text-[var(--color-text-secondary)] mb-1.5">생성된 요청 URL</p>
|
||||||
<div className="flex items-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
<div className="flex items-center p-3 bg-[var(--color-bg-base)] rounded-lg">
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-1 rounded text-xs font-bold uppercase text-white shrink-0 mr-3 ${METHOD_COLORS_LARGE[api.apiMethod] ?? 'bg-gray-500'}`}
|
className={`px-2 py-1 rounded text-xs font-bold uppercase text-white shrink-0 mr-3 ${METHOD_COLORS_LARGE[api.apiMethod] ?? 'bg-gray-500'}`}
|
||||||
>
|
>
|
||||||
{api.apiMethod}
|
{api.apiMethod}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-sm text-gray-700 dark:text-gray-300 break-all flex-1">
|
<span className="font-mono text-sm text-[var(--color-text-primary)] break-all flex-1">
|
||||||
{generatedUrl}
|
{generatedUrl}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={handleCopyGeneratedUrl}
|
onClick={handleCopyGeneratedUrl}
|
||||||
className="ml-2 px-2 py-0.5 text-xs bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors"
|
className="ml-2 px-2 py-0.5 text-xs bg-[var(--color-bg-base)] text-[var(--color-text-tertiary)] rounded hover:bg-[var(--color-border)] transition-colors"
|
||||||
>
|
>
|
||||||
{urlCopied ? '복사됨' : '복사'}
|
{urlCopied ? '복사됨' : '복사'}
|
||||||
</button>
|
</button>
|
||||||
@ -359,7 +354,7 @@ const ApiHubApiDetailPage = () => {
|
|||||||
{/* 샘플 코드 */}
|
{/* 샘플 코드 */}
|
||||||
{commonSampleCode && (
|
{commonSampleCode && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">샘플 코드</p>
|
<p className="text-xs font-medium text-[var(--color-text-secondary)] mb-1.5">샘플 코드</p>
|
||||||
<pre className="bg-gray-900 text-green-400 text-sm font-mono p-4 rounded-lg overflow-x-auto">
|
<pre className="bg-gray-900 text-green-400 text-sm font-mono p-4 rounded-lg overflow-x-auto">
|
||||||
{commonSampleCode}
|
{commonSampleCode}
|
||||||
</pre>
|
</pre>
|
||||||
@ -367,7 +362,7 @@ const ApiHubApiDetailPage = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{urlInputParams.length === 0 && !commonSampleCode && (
|
{urlInputParams.length === 0 && !commonSampleCode && (
|
||||||
<p className="text-sm text-gray-400 dark:text-gray-500">요청인자가 등록되지 않았습니다</p>
|
<p className="text-sm text-[var(--color-text-tertiary)]">요청인자가 등록되지 않았습니다</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -375,31 +370,31 @@ const ApiHubApiDetailPage = () => {
|
|||||||
|
|
||||||
{/* 요청인자 */}
|
{/* 요청인자 */}
|
||||||
{detail.requestParams.length > 0 && (
|
{detail.requestParams.length > 0 && (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-100 dark:border-gray-700">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow border border-[var(--color-border)]">
|
||||||
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-700">
|
<div className="px-6 py-4 border-b border-[var(--color-border)]">
|
||||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">요청인자</h2>
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">요청인자</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">인자명</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">인자명</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">의미</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">의미</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">설명</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">설명</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{detail.requestParams.map((param) => (
|
{detail.requestParams.map((param) => (
|
||||||
<tr key={param.paramId} className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
<tr key={param.paramId} className="hover:bg-[var(--color-bg-base)] transition-colors">
|
||||||
<td className="px-4 py-3 font-mono text-xs text-gray-900 dark:text-gray-100 font-medium">
|
<td className="px-4 py-3 font-mono text-xs text-[var(--color-text-primary)] font-medium">
|
||||||
{param.paramName}
|
{param.paramName}
|
||||||
{param.required && <span className="text-red-500 ml-0.5">*</span>}
|
{param.required && <span className="text-red-500 ml-0.5">*</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
||||||
{param.paramMeaning ?? <span className="text-gray-400 dark:text-gray-500">-</span>}
|
{param.paramMeaning ?? <span className="text-[var(--color-text-tertiary)]">-</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400 max-w-xs">
|
<td className="px-4 py-3 text-[var(--color-text-tertiary)] max-w-xs">
|
||||||
{param.paramDescription ?? <span className="text-gray-400 dark:text-gray-500">-</span>}
|
{param.paramDescription ?? <span className="text-[var(--color-text-tertiary)]">-</span>}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@ -411,39 +406,39 @@ const ApiHubApiDetailPage = () => {
|
|||||||
|
|
||||||
{/* 출력결과 */}
|
{/* 출력결과 */}
|
||||||
{detail.responseParams.length > 0 && (
|
{detail.responseParams.length > 0 && (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-100 dark:border-gray-700">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow border border-[var(--color-border)]">
|
||||||
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-700">
|
<div className="px-6 py-4 border-b border-[var(--color-border)]">
|
||||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">출력결과</h2>
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">출력결과</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-1/4">변수명</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase w-1/4">변수명</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-1/4">의미(단위)</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase w-1/4">의미(단위)</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-1/4">변수명</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase w-1/4">변수명</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-1/4">의미(단위)</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase w-1/4">의미(단위)</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{Array.from({ length: Math.ceil(detail.responseParams.length / 2) }, (_, rowIdx) => {
|
{Array.from({ length: Math.ceil(detail.responseParams.length / 2) }, (_, rowIdx) => {
|
||||||
const left = detail.responseParams[rowIdx * 2];
|
const left = detail.responseParams[rowIdx * 2];
|
||||||
const right = detail.responseParams[rowIdx * 2 + 1];
|
const right = detail.responseParams[rowIdx * 2 + 1];
|
||||||
return (
|
return (
|
||||||
<tr key={rowIdx} className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
<tr key={rowIdx} className="hover:bg-[var(--color-bg-base)] transition-colors">
|
||||||
<td className="px-4 py-3 font-mono text-xs text-gray-900 dark:text-gray-100 font-medium">
|
<td className="px-4 py-3 font-mono text-xs text-[var(--color-text-primary)] font-medium">
|
||||||
{left.paramName}
|
{left.paramName}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
||||||
{left.paramMeaning ?? <span className="text-gray-400 dark:text-gray-500">-</span>}
|
{left.paramMeaning ?? <span className="text-[var(--color-text-tertiary)]">-</span>}
|
||||||
</td>
|
</td>
|
||||||
{right ? (
|
{right ? (
|
||||||
<>
|
<>
|
||||||
<td className="px-4 py-3 font-mono text-xs text-gray-900 dark:text-gray-100 font-medium">
|
<td className="px-4 py-3 font-mono text-xs text-[var(--color-text-primary)] font-medium">
|
||||||
{right.paramName}
|
{right.paramName}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
||||||
{right.paramMeaning ?? <span className="text-gray-400 dark:text-gray-500">-</span>}
|
{right.paramMeaning ?? <span className="text-[var(--color-text-tertiary)]">-</span>}
|
||||||
</td>
|
</td>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@ -463,13 +458,13 @@ const ApiHubApiDetailPage = () => {
|
|||||||
|
|
||||||
{/* 참고자료 */}
|
{/* 참고자료 */}
|
||||||
{spec?.referenceUrl && (
|
{spec?.referenceUrl && (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 border border-gray-100 dark:border-gray-700">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 border border-[var(--color-border)]">
|
||||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-3">참고자료</h2>
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-3">참고자료</h2>
|
||||||
<a
|
<a
|
||||||
href={spec.referenceUrl}
|
href={spec.referenceUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 break-all underline underline-offset-2"
|
className="text-sm text-[var(--color-primary)] hover:text-[var(--color-primary-hover)] break-all underline underline-offset-2"
|
||||||
>
|
>
|
||||||
{spec.referenceUrl}
|
{spec.referenceUrl}
|
||||||
</a>
|
</a>
|
||||||
@ -478,9 +473,9 @@ const ApiHubApiDetailPage = () => {
|
|||||||
|
|
||||||
{/* 비고 */}
|
{/* 비고 */}
|
||||||
{spec?.note && (
|
{spec?.note && (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 border border-gray-100 dark:border-gray-700">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 border border-[var(--color-border)]">
|
||||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-3">비고</h2>
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-3">비고</h2>
|
||||||
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">
|
<p className="text-sm text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap">
|
||||||
{spec.note}
|
{spec.note}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -114,7 +114,7 @@ const ApiHubDomainPage = () => {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="text-gray-500 dark:text-gray-400">로딩 중...</div>
|
<div className="text-[var(--color-text-secondary)]">로딩 중...</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -122,8 +122,8 @@ const ApiHubDomainPage = () => {
|
|||||||
if (!domainInfo) {
|
if (!domainInfo) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-12 text-center">
|
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-surface)] p-12 text-center">
|
||||||
<p className="text-gray-500 dark:text-gray-400">도메인을 찾을 수 없습니다.</p>
|
<p className="text-[var(--color-text-secondary)]">도메인을 찾을 수 없습니다.</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/api-hub')}
|
onClick={() => navigate('/api-hub')}
|
||||||
className="mt-4 text-sm text-indigo-500 hover:underline"
|
className="mt-4 text-sm text-indigo-500 hover:underline"
|
||||||
@ -141,7 +141,7 @@ const ApiHubDomainPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto space-y-6">
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
{/* 헤더 카드 */}
|
{/* 헤더 카드 */}
|
||||||
<div className={`relative overflow-hidden rounded-2xl border bg-white dark:bg-gray-800 ${palette.border} p-6`}>
|
<div className={`relative overflow-hidden rounded-2xl border bg-[var(--color-bg-surface)] ${palette.border} p-6`}>
|
||||||
{/* 상단 컬러 라인 */}
|
{/* 상단 컬러 라인 */}
|
||||||
<div className={`absolute inset-x-0 top-0 h-1 bg-gradient-to-r ${palette.line} to-transparent`} />
|
<div className={`absolute inset-x-0 top-0 h-1 bg-gradient-to-r ${palette.line} to-transparent`} />
|
||||||
|
|
||||||
@ -165,7 +165,7 @@ const ApiHubDomainPage = () => {
|
|||||||
|
|
||||||
{/* 도메인명 + API 개수 */}
|
{/* 도메인명 + API 개수 */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-gray-100">
|
<h1 className="text-2xl font-bold tracking-tight text-[var(--color-text-primary)]">
|
||||||
{formatDomain(domainInfo.domain)}
|
{formatDomain(domainInfo.domain)}
|
||||||
</h1>
|
</h1>
|
||||||
<p className={`mt-1 text-sm font-medium ${palette.color}`}>
|
<p className={`mt-1 text-sm font-medium ${palette.color}`}>
|
||||||
@ -176,15 +176,15 @@ const ApiHubDomainPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API 목록 */}
|
{/* API 목록 */}
|
||||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden">
|
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-surface)] overflow-hidden">
|
||||||
{/* 검색 헤더 */}
|
{/* 검색 헤더 */}
|
||||||
<div className="flex items-center justify-between gap-4 border-b border-gray-200 dark:border-gray-700 px-5 py-3">
|
<div className="flex items-center justify-between gap-4 border-b border-[var(--color-border)] px-5 py-3">
|
||||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
<h2 className="text-sm font-semibold text-[var(--color-text-primary)]">
|
||||||
API 목록
|
API 목록
|
||||||
<span className="ml-2 text-xs font-normal text-gray-400 dark:text-gray-500">{domainInfo.apis.length}건</span>
|
<span className="ml-2 text-xs font-normal text-[var(--color-text-tertiary)]">{domainInfo.apis.length}건</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-[var(--color-text-tertiary)] pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
@ -192,7 +192,7 @@ const ApiHubDomainPage = () => {
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="API 검색..."
|
placeholder="API 검색..."
|
||||||
className="w-52 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg pl-8 pr-3 py-1.5 text-xs text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:ring-1 focus:ring-indigo-500 focus:outline-none"
|
className="w-52 bg-[var(--color-bg-base)] border border-[var(--color-border)] rounded-lg pl-8 pr-3 py-1.5 text-xs text-[var(--color-text-primary)] placeholder-gray-400 focus:ring-1 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -207,22 +207,22 @@ const ApiHubDomainPage = () => {
|
|||||||
|
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="px-5 py-12 text-center text-sm text-gray-400 dark:text-gray-500">
|
<div className="px-5 py-12 text-center text-sm text-[var(--color-text-tertiary)]">
|
||||||
{searchQuery.trim() ? '검색 결과가 없습니다.' : '등록된 API가 없습니다.'}
|
{searchQuery.trim() ? '검색 결과가 없습니다.' : '등록된 API가 없습니다.'}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="divide-y divide-gray-100 dark:divide-gray-700/50">
|
<div className="divide-y divide-[var(--color-border)]">
|
||||||
{filtered.map((api) => (
|
{filtered.map((api) => (
|
||||||
<div
|
<div
|
||||||
key={`${api.serviceId}-${api.apiId}`}
|
key={`${api.serviceId}-${api.apiId}`}
|
||||||
onClick={() => navigate(`/api-hub/services/${api.serviceId}/apis/${api.apiId}`)}
|
onClick={() => navigate(`/api-hub/services/${api.serviceId}/apis/${api.apiId}`)}
|
||||||
className="flex items-center gap-4 px-5 py-3.5 cursor-pointer transition-colors hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
className="flex items-center gap-4 px-5 py-3.5 cursor-pointer transition-colors hover:bg-[var(--color-bg-base)]"
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">{api.apiName}</p>
|
<p className="text-sm font-semibold text-[var(--color-text-primary)] truncate">{api.apiName}</p>
|
||||||
</div>
|
</div>
|
||||||
{hasItem(api.apiId) ? (
|
{hasItem(api.apiId) ? (
|
||||||
<button
|
<button
|
||||||
@ -237,7 +237,7 @@ const ApiHubDomainPage = () => {
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); addItem({ apiId: api.apiId, serviceId: api.serviceId, apiName: api.apiName, domain: domainInfo?.domain }); }}
|
onClick={(e) => { e.stopPropagation(); addItem({ apiId: api.apiId, serviceId: api.serviceId, apiName: api.apiName, domain: domainInfo?.domain }); }}
|
||||||
className="flex-shrink-0 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 text-xs font-medium transition-colors flex items-center gap-1"
|
className="flex-shrink-0 px-3 py-1.5 rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] hover:bg-[var(--color-bg-base)] text-xs font-medium transition-colors flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
@ -245,7 +245,7 @@ const ApiHubDomainPage = () => {
|
|||||||
신청함에 담기
|
신청함에 담기
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<svg className="h-4 w-4 flex-shrink-0 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="h-4 w-4 flex-shrink-0 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -39,12 +39,12 @@ interface DomainSectionProps {
|
|||||||
const DomainSection = ({ domainName, apis, serviceId, onNavigate }: DomainSectionProps) => (
|
const DomainSection = ({ domainName, apis, serviceId, onNavigate }: DomainSectionProps) => (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200">{domainName}</h3>
|
<h3 className="text-base font-semibold text-[var(--color-text-primary)]">{domainName}</h3>
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400">
|
<span className="text-xs px-2 py-0.5 rounded-full bg-[var(--color-bg-base)] text-[var(--color-text-secondary)]">
|
||||||
{apis.length}개
|
{apis.length}개
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden border border-gray-100 dark:border-gray-700">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow overflow-hidden border border-[var(--color-border)]">
|
||||||
<table className="w-full text-sm table-fixed">
|
<table className="w-full text-sm table-fixed">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col className="w-[8%]" />
|
<col className="w-[8%]" />
|
||||||
@ -53,16 +53,16 @@ const DomainSection = ({ domainName, apis, serviceId, onNavigate }: DomainSectio
|
|||||||
<col className="w-[40%]" />
|
<col className="w-[40%]" />
|
||||||
<col className="w-[5%]" />
|
<col className="w-[5%]" />
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">메서드</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">메서드</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">경로</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">경로</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">API명</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">API명</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">설명</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">설명</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">상태</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">상태</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{apis.map((api) => (
|
{apis.map((api) => (
|
||||||
<tr
|
<tr
|
||||||
key={api.apiId}
|
key={api.apiId}
|
||||||
@ -76,14 +76,14 @@ const DomainSection = ({ domainName, apis, serviceId, onNavigate }: DomainSectio
|
|||||||
{api.apiMethod}
|
{api.apiMethod}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 font-mono text-xs text-gray-700 dark:text-gray-300 truncate" title={api.apiPath}>
|
<td className="px-4 py-3 font-mono text-xs text-[var(--color-text-primary)] truncate" title={api.apiPath}>
|
||||||
{api.apiPath}
|
{api.apiPath}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100 font-medium truncate" title={api.apiName}>
|
<td className="px-4 py-3 text-[var(--color-text-primary)] font-medium truncate" title={api.apiName}>
|
||||||
{api.apiName}
|
{api.apiName}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 truncate" title={api.description || ''}>
|
<td className="px-4 py-3 text-[var(--color-text-secondary)] truncate" title={api.description || ''}>
|
||||||
{api.description || <span className="text-gray-300 dark:text-gray-600">-</span>}
|
{api.description || <span className="text-[var(--color-text-tertiary)]">-</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{api.isActive ? (
|
{api.isActive ? (
|
||||||
@ -134,7 +134,7 @@ const ApiHubServicePage = () => {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="text-gray-500 dark:text-gray-400">로딩 중...</div>
|
<div className="text-[var(--color-text-secondary)]">로딩 중...</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -144,11 +144,11 @@ const ApiHubServicePage = () => {
|
|||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/api-hub')}
|
onClick={() => navigate('/api-hub')}
|
||||||
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 mb-4 inline-block"
|
className="text-sm text-[var(--color-primary)] hover:text-blue-800 mb-4 inline-block"
|
||||||
>
|
>
|
||||||
← API HUB으로
|
← API HUB으로
|
||||||
</button>
|
</button>
|
||||||
<div className="text-center py-20 text-gray-500 dark:text-gray-400">
|
<div className="text-center py-20 text-[var(--color-text-secondary)]">
|
||||||
{error ?? '서비스를 찾을 수 없습니다'}
|
{error ?? '서비스를 찾을 수 없습니다'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -168,21 +168,21 @@ const ApiHubServicePage = () => {
|
|||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/api-hub')}
|
onClick={() => navigate('/api-hub')}
|
||||||
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 mb-4 inline-block"
|
className="text-sm text-[var(--color-primary)] hover:text-blue-800 mb-4 inline-block"
|
||||||
>
|
>
|
||||||
← API HUB으로
|
← API HUB으로
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Service Header */}
|
{/* Service Header */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6 border border-gray-100 dark:border-gray-700">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6 border border-[var(--color-border)]">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div className="flex items-center gap-3 mb-1">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{service.serviceName}</h1>
|
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">{service.serviceName}</h1>
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400 font-mono">{service.serviceCode}</span>
|
<span className="text-sm text-[var(--color-text-secondary)] font-mono">{service.serviceCode}</span>
|
||||||
</div>
|
</div>
|
||||||
{service.description && (
|
{service.description && (
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-2">{service.description}</p>
|
<p className="text-[var(--color-text-secondary)] mt-2">{service.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-2 ml-6 shrink-0">
|
<div className="flex flex-col items-end gap-2 ml-6 shrink-0">
|
||||||
@ -192,7 +192,7 @@ const ApiHubServicePage = () => {
|
|||||||
<span className={`w-2 h-2 rounded-full ${HEALTH_DOT[service.healthStatus] ?? 'bg-gray-400'}`} />
|
<span className={`w-2 h-2 rounded-full ${HEALTH_DOT[service.healthStatus] ?? 'bg-gray-400'}`} />
|
||||||
{HEALTH_LABEL[service.healthStatus] ?? service.healthStatus}
|
{HEALTH_LABEL[service.healthStatus] ?? service.healthStatus}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
|
<div className="flex items-center gap-4 text-sm text-[var(--color-text-secondary)]">
|
||||||
<span>API {service.apiCount}개</span>
|
<span>API {service.apiCount}개</span>
|
||||||
<span>도메인 {service.domains.length}개</span>
|
<span>도메인 {service.domains.length}개</span>
|
||||||
</div>
|
</div>
|
||||||
@ -212,7 +212,7 @@ const ApiHubServicePage = () => {
|
|||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-8 text-center text-gray-400 dark:text-gray-500">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-8 text-center text-[var(--color-text-tertiary)]">
|
||||||
등록된 API가 없습니다
|
등록된 API가 없습니다
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -9,15 +9,17 @@ import {
|
|||||||
reviewRequest,
|
reviewRequest,
|
||||||
} from '../../services/apiKeyService';
|
} from '../../services/apiKeyService';
|
||||||
import { getServices, getServiceApis } from '../../services/serviceService';
|
import { getServices, getServiceApis } from '../../services/serviceService';
|
||||||
|
import Badge, { type BadgeVariant } from '../../components/ui/Badge';
|
||||||
|
import Button from '../../components/ui/Button';
|
||||||
|
|
||||||
const STATUS_BADGE: Record<string, string> = {
|
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||||
ACTIVE: 'bg-green-100 text-green-800',
|
ACTIVE: 'success',
|
||||||
PENDING: 'bg-yellow-100 text-yellow-800',
|
PENDING: 'warning',
|
||||||
REVOKED: 'bg-red-100 text-red-800',
|
REVOKED: 'danger',
|
||||||
EXPIRED: 'bg-gray-100 text-gray-800',
|
EXPIRED: 'default',
|
||||||
INACTIVE: 'bg-gray-100 text-gray-800',
|
INACTIVE: 'default',
|
||||||
APPROVED: 'bg-green-100 text-green-800',
|
APPROVED: 'success',
|
||||||
REJECTED: 'bg-red-100 text-red-800',
|
REJECTED: 'danger',
|
||||||
};
|
};
|
||||||
|
|
||||||
const METHOD_BADGE_STYLE: Record<string, string> = {
|
const METHOD_BADGE_STYLE: Record<string, string> = {
|
||||||
@ -342,108 +344,108 @@ const KeyAdminPage = () => {
|
|||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div className="flex items-center gap-3 mb-1">
|
||||||
<div className="w-10 h-10 rounded-xl bg-blue-50 dark:bg-blue-900/20 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-xl bg-[var(--color-primary-subtle)] flex items-center justify-center">
|
||||||
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
|
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">API Key 관리</h1>
|
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">API Key 관리</h1>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">API Key 신청 검토 및 발급된 키의 수명주기를 관리합니다</p>
|
<p className="text-sm text-[var(--color-text-secondary)]">API Key 신청 검토 및 발급된 키의 수명주기를 관리합니다</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && !isReviewModalOpen && !isDetailModalOpen && (
|
{error && !isReviewModalOpen && !isDetailModalOpen && (
|
||||||
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/10 text-red-700 dark:text-red-400 rounded-lg text-sm border border-red-200 dark:border-red-800/30">{error}</div>
|
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm border border-red-200">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* KPI Cards */}
|
{/* KPI Cards */}
|
||||||
<div className="flex flex-wrap gap-4 mb-6">
|
<div className="flex flex-wrap gap-4 mb-6">
|
||||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-5 flex items-center gap-4 flex-1 min-w-[200px]">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-5 flex items-center gap-4 flex-1 min-w-[200px]">
|
||||||
<div className="w-11 h-11 rounded-xl flex items-center justify-center bg-amber-50 dark:bg-amber-900/20">
|
<div className="w-11 h-11 rounded-xl flex items-center justify-center bg-amber-50">
|
||||||
<svg className="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
<svg className="w-5 h-5 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">{pendingCount}</div>
|
<div className="text-3xl font-bold text-[var(--color-text-primary)]">{pendingCount}</div>
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">대기 중 신청</div>
|
<div className="text-sm text-[var(--color-text-secondary)]">대기 중 신청</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-5 flex items-center gap-4 flex-1 min-w-[200px]">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-5 flex items-center gap-4 flex-1 min-w-[200px]">
|
||||||
<div className="w-11 h-11 rounded-xl flex items-center justify-center bg-green-50 dark:bg-green-900/20">
|
<div className="w-11 h-11 rounded-xl flex items-center justify-center bg-green-50">
|
||||||
<svg className="w-5 h-5 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
|
<svg className="w-5 h-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">{activeKeyCount}</div>
|
<div className="text-3xl font-bold text-[var(--color-text-primary)]">{activeKeyCount}</div>
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">활성 키</div>
|
<div className="text-sm text-[var(--color-text-secondary)]">활성 키</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-5 flex items-center gap-4 flex-1 min-w-[200px]">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-5 flex items-center gap-4 flex-1 min-w-[200px]">
|
||||||
<div className="w-11 h-11 rounded-xl flex items-center justify-center bg-amber-50 dark:bg-amber-900/20">
|
<div className="w-11 h-11 rounded-xl flex items-center justify-center bg-amber-50">
|
||||||
<svg className="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
<svg className="w-5 h-5 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">{expiringCount}</div>
|
<div className="text-3xl font-bold text-[var(--color-text-primary)]">{expiringCount}</div>
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">만료 임박 (14일)</div>
|
<div className="text-sm text-[var(--color-text-secondary)]">만료 임박 (14일)</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-5 flex items-center gap-4 flex-1 min-w-[200px]">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl p-5 flex items-center gap-4 flex-1 min-w-[200px]">
|
||||||
<div className="w-11 h-11 rounded-xl flex items-center justify-center bg-red-50 dark:bg-red-900/20">
|
<div className="w-11 h-11 rounded-xl flex items-center justify-center bg-red-50">
|
||||||
<svg className="w-5 h-5 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" /></svg>
|
<svg className="w-5 h-5 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" /></svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-3xl font-bold text-gray-900 dark:text-gray-100">{revokedCount}</div>
|
<div className="text-3xl font-bold text-[var(--color-text-primary)]">{revokedCount}</div>
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">폐기된 키</div>
|
<div className="text-sm text-[var(--color-text-secondary)]">폐기된 키</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Unified Card Container */}
|
{/* Unified Card Container */}
|
||||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
|
<div className="bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl overflow-hidden">
|
||||||
{/* Tab Header */}
|
{/* Tab Header */}
|
||||||
<div className="flex items-center justify-between border-b border-gray-200 dark:border-gray-700 px-4">
|
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-4">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleTabSwitch('requests')}
|
onClick={() => handleTabSwitch('requests')}
|
||||||
className={`flex items-center gap-2 px-4 py-3.5 text-sm -mb-px border-b-2 transition-colors ${
|
className={`flex items-center gap-2 px-4 py-3.5 text-sm -mb-px border-b-2 transition-colors ${
|
||||||
activeTab === 'requests'
|
activeTab === 'requests'
|
||||||
? 'border-blue-600 text-blue-600 dark:text-blue-400 font-semibold'
|
? 'border-[var(--color-primary)] text-[var(--color-primary)] font-semibold'
|
||||||
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
|
: 'border-transparent text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
||||||
신청 관리
|
신청 관리
|
||||||
{pendingCount > 0 && (
|
{pendingCount > 0 && (
|
||||||
<span className="bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 text-xs font-semibold px-2 py-0.5 rounded-full">{pendingCount}</span>
|
<Badge variant="warning" size="sm">{pendingCount}</Badge>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleTabSwitch('keys')}
|
onClick={() => handleTabSwitch('keys')}
|
||||||
className={`flex items-center gap-2 px-4 py-3.5 text-sm -mb-px border-b-2 transition-colors ${
|
className={`flex items-center gap-2 px-4 py-3.5 text-sm -mb-px border-b-2 transition-colors ${
|
||||||
activeTab === 'keys'
|
activeTab === 'keys'
|
||||||
? 'border-blue-600 text-blue-600 dark:text-blue-400 font-semibold'
|
? 'border-[var(--color-primary)] text-[var(--color-primary)] font-semibold'
|
||||||
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
|
: 'border-transparent text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
|
||||||
키 관리
|
키 관리
|
||||||
{activeKeyCount > 0 && (
|
{activeKeyCount > 0 && (
|
||||||
<span className="bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 text-xs font-semibold px-2 py-0.5 rounded-full">{activeKeyCount}</span>
|
<Badge variant="primary" size="sm">{activeKeyCount}</Badge>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<svg className="w-4 h-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
<svg className="w-4 h-4 text-[var(--color-text-tertiary)] absolute left-3 top-1/2 -translate-y-1/2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="검색..."
|
placeholder="검색..."
|
||||||
className="bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg pl-9 pr-3 py-2 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 w-56 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="bg-[var(--color-bg-base)] border border-[var(--color-border)] rounded-lg pl-9 pr-3 py-2 text-sm text-[var(--color-text-primary)] placeholder-gray-400 w-56 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter Chips */}
|
{/* Filter Chips */}
|
||||||
<div className="px-4 py-3 border-b border-gray-100 dark:border-gray-700/50 flex items-center gap-2">
|
<div className="px-4 py-3 border-b border-[var(--color-border)] flex items-center gap-2">
|
||||||
{activeTab === 'requests' ? (
|
{activeTab === 'requests' ? (
|
||||||
<>
|
<>
|
||||||
{([['ALL', '전체'], ['PENDING', '대기'], ['APPROVED', '승인'], ['REJECTED', '반려']] as const).map(([value, label]) => (
|
{([['ALL', '전체'], ['PENDING', '대기'], ['APPROVED', '승인'], ['REJECTED', '반려']] as const).map(([value, label]) => (
|
||||||
@ -452,12 +454,12 @@ const KeyAdminPage = () => {
|
|||||||
onClick={() => { setRequestFilter(value); setRequestPage(0); }}
|
onClick={() => { setRequestFilter(value); setRequestPage(0); }}
|
||||||
className={`px-3.5 py-1.5 rounded-full text-sm font-medium border cursor-pointer transition-colors ${
|
className={`px-3.5 py-1.5 rounded-full text-sm font-medium border cursor-pointer transition-colors ${
|
||||||
requestFilter === value
|
requestFilter === value
|
||||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400'
|
? 'bg-[var(--color-primary-subtle)] border-[var(--color-primary)] text-[var(--color-primary)]'
|
||||||
: 'border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-base)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
<span className="ml-1.5 text-xs opacity-70">{requestStatusCounts[value] ?? 0}</span>
|
<Badge size="sm" className="ml-1.5 opacity-70">{requestStatusCounts[value] ?? 0}</Badge>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@ -469,12 +471,12 @@ const KeyAdminPage = () => {
|
|||||||
onClick={() => { setKeyFilter(value); setKeyPage(0); }}
|
onClick={() => { setKeyFilter(value); setKeyPage(0); }}
|
||||||
className={`px-3.5 py-1.5 rounded-full text-sm font-medium border cursor-pointer transition-colors ${
|
className={`px-3.5 py-1.5 rounded-full text-sm font-medium border cursor-pointer transition-colors ${
|
||||||
keyFilter === value
|
keyFilter === value
|
||||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400'
|
? 'bg-[var(--color-primary-subtle)] border-[var(--color-primary)] text-[var(--color-primary)]'
|
||||||
: 'border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-base)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
<span className="ml-1.5 text-xs opacity-70">{keyStatusCounts[value] ?? 0}</span>
|
<Badge size="sm" className="ml-1.5 opacity-70">{keyStatusCounts[value] ?? 0}</Badge>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@ -485,57 +487,59 @@ const KeyAdminPage = () => {
|
|||||||
{activeTab === 'requests' && (
|
{activeTab === 'requests' && (
|
||||||
<>
|
<>
|
||||||
{requestsLoading ? (
|
{requestsLoading ? (
|
||||||
<div className="text-center py-10 text-gray-500 dark:text-gray-400">로딩 중...</div>
|
<div className="text-center py-10 text-[var(--color-text-secondary)]">로딩 중...</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-800/80">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">신청자</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">신청자</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">키 이름</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">키 이름</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">상태</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">상태</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">API 수</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">API 수</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">신청일시</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">신청일시</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Actions</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{pagedRequests.map((req) => (
|
{pagedRequests.map((req) => (
|
||||||
<tr key={req.requestId} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
<tr key={req.requestId} className="hover:bg-[var(--color-bg-base)] transition-colors">
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<svg className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" /><circle cx="12" cy="7" r="4" /></svg>
|
<svg className="w-3.5 h-3.5 text-[var(--color-text-tertiary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" /><circle cx="12" cy="7" r="4" /></svg>
|
||||||
{req.userName}
|
{req.userName}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100 font-medium">{req.keyName}</td>
|
<td className="px-4 py-3 text-[var(--color-text-primary)] font-medium">{req.keyName}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className={`inline-block px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[req.status] || 'bg-gray-100 text-gray-800'}`}>
|
<Badge variant={STATUS_VARIANT[req.status] || 'default'}>
|
||||||
{req.status === 'PENDING' ? '대기' : req.status === 'APPROVED' ? '승인' : '반려'}
|
{req.status === 'PENDING' ? '대기' : req.status === 'APPROVED' ? '승인' : '반려'}
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 px-2 py-0.5 rounded text-xs font-semibold">{req.requestedApiIds.length}개</span>
|
<span className="bg-[var(--color-primary-subtle)] text-[var(--color-primary)] px-2 py-0.5 rounded text-xs font-semibold">{req.requestedApiIds.length}개</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{formatDateTime(req.createdAt)}</td>
|
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{formatDateTime(req.createdAt)}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{req.status === 'PENDING' && (
|
{req.status === 'PENDING' && (
|
||||||
<button
|
<Button
|
||||||
onClick={() => handleOpenReview(req)}
|
onClick={() => handleOpenReview(req)}
|
||||||
className="bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-800/40 dark:text-blue-400 px-3 py-1 rounded-lg text-sm font-medium"
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
검토
|
검토
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{(req.status === 'APPROVED' || req.status === 'REJECTED') && (
|
{(req.status === 'APPROVED' || req.status === 'REJECTED') && (
|
||||||
<button
|
<Button
|
||||||
onClick={() => handleOpenDetail(req)}
|
onClick={() => handleOpenDetail(req)}
|
||||||
className="bg-gray-100 hover:bg-gray-200 text-gray-700 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-gray-300 px-3 py-1 rounded-lg text-sm font-medium"
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
상세
|
상세
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -543,7 +547,7 @@ const KeyAdminPage = () => {
|
|||||||
))}
|
))}
|
||||||
{filteredRequests.length === 0 && (
|
{filteredRequests.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
|
<td colSpan={6} className="px-4 py-8 text-center text-[var(--color-text-tertiary)]">
|
||||||
{searchQuery || requestFilter !== 'ALL' ? '조건에 맞는 신청이 없습니다.' : '신청 내역이 없습니다.'}
|
{searchQuery || requestFilter !== 'ALL' ? '조건에 맞는 신청이 없습니다.' : '신청 내역이 없습니다.'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -552,15 +556,15 @@ const KeyAdminPage = () => {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{/* Table Footer */}
|
{/* Table Footer */}
|
||||||
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-700/50 flex items-center justify-between">
|
<div className="px-4 py-3 border-t border-[var(--color-border)] flex items-center justify-between">
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">총 {filteredRequests.length}건</span>
|
<span className="text-sm text-[var(--color-text-secondary)]">총 {filteredRequests.length}건</span>
|
||||||
{requestTotalPages > 1 && (
|
{requestTotalPages > 1 && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button onClick={() => setRequestPage(Math.max(0, requestPage - 1))} disabled={requestPage === 0}
|
<button onClick={() => setRequestPage(Math.max(0, requestPage - 1))} disabled={requestPage === 0}
|
||||||
className="px-2.5 py-1 text-xs rounded border border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 disabled:opacity-40">이전</button>
|
className="px-2.5 py-1 text-xs rounded border border-[var(--color-border)] text-[var(--color-text-secondary)] disabled:opacity-40">이전</button>
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 px-2">{requestPage + 1} / {requestTotalPages}</span>
|
<span className="text-xs text-[var(--color-text-secondary)] px-2">{requestPage + 1} / {requestTotalPages}</span>
|
||||||
<button onClick={() => setRequestPage(Math.min(requestTotalPages - 1, requestPage + 1))} disabled={requestPage >= requestTotalPages - 1}
|
<button onClick={() => setRequestPage(Math.min(requestTotalPages - 1, requestPage + 1))} disabled={requestPage >= requestTotalPages - 1}
|
||||||
className="px-2.5 py-1 text-xs rounded border border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 disabled:opacity-40">다음</button>
|
className="px-2.5 py-1 text-xs rounded border border-[var(--color-border)] text-[var(--color-text-secondary)] disabled:opacity-40">다음</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -573,29 +577,29 @@ const KeyAdminPage = () => {
|
|||||||
{activeTab === 'keys' && (
|
{activeTab === 'keys' && (
|
||||||
<>
|
<>
|
||||||
{keysLoading ? (
|
{keysLoading ? (
|
||||||
<div className="text-center py-10 text-gray-500 dark:text-gray-400">로딩 중...</div>
|
<div className="text-center py-10 text-[var(--color-text-secondary)]">로딩 중...</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-800/80">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">소유자</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">소유자</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">키 이름</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">키 이름</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Prefix</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">Prefix</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">상태</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">상태</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">만료일시</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">만료일시</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">마지막 사용일시</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">마지막 사용일시</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Actions</th>
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{pagedKeys.map((key) => {
|
{pagedKeys.map((key) => {
|
||||||
const daysLeft = getDaysUntilExpiry(key.expiresAt);
|
const daysLeft = getDaysUntilExpiry(key.expiresAt);
|
||||||
const isExpiringSoon = key.status === 'ACTIVE' && daysLeft !== null && daysLeft <= 14;
|
const isExpiringSoon = key.status === 'ACTIVE' && daysLeft !== null && daysLeft <= 14;
|
||||||
return (
|
return (
|
||||||
<tr key={key.apiKeyId} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
<tr key={key.apiKeyId} className="hover:bg-[var(--color-bg-base)] transition-colors">
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<svg className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" /><circle cx="12" cy="7" r="4" /></svg>
|
<svg className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" /><circle cx="12" cy="7" r="4" /></svg>
|
||||||
{key.userName || '-'}
|
{key.userName || '-'}
|
||||||
@ -603,39 +607,41 @@ const KeyAdminPage = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100 font-medium">{key.keyName}</td>
|
<td className="px-4 py-3 text-gray-900 dark:text-gray-100 font-medium">{key.keyName}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 px-2 py-0.5 rounded text-xs font-mono font-semibold">{key.apiKeyPrefix}</span>
|
<Badge variant="info" className="font-mono font-semibold">{key.apiKeyPrefix}</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className={`inline-block px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[key.status] || 'bg-gray-100 text-gray-800'}`}>
|
<Badge variant={STATUS_VARIANT[key.status] || 'default'}>
|
||||||
{KEY_STATUS_CONFIG[key.status]?.label || key.status}
|
{KEY_STATUS_CONFIG[key.status]?.label || key.status}
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-gray-500 dark:text-gray-400">{key.expiresAt ? formatDateTime(key.expiresAt) : '영구'}</span>
|
<span className="text-[var(--color-text-secondary)]">{key.expiresAt ? formatDateTime(key.expiresAt) : '영구'}</span>
|
||||||
{isExpiringSoon && (
|
{isExpiringSoon && (
|
||||||
<span className={`text-xs font-medium px-1.5 py-0.5 rounded ${daysLeft !== null && daysLeft <= 3 ? 'bg-red-100 dark:bg-red-900/20 text-red-600 dark:text-red-400' : 'bg-amber-100 dark:bg-amber-900/20 text-amber-600 dark:text-amber-400'}`}>
|
<Badge variant={daysLeft !== null && daysLeft <= 3 ? 'danger' : 'warning'} size="sm">
|
||||||
⚠ {daysLeft}일 남음
|
⚠ {daysLeft}일 남음
|
||||||
</span>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{formatDateTime(key.lastUsedAt)}</td>
|
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{formatDateTime(key.lastUsedAt)}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<Button
|
||||||
onClick={() => handleViewDetail(key)}
|
onClick={() => handleViewDetail(key)}
|
||||||
className="bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-800/40 dark:text-blue-400 px-3 py-1 rounded-lg text-sm font-medium"
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
상세
|
상세
|
||||||
</button>
|
</Button>
|
||||||
{key.status === 'ACTIVE' && (
|
{key.status === 'ACTIVE' && (
|
||||||
<button
|
<Button
|
||||||
onClick={() => handleRevokeKey(key)}
|
onClick={() => handleRevokeKey(key)}
|
||||||
className="bg-red-100 hover:bg-red-200 text-red-700 dark:bg-red-900/30 dark:hover:bg-red-800/40 dark:text-red-400 px-3 py-1 rounded-lg text-sm font-medium"
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
폐기
|
폐기
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -804,9 +810,9 @@ const KeyAdminPage = () => {
|
|||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">{isReviewReadOnly ? '신청 상세' : 'API Key 신청 검토'}</h2>
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">{isReviewReadOnly ? '신청 상세' : 'API Key 신청 검토'}</h2>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500">{formatDateTime(selectedRequest.createdAt)}</p>
|
<p className="text-xs text-gray-500 dark:text-gray-500">{formatDateTime(selectedRequest.createdAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={`inline-block px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[selectedRequest.status] || 'bg-gray-100 text-gray-800'}`}>
|
<Badge variant={STATUS_VARIANT[selectedRequest.status] || 'default'}>
|
||||||
{selectedRequest.status === 'PENDING' ? '대기중' : selectedRequest.status === 'APPROVED' ? '승인' : '반려'}
|
{selectedRequest.status === 'PENDING' ? '대기중' : selectedRequest.status === 'APPROVED' ? '승인' : '반려'}
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleCloseReview} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1 rounded-lg hover:bg-gray-200/50 dark:hover:bg-gray-700/50">
|
<button onClick={handleCloseReview} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1 rounded-lg hover:bg-gray-200/50 dark:hover:bg-gray-700/50">
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
|||||||
import { getCatalog } from '../../services/apiHubService';
|
import { getCatalog } from '../../services/apiHubService';
|
||||||
import { createKeyRequest } from '../../services/apiKeyService';
|
import { createKeyRequest } from '../../services/apiKeyService';
|
||||||
import type { ServiceCatalog } from '../../types/apihub';
|
import type { ServiceCatalog } from '../../types/apihub';
|
||||||
|
import Button from '../../components/ui/Button';
|
||||||
|
|
||||||
const IndeterminateCheckbox = ({ checked, indeterminate, onChange, className }: { checked: boolean; indeterminate: boolean; onChange: () => void; className?: string }) => {
|
const IndeterminateCheckbox = ({ checked, indeterminate, onChange, className }: { checked: boolean; indeterminate: boolean; onChange: () => void; className?: string }) => {
|
||||||
const ref = useRef<HTMLInputElement>(null);
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
@ -265,7 +266,7 @@ const KeyRequestPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="max-w-7xl mx-auto text-center py-10 text-gray-500 dark:text-gray-400">로딩 중...</div>;
|
return <div className="max-w-7xl mx-auto text-center py-10 text-[var(--color-text-secondary)]">로딩 중...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
@ -277,12 +278,12 @@ const KeyRequestPage = () => {
|
|||||||
<p className="text-green-700 text-sm mb-4">
|
<p className="text-green-700 text-sm mb-4">
|
||||||
관리자 승인 후 API Key가 생성됩니다.
|
관리자 승인 후 API Key가 생성됩니다.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Button
|
||||||
onClick={() => navigate('/apikeys/my-keys')}
|
onClick={() => navigate('/apikeys/my-keys')}
|
||||||
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
variant="primary"
|
||||||
>
|
>
|
||||||
내 키 목록으로 이동
|
내 키 목록으로 이동
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -291,16 +292,16 @@ const KeyRequestPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6">API Key 신청</h1>
|
<h1 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6">API Key 신청</h1>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6 space-y-4">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
Key Name <span className="text-red-500">*</span>
|
Key Name <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -309,97 +310,97 @@ const KeyRequestPage = () => {
|
|||||||
onChange={(e) => setKeyName(e.target.value)}
|
onChange={(e) => setKeyName(e.target.value)}
|
||||||
required
|
required
|
||||||
placeholder="API Key 이름을 입력하세요"
|
placeholder="API Key 이름을 입력하세요"
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">사용 목적</label>
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">사용 목적</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={purpose}
|
value={purpose}
|
||||||
onChange={(e) => setPurpose(e.target.value)}
|
onChange={(e) => setPurpose(e.target.value)}
|
||||||
rows={2}
|
rows={2}
|
||||||
placeholder="사용 목적을 입력하세요"
|
placeholder="사용 목적을 입력하세요"
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
사용 기간 <span className="text-red-500">*</span>
|
사용 기간 <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<button type="button" onClick={() => handlePresetPeriod(3)}
|
<button type="button" onClick={() => handlePresetPeriod(3)}
|
||||||
disabled={isPermanent || usagePeriodMode === 'custom'}
|
disabled={isPermanent || usagePeriodMode === 'custom'}
|
||||||
className={`px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 ${isPermanent || usagePeriodMode === 'custom' ? 'opacity-40 cursor-not-allowed' : 'hover:bg-blue-50 dark:hover:bg-gray-600'}`}>
|
className={`px-3 py-1.5 text-sm rounded-lg border border-[var(--color-border-strong)] text-[var(--color-text-primary)] bg-[var(--color-bg-surface)] ${isPermanent || usagePeriodMode === 'custom' ? 'opacity-40 cursor-not-allowed' : 'hover:bg-[var(--color-bg-base)]'}`}>
|
||||||
3개월
|
3개월
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => handlePresetPeriod(6)}
|
<button type="button" onClick={() => handlePresetPeriod(6)}
|
||||||
disabled={isPermanent || usagePeriodMode === 'custom'}
|
disabled={isPermanent || usagePeriodMode === 'custom'}
|
||||||
className={`px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 ${isPermanent || usagePeriodMode === 'custom' ? 'opacity-40 cursor-not-allowed' : 'hover:bg-blue-50 dark:hover:bg-gray-600'}`}>
|
className={`px-3 py-1.5 text-sm rounded-lg border border-[var(--color-border-strong)] text-[var(--color-text-primary)] bg-[var(--color-bg-surface)] ${isPermanent || usagePeriodMode === 'custom' ? 'opacity-40 cursor-not-allowed' : 'hover:bg-[var(--color-bg-base)]'}`}>
|
||||||
6개월
|
6개월
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => handlePresetPeriod(9)}
|
<button type="button" onClick={() => handlePresetPeriod(9)}
|
||||||
disabled={isPermanent || usagePeriodMode === 'custom'}
|
disabled={isPermanent || usagePeriodMode === 'custom'}
|
||||||
className={`px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 ${isPermanent || usagePeriodMode === 'custom' ? 'opacity-40 cursor-not-allowed' : 'hover:bg-blue-50 dark:hover:bg-gray-600'}`}>
|
className={`px-3 py-1.5 text-sm rounded-lg border border-[var(--color-border-strong)] text-[var(--color-text-primary)] bg-[var(--color-bg-surface)] ${isPermanent || usagePeriodMode === 'custom' ? 'opacity-40 cursor-not-allowed' : 'hover:bg-[var(--color-bg-base)]'}`}>
|
||||||
9개월
|
9개월
|
||||||
</button>
|
</button>
|
||||||
<span className="text-gray-400 dark:text-gray-600 mx-1">|</span>
|
<span className="text-[var(--color-text-tertiary)] mx-1">|</span>
|
||||||
<button type="button" onClick={handlePermanent}
|
<Button type="button" size="sm" onClick={handlePermanent}
|
||||||
className={`px-3 py-1.5 text-sm rounded-lg border font-medium ${isPermanent ? 'bg-indigo-600 text-white border-indigo-600' : 'text-indigo-600 border-indigo-300 dark:border-indigo-500 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/30'}`}>
|
variant={isPermanent ? 'primary' : 'outline'}>
|
||||||
영구
|
영구
|
||||||
</button>
|
</Button>
|
||||||
<span className="text-gray-400 dark:text-gray-600 mx-1">|</span>
|
<span className="text-[var(--color-text-tertiary)] mx-1">|</span>
|
||||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none">
|
<label className="flex items-center gap-2 text-sm text-[var(--color-text-primary)] cursor-pointer select-none">
|
||||||
직접 선택
|
직접 선택
|
||||||
<button type="button"
|
<button type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUsagePeriodMode(usagePeriodMode === 'custom' ? 'preset' : 'custom');
|
setUsagePeriodMode(usagePeriodMode === 'custom' ? 'preset' : 'custom');
|
||||||
setIsPermanent(false);
|
setIsPermanent(false);
|
||||||
}}
|
}}
|
||||||
className={`relative w-10 h-5 rounded-full transition-colors ${usagePeriodMode === 'custom' && !isPermanent ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'}`}>
|
className={`relative w-10 h-5 rounded-full transition-colors ${usagePeriodMode === 'custom' && !isPermanent ? 'bg-[var(--color-primary)] dark:bg-[var(--color-primary-600)]' : 'bg-[var(--color-border-strong)]'}`}>
|
||||||
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${usagePeriodMode === 'custom' && !isPermanent ? 'translate-x-5' : ''}`} />
|
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${usagePeriodMode === 'custom' && !isPermanent ? 'translate-x-5' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{isPermanent ? (
|
{isPermanent ? (
|
||||||
<div className="flex items-center gap-2 px-3 py-2 bg-indigo-50 dark:bg-indigo-900/30 border border-indigo-200 dark:border-indigo-700 rounded-lg">
|
<div className="flex items-center gap-2 px-3 py-2 bg-indigo-50 border border-indigo-200 rounded-lg">
|
||||||
<span className="text-indigo-700 dark:text-indigo-300 text-sm font-medium">영구 사용 (만료일 없음)</span>
|
<span className="text-indigo-700 text-sm font-medium">영구 사용 (만료일 없음)</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input type="date" value={usageFromDate}
|
<input type="date" value={usageFromDate}
|
||||||
onChange={(e) => setUsageFromDate(e.target.value)}
|
onChange={(e) => setUsageFromDate(e.target.value)}
|
||||||
readOnly={usagePeriodMode !== 'custom'}
|
readOnly={usagePeriodMode !== 'custom'}
|
||||||
className={`border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none ${usagePeriodMode !== 'custom' ? 'bg-gray-50 text-gray-500 dark:bg-gray-700 dark:text-gray-400' : 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'}`} />
|
className={`border border-[var(--color-border-strong)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none ${usagePeriodMode !== 'custom' ? 'bg-[var(--color-bg-base)] text-[var(--color-text-secondary)]' : 'bg-[var(--color-bg-surface)] text-[var(--color-text-primary)]'}`} />
|
||||||
<span className="text-gray-500 dark:text-gray-400">~</span>
|
<span className="text-[var(--color-text-secondary)]">~</span>
|
||||||
<input type="date" value={usageToDate}
|
<input type="date" value={usageToDate}
|
||||||
onChange={(e) => setUsageToDate(e.target.value)}
|
onChange={(e) => setUsageToDate(e.target.value)}
|
||||||
readOnly={usagePeriodMode !== 'custom'}
|
readOnly={usagePeriodMode !== 'custom'}
|
||||||
className={`border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none ${usagePeriodMode !== 'custom' ? 'bg-gray-50 text-gray-500 dark:bg-gray-700 dark:text-gray-400' : 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'}`} />
|
className={`border border-[var(--color-border-strong)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none ${usagePeriodMode !== 'custom' ? 'bg-[var(--color-bg-base)] text-[var(--color-text-secondary)]' : 'bg-[var(--color-bg-surface)] text-[var(--color-text-primary)]'}`} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
서비스 IP <span className="text-red-500">*</span>
|
서비스 IP <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text" value={serviceIp}
|
<input type="text" value={serviceIp}
|
||||||
onChange={(e) => setServiceIp(e.target.value)}
|
onChange={(e) => setServiceIp(e.target.value)}
|
||||||
required placeholder="예: 192.168.1.100"
|
required placeholder="예: 192.168.1.100"
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none" />
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none" />
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">발급받은 API Key로 프록시 서버에 요청하는 서비스 IP</p>
|
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">발급받은 API Key로 프록시 서버에 요청하는 서비스 IP</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
서비스 용도 <span className="text-red-500">*</span>
|
서비스 용도 <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select value={servicePurpose}
|
<select value={servicePurpose}
|
||||||
onChange={(e) => setServicePurpose(e.target.value)}
|
onChange={(e) => setServicePurpose(e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none">
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none">
|
||||||
<option value="">선택하세요</option>
|
<option value="">선택하세요</option>
|
||||||
<option value="로컬 환경">로컬 환경</option>
|
<option value="로컬 환경">로컬 환경</option>
|
||||||
<option value="개발 서버">개발 서버</option>
|
<option value="개발 서버">개발 서버</option>
|
||||||
@ -408,13 +409,13 @@ const KeyRequestPage = () => {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
||||||
하루 예상 요청량 <span className="text-red-500">*</span>
|
하루 예상 요청량 <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select value={dailyRequestEstimate}
|
<select value={dailyRequestEstimate}
|
||||||
onChange={(e) => setDailyRequestEstimate(e.target.value)}
|
onChange={(e) => setDailyRequestEstimate(e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none">
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none">
|
||||||
<option value="">선택하세요</option>
|
<option value="">선택하세요</option>
|
||||||
<option value="100">100 이하</option>
|
<option value="100">100 이하</option>
|
||||||
<option value="500">100~500</option>
|
<option value="500">100~500</option>
|
||||||
@ -428,9 +429,9 @@ const KeyRequestPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API Selection Section */}
|
{/* API Selection Section */}
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 shadow mb-6 overflow-hidden">
|
<div className="bg-[var(--color-bg-surface)] rounded-xl border border-[var(--color-border)] shadow mb-6 overflow-hidden">
|
||||||
{/* Header bar */}
|
{/* Header bar */}
|
||||||
<div className="flex items-center justify-between gap-4 px-5 py-3.5 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
|
<div className="flex items-center justify-between gap-4 px-5 py-3.5 border-b border-[var(--color-border)] bg-[var(--color-bg-surface)]">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<label className="flex items-center gap-2 cursor-pointer" onClick={(e) => e.stopPropagation()}>
|
<label className="flex items-center gap-2 cursor-pointer" onClick={(e) => e.stopPropagation()}>
|
||||||
<IndeterminateCheckbox
|
<IndeterminateCheckbox
|
||||||
@ -440,9 +441,9 @@ const KeyRequestPage = () => {
|
|||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">API 선택</h2>
|
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">API 선택</h2>
|
||||||
{selectedApiIds.size > 0 && (
|
{selectedApiIds.size > 0 && (
|
||||||
<span className="text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-2.5 py-0.5 rounded-full">
|
<span className="text-xs font-medium bg-blue-100 text-[var(--color-primary)] px-2.5 py-0.5 rounded-full">
|
||||||
{selectedApiIds.size}개 선택
|
{selectedApiIds.size}개 선택
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -453,13 +454,13 @@ const KeyRequestPage = () => {
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="API 검색..."
|
placeholder="API 검색..."
|
||||||
className="bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-1.5 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:outline-none w-56"
|
className="bg-[var(--color-bg-base)] border border-[var(--color-border)] rounded-lg px-3 py-1.5 text-sm text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none w-56"
|
||||||
/>
|
/>
|
||||||
{selectedApiIds.size > 0 && (
|
{selectedApiIds.size > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClearSelection}
|
onClick={handleClearSelection}
|
||||||
className="text-xs text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 px-2 py-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
className="text-xs text-[var(--color-text-secondary)] hover:text-red-500 px-2 py-1.5 rounded-lg hover:bg-[var(--color-bg-base)] transition-colors"
|
||||||
>
|
>
|
||||||
선택 해제
|
선택 해제
|
||||||
</button>
|
</button>
|
||||||
@ -480,15 +481,15 @@ const KeyRequestPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={domainGroup.domain}
|
key={domainGroup.domain}
|
||||||
className={`rounded-xl border overflow-hidden transition-colors ${hasSelections ? 'border-blue-300 dark:border-blue-700' : 'border-gray-200 dark:border-gray-700'}`}
|
className={`rounded-xl border overflow-hidden transition-colors ${hasSelections ? 'border-blue-300' : 'border-[var(--color-border)]'}`}
|
||||||
>
|
>
|
||||||
{/* Domain header */}
|
{/* Domain header */}
|
||||||
<div
|
<div
|
||||||
className={`flex items-center justify-between px-5 py-3.5 cursor-pointer ${hasSelections ? 'bg-blue-50/50 dark:bg-blue-900/20' : 'bg-gray-50 dark:bg-gray-800/80'}`}
|
className={`flex items-center justify-between px-5 py-3.5 cursor-pointer ${hasSelections ? 'bg-blue-50/50' : 'bg-[var(--color-bg-base)]'}`}
|
||||||
onClick={() => handleToggleDomain(domainGroup.domain)}
|
onClick={() => handleToggleDomain(domainGroup.domain)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<svg className={`h-4 w-4 text-gray-400 transition-transform ${isDomainExpanded ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className={`h-4 w-4 text-[var(--color-text-tertiary)] transition-transform ${isDomainExpanded ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
<label className="flex items-center cursor-pointer" onClick={(e) => e.stopPropagation()}>
|
<label className="flex items-center cursor-pointer" onClick={(e) => e.stopPropagation()}>
|
||||||
@ -499,27 +500,27 @@ const KeyRequestPage = () => {
|
|||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">{/^[a-zA-Z\s\-_]+$/.test(domainGroup.domain) ? domainGroup.domain.toUpperCase() : domainGroup.domain}</span>
|
<span className="font-semibold text-[var(--color-text-primary)]">{/^[a-zA-Z\s\-_]+$/.test(domainGroup.domain) ? domainGroup.domain.toUpperCase() : domainGroup.domain}</span>
|
||||||
{selectedCount > 0 && (
|
{selectedCount > 0 && (
|
||||||
<span className="text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-2 py-0.5 rounded-full">
|
<span className="text-xs font-medium bg-blue-100 text-[var(--color-primary)] px-2 py-0.5 rounded-full">
|
||||||
{selectedCount} selected
|
{selectedCount} selected
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 px-2 py-0.5 rounded-full">
|
<span className="text-xs font-medium bg-[var(--color-bg-base)] text-[var(--color-text-secondary)] px-2 py-0.5 rounded-full">
|
||||||
{domainApis.length}개 API
|
{domainApis.length}개 API
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API list */}
|
{/* API list */}
|
||||||
{isDomainExpanded && (
|
{isDomainExpanded && (
|
||||||
<div className="divide-y divide-gray-100 dark:divide-gray-700/50 bg-white dark:bg-gray-900">
|
<div className="divide-y divide-[var(--color-border)] bg-[var(--color-bg-surface)]">
|
||||||
{domainApis.map((api) => {
|
{domainApis.map((api) => {
|
||||||
const isSelected = selectedApiIds.has(api.apiId);
|
const isSelected = selectedApiIds.has(api.apiId);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={api.apiId}
|
key={api.apiId}
|
||||||
className={`flex items-start gap-3 px-5 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/30 ${isSelected ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''}`}
|
className={`flex items-start gap-3 px-5 py-3 cursor-pointer hover:bg-[var(--color-bg-base)] ${isSelected ? 'bg-blue-50/50' : ''}`}
|
||||||
onClick={() => handleToggleApi(api.apiId)}
|
onClick={() => handleToggleApi(api.apiId)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center pt-0.5">
|
<div className="flex items-center pt-0.5">
|
||||||
@ -532,7 +533,7 @@ const KeyRequestPage = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">{api.apiName}</p>
|
<p className="text-sm font-semibold text-[var(--color-text-primary)] truncate">{api.apiName}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -543,7 +544,7 @@ const KeyRequestPage = () => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{filteredDomainGroups.length === 0 && (
|
{filteredDomainGroups.length === 0 && (
|
||||||
<div className="px-6 py-8 text-center text-gray-400 dark:text-gray-500">
|
<div className="px-6 py-8 text-center text-[var(--color-text-tertiary)]">
|
||||||
{searchQuery.trim() ? '검색 결과가 없습니다.' : '등록된 API가 없습니다.'}
|
{searchQuery.trim() ? '검색 결과가 없습니다.' : '등록된 API가 없습니다.'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -553,7 +554,7 @@ const KeyRequestPage = () => {
|
|||||||
{/* Bottom sticky summary bar */}
|
{/* Bottom sticky summary bar */}
|
||||||
{selectedApiIds.size > 0 && (
|
{selectedApiIds.size > 0 && (
|
||||||
<div className="sticky bottom-4 z-10 mx-auto mb-4">
|
<div className="sticky bottom-4 z-10 mx-auto mb-4">
|
||||||
<div className="bg-blue-600 dark:bg-blue-700 text-white rounded-xl px-5 py-3 shadow-lg flex items-center justify-between">
|
<div className="bg-[var(--color-primary)] dark:bg-[var(--color-primary-600)] text-white rounded-xl px-5 py-3 shadow-lg flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">{selectedApiIds.size}개 API가 선택되었습니다</span>
|
<span className="text-sm font-medium">{selectedApiIds.size}개 API가 선택되었습니다</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -567,13 +568,13 @@ const KeyRequestPage = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="bg-blue-600 hover:bg-blue-700 disabled:bg-blue-300 text-white px-6 py-2 rounded-lg text-sm font-medium"
|
variant="primary"
|
||||||
>
|
>
|
||||||
{isSubmitting ? '신청 중...' : '신청하기'}
|
{isSubmitting ? '신청 중...' : '신청하기'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,14 +1,17 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import type { ApiKey } from '../../types/apikey';
|
import type { ApiKey } from '../../types/apikey';
|
||||||
import { getMyKeys, revokeKey } from '../../services/apiKeyService';
|
import { getMyKeys, revokeKey } from '../../services/apiKeyService';
|
||||||
|
import Badge from '../../components/ui/Badge';
|
||||||
|
import type { BadgeVariant } from '../../components/ui/Badge';
|
||||||
|
import Button from '../../components/ui/Button';
|
||||||
|
|
||||||
const STATUS_BADGE: Record<string, string> = {
|
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||||
ACTIVE: 'bg-green-100 text-green-800',
|
ACTIVE: 'success',
|
||||||
PENDING: 'bg-yellow-100 text-yellow-800',
|
PENDING: 'warning',
|
||||||
REVOKED: 'bg-red-100 text-red-800',
|
REVOKED: 'danger',
|
||||||
EXPIRED: 'bg-gray-100 text-gray-800',
|
EXPIRED: 'default',
|
||||||
INACTIVE: 'bg-gray-100 text-gray-800',
|
INACTIVE: 'danger',
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDateTime = (dateStr: string | null): string => {
|
const formatDateTime = (dateStr: string | null): string => {
|
||||||
@ -17,6 +20,7 @@ const formatDateTime = (dateStr: string | null): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MyKeysPage = () => {
|
const MyKeysPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -67,20 +71,17 @@ const MyKeysPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="max-w-7xl mx-auto text-center py-10 text-gray-500 dark:text-gray-400">로딩 중...</div>;
|
return <div className="max-w-7xl mx-auto text-center py-10 text-[var(--color-text-secondary)]">로딩 중...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">My API Keys</h1>
|
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">My API Keys</h1>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Link
|
<Button onClick={() => navigate('/apikeys/request')}>
|
||||||
to="/apikeys/request"
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
|
||||||
>
|
|
||||||
API Key 신청
|
API Key 신청
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -88,51 +89,48 @@ const MyKeysPage = () => {
|
|||||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
|
<div className="overflow-x-auto bg-[var(--color-bg-surface)] rounded-lg shadow">
|
||||||
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Key Name</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Key Name</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Prefix</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Prefix</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Status</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Status</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Expires At</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Expires At</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Last Used At</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Last Used At</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Created At</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Created At</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Actions</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{keys.map((key) => (
|
{keys.map((key) => (
|
||||||
<tr key={key.apiKeyId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
<tr key={key.apiKeyId} className="hover:bg-[var(--color-bg-base)]">
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{key.keyName}</td>
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">{key.keyName}</td>
|
||||||
<td className="px-4 py-3 font-mono text-gray-600 dark:text-gray-400">{key.apiKeyPrefix}</td>
|
<td className="px-4 py-3 font-mono text-[var(--color-text-secondary)]">{key.apiKeyPrefix}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span
|
<Badge variant={STATUS_VARIANT[key.status] ?? 'default'}>
|
||||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
||||||
STATUS_BADGE[key.status] || 'bg-gray-100 text-gray-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{key.status}
|
{key.status}
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{formatDateTime(key.expiresAt)}</td>
|
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{formatDateTime(key.expiresAt)}</td>
|
||||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{formatDateTime(key.lastUsedAt)}</td>
|
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{formatDateTime(key.lastUsedAt)}</td>
|
||||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{formatDateTime(key.createdAt)}</td>
|
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{formatDateTime(key.createdAt)}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{key.status === 'ACTIVE' && (
|
{key.status === 'ACTIVE' && (
|
||||||
<button
|
<Button
|
||||||
onClick={() => handleRevoke(key)}
|
onClick={() => handleRevoke(key)}
|
||||||
className="bg-red-100 hover:bg-red-200 text-red-700 dark:bg-red-900/30 dark:hover:bg-red-800/40 dark:text-red-400 px-3 py-1 rounded-lg text-sm font-medium"
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
폐기
|
폐기
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{keys.length === 0 && (
|
{keys.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
|
<td colSpan={7} className="px-4 py-8 text-center text-[var(--color-text-tertiary)]">
|
||||||
등록된 API Key가 없습니다.
|
등록된 API Key가 없습니다.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -143,34 +141,36 @@ const MyKeysPage = () => {
|
|||||||
|
|
||||||
{rawKeyModal && (
|
{rawKeyModal && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg mx-4">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow-xl w-full max-w-lg mx-4">
|
||||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="px-6 py-4 border-b border-[var(--color-border)]">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">API Key 생성 완료</h2>
|
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">API Key 생성 완료</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-4 space-y-4">
|
<div className="px-6 py-4 space-y-4">
|
||||||
<div className="p-3 bg-yellow-50 border border-yellow-200 text-yellow-800 rounded-lg text-sm">
|
<div className="p-3 bg-yellow-50 border border-yellow-200 text-yellow-800 rounded-lg text-sm">
|
||||||
이 키는 다시 표시되지 않습니다. 안전한 곳에 보관하세요.
|
이 키는 다시 표시되지 않습니다. 안전한 곳에 보관하세요.
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Key Name</label>
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">Key Name</label>
|
||||||
<p className="text-gray-900 dark:text-gray-100">{rawKeyModal.keyName}</p>
|
<p className="text-[var(--color-text-primary)]">{rawKeyModal.keyName}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">API Key</label>
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">API Key</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code className="flex-1 bg-gray-100 dark:bg-gray-700 px-3 py-2 rounded-lg text-sm font-mono break-all text-gray-900 dark:text-gray-100">
|
<code className="flex-1 bg-[var(--color-bg-base)] px-3 py-2 rounded-lg text-sm font-mono break-all text-[var(--color-text-primary)]">
|
||||||
{rawKeyModal.rawKey}
|
{rawKeyModal.rawKey}
|
||||||
</code>
|
</code>
|
||||||
<button
|
<Button
|
||||||
onClick={handleCopyRawKey}
|
onClick={handleCopyRawKey}
|
||||||
className="shrink-0 bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded-lg text-sm font-medium"
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
{copied ? '복사됨!' : '복사'}
|
{copied ? '복사됨!' : '복사'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
<div className="px-6 py-4 border-t border-[var(--color-border)] flex justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setRawKeyModal(null);
|
setRawKeyModal(null);
|
||||||
|
|||||||
@ -70,7 +70,7 @@ const RequestLogDetailPage = () => {
|
|||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="max-w-7xl mx-auto text-center py-10 text-gray-500 dark:text-gray-400">로딩 중...</div>;
|
return <div className="max-w-7xl mx-auto text-center py-10 text-[var(--color-text-secondary)]">로딩 중...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@ -78,7 +78,7 @@ const RequestLogDetailPage = () => {
|
|||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="text-blue-600 hover:text-blue-800 font-medium mb-4"
|
className="text-[var(--color-primary)] hover:text-blue-800 font-medium mb-4"
|
||||||
>
|
>
|
||||||
← 목록으로
|
← 목록으로
|
||||||
</button>
|
</button>
|
||||||
@ -96,22 +96,22 @@ const RequestLogDetailPage = () => {
|
|||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="text-blue-600 hover:text-blue-800 font-medium mb-6"
|
className="text-[var(--color-primary)] hover:text-blue-800 font-medium mb-6"
|
||||||
>
|
>
|
||||||
← 목록으로
|
← 목록으로
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 기본 정보 */}
|
{/* 기본 정보 */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">기본 정보</h2>
|
<h2 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">기본 정보</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">요청 시간</span>
|
<span className="block text-sm font-medium text-[var(--color-text-secondary)]">요청 시간</span>
|
||||||
<span className="text-sm text-gray-900 dark:text-gray-100">{formatDateTime(log.requestedAt)}</span>
|
<span className="text-sm text-[var(--color-text-primary)]">{formatDateTime(log.requestedAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">요청</span>
|
<span className="block text-sm font-medium text-[var(--color-text-secondary)]">요청</span>
|
||||||
<span className="text-sm text-gray-900 dark:text-gray-100">
|
<span className="text-sm text-[var(--color-text-primary)]">
|
||||||
<span
|
<span
|
||||||
className={`inline-block px-2 py-0.5 rounded text-xs font-bold mr-2 ${
|
className={`inline-block px-2 py-0.5 rounded text-xs font-bold mr-2 ${
|
||||||
METHOD_COLOR[log.requestMethod] || 'bg-gray-100 text-gray-800'
|
METHOD_COLOR[log.requestMethod] || 'bg-gray-100 text-gray-800'
|
||||||
@ -123,25 +123,25 @@ const RequestLogDetailPage = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">응답 코드</span>
|
<span className="block text-sm font-medium text-[var(--color-text-secondary)]">응답 코드</span>
|
||||||
<span className="text-sm text-gray-900 dark:text-gray-100">
|
<span className="text-sm text-[var(--color-text-primary)]">
|
||||||
{log.responseStatus != null ? log.responseStatus : '-'}
|
{log.responseStatus != null ? log.responseStatus : '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">응답시간(ms)</span>
|
<span className="block text-sm font-medium text-[var(--color-text-secondary)]">응답시간(ms)</span>
|
||||||
<span className="text-sm text-gray-900 dark:text-gray-100">
|
<span className="text-sm text-[var(--color-text-primary)]">
|
||||||
{log.responseTime != null ? log.responseTime : '-'}
|
{log.responseTime != null ? log.responseTime : '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">응답크기(bytes)</span>
|
<span className="block text-sm font-medium text-[var(--color-text-secondary)]">응답크기(bytes)</span>
|
||||||
<span className="text-sm text-gray-900 dark:text-gray-100">
|
<span className="text-sm text-[var(--color-text-primary)]">
|
||||||
{log.responseSize != null ? log.responseSize : '-'}
|
{log.responseSize != null ? log.responseSize : '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">상태</span>
|
<span className="block text-sm font-medium text-[var(--color-text-secondary)]">상태</span>
|
||||||
<span
|
<span
|
||||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
STATUS_BADGE[log.requestStatus] || 'bg-gray-100 text-gray-800'
|
STATUS_BADGE[log.requestStatus] || 'bg-gray-100 text-gray-800'
|
||||||
@ -153,42 +153,42 @@ const RequestLogDetailPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">서비스</span>
|
<span className="block text-sm font-medium text-[var(--color-text-secondary)]">서비스</span>
|
||||||
<span className="text-sm text-gray-900 dark:text-gray-100">{log.serviceName || '-'}</span>
|
<span className="text-sm text-[var(--color-text-primary)]">{log.serviceName || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">API Key</span>
|
<span className="block text-sm font-medium text-[var(--color-text-secondary)]">API Key</span>
|
||||||
<span className="text-sm text-gray-900 dark:text-gray-100 font-mono">
|
<span className="text-sm text-[var(--color-text-primary)] font-mono">
|
||||||
{log.apiKeyPrefix || '-'}
|
{log.apiKeyPrefix || '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">사용자</span>
|
<span className="block text-sm font-medium text-[var(--color-text-secondary)]">사용자</span>
|
||||||
<span className="text-sm text-gray-900 dark:text-gray-100">{log.userName || '-'}</span>
|
<span className="text-sm text-[var(--color-text-primary)]">{log.userName || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">IP</span>
|
<span className="block text-sm font-medium text-[var(--color-text-secondary)]">IP</span>
|
||||||
<span className="text-sm text-gray-900 dark:text-gray-100 font-mono">{log.requestIp}</span>
|
<span className="text-sm text-[var(--color-text-primary)] font-mono">{log.requestIp}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 요청 정보 */}
|
{/* 요청 정보 */}
|
||||||
{(formattedHeaders || formattedParams) && (
|
{(formattedHeaders || formattedParams) && (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">요청 정보</h2>
|
<h2 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">요청 정보</h2>
|
||||||
{formattedHeaders && (
|
{formattedHeaders && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Request Headers</span>
|
<span className="block text-sm font-medium text-[var(--color-text-secondary)] mb-1">Request Headers</span>
|
||||||
<pre className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 text-sm text-gray-800 dark:text-gray-200 overflow-x-auto">
|
<pre className="bg-[var(--color-bg-base)] rounded-lg p-4 text-sm text-[var(--color-text-primary)] overflow-x-auto">
|
||||||
{formattedHeaders}
|
{formattedHeaders}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{formattedParams && (
|
{formattedParams && (
|
||||||
<div>
|
<div>
|
||||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Request Params</span>
|
<span className="block text-sm font-medium text-[var(--color-text-secondary)] mb-1">Request Params</span>
|
||||||
<pre className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 text-sm text-gray-800 dark:text-gray-200 overflow-x-auto">
|
<pre className="bg-[var(--color-bg-base)] rounded-lg p-4 text-sm text-[var(--color-text-primary)] overflow-x-auto">
|
||||||
{formattedParams}
|
{formattedParams}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,22 +4,25 @@ import type { RequestLog, PageResponse } from '../../types/monitoring';
|
|||||||
import type { ServiceInfo } from '../../types/service';
|
import type { ServiceInfo } from '../../types/service';
|
||||||
import { searchLogs } from '../../services/monitoringService';
|
import { searchLogs } from '../../services/monitoringService';
|
||||||
import { getServices } from '../../services/serviceService';
|
import { getServices } from '../../services/serviceService';
|
||||||
|
import Badge from '../../components/ui/Badge';
|
||||||
|
import type { BadgeVariant } from '../../components/ui/Badge';
|
||||||
|
import Button from '../../components/ui/Button';
|
||||||
|
|
||||||
const METHOD_COLOR: Record<string, string> = {
|
const METHOD_CLASS: Record<string, string> = {
|
||||||
GET: 'bg-green-100 text-green-800',
|
GET: 'bg-green-100 text-green-800 dark:bg-green-500/15 dark:text-green-400',
|
||||||
POST: 'bg-blue-100 text-blue-800',
|
POST: 'bg-blue-100 text-blue-800 dark:bg-blue-500/15 dark:text-blue-400',
|
||||||
PUT: 'bg-orange-100 text-orange-800',
|
PUT: 'bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-400',
|
||||||
DELETE: 'bg-red-100 text-red-800',
|
DELETE: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-400',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_BADGE: Record<string, string> = {
|
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||||
SUCCESS: 'bg-green-100 text-green-800',
|
SUCCESS: 'success',
|
||||||
FAIL: 'bg-red-100 text-red-800',
|
FAIL: 'danger',
|
||||||
DENIED: 'bg-red-100 text-red-800',
|
DENIED: 'warning',
|
||||||
EXPIRED: 'bg-orange-100 text-orange-800',
|
EXPIRED: 'warning',
|
||||||
INVALID_KEY: 'bg-red-100 text-red-800',
|
INVALID_KEY: 'danger',
|
||||||
ERROR: 'bg-orange-100 text-orange-800',
|
ERROR: 'danger',
|
||||||
FAILED: 'bg-gray-100 text-gray-800',
|
FAILED: 'default',
|
||||||
};
|
};
|
||||||
|
|
||||||
const REQUEST_STATUSES = ['SUCCESS', 'FAIL', 'DENIED', 'EXPIRED', 'INVALID_KEY', 'ERROR', 'FAILED'];
|
const REQUEST_STATUSES = ['SUCCESS', 'FAIL', 'DENIED', 'EXPIRED', 'INVALID_KEY', 'ERROR', 'FAILED'];
|
||||||
@ -162,13 +165,13 @@ const RequestLogsPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6">Request Logs</h1>
|
<h1 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6">Request Logs</h1>
|
||||||
|
|
||||||
{/* Search Form */}
|
{/* Search Form */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||||
<div className="md:col-span-3">
|
<div className="md:col-span-3">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">기간</label>
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">기간</label>
|
||||||
<div className="flex items-center gap-2 flex-wrap mb-2">
|
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||||
{([
|
{([
|
||||||
{ label: '오늘', fn: () => { const t = getToday(); setStartDate(t); setEndDate(t); setDatePreset('오늘'); } },
|
{ label: '오늘', fn: () => { const t = getToday(); setStartDate(t); setEndDate(t); setDatePreset('오늘'); } },
|
||||||
@ -184,8 +187,8 @@ const RequestLogsPage = () => {
|
|||||||
onClick={btn.fn}
|
onClick={btn.fn}
|
||||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
|
className={`px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
|
||||||
datePreset === btn.label
|
datePreset === btn.label
|
||||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400'
|
? 'bg-blue-50 border-blue-300 text-[var(--color-primary)]'
|
||||||
: 'border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
: 'border-[var(--color-border)] text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-base)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{btn.label}
|
{btn.label}
|
||||||
@ -197,25 +200,25 @@ const RequestLogsPage = () => {
|
|||||||
type="date"
|
type="date"
|
||||||
value={startDate}
|
value={startDate}
|
||||||
onChange={(e) => { setStartDate(e.target.value); setDatePreset('직접 선택'); }}
|
onChange={(e) => { setStartDate(e.target.value); setDatePreset('직접 선택'); }}
|
||||||
className="flex-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="flex-1 border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<span className="text-gray-500 dark:text-gray-400">~</span>
|
<span className="text-[var(--color-text-secondary)]">~</span>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={endDate}
|
value={endDate}
|
||||||
onChange={(e) => { setEndDate(e.target.value); setDatePreset('직접 선택'); }}
|
onChange={(e) => { setEndDate(e.target.value); setDatePreset('직접 선택'); }}
|
||||||
className="flex-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="flex-1 border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end gap-3 flex-wrap">
|
<div className="flex items-end gap-3 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">서비스</label>
|
<label className="block text-xs font-medium text-[var(--color-text-secondary)] mb-1">서비스</label>
|
||||||
<select
|
<select
|
||||||
value={serviceId}
|
value={serviceId}
|
||||||
onChange={(e) => setServiceId(e.target.value)}
|
onChange={(e) => setServiceId(e.target.value)}
|
||||||
className="border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">전체</option>
|
<option value="">전체</option>
|
||||||
{services.map((s) => (
|
{services.map((s) => (
|
||||||
@ -224,11 +227,11 @@ const RequestLogsPage = () => {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">상태</label>
|
<label className="block text-xs font-medium text-[var(--color-text-secondary)] mb-1">상태</label>
|
||||||
<select
|
<select
|
||||||
value={requestStatus}
|
value={requestStatus}
|
||||||
onChange={(e) => setRequestStatus(e.target.value)}
|
onChange={(e) => setRequestStatus(e.target.value)}
|
||||||
className="border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">전체</option>
|
<option value="">전체</option>
|
||||||
{REQUEST_STATUSES.map((s) => (
|
{REQUEST_STATUSES.map((s) => (
|
||||||
@ -237,11 +240,11 @@ const RequestLogsPage = () => {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Method</label>
|
<label className="block text-xs font-medium text-[var(--color-text-secondary)] mb-1">Method</label>
|
||||||
<select
|
<select
|
||||||
value={requestMethod}
|
value={requestMethod}
|
||||||
onChange={(e) => setRequestMethod(e.target.value)}
|
onChange={(e) => setRequestMethod(e.target.value)}
|
||||||
className="border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">전체</option>
|
<option value="">전체</option>
|
||||||
{HTTP_METHODS.map((m) => (
|
{HTTP_METHODS.map((m) => (
|
||||||
@ -250,18 +253,12 @@ const RequestLogsPage = () => {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end gap-2 ml-auto">
|
<div className="flex items-end gap-2 ml-auto">
|
||||||
<button
|
<Button onClick={() => handleSearch(0)} variant="primary">
|
||||||
onClick={() => handleSearch(0)}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
|
||||||
>
|
|
||||||
검색
|
검색
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button onClick={handleResetAndSearch} variant="secondary">
|
||||||
onClick={handleResetAndSearch}
|
|
||||||
className="bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-lg text-sm font-medium"
|
|
||||||
>
|
|
||||||
초기화
|
초기화
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -271,68 +268,60 @@ const RequestLogsPage = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Results Table */}
|
{/* Results Table */}
|
||||||
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow mb-6">
|
<div className="overflow-x-auto bg-[var(--color-bg-surface)] rounded-lg shadow mb-6">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-10 text-gray-500 dark:text-gray-400">로딩 중...</div>
|
<div className="text-center py-10 text-[var(--color-text-secondary)]">로딩 중...</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">시간</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">시간</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">서비스</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">서비스</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Method</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Method</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">URL</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">URL</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Status Code</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Status Code</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">응답시간(ms)</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">응답시간(ms)</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">상태</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">상태</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">IP</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">IP</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{result && result.content.length > 0 ? (
|
{result && result.content.length > 0 ? (
|
||||||
result.content.map((log) => (
|
result.content.map((log) => (
|
||||||
<tr
|
<tr
|
||||||
key={log.logId}
|
key={log.logId}
|
||||||
onClick={() => handleRowClick(log.logId)}
|
onClick={() => handleRowClick(log.logId)}
|
||||||
className="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700"
|
className="cursor-pointer hover:bg-[var(--color-bg-base)]"
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-gray-700 dark:text-gray-300">
|
<td className="px-4 py-3 whitespace-nowrap text-[var(--color-text-primary)]">
|
||||||
{formatDateTime(log.requestedAt)}
|
{formatDateTime(log.requestedAt)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{log.serviceName || '-'}</td>
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">{log.serviceName || '-'}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span
|
<Badge className={METHOD_CLASS[log.requestMethod]}>
|
||||||
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
|
|
||||||
METHOD_COLOR[log.requestMethod] || 'bg-gray-100 text-gray-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{log.requestMethod}
|
{log.requestMethod}
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 truncate max-w-[250px]" title={log.requestUrl}>
|
<td className="px-4 py-3 text-[var(--color-text-secondary)] truncate max-w-[250px]" title={log.requestUrl}>
|
||||||
{log.requestUrl}
|
{log.requestUrl}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
||||||
{log.responseStatus != null ? log.responseStatus : '-'}
|
{log.responseStatus != null ? log.responseStatus : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
||||||
{log.responseTime != null ? log.responseTime : '-'}
|
{log.responseTime != null ? log.responseTime : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span
|
<Badge variant={STATUS_VARIANT[log.requestStatus] ?? 'default'}>
|
||||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
||||||
STATUS_BADGE[log.requestStatus] || 'bg-gray-100 text-gray-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{log.requestStatus}
|
{log.requestStatus}
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">{log.requestIp}</td>
|
<td className="px-4 py-3 font-mono text-xs text-[var(--color-text-tertiary)]">{log.requestIp}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
|
<td colSpan={8} className="px-4 py-8 text-center text-[var(--color-text-tertiary)]">
|
||||||
검색 결과가 없습니다
|
검색 결과가 없습니다
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -345,24 +334,24 @@ const RequestLogsPage = () => {
|
|||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{result && result.totalElements > 0 && (
|
{result && result.totalElements > 0 && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
<span className="text-sm text-[var(--color-text-secondary)]">
|
||||||
총 {result.totalElements}건 / {result.page + 1} / {result.totalPages} 페이지
|
총 {result.totalElements}건 / {result.page + 1} / {result.totalPages} 페이지
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<Button
|
||||||
onClick={handlePrev}
|
onClick={handlePrev}
|
||||||
disabled={currentPage === 0}
|
disabled={currentPage === 0}
|
||||||
className="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"
|
variant="outline"
|
||||||
>
|
>
|
||||||
이전
|
이전
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
disabled={!result || currentPage >= result.totalPages - 1}
|
disabled={!result || currentPage >= result.totalPages - 1}
|
||||||
className="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"
|
variant="outline"
|
||||||
>
|
>
|
||||||
다음
|
다음
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -53,45 +53,45 @@ const ServiceStatusDetailPage = () => {
|
|||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="text-center py-20 text-gray-500 dark:text-gray-400">로딩 중...</div>;
|
return <div className="text-center py-20 text-[var(--color-text-secondary)]">로딩 중...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!detail) {
|
if (!detail) {
|
||||||
return <div className="text-center py-20 text-gray-500 dark:text-gray-400">서비스를 찾을 수 없습니다</div>;
|
return <div className="text-center py-20 text-[var(--color-text-secondary)]">서비스를 찾을 수 없습니다</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/monitoring/service-status')}
|
onClick={() => navigate('/monitoring/service-status')}
|
||||||
className="text-sm text-blue-600 hover:text-blue-800 mb-4 inline-block"
|
className="text-sm text-[var(--color-primary)] hover:text-blue-800 mb-4 inline-block"
|
||||||
>
|
>
|
||||||
← Status 목록으로
|
← Status 목록으로
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={`w-4 h-4 rounded-full ${STATUS_COLOR[detail.currentStatus] || 'bg-gray-400'}`} />
|
<div className={`w-4 h-4 rounded-full ${STATUS_COLOR[detail.currentStatus] || 'bg-gray-400'}`} />
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{detail.serviceName}</h1>
|
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">{detail.serviceName}</h1>
|
||||||
<span className="text-gray-500 dark:text-gray-400">{detail.serviceCode}</span>
|
<span className="text-[var(--color-text-secondary)]">{detail.serviceCode}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className={`text-lg font-semibold ${detail.currentStatus === 'UP' ? 'text-green-600' : 'text-red-600'}`}>
|
<div className={`text-lg font-semibold ${detail.currentStatus === 'UP' ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
{detail.currentStatus === 'UP' ? 'Operational' : detail.currentStatus === 'DOWN' ? 'Down' : 'Unknown'}
|
{detail.currentStatus === 'UP' ? 'Operational' : detail.currentStatus === 'DOWN' ? 'Down' : 'Unknown'}
|
||||||
</div>
|
</div>
|
||||||
{detail.lastResponseTime !== null && (
|
{detail.lastResponseTime !== null && (
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">{detail.lastResponseTime}ms</div>
|
<div className="text-sm text-[var(--color-text-secondary)]">{detail.lastResponseTime}ms</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Uptime Summary */}
|
{/* Uptime Summary */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">90일 Uptime</h2>
|
<h2 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">90일 Uptime</h2>
|
||||||
<div className="text-4xl font-bold text-gray-900 dark:text-gray-100 mb-4">{detail.uptimePercent90d.toFixed(3)}%</div>
|
<div className="text-4xl font-bold text-[var(--color-text-primary)] mb-4">{detail.uptimePercent90d.toFixed(3)}%</div>
|
||||||
|
|
||||||
{/* 90-Day Bar */}
|
{/* 90-Day Bar */}
|
||||||
<div className="flex items-center gap-0.5 mb-2">
|
<div className="flex items-center gap-0.5 mb-2">
|
||||||
@ -110,16 +110,16 @@ const ServiceStatusDetailPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{detail.dailyUptime.length === 0 && (
|
{detail.dailyUptime.length === 0 && (
|
||||||
<div className="flex-1 h-10 bg-gray-100 dark:bg-gray-700 rounded-sm" />
|
<div className="flex-1 h-10 bg-[var(--color-bg-base)] rounded-sm" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-xs text-gray-400 dark:text-gray-500">
|
<div className="flex justify-between text-xs text-[var(--color-text-tertiary)]">
|
||||||
<span>{detail.dailyUptime.length > 0 ? formatDate(detail.dailyUptime[0].date) : ''}</span>
|
<span>{detail.dailyUptime.length > 0 ? formatDate(detail.dailyUptime[0].date) : ''}</span>
|
||||||
<span>Today</span>
|
<span>Today</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Daily Uptime Legend */}
|
{/* Daily Uptime Legend */}
|
||||||
<div className="flex items-center gap-4 mt-4 text-xs text-gray-500 dark:text-gray-400">
|
<div className="flex items-center gap-4 mt-4 text-xs text-[var(--color-text-secondary)]">
|
||||||
<div className="flex items-center gap-1"><div className="w-3 h-3 rounded-sm bg-green-500" /> 99.9%+</div>
|
<div className="flex items-center gap-1"><div className="w-3 h-3 rounded-sm bg-green-500" /> 99.9%+</div>
|
||||||
<div className="flex items-center gap-1"><div className="w-3 h-3 rounded-sm bg-green-400" /> 99%+</div>
|
<div className="flex items-center gap-1"><div className="w-3 h-3 rounded-sm bg-green-400" /> 99%+</div>
|
||||||
<div className="flex items-center gap-1"><div className="w-3 h-3 rounded-sm bg-yellow-400" /> 95%+</div>
|
<div className="flex items-center gap-1"><div className="w-3 h-3 rounded-sm bg-yellow-400" /> 95%+</div>
|
||||||
@ -129,31 +129,31 @@ const ServiceStatusDetailPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Checks */}
|
{/* Recent Checks */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow mb-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow mb-6">
|
||||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
<div className="p-6 border-b border-[var(--color-border)]">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">최근 체크 이력</h2>
|
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">최근 체크 이력</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">시간</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">시간</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">상태</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">상태</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">응답시간</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">응답시간</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">에러</th>
|
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">에러</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{detail.recentChecks.map((check, idx) => (
|
{detail.recentChecks.map((check, idx) => (
|
||||||
<tr key={idx} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
<tr key={idx} className="hover:bg-[var(--color-bg-base)]">
|
||||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400 whitespace-nowrap">{formatTime(check.checkedAt)}</td>
|
<td className="px-4 py-3 text-[var(--color-text-secondary)] whitespace-nowrap">{formatTime(check.checkedAt)}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className={`w-2.5 h-2.5 rounded-full ${STATUS_COLOR[check.status] || 'bg-gray-400'}`} />
|
<div className={`w-2.5 h-2.5 rounded-full ${STATUS_COLOR[check.status] || 'bg-gray-400'}`} />
|
||||||
<span className={check.status === 'UP' ? 'text-green-700' : 'text-red-700'}>{check.status}</span>
|
<span className={check.status === 'UP' ? 'text-green-700' : 'text-red-700'}>{check.status}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
|
<td className="px-4 py-3 text-[var(--color-text-secondary)]">
|
||||||
{check.responseTime !== null ? `${check.responseTime}ms` : '-'}
|
{check.responseTime !== null ? `${check.responseTime}ms` : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-red-600 text-xs max-w-xs truncate">
|
<td className="px-4 py-3 text-red-600 text-xs max-w-xs truncate">
|
||||||
@ -163,7 +163,7 @@ const ServiceStatusDetailPage = () => {
|
|||||||
))}
|
))}
|
||||||
{detail.recentChecks.length === 0 && (
|
{detail.recentChecks.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={4} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
|
<td colSpan={4} className="px-4 py-8 text-center text-[var(--color-text-tertiary)]">
|
||||||
체크 이력이 없습니다
|
체크 이력이 없습니다
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -6,29 +6,32 @@ import {
|
|||||||
import type { ApiStatsResponse } from '../../types/statistics';
|
import type { ApiStatsResponse } from '../../types/statistics';
|
||||||
import { getApiStats } from '../../services/statisticsService';
|
import { getApiStats } from '../../services/statisticsService';
|
||||||
import DateRangeFilter from '../../components/DateRangeFilter';
|
import DateRangeFilter from '../../components/DateRangeFilter';
|
||||||
|
import { CHART_COLORS_HEX } from '../../constants/chart';
|
||||||
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
|
import Badge from '../../components/ui/Badge';
|
||||||
|
|
||||||
const PIE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
|
const SERVICE_TAG_CLASSES = [
|
||||||
|
'bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-400',
|
||||||
const SERVICE_TAG_STYLES = [
|
'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-400',
|
||||||
'bg-blue-100 text-blue-700',
|
'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-400',
|
||||||
'bg-emerald-100 text-emerald-700',
|
'bg-red-100 text-red-700 dark:bg-red-500/15 dark:text-red-400',
|
||||||
'bg-amber-100 text-amber-700',
|
'bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-400',
|
||||||
'bg-red-100 text-red-700',
|
'bg-cyan-100 text-cyan-700 dark:bg-cyan-500/15 dark:text-cyan-400',
|
||||||
'bg-violet-100 text-violet-700',
|
|
||||||
'bg-cyan-100 text-cyan-700',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const METHOD_BADGE: Record<string, string> = {
|
const METHOD_CLASS: Record<string, string> = {
|
||||||
GET: 'bg-blue-100 text-blue-800',
|
GET: 'bg-blue-100 text-blue-800 dark:bg-blue-500/15 dark:text-blue-400',
|
||||||
POST: 'bg-green-100 text-green-800',
|
POST: 'bg-green-100 text-green-800 dark:bg-green-500/15 dark:text-green-400',
|
||||||
PUT: 'bg-amber-100 text-amber-800',
|
PUT: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-400',
|
||||||
DELETE: 'bg-red-100 text-red-800',
|
DELETE: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-400',
|
||||||
PATCH: 'bg-purple-100 text-purple-800',
|
PATCH: 'bg-purple-100 text-purple-800 dark:bg-purple-500/15 dark:text-purple-400',
|
||||||
};
|
};
|
||||||
|
|
||||||
const getToday = () => new Date().toISOString().slice(0, 10);
|
const getToday = () => new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
const ApiStatsPage = () => {
|
const ApiStatsPage = () => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const chartColors = CHART_COLORS_HEX[theme];
|
||||||
const [startDate, setStartDate] = useState(getToday());
|
const [startDate, setStartDate] = useState(getToday());
|
||||||
const [endDate, setEndDate] = useState(getToday());
|
const [endDate, setEndDate] = useState(getToday());
|
||||||
const [data, setData] = useState<ApiStatsResponse | null>(null);
|
const [data, setData] = useState<ApiStatsResponse | null>(null);
|
||||||
@ -68,12 +71,12 @@ const ApiStatsPage = () => {
|
|||||||
const map: Record<string, { tag: string; bar: string }> = {};
|
const map: Record<string, { tag: string; bar: string }> = {};
|
||||||
serviceNames.forEach((name, i) => {
|
serviceNames.forEach((name, i) => {
|
||||||
map[name] = {
|
map[name] = {
|
||||||
tag: SERVICE_TAG_STYLES[i % SERVICE_TAG_STYLES.length],
|
tag: SERVICE_TAG_CLASSES[i % SERVICE_TAG_CLASSES.length],
|
||||||
bar: PIE_COLORS[i % PIE_COLORS.length],
|
bar: chartColors[i % chartColors.length],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return map;
|
return map;
|
||||||
}, [data]);
|
}, [data, chartColors]);
|
||||||
|
|
||||||
const statusChartData = useMemo(() => {
|
const statusChartData = useMemo(() => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
@ -86,14 +89,14 @@ const ApiStatsPage = () => {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="text-gray-500 dark:text-gray-400">로딩 중...</div>
|
<div className="text-[var(--color-text-secondary)]">로딩 중...</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6">API 통계</h1>
|
<h1 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6">API 통계</h1>
|
||||||
|
|
||||||
<DateRangeFilter
|
<DateRangeFilter
|
||||||
startDate={startDate}
|
startDate={startDate}
|
||||||
@ -104,14 +107,14 @@ const ApiStatsPage = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{!data ? (
|
{!data ? (
|
||||||
<p className="text-gray-400 dark:text-gray-500 text-center py-20">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Charts */}
|
{/* Charts */}
|
||||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||||
{/* Chart 1: HTTP Method Distribution */}
|
{/* Chart 1: HTTP Method Distribution */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">HTTP 메서드 분포</h3>
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">HTTP 메서드 분포</h3>
|
||||||
{data.methodDistribution.length > 0 ? (
|
{data.methodDistribution.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
@ -123,7 +126,7 @@ const ApiStatsPage = () => {
|
|||||||
outerRadius={100}
|
outerRadius={100}
|
||||||
>
|
>
|
||||||
{data.methodDistribution.map((_, idx) => (
|
{data.methodDistribution.map((_, idx) => (
|
||||||
<Cell key={idx} fill={PIE_COLORS[idx % PIE_COLORS.length]} />
|
<Cell key={idx} fill={chartColors[idx % chartColors.length]} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
@ -131,13 +134,13 @@ const ApiStatsPage = () => {
|
|||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-400 dark:text-gray-500 text-center py-20">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart 2: HTTP Status Code Distribution */}
|
{/* Chart 2: HTTP Status Code Distribution */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">HTTP 상태 코드 분포</h3>
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">HTTP 상태 코드 분포</h3>
|
||||||
{statusChartData.length > 0 ? (
|
{statusChartData.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<BarChart data={statusChartData}>
|
<BarChart data={statusChartData}>
|
||||||
@ -146,65 +149,65 @@ const ApiStatsPage = () => {
|
|||||||
<YAxis />
|
<YAxis />
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Legend />
|
<Legend />
|
||||||
<Bar dataKey="count" fill="#3b82f6" name="건수" />
|
<Bar dataKey="count" fill={chartColors[0]} name="건수" />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-400 dark:text-gray-500 text-center py-20">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table 1: Top APIs */}
|
{/* Table 1: Top APIs */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow mb-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow mb-6">
|
||||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
<div className="p-6 border-b border-[var(--color-border)]">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">API 호출 순위</h3>
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">API 호출 순위</h3>
|
||||||
</div>
|
</div>
|
||||||
{data.topApis.length > 0 ? (
|
{data.topApis.length > 0 ? (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">순위</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">순위</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">서비스</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">서비스</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">API</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">API</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">메서드</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">메서드</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">호출 수</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">호출 수</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">평균 응답시간</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">평균 응답시간</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">성공률</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">성공률</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{data.topApis.slice(0, 20).map((api, idx) => {
|
{data.topApis.slice(0, 20).map((api, idx) => {
|
||||||
const maxCount = data.topApis[0]?.callCount || 1;
|
const maxCount = data.topApis[0]?.callCount || 1;
|
||||||
const pct = (api.callCount / maxCount) * 100;
|
const pct = (api.callCount / maxCount) * 100;
|
||||||
const colors = serviceColorMap[api.serviceName] || { tag: SERVICE_TAG_STYLES[0], bar: PIE_COLORS[0] };
|
const colors = serviceColorMap[api.serviceName] || { tag: SERVICE_TAG_CLASSES[0], bar: chartColors[0] };
|
||||||
return (
|
return (
|
||||||
<tr key={idx} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
<tr key={idx} className="hover:bg-[var(--color-bg-base)]">
|
||||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{idx + 1}</td>
|
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{idx + 1}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${colors.tag}`}>
|
<Badge className={colors.tag}>
|
||||||
{api.serviceName}
|
{api.serviceName}
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100 truncate max-w-[250px]" title={api.apiName}>
|
<td className="px-4 py-3 text-[var(--color-text-primary)] truncate max-w-[250px]" title={api.apiName}>
|
||||||
{api.apiName}
|
{api.apiName}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${METHOD_BADGE[api.requestMethod] ?? 'bg-gray-100 text-gray-800'}`}>
|
<Badge className={METHOD_CLASS[api.requestMethod]}>
|
||||||
{api.requestMethod}
|
{api.requestMethod}
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-24 bg-gray-100 dark:bg-gray-700 rounded-full h-4">
|
<div className="w-24 bg-[var(--color-bg-base)] rounded-full h-4">
|
||||||
<div className="h-4 rounded-full" style={{ width: `${pct}%`, backgroundColor: colors.bar }} />
|
<div className="h-4 rounded-full" style={{ width: `${pct}%`, backgroundColor: colors.bar }} />
|
||||||
</div>
|
</div>
|
||||||
<span>{api.callCount.toLocaleString()}</span>
|
<span>{api.callCount.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{api.avgResponseTime.toFixed(0)}ms</td>
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">{api.avgResponseTime.toFixed(0)}ms</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{api.successRate.toFixed(1)}%</td>
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">{api.successRate.toFixed(1)}%</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -212,42 +215,42 @@ const ApiStatsPage = () => {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-400 dark:text-gray-500 text-center py-8">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-8">데이터가 없습니다</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table 2: Top Error APIs */}
|
{/* Table 2: Top Error APIs */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow">
|
||||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
<div className="p-6 border-b border-[var(--color-border)]">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">API 에러 순위</h3>
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">API 에러 순위</h3>
|
||||||
</div>
|
</div>
|
||||||
{data.topErrorApis.length > 0 ? (
|
{data.topErrorApis.length > 0 ? (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">순위</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">순위</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">서비스</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">서비스</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">API</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">API</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">에러 수</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">에러 수</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">전체 수</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">전체 수</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">에러율</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">에러율</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{data.topErrorApis.slice(0, 10).map((api, idx) => {
|
{data.topErrorApis.slice(0, 10).map((api, idx) => {
|
||||||
const colors = serviceColorMap[api.serviceName] || { tag: SERVICE_TAG_STYLES[0], bar: PIE_COLORS[0] };
|
const colors = serviceColorMap[api.serviceName] || { tag: SERVICE_TAG_CLASSES[0], bar: chartColors[0] };
|
||||||
return (
|
return (
|
||||||
<tr key={idx} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
<tr key={idx} className="hover:bg-[var(--color-bg-base)]">
|
||||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{idx + 1}</td>
|
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{idx + 1}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${colors.tag}`}>
|
<Badge className={colors.tag}>
|
||||||
{api.serviceName}
|
{api.serviceName}
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100" title={api.apiName}>{api.apiName}</td>
|
<td className="px-4 py-3 text-[var(--color-text-primary)]" title={api.apiName}>{api.apiName}</td>
|
||||||
<td className="px-4 py-3 text-red-600">{api.errorCount.toLocaleString()}</td>
|
<td className="px-4 py-3 text-red-600">{api.errorCount.toLocaleString()}</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{api.totalCount.toLocaleString()}</td>
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">{api.totalCount.toLocaleString()}</td>
|
||||||
<td className="px-4 py-3 text-red-600">{api.errorRate.toFixed(1)}%</td>
|
<td className="px-4 py-3 text-red-600">{api.errorRate.toFixed(1)}%</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@ -256,7 +259,7 @@ const ApiStatsPage = () => {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-400 dark:text-gray-500 text-center py-8">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-8">데이터가 없습니다</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -6,12 +6,14 @@ import {
|
|||||||
import type { ServiceStatsResponse } from '../../types/statistics';
|
import type { ServiceStatsResponse } from '../../types/statistics';
|
||||||
import { getServiceStats } from '../../services/statisticsService';
|
import { getServiceStats } from '../../services/statisticsService';
|
||||||
import DateRangeFilter from '../../components/DateRangeFilter';
|
import DateRangeFilter from '../../components/DateRangeFilter';
|
||||||
|
import { CHART_COLORS_HEX } from '../../constants/chart';
|
||||||
const PIE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
|
|
||||||
const getToday = () => new Date().toISOString().slice(0, 10);
|
const getToday = () => new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
const ServiceStatsPage = () => {
|
const ServiceStatsPage = () => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const chartColors = CHART_COLORS_HEX[theme];
|
||||||
const [startDate, setStartDate] = useState(getToday());
|
const [startDate, setStartDate] = useState(getToday());
|
||||||
const [endDate, setEndDate] = useState(getToday());
|
const [endDate, setEndDate] = useState(getToday());
|
||||||
const [data, setData] = useState<ServiceStatsResponse | null>(null);
|
const [data, setData] = useState<ServiceStatsResponse | null>(null);
|
||||||
@ -69,14 +71,14 @@ const ServiceStatsPage = () => {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="text-gray-500 dark:text-gray-400">로딩 중...</div>
|
<div className="text-[var(--color-text-secondary)]">로딩 중...</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6">서비스 통계</h1>
|
<h1 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6">서비스 통계</h1>
|
||||||
|
|
||||||
<DateRangeFilter
|
<DateRangeFilter
|
||||||
startDate={startDate}
|
startDate={startDate}
|
||||||
@ -87,20 +89,20 @@ const ServiceStatsPage = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{!data || data.serviceStats.length === 0 ? (
|
{!data || data.serviceStats.length === 0 ? (
|
||||||
<p className="text-gray-400 dark:text-gray-500 text-center py-20">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
<div className="flex flex-wrap gap-4 mb-6">
|
<div className="flex flex-wrap gap-4 mb-6">
|
||||||
{data.serviceStats.map((svc) => (
|
{data.serviceStats.map((svc) => (
|
||||||
<div key={svc.serviceId} className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 flex-1 min-w-[200px]">
|
<div key={svc.serviceId} className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 flex-1 min-w-[200px]">
|
||||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{svc.serviceName}</p>
|
<p className="text-sm font-medium text-[var(--color-text-secondary)]">{svc.serviceName}</p>
|
||||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1">
|
<p className="text-2xl font-bold text-[var(--color-text-primary)] mt-1">
|
||||||
{svc.totalRequests.toLocaleString()}
|
{svc.totalRequests.toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-3 mt-2 text-sm">
|
<div className="flex items-center gap-3 mt-2 text-sm">
|
||||||
<span className="text-green-600">성공 {svc.successRate.toFixed(1)}%</span>
|
<span className="text-green-600">성공 {svc.successRate.toFixed(1)}%</span>
|
||||||
<span className="text-gray-500 dark:text-gray-400">{svc.avgResponseTime.toFixed(0)}ms</span>
|
<span className="text-[var(--color-text-secondary)]">{svc.avgResponseTime.toFixed(0)}ms</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -109,8 +111,8 @@ const ServiceStatsPage = () => {
|
|||||||
{/* Charts */}
|
{/* Charts */}
|
||||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||||
{/* Chart 1: Service Request Count Bar */}
|
{/* Chart 1: Service Request Count Bar */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">서비스별 요청 수</h3>
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">서비스별 요청 수</h3>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<BarChart data={barChartData} layout="vertical">
|
<BarChart data={barChartData} layout="vertical">
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
@ -118,14 +120,14 @@ const ServiceStatsPage = () => {
|
|||||||
<YAxis dataKey="serviceName" type="category" width={120} />
|
<YAxis dataKey="serviceName" type="category" width={120} />
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Legend />
|
<Legend />
|
||||||
<Bar dataKey="totalRequests" fill="#3b82f6" name="요청 수" />
|
<Bar dataKey="totalRequests" fill={chartColors[0]} name="요청 수" />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart 2: Hourly Service Trend */}
|
{/* Chart 2: Hourly Service Trend */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">시간별 서비스 요청 추이</h3>
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">시간별 서비스 요청 추이</h3>
|
||||||
{hourlyTrendPivoted.data.length > 0 ? (
|
{hourlyTrendPivoted.data.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<LineChart data={hourlyTrendPivoted.data}>
|
<LineChart data={hourlyTrendPivoted.data}>
|
||||||
@ -139,13 +141,13 @@ const ServiceStatsPage = () => {
|
|||||||
key={name}
|
key={name}
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey={name}
|
dataKey={name}
|
||||||
stroke={PIE_COLORS[idx % PIE_COLORS.length]}
|
stroke={chartColors[idx % chartColors.length]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-400 dark:text-gray-500 text-center py-20">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -153,8 +155,8 @@ const ServiceStatsPage = () => {
|
|||||||
{/* Charts Row 2: Error Rate + Response Time */}
|
{/* Charts Row 2: Error Rate + Response Time */}
|
||||||
<div className="grid grid-cols-2 gap-6">
|
<div className="grid grid-cols-2 gap-6">
|
||||||
{/* Chart: Error Rate Comparison */}
|
{/* Chart: Error Rate Comparison */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">서비스별 에러율 비교</h3>
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">서비스별 에러율 비교</h3>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<BarChart
|
<BarChart
|
||||||
data={data.serviceStats.map((s) => ({
|
data={data.serviceStats.map((s) => ({
|
||||||
@ -176,8 +178,8 @@ const ServiceStatsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart: Avg Response Time Comparison */}
|
{/* Chart: Avg Response Time Comparison */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">서비스별 평균 응답시간 비교</h3>
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">서비스별 평균 응답시간 비교</h3>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<BarChart
|
<BarChart
|
||||||
data={data.serviceStats.map((s) => ({
|
data={data.serviceStats.map((s) => ({
|
||||||
|
|||||||
@ -6,12 +6,14 @@ import {
|
|||||||
import type { TenantStatsResponse } from '../../types/statistics';
|
import type { TenantStatsResponse } from '../../types/statistics';
|
||||||
import { getTenantStats } from '../../services/statisticsService';
|
import { getTenantStats } from '../../services/statisticsService';
|
||||||
import DateRangeFilter from '../../components/DateRangeFilter';
|
import DateRangeFilter from '../../components/DateRangeFilter';
|
||||||
|
import { CHART_COLORS_HEX } from '../../constants/chart';
|
||||||
const PIE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
|
|
||||||
const getToday = () => new Date().toISOString().slice(0, 10);
|
const getToday = () => new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
const TenantStatsPage = () => {
|
const TenantStatsPage = () => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const chartColors = CHART_COLORS_HEX[theme];
|
||||||
const [startDate, setStartDate] = useState(getToday());
|
const [startDate, setStartDate] = useState(getToday());
|
||||||
const [endDate, setEndDate] = useState(getToday());
|
const [endDate, setEndDate] = useState(getToday());
|
||||||
const [data, setData] = useState<TenantStatsResponse | null>(null);
|
const [data, setData] = useState<TenantStatsResponse | null>(null);
|
||||||
@ -61,14 +63,14 @@ const TenantStatsPage = () => {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="text-gray-500 dark:text-gray-400">로딩 중...</div>
|
<div className="text-[var(--color-text-secondary)]">로딩 중...</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6">테넌트 통계</h1>
|
<h1 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6">테넌트 통계</h1>
|
||||||
|
|
||||||
<DateRangeFilter
|
<DateRangeFilter
|
||||||
startDate={startDate}
|
startDate={startDate}
|
||||||
@ -79,19 +81,19 @@ const TenantStatsPage = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{!data || data.tenantStats.length === 0 ? (
|
{!data || data.tenantStats.length === 0 ? (
|
||||||
<p className="text-gray-400 dark:text-gray-500 text-center py-20">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
<div className="flex gap-4 mb-6">
|
<div className="flex gap-4 mb-6">
|
||||||
{data.tenantStats.map((tenant) => (
|
{data.tenantStats.map((tenant) => (
|
||||||
<div key={tenant.tenantId} className="flex-1 bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div key={tenant.tenantId} className="flex-1 bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{tenant.tenantName || 'Unknown'}</p>
|
<p className="text-sm font-medium text-[var(--color-text-secondary)]">{tenant.tenantName || 'Unknown'}</p>
|
||||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1">
|
<p className="text-2xl font-bold text-[var(--color-text-primary)] mt-1">
|
||||||
{tenant.totalRequests.toLocaleString()}
|
{tenant.totalRequests.toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-3 mt-2 text-sm">
|
<div className="flex items-center gap-3 mt-2 text-sm">
|
||||||
<span className="text-gray-500 dark:text-gray-400">사용자 {tenant.activeUsers}</span>
|
<span className="text-[var(--color-text-secondary)]">사용자 {tenant.activeUsers}</span>
|
||||||
<span className="text-green-600">성공 {tenant.successRate.toFixed(1)}%</span>
|
<span className="text-green-600">성공 {tenant.successRate.toFixed(1)}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -101,8 +103,8 @@ const TenantStatsPage = () => {
|
|||||||
{/* Charts */}
|
{/* Charts */}
|
||||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||||
{/* Chart 1: Daily Tenant Trend */}
|
{/* Chart 1: Daily Tenant Trend */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">일별 테넌트 요청 추이</h3>
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">일별 테넌트 요청 추이</h3>
|
||||||
{dailyTrendPivoted.data.length > 0 ? (
|
{dailyTrendPivoted.data.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<LineChart data={dailyTrendPivoted.data}>
|
<LineChart data={dailyTrendPivoted.data}>
|
||||||
@ -116,19 +118,19 @@ const TenantStatsPage = () => {
|
|||||||
key={name}
|
key={name}
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey={name}
|
dataKey={name}
|
||||||
stroke={PIE_COLORS[idx % PIE_COLORS.length]}
|
stroke={chartColors[idx % chartColors.length]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-400 dark:text-gray-500 text-center py-20">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart 2: Tenant API Key Stats */}
|
{/* Chart 2: Tenant API Key Stats */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">테넌트별 API Key 현황</h3>
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">테넌트별 API Key 현황</h3>
|
||||||
{data.apiKeyStats.length > 0 ? (
|
{data.apiKeyStats.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<BarChart data={data.apiKeyStats}>
|
<BarChart data={data.apiKeyStats}>
|
||||||
@ -137,40 +139,40 @@ const TenantStatsPage = () => {
|
|||||||
<YAxis />
|
<YAxis />
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Legend />
|
<Legend />
|
||||||
<Bar dataKey="totalKeys" fill="#3b82f6" name="전체 키" />
|
<Bar dataKey="totalKeys" fill={chartColors[0]} name="전체 키" />
|
||||||
<Bar dataKey="activeKeys" fill="#10b981" name="활성 키" />
|
<Bar dataKey="activeKeys" fill="#10b981" name="활성 키" />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-400 dark:text-gray-500 text-center py-20">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table: Tenant Details */}
|
{/* Table: Tenant Details */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow">
|
||||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
<div className="p-6 border-b border-[var(--color-border)]">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">테넌트 상세</h3>
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">테넌트 상세</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">테넌트</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">테넌트</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">요청 수</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">요청 수</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">활성 사용자</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">활성 사용자</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">성공률</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">성공률</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">평균 응답시간</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">평균 응답시간</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{data.tenantStats.map((tenant) => (
|
{data.tenantStats.map((tenant) => (
|
||||||
<tr key={tenant.tenantId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
<tr key={tenant.tenantId} className="hover:bg-[var(--color-bg-base)]">
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100 font-medium">{tenant.tenantName || 'Unknown'}</td>
|
<td className="px-4 py-3 text-[var(--color-text-primary)] font-medium">{tenant.tenantName || 'Unknown'}</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{tenant.totalRequests.toLocaleString()}</td>
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">{tenant.totalRequests.toLocaleString()}</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{tenant.activeUsers}</td>
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">{tenant.activeUsers}</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{tenant.successRate.toFixed(1)}%</td>
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">{tenant.successRate.toFixed(1)}%</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{tenant.avgResponseTime.toFixed(0)}ms</td>
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">{tenant.avgResponseTime.toFixed(0)}ms</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import {
|
|||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import type { UsageTrendResponse } from '../../types/statistics';
|
import type { UsageTrendResponse } from '../../types/statistics';
|
||||||
import { getUsageTrend } from '../../services/statisticsService';
|
import { getUsageTrend } from '../../services/statisticsService';
|
||||||
|
import { CHART_COLORS_HEX } from '../../constants/chart';
|
||||||
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
|
|
||||||
type Period = 'daily' | 'weekly' | 'monthly';
|
type Period = 'daily' | 'weekly' | 'monthly';
|
||||||
|
|
||||||
@ -33,6 +35,8 @@ const getSuccessRateColor = (rate: number): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const UsageTrendPage = () => {
|
const UsageTrendPage = () => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const chartColors = CHART_COLORS_HEX[theme];
|
||||||
const [period, setPeriod] = useState<Period>('daily');
|
const [period, setPeriod] = useState<Period>('daily');
|
||||||
const [data, setData] = useState<UsageTrendResponse | null>(null);
|
const [data, setData] = useState<UsageTrendResponse | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@ -60,18 +64,18 @@ const UsageTrendPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6">사용량 추이</h1>
|
<h1 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6">사용량 추이</h1>
|
||||||
|
|
||||||
{/* Period Tabs */}
|
{/* Period Tabs */}
|
||||||
<div className="flex border-b border-gray-200 dark:border-gray-700 mb-6">
|
<div className="flex border-b border-[var(--color-border)] mb-6">
|
||||||
{PERIOD_OPTIONS.map((opt) => (
|
{PERIOD_OPTIONS.map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={opt.key}
|
key={opt.key}
|
||||||
onClick={() => setPeriod(opt.key)}
|
onClick={() => setPeriod(opt.key)}
|
||||||
className={`px-4 py-2 text-sm font-medium -mb-px ${
|
className={`px-4 py-2 text-sm font-medium -mb-px ${
|
||||||
period === opt.key
|
period === opt.key
|
||||||
? 'border-b-2 border-blue-600 text-blue-600'
|
? 'border-b-2 border-[var(--color-primary)] text-[var(--color-primary)]'
|
||||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
|
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
@ -81,21 +85,21 @@ const UsageTrendPage = () => {
|
|||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="text-gray-500 dark:text-gray-400">로딩 중...</div>
|
<div className="text-[var(--color-text-secondary)]">로딩 중...</div>
|
||||||
</div>
|
</div>
|
||||||
) : !data || data.items.length === 0 ? (
|
) : !data || data.items.length === 0 ? (
|
||||||
<p className="text-gray-400 dark:text-gray-500 text-center py-20">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Chart 1: 요청 수 추이 (full width) */}
|
{/* Chart 1: 요청 수 추이 (full width) */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">요청 수 추이</h3>
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">요청 수 추이</h3>
|
||||||
<ResponsiveContainer width="100%" height={350}>
|
<ResponsiveContainer width="100%" height={350}>
|
||||||
<LineChart data={chartData}>
|
<LineChart data={chartData}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="totalRequestsFill" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="totalRequestsFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.15} />
|
<stop offset="5%" stopColor={chartColors[0]} stopOpacity={0.15} />
|
||||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
<stop offset="95%" stopColor={chartColors[0]} stopOpacity={0} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
@ -113,7 +117,7 @@ const UsageTrendPage = () => {
|
|||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="totalRequests"
|
dataKey="totalRequests"
|
||||||
stroke="#3b82f6"
|
stroke={chartColors[0]}
|
||||||
name="총 요청"
|
name="총 요청"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={false}
|
dot={false}
|
||||||
@ -133,8 +137,8 @@ const UsageTrendPage = () => {
|
|||||||
{/* Charts 2 & 3: 2 column grid */}
|
{/* Charts 2 & 3: 2 column grid */}
|
||||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||||
{/* Chart 2: 성공률 + 응답시간 추이 */}
|
{/* Chart 2: 성공률 + 응답시간 추이 */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">성공률 + 응답시간 추이</h3>
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">성공률 + 응답시간 추이</h3>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<ComposedChart data={chartData}>
|
<ComposedChart data={chartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
@ -155,7 +159,7 @@ const UsageTrendPage = () => {
|
|||||||
<Bar
|
<Bar
|
||||||
yAxisId="right"
|
yAxisId="right"
|
||||||
dataKey="avgResponseTime"
|
dataKey="avgResponseTime"
|
||||||
fill="#3b82f6"
|
fill={chartColors[0]}
|
||||||
name="평균 응답시간(ms)"
|
name="평균 응답시간(ms)"
|
||||||
barSize={20}
|
barSize={20}
|
||||||
/>
|
/>
|
||||||
@ -164,8 +168,8 @@ const UsageTrendPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart 3: 활성 사용자 추이 */}
|
{/* Chart 3: 활성 사용자 추이 */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">활성 사용자 추이</h3>
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">활성 사용자 추이</h3>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<BarChart data={chartData}>
|
<BarChart data={chartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
@ -173,52 +177,52 @@ const UsageTrendPage = () => {
|
|||||||
<YAxis />
|
<YAxis />
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Legend />
|
<Legend />
|
||||||
<Bar dataKey="activeUsers" fill="#06b6d4" name="활성 사용자" />
|
<Bar dataKey="activeUsers" fill={chartColors[1]} name="활성 사용자" />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table: 상세 데이터 */}
|
{/* Table: 상세 데이터 */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow">
|
||||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
<div className="p-6 border-b border-[var(--color-border)]">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">상세 데이터</h3>
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">상세 데이터</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">기간</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">기간</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">총 요청</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">총 요청</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">성공</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">성공</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">실패</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">실패</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">성공률(%)</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">성공률(%)</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">평균 응답시간(ms)</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">평균 응답시간(ms)</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">활성 사용자</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">활성 사용자</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{data.items.map((item) => (
|
{data.items.map((item) => (
|
||||||
<tr key={item.label} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
<tr key={item.label} className="hover:bg-[var(--color-bg-base)]">
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
||||||
{formatLabel(item.label, period)}
|
{formatLabel(item.label, period)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
||||||
{item.totalRequests.toLocaleString()}
|
{item.totalRequests.toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
||||||
{item.successCount.toLocaleString()}
|
{item.successCount.toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
||||||
{item.failureCount.toLocaleString()}
|
{item.failureCount.toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
<td className={`px-4 py-3 font-medium ${getSuccessRateColor(item.successRate)}`}>
|
<td className={`px-4 py-3 font-medium ${getSuccessRateColor(item.successRate)}`}>
|
||||||
{item.successRate.toFixed(1)}
|
{item.successRate.toFixed(1)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
||||||
{item.avgResponseTime.toFixed(0)}
|
{item.avgResponseTime.toFixed(0)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
||||||
{item.activeUsers.toLocaleString()}
|
{item.activeUsers.toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -6,8 +6,8 @@ import {
|
|||||||
import type { UserStatsResponse } from '../../types/statistics';
|
import type { UserStatsResponse } from '../../types/statistics';
|
||||||
import { getUserStats } from '../../services/statisticsService';
|
import { getUserStats } from '../../services/statisticsService';
|
||||||
import DateRangeFilter from '../../components/DateRangeFilter';
|
import DateRangeFilter from '../../components/DateRangeFilter';
|
||||||
|
import { CHART_COLORS_HEX } from '../../constants/chart';
|
||||||
const PIE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
|
|
||||||
const ROLE_BADGE: Record<string, string> = {
|
const ROLE_BADGE: Record<string, string> = {
|
||||||
ADMIN: 'bg-red-100 text-red-800',
|
ADMIN: 'bg-red-100 text-red-800',
|
||||||
@ -18,6 +18,8 @@ const ROLE_BADGE: Record<string, string> = {
|
|||||||
const getToday = () => new Date().toISOString().slice(0, 10);
|
const getToday = () => new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
const UserStatsPage = () => {
|
const UserStatsPage = () => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const chartColors = CHART_COLORS_HEX[theme];
|
||||||
const [startDate, setStartDate] = useState(getToday());
|
const [startDate, setStartDate] = useState(getToday());
|
||||||
const [endDate, setEndDate] = useState(getToday());
|
const [endDate, setEndDate] = useState(getToday());
|
||||||
const [data, setData] = useState<UserStatsResponse | null>(null);
|
const [data, setData] = useState<UserStatsResponse | null>(null);
|
||||||
@ -54,14 +56,14 @@ const UserStatsPage = () => {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="text-gray-500 dark:text-gray-400">로딩 중...</div>
|
<div className="text-[var(--color-text-secondary)]">로딩 중...</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6">사용자 통계</h1>
|
<h1 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6">사용자 통계</h1>
|
||||||
|
|
||||||
<DateRangeFilter
|
<DateRangeFilter
|
||||||
startDate={startDate}
|
startDate={startDate}
|
||||||
@ -72,30 +74,30 @@ const UserStatsPage = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{!data ? (
|
{!data ? (
|
||||||
<p className="text-gray-400 dark:text-gray-500 text-center py-20">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
<div className="flex gap-4 mb-6">
|
<div className="flex gap-4 mb-6">
|
||||||
<div className="flex-1 bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="flex-1 bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">전체 사용자</p>
|
<p className="text-sm text-[var(--color-text-secondary)]">전체 사용자</p>
|
||||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{data.totalUsers}</p>
|
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{data.totalUsers}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="flex-1 bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">API Key 보유 사용자</p>
|
<p className="text-sm text-[var(--color-text-secondary)]">API Key 보유 사용자</p>
|
||||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{data.usersWithActiveKey}</p>
|
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{data.usersWithActiveKey}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="flex-1 bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">API 요청 사용자</p>
|
<p className="text-sm text-[var(--color-text-secondary)]">API 요청 사용자</p>
|
||||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{data.totalActiveUsers}</p>
|
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{data.totalActiveUsers}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts */}
|
{/* Charts */}
|
||||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||||
{/* Chart 1: Daily Active Users */}
|
{/* Chart 1: Daily Active Users */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">일별 API 요청 사용자 추이</h3>
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">일별 API 요청 사용자 추이</h3>
|
||||||
{data.dailyActiveUsers.length > 0 ? (
|
{data.dailyActiveUsers.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<AreaChart data={data.dailyActiveUsers}>
|
<AreaChart data={data.dailyActiveUsers}>
|
||||||
@ -107,21 +109,21 @@ const UserStatsPage = () => {
|
|||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="activeUsers"
|
dataKey="activeUsers"
|
||||||
stroke="#3b82f6"
|
stroke={chartColors[0]}
|
||||||
fill="#3b82f6"
|
fill={chartColors[0]}
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
name="API 요청 사용자"
|
name="API 요청 사용자"
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-400 dark:text-gray-500 text-center py-20">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart 2: Role Distribution Donut */}
|
{/* Chart 2: Role Distribution Donut */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">역할별 요청 분포</h3>
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">역할별 요청 분포</h3>
|
||||||
{data.roleDistribution.length > 0 ? (
|
{data.roleDistribution.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
@ -133,7 +135,7 @@ const UserStatsPage = () => {
|
|||||||
outerRadius={100}
|
outerRadius={100}
|
||||||
>
|
>
|
||||||
{data.roleDistribution.map((_, idx) => (
|
{data.roleDistribution.map((_, idx) => (
|
||||||
<Cell key={idx} fill={PIE_COLORS[idx % PIE_COLORS.length]} />
|
<Cell key={idx} fill={chartColors[idx % chartColors.length]} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
@ -141,47 +143,47 @@ const UserStatsPage = () => {
|
|||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-400 dark:text-gray-500 text-center py-20">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-20">데이터가 없습니다</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table: Top Users */}
|
{/* Table: Top Users */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow">
|
||||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
<div className="p-6 border-b border-[var(--color-border)]">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">상위 사용자 Top 10</h3>
|
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">상위 사용자 Top 10</h3>
|
||||||
</div>
|
</div>
|
||||||
{data.topUsers.length > 0 ? (
|
{data.topUsers.length > 0 ? (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
<thead className="bg-[var(--color-bg-base)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">순위</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">순위</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">사용자</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">사용자</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">역할</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">역할</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">요청 수</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">요청 수</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">성공률</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">성공률</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="divide-y divide-[var(--color-border)]">
|
||||||
{data.topUsers.slice(0, 10).map((user, idx) => (
|
{data.topUsers.slice(0, 10).map((user, idx) => (
|
||||||
<tr key={user.userId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
<tr key={user.userId} className="hover:bg-[var(--color-bg-base)]">
|
||||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{idx + 1}</td>
|
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{idx + 1}</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{user.userName}</td>
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">{user.userName}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${ROLE_BADGE[user.role] ?? 'bg-gray-100 text-gray-800'}`}>
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${ROLE_BADGE[user.role] ?? 'bg-gray-100 text-gray-800'}`}>
|
||||||
{user.role}
|
{user.role}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{user.requestCount.toLocaleString()}</td>
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">{user.requestCount.toLocaleString()}</td>
|
||||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{user.successRate.toFixed(1)}%</td>
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">{user.successRate.toFixed(1)}%</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-400 dark:text-gray-500 text-center py-8">데이터가 없습니다</p>
|
<p className="text-[var(--color-text-tertiary)] text-center py-8">데이터가 없습니다</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
4
frontend/src/utils/cn.ts
Normal file
4
frontend/src/utils/cn.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
|
||||||
불러오는 중...
Reference in New Issue
Block a user