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` 참조
|
||||
- 팀 정책: `.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`)
|
||||
|
||||
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",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"recharts": "^3.8.1"
|
||||
"recharts": "^3.8.1",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
@ -4040,6 +4042,16 @@
|
||||
"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": {
|
||||
"version": "4.2.2",
|
||||
"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}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"recharts": "^3.8.1"
|
||||
"recharts": "^3.8.1",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
|
||||
@ -2,6 +2,7 @@ import { useState, useEffect, useMemo } from 'react';
|
||||
import { createKeyRequest } from '../services/apiKeyService';
|
||||
import { getCatalog } from '../services/apiHubService';
|
||||
import type { ServiceCatalog } from '../types/apihub';
|
||||
import Button from './ui/Button';
|
||||
|
||||
interface ApiKeyRequestModalProps {
|
||||
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"
|
||||
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">
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">API 사용 신청</h2>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--color-border)]">
|
||||
<h2 className="text-lg font-bold text-[var(--color-text-primary)]">API 사용 신청</h2>
|
||||
<button
|
||||
type="button"
|
||||
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}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
@ -166,24 +167,24 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
||||
{/* 성공 메시지 */}
|
||||
{success ? (
|
||||
<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">
|
||||
<h3 className="text-lg font-semibold text-green-800 dark:text-green-300 mb-2">신청이 완료되었습니다</h3>
|
||||
<p className="text-green-700 dark:text-green-400 text-sm">관리자 승인 후 API Key가 생성됩니다.</p>
|
||||
<div className="bg-[var(--color-success-subtle)] border border-[var(--color-success-border)] rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--color-success-text-strong)] mb-2">신청이 완료되었습니다</h3>
|
||||
<p className="text-[var(--color-success-text)] text-sm">관리자 승인 후 API Key가 생성됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-5">
|
||||
{/* 에러 메시지 */}
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API 선택 (도메인 기반 체크박스 트리) */}
|
||||
<div className="border border-gray-200 dark:border-gray-700 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">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
<div className="border border-[var(--color-border)] rounded-lg overflow-hidden">
|
||||
<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-[var(--color-text-primary)]">
|
||||
API 선택
|
||||
{selectedApiIds.size > 0 && (
|
||||
<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}
|
||||
onChange={(e) => setApiSearch(e.target.value)}
|
||||
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 className="max-h-56 overflow-y-auto">
|
||||
{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]) => {
|
||||
const isExpanded = expandedDomains.has(domain);
|
||||
const allSelected = apis.every((a) => selectedApiIds.has(a.apiId));
|
||||
@ -207,10 +208,10 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
||||
return (
|
||||
<div key={domain}>
|
||||
<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; })}
|
||||
>
|
||||
<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
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
@ -225,13 +226,13 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">{domain}</span>
|
||||
<span className="text-xs text-gray-400">{apis.length}</span>
|
||||
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{domain}</span>
|
||||
<span className="text-xs text-[var(--color-text-tertiary)]">{apis.length}</span>
|
||||
</div>
|
||||
{isExpanded && apis.map((a) => (
|
||||
<div
|
||||
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; })}
|
||||
>
|
||||
<input
|
||||
@ -241,7 +242,7 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
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>
|
||||
@ -252,7 +253,7 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
||||
|
||||
{/* Key Name */}
|
||||
<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>
|
||||
</label>
|
||||
<input
|
||||
@ -261,25 +262,25 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
||||
onChange={(e) => setKeyName(e.target.value)}
|
||||
required
|
||||
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>
|
||||
<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
|
||||
value={purpose}
|
||||
onChange={(e) => setPurpose(e.target.value)}
|
||||
rows={2}
|
||||
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>
|
||||
<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>
|
||||
</label>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
@ -289,21 +290,21 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
||||
type="button"
|
||||
onClick={() => handlePresetPeriod(m)}
|
||||
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}개월
|
||||
</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}
|
||||
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>
|
||||
<span className="text-gray-400 dark:text-gray-600 mx-1">|</span>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none">
|
||||
<span className="text-[var(--color-text-tertiary)] mx-1">|</span>
|
||||
<label className="flex items-center gap-2 text-sm text-[var(--color-text-primary)] cursor-pointer select-none">
|
||||
직접 선택
|
||||
<button
|
||||
type="button"
|
||||
@ -311,15 +312,15 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
||||
setPeriodMode(periodMode === 'custom' ? 'preset' : 'custom');
|
||||
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' : ''}`} />
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
{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">
|
||||
<span className="text-indigo-700 dark:text-indigo-300 text-sm font-medium">영구 사용 (만료일 없음)</span>
|
||||
<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 text-sm font-medium">영구 사용 (만료일 없음)</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
@ -328,15 +329,15 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
||||
value={fromDate}
|
||||
onChange={(e) => setFromDate(e.target.value)}
|
||||
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
|
||||
type="date"
|
||||
value={toDate}
|
||||
onChange={(e) => setToDate(e.target.value)}
|
||||
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>
|
||||
)}
|
||||
@ -344,7 +345,7 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
||||
|
||||
{/* 서비스 IP */}
|
||||
<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>
|
||||
</label>
|
||||
<input
|
||||
@ -353,22 +354,22 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
||||
onChange={(e) => setServiceIp(e.target.value)}
|
||||
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 className="grid grid-cols-2 gap-4">
|
||||
<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>
|
||||
</label>
|
||||
<select
|
||||
value={servicePurpose}
|
||||
onChange={(e) => setServicePurpose(e.target.value)}
|
||||
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>
|
||||
@ -378,14 +379,14 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
||||
</select>
|
||||
</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>
|
||||
</label>
|
||||
<select
|
||||
value={dailyEstimate}
|
||||
onChange={(e) => setDailyEstimate(e.target.value)}
|
||||
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="100">100 이하</option>
|
||||
@ -400,20 +401,19 @@ const ApiKeyRequestModal = ({ isOpen, onClose, initialApiIds, onSuccess }: ApiKe
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
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"
|
||||
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 ? '신청 중...' : '신청하기'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@ -39,15 +39,15 @@ const DateRangeFilter = ({
|
||||
|
||||
return (
|
||||
<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) => (
|
||||
<button
|
||||
key={preset.days}
|
||||
onClick={() => onPreset(preset.days)}
|
||||
className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
||||
isPresetActive(startDate, endDate, preset.days)
|
||||
? 'bg-blue-600 text-white'
|
||||
: '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-primary)] text-white dark:bg-[var(--color-primary-600)]'
|
||||
: 'bg-[var(--color-bg-base)] text-[var(--color-text-primary)] hover:bg-[var(--color-border)]'
|
||||
}`}
|
||||
>
|
||||
{preset.label}
|
||||
@ -57,14 +57,14 @@ const DateRangeFilter = ({
|
||||
type="date"
|
||||
value={startDate}
|
||||
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
|
||||
type="date"
|
||||
value={endDate}
|
||||
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>
|
||||
);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import Button from './ui/Button';
|
||||
|
||||
interface RoleGuardProps {
|
||||
allowedRoles: string[];
|
||||
@ -13,23 +14,20 @@ const RoleGuard = ({ allowedRoles, children }: RoleGuardProps) => {
|
||||
if (!user || !allowedRoles.includes(user.role)) {
|
||||
return (
|
||||
<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="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">
|
||||
<svg className="w-8 h-8 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
|
||||
<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-[var(--color-danger-subtle)] border border-[var(--color-danger-border)] flex items-center justify-center">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-2">접근 권한이 없습니다</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
이 페이지는 <span className="font-semibold text-gray-700 dark:text-gray-300">{allowedRoles.join(', ')}</span> 권한이 필요합니다.
|
||||
<br />현재 권한: <span className="font-semibold text-gray-700 dark:text-gray-300">{user?.role || '-'}</span>
|
||||
<h2 className="text-xl font-bold text-[var(--color-text-primary)] mb-2">접근 권한이 없습니다</h2>
|
||||
<p className="text-sm text-[var(--color-text-secondary)] mb-6">
|
||||
이 페이지는 <span className="font-semibold text-[var(--color-text-primary)]">{allowedRoles.join(', ')}</span> 권한이 필요합니다.
|
||||
<br />현재 권한: <span className="font-semibold text-[var(--color-text-primary)]">{user?.role || '-'}</span>
|
||||
</p>
|
||||
<button
|
||||
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 onClick={() => navigate('/dashboard')}>
|
||||
대시보드로 돌아가기
|
||||
</button>
|
||||
</Button>
|
||||
</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 *));
|
||||
|
||||
@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 input[type="date"]::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1);
|
||||
|
||||
@ -11,8 +11,8 @@ import {
|
||||
getSummary, getHourlyTrend, getServiceRatio,
|
||||
getErrorTrend, getTopApis, getRecentLogs, getHeartbeat,
|
||||
} from '../services/dashboardService';
|
||||
|
||||
const PIE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
|
||||
import { CHART_COLORS_HEX } from '../constants/chart';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
|
||||
const SERVICE_TAG_STYLES = [
|
||||
'bg-blue-100 text-blue-700',
|
||||
@ -48,6 +48,8 @@ const truncate = (str: string, max: number): string => {
|
||||
|
||||
const DashboardPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { theme } = useTheme();
|
||||
const chartColors = CHART_COLORS_HEX[theme];
|
||||
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [heartbeat, setHeartbeat] = useState<HeartbeatStatus[]>([]);
|
||||
@ -112,16 +114,16 @@ const DashboardPage = () => {
|
||||
serviceNames.forEach((name, i) => {
|
||||
map[name] = {
|
||||
tag: SERVICE_TAG_STYLES[i % SERVICE_TAG_STYLES.length],
|
||||
bar: PIE_COLORS[i % PIE_COLORS.length],
|
||||
bar: chartColors[i % chartColors.length],
|
||||
};
|
||||
});
|
||||
return map;
|
||||
}, [topApis]);
|
||||
}, [topApis, chartColors]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -130,35 +132,35 @@ const DashboardPage = () => {
|
||||
<div>
|
||||
{/* Header */}
|
||||
<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 && (
|
||||
<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>
|
||||
|
||||
{/* Row 1: Summary Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">오늘 총 요청</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{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'}`}>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||
<p className="text-sm text-[var(--color-text-secondary)]">오늘 총 요청</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-[var(--color-text-secondary)]'}`}>
|
||||
{stats.changePercent > 0 ? '\u25B2' : stats.changePercent < 0 ? '\u25BC' : ''} 전일 대비 {stats.changePercent.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">성공률</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{stats.successRate.toFixed(1)}%</p>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||
<p className="text-sm text-[var(--color-text-secondary)]">성공률</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>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">평균 응답 시간</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{stats.avgResponseTime.toFixed(0)}ms</p>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||
<p className="text-sm text-[var(--color-text-secondary)]">평균 응답 시간</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{stats.avgResponseTime.toFixed(0)}ms</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">API 요청 사용자</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{stats.activeUserCount}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">오늘</p>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||
<p className="text-sm text-[var(--color-text-secondary)]">API 요청 사용자</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{stats.activeUserCount}</p>
|
||||
<p className="text-sm text-[var(--color-text-secondary)]">오늘</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -175,7 +177,7 @@ const DashboardPage = () => {
|
||||
return (
|
||||
<div
|
||||
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')}
|
||||
>
|
||||
<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'
|
||||
}`}
|
||||
/>
|
||||
<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 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'}
|
||||
</span>
|
||||
{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>
|
||||
@ -199,8 +201,8 @@ const DashboardPage = () => {
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">등록된 서비스가 없습니다</p>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-4">
|
||||
<p className="text-[var(--color-text-secondary)] text-sm">등록된 서비스가 없습니다</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -208,8 +210,8 @@ const DashboardPage = () => {
|
||||
{/* Row 3: Charts 2x2 */}
|
||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||
{/* Chart 1: Hourly Trend */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">시간별 요청 추이</h3>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">시간별 요청 추이</h3>
|
||||
{hourlyTrend.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={hourlyTrend}>
|
||||
@ -218,18 +220,18 @@ const DashboardPage = () => {
|
||||
<YAxis />
|
||||
<Tooltip labelFormatter={(h) => `${h}시`} />
|
||||
<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="실패" />
|
||||
</LineChart>
|
||||
</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>
|
||||
|
||||
{/* Chart 2: Service Ratio */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">서비스별 요청 비율</h3>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">서비스별 요청 비율</h3>
|
||||
{serviceRatio.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
@ -241,7 +243,7 @@ const DashboardPage = () => {
|
||||
outerRadius={100}
|
||||
>
|
||||
{serviceRatio.map((_, idx) => (
|
||||
<Cell key={idx} fill={PIE_COLORS[idx % PIE_COLORS.length]} />
|
||||
<Cell key={idx} fill={chartColors[idx % chartColors.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
@ -249,13 +251,13 @@ const DashboardPage = () => {
|
||||
</PieChart>
|
||||
</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>
|
||||
|
||||
{/* Chart 3: Error Trend */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">에러율 추이</h3>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">에러율 추이</h3>
|
||||
{errorTrendPivoted.data.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={errorTrendPivoted.data}>
|
||||
@ -270,83 +272,83 @@ const DashboardPage = () => {
|
||||
type="monotone"
|
||||
dataKey={name}
|
||||
stackId="1"
|
||||
stroke={PIE_COLORS[idx % PIE_COLORS.length]}
|
||||
fill={PIE_COLORS[idx % PIE_COLORS.length]}
|
||||
stroke={chartColors[idx % chartColors.length]}
|
||||
fill={chartColors[idx % chartColors.length]}
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</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>
|
||||
|
||||
{/* Chart 4: Top APIs */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">상위 호출 API</h3>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">상위 호출 API</h3>
|
||||
{topApis.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{topApis.map((api, idx) => {
|
||||
const maxCount = topApis[0]?.count || 1;
|
||||
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 (
|
||||
<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}`}>
|
||||
{api.serviceName}
|
||||
</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}
|
||||
</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
|
||||
className="h-5 rounded-full"
|
||||
style={{ width: `${pct}%`, backgroundColor: colors.bar }}
|
||||
/>
|
||||
</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>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
{/* Row 4: Recent Logs */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow mb-6">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">최근 요청 로그</h3>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow mb-6">
|
||||
<div className="p-6 border-b border-[var(--color-border)]">
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">최근 요청 로그</h3>
|
||||
</div>
|
||||
{recentLogs.length > 0 ? (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<thead className="bg-[var(--color-bg-base)]">
|
||||
<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-gray-500 dark:text-gray-400 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-gray-500 dark:text-gray-400 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-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-[var(--color-text-secondary)] 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-[var(--color-text-secondary)] uppercase">URL</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-[var(--color-text-secondary)] uppercase">응답시간</th>
|
||||
</tr>
|
||||
</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) => (
|
||||
<tr
|
||||
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}`)}
|
||||
>
|
||||
<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-gray-900 dark:text-gray-100">{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-gray-900 dark:text-gray-100" title={log.requestUrl}>
|
||||
<td className="px-4 py-3 text-[var(--color-text-tertiary)] whitespace-nowrap">{log.requestedAt}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{log.serviceName ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{log.userName ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-primary)]" title={log.requestUrl}>
|
||||
{truncate(log.requestUrl, 40)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@ -354,7 +356,7 @@ const DashboardPage = () => {
|
||||
{log.requestStatus}
|
||||
</span>
|
||||
</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` : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
@ -362,17 +364,17 @@ const DashboardPage = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
</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
|
||||
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>
|
||||
</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>
|
||||
|
||||
@ -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 navigate = useNavigate();
|
||||
return (
|
||||
<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>
|
||||
<p className="mt-3 text-gray-600 dark:text-gray-400">요청하신 페이지를 찾을 수 없습니다.</p>
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
<h1 className="text-4xl font-bold text-[var(--color-text-primary)]">404 - Page Not Found</h1>
|
||||
<p className="mt-3 text-[var(--color-text-secondary)]">요청하신 페이지를 찾을 수 없습니다.</p>
|
||||
<Button className="mt-6" onClick={() => navigate('/dashboard')}>
|
||||
Dashboard로 이동
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -17,21 +17,23 @@ import {
|
||||
getDomains,
|
||||
} from '../../services/serviceService';
|
||||
import type { ApiDomainInfo } from '../../types/apihub';
|
||||
import Badge from '../../components/ui/Badge';
|
||||
import Button from '../../components/ui/Button';
|
||||
|
||||
const METHOD_COLOR: Record<string, string> = {
|
||||
GET: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
||||
POST: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
||||
PUT: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
|
||||
DELETE: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
|
||||
const METHOD_CLASS: Record<string, string> = {
|
||||
GET: 'bg-green-50 text-green-700 dark:bg-green-500/15 dark:text-green-400',
|
||||
POST: 'bg-blue-50 text-blue-700 dark:bg-blue-500/15 dark:text-blue-400',
|
||||
PUT: 'bg-amber-50 text-amber-700 dark:bg-amber-500/15 dark:text-amber-400',
|
||||
DELETE: 'bg-red-50 text-red-700 dark:bg-red-500/15 dark:text-red-400',
|
||||
};
|
||||
|
||||
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 =
|
||||
'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 { serviceId: serviceIdStr, apiId: apiIdStr } = useParams<{
|
||||
@ -318,7 +320,7 @@ const ApiEditPage = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -327,10 +329,10 @@ const ApiEditPage = () => {
|
||||
if (notFound) {
|
||||
return (
|
||||
<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
|
||||
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 목록으로 돌아가기
|
||||
</Link>
|
||||
@ -342,12 +344,12 @@ const ApiEditPage = () => {
|
||||
if (error) {
|
||||
return (
|
||||
<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}
|
||||
</div>
|
||||
<Link
|
||||
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 목록으로 돌아가기
|
||||
</Link>
|
||||
@ -358,48 +360,37 @@ const ApiEditPage = () => {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* 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
|
||||
to="/admin/apis"
|
||||
className="hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
className="hover:text-[var(--color-primary)] transition-colors"
|
||||
>
|
||||
API 관리
|
||||
</Link>
|
||||
<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 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>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<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">
|
||||
<span
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<Badge className={METHOD_CLASS[apiMethod] ?? 'bg-[var(--color-bg-base)] text-[var(--color-text-primary)]'}>
|
||||
{apiMethod}
|
||||
</span>
|
||||
<span className="font-mono text-sm text-gray-600 dark:text-gray-400">{apiPath}</span>
|
||||
</Badge>
|
||||
<span className="font-mono text-sm text-[var(--color-text-tertiary)]">{apiPath}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
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 onClick={handleDelete} variant="danger">
|
||||
삭제
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving} variant="primary">
|
||||
{saving ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -408,8 +399,8 @@ const ApiEditPage = () => {
|
||||
<div
|
||||
className={`mb-4 p-3 rounded-lg text-sm ${
|
||||
saveMessage.type === 'success'
|
||||
? 'bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||
: 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400'
|
||||
? 'bg-green-50 text-green-700'
|
||||
: 'bg-red-50 text-red-700'
|
||||
}`}
|
||||
>
|
||||
{saveMessage.text}
|
||||
@ -419,8 +410,8 @@ const ApiEditPage = () => {
|
||||
{/* Sections */}
|
||||
<div className="space-y-6 mt-6">
|
||||
{/* Section 1: 기본 정보 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-4">기본 정보</h2>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||
<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>
|
||||
<label className={LABEL_CLS}>Method</label>
|
||||
@ -483,9 +474,9 @@ const ApiEditPage = () => {
|
||||
id="apiIsActive"
|
||||
checked={apiIsActive}
|
||||
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
|
||||
</label>
|
||||
</div>
|
||||
@ -503,8 +494,8 @@ const ApiEditPage = () => {
|
||||
</div>
|
||||
|
||||
{/* Section 2: API 명세 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-4">API 명세</h2>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-4">API 명세</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className={LABEL_CLS}>샘플 URL</label>
|
||||
@ -524,9 +515,9 @@ const ApiEditPage = () => {
|
||||
id="authRequired"
|
||||
checked={authRequired}
|
||||
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>
|
||||
</div>
|
||||
@ -551,9 +542,9 @@ const ApiEditPage = () => {
|
||||
id="deprecated"
|
||||
checked={deprecated}
|
||||
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
|
||||
</label>
|
||||
</div>
|
||||
@ -593,36 +584,32 @@ const ApiEditPage = () => {
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">요청인자</h2>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">요청인자</h2>
|
||||
<Button type="button" onClick={() => addParam()} variant="secondary" size="sm">
|
||||
행 추가
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||
<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-gray-500 dark:text-gray-400">인자명</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-gray-500 dark:text-gray-400">설명</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-left text-xs font-medium text-gray-500 dark:text-gray-400 w-28">입력유형</th>
|
||||
<tr className="border-b border-[var(--color-border)]">
|
||||
<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-[var(--color-text-secondary)]">인자명</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-[var(--color-text-secondary)]">설명</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-[var(--color-text-secondary)] w-28">입력유형</th>
|
||||
<th className="px-2 py-2 w-10" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<tbody className="divide-y divide-[var(--color-border)]">
|
||||
{requestParams.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
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>
|
||||
@ -630,7 +617,7 @@ const ApiEditPage = () => {
|
||||
) : (
|
||||
requestParams.map((param, 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}
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
@ -669,7 +656,7 @@ const ApiEditPage = () => {
|
||||
type="checkbox"
|
||||
checked={param.required ?? false}
|
||||
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 className="px-2 py-1.5">
|
||||
@ -688,7 +675,7 @@ const ApiEditPage = () => {
|
||||
<button
|
||||
type="button"
|
||||
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="삭제"
|
||||
>
|
||||
×
|
||||
@ -703,30 +690,27 @@ const ApiEditPage = () => {
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<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">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
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 파싱'}
|
||||
</button>
|
||||
<button
|
||||
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 type="button" onClick={addResponseParam} variant="secondary" size="sm">
|
||||
행 추가
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||
<div className="mb-4 p-4 bg-[var(--color-bg-base)] rounded-lg border border-[var(--color-border)]">
|
||||
<p className="text-xs text-[var(--color-text-secondary)] mb-2">
|
||||
JSON 응답 예시를 붙여넣으면 키를 자동으로 추출합니다. (기존 목록을 대체합니다)
|
||||
</p>
|
||||
<textarea
|
||||
@ -739,33 +723,35 @@ const ApiEditPage = () => {
|
||||
{jsonError && (
|
||||
<p className="mt-1.5 text-xs text-red-500">{jsonError}</p>
|
||||
)}
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={parseJsonToParams}
|
||||
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 className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||
<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-gray-500 dark:text-gray-400">변수명</th>
|
||||
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">의미(단위)</th>
|
||||
<tr className="border-b border-[var(--color-border)]">
|
||||
<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-[var(--color-text-secondary)]">변수명</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" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<tbody className="divide-y divide-[var(--color-border)]">
|
||||
{responseParams.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
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>
|
||||
@ -773,7 +759,7 @@ const ApiEditPage = () => {
|
||||
) : (
|
||||
responseParams.map((param, 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}
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
@ -800,7 +786,7 @@ const ApiEditPage = () => {
|
||||
<button
|
||||
type="button"
|
||||
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="삭제"
|
||||
>
|
||||
×
|
||||
|
||||
@ -2,12 +2,14 @@ import { useState, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { ServiceInfo, ServiceApi, CreateServiceApiRequest } from '../../types/service';
|
||||
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> = {
|
||||
GET: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
||||
POST: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
||||
PUT: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
|
||||
DELETE: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
|
||||
GET: 'bg-green-50 text-green-700 dark:bg-green-500/15 dark:text-green-400',
|
||||
POST: 'bg-blue-50 text-blue-700 dark:bg-blue-500/15 dark:text-blue-400',
|
||||
PUT: 'bg-amber-50 text-amber-700 dark:bg-amber-500/15 dark:text-amber-400',
|
||||
DELETE: 'bg-red-50 text-red-700 dark:bg-red-500/15 dark:text-red-400',
|
||||
};
|
||||
|
||||
interface FlatApi extends ServiceApi {
|
||||
@ -148,7 +150,7 @@ const ApisPage = () => {
|
||||
|
||||
if (loading) {
|
||||
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>
|
||||
);
|
||||
@ -158,18 +160,13 @@ const ApisPage = () => {
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">API 관리</h1>
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">API 관리</h1>
|
||||
<Button onClick={handleOpenModal}>API 생성</Button>
|
||||
</div>
|
||||
|
||||
{/* Global 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}
|
||||
</div>
|
||||
)}
|
||||
@ -181,7 +178,7 @@ const ApisPage = () => {
|
||||
onChange={(e) =>
|
||||
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>
|
||||
{services.map((svc) => (
|
||||
@ -196,18 +193,18 @@ const ApisPage = () => {
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
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) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setFilterActive(v)}
|
||||
className={`px-3 py-2 font-medium ${
|
||||
filterActive === v
|
||||
? 'bg-blue-600 text-white'
|
||||
: '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-primary)] text-white dark:bg-[var(--color-primary-600)]'
|
||||
: 'bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] hover:bg-[var(--color-bg-base)]'
|
||||
}`}
|
||||
>
|
||||
{v === 'all' ? '전체' : v === 'active' ? 'Active' : 'Inactive'}
|
||||
@ -217,62 +214,52 @@ const ApisPage = () => {
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<div className="overflow-x-auto bg-[var(--color-bg-surface)] rounded-lg shadow">
|
||||
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
||||
<thead className="bg-[var(--color-bg-base)]">
|
||||
<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-gray-500 dark:text-gray-400">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-gray-500 dark:text-gray-400">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-gray-500 dark:text-gray-400">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-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-[var(--color-text-secondary)]">Method</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-[var(--color-text-secondary)]">API명</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-[var(--color-text-secondary)]">Section</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-[var(--color-text-secondary)]">명세</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tbody className="divide-y divide-[var(--color-border)]">
|
||||
{filteredApis.map((api) => (
|
||||
<tr
|
||||
key={`${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">
|
||||
<span
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<Badge className={METHOD_COLOR[api.apiMethod] ?? ''}>
|
||||
{api.apiMethod}
|
||||
</span>
|
||||
</Badge>
|
||||
</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}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{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-gray-500 dark:text-gray-400">{api.apiSection || '-'}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{api.apiName}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{api.apiDomain || '-'}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{api.apiSection || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<Badge variant={api.isActive ? 'success' : 'danger'}>
|
||||
{api.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</Badge>
|
||||
</td>
|
||||
<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>
|
||||
</tr>
|
||||
))}
|
||||
{filteredApis.length === 0 && (
|
||||
<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가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
@ -284,33 +271,30 @@ const ApisPage = () => {
|
||||
{/* Create API Modal */}
|
||||
{isModalOpen && (
|
||||
<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="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">API 생성</h2>
|
||||
<button
|
||||
onClick={handleCloseModal}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl font-bold leading-none"
|
||||
>
|
||||
<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-[var(--color-border)]">
|
||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">API 생성</h2>
|
||||
<Button variant="ghost" size="sm" onClick={handleCloseModal} className="text-xl font-bold leading-none px-2">
|
||||
×
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleModalSubmit} className="px-6 py-4 space-y-4">
|
||||
{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}
|
||||
</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>
|
||||
</label>
|
||||
<select
|
||||
value={modalServiceId}
|
||||
onChange={(e) => setModalServiceId(Number(e.target.value))}
|
||||
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) => (
|
||||
<option key={svc.serviceId} value={svc.serviceId}>
|
||||
@ -321,13 +305,13 @@ const ApisPage = () => {
|
||||
</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>
|
||||
</label>
|
||||
<select
|
||||
value={modalMethod}
|
||||
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) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
@ -336,7 +320,7 @@ const ApisPage = () => {
|
||||
</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>
|
||||
</label>
|
||||
<input
|
||||
@ -345,12 +329,12 @@ const ApisPage = () => {
|
||||
onChange={(e) => setModalPath(e.target.value)}
|
||||
required
|
||||
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>
|
||||
<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>
|
||||
</label>
|
||||
<input
|
||||
@ -359,13 +343,13 @@ const ApisPage = () => {
|
||||
onChange={(e) => setModalName(e.target.value)}
|
||||
required
|
||||
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 className="grid grid-cols-2 gap-3">
|
||||
<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
|
||||
</label>
|
||||
<input
|
||||
@ -373,11 +357,11 @@ const ApisPage = () => {
|
||||
value={modalDomain}
|
||||
onChange={(e) => setModalDomain(e.target.value)}
|
||||
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>
|
||||
<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
|
||||
</label>
|
||||
<input
|
||||
@ -385,13 +369,13 @@ const ApisPage = () => {
|
||||
value={modalSection}
|
||||
onChange={(e) => setModalSection(e.target.value)}
|
||||
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>
|
||||
<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>
|
||||
<textarea
|
||||
@ -399,25 +383,17 @@ const ApisPage = () => {
|
||||
onChange={(e) => setModalDescription(e.target.value)}
|
||||
rows={3}
|
||||
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 className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
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 type="button" variant="outline" onClick={handleCloseModal}>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? '생성 중...' : '생성'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import type { ApiDomainInfo } from '../../types/apihub';
|
||||
import type { CreateDomainRequest, UpdateDomainRequest } 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'];
|
||||
|
||||
@ -121,43 +122,38 @@ const DomainsPage = () => {
|
||||
const previewPath = iconPath.trim() || null;
|
||||
|
||||
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 (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Domains</h1>
|
||||
<button
|
||||
onClick={handleOpenCreate}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
도메인 추가
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">Domains</h1>
|
||||
<Button onClick={handleOpenCreate}>도메인 추가</Button>
|
||||
</div>
|
||||
|
||||
{error && !isModalOpen && (
|
||||
<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">
|
||||
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<div className="overflow-x-auto bg-[var(--color-bg-surface)] rounded-lg shadow">
|
||||
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
||||
<thead className="bg-[var(--color-bg-base)]">
|
||||
<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-gray-500 dark:text-gray-400">아이콘</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-gray-500 dark:text-gray-400">정렬순서</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)]">#</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-[var(--color-text-secondary)]">도메인명</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-[var(--color-text-secondary)]">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tbody className="divide-y divide-[var(--color-border)]">
|
||||
{domains.map((domain, index) => (
|
||||
<tr key={domain.domainId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{index + 1}</td>
|
||||
<tr key={domain.domainId} className="hover:bg-[var(--color-bg-base)]">
|
||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{index + 1}</td>
|
||||
<td className="px-4 py-3">
|
||||
<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"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@ -170,29 +166,23 @@ const DomainsPage = () => {
|
||||
))}
|
||||
</svg>
|
||||
</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-gray-500 dark:text-gray-400">{domain.sortOrder}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-primary)] font-medium">{domain.domainName}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{domain.sortOrder}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
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 variant="outline" size="sm" onClick={() => handleOpenEdit(domain)}>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
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 variant="danger" size="sm" onClick={() => handleDelete(domain)}>
|
||||
삭제
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{domains.length === 0 && (
|
||||
<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>
|
||||
</tr>
|
||||
@ -203,9 +193,9 @@ const DomainsPage = () => {
|
||||
|
||||
{isModalOpen && (
|
||||
<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="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
<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-[var(--color-border)]">
|
||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
{editingDomain ? '도메인 수정' : '도메인 추가'}
|
||||
</h2>
|
||||
</div>
|
||||
@ -215,7 +205,7 @@ const DomainsPage = () => {
|
||||
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</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>
|
||||
<input
|
||||
@ -223,13 +213,13 @@ const DomainsPage = () => {
|
||||
value={domainName}
|
||||
onChange={(e) => setDomainName(e.target.value)}
|
||||
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>
|
||||
<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 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://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)}
|
||||
rows={3}
|
||||
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>
|
||||
<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>
|
||||
<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
|
||||
className="h-8 w-8 text-gray-600 dark:text-gray-300"
|
||||
className="h-8 w-8 text-[var(--color-text-secondary)]"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@ -264,7 +254,7 @@ const DomainsPage = () => {
|
||||
</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>
|
||||
<input
|
||||
@ -272,24 +262,15 @@ const DomainsPage = () => {
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(Number(e.target.value))}
|
||||
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 className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<div className="px-6 py-4 border-t border-[var(--color-border)] flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={handleCloseModal}>
|
||||
Cancel
|
||||
</button>
|
||||
<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>
|
||||
</Button>
|
||||
<Button type="submit">Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getSystemConfig, updateSystemConfig } from '../../services/configService';
|
||||
import Button from '../../components/ui/Button';
|
||||
|
||||
const COMMON_SAMPLE_CODE_KEY = 'COMMON_SAMPLE_CODE';
|
||||
|
||||
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 [sampleCode, setSampleCode] = useState('');
|
||||
@ -50,7 +51,7 @@ const SampleCodePage = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -59,18 +60,18 @@ const SampleCodePage = () => {
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">공통 샘플 코드 관리</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">공통 샘플 코드 관리</h1>
|
||||
<p className="text-sm text-[var(--color-text-secondary)] mt-1">
|
||||
API HUB 상세 페이지에 공통으로 표시되는 샘플 코드를 관리합니다.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
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 shrink-0"
|
||||
className="shrink-0"
|
||||
>
|
||||
{saving ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
@ -85,8 +86,8 @@ const SampleCodePage = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-2">
|
||||
샘플 코드
|
||||
</label>
|
||||
<textarea
|
||||
@ -96,7 +97,7 @@ const SampleCodePage = () => {
|
||||
placeholder="API HUB 상세 페이지에 표시할 공통 샘플 코드를 입력하세요."
|
||||
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 생성' 섹션 하단에 표시됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -13,11 +13,13 @@ import {
|
||||
deleteService,
|
||||
getServiceApis,
|
||||
} 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 }> = {
|
||||
UP: { dot: 'bg-green-500', bg: 'bg-green-100', text: 'text-green-800' },
|
||||
DOWN: { dot: 'bg-red-500', bg: 'bg-red-100', text: 'text-red-800' },
|
||||
UNKNOWN: { dot: 'bg-gray-400', bg: 'bg-gray-100', text: 'text-gray-800' },
|
||||
const HEALTH_DOT: Record<string, string> = {
|
||||
UP: 'bg-green-500',
|
||||
DOWN: 'bg-red-500',
|
||||
UNKNOWN: 'bg-gray-400',
|
||||
};
|
||||
|
||||
const METHOD_COLOR: Record<string, string> = {
|
||||
@ -189,103 +191,98 @@ const ServicesPage = () => {
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Services</h1>
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">Services</h1>
|
||||
<Button onClick={handleOpenCreateService}>Create Service</Button>
|
||||
</div>
|
||||
|
||||
{error && !isServiceModalOpen && (
|
||||
<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">
|
||||
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<div className="overflow-x-auto bg-[var(--color-bg-surface)] rounded-lg shadow mb-6">
|
||||
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
||||
<thead className="bg-[var(--color-bg-base)]">
|
||||
<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-gray-500 dark:text-gray-400">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-gray-500 dark:text-gray-400">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-gray-500 dark:text-gray-400">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-gray-500 dark:text-gray-400">Actions</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-[var(--color-text-secondary)]">Name</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-[var(--color-text-secondary)]">Health Status</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-[var(--color-text-secondary)]">Last Checked</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-[var(--color-text-secondary)]">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tbody className="divide-y divide-[var(--color-border)]">
|
||||
{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;
|
||||
return (
|
||||
<tr
|
||||
key={service.serviceId}
|
||||
onClick={() => handleSelectService(service)}
|
||||
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 text-gray-900 dark:text-gray-100">{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 font-mono text-[var(--color-text-primary)]">{service.serviceCode}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{service.serviceName}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-secondary)] truncate max-w-[200px]">
|
||||
{service.serviceUrl || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
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 ${badge.dot}`} />
|
||||
<Badge variant={healthVariant} className="gap-1.5">
|
||||
<span className={`w-2 h-2 rounded-full ${dot}`} />
|
||||
{service.healthStatus}
|
||||
</span>
|
||||
</Badge>
|
||||
</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}ms`
|
||||
: '-'}
|
||||
</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)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<Badge variant={service.isActive ? 'success' : 'danger'}>
|
||||
{service.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
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) => {
|
||||
e.stopPropagation();
|
||||
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>
|
||||
</td>
|
||||
</tr>
|
||||
@ -293,7 +290,7 @@ const ServicesPage = () => {
|
||||
})}
|
||||
{services.length === 0 && (
|
||||
<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>
|
||||
</tr>
|
||||
@ -303,34 +300,29 @@ const ServicesPage = () => {
|
||||
</div>
|
||||
|
||||
{selectedService && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--color-border)]">
|
||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
APIs for {selectedService.serviceName}
|
||||
</h2>
|
||||
<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>
|
||||
<Button size="sm" onClick={() => navigate('/admin/apis')}>API 관리</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
||||
<thead className="bg-[var(--color-bg-base)]">
|
||||
<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-gray-500 dark:text-gray-400">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-gray-500 dark:text-gray-400">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-gray-500 dark:text-gray-400">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)]">Method</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-[var(--color-text-secondary)]">Name</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-[var(--color-text-secondary)]">Section</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-[var(--color-text-secondary)]">Active</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tbody className="divide-y divide-[var(--color-border)]">
|
||||
{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">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
|
||||
@ -340,27 +332,21 @@ const ServicesPage = () => {
|
||||
{api.apiMethod}
|
||||
</span>
|
||||
</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 text-gray-900 dark:text-gray-100">{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-gray-500 dark:text-gray-400">{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 font-mono text-[var(--color-text-primary)]">{api.apiPath}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{api.apiName}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{api.apiDomain || '-'}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{api.apiSection || '-'}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{api.description || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<Badge variant={api.isActive ? 'success' : 'danger'}>
|
||||
{api.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{serviceApis.length === 0 && (
|
||||
<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가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
@ -373,9 +359,9 @@ const ServicesPage = () => {
|
||||
|
||||
{isServiceModalOpen && (
|
||||
<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="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
<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-[var(--color-border)]">
|
||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
{editingService ? '서비스 수정' : '서비스 생성'}
|
||||
</h2>
|
||||
</div>
|
||||
@ -385,7 +371,7 @@ const ServicesPage = () => {
|
||||
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</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
|
||||
</label>
|
||||
<input
|
||||
@ -394,11 +380,11 @@ const ServicesPage = () => {
|
||||
onChange={(e) => setServiceCode(e.target.value)}
|
||||
disabled={!!editingService}
|
||||
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>
|
||||
<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
|
||||
</label>
|
||||
<input
|
||||
@ -406,44 +392,44 @@ const ServicesPage = () => {
|
||||
value={serviceName}
|
||||
onChange={(e) => setServiceName(e.target.value)}
|
||||
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>
|
||||
<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
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={serviceUrl}
|
||||
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>
|
||||
<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
|
||||
</label>
|
||||
<textarea
|
||||
value={serviceDescription}
|
||||
onChange={(e) => setServiceDescription(e.target.value)}
|
||||
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>
|
||||
<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
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={healthCheckUrl}
|
||||
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>
|
||||
<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)
|
||||
</label>
|
||||
<input
|
||||
@ -451,7 +437,7 @@ const ServicesPage = () => {
|
||||
value={healthCheckInterval}
|
||||
onChange={(e) => setHealthCheckInterval(Number(e.target.value))}
|
||||
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>
|
||||
{editingService && (
|
||||
@ -463,26 +449,17 @@ const ServicesPage = () => {
|
||||
onChange={(e) => setServiceIsActive(e.target.checked)}
|
||||
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
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<div className="px-6 py-4 border-t border-[var(--color-border)] flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={handleCloseServiceModal}>
|
||||
Cancel
|
||||
</button>
|
||||
<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>
|
||||
</Button>
|
||||
<Button type="submit">Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Tenant, CreateTenantRequest, UpdateTenantRequest } from '../../types/tenant';
|
||||
import { getTenants, createTenant, updateTenant } from '../../services/tenantService';
|
||||
import Button from '../../components/ui/Button';
|
||||
import Badge from '../../components/ui/Badge';
|
||||
|
||||
const TenantsPage = () => {
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
@ -94,70 +96,56 @@ const TenantsPage = () => {
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Tenants</h1>
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">Tenants</h1>
|
||||
<Button onClick={handleOpenCreate}>Create Tenant</Button>
|
||||
</div>
|
||||
|
||||
{error && !isModalOpen && (
|
||||
<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">
|
||||
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<div className="overflow-x-auto bg-[var(--color-bg-surface)] rounded-lg shadow">
|
||||
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
||||
<thead className="bg-[var(--color-bg-base)]">
|
||||
<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-gray-500 dark:text-gray-400">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-gray-500 dark:text-gray-400">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-gray-500 dark:text-gray-400">Actions</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-[var(--color-text-secondary)]">Name</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-[var(--color-text-secondary)]">Active</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-[var(--color-text-secondary)]">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tbody className="divide-y divide-[var(--color-border)]">
|
||||
{tenants.map((tenant) => (
|
||||
<tr key={tenant.tenantId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-4 py-3 font-mono text-gray-900 dark:text-gray-100">{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-gray-500 dark:text-gray-400">{tenant.description || '-'}</td>
|
||||
<tr key={tenant.tenantId} className="hover:bg-[var(--color-bg-base)]">
|
||||
<td className="px-4 py-3 font-mono text-[var(--color-text-primary)]">{tenant.tenantCode}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{tenant.tenantName}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{tenant.description || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<Badge variant={tenant.isActive ? 'success' : 'danger'}>
|
||||
{tenant.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</Badge>
|
||||
</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()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
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 variant="outline" size="sm" onClick={() => handleOpenEdit(tenant)}>
|
||||
수정
|
||||
</button>
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{tenants.length === 0 && (
|
||||
<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>
|
||||
</tr>
|
||||
@ -168,9 +156,9 @@ const TenantsPage = () => {
|
||||
|
||||
{isModalOpen && (
|
||||
<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="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
<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-[var(--color-border)]">
|
||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
{editingTenant ? '테넌트 수정' : '테넌트 생성'}
|
||||
</h2>
|
||||
</div>
|
||||
@ -180,7 +168,7 @@ const TenantsPage = () => {
|
||||
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</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
|
||||
</label>
|
||||
<input
|
||||
@ -189,11 +177,11 @@ const TenantsPage = () => {
|
||||
onChange={(e) => setTenantCode(e.target.value)}
|
||||
disabled={!!editingTenant}
|
||||
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>
|
||||
<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
|
||||
</label>
|
||||
<input
|
||||
@ -201,18 +189,18 @@ const TenantsPage = () => {
|
||||
value={tenantName}
|
||||
onChange={(e) => setTenantName(e.target.value)}
|
||||
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>
|
||||
<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
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
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>
|
||||
{editingTenant && (
|
||||
@ -224,26 +212,17 @@ const TenantsPage = () => {
|
||||
onChange={(e) => setIsActive(e.target.checked)}
|
||||
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
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<div className="px-6 py-4 border-t border-[var(--color-border)] flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={handleCloseModal}>
|
||||
Cancel
|
||||
</button>
|
||||
<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>
|
||||
</Button>
|
||||
<Button type="submit">Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -3,12 +3,15 @@ import type { UserDetail, CreateUserRequest, UpdateUserRequest } from '../../typ
|
||||
import type { Tenant } from '../../types/tenant';
|
||||
import { getUsers, createUser, updateUser, deactivateUser } from '../../services/userService';
|
||||
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> = {
|
||||
ADMIN: 'bg-red-100 text-red-800',
|
||||
MANAGER: 'bg-orange-100 text-orange-800',
|
||||
USER: 'bg-blue-100 text-blue-800',
|
||||
VIEWER: 'bg-gray-100 text-gray-800',
|
||||
const ROLE_BADGE_VARIANT: Record<string, BadgeVariant> = {
|
||||
ADMIN: 'danger',
|
||||
MANAGER: 'warning',
|
||||
USER: 'primary',
|
||||
VIEWER: 'default',
|
||||
};
|
||||
|
||||
const UsersPage = () => {
|
||||
@ -135,92 +138,73 @@ const UsersPage = () => {
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Users</h1>
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">Users</h1>
|
||||
<Button onClick={handleOpenCreate}>Create User</Button>
|
||||
</div>
|
||||
|
||||
{error && !isModalOpen && (
|
||||
<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">
|
||||
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<div className="overflow-x-auto bg-[var(--color-bg-surface)] rounded-lg shadow">
|
||||
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
||||
<thead className="bg-[var(--color-bg-base)]">
|
||||
<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-gray-500 dark:text-gray-400">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-gray-500 dark:text-gray-400">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-gray-500 dark:text-gray-400">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-gray-500 dark:text-gray-400">Actions</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-[var(--color-text-secondary)]">Name</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-[var(--color-text-secondary)]">Tenant</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-[var(--color-text-secondary)]">Active</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-[var(--color-text-secondary)]">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tbody className="divide-y divide-[var(--color-border)]">
|
||||
{users.map((user) => (
|
||||
<tr key={user.userId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-4 py-3 font-mono text-gray-900 dark:text-gray-100">{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-gray-500 dark:text-gray-400">{user.email || '-'}</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{user.tenantName || '-'}</td>
|
||||
<tr key={user.userId} className="hover:bg-[var(--color-bg-base)]">
|
||||
<td className="px-4 py-3 font-mono text-[var(--color-text-primary)]">{user.loginId}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{user.userName}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{user.email || '-'}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{user.tenantName || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
ROLE_BADGE[user.role] || 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
<Badge variant={ROLE_BADGE_VARIANT[user.role] ?? 'default'}>
|
||||
{user.role}
|
||||
</span>
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<Badge variant={user.isActive ? 'success' : 'danger'}>
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</Badge>
|
||||
</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
|
||||
? new Date(user.lastLoginAt).toLocaleString()
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 space-x-2">
|
||||
<button
|
||||
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
|
||||
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"
|
||||
>
|
||||
비활성화
|
||||
</button>
|
||||
)}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => handleOpenEdit(user)}>
|
||||
수정
|
||||
</Button>
|
||||
{user.isActive && (
|
||||
<Button variant="danger" size="sm" onClick={() => handleDeactivate(user)}>
|
||||
비활성화
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<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>
|
||||
</tr>
|
||||
@ -231,9 +215,9 @@ const UsersPage = () => {
|
||||
|
||||
{isModalOpen && (
|
||||
<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="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
<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-[var(--color-border)]">
|
||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
{editingUser ? '사용자 수정' : '사용자 생성'}
|
||||
</h2>
|
||||
</div>
|
||||
@ -243,52 +227,52 @@ const UsersPage = () => {
|
||||
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</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
|
||||
type="text"
|
||||
value={loginId}
|
||||
onChange={(e) => setLoginId(e.target.value)}
|
||||
disabled={!!editingUser}
|
||||
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>
|
||||
<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
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required={!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>
|
||||
<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
|
||||
type="text"
|
||||
value={userName}
|
||||
onChange={(e) => setUserName(e.target.value)}
|
||||
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>
|
||||
<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
|
||||
type="email"
|
||||
value={email}
|
||||
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>
|
||||
<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
|
||||
value={tenantId}
|
||||
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>
|
||||
{tenants.map((t) => (
|
||||
@ -299,11 +283,11 @@ const UsersPage = () => {
|
||||
</select>
|
||||
</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
|
||||
value={role}
|
||||
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="MANAGER">MANAGER</option>
|
||||
@ -320,26 +304,17 @@ const UsersPage = () => {
|
||||
onChange={(e) => setIsActive(e.target.checked)}
|
||||
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
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<div className="px-6 py-4 border-t border-[var(--color-border)] flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={handleCloseModal}>
|
||||
Cancel
|
||||
</button>
|
||||
<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>
|
||||
</Button>
|
||||
<Button type="submit">Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -6,6 +6,7 @@ import { getServiceCatalog, getApiHubApiDetail } from '../../services/apiHubServ
|
||||
import { getSystemConfig } from '../../services/configService';
|
||||
import { useApiRequestBasket } from '../../hooks/useApiRequestBasket';
|
||||
import ApiKeyRequestModal from '../../components/ApiKeyRequestModal';
|
||||
import Button from '../../components/ui/Button';
|
||||
|
||||
const COMMON_SAMPLE_CODE_KEY = 'COMMON_SAMPLE_CODE';
|
||||
|
||||
@ -81,7 +82,7 @@ const ApiHubApiDetailPage = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -91,11 +92,11 @@ const ApiHubApiDetailPage = () => {
|
||||
<div>
|
||||
<button
|
||||
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>
|
||||
<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를 찾을 수 없습니다'}
|
||||
</div>
|
||||
</div>
|
||||
@ -155,26 +156,26 @@ const ApiHubApiDetailPage = () => {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* 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
|
||||
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
|
||||
</button>
|
||||
<span>/</span>
|
||||
<button
|
||||
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}
|
||||
</button>
|
||||
<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>
|
||||
|
||||
{/* 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 gap-4">
|
||||
<span
|
||||
@ -182,13 +183,13 @@ const ApiHubApiDetailPage = () => {
|
||||
>
|
||||
{api.apiMethod}
|
||||
</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 className="flex items-center gap-2 shrink-0">
|
||||
{hasItem(Number(apiId)) ? (
|
||||
<button
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
@ -203,7 +204,7 @@ const ApiHubApiDetailPage = () => {
|
||||
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">
|
||||
<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
|
||||
onClick={handleOpenRequest}
|
||||
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
<Button onClick={handleOpenRequest}>
|
||||
API 사용 신청
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -225,21 +223,21 @@ const ApiHubApiDetailPage = () => {
|
||||
|
||||
{/* 기본 정보 */}
|
||||
{api.description && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 border border-gray-100 dark:border-gray-700">
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-4">기본 정보</h2>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">{api.description}</p>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 border border-[var(--color-border)]">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-4">기본 정보</h2>
|
||||
<p className="text-sm text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap">{api.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 샘플 URL */}
|
||||
{spec?.sampleUrl && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 border border-gray-100 dark:border-gray-700">
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-3">샘플 URL</h2>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 border border-[var(--color-border)]">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-3">샘플 URL</h2>
|
||||
<a
|
||||
href={spec.sampleUrl}
|
||||
target="_blank"
|
||||
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}
|
||||
</a>
|
||||
@ -247,15 +245,15 @@ const ApiHubApiDetailPage = () => {
|
||||
)}
|
||||
|
||||
{/* 요청 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
|
||||
type="button"
|
||||
onClick={() => setUrlGenOpen((v) => !v)}
|
||||
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
|
||||
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"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@ -266,20 +264,20 @@ const ApiHubApiDetailPage = () => {
|
||||
</button>
|
||||
|
||||
{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 생성 폼 */}
|
||||
{urlInputParams.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="space-y-3 mb-4">
|
||||
{urlInputParams.map((p) => {
|
||||
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();
|
||||
|
||||
return (
|
||||
<div key={p.paramName}>
|
||||
<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.required && <span className="text-red-500 ml-0.5">*</span>}
|
||||
</label>
|
||||
@ -324,31 +322,28 @@ const ApiHubApiDetailPage = () => {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleGenerateUrl}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
<Button onClick={handleGenerateUrl}>
|
||||
URL 생성
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 생성된 URL */}
|
||||
{generatedUrl && (
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">생성된 요청 URL</p>
|
||||
<div className="flex items-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p className="text-xs font-medium text-[var(--color-text-secondary)] mb-1.5">생성된 요청 URL</p>
|
||||
<div className="flex items-center p-3 bg-[var(--color-bg-base)] rounded-lg">
|
||||
<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'}`}
|
||||
>
|
||||
{api.apiMethod}
|
||||
</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}
|
||||
</span>
|
||||
<button
|
||||
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 ? '복사됨' : '복사'}
|
||||
</button>
|
||||
@ -359,7 +354,7 @@ const ApiHubApiDetailPage = () => {
|
||||
{/* 샘플 코드 */}
|
||||
{commonSampleCode && (
|
||||
<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">
|
||||
{commonSampleCode}
|
||||
</pre>
|
||||
@ -367,7 +362,7 @@ const ApiHubApiDetailPage = () => {
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
@ -375,31 +370,31 @@ const ApiHubApiDetailPage = () => {
|
||||
|
||||
{/* 요청인자 */}
|
||||
{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="px-6 py-4 border-b border-gray-100 dark:border-gray-700">
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">요청인자</h2>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow border border-[var(--color-border)]">
|
||||
<div className="px-6 py-4 border-b border-[var(--color-border)]">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">요청인자</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<thead className="bg-[var(--color-bg-base)]">
|
||||
<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-gray-500 dark:text-gray-400 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-[var(--color-text-secondary)] uppercase">의미</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">설명</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tbody className="divide-y divide-[var(--color-border)]">
|
||||
{detail.requestParams.map((param) => (
|
||||
<tr key={param.paramId} className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-900 dark:text-gray-100 font-medium">
|
||||
<tr key={param.paramId} className="hover:bg-[var(--color-bg-base)] transition-colors">
|
||||
<td className="px-4 py-3 font-mono text-xs text-[var(--color-text-primary)] font-medium">
|
||||
{param.paramName}
|
||||
{param.required && <span className="text-red-500 ml-0.5">*</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">
|
||||
{param.paramMeaning ?? <span className="text-gray-400 dark:text-gray-500">-</span>}
|
||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
||||
{param.paramMeaning ?? <span className="text-[var(--color-text-tertiary)]">-</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400 max-w-xs">
|
||||
{param.paramDescription ?? <span className="text-gray-400 dark:text-gray-500">-</span>}
|
||||
<td className="px-4 py-3 text-[var(--color-text-tertiary)] max-w-xs">
|
||||
{param.paramDescription ?? <span className="text-[var(--color-text-tertiary)]">-</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@ -411,39 +406,39 @@ const ApiHubApiDetailPage = () => {
|
||||
|
||||
{/* 출력결과 */}
|
||||
{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="px-6 py-4 border-b border-gray-100 dark:border-gray-700">
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">출력결과</h2>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow border border-[var(--color-border)]">
|
||||
<div className="px-6 py-4 border-b border-[var(--color-border)]">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">출력결과</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<thead className="bg-[var(--color-bg-base)]">
|
||||
<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-gray-500 dark:text-gray-400 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-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-[var(--color-text-secondary)] 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-[var(--color-text-secondary)] uppercase w-1/4">의미(단위)</th>
|
||||
</tr>
|
||||
</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) => {
|
||||
const left = detail.responseParams[rowIdx * 2];
|
||||
const right = detail.responseParams[rowIdx * 2 + 1];
|
||||
return (
|
||||
<tr key={rowIdx} className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-900 dark:text-gray-100 font-medium">
|
||||
<tr key={rowIdx} className="hover:bg-[var(--color-bg-base)] transition-colors">
|
||||
<td className="px-4 py-3 font-mono text-xs text-[var(--color-text-primary)] font-medium">
|
||||
{left.paramName}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">
|
||||
{left.paramMeaning ?? <span className="text-gray-400 dark:text-gray-500">-</span>}
|
||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
||||
{left.paramMeaning ?? <span className="text-[var(--color-text-tertiary)]">-</span>}
|
||||
</td>
|
||||
{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}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">
|
||||
{right.paramMeaning ?? <span className="text-gray-400 dark:text-gray-500">-</span>}
|
||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
||||
{right.paramMeaning ?? <span className="text-[var(--color-text-tertiary)]">-</span>}
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
@ -463,13 +458,13 @@ const ApiHubApiDetailPage = () => {
|
||||
|
||||
{/* 참고자료 */}
|
||||
{spec?.referenceUrl && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 border border-gray-100 dark:border-gray-700">
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-3">참고자료</h2>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 border border-[var(--color-border)]">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-3">참고자료</h2>
|
||||
<a
|
||||
href={spec.referenceUrl}
|
||||
target="_blank"
|
||||
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}
|
||||
</a>
|
||||
@ -478,9 +473,9 @@ const ApiHubApiDetailPage = () => {
|
||||
|
||||
{/* 비고 */}
|
||||
{spec?.note && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 border border-gray-100 dark:border-gray-700">
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-3">비고</h2>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 border border-[var(--color-border)]">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-3">비고</h2>
|
||||
<p className="text-sm text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap">
|
||||
{spec.note}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -114,7 +114,7 @@ const ApiHubDomainPage = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -122,8 +122,8 @@ const ApiHubDomainPage = () => {
|
||||
if (!domainInfo) {
|
||||
return (
|
||||
<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">
|
||||
<p className="text-gray-500 dark:text-gray-400">도메인을 찾을 수 없습니다.</p>
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-surface)] p-12 text-center">
|
||||
<p className="text-[var(--color-text-secondary)]">도메인을 찾을 수 없습니다.</p>
|
||||
<button
|
||||
onClick={() => navigate('/api-hub')}
|
||||
className="mt-4 text-sm text-indigo-500 hover:underline"
|
||||
@ -141,7 +141,7 @@ const ApiHubDomainPage = () => {
|
||||
return (
|
||||
<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`} />
|
||||
|
||||
@ -165,7 +165,7 @@ const ApiHubDomainPage = () => {
|
||||
|
||||
{/* 도메인명 + API 개수 */}
|
||||
<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)}
|
||||
</h1>
|
||||
<p className={`mt-1 text-sm font-medium ${palette.color}`}>
|
||||
@ -176,15 +176,15 @@ const ApiHubDomainPage = () => {
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
<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-[var(--color-text-primary)]">
|
||||
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>
|
||||
<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" />
|
||||
</svg>
|
||||
<input
|
||||
@ -192,7 +192,7 @@ const ApiHubDomainPage = () => {
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
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>
|
||||
@ -207,22 +207,22 @@ const ApiHubDomainPage = () => {
|
||||
|
||||
if (filtered.length === 0) {
|
||||
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가 없습니다.'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||
<div className="divide-y divide-[var(--color-border)]">
|
||||
{filtered.map((api) => (
|
||||
<div
|
||||
key={`${api.serviceId}-${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">
|
||||
<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>
|
||||
{hasItem(api.apiId) ? (
|
||||
<button
|
||||
@ -237,7 +237,7 @@ const ApiHubDomainPage = () => {
|
||||
) : (
|
||||
<button
|
||||
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">
|
||||
<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>
|
||||
)}
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@ -39,12 +39,12 @@ interface DomainSectionProps {
|
||||
const DomainSection = ({ domainName, apis, serviceId, onNavigate }: DomainSectionProps) => (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200">{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">
|
||||
<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-[var(--color-bg-base)] text-[var(--color-text-secondary)]">
|
||||
{apis.length}개
|
||||
</span>
|
||||
</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">
|
||||
<colgroup>
|
||||
<col className="w-[8%]" />
|
||||
@ -53,16 +53,16 @@ const DomainSection = ({ domainName, apis, serviceId, onNavigate }: DomainSectio
|
||||
<col className="w-[40%]" />
|
||||
<col className="w-[5%]" />
|
||||
</colgroup>
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<thead className="bg-[var(--color-bg-base)]">
|
||||
<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-gray-500 dark:text-gray-400 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-gray-500 dark:text-gray-400 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-[var(--color-text-secondary)] uppercase">경로</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-[var(--color-text-secondary)] uppercase">설명</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tbody className="divide-y divide-[var(--color-border)]">
|
||||
{apis.map((api) => (
|
||||
<tr
|
||||
key={api.apiId}
|
||||
@ -76,14 +76,14 @@ const DomainSection = ({ domainName, apis, serviceId, onNavigate }: DomainSectio
|
||||
{api.apiMethod}
|
||||
</span>
|
||||
</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}
|
||||
</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}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 truncate" title={api.description || ''}>
|
||||
{api.description || <span className="text-gray-300 dark:text-gray-600">-</span>}
|
||||
<td className="px-4 py-3 text-[var(--color-text-secondary)] truncate" title={api.description || ''}>
|
||||
{api.description || <span className="text-[var(--color-text-tertiary)]">-</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{api.isActive ? (
|
||||
@ -134,7 +134,7 @@ const ApiHubServicePage = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -144,11 +144,11 @@ const ApiHubServicePage = () => {
|
||||
<div>
|
||||
<button
|
||||
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으로
|
||||
</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 ?? '서비스를 찾을 수 없습니다'}
|
||||
</div>
|
||||
</div>
|
||||
@ -168,21 +168,21 @@ const ApiHubServicePage = () => {
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<button
|
||||
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으로
|
||||
</button>
|
||||
|
||||
{/* 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-1 min-w-0">
|
||||
<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>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 font-mono">{service.serviceCode}</span>
|
||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">{service.serviceName}</h1>
|
||||
<span className="text-sm text-[var(--color-text-secondary)] font-mono">{service.serviceCode}</span>
|
||||
</div>
|
||||
{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 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'}`} />
|
||||
{HEALTH_LABEL[service.healthStatus] ?? service.healthStatus}
|
||||
</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>도메인 {service.domains.length}개</span>
|
||||
</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가 없습니다
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -9,15 +9,17 @@ import {
|
||||
reviewRequest,
|
||||
} from '../../services/apiKeyService';
|
||||
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> = {
|
||||
ACTIVE: 'bg-green-100 text-green-800',
|
||||
PENDING: 'bg-yellow-100 text-yellow-800',
|
||||
REVOKED: 'bg-red-100 text-red-800',
|
||||
EXPIRED: 'bg-gray-100 text-gray-800',
|
||||
INACTIVE: 'bg-gray-100 text-gray-800',
|
||||
APPROVED: 'bg-green-100 text-green-800',
|
||||
REJECTED: 'bg-red-100 text-red-800',
|
||||
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||
ACTIVE: 'success',
|
||||
PENDING: 'warning',
|
||||
REVOKED: 'danger',
|
||||
EXPIRED: 'default',
|
||||
INACTIVE: 'default',
|
||||
APPROVED: 'success',
|
||||
REJECTED: 'danger',
|
||||
};
|
||||
|
||||
const METHOD_BADGE_STYLE: Record<string, string> = {
|
||||
@ -342,108 +344,108 @@ const KeyAdminPage = () => {
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<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">
|
||||
<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>
|
||||
<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-[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>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">API Key 관리</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">API Key 신청 검토 및 발급된 키의 수명주기를 관리합니다</p>
|
||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">API Key 관리</h1>
|
||||
<p className="text-sm text-[var(--color-text-secondary)]">API Key 신청 검토 및 발급된 키의 수명주기를 관리합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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 */}
|
||||
<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="w-11 h-11 rounded-xl flex items-center justify-center bg-amber-50 dark:bg-amber-900/20">
|
||||
<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>
|
||||
<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">
|
||||
<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 className="text-3xl font-bold text-gray-900 dark:text-gray-100">{pendingCount}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">대기 중 신청</div>
|
||||
<div className="text-3xl font-bold text-[var(--color-text-primary)]">{pendingCount}</div>
|
||||
<div className="text-sm text-[var(--color-text-secondary)]">대기 중 신청</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="w-11 h-11 rounded-xl flex items-center justify-center bg-green-50 dark:bg-green-900/20">
|
||||
<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>
|
||||
<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">
|
||||
<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 className="text-3xl font-bold text-gray-900 dark:text-gray-100">{activeKeyCount}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">활성 키</div>
|
||||
<div className="text-3xl font-bold text-[var(--color-text-primary)]">{activeKeyCount}</div>
|
||||
<div className="text-sm text-[var(--color-text-secondary)]">활성 키</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="w-11 h-11 rounded-xl flex items-center justify-center bg-amber-50 dark:bg-amber-900/20">
|
||||
<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>
|
||||
<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">
|
||||
<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 className="text-3xl font-bold text-gray-900 dark:text-gray-100">{expiringCount}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">만료 임박 (14일)</div>
|
||||
<div className="text-3xl font-bold text-[var(--color-text-primary)]">{expiringCount}</div>
|
||||
<div className="text-sm text-[var(--color-text-secondary)]">만료 임박 (14일)</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="w-11 h-11 rounded-xl flex items-center justify-center bg-red-50 dark:bg-red-900/20">
|
||||
<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>
|
||||
<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">
|
||||
<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 className="text-3xl font-bold text-gray-900 dark:text-gray-100">{revokedCount}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">폐기된 키</div>
|
||||
<div className="text-3xl font-bold text-[var(--color-text-primary)]">{revokedCount}</div>
|
||||
<div className="text-sm text-[var(--color-text-secondary)]">폐기된 키</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
<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">
|
||||
<button
|
||||
onClick={() => handleTabSwitch('requests')}
|
||||
className={`flex items-center gap-2 px-4 py-3.5 text-sm -mb-px border-b-2 transition-colors ${
|
||||
activeTab === 'requests'
|
||||
? 'border-blue-600 text-blue-600 dark:text-blue-400 font-semibold'
|
||||
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
|
||||
? 'border-[var(--color-primary)] text-[var(--color-primary)] font-semibold'
|
||||
: '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>
|
||||
신청 관리
|
||||
{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
|
||||
onClick={() => handleTabSwitch('keys')}
|
||||
className={`flex items-center gap-2 px-4 py-3.5 text-sm -mb-px border-b-2 transition-colors ${
|
||||
activeTab === 'keys'
|
||||
? 'border-blue-600 text-blue-600 dark:text-blue-400 font-semibold'
|
||||
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
|
||||
? 'border-[var(--color-primary)] text-[var(--color-primary)] font-semibold'
|
||||
: '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>
|
||||
키 관리
|
||||
{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>
|
||||
</div>
|
||||
<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
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
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>
|
||||
|
||||
{/* 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' ? (
|
||||
<>
|
||||
{([['ALL', '전체'], ['PENDING', '대기'], ['APPROVED', '승인'], ['REJECTED', '반려']] as const).map(([value, label]) => (
|
||||
@ -452,12 +454,12 @@ const KeyAdminPage = () => {
|
||||
onClick={() => { setRequestFilter(value); setRequestPage(0); }}
|
||||
className={`px-3.5 py-1.5 rounded-full text-sm font-medium border cursor-pointer transition-colors ${
|
||||
requestFilter === value
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400'
|
||||
: 'border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||
? 'bg-[var(--color-primary-subtle)] border-[var(--color-primary)] text-[var(--color-primary)]'
|
||||
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-base)]'
|
||||
}`}
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
</>
|
||||
@ -469,12 +471,12 @@ const KeyAdminPage = () => {
|
||||
onClick={() => { setKeyFilter(value); setKeyPage(0); }}
|
||||
className={`px-3.5 py-1.5 rounded-full text-sm font-medium border cursor-pointer transition-colors ${
|
||||
keyFilter === value
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400'
|
||||
: 'border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||
? 'bg-[var(--color-primary-subtle)] border-[var(--color-primary)] text-[var(--color-primary)]'
|
||||
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-base)]'
|
||||
}`}
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
</>
|
||||
@ -485,57 +487,59 @@ const KeyAdminPage = () => {
|
||||
{activeTab === 'requests' && (
|
||||
<>
|
||||
{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">
|
||||
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800/80">
|
||||
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
||||
<thead className="bg-[var(--color-bg-base)]">
|
||||
<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-gray-500 dark:text-gray-400">키 이름</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-gray-500 dark:text-gray-400">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-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)]">신청자</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-[var(--color-text-secondary)]">상태</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-[var(--color-text-secondary)]">신청일시</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tbody className="divide-y divide-[var(--color-border)]">
|
||||
{pagedRequests.map((req) => (
|
||||
<tr key={req.requestId} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">
|
||||
<tr key={req.requestId} className="hover:bg-[var(--color-bg-base)] transition-colors">
|
||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
||||
<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}
|
||||
</span>
|
||||
</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">
|
||||
<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' ? '승인' : '반려'}
|
||||
</span>
|
||||
</Badge>
|
||||
</td>
|
||||
<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 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">
|
||||
<div className="flex gap-2">
|
||||
{req.status === 'PENDING' && (
|
||||
<button
|
||||
<Button
|
||||
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') && (
|
||||
<button
|
||||
<Button
|
||||
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>
|
||||
</td>
|
||||
@ -543,7 +547,7 @@ const KeyAdminPage = () => {
|
||||
))}
|
||||
{filteredRequests.length === 0 && (
|
||||
<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' ? '조건에 맞는 신청이 없습니다.' : '신청 내역이 없습니다.'}
|
||||
</td>
|
||||
</tr>
|
||||
@ -552,15 +556,15 @@ const KeyAdminPage = () => {
|
||||
</table>
|
||||
</div>
|
||||
{/* Table Footer */}
|
||||
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-700/50 flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">총 {filteredRequests.length}건</span>
|
||||
<div className="px-4 py-3 border-t border-[var(--color-border)] flex items-center justify-between">
|
||||
<span className="text-sm text-[var(--color-text-secondary)]">총 {filteredRequests.length}건</span>
|
||||
{requestTotalPages > 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<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>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 px-2">{requestPage + 1} / {requestTotalPages}</span>
|
||||
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-[var(--color-text-secondary)] px-2">{requestPage + 1} / {requestTotalPages}</span>
|
||||
<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>
|
||||
@ -573,29 +577,29 @@ const KeyAdminPage = () => {
|
||||
{activeTab === 'keys' && (
|
||||
<>
|
||||
{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">
|
||||
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800/80">
|
||||
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
||||
<thead className="bg-[var(--color-bg-base)]">
|
||||
<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-gray-500 dark:text-gray-400">키 이름</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-gray-500 dark:text-gray-400">상태</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-gray-500 dark:text-gray-400">마지막 사용일시</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)]">소유자</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-[var(--color-text-secondary)]">Prefix</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-[var(--color-text-secondary)]">만료일시</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-[var(--color-text-secondary)]">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tbody className="divide-y divide-[var(--color-border)]">
|
||||
{pagedKeys.map((key) => {
|
||||
const daysLeft = getDaysUntilExpiry(key.expiresAt);
|
||||
const isExpiringSoon = key.status === 'ACTIVE' && daysLeft !== null && daysLeft <= 14;
|
||||
return (
|
||||
<tr key={key.apiKeyId} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">
|
||||
<tr key={key.apiKeyId} className="hover:bg-[var(--color-bg-base)] transition-colors">
|
||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
||||
<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>
|
||||
{key.userName || '-'}
|
||||
@ -603,39 +607,41 @@ const KeyAdminPage = () => {
|
||||
</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">
|
||||
<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 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}
|
||||
</span>
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<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 && (
|
||||
<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}일 남음
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{formatDateTime(key.lastUsedAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
<Button
|
||||
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' && (
|
||||
<button
|
||||
<Button
|
||||
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>
|
||||
</td>
|
||||
@ -804,9 +810,9 @@ const KeyAdminPage = () => {
|
||||
<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>
|
||||
</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' ? '승인' : '반려'}
|
||||
</span>
|
||||
</Badge>
|
||||
</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">
|
||||
<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 { createKeyRequest } from '../../services/apiKeyService';
|
||||
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 ref = useRef<HTMLInputElement>(null);
|
||||
@ -265,7 +266,7 @@ const KeyRequestPage = () => {
|
||||
};
|
||||
|
||||
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) {
|
||||
@ -277,12 +278,12 @@ const KeyRequestPage = () => {
|
||||
<p className="text-green-700 text-sm mb-4">
|
||||
관리자 승인 후 API Key가 생성됩니다.
|
||||
</p>
|
||||
<button
|
||||
<Button
|
||||
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>
|
||||
@ -291,16 +292,16 @@ const KeyRequestPage = () => {
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</label>
|
||||
<input
|
||||
@ -309,97 +310,97 @@ const KeyRequestPage = () => {
|
||||
onChange={(e) => setKeyName(e.target.value)}
|
||||
required
|
||||
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>
|
||||
<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
|
||||
value={purpose}
|
||||
onChange={(e) => setPurpose(e.target.value)}
|
||||
rows={2}
|
||||
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>
|
||||
<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>
|
||||
</label>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<button type="button" onClick={() => handlePresetPeriod(3)}
|
||||
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개월
|
||||
</button>
|
||||
<button type="button" onClick={() => handlePresetPeriod(6)}
|
||||
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개월
|
||||
</button>
|
||||
<button type="button" onClick={() => handlePresetPeriod(9)}
|
||||
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개월
|
||||
</button>
|
||||
<span className="text-gray-400 dark:text-gray-600 mx-1">|</span>
|
||||
<button type="button" 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'}`}>
|
||||
<span className="text-[var(--color-text-tertiary)] mx-1">|</span>
|
||||
<Button type="button" size="sm" onClick={handlePermanent}
|
||||
variant={isPermanent ? 'primary' : 'outline'}>
|
||||
영구
|
||||
</button>
|
||||
<span className="text-gray-400 dark:text-gray-600 mx-1">|</span>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none">
|
||||
</Button>
|
||||
<span className="text-[var(--color-text-tertiary)] mx-1">|</span>
|
||||
<label className="flex items-center gap-2 text-sm text-[var(--color-text-primary)] cursor-pointer select-none">
|
||||
직접 선택
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
setUsagePeriodMode(usagePeriodMode === 'custom' ? 'preset' : 'custom');
|
||||
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' : ''}`} />
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
{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">
|
||||
<span className="text-indigo-700 dark:text-indigo-300 text-sm font-medium">영구 사용 (만료일 없음)</span>
|
||||
<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 text-sm font-medium">영구 사용 (만료일 없음)</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="date" value={usageFromDate}
|
||||
onChange={(e) => setUsageFromDate(e.target.value)}
|
||||
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'}`} />
|
||||
<span className="text-gray-500 dark:text-gray-400">~</span>
|
||||
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-[var(--color-text-secondary)]">~</span>
|
||||
<input type="date" value={usageToDate}
|
||||
onChange={(e) => setUsageToDate(e.target.value)}
|
||||
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>
|
||||
<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>
|
||||
</label>
|
||||
<input type="text" value={serviceIp}
|
||||
onChange={(e) => setServiceIp(e.target.value)}
|
||||
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" />
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">발급받은 API Key로 프록시 서버에 요청하는 서비스 IP</p>
|
||||
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-[var(--color-text-tertiary)] mt-1">발급받은 API Key로 프록시 서버에 요청하는 서비스 IP</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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>
|
||||
</label>
|
||||
<select value={servicePurpose}
|
||||
onChange={(e) => setServicePurpose(e.target.value)}
|
||||
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>
|
||||
@ -408,13 +409,13 @@ const KeyRequestPage = () => {
|
||||
</select>
|
||||
</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>
|
||||
</label>
|
||||
<select value={dailyRequestEstimate}
|
||||
onChange={(e) => setDailyRequestEstimate(e.target.value)}
|
||||
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="100">100 이하</option>
|
||||
<option value="500">100~500</option>
|
||||
@ -428,9 +429,9 @@ const KeyRequestPage = () => {
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
<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">
|
||||
<label className="flex items-center gap-2 cursor-pointer" onClick={(e) => e.stopPropagation()}>
|
||||
<IndeterminateCheckbox
|
||||
@ -440,9 +441,9 @@ const KeyRequestPage = () => {
|
||||
className="rounded"
|
||||
/>
|
||||
</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 && (
|
||||
<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}개 선택
|
||||
</span>
|
||||
)}
|
||||
@ -453,13 +454,13 @@ const KeyRequestPage = () => {
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
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 && (
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
@ -480,15 +481,15 @@ const KeyRequestPage = () => {
|
||||
return (
|
||||
<div
|
||||
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 */}
|
||||
<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)}
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
<label className="flex items-center cursor-pointer" onClick={(e) => e.stopPropagation()}>
|
||||
@ -499,27 +500,27 @@ const KeyRequestPage = () => {
|
||||
className="rounded"
|
||||
/>
|
||||
</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 && (
|
||||
<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
|
||||
</span>
|
||||
)}
|
||||
</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
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* API list */}
|
||||
{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) => {
|
||||
const isSelected = selectedApiIds.has(api.apiId);
|
||||
return (
|
||||
<div
|
||||
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)}
|
||||
>
|
||||
<div className="flex items-center pt-0.5">
|
||||
@ -532,7 +533,7 @@ const KeyRequestPage = () => {
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
@ -543,7 +544,7 @@ const KeyRequestPage = () => {
|
||||
);
|
||||
})}
|
||||
{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가 없습니다.'}
|
||||
</div>
|
||||
)}
|
||||
@ -553,7 +554,7 @@ const KeyRequestPage = () => {
|
||||
{/* Bottom sticky summary bar */}
|
||||
{selectedApiIds.size > 0 && (
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
@ -567,13 +568,13 @@ const KeyRequestPage = () => {
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
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 ? '신청 중...' : '신청하기'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { ApiKey } from '../../types/apikey';
|
||||
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> = {
|
||||
ACTIVE: 'bg-green-100 text-green-800',
|
||||
PENDING: 'bg-yellow-100 text-yellow-800',
|
||||
REVOKED: 'bg-red-100 text-red-800',
|
||||
EXPIRED: 'bg-gray-100 text-gray-800',
|
||||
INACTIVE: 'bg-gray-100 text-gray-800',
|
||||
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||
ACTIVE: 'success',
|
||||
PENDING: 'warning',
|
||||
REVOKED: 'danger',
|
||||
EXPIRED: 'default',
|
||||
INACTIVE: 'danger',
|
||||
};
|
||||
|
||||
const formatDateTime = (dateStr: string | null): string => {
|
||||
@ -17,6 +20,7 @@ const formatDateTime = (dateStr: string | null): string => {
|
||||
};
|
||||
|
||||
const MyKeysPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -67,20 +71,17 @@ const MyKeysPage = () => {
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<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">
|
||||
<Link
|
||||
to="/apikeys/request"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
<Button onClick={() => navigate('/apikeys/request')}>
|
||||
API Key 신청
|
||||
</Link>
|
||||
</Button>
|
||||
</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="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<div className="overflow-x-auto bg-[var(--color-bg-surface)] rounded-lg shadow">
|
||||
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
||||
<thead className="bg-[var(--color-bg-base)]">
|
||||
<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-gray-500 dark:text-gray-400">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-gray-500 dark:text-gray-400">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-gray-500 dark:text-gray-400">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)]">Key Name</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-[var(--color-text-secondary)]">Status</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-[var(--color-text-secondary)]">Last Used 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-[var(--color-text-secondary)]">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tbody className="divide-y divide-[var(--color-border)]">
|
||||
{keys.map((key) => (
|
||||
<tr key={key.apiKeyId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{key.keyName}</td>
|
||||
<td className="px-4 py-3 font-mono text-gray-600 dark:text-gray-400">{key.apiKeyPrefix}</td>
|
||||
<tr key={key.apiKeyId} className="hover:bg-[var(--color-bg-base)]">
|
||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{key.keyName}</td>
|
||||
<td className="px-4 py-3 font-mono text-[var(--color-text-secondary)]">{key.apiKeyPrefix}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 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}
|
||||
</span>
|
||||
</Badge>
|
||||
</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-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.createdAt)}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{formatDateTime(key.expiresAt)}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{formatDateTime(key.lastUsedAt)}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{formatDateTime(key.createdAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
{key.status === 'ACTIVE' && (
|
||||
<button
|
||||
<Button
|
||||
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>
|
||||
</tr>
|
||||
))}
|
||||
{keys.length === 0 && (
|
||||
<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가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
@ -143,34 +141,36 @@ const MyKeysPage = () => {
|
||||
|
||||
{rawKeyModal && (
|
||||
<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="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">API Key 생성 완료</h2>
|
||||
<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-[var(--color-border)]">
|
||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">API Key 생성 완료</h2>
|
||||
</div>
|
||||
<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>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Key Name</label>
|
||||
<p className="text-gray-900 dark:text-gray-100">{rawKeyModal.keyName}</p>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">Key Name</label>
|
||||
<p className="text-[var(--color-text-primary)]">{rawKeyModal.keyName}</p>
|
||||
</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">
|
||||
<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}
|
||||
</code>
|
||||
<button
|
||||
<Button
|
||||
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 ? '복사됨!' : '복사'}
|
||||
</button>
|
||||
</Button>
|
||||
</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
|
||||
onClick={() => {
|
||||
setRawKeyModal(null);
|
||||
|
||||
@ -70,7 +70,7 @@ const RequestLogDetailPage = () => {
|
||||
}, [id]);
|
||||
|
||||
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) {
|
||||
@ -78,7 +78,7 @@ const RequestLogDetailPage = () => {
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<button
|
||||
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>
|
||||
@ -96,22 +96,22 @@ const RequestLogDetailPage = () => {
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<button
|
||||
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>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">기본 정보</h2>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6">
|
||||
<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>
|
||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">요청 시간</span>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100">{formatDateTime(log.requestedAt)}</span>
|
||||
<span className="block text-sm font-medium text-[var(--color-text-secondary)]">요청 시간</span>
|
||||
<span className="text-sm text-[var(--color-text-primary)]">{formatDateTime(log.requestedAt)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">요청</span>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100">
|
||||
<span className="block text-sm font-medium text-[var(--color-text-secondary)]">요청</span>
|
||||
<span className="text-sm text-[var(--color-text-primary)]">
|
||||
<span
|
||||
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'
|
||||
@ -123,25 +123,25 @@ const RequestLogDetailPage = () => {
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">응답 코드</span>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100">
|
||||
<span className="block text-sm font-medium text-[var(--color-text-secondary)]">응답 코드</span>
|
||||
<span className="text-sm text-[var(--color-text-primary)]">
|
||||
{log.responseStatus != null ? log.responseStatus : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">응답시간(ms)</span>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100">
|
||||
<span className="block text-sm font-medium text-[var(--color-text-secondary)]">응답시간(ms)</span>
|
||||
<span className="text-sm text-[var(--color-text-primary)]">
|
||||
{log.responseTime != null ? log.responseTime : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">응답크기(bytes)</span>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100">
|
||||
<span className="block text-sm font-medium text-[var(--color-text-secondary)]">응답크기(bytes)</span>
|
||||
<span className="text-sm text-[var(--color-text-primary)]">
|
||||
{log.responseSize != null ? log.responseSize : '-'}
|
||||
</span>
|
||||
</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={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
STATUS_BADGE[log.requestStatus] || 'bg-gray-100 text-gray-800'
|
||||
@ -153,42 +153,42 @@ const RequestLogDetailPage = () => {
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">서비스</span>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100">{log.serviceName || '-'}</span>
|
||||
<span className="block text-sm font-medium text-[var(--color-text-secondary)]">서비스</span>
|
||||
<span className="text-sm text-[var(--color-text-primary)]">{log.serviceName || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">API Key</span>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100 font-mono">
|
||||
<span className="block text-sm font-medium text-[var(--color-text-secondary)]">API Key</span>
|
||||
<span className="text-sm text-[var(--color-text-primary)] font-mono">
|
||||
{log.apiKeyPrefix || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">사용자</span>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100">{log.userName || '-'}</span>
|
||||
<span className="block text-sm font-medium text-[var(--color-text-secondary)]">사용자</span>
|
||||
<span className="text-sm text-[var(--color-text-primary)]">{log.userName || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400">IP</span>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100 font-mono">{log.requestIp}</span>
|
||||
<span className="block text-sm font-medium text-[var(--color-text-secondary)]">IP</span>
|
||||
<span className="text-sm text-[var(--color-text-primary)] font-mono">{log.requestIp}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요청 정보 */}
|
||||
{(formattedHeaders || formattedParams) && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">요청 정보</h2>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">요청 정보</h2>
|
||||
{formattedHeaders && (
|
||||
<div className="mb-4">
|
||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400 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">
|
||||
<span className="block text-sm font-medium text-[var(--color-text-secondary)] mb-1">Request Headers</span>
|
||||
<pre className="bg-[var(--color-bg-base)] rounded-lg p-4 text-sm text-[var(--color-text-primary)] overflow-x-auto">
|
||||
{formattedHeaders}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{formattedParams && (
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-500 dark:text-gray-400 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">
|
||||
<span className="block text-sm font-medium text-[var(--color-text-secondary)] mb-1">Request Params</span>
|
||||
<pre className="bg-[var(--color-bg-base)] rounded-lg p-4 text-sm text-[var(--color-text-primary)] overflow-x-auto">
|
||||
{formattedParams}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@ -4,22 +4,25 @@ import type { RequestLog, PageResponse } from '../../types/monitoring';
|
||||
import type { ServiceInfo } from '../../types/service';
|
||||
import { searchLogs } from '../../services/monitoringService';
|
||||
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> = {
|
||||
GET: 'bg-green-100 text-green-800',
|
||||
POST: 'bg-blue-100 text-blue-800',
|
||||
PUT: 'bg-orange-100 text-orange-800',
|
||||
DELETE: 'bg-red-100 text-red-800',
|
||||
const METHOD_CLASS: Record<string, string> = {
|
||||
GET: 'bg-green-100 text-green-800 dark:bg-green-500/15 dark:text-green-400',
|
||||
POST: 'bg-blue-100 text-blue-800 dark:bg-blue-500/15 dark:text-blue-400',
|
||||
PUT: 'bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-400',
|
||||
DELETE: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-400',
|
||||
};
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
SUCCESS: 'bg-green-100 text-green-800',
|
||||
FAIL: 'bg-red-100 text-red-800',
|
||||
DENIED: 'bg-red-100 text-red-800',
|
||||
EXPIRED: 'bg-orange-100 text-orange-800',
|
||||
INVALID_KEY: 'bg-red-100 text-red-800',
|
||||
ERROR: 'bg-orange-100 text-orange-800',
|
||||
FAILED: 'bg-gray-100 text-gray-800',
|
||||
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||
SUCCESS: 'success',
|
||||
FAIL: 'danger',
|
||||
DENIED: 'warning',
|
||||
EXPIRED: 'warning',
|
||||
INVALID_KEY: 'danger',
|
||||
ERROR: 'danger',
|
||||
FAILED: 'default',
|
||||
};
|
||||
|
||||
const REQUEST_STATUSES = ['SUCCESS', 'FAIL', 'DENIED', 'EXPIRED', 'INVALID_KEY', 'ERROR', 'FAILED'];
|
||||
@ -162,13 +165,13 @@ const RequestLogsPage = () => {
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<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="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">
|
||||
{([
|
||||
{ label: '오늘', fn: () => { const t = getToday(); setStartDate(t); setEndDate(t); setDatePreset('오늘'); } },
|
||||
@ -184,8 +187,8 @@ const RequestLogsPage = () => {
|
||||
onClick={btn.fn}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
|
||||
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'
|
||||
: 'border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||
? 'bg-blue-50 border-blue-300 text-[var(--color-primary)]'
|
||||
: 'border-[var(--color-border)] text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-base)]'
|
||||
}`}
|
||||
>
|
||||
{btn.label}
|
||||
@ -197,25 +200,25 @@ const RequestLogsPage = () => {
|
||||
type="date"
|
||||
value={startDate}
|
||||
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
|
||||
type="date"
|
||||
value={endDate}
|
||||
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 className="flex items-end gap-3 flex-wrap">
|
||||
<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
|
||||
value={serviceId}
|
||||
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>
|
||||
{services.map((s) => (
|
||||
@ -224,11 +227,11 @@ const RequestLogsPage = () => {
|
||||
</select>
|
||||
</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
|
||||
value={requestStatus}
|
||||
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>
|
||||
{REQUEST_STATUSES.map((s) => (
|
||||
@ -237,11 +240,11 @@ const RequestLogsPage = () => {
|
||||
</select>
|
||||
</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
|
||||
value={requestMethod}
|
||||
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>
|
||||
{HTTP_METHODS.map((m) => (
|
||||
@ -250,18 +253,12 @@ const RequestLogsPage = () => {
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end gap-2 ml-auto">
|
||||
<button
|
||||
onClick={() => handleSearch(0)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
<Button onClick={() => handleSearch(0)} variant="primary">
|
||||
검색
|
||||
</button>
|
||||
<button
|
||||
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 onClick={handleResetAndSearch} variant="secondary">
|
||||
초기화
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -271,68 +268,60 @@ const RequestLogsPage = () => {
|
||||
)}
|
||||
|
||||
{/* 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 ? (
|
||||
<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">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
|
||||
<thead className="bg-[var(--color-bg-base)]">
|
||||
<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-gray-500 dark:text-gray-400">서비스</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-gray-500 dark:text-gray-400">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-gray-500 dark:text-gray-400">응답시간(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-gray-500 dark:text-gray-400">IP</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-[var(--color-text-secondary)]">서비스</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-[var(--color-text-secondary)]">URL</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-[var(--color-text-secondary)]">응답시간(ms)</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-[var(--color-text-secondary)]">IP</th>
|
||||
</tr>
|
||||
</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.content.map((log) => (
|
||||
<tr
|
||||
key={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)}
|
||||
</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">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
|
||||
METHOD_COLOR[log.requestMethod] || 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
<Badge className={METHOD_CLASS[log.requestMethod]}>
|
||||
{log.requestMethod}
|
||||
</span>
|
||||
</Badge>
|
||||
</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}
|
||||
</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 : '-'}
|
||||
</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 : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
STATUS_BADGE[log.requestStatus] || 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
<Badge variant={STATUS_VARIANT[log.requestStatus] ?? 'default'}>
|
||||
{log.requestStatus}
|
||||
</span>
|
||||
</Badge>
|
||||
</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>
|
||||
<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>
|
||||
</tr>
|
||||
@ -345,24 +334,24 @@ const RequestLogsPage = () => {
|
||||
{/* Pagination */}
|
||||
{result && result.totalElements > 0 && (
|
||||
<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} 페이지
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
<Button
|
||||
onClick={handlePrev}
|
||||
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}
|
||||
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>
|
||||
)}
|
||||
|
||||
@ -53,45 +53,45 @@ const ServiceStatusDetailPage = () => {
|
||||
}, [fetchData]);
|
||||
|
||||
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) {
|
||||
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 (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<button
|
||||
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 목록으로
|
||||
</button>
|
||||
|
||||
{/* 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 gap-3">
|
||||
<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>
|
||||
<span className="text-gray-500 dark:text-gray-400">{detail.serviceCode}</span>
|
||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">{detail.serviceName}</h1>
|
||||
<span className="text-[var(--color-text-secondary)]">{detail.serviceCode}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={`text-lg font-semibold ${detail.currentStatus === 'UP' ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{detail.currentStatus === 'UP' ? 'Operational' : detail.currentStatus === 'DOWN' ? 'Down' : 'Unknown'}
|
||||
</div>
|
||||
{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>
|
||||
|
||||
{/* Uptime Summary */}
|
||||
<div className="bg-white dark:bg-gray-800 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>
|
||||
<div className="text-4xl font-bold text-gray-900 dark:text-gray-100 mb-4">{detail.uptimePercent90d.toFixed(3)}%</div>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">90일 Uptime</h2>
|
||||
<div className="text-4xl font-bold text-[var(--color-text-primary)] mb-4">{detail.uptimePercent90d.toFixed(3)}%</div>
|
||||
|
||||
{/* 90-Day Bar */}
|
||||
<div className="flex items-center gap-0.5 mb-2">
|
||||
@ -110,16 +110,16 @@ const ServiceStatusDetailPage = () => {
|
||||
</div>
|
||||
))}
|
||||
{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 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>Today</span>
|
||||
</div>
|
||||
|
||||
{/* 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-400" /> 99%+</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>
|
||||
|
||||
{/* Recent Checks */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow mb-6">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">최근 체크 이력</h2>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow mb-6">
|
||||
<div className="p-6 border-b border-[var(--color-border)]">
|
||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">최근 체크 이력</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<thead className="bg-[var(--color-bg-base)]">
|
||||
<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-gray-500 dark:text-gray-400">상태</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-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-[var(--color-text-secondary)]">상태</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-[var(--color-text-secondary)]">에러</th>
|
||||
</tr>
|
||||
</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) => (
|
||||
<tr key={idx} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400 whitespace-nowrap">{formatTime(check.checkedAt)}</td>
|
||||
<tr key={idx} className="hover:bg-[var(--color-bg-base)]">
|
||||
<td className="px-4 py-3 text-[var(--color-text-secondary)] whitespace-nowrap">{formatTime(check.checkedAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
</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` : '-'}
|
||||
</td>
|
||||
<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 && (
|
||||
<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>
|
||||
</tr>
|
||||
|
||||
@ -6,29 +6,32 @@ import {
|
||||
import type { ApiStatsResponse } from '../../types/statistics';
|
||||
import { getApiStats } from '../../services/statisticsService';
|
||||
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_STYLES = [
|
||||
'bg-blue-100 text-blue-700',
|
||||
'bg-emerald-100 text-emerald-700',
|
||||
'bg-amber-100 text-amber-700',
|
||||
'bg-red-100 text-red-700',
|
||||
'bg-violet-100 text-violet-700',
|
||||
'bg-cyan-100 text-cyan-700',
|
||||
const SERVICE_TAG_CLASSES = [
|
||||
'bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-400',
|
||||
'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-400',
|
||||
'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-400',
|
||||
'bg-red-100 text-red-700 dark:bg-red-500/15 dark:text-red-400',
|
||||
'bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-400',
|
||||
'bg-cyan-100 text-cyan-700 dark:bg-cyan-500/15 dark:text-cyan-400',
|
||||
];
|
||||
|
||||
const METHOD_BADGE: Record<string, string> = {
|
||||
GET: 'bg-blue-100 text-blue-800',
|
||||
POST: 'bg-green-100 text-green-800',
|
||||
PUT: 'bg-amber-100 text-amber-800',
|
||||
DELETE: 'bg-red-100 text-red-800',
|
||||
PATCH: 'bg-purple-100 text-purple-800',
|
||||
const METHOD_CLASS: Record<string, string> = {
|
||||
GET: 'bg-blue-100 text-blue-800 dark:bg-blue-500/15 dark:text-blue-400',
|
||||
POST: 'bg-green-100 text-green-800 dark:bg-green-500/15 dark:text-green-400',
|
||||
PUT: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-400',
|
||||
DELETE: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-400',
|
||||
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 ApiStatsPage = () => {
|
||||
const { theme } = useTheme();
|
||||
const chartColors = CHART_COLORS_HEX[theme];
|
||||
const [startDate, setStartDate] = useState(getToday());
|
||||
const [endDate, setEndDate] = useState(getToday());
|
||||
const [data, setData] = useState<ApiStatsResponse | null>(null);
|
||||
@ -68,12 +71,12 @@ const ApiStatsPage = () => {
|
||||
const map: Record<string, { tag: string; bar: string }> = {};
|
||||
serviceNames.forEach((name, i) => {
|
||||
map[name] = {
|
||||
tag: SERVICE_TAG_STYLES[i % SERVICE_TAG_STYLES.length],
|
||||
bar: PIE_COLORS[i % PIE_COLORS.length],
|
||||
tag: SERVICE_TAG_CLASSES[i % SERVICE_TAG_CLASSES.length],
|
||||
bar: chartColors[i % chartColors.length],
|
||||
};
|
||||
});
|
||||
return map;
|
||||
}, [data]);
|
||||
}, [data, chartColors]);
|
||||
|
||||
const statusChartData = useMemo(() => {
|
||||
if (!data) return [];
|
||||
@ -86,14 +89,14 @@ const ApiStatsPage = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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
|
||||
startDate={startDate}
|
||||
@ -104,14 +107,14 @@ const ApiStatsPage = () => {
|
||||
/>
|
||||
|
||||
{!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 */}
|
||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||
{/* Chart 1: HTTP Method Distribution */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">HTTP 메서드 분포</h3>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">HTTP 메서드 분포</h3>
|
||||
{data.methodDistribution.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
@ -123,7 +126,7 @@ const ApiStatsPage = () => {
|
||||
outerRadius={100}
|
||||
>
|
||||
{data.methodDistribution.map((_, idx) => (
|
||||
<Cell key={idx} fill={PIE_COLORS[idx % PIE_COLORS.length]} />
|
||||
<Cell key={idx} fill={chartColors[idx % chartColors.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
@ -131,13 +134,13 @@ const ApiStatsPage = () => {
|
||||
</PieChart>
|
||||
</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>
|
||||
|
||||
{/* Chart 2: HTTP Status Code Distribution */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">HTTP 상태 코드 분포</h3>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">HTTP 상태 코드 분포</h3>
|
||||
{statusChartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={statusChartData}>
|
||||
@ -146,65 +149,65 @@ const ApiStatsPage = () => {
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey="count" fill="#3b82f6" name="건수" />
|
||||
<Bar dataKey="count" fill={chartColors[0]} name="건수" />
|
||||
</BarChart>
|
||||
</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>
|
||||
|
||||
{/* Table 1: Top APIs */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow mb-6">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">API 호출 순위</h3>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow mb-6">
|
||||
<div className="p-6 border-b border-[var(--color-border)]">
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">API 호출 순위</h3>
|
||||
</div>
|
||||
{data.topApis.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<thead className="bg-[var(--color-bg-base)]">
|
||||
<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-gray-500 dark:text-gray-400 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-gray-500 dark:text-gray-400 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-gray-500 dark:text-gray-400 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-[var(--color-text-secondary)] uppercase">서비스</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-[var(--color-text-secondary)] 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-[var(--color-text-secondary)] uppercase">평균 응답시간</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">성공률</th>
|
||||
</tr>
|
||||
</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) => {
|
||||
const maxCount = data.topApis[0]?.callCount || 1;
|
||||
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 (
|
||||
<tr key={idx} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{idx + 1}</td>
|
||||
<tr key={idx} className="hover:bg-[var(--color-bg-base)]">
|
||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{idx + 1}</td>
|
||||
<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}
|
||||
</span>
|
||||
</Badge>
|
||||
</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}
|
||||
</td>
|
||||
<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}
|
||||
</span>
|
||||
</Badge>
|
||||
</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="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>
|
||||
<span>{api.callCount.toLocaleString()}</span>
|
||||
</div>
|
||||
</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-gray-900 dark:text-gray-100">{api.successRate.toFixed(1)}%</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-[var(--color-text-primary)]">{api.successRate.toFixed(1)}%</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@ -212,42 +215,42 @@ const ApiStatsPage = () => {
|
||||
</table>
|
||||
</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>
|
||||
|
||||
{/* Table 2: Top Error APIs */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">API 에러 순위</h3>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow">
|
||||
<div className="p-6 border-b border-[var(--color-border)]">
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">API 에러 순위</h3>
|
||||
</div>
|
||||
{data.topErrorApis.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<thead className="bg-[var(--color-bg-base)]">
|
||||
<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-gray-500 dark:text-gray-400 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-gray-500 dark:text-gray-400 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-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-[var(--color-text-secondary)] uppercase">서비스</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-[var(--color-text-secondary)] 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-[var(--color-text-secondary)] uppercase">에러율</th>
|
||||
</tr>
|
||||
</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) => {
|
||||
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 (
|
||||
<tr key={idx} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{idx + 1}</td>
|
||||
<tr key={idx} className="hover:bg-[var(--color-bg-base)]">
|
||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{idx + 1}</td>
|
||||
<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}
|
||||
</span>
|
||||
</Badge>
|
||||
</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-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>
|
||||
</tr>
|
||||
);
|
||||
@ -256,7 +259,7 @@ const ApiStatsPage = () => {
|
||||
</table>
|
||||
</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>
|
||||
</>
|
||||
|
||||
@ -6,12 +6,14 @@ import {
|
||||
import type { ServiceStatsResponse } from '../../types/statistics';
|
||||
import { getServiceStats } from '../../services/statisticsService';
|
||||
import DateRangeFilter from '../../components/DateRangeFilter';
|
||||
|
||||
const PIE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
|
||||
import { CHART_COLORS_HEX } from '../../constants/chart';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
|
||||
const getToday = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
const ServiceStatsPage = () => {
|
||||
const { theme } = useTheme();
|
||||
const chartColors = CHART_COLORS_HEX[theme];
|
||||
const [startDate, setStartDate] = useState(getToday());
|
||||
const [endDate, setEndDate] = useState(getToday());
|
||||
const [data, setData] = useState<ServiceStatsResponse | null>(null);
|
||||
@ -69,14 +71,14 @@ const ServiceStatsPage = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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
|
||||
startDate={startDate}
|
||||
@ -87,20 +89,20 @@ const ServiceStatsPage = () => {
|
||||
/>
|
||||
|
||||
{!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 */}
|
||||
<div className="flex flex-wrap gap-4 mb-6">
|
||||
{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]">
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{svc.serviceName}</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1">
|
||||
<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-[var(--color-text-secondary)]">{svc.serviceName}</p>
|
||||
<p className="text-2xl font-bold text-[var(--color-text-primary)] mt-1">
|
||||
{svc.totalRequests.toLocaleString()}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mt-2 text-sm">
|
||||
<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>
|
||||
))}
|
||||
@ -109,8 +111,8 @@ const ServiceStatsPage = () => {
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||
{/* Chart 1: Service Request Count Bar */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">서비스별 요청 수</h3>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">서비스별 요청 수</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={barChartData} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
@ -118,14 +120,14 @@ const ServiceStatsPage = () => {
|
||||
<YAxis dataKey="serviceName" type="category" width={120} />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey="totalRequests" fill="#3b82f6" name="요청 수" />
|
||||
<Bar dataKey="totalRequests" fill={chartColors[0]} name="요청 수" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Chart 2: Hourly Service Trend */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">시간별 서비스 요청 추이</h3>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">시간별 서비스 요청 추이</h3>
|
||||
{hourlyTrendPivoted.data.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={hourlyTrendPivoted.data}>
|
||||
@ -139,13 +141,13 @@ const ServiceStatsPage = () => {
|
||||
key={name}
|
||||
type="monotone"
|
||||
dataKey={name}
|
||||
stroke={PIE_COLORS[idx % PIE_COLORS.length]}
|
||||
stroke={chartColors[idx % chartColors.length]}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</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>
|
||||
@ -153,8 +155,8 @@ const ServiceStatsPage = () => {
|
||||
{/* Charts Row 2: Error Rate + Response Time */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Chart: Error Rate Comparison */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">서비스별 에러율 비교</h3>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">서비스별 에러율 비교</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart
|
||||
data={data.serviceStats.map((s) => ({
|
||||
@ -176,8 +178,8 @@ const ServiceStatsPage = () => {
|
||||
</div>
|
||||
|
||||
{/* Chart: Avg Response Time Comparison */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">서비스별 평균 응답시간 비교</h3>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">서비스별 평균 응답시간 비교</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart
|
||||
data={data.serviceStats.map((s) => ({
|
||||
|
||||
@ -6,12 +6,14 @@ import {
|
||||
import type { TenantStatsResponse } from '../../types/statistics';
|
||||
import { getTenantStats } from '../../services/statisticsService';
|
||||
import DateRangeFilter from '../../components/DateRangeFilter';
|
||||
|
||||
const PIE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
|
||||
import { CHART_COLORS_HEX } from '../../constants/chart';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
|
||||
const getToday = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
const TenantStatsPage = () => {
|
||||
const { theme } = useTheme();
|
||||
const chartColors = CHART_COLORS_HEX[theme];
|
||||
const [startDate, setStartDate] = useState(getToday());
|
||||
const [endDate, setEndDate] = useState(getToday());
|
||||
const [data, setData] = useState<TenantStatsResponse | null>(null);
|
||||
@ -61,14 +63,14 @@ const TenantStatsPage = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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
|
||||
startDate={startDate}
|
||||
@ -79,19 +81,19 @@ const TenantStatsPage = () => {
|
||||
/>
|
||||
|
||||
{!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 */}
|
||||
<div className="flex gap-4 mb-6">
|
||||
{data.tenantStats.map((tenant) => (
|
||||
<div key={tenant.tenantId} className="flex-1 bg-white dark:bg-gray-800 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-2xl font-bold text-gray-900 dark:text-gray-100 mt-1">
|
||||
<div key={tenant.tenantId} className="flex-1 bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||
<p className="text-sm font-medium text-[var(--color-text-secondary)]">{tenant.tenantName || 'Unknown'}</p>
|
||||
<p className="text-2xl font-bold text-[var(--color-text-primary)] mt-1">
|
||||
{tenant.totalRequests.toLocaleString()}
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -101,8 +103,8 @@ const TenantStatsPage = () => {
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||
{/* Chart 1: Daily Tenant Trend */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">일별 테넌트 요청 추이</h3>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">일별 테넌트 요청 추이</h3>
|
||||
{dailyTrendPivoted.data.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={dailyTrendPivoted.data}>
|
||||
@ -116,19 +118,19 @@ const TenantStatsPage = () => {
|
||||
key={name}
|
||||
type="monotone"
|
||||
dataKey={name}
|
||||
stroke={PIE_COLORS[idx % PIE_COLORS.length]}
|
||||
stroke={chartColors[idx % chartColors.length]}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</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>
|
||||
|
||||
{/* Chart 2: Tenant API Key Stats */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">테넌트별 API Key 현황</h3>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">테넌트별 API Key 현황</h3>
|
||||
{data.apiKeyStats.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={data.apiKeyStats}>
|
||||
@ -137,40 +139,40 @@ const TenantStatsPage = () => {
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey="totalKeys" fill="#3b82f6" name="전체 키" />
|
||||
<Bar dataKey="totalKeys" fill={chartColors[0]} name="전체 키" />
|
||||
<Bar dataKey="activeKeys" fill="#10b981" name="활성 키" />
|
||||
</BarChart>
|
||||
</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>
|
||||
|
||||
{/* Table: Tenant Details */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">테넌트 상세</h3>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow">
|
||||
<div className="p-6 border-b border-[var(--color-border)]">
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">테넌트 상세</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<thead className="bg-[var(--color-bg-base)]">
|
||||
<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-gray-500 dark:text-gray-400 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-gray-500 dark:text-gray-400 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-[var(--color-text-secondary)] 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-[var(--color-text-secondary)] uppercase">성공률</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">평균 응답시간</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tbody className="divide-y divide-[var(--color-border)]">
|
||||
{data.tenantStats.map((tenant) => (
|
||||
<tr key={tenant.tenantId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<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-gray-900 dark:text-gray-100">{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-gray-900 dark:text-gray-100">{tenant.successRate.toFixed(1)}%</td>
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{tenant.avgResponseTime.toFixed(0)}ms</td>
|
||||
<tr key={tenant.tenantId} className="hover:bg-[var(--color-bg-base)]">
|
||||
<td className="px-4 py-3 text-[var(--color-text-primary)] font-medium">{tenant.tenantName || 'Unknown'}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{tenant.totalRequests.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{tenant.activeUsers}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{tenant.successRate.toFixed(1)}%</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{tenant.avgResponseTime.toFixed(0)}ms</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@ -5,6 +5,8 @@ import {
|
||||
} from 'recharts';
|
||||
import type { UsageTrendResponse } from '../../types/statistics';
|
||||
import { getUsageTrend } from '../../services/statisticsService';
|
||||
import { CHART_COLORS_HEX } from '../../constants/chart';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
|
||||
type Period = 'daily' | 'weekly' | 'monthly';
|
||||
|
||||
@ -33,6 +35,8 @@ const getSuccessRateColor = (rate: number): string => {
|
||||
};
|
||||
|
||||
const UsageTrendPage = () => {
|
||||
const { theme } = useTheme();
|
||||
const chartColors = CHART_COLORS_HEX[theme];
|
||||
const [period, setPeriod] = useState<Period>('daily');
|
||||
const [data, setData] = useState<UsageTrendResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@ -60,18 +64,18 @@ const UsageTrendPage = () => {
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<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) => (
|
||||
<button
|
||||
key={opt.key}
|
||||
onClick={() => setPeriod(opt.key)}
|
||||
className={`px-4 py-2 text-sm font-medium -mb-px ${
|
||||
period === opt.key
|
||||
? 'border-b-2 border-blue-600 text-blue-600'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
|
||||
? 'border-b-2 border-[var(--color-primary)] text-[var(--color-primary)]'
|
||||
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
@ -81,21 +85,21 @@ const UsageTrendPage = () => {
|
||||
|
||||
{isLoading ? (
|
||||
<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>
|
||||
) : !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) */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">요청 수 추이</h3>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">요청 수 추이</h3>
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<LineChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="totalRequestsFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.15} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
<stop offset="5%" stopColor={chartColors[0]} stopOpacity={0.15} />
|
||||
<stop offset="95%" stopColor={chartColors[0]} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
@ -113,7 +117,7 @@ const UsageTrendPage = () => {
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="totalRequests"
|
||||
stroke="#3b82f6"
|
||||
stroke={chartColors[0]}
|
||||
name="총 요청"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
@ -133,8 +137,8 @@ const UsageTrendPage = () => {
|
||||
{/* Charts 2 & 3: 2 column grid */}
|
||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||
{/* Chart 2: 성공률 + 응답시간 추이 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">성공률 + 응답시간 추이</h3>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">성공률 + 응답시간 추이</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ComposedChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
@ -155,7 +159,7 @@ const UsageTrendPage = () => {
|
||||
<Bar
|
||||
yAxisId="right"
|
||||
dataKey="avgResponseTime"
|
||||
fill="#3b82f6"
|
||||
fill={chartColors[0]}
|
||||
name="평균 응답시간(ms)"
|
||||
barSize={20}
|
||||
/>
|
||||
@ -164,8 +168,8 @@ const UsageTrendPage = () => {
|
||||
</div>
|
||||
|
||||
{/* Chart 3: 활성 사용자 추이 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">활성 사용자 추이</h3>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">활성 사용자 추이</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
@ -173,52 +177,52 @@ const UsageTrendPage = () => {
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey="activeUsers" fill="#06b6d4" name="활성 사용자" />
|
||||
<Bar dataKey="activeUsers" fill={chartColors[1]} name="활성 사용자" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table: 상세 데이터 */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">상세 데이터</h3>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow">
|
||||
<div className="p-6 border-b border-[var(--color-border)]">
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">상세 데이터</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<thead className="bg-[var(--color-bg-base)]">
|
||||
<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-gray-500 dark:text-gray-400 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-gray-500 dark:text-gray-400 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-gray-500 dark:text-gray-400 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>
|
||||
<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-[var(--color-text-secondary)] 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-[var(--color-text-secondary)] uppercase">성공률(%)</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-[var(--color-text-secondary)] uppercase">활성 사용자</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tbody className="divide-y divide-[var(--color-border)]">
|
||||
{data.items.map((item) => (
|
||||
<tr key={item.label} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">
|
||||
<tr key={item.label} className="hover:bg-[var(--color-bg-base)]">
|
||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
||||
{formatLabel(item.label, period)}
|
||||
</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()}
|
||||
</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()}
|
||||
</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()}
|
||||
</td>
|
||||
<td className={`px-4 py-3 font-medium ${getSuccessRateColor(item.successRate)}`}>
|
||||
{item.successRate.toFixed(1)}
|
||||
</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)}
|
||||
</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()}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -6,8 +6,8 @@ import {
|
||||
import type { UserStatsResponse } from '../../types/statistics';
|
||||
import { getUserStats } from '../../services/statisticsService';
|
||||
import DateRangeFilter from '../../components/DateRangeFilter';
|
||||
|
||||
const PIE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
|
||||
import { CHART_COLORS_HEX } from '../../constants/chart';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
|
||||
const ROLE_BADGE: Record<string, string> = {
|
||||
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 UserStatsPage = () => {
|
||||
const { theme } = useTheme();
|
||||
const chartColors = CHART_COLORS_HEX[theme];
|
||||
const [startDate, setStartDate] = useState(getToday());
|
||||
const [endDate, setEndDate] = useState(getToday());
|
||||
const [data, setData] = useState<UserStatsResponse | null>(null);
|
||||
@ -54,14 +56,14 @@ const UserStatsPage = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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
|
||||
startDate={startDate}
|
||||
@ -72,30 +74,30 @@ const UserStatsPage = () => {
|
||||
/>
|
||||
|
||||
{!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 */}
|
||||
<div className="flex gap-4 mb-6">
|
||||
<div className="flex-1 bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">전체 사용자</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{data.totalUsers}</p>
|
||||
<div className="flex-1 bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||
<p className="text-sm text-[var(--color-text-secondary)]">전체 사용자</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{data.totalUsers}</p>
|
||||
</div>
|
||||
<div className="flex-1 bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">API Key 보유 사용자</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{data.usersWithActiveKey}</p>
|
||||
<div className="flex-1 bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||
<p className="text-sm text-[var(--color-text-secondary)]">API Key 보유 사용자</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{data.usersWithActiveKey}</p>
|
||||
</div>
|
||||
<div className="flex-1 bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">API 요청 사용자</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">{data.totalActiveUsers}</p>
|
||||
<div className="flex-1 bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||
<p className="text-sm text-[var(--color-text-secondary)]">API 요청 사용자</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-text-primary)]">{data.totalActiveUsers}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||
{/* Chart 1: Daily Active Users */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">일별 API 요청 사용자 추이</h3>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">일별 API 요청 사용자 추이</h3>
|
||||
{data.dailyActiveUsers.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data.dailyActiveUsers}>
|
||||
@ -107,21 +109,21 @@ const UserStatsPage = () => {
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="activeUsers"
|
||||
stroke="#3b82f6"
|
||||
fill="#3b82f6"
|
||||
stroke={chartColors[0]}
|
||||
fill={chartColors[0]}
|
||||
fillOpacity={0.3}
|
||||
name="API 요청 사용자"
|
||||
/>
|
||||
</AreaChart>
|
||||
</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>
|
||||
|
||||
{/* Chart 2: Role Distribution Donut */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">역할별 요청 분포</h3>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">역할별 요청 분포</h3>
|
||||
{data.roleDistribution.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
@ -133,7 +135,7 @@ const UserStatsPage = () => {
|
||||
outerRadius={100}
|
||||
>
|
||||
{data.roleDistribution.map((_, idx) => (
|
||||
<Cell key={idx} fill={PIE_COLORS[idx % PIE_COLORS.length]} />
|
||||
<Cell key={idx} fill={chartColors[idx % chartColors.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
@ -141,47 +143,47 @@ const UserStatsPage = () => {
|
||||
</PieChart>
|
||||
</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>
|
||||
|
||||
{/* Table: Top Users */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">상위 사용자 Top 10</h3>
|
||||
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow">
|
||||
<div className="p-6 border-b border-[var(--color-border)]">
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">상위 사용자 Top 10</h3>
|
||||
</div>
|
||||
{data.topUsers.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<thead className="bg-[var(--color-bg-base)]">
|
||||
<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-gray-500 dark:text-gray-400 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-gray-500 dark:text-gray-400 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-[var(--color-text-secondary)] 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-[var(--color-text-secondary)] uppercase">요청 수</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">성공률</th>
|
||||
</tr>
|
||||
</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) => (
|
||||
<tr key={user.userId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{idx + 1}</td>
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{user.userName}</td>
|
||||
<tr key={user.userId} className="hover:bg-[var(--color-bg-base)]">
|
||||
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{idx + 1}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{user.userName}</td>
|
||||
<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'}`}>
|
||||
{user.role}
|
||||
</span>
|
||||
</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-gray-900 dark:text-gray-100">{user.successRate.toFixed(1)}%</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{user.requestCount.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-primary)]">{user.successRate.toFixed(1)}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
</>
|
||||
|
||||
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