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:
HYOJIN 2026-04-15 16:38:00 +09:00
부모 2a8723419d
커밋 c2a71c1b77
47개의 변경된 파일4366개의 추가작업 그리고 1318개의 파일을 삭제

파일 보기

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

파일 보기

@ -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
파일 보기

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

파일 보기

@ -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
파일 보기

@ -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
파일 보기

@ -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
파일 보기

@ -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
파일 보기

@ -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
파일 보기

@ -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 우회 금지)

파일 보기

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

파일 보기

@ -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)]` |

파일 보기

@ -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
파일 보기

@ -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
파일 보기

@ -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)]` |

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -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 &apos; URL &apos; .
</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"
>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => handleOpenEdit(user)}>
</button>
</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 variant="danger" size="sm" onClick={() => handleDeactivate(user)}>
</button>
</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">
&#x26A0; {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"
>
&larr;
</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"
>
&larr;
</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
파일 보기

@ -0,0 +1,4 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));