Compare commits
21 Commits
dev
...
406af7408a
| Author | SHA1 | Date | |
|---|---|---|---|
| 406af7408a | |||
| 4c52d6d82f | |||
| 076f27a12a | |||
| f875e338eb | |||
| a16af8ad7d | |||
| 19ebb1c6ea | |||
| 276ef09d89 | |||
| b73867c65d | |||
| 7c194d7452 | |||
| 1ac907cd27 | |||
| 12feeb2775 | |||
| 434a814246 | |||
| 8f1d75b4d5 | |||
| 3cea3e66d0 | |||
| f650d51f68 | |||
| 95291e6922 | |||
| def87bd47a | |||
| 89bad1d141 | |||
| e5a518b211 | |||
| ca01f33d71 | |||
| 851a2acd69 |
@@ -1,34 +0,0 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
# 개발 기본 원칙
|
||||
|
||||
## 언어 및 커뮤니케이션
|
||||
|
||||
- 모든 응답은 **한글**로 작성
|
||||
- 코드 주석, 문서, 플랜, 결과물 모두 한글 사용
|
||||
|
||||
## 개발 도구 활용
|
||||
|
||||
- **Skills**: 프로젝트에 적합한 스킬을 적극 활용하여 베스트 프랙티스 적용
|
||||
- **MCP 서버**:
|
||||
- `sequential-thinking`: 복잡한 문제 해결 시 단계별 사고 과정 정리
|
||||
- `tavily-remote`: 최신 기술 트렌드 및 문서 검색
|
||||
- `playwright` / `playwriter`: 브라우저 자동화 테스트
|
||||
- `next-devtools`: Next.js 프로젝트 개발 및 디버깅
|
||||
- `context7`: 라이브러리/프레임워크 공식 문서 참조
|
||||
- `supabase-mcp-server`: Supabase 프로젝트 관리 및 쿼리
|
||||
|
||||
## 코드 품질
|
||||
|
||||
- 린트 에러는 즉시 수정
|
||||
- React 베스트 프랙티스 준수 (예: useEffect 내 setState 지양)
|
||||
- TypeScript 타입 안정성 유지
|
||||
- 접근성(a11y) 고려한 UI 구현
|
||||
|
||||
## 테스트 및 검증
|
||||
|
||||
- 브라우저 테스트는 MCP Playwright 활용
|
||||
- 변경 사항은 반드시 로컬에서 검증 후 완료 보고
|
||||
- 에러 발생 시 근본 원인 파악 및 해결
|
||||
@@ -1,333 +0,0 @@
|
||||
---
|
||||
trigger: manual
|
||||
---
|
||||
|
||||
# 역할
|
||||
|
||||
시니어 프론트엔드 엔지니어이자 "문서화 전문가".
|
||||
목표: **코드 변경 없이 주석만 추가**하여 신규 개발자가 파일을 열었을 때 5분 내에 '사용자 행동 흐름'과 '데이터 흐름'을 파악할 수 있게 만든다.
|
||||
|
||||
# 기술 스택
|
||||
|
||||
- TypeScript + React/Next.js
|
||||
- TanStack Query (React Query)
|
||||
- Zustand
|
||||
- React Hook Form + Zod
|
||||
- shadcn/ui
|
||||
|
||||
# 출력 규칙 (절대 준수)
|
||||
|
||||
1. **코드 로직 변경 금지**: 타입/런타임/동작/변수명/import 변경 절대 금지
|
||||
2. **주석만 추가**: TSDoc/라인주석/블록주석만 삽입
|
||||
3. **충분한 인라인 주석**: state, handler, JSX 각 영역에 주석 추가 (과하지 않게 적당히)
|
||||
4. **한국어 사용**: 딱딱한 한자어 대신 쉬운 일상 용어 사용
|
||||
|
||||
────────────────────────────────────────────────────────
|
||||
|
||||
# 1) 파일 상단 TSDoc (모든 주요 파일 필수)
|
||||
|
||||
**형식:**
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* @file <파일명>
|
||||
* @description <1-2줄로 파일 목적 설명>
|
||||
* @remarks
|
||||
* - [레이어] Infrastructure/Hooks/Components/Core 중 하나
|
||||
* - [사용자 행동] 검색 -> 목록 -> 상세 -> 편집 (1줄)
|
||||
* - [데이터 흐름] UI -> Service -> API -> DTO -> Adapter -> UI (1줄)
|
||||
* - [연관 파일] types.ts, useXXXQuery.ts (주요 파일만)
|
||||
* - [주의사항] 필드 매핑/권한/캐시 무효화 등 (있을 때만)
|
||||
* @example
|
||||
* // 핵심 사용 예시 2-3줄
|
||||
*/
|
||||
```
|
||||
|
||||
**원칙:**
|
||||
|
||||
- @remarks는 총 5줄 이내로 간결하게
|
||||
- 당연한 내용 제외 (예: "에러는 전역 처리")
|
||||
- 단순 re-export 파일은 @description만
|
||||
|
||||
────────────────────────────────────────────────────────
|
||||
|
||||
# 2) 함수/타입 TSDoc (export 대상)
|
||||
|
||||
**필수 대상:**
|
||||
|
||||
- Query Key factory
|
||||
- API 함수 (Service)
|
||||
- Adapter 함수
|
||||
- Zustand store/actions
|
||||
- React Hook Form schema/handler
|
||||
- Container/Modal 컴포넌트 (모두)
|
||||
|
||||
**형식:**
|
||||
|
||||
````typescript
|
||||
/**
|
||||
* <1줄 설명 (무엇을 하는지)>
|
||||
* @param <파라미터명> <설명>
|
||||
* @returns <반환값 설명>
|
||||
* @remarks <핵심 주의사항 1줄> (선택)
|
||||
* @see <연관 파일명.tsx의 함수명 - 어떤 역할로 호출하는지>
|
||||
*/
|
||||
|
||||
## 2-1. 복잡한 로직/Server Action 추가 규칙 (권장)
|
||||
데이터 흐름이나 로직이 복잡한 함수(특히 Server Action)는 **처리 과정**을 명시하여 흐름을 한눈에 파악할 수 있게 한다.
|
||||
|
||||
**형식:**
|
||||
```typescript
|
||||
/**
|
||||
* [함수명]
|
||||
*
|
||||
* <상세 설명>
|
||||
*
|
||||
* 처리 과정:
|
||||
* 1. <데이터 추출/준비>
|
||||
* 2. <검증 로직>
|
||||
* 3. <외부 API/DB 호출>
|
||||
* 4. <분기 처리 (성공/실패)>
|
||||
* 5. <결과 반환/리다이렉트>
|
||||
*
|
||||
* @param ...
|
||||
*/
|
||||
````
|
||||
|
||||
````
|
||||
|
||||
## ⭐ @see 강화 규칙 (필수)
|
||||
|
||||
모든 함수/컴포넌트에 **@see를 적극적으로 추가**하여 호출 관계를 명확히 한다.
|
||||
|
||||
**@see 작성 패턴:**
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* @see OpptyDetailHeader.tsx - handleApprovalClick()에서 다이얼로그 열기
|
||||
* @see useContractApproval.ts - onConfirm 콜백으로 날짜 전달
|
||||
*/
|
||||
|
||||
/**
|
||||
* @see LeadDetailPage.tsx - 리드 상세 페이지에서 목록 조회
|
||||
* @see LeadSearchForm.tsx - 검색 폼 제출 시 호출
|
||||
*/
|
||||
````
|
||||
|
||||
**@see 필수 포함 정보:**
|
||||
|
||||
1. **파일명** - 어떤 파일에서 호출하는지
|
||||
2. **함수/이벤트명** - 어떤 함수나 이벤트에서 호출하는지
|
||||
3. **호출 목적** - 왜 호출하는지 (간단히)
|
||||
|
||||
**예시:**
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 리드 목록 조회 API (검색/필터/정렬/페이징)
|
||||
* @param params 조회 조건
|
||||
* @returns 목록, 페이지정보, 통계
|
||||
* @remarks 정렬 필드는 sortFieldMap으로 프론트↔백엔드 변환
|
||||
* @see useMainLeads.ts - useQuery의 queryFn으로 호출
|
||||
* @see LeadTableContainer.tsx - 테이블 데이터 소스로 사용
|
||||
*/
|
||||
```
|
||||
|
||||
**DTO/Interface:**
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 리드 생성 요청 데이터 구조 (DTO)
|
||||
* @see LeadCreateModal.tsx - 리드 생성 폼 제출 시 사용
|
||||
*/
|
||||
export interface CreateLeadRequest { ... }
|
||||
```
|
||||
|
||||
**Query Key Factory:**
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 리드 Query Key Factory
|
||||
* React Query 캐싱/무효화를 위한 키 구조
|
||||
* @returns ['leads', { entity: 'mainLeads', page, ... }] 형태
|
||||
* @see useLeadsQuery.ts - queryKey로 사용
|
||||
* @see useLeadMutations.ts - invalidateQueries 대상
|
||||
*/
|
||||
export const leadKeys = { ... }
|
||||
|
||||
/** 메인 리드 목록 키 */
|
||||
mainLeads: (...) => [...],
|
||||
```
|
||||
|
||||
────────────────────────────────────────────────────────
|
||||
|
||||
# 3) 인라인 주석 (적극 활용)
|
||||
|
||||
## 3-1. State 주석 (필수)
|
||||
|
||||
모든 useState/useRef에 역할 주석 추가
|
||||
|
||||
```typescript
|
||||
// [State] 선택된 날짜 (기본값: 오늘)
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||
|
||||
// [State] 캘린더 팝오버 열림 상태
|
||||
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
|
||||
|
||||
// [Ref] 파일 input 참조 (프로그래밍 방식 클릭용)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
```
|
||||
|
||||
## 3-2. Handler/함수 주석 (필수)
|
||||
|
||||
이벤트 핸들러에 Step 주석 추가
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 작성 확인 버튼 클릭 핸들러
|
||||
* @see OpptyDetailHeader.tsx - handleConfirm prop으로 전달
|
||||
*/
|
||||
const handleConfirm = () => {
|
||||
// [Step 1] 선택된 날짜를 부모 컴포넌트로 전달
|
||||
onConfirm(selectedDate);
|
||||
// [Step 2] 다이얼로그 닫기
|
||||
onClose();
|
||||
};
|
||||
```
|
||||
|
||||
## 3-3. JSX 영역 주석 (필수)
|
||||
|
||||
UI 구조를 파악하기 쉽게 영역별 주석 추가
|
||||
|
||||
```tsx
|
||||
return (
|
||||
<Dialog>
|
||||
{/* ========== 헤더 영역 ========== */}
|
||||
<DialogHeader>
|
||||
<DialogTitle>제목</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* ========== 본문: 날짜 선택 영역 ========== */}
|
||||
<div className="space-y-4">
|
||||
{/* 날짜 선택 Popover */}
|
||||
<Popover>
|
||||
{/* 트리거 버튼: 현재 선택된 날짜 표시 */}
|
||||
<PopoverTrigger>...</PopoverTrigger>
|
||||
{/* 캘린더 컨텐츠: 한국어 로케일 */}
|
||||
<PopoverContent>...</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* ========== 하단: 액션 버튼 영역 ========== */}
|
||||
<div className="flex gap-2">
|
||||
<Button>취소</Button>
|
||||
<Button>확인</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
```
|
||||
|
||||
**JSX 주석 규칙:**
|
||||
|
||||
- `{/* ========== 영역명 ========== */}` - 큰 섹션 구분
|
||||
- `{/* 설명 */}` - 개별 요소 설명
|
||||
- 스크롤 없이 UI 구조 파악 가능하게
|
||||
|
||||
────────────────────────────────────────────────────────
|
||||
|
||||
# 4) 함수 내부 Step 주석
|
||||
|
||||
**대상:**
|
||||
조건/분기/데이터 변환/API 호출/상태 변경이 있는 함수
|
||||
|
||||
**형식:**
|
||||
|
||||
```typescript
|
||||
// [Step 1] <무엇을 하는지 간결하게>
|
||||
// [Step 2] <다음 단계>
|
||||
// [Step 3] <최종 단계>
|
||||
```
|
||||
|
||||
**규칙:**
|
||||
|
||||
- 각 Step은 1줄로
|
||||
- 반드시 1번부터 순차적으로
|
||||
- "무엇을", "왜"를 명확하게
|
||||
|
||||
**예시:**
|
||||
|
||||
```typescript
|
||||
export const getMainLeads = async (params) => {
|
||||
// [Step 1] UI 정렬 필드를 백엔드 컬럼명으로 매핑
|
||||
const mappedField = sortFieldMap[sortField] || sortField;
|
||||
|
||||
// [Step 2] API 요청 파라미터 구성
|
||||
const requestParams = { ... };
|
||||
|
||||
// [Step 3] 리드 목록 조회 API 호출
|
||||
const { data } = await axiosInstance.get(...);
|
||||
|
||||
// [Step 4] 응답 데이터 검증 및 기본값 설정
|
||||
let dataList = data?.data?.list || [];
|
||||
|
||||
// [Step 5] UI 모델로 변환 및 결과 반환
|
||||
return { list: dataList.map(convertToRow), ... };
|
||||
}
|
||||
```
|
||||
|
||||
────────────────────────────────────────────────────────
|
||||
|
||||
# 5) 레이어별 특수 규칙
|
||||
|
||||
## 5-1. Service/API
|
||||
|
||||
- **Step 주석**: API 호출 흐름을 단계별로 명시
|
||||
- **@see**: 이 API를 호출하는 모든 훅/컴포넌트 명시
|
||||
|
||||
## 5-2. Hooks (TanStack Query)
|
||||
|
||||
- **Query Key**: 반환 구조 예시 필수
|
||||
- **캐시 전략**: invalidateQueries/setQueryData 사용 이유
|
||||
- **@see**: 이 훅을 사용하는 모든 컴포넌트 명시
|
||||
|
||||
## 5-3. Adapters
|
||||
|
||||
- **간단한 변환**: 주석 불필요
|
||||
- **복잡한 변환**: 입력→출력 1줄 설명 + 변환 규칙
|
||||
|
||||
## 5-4. Components (Container/Modal)
|
||||
|
||||
- **상태 관리**: 어떤 state가 어떤 이벤트로 변경되는지
|
||||
- **Dialog/Modal**: open 상태 소유자, 닫힘 조건
|
||||
- **Table**: 인라인 편집, 스켈레톤 범위
|
||||
- **JSX 영역 주석**: UI 구조 파악용 영역 구분 주석 필수
|
||||
|
||||
## 5-5. Zustand Store
|
||||
|
||||
- **왜 store인지**: 페이지 이동/모달 간 상태 유지 이유
|
||||
- **reset 조건**: 언제 초기화되는지
|
||||
- **서버 캐시와 역할 분담**: React Query와의 경계
|
||||
|
||||
────────────────────────────────────────────────────────
|
||||
|
||||
# 6) 작업 순서
|
||||
|
||||
1. 파일 레이어 판별 (Infrastructure/Hooks/UI/Core)
|
||||
2. 파일 상단 TSDoc 추가 (@see 포함)
|
||||
3. export 대상에 TSDoc 추가 (@see 필수)
|
||||
4. State/Ref에 인라인 주석 추가
|
||||
5. Handler 함수에 TSDoc + Step 주석 추가
|
||||
6. JSX 영역별 구분 주석 추가
|
||||
7. Query Key Factory에 반환 구조 예시 추가
|
||||
|
||||
# 제약사항
|
||||
|
||||
- **@author는 jihoon87.lee 고정**
|
||||
- **@see는 필수**: 호출 관계 명확히
|
||||
- **Step 주석은 1줄**: 간결하게
|
||||
- **JSX 주석 필수**: UI 구조 파악용
|
||||
- **@see는 파일명 + 함수명 + 역할**: 전체 경로 불필요
|
||||
|
||||
# 지금부터 작업
|
||||
|
||||
내가 주는 코드를 위 규칙에 맞게 "주석만" 보강하라.
|
||||
@@ -1,96 +0,0 @@
|
||||
---
|
||||
name: find-skills
|
||||
description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", or "is there a skill that can help with X".
|
||||
---
|
||||
|
||||
# Find Skills
|
||||
|
||||
This skill helps you discover and install skills from the open agent skills ecosystem.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when the user:
|
||||
|
||||
- Asks "how do I do X" where X might be a common task with an existing skill
|
||||
- Says "find a skill for X" or "is there a skill for X"
|
||||
- Asks "can you do X" where X is a specialized capability
|
||||
- Expresses interest in extending agent capabilities
|
||||
- Wants to search for tools, templates, or workflows
|
||||
- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)
|
||||
|
||||
## What is the Skills CLI?
|
||||
|
||||
The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.
|
||||
|
||||
**Key commands:**
|
||||
|
||||
- `npx skills find [query]` - Search for skills interactively or by keyword
|
||||
- `npx skills add` - Install a skill from GitHub or other sources
|
||||
- `npx skills check` - Check for skill updates
|
||||
- `npx skills update` - Update all installed skills
|
||||
|
||||
**Browse skills at:** <https://skills.sh/>
|
||||
|
||||
## How to Help Users Find Skills
|
||||
|
||||
### Step 1: Understand What They Need
|
||||
|
||||
When a user asks for help with something, identify:
|
||||
|
||||
1. The domain (e.g., React, testing, design, deployment)
|
||||
2. The specific task (e.g., writing tests, creating animations, reviewing PRs)
|
||||
3. Whether this is a common enough task that a skill likely exists
|
||||
|
||||
### Step 2: Search for Skills
|
||||
|
||||
Run the find command with a relevant query:
|
||||
|
||||
```bash
|
||||
npx skills find [query]
|
||||
```
|
||||
|
||||
For example:
|
||||
|
||||
- User asks "how do I make my React app faster?" → `npx skills find react performance`
|
||||
- User asks "can you help me with PR reviews?" → `npx skills find pr review`
|
||||
- User asks "I need to create a changelog" → `npx skills find changelog`
|
||||
|
||||
### Step 3: Present Recommendations
|
||||
|
||||
When you find relevant skills, present them to the user with:
|
||||
|
||||
1. The skill name and what it does
|
||||
2. The installation command
|
||||
3. A link to the skill's page
|
||||
|
||||
**Example response:**
|
||||
|
||||
> I found a skill that might help!
|
||||
>
|
||||
> **vercel-react-best-practices**
|
||||
> Vercel's official React performance guidelines for AI agents.
|
||||
>
|
||||
> To install it:
|
||||
> `npx skills add vercel-labs/agent-skills@vercel-react-best-practices`
|
||||
>
|
||||
> Learn more: <https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices>
|
||||
|
||||
If the user wants to proceed, you can install the skill for them:
|
||||
|
||||
```bash
|
||||
npx skills add vercel-labs/agent-skills@vercel-react-best-practices
|
||||
```
|
||||
|
||||
### Step 4: Verify Installation (Optional)
|
||||
|
||||
After installing, you can verify it was installed correctly:
|
||||
|
||||
```bash
|
||||
npx skills list
|
||||
```
|
||||
|
||||
## When No Skills Are Found
|
||||
|
||||
1. Try a broader search term
|
||||
2. Check the [skills.sh](https://skills.sh/) website manually if you suspect a network issue
|
||||
3. Suggest the user could create their own skill with `npx skills init`
|
||||
@@ -1,95 +0,0 @@
|
||||
---
|
||||
name: vercel-react-best-practices
|
||||
description: React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.
|
||||
license: MIT
|
||||
metadata:
|
||||
author: vercel
|
||||
version: "1.0.0"
|
||||
---
|
||||
|
||||
# Vercel React Best Practices
|
||||
|
||||
Comprehensive performance optimization guide for React and Next.js applications, maintained by Vercel. Contains 57 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation.
|
||||
|
||||
## When to Apply
|
||||
|
||||
Reference these guidelines when:
|
||||
|
||||
- Writing new React components or Next.js pages
|
||||
- Implementing data fetching (client or server-side)
|
||||
- Reviewing code for performance issues
|
||||
- Refactoring existing React/Next.js code
|
||||
- Optimizing bundle size or load times
|
||||
|
||||
## Rule Categories by Priority
|
||||
|
||||
| Priority | Category | Impact | Prefix |
|
||||
| -------- | ------------------------- | ----------- | ------------ |
|
||||
| 1 | Eliminating Waterfalls | CRITICAL | `async-` |
|
||||
| 2 | Bundle Size Optimization | CRITICAL | `bundle-` |
|
||||
| 3 | Server-Side Performance | HIGH | `server-` |
|
||||
| 4 | Client-Side Data Fetching | MEDIUM-HIGH | `client-` |
|
||||
| 5 | Re-render Optimization | MEDIUM | `rerender-` |
|
||||
| 6 | Rendering Performance | MEDIUM | `rendering-` |
|
||||
| 7 | JavaScript Performance | LOW-MEDIUM | `js-` |
|
||||
| 8 | Advanced Patterns | LOW | `advanced-` |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### 1. Eliminating Waterfalls (CRITICAL)
|
||||
|
||||
- `async-defer-await` - Move await into branches where actually used
|
||||
- `async-parallel` - Use Promise.all() for independent operations
|
||||
- `async-dependencies` - Use better-all for partial dependencies
|
||||
- `async-api-routes` - Start promises early, await late in API routes
|
||||
- `async-suspense-boundaries` - Use Suspense to stream content
|
||||
|
||||
### 2. Bundle Size Optimization (CRITICAL)
|
||||
|
||||
- `bundle-barrel-imports` - Avoid large barrel files; use direct imports
|
||||
- `bundle-large-libraries` - Optimize heavy deps (e.g. framer-motion, lucide)
|
||||
- `bundle-conditional` - Lazy load conditional components
|
||||
- `bundle-route-split` - Split huge page components
|
||||
- `bundle-dynamic-imports` - Use next/dynamic for heavy client components
|
||||
|
||||
### 3. Server-Side Performance (HIGH)
|
||||
|
||||
- `server-cache-react` - Use React.cache() for per-request deduplication
|
||||
- `server-cache-next` - Use unstable_cache for data coaching
|
||||
- `server-only-utils` - Mark server-only code with 'server-only' package
|
||||
- `server-component-boundaries` - Keep client components at leaves
|
||||
- `server-image-optimization` - Use next/image with proper sizing
|
||||
|
||||
### 4. Client-Side Data Fetching (MEDIUM-HIGH)
|
||||
|
||||
- `client-use-swr` - Use SWR/TanStack Query for client-side data
|
||||
- `client-no-effect-fetch` - Avoid fetching in useEffect without caching libraries
|
||||
- `client-prefetch-link` - Use next/link prefetching
|
||||
- `client-caching-headers` - Respect cache-control headers
|
||||
|
||||
### 5. Re-render Optimization (MEDIUM)
|
||||
|
||||
- `rerender-memo-props` - Memoize complex props
|
||||
- `rerender-dependencies` - Use primitive dependencies in effects
|
||||
- `rerender-functional-setstate` - Use functional setState for stable callbacks
|
||||
- `rerender-context-split` - Split context to avoid wide re-renders
|
||||
|
||||
### 6. Rendering Performance (MEDIUM)
|
||||
|
||||
- `rendering-image-priority` - Priority load LCP images
|
||||
- `rendering-list-virtualization` - Virtualize long lists
|
||||
- `rendering-content-visibility` - Use content-visibility for long lists
|
||||
- `rendering-hoist-jsx` - Extract static JSX outside components
|
||||
- `rendering-hydration-no-flicker` - Use inline script for client-only data
|
||||
|
||||
### 7. JavaScript Performance (LOW-MEDIUM)
|
||||
|
||||
- `js-batch-dom-css` - Group CSS changes
|
||||
- `js-index-maps` - Build Map for repeated lookups
|
||||
- `js-combine-iterations` - Combine multiple filter/map into one loop
|
||||
- `js-set-map-lookups` - Use Set/Map for O(1) lookups
|
||||
|
||||
### 8. Advanced Patterns (LOW)
|
||||
|
||||
- `advanced-event-handler-refs` - Store event handlers in refs
|
||||
- `advanced-init-once` - Initialize app once per app load
|
||||
57
.agents/skills/dev-auto-pipeline/SKILL.md
Normal file
57
.agents/skills/dev-auto-pipeline/SKILL.md
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: dev-auto-pipeline
|
||||
description: 기능 개발/버그 수정/리팩토링 같은 구현 요청에서 계획→구현→리팩토링→테스트→완료체크를 순서대로 실행하는 상위 오케스트레이션 스킬. 단순 설명/문서 요약/잡담에는 사용하지 않는다.
|
||||
---
|
||||
|
||||
# Dev Auto Pipeline
|
||||
|
||||
## 목표
|
||||
|
||||
- 개발 요청을 표준 5단계로 자동 처리한다.
|
||||
- 각 단계 결과를 다음 단계 입력으로 넘겨 누락을 줄인다.
|
||||
|
||||
## 실행 단계 (고정)
|
||||
|
||||
1. `dev-plan-writer`
|
||||
2. `dev-mcp-implementation`
|
||||
3. `dev-refactor-polish`
|
||||
4. `dev-test-gate`
|
||||
5. `dev-plan-completion-checker`
|
||||
|
||||
## 단계 연결 규칙
|
||||
|
||||
1. 계획 단계에서 생성한 계획 문서(`common-docs/improvement/plans/*.md`)를 이후 단계의 기준 문서로 사용한다.
|
||||
2. 구현/리팩토링에서 변경된 파일 목록을 테스트 단계 입력으로 전달한다.
|
||||
3. 테스트 결과를 완료체크 단계 입력으로 전달한다.
|
||||
4. 완료체크는 계획 대비 `완료/부분 완료/미완료`와 최종 판정을 반드시 남긴다.
|
||||
|
||||
## common-docs 기준
|
||||
|
||||
- 사용 문서:
|
||||
- `common-docs/api-reference/openapi_all.xlsx`
|
||||
- `common-docs/api-reference/kis_api_reference.md`
|
||||
- `common-docs/api-reference/kis-error-code-reference.md`
|
||||
- `common-docs/features/trade-stock-sync.md`
|
||||
- `common-docs/ui/GLOBAL_ALERT_SYSTEM.md`
|
||||
- 제외 문서:
|
||||
- `common-docs/features-autotrade-design.md`
|
||||
|
||||
## 최종 보고 형식
|
||||
|
||||
```md
|
||||
[1. 계획]
|
||||
- ...
|
||||
|
||||
[2. 구현]
|
||||
- ...
|
||||
|
||||
[3. 리팩토링/성능/가독성]
|
||||
- ...
|
||||
|
||||
[4. 테스트]
|
||||
- ...
|
||||
|
||||
[5. 계획 대비 완료체크]
|
||||
- 완료/부분 완료/미완료
|
||||
- 최종 판정: 배포 가능/보완 필요
|
||||
```
|
||||
6
.agents/skills/dev-auto-pipeline/agents/openai.yaml
Normal file
6
.agents/skills/dev-auto-pipeline/agents/openai.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
interface:
|
||||
display_name: "Dev Auto Pipeline"
|
||||
short_description: "Run end-to-end development pipeline"
|
||||
default_prompt: "Use $dev-auto-pipeline to execute plan, implement, refactor, test, and completion checks."
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
123
.agents/skills/dev-mcp-implementation/SKILL.md
Normal file
123
.agents/skills/dev-mcp-implementation/SKILL.md
Normal file
@@ -0,0 +1,123 @@
|
||||
---
|
||||
name: dev-mcp-implementation
|
||||
description: 구현 단계에서 MCP와 기존 스킬을 활용해 근거 기반으로 코드를 작성하는 스킬. 계획 문서가 확정된 뒤 실제 코드 변경이 필요할 때 사용하며, 단순 계획 작성/완료 판정 단계에는 사용하지 않는다.
|
||||
---
|
||||
|
||||
# Dev MCP Implementation
|
||||
|
||||
## 목표
|
||||
|
||||
- 추측 구현을 줄이고 공식 문서/런타임 진단 기반으로 구현한다.
|
||||
- 구현 결과를 나중에 리팩토링/테스트 단계로 넘기기 쉬운 형태로 만든다.
|
||||
|
||||
## 기본 구현 원칙 (AGENTS 반영)
|
||||
|
||||
1. 모든 코드/주석/설명은 한국어 기준으로 작성한다.
|
||||
2. 기술 스택 기준을 지킨다.
|
||||
- Next.js 16 App Router, React 19, TypeScript
|
||||
- Zustand(클라이언트 UI 상태), Supabase, react-hook-form + zod
|
||||
- Tailwind CSS v4, Radix UI
|
||||
3. 사이드이펙트가 예상되면 영향 범위를 먼저 확인하고 구현한다.
|
||||
4. 불필요한 삭제는 하지 않는다. 삭제가 필요하면 영향 검증 후 진행한다.
|
||||
|
||||
## 구현 순서
|
||||
|
||||
1. `dev-plan-writer` 결과를 읽고 구현 범위를 고정한다.
|
||||
2. Next.js 프로젝트면 `next-devtools`로 현재 라우트/에러 상태를 먼저 확인한다.
|
||||
3. 외부 라이브러리 API가 모호하면 `context7`로 공식 문서를 확인한다.
|
||||
4. 복잡한 로직은 `sequential-thinking`으로 엣지 케이스(경계 상황)를 먼저 정리한다.
|
||||
5. DB/권한/SQL 변경은 `supabase-mcp-server`로 안전하게 반영한다.
|
||||
6. 코드 수정 후 최소 동작 확인(`lint`/핵심 UI 실행)을 진행한다.
|
||||
|
||||
## 리팩토링 구현 규칙 (refactoring-rule 반영)
|
||||
|
||||
1. 리팩토링 요청이면 `FEATURE_ROOT` 기준으로 작업한다.
|
||||
2. 아래 기본 구조를 우선 사용한다.
|
||||
- `apis`, `components`, `hooks`, `stores`, `types`
|
||||
3. 필요 시 선택 구조를 사용한다.
|
||||
- `utils`, `lib`, `constants`
|
||||
4. 대형 파일은 책임 단위로 분해하고, 로직은 보존한다.
|
||||
5. `index.ts` 배럴 export 의존을 줄이고 직접 경로 import로 전환한다.
|
||||
6. 파일 이동 후 외부 진입점(`page.tsx` 등) import까지 함께 갱신한다.
|
||||
|
||||
## 필수 적용 스킬
|
||||
|
||||
- `nextjs-app-router-patterns`: Server/Client 경계 검증
|
||||
- `vercel-react-best-practices`: 렌더링/번들/데이터 요청 최적화
|
||||
|
||||
## MCP 활용 맵 (AGENTS 반영)
|
||||
|
||||
- `next-devtools`: Next.js 라우트/컴파일/런타임 오류 점검
|
||||
- `playwright`: 브라우저 상호작용/스모크 검증
|
||||
- `playwriter`: Chrome 확장 기반 상세 디버깅
|
||||
- `context7`: 라이브러리/프레임워크 공식 문서 조회
|
||||
- `supabase-mcp-server`: DB/SQL/함수 작업
|
||||
- `tavily-remote`: 최신 자료/기술 검색
|
||||
- `sequential-thinking`: 복잡 로직 단계화
|
||||
- `figma`: 디자인 파일 레이아웃/스타일/에셋 확인
|
||||
- `mcp:kis-code-assistant-mcp`: 한국투자증권 API 검색/소스 확인
|
||||
|
||||
## 코드/주석 규칙 (문서화 전문가 기준)
|
||||
|
||||
1. 주석 보강 작업은 코드 로직(타입/런타임/동작/변수명/import)을 바꾸지 않는다.
|
||||
2. 주석은 쉬운 한글로 작성하고 "사용처"와 "데이터 흐름"을 먼저 보이게 쓴다.
|
||||
3. 함수/API/Query 주석은 아래 3가지를 중심으로 작성한다.
|
||||
- `[목적]`
|
||||
- `[사용처]`
|
||||
- `[데이터 흐름]`
|
||||
4. 상태(`useState`, `useRef`, store)에는 "값이 바뀌면 화면이 어떻게 변하는지" 한 줄 주석을 단다.
|
||||
5. 복잡한 로직/이벤트 핸들러는 `1, 2, 3...` 단계 주석으로 흐름을 나눈다.
|
||||
6. 긴 JSX는 화면 구역별 주석으로 시각적으로 분리한다.
|
||||
- 예: `{/* ===== 1. 상단: 제목/액션 ===== */}`
|
||||
7. `@param`, `@see`, `@remarks` 같은 딱딱한 TSDoc 태그는 강제하지 않는다.
|
||||
|
||||
## UI/브랜드/문구 규칙
|
||||
|
||||
1. 새 UI는 `indigo/purple/pink` 하드코딩 대신 `brand-*` 토큰을 사용한다.
|
||||
2. 기본 액션 색은 `primary`를 우선한다.
|
||||
3. 색상 톤 변경은 컴포넌트 개별 수정보다 `app/globals.css` 토큰 조정을 우선 검토한다.
|
||||
4. 사용자 문구는 불안을 줄이고 확신을 주는 친근한 톤을 사용한다.
|
||||
|
||||
## common-docs 구현 규칙
|
||||
|
||||
1. KIS API 구현 기준:
|
||||
- `openapi_all.xlsx`를 1순위 스펙으로 본다.
|
||||
- 문서 확인 순서: `openapi_all.xlsx` -> `mcp:kis-code-assistant-mcp` -> `.tmp/open-trading-api` -> `kis_api_reference.md`
|
||||
- 차이가 크면 사용자에게 최신 파일 재확인을 요청한다.
|
||||
2. 에러코드 처리 기준:
|
||||
- `kis-error-code-reference.md`를 따라 `msg_cd + 문구` 형태를 유지한다.
|
||||
- `lib/kis/error-codes.ts`의 `buildKisErrorDetail`/`getKisErrorGuide` 사용 패턴을 유지한다.
|
||||
3. 종목 마스터 데이터 기준:
|
||||
- `features/trade/data/korean-stocks.json`은 수동 편집하지 않는다.
|
||||
- `trade-stock-sync.md` 기준으로 `npm run sync:stocks` / `npm run sync:stocks:check`를 사용한다.
|
||||
4. 전역 알림 UI 기준:
|
||||
- `GLOBAL_ALERT_SYSTEM.md` 기준으로 `useGlobalAlert` 패턴을 우선 사용한다.
|
||||
- 로컬 임시 Alert/Confirm 구현보다 전역 시스템(`GlobalAlertModal`) 연동을 우선한다.
|
||||
5. 제외 문서:
|
||||
- `features-autotrade-design.md`는 현 구현 기준에서 제외한다.
|
||||
|
||||
## 출력 템플릿
|
||||
|
||||
```md
|
||||
[구현 결과]
|
||||
- ...
|
||||
|
||||
[사용한 MCP/Skills]
|
||||
- MCP: ...
|
||||
- Skills: ...
|
||||
|
||||
[변경 파일]
|
||||
- ...
|
||||
|
||||
[핵심 데이터 흐름]
|
||||
- 어느 UI -> A 함수 호출 -> B 함수 호출 -> 리턴값 반영
|
||||
|
||||
[남은 이슈]
|
||||
- ...
|
||||
```
|
||||
|
||||
## 규칙
|
||||
|
||||
- 필요 없는 파일/코드는 남기지 않는다.
|
||||
- 불확실한 라이브러리 API는 문서 근거 없이 단정하지 않는다.
|
||||
- 구현 단계에서 성능에 큰 악영향이 보이면 즉시 메모(기록)하고 다음 단계에서 정리한다.
|
||||
23
.agents/skills/dev-mcp-implementation/agents/openai.yaml
Normal file
23
.agents/skills/dev-mcp-implementation/agents/openai.yaml
Normal file
@@ -0,0 +1,23 @@
|
||||
interface:
|
||||
display_name: "Dev MCP Implementation"
|
||||
short_description: "Implement features with MCP workflows"
|
||||
default_prompt: "Use $dev-mcp-implementation to build code using MCP-first verification."
|
||||
dependencies:
|
||||
tools:
|
||||
- type: "mcp"
|
||||
value: "next-devtools"
|
||||
description: "Next.js route and runtime diagnostics"
|
||||
- type: "mcp"
|
||||
value: "context7"
|
||||
description: "Official framework and library docs"
|
||||
- type: "mcp"
|
||||
value: "supabase-mcp-server"
|
||||
description: "Supabase SQL and function operations"
|
||||
- type: "mcp"
|
||||
value: "playwright"
|
||||
description: "Browser smoke verification"
|
||||
- type: "mcp"
|
||||
value: "kis-code-assistant-mcp"
|
||||
description: "KIS API lookup and source references"
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
58
.agents/skills/dev-plan-completion-checker/SKILL.md
Normal file
58
.agents/skills/dev-plan-completion-checker/SKILL.md
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: dev-plan-completion-checker
|
||||
description: 구현 완료 후 계획 문서와 실제 변경·테스트 근거를 대조해 완료 상태를 판정하는 스킬. 최종 점검 단계에서 사용하며, 계획 작성/구현/테스트 실행 단계를 대신하지 않는다.
|
||||
---
|
||||
|
||||
# Dev Plan Completion Checker
|
||||
|
||||
## 목표
|
||||
|
||||
- 계획대로 구현이 수행됐는지 객관적으로 확인한다.
|
||||
- 누락/부분 완료 항목을 마지막에 명확히 남긴다.
|
||||
|
||||
## 입력
|
||||
|
||||
1. 계획 문서 경로 (`common-docs/improvement/plans/*.md`)
|
||||
2. 변경 파일 목록
|
||||
3. 테스트 결과 (`lint`, `build`, `playwright smoke`, 추가 검증)
|
||||
|
||||
## 작업 순서
|
||||
|
||||
1. 계획 문서의 체크 항목을 읽는다.
|
||||
- 구현 단계 체크박스
|
||||
- 검증 계획 체크박스
|
||||
2. 변경 파일/테스트 결과를 근거로 각 항목 상태를 판정한다.
|
||||
- 완료: 근거가 충분함
|
||||
- 부분 완료: 일부 근거만 있음
|
||||
- 미완료: 근거가 없음
|
||||
3. 누락 항목에 대해 바로 실행 가능한 후속 작업을 작성한다.
|
||||
4. 최종 완료 판정(`배포 가능` / `보완 필요`)을 내린다.
|
||||
|
||||
## 판정 규칙
|
||||
|
||||
1. 구현 단계에 미완료가 1개 이상이면 `보완 필요`
|
||||
2. 검증 계획에 미완료가 있으면 `보완 필요`
|
||||
3. 테스트 생략 항목은 사유와 대체 검증이 있으면 `부분 완료`로 인정 가능
|
||||
|
||||
## 출력 템플릿
|
||||
|
||||
```md
|
||||
[계획 문서]
|
||||
- 경로: ...
|
||||
|
||||
[완료 체크 결과]
|
||||
- 완료: ...
|
||||
- 부분 완료: ...
|
||||
- 미완료: ...
|
||||
|
||||
[근거]
|
||||
- 변경 파일: ...
|
||||
- 테스트 결과: ...
|
||||
|
||||
[보완 필요 항목]
|
||||
1. ...
|
||||
2. ...
|
||||
|
||||
[최종 판정]
|
||||
- 배포 가능/보완 필요
|
||||
```
|
||||
@@ -0,0 +1,6 @@
|
||||
interface:
|
||||
display_name: "Dev Completion Checker"
|
||||
short_description: "Check plan completion against evidence"
|
||||
default_prompt: "Use $dev-plan-completion-checker to compare plan checklists with changed files and test results."
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
153
.agents/skills/dev-plan-writer/SKILL.md
Normal file
153
.agents/skills/dev-plan-writer/SKILL.md
Normal file
@@ -0,0 +1,153 @@
|
||||
---
|
||||
name: dev-plan-writer
|
||||
description: 구현 전에 실행 가능한 계획 문서를 만드는 스킬. 기능 추가/버그 수정/구조 변경 요청에서 범위·영향·작업 순서·검증 기준을 먼저 고정할 때 사용하며, 실제 코드 대량 구현 단계에서는 단독 사용하지 않는다.
|
||||
---
|
||||
|
||||
# Dev Plan Writer
|
||||
|
||||
## 목표
|
||||
|
||||
- 구현 전에 계획부터 확정하여 누락(빠뜨림)을 줄인다.
|
||||
- 주니어 개발자도 바로 따라갈 수 있게 단계를 단순하게 작성한다.
|
||||
|
||||
## 언어/소통 규칙
|
||||
|
||||
1. 모든 계획과 설명을 한국어로 작성한다.
|
||||
2. 어려운 용어는 짧은 괄호 설명을 붙인다.
|
||||
3. 요청이 모호하면 질문 1~3개로 범위를 먼저 고정한다.
|
||||
|
||||
## 프로젝트 기본 컨텍스트
|
||||
|
||||
- 기술 스택: Next.js 16 App Router, React 19, TypeScript, Zustand, Supabase, react-hook-form, zod, Tailwind CSS v4, Radix UI
|
||||
- 기본 명령어: `npm run dev`(포트 3001), `npm run lint`, `npm run build`, `npm run start`
|
||||
|
||||
## 안전 계획 규칙
|
||||
|
||||
1. 수정/추가/삭제 파일을 분리해서 영향 범위를 먼저 적는다.
|
||||
2. 삭제/이동/계약 변경(입출력 규칙 변경)은 사전 확인 질문을 남긴다.
|
||||
3. "진짜 필요 없는 코드만 제거" 원칙으로 계획을 세운다.
|
||||
4. 사이드이펙트(옆 영향) 가능성이 있으면 검증 단계를 계획에 반드시 넣는다.
|
||||
|
||||
## 작업 순서
|
||||
|
||||
1. 요구사항을 3줄 이내로 요약한다.
|
||||
2. 모호한 부분이 있으면 질문 1~3개로 범위를 먼저 고정한다.
|
||||
3. 영향 파일(수정/추가/삭제)을 먼저 찾고, 사이드이펙트(옆 영향)를 표시한다.
|
||||
4. 사용할 MCP/Skills를 단계별로 고른다.
|
||||
5. 구현 단계를 순서대로 작성한다.
|
||||
6. 검증 단계를 구현 단계와 1:1로 매핑한다.
|
||||
|
||||
## 계획 문서 저장 규칙 (필수)
|
||||
|
||||
1. 저장 위치: `common-docs/improvement/plans/`
|
||||
2. 파일명 규칙: `dev-plan-YYYY-MM-DD-<작업슬러그>.md`
|
||||
- 예: `dev-plan-2026-02-25-order-validation.md`
|
||||
3. 하나의 개발 요청은 하나의 계획 파일을 기준으로 끝까지 추적한다.
|
||||
4. 구현이 시작되면 같은 파일에 진행/완료 상태를 계속 갱신한다.
|
||||
|
||||
## 계획 상태 관리 규칙
|
||||
|
||||
1. 구현 단계/검증 계획을 체크박스 형식으로 작성한다.
|
||||
2. 각 체크 항목 옆에 근거(변경 파일, 테스트 결과)를 짧게 남긴다.
|
||||
3. 완료 판단은 마지막에 `dev-plan-completion-checker`가 수행한다.
|
||||
|
||||
## 리팩토링 요청 전용 계획 규칙 (refactoring-rule 반영)
|
||||
|
||||
1. 입력값으로 `FEATURE_ROOT`를 명시한다.
|
||||
2. 목표에 아래 4가지를 반드시 넣는다.
|
||||
- 표준 폴더 구조(`apis/components/hooks/stores/types`)
|
||||
- 선택 폴더 허용(`utils/lib/constants`)
|
||||
- 대형 파일 분해
|
||||
- 배럴 파일 제거 및 직접 import
|
||||
3. 작업 지시는 6단계로 고정해 계획한다.
|
||||
- 분석 -> 구조 설계 -> 이동/생성 -> 경로 수정 -> 청소 -> 진입점 갱신
|
||||
4. 계획 문서에 "권장 파일 구조 트리"를 포함한다.
|
||||
|
||||
## 도구 선택 기준
|
||||
|
||||
- Next.js 런타임/라우트 점검: `next-devtools`
|
||||
- 라이브러리 공식 문서 확인: `context7`
|
||||
- 복잡 로직 분해: `sequential-thinking`
|
||||
- Supabase SQL/함수 작업: `supabase-mcp-server`
|
||||
- 브라우저 동작 검증: `playwright`
|
||||
- Chrome 확장 기반 디버깅: `playwriter`
|
||||
- 최신 기술/레퍼런스 검색: `tavily-remote`
|
||||
- Figma 레이아웃/스타일 확인: `figma`
|
||||
|
||||
## common-docs 계획 반영 규칙
|
||||
|
||||
1. `common-docs` 기준 문서를 계획 단계에서 먼저 지정한다.
|
||||
- `common-docs/api-reference/openapi_all.xlsx`
|
||||
- `common-docs/api-reference/kis_api_reference.md`
|
||||
- `common-docs/api-reference/kis-error-code-reference.md`
|
||||
- `common-docs/features/trade-stock-sync.md`
|
||||
- `common-docs/ui/GLOBAL_ALERT_SYSTEM.md`
|
||||
2. 아래 문서는 계획에서 제외한다.
|
||||
- `common-docs/features-autotrade-design.md` (향후 기획 문서)
|
||||
3. KIS 연동 작업이면 스펙 확인 순서를 계획에 명시한다.
|
||||
- `openapi_all.xlsx` -> `mcp:kis-code-assistant-mcp` -> `.tmp/open-trading-api` -> `kis_api_reference.md`
|
||||
4. 종목 코드/마스터 데이터 변경이면 `trade-stock-sync.md` 기준으로 자동 동기화 명령을 계획에 넣는다.
|
||||
5. 사용자 알림/확인 모달 변경이면 `GLOBAL_ALERT_SYSTEM.md` 기준으로 전역 알림 시스템 유지 계획을 넣는다.
|
||||
|
||||
## 출력 템플릿
|
||||
|
||||
```md
|
||||
[계획 문서 경로]
|
||||
- common-docs/improvement/plans/dev-plan-YYYY-MM-DD-<작업슬러그>.md
|
||||
|
||||
[요구사항 요약]
|
||||
- ...
|
||||
|
||||
[확인 질문(필요 시 1~3개)]
|
||||
- ...
|
||||
|
||||
[가정]
|
||||
- ...
|
||||
|
||||
[영향 범위]
|
||||
- 수정: ...
|
||||
- 추가: ...
|
||||
- 삭제: ...
|
||||
|
||||
[구현 단계]
|
||||
- [ ] 1. ...
|
||||
- [ ] 2. ...
|
||||
- [ ] 3. ...
|
||||
|
||||
[사용할 MCP/Skills]
|
||||
- MCP: ...
|
||||
- Skills: ...
|
||||
|
||||
[참조 문서(common-docs)]
|
||||
- ...
|
||||
|
||||
[주석/문서 반영 계획]
|
||||
- 함수 주석: [목적]/[사용처]/[데이터 흐름]
|
||||
- 상태 주석: 값 변경 시 화면 영향 한 줄 설명
|
||||
- 복잡 로직/핸들러: 1, 2, 3 단계 주석
|
||||
- JSX 구역 주석: 화면 구조가 보이게 분리
|
||||
- TSDoc 딱딱한 태그(`@param`, `@see`, `@remarks`) 강제 없음
|
||||
|
||||
[리팩토링 구조 계획(리팩토링 요청 시)]
|
||||
- FEATURE_ROOT: ...
|
||||
- 목표(표준 구조/선택 구조/대형파일 분해/배럴 제거): ...
|
||||
- Workflow 6단계: ...
|
||||
- 권장 구조 트리: ...
|
||||
|
||||
[리스크/회귀 포인트]
|
||||
- ...
|
||||
|
||||
[검증 계획]
|
||||
- [ ] 1. ...
|
||||
- [ ] 2. ...
|
||||
- [ ] 3. ...
|
||||
|
||||
[진행 로그]
|
||||
- 2026-..-..: ...
|
||||
```
|
||||
|
||||
## 규칙
|
||||
|
||||
- 계획 승인 전에 실제 구현 코드를 대량 작성하지 않는다.
|
||||
- 파일 삭제는 반드시 필요성/대체 경로를 확인한 뒤 진행한다.
|
||||
- 동작 변경과 리팩토링을 섞지 않는다.
|
||||
17
.agents/skills/dev-plan-writer/agents/openai.yaml
Normal file
17
.agents/skills/dev-plan-writer/agents/openai.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
interface:
|
||||
display_name: "Dev Plan Writer"
|
||||
short_description: "Write implementation plans with checks"
|
||||
default_prompt: "Use $dev-plan-writer to create a tracked implementation plan file."
|
||||
dependencies:
|
||||
tools:
|
||||
- type: "mcp"
|
||||
value: "next-devtools"
|
||||
description: "Next.js runtime and route diagnostics"
|
||||
- type: "mcp"
|
||||
value: "context7"
|
||||
description: "Official library documentation lookup"
|
||||
- type: "mcp"
|
||||
value: "sequential-thinking"
|
||||
description: "Step-by-step reasoning for complex planning"
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
145
.agents/skills/dev-refactor-polish/SKILL.md
Normal file
145
.agents/skills/dev-refactor-polish/SKILL.md
Normal file
@@ -0,0 +1,145 @@
|
||||
---
|
||||
name: dev-refactor-polish
|
||||
description: 구현 완료 직후 가독성·데이터 흐름·성능을 다듬는 후처리 리팩토링 스킬. 핵심 동작을 바꾸지 않는 정리 단계에서 사용하며, 신규 기능의 본 구현 단계 대체 용도로는 사용하지 않는다.
|
||||
---
|
||||
|
||||
# Dev Refactor Polish
|
||||
|
||||
## 목표
|
||||
|
||||
- 핵심 비즈니스 동작은 유지하고 코드 품질을 높인다.
|
||||
- 읽기 쉬운 구조와 명확한 데이터 흐름 설명을 만든다.
|
||||
- 사용자 혼란을 줄이는 작은 UX 개선(문구, 버튼 라벨, 피드백 표시)은 허용한다.
|
||||
|
||||
## 리팩토링 목표 (refactoring-rule 반영)
|
||||
|
||||
1. 표준 폴더 구조를 지향한다.
|
||||
- 기본: `apis`, `components`, `hooks`, `stores`, `types`
|
||||
2. 필요 시 보조 폴더를 유연하게 허용한다.
|
||||
- 선택: `utils`, `lib`, `constants`
|
||||
3. 거대한 단일 파일은 기능 단위로 분해한다.
|
||||
4. 배럴 파일(`index.ts` re-export) 의존을 줄이고 직접 import 경로를 사용한다.
|
||||
|
||||
## 리팩토링 기본 원칙
|
||||
|
||||
1. 설명과 주석은 한국어로 쉽게 쓴다. 어려운 용어는 괄호로 짧게 풀어쓴다.
|
||||
2. 사이드이펙트 위험이 있으면 영향 범위를 먼저 확인하고 수정한다.
|
||||
3. 불필요한 코드만 제거하고, 영향 가능성이 있으면 검증 후 반영한다.
|
||||
|
||||
## 리팩토링 순서
|
||||
|
||||
1. 핵심 동작 변경 없이 중복 코드를 줄인다.
|
||||
2. 함수/변수 이름을 역할이 드러나는 이름으로 정리한다.
|
||||
3. 복잡한 JSX는 섹션 주석으로 나눈다.
|
||||
4. 상태/이벤트/복잡 로직에 인라인 주석을 보강한다.
|
||||
5. 함수/API/Query에 쉬운 설명 주석을 보강한다.
|
||||
6. 데이터 흐름을 `어느 UI -> A 함수 호출 -> B 함수 호출 -> 리턴값 반영`으로 명시한다.
|
||||
7. `vercel-react-best-practices` 기준으로 불필요한 리렌더/요청을 줄인다.
|
||||
|
||||
## 작업 지시 (Workflow, refactoring-rule 반영)
|
||||
|
||||
1. 분석: `FEATURE_ROOT` 내부 파일 구조와 외부 import 의존성을 파악한다.
|
||||
2. 구조 설계: 기본/선택 폴더 기준으로 파일 분류 계획을 세운다.
|
||||
3. 이동/생성: 파일을 이동하거나 책임 단위로 분리 생성한다.
|
||||
4. 경로 수정: 이동된 파일에 맞춰 import 경로를 일괄 수정한다.
|
||||
5. 청소: 옛 폴더와 불필요한 `index.ts`를 정리한다.
|
||||
6. 진입점 갱신: `page.tsx` 등 외부 진입 파일 import를 최종 갱신한다.
|
||||
|
||||
## 권장 파일 구조 (Standard Structure)
|
||||
|
||||
```text
|
||||
<FEATURE_ROOT>/
|
||||
├── apis/
|
||||
│ ├── apiError.ts
|
||||
│ ├── <feature>.api.ts
|
||||
│ ├── <feature>Form.adapter.ts
|
||||
│ └── <feature>List.adapter.ts
|
||||
├── hooks/
|
||||
│ ├── queryKeys.ts
|
||||
│ ├── use<Feature>List.ts
|
||||
│ ├── use<Feature>Mutations.ts
|
||||
│ └── use<Feature>Form.ts
|
||||
├── types/
|
||||
│ ├── api.types.ts
|
||||
│ ├── <feature>.types.ts
|
||||
│ └── selectOption.types.ts
|
||||
├── stores/
|
||||
│ └── <feature>Store.ts
|
||||
├── components/
|
||||
│ ├── <Feature>Container.tsx
|
||||
│ └── <Feature>Modal.tsx
|
||||
├── utils/ # Optional
|
||||
│ └── <feature>Utils.ts
|
||||
├── lib/ # Optional
|
||||
│ └── <feature>Lib.ts
|
||||
└── constants/ # Optional
|
||||
└── <feature>.constants.ts
|
||||
```
|
||||
|
||||
## 의존성/리스크 분석 규칙
|
||||
|
||||
1. 파일 이동 전 `sequential-thinking`으로 의존성 지도를 먼저 만든다.
|
||||
2. 최신 구조 기준이 모호하면 `context7`로 공식 문서를 확인한다.
|
||||
|
||||
## common-docs 리팩토링 반영 규칙
|
||||
|
||||
1. KIS 연동 리팩토링 시 아래 기준을 유지한다.
|
||||
- 스펙 기준: `common-docs/api-reference/openapi_all.xlsx`
|
||||
- 보조 확인: `kis_api_reference.md`, `kis-error-code-reference.md`
|
||||
2. 에러 처리 리팩토링 시 `lib/kis/error-codes.ts` 중심 구조(`msg_cd + 문구`)를 깨지 않게 유지한다.
|
||||
3. 종목 데이터 리팩토링 시 `korean-stocks.json`을 수동 편집 대상으로 옮기지 않는다.
|
||||
- 동기화 흐름은 `trade-stock-sync.md` 기준으로 유지한다.
|
||||
4. 알림 UI 리팩토링 시 전역 알림 구조(`useGlobalAlert`, `GlobalAlertModal`)를 우선 유지한다.
|
||||
5. `features-autotrade-design.md`는 리팩토링 기준 문서로 사용하지 않는다.
|
||||
|
||||
## 주석 규칙 (문서화 전문가 기준)
|
||||
|
||||
1. 주석 보강 작업은 코드 로직(타입/런타임/동작/변수명/import)을 바꾸지 않는다.
|
||||
2. 함수/API/쿼리 주석은 `[목적]`, `[사용처]`, `[데이터 흐름]` 중심으로 쉽게 쓴다.
|
||||
3. 상태(`useState`, `useRef`, store)는 "화면에 어떤 영향을 주는지" 한 줄 주석을 단다.
|
||||
4. 복잡한 로직/핸들러는 `1.`, `2.`, `3.` 단계 주석으로 흐름을 나눈다.
|
||||
5. 긴 JSX는 화면 구역 주석으로 나눠서 읽기 쉽게 만든다.
|
||||
- 예: `{/* ===== 1. 상단: 페이지 제목 및 액션 버튼 ===== */}`
|
||||
6. `@param`, `@see`, `@remarks` 같은 딱딱한 TSDoc 태그는 강제하지 않는다.
|
||||
7. 결과 기준은 "주니어가 5분 내 파악 가능한지"로 잡는다.
|
||||
|
||||
## UI/브랜드/문구 규칙
|
||||
|
||||
1. UI 수정 시 `brand-*` 토큰과 `primary` 사용 기준을 지킨다.
|
||||
2. 사용자 문구는 불안을 줄이고 확신을 주는 톤으로 개선한다.
|
||||
|
||||
## 품질 체크리스트
|
||||
|
||||
- 핵심 비즈니스 로직 변경이 없는가?
|
||||
- 작은 UX 개선이라도 API 계약(입출력 규칙), 권한, 저장 데이터 구조를 건드리지 않았는가?
|
||||
- 주니어가 5분 안에 흐름을 파악할 수 있는가?
|
||||
- 상태 변경이 화면 어디에 반영되는지 보이는가?
|
||||
- 무거운 계산/렌더가 메모이제이션(재계산 줄이기) 대상인지 검토했는가?
|
||||
|
||||
## 출력 템플릿
|
||||
|
||||
```md
|
||||
[리팩토링 요약]
|
||||
- ...
|
||||
|
||||
[가독성 개선 포인트]
|
||||
- ...
|
||||
|
||||
[작은 UX 개선 포인트]
|
||||
- ...
|
||||
|
||||
[성능 개선 포인트]
|
||||
- ...
|
||||
|
||||
[데이터 흐름 정리]
|
||||
- 어느 UI -> A 함수 호출 -> B 함수 호출 -> 리턴값 반영
|
||||
|
||||
[회귀 위험 점검]
|
||||
- ...
|
||||
```
|
||||
|
||||
## 규칙
|
||||
|
||||
- 파일 이동/삭제가 있으면 영향 범위를 먼저 검증한다.
|
||||
- 구조 개선은 하되 과도한 분리(쪼개기)는 피한다.
|
||||
- 작은 UX 개선을 했으면 왜 개선했는지 한 줄 근거를 남긴다.
|
||||
14
.agents/skills/dev-refactor-polish/agents/openai.yaml
Normal file
14
.agents/skills/dev-refactor-polish/agents/openai.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
interface:
|
||||
display_name: "Dev Refactor Polish"
|
||||
short_description: "Refactor code for readability and performance"
|
||||
default_prompt: "Use $dev-refactor-polish to improve readability, data flow, and small UX polish."
|
||||
dependencies:
|
||||
tools:
|
||||
- type: "mcp"
|
||||
value: "context7"
|
||||
description: "Official docs for framework-safe refactors"
|
||||
- type: "mcp"
|
||||
value: "sequential-thinking"
|
||||
description: "Dependency impact reasoning before file moves"
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
91
.agents/skills/dev-test-gate/SKILL.md
Normal file
91
.agents/skills/dev-test-gate/SKILL.md
Normal file
@@ -0,0 +1,91 @@
|
||||
---
|
||||
name: dev-test-gate
|
||||
description: 개발/리팩토링 후 lint·build·Playwright 스모크 테스트를 실행하고 실패 원인을 정리하는 검증 스킬. 최종 품질 게이트 단계에서 사용하며, 구현 자체를 대체하지 않는다.
|
||||
---
|
||||
|
||||
# Dev Test Gate
|
||||
|
||||
## 목표
|
||||
|
||||
- 변경 사항의 안정성을 빠르게 확인한다.
|
||||
- 실패 원인과 영향 범위를 짧고 명확하게 남긴다.
|
||||
|
||||
## 공통 기준
|
||||
|
||||
1. 결과 보고는 한국어로 작성한다.
|
||||
2. 테스트 결과는 주니어도 이해 가능하게 쉬운 말로 정리한다.
|
||||
3. 테스트 생략은 원칙적으로 금지하고, 불가한 경우 사유와 대체 검증을 남긴다.
|
||||
|
||||
## 테스트 순서
|
||||
|
||||
1. 정적 검사: `npm run lint`
|
||||
2. 빌드 검사: `npm run build`
|
||||
3. 개발 서버 실행: `npm run dev` (기본 포트 3001)
|
||||
4. 런타임 확인: 핵심 화면 로드와 기본 동작 확인
|
||||
5. Playwright 스모크 테스트(기본): 핵심 화면 간단 확인을 반드시 수행
|
||||
6. 사용자 요청 테스트가 있으면 해당 테스트를 추가 실행한다.
|
||||
|
||||
## Playwright 스모크 기본 규칙
|
||||
|
||||
1. 핵심 화면 3종을 기본 대상으로 잡는다.
|
||||
2. 화면 타입은 아래 기준으로 고른다.
|
||||
- 서비스 진입 화면 1개
|
||||
- 핵심 기능 화면 1개
|
||||
- 설정/인증 관련 화면 1개
|
||||
3. 각 화면에서 최소 항목을 확인한다.
|
||||
- 페이지 로드 성공
|
||||
- 치명 오류 문구/콘솔 에러 없음
|
||||
- 핵심 버튼 또는 입력 요소 1개 이상 상호작용 가능
|
||||
|
||||
## 검증 보강 규칙
|
||||
|
||||
1. UI 변경이 있으면 브랜드 토큰(`brand-*`, `primary`) 적용 여부를 함께 점검한다.
|
||||
2. KIS API 연동 변경이 있으면 계좌/인증/오류 처리 기본 시나리오를 스모크 범위에 포함한다.
|
||||
3. 리팩토링 요청이면 구조 점검을 추가한다.
|
||||
- `FEATURE_ROOT`가 목표 구조(`apis/components/hooks/stores/types`)를 따르는지 확인
|
||||
- 파일 이동 후 진입점 import 경로가 깨지지 않았는지 확인
|
||||
- 불필요한 `index.ts` 배럴 파일 잔존 여부를 확인
|
||||
|
||||
## common-docs 연계 검증 규칙
|
||||
|
||||
1. KIS 연동 파일 변경 시 아래를 점검한다.
|
||||
- `kis_api_reference.md` 기준 엔드포인트/흐름이 크게 어긋나지 않는지 확인
|
||||
- `kis-error-code-reference.md` 기준 `msg_cd + 문구` 표시 흐름 유지 확인
|
||||
2. `features/trade/data/korean-stocks.json` 또는 동기화 스크립트 변경 시
|
||||
- `npm run sync:stocks:check`를 추가 실행한다.
|
||||
3. 전역 알림 관련 파일(`features/layout/hooks/use-global-alert.ts`, `GlobalAlertModal`) 변경 시
|
||||
- 핵심 시나리오(성공 알림 1건, 확인 모달 1건)를 스모크 검증에 포함한다.
|
||||
4. `features-autotrade-design.md`는 테스트 기준 문서에서 제외한다.
|
||||
|
||||
## 실패 처리 규칙
|
||||
|
||||
1. 실패 로그에서 직접 원인 라인을 먼저 찾는다.
|
||||
2. 원인 수정 후 같은 테스트를 재실행한다.
|
||||
3. 연쇄 실패(한 수정으로 여러 실패)가 있으면 우선순위를 나눠 정리한다.
|
||||
4. 시간/환경 제한으로 테스트를 못 돌리면 이유와 대체 검증을 반드시 기록한다.
|
||||
|
||||
## 출력 템플릿
|
||||
|
||||
```md
|
||||
[테스트 결과]
|
||||
- lint: 통과/실패
|
||||
- build: 통과/실패
|
||||
- playwright smoke: 통과/실패
|
||||
- common-docs 연계 검증: 통과/실패
|
||||
- 추가 테스트: ...
|
||||
|
||||
[실패 및 조치]
|
||||
- ...
|
||||
|
||||
[최종 상태]
|
||||
- 배포 가능/보류
|
||||
```
|
||||
|
||||
## 완료체크 인계 규칙
|
||||
|
||||
1. 테스트 결과는 `dev-plan-completion-checker`에 그대로 전달한다.
|
||||
2. 전달 형식은 아래 4줄을 포함한다.
|
||||
- lint 결과
|
||||
- build 결과
|
||||
- playwright smoke 결과
|
||||
- 생략/실패 사유 및 대체 검증
|
||||
14
.agents/skills/dev-test-gate/agents/openai.yaml
Normal file
14
.agents/skills/dev-test-gate/agents/openai.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
interface:
|
||||
display_name: "Dev Test Gate"
|
||||
short_description: "Run lint, build, and Playwright smoke tests"
|
||||
default_prompt: "Use $dev-test-gate to run lint, build, and smoke verification before completion."
|
||||
dependencies:
|
||||
tools:
|
||||
- type: "mcp"
|
||||
value: "playwright"
|
||||
description: "Browser smoke test automation"
|
||||
- type: "mcp"
|
||||
value: "next-devtools"
|
||||
description: "Next.js runtime error and route checks"
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
14
.agents/skills/nextjs-app-router-patterns/agents/openai.yaml
Normal file
14
.agents/skills/nextjs-app-router-patterns/agents/openai.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
interface:
|
||||
display_name: "Next.js App Router Patterns"
|
||||
short_description: "Next.js App Router patterns and checks"
|
||||
default_prompt: "Use $nextjs-app-router-patterns to review App Router structure, server/client boundaries, and data fetching patterns."
|
||||
dependencies:
|
||||
tools:
|
||||
- type: "mcp"
|
||||
value: "next-devtools"
|
||||
description: "Next.js runtime route and error diagnostics"
|
||||
- type: "mcp"
|
||||
value: "context7"
|
||||
description: "Official Next.js documentation lookup"
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
@@ -1,9 +1,9 @@
|
||||
# Supabase 환경 설정 예제 파일
|
||||
# 이 파일의 이름을 .env.local 로 변경한 뒤, 실제 값을 채워넣으세요.
|
||||
# Supabase 환경 설정 예제 파일
|
||||
# 이 파일을 .env.local로 복사한 뒤 실제 값을 채워 주세요.
|
||||
# 값 확인: https://supabase.com/dashboard/project/_/settings/api
|
||||
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
|
||||
# 세션 타임아웃 (분 단위)
|
||||
# 세션 타임아웃(분 단위)
|
||||
NEXT_PUBLIC_SESSION_TIMEOUT_MINUTES=30
|
||||
|
||||
6
.gemini/settings.json
Normal file
6
.gemini/settings.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"tools": {
|
||||
"approvalMode": "auto_edit",
|
||||
"allowed": ["run_shell_command"]
|
||||
}
|
||||
}
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -119,8 +119,14 @@ storybook-static/
|
||||
*.local
|
||||
.cache/
|
||||
node_modules
|
||||
.tmp/
|
||||
|
||||
# ========================================
|
||||
# Custom
|
||||
# ========================================
|
||||
.playwright-mcp/
|
||||
|
||||
# ========================================
|
||||
# Documentation (문서)
|
||||
# ========================================
|
||||
docs/
|
||||
|
||||
54
AGENTS.md
54
AGENTS.md
@@ -1,45 +1,17 @@
|
||||
# AGENTS.md (auto-trade)
|
||||
# AGENTS.md (auto-trade)
|
||||
|
||||
## 기본 원칙
|
||||
- 모든 응답과 설명은 한국어로 작성.
|
||||
- 쉬운 말로 설명. 어려운 용어는 괄호로 짧게 뜻을 덧붙임.
|
||||
- 요청이 모호하면 먼저 질문 1~3개로 범위를 확인.
|
||||
## 운영 원칙
|
||||
|
||||
## 프로젝트 요약
|
||||
- Next.js 16 App Router, React 19, TypeScript
|
||||
- 상태 관리: zustand
|
||||
- 데이터: Supabase
|
||||
- 폼 및 검증: react-hook-form, zod
|
||||
- UI: Tailwind CSS v4, Radix UI (components.json 사용)
|
||||
- 중복 규칙은 `AGENTS.md`에 두지 않고 각 스킬(`.agents/skills/*/SKILL.md`)에서 관리한다.
|
||||
- 개발 작업은 스킬 기반으로 수행한다.
|
||||
|
||||
## 명령어
|
||||
- 개발 서버: (포트는 3001번이야)
|
||||
pm run dev
|
||||
- 린트:
|
||||
pm run lint
|
||||
- 빌드:
|
||||
pm run build
|
||||
- 실행:
|
||||
pm run start
|
||||
## 스킬 호출 규칙
|
||||
|
||||
## 코드 및 문서 규칙
|
||||
- JSX 섹션 주석 형식: {/* ========== SECTION NAME ========== */}
|
||||
- 함수 및 컴포넌트 JSDoc에 @see 필수 (호출 파일, 함수 또는 이벤트 이름, 목적 포함)
|
||||
- 상태 정의, 이벤트 핸들러, 복잡한 JSX 로직에는 인라인 주석을 충분히 작성
|
||||
|
||||
## 브랜드 색상 규칙
|
||||
- 메인 컬러는 헤더 로고/프로필 기준의 보라 계열 `brand` 팔레트를 사용.
|
||||
- 새 UI 작성 시 `indigo/purple/pink` 하드코딩 대신 `brand-*` 토큰 사용. 예: `bg-brand-500`, `text-brand-600`, `from-brand-500 to-brand-700`.
|
||||
- 기본 액션 색(버튼/포커스)은 `primary`를 사용하고, `primary`는 `app/globals.css`의 `brand` 팔레트와 같은 톤으로 유지.
|
||||
- 색상 변경이 필요하면 컴포넌트 개별 수정보다 먼저 `app/globals.css` 토큰을 수정.
|
||||
|
||||
## 설명 방식
|
||||
- 단계별로 짧게, 예시는 1개만.
|
||||
- 사용자가 요청한 변경과 이유를 함께 설명.
|
||||
- 파일 경로는 pp/...처럼 코드 형식으로 표기.
|
||||
|
||||
## 여러 도구를 함께 쓸 때 (쉬운 설명)
|
||||
- 기준 설명을 한 군데에 모아두고, 그 파일만 계속 업데이트하는 것이 핵심.
|
||||
- 예를 들어 PROJECT_CONTEXT.md에 스택, 폴더 구조, 규칙을 적어둔다.
|
||||
- 그리고 각 도구의 규칙 파일에 PROJECT_CONTEXT.md를 참고라고 써 둔다.
|
||||
- 이렇게 하면 어떤 도구를 쓰든 같은 파일을 읽게 되어, 매번 다시 설명할 일이 줄어든다.
|
||||
- 개발 요청 키워드(`개발해줘`, `구현해줘`, `기능 추가`, `버그 수정`, `리팩토링`, `개선해줘`, `고쳐줘`, `최적화해줘`, `만들어줘`)가 들어오면 `dev-auto-pipeline` 스킬을 우선 사용한다.
|
||||
- 파이프라인 단계 스킬은 아래 순서로 사용한다.
|
||||
1. `dev-plan-writer`
|
||||
2. `dev-mcp-implementation`
|
||||
3. `dev-refactor-polish`
|
||||
4. `dev-test-gate`
|
||||
5. `dev-plan-completion-checker`
|
||||
- 단순 설명/문서 요약/잡담 요청에는 파이프라인 스킬을 강제하지 않는다.
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
# PROJECT_CONTEXT.md
|
||||
|
||||
이 파일은 프로젝트 설명의 기준(원본)입니다.
|
||||
여기만 업데이트하고, 다른 도구 규칙에서는 이 파일을 참고하도록 연결하세요.
|
||||
|
||||
## 한 줄 요약
|
||||
- 자동매매(오토 트레이드) 웹 앱
|
||||
|
||||
## 기술 스택
|
||||
- Next.js 16 (App Router)
|
||||
- React 19, TypeScript
|
||||
- 상태 관리: zustand
|
||||
- 데이터: Supabase
|
||||
- 폼/검증: react-hook-form, zod
|
||||
- UI: Tailwind CSS v4, Radix UI
|
||||
|
||||
## 폴더 구조(핵심만)
|
||||
- pp/ 라우팅 및 페이지
|
||||
- eatures/ 도메인별 기능
|
||||
- components/ 공용 UI
|
||||
- lib/ 유틸/클라이언트
|
||||
- utils/ 헬퍼
|
||||
|
||||
## 주요 규칙(요약)
|
||||
- JSX 섹션 주석 형식: {/* ========== SECTION NAME ========== */}
|
||||
- 함수/컴포넌트 JSDoc에 @see 필수
|
||||
- 파일 상단에 @author jihoon87.lee
|
||||
- 상태/이벤트/복잡 로직에 인라인 주석 충분히 작성
|
||||
|
||||
## 작업 흐름
|
||||
- 개발 서버:
|
||||
pm run dev
|
||||
- 린트:
|
||||
pm run lint
|
||||
- 빌드:
|
||||
pm run build
|
||||
- 실행:
|
||||
pm run start
|
||||
|
||||
## 자주 하는 설명 템플릿
|
||||
- 변경 이유: (왜 바꾸는지)
|
||||
- 변경 내용: (무엇을 바꾸는지)
|
||||
- 영향 범위: (어디에 영향이 있는지)
|
||||
|
||||
## 업데이트 가이드
|
||||
- 새 규칙/패턴이 생기면 여기에 먼저 추가
|
||||
- 문장이 길어지면 더 짧게 요약
|
||||
- 도구별 규칙 파일에는 PROJECT_CONTEXT.md 참고만 적기
|
||||
164
README.md
164
README.md
@@ -1,36 +1,160 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# auto-trade
|
||||
|
||||
## Getting Started
|
||||
한국투자증권(KIS) Open API와 Supabase 인증을 연결한 국내주식 트레이딩 대시보드입니다.
|
||||
사용자는 로그인 후 API 키를 검증하고, 종목 검색/상세/차트/호가/체결/주문 화면을 한 곳에서 사용할 수 있습니다.
|
||||
|
||||
First, run the development server:
|
||||
## 1) 핵심 기능
|
||||
|
||||
- 인증: Supabase 이메일/소셜 로그인, 비밀번호 재설정, 세션 타임아웃 자동 로그아웃
|
||||
- KIS 연결: 실전/모의 모드 선택, API 키 검증, 웹소켓 승인키 발급
|
||||
- 트레이드 화면: 종목 검색, 종목 개요, 캔들 차트, 실시간 호가/체결, 현금 주문
|
||||
- 종목 검색 인덱스: `korean-stocks.json` 기반 고속 검색 + 자동 갱신 스크립트
|
||||
|
||||
## 2) 기술 스택
|
||||
|
||||
- 프레임워크: Next.js 16 (App Router), React 19, TypeScript
|
||||
- 상태관리: Zustand
|
||||
- 서버 상태: TanStack Query (React Query)
|
||||
- 인증/백엔드: Supabase (`@supabase/ssr`, `@supabase/supabase-js`)
|
||||
- UI: Tailwind CSS v4, Radix UI, Sonner
|
||||
- 차트: `lightweight-charts`
|
||||
|
||||
## 3) 화면/라우트
|
||||
|
||||
- `/`: 서비스 랜딩 페이지
|
||||
- `/login`, `/signup`, `/forgot-password`, `/reset-password`: 인증 페이지
|
||||
- `/dashboard`: 로그인 전용(현재 플레이스홀더)
|
||||
- `/settings`: KIS API 키 연결/해제
|
||||
- `/trade`: 실제 트레이딩 대시보드
|
||||
|
||||
## 4) UI 흐름 (중요)
|
||||
|
||||
- 인증 흐름: 로그인 UI -> `features/auth/actions.ts` -> Supabase Auth -> 세션 쿠키 반영 -> 보호 라우트 접근 허용
|
||||
- KIS 연결 흐름: 설정 UI -> `/api/kis/validate` -> 토큰 발급 성공 확인 -> `use-kis-runtime-store`에 검증 상태 저장
|
||||
- 실시간 흐름: 트레이드 UI -> `/api/kis/ws/approval` -> `useKisTradeWebSocket` 구독 -> 체결/호가 파싱 -> 차트/호가창 반영
|
||||
- 검색 흐름: 검색 UI -> `/api/kis/domestic/search` -> `korean-stocks.json` 메모리 검색 -> 결과 목록 반영
|
||||
|
||||
## 5) 빠른 시작
|
||||
|
||||
### 5-1. 요구 사항
|
||||
|
||||
- Node.js 20 이상
|
||||
- npm 10 이상 권장
|
||||
|
||||
### 5-2. 설치
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 5-3. 환경변수 설정
|
||||
|
||||
`.env.example`을 복사해서 `.env.local`을 만듭니다.
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
Copy-Item .env.example .env.local
|
||||
```
|
||||
|
||||
필수 값은 아래를 먼저 채우면 됩니다.
|
||||
|
||||
- `NEXT_PUBLIC_SUPABASE_URL`
|
||||
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
||||
|
||||
KIS는 `.env`에 저장하지 않고 `/settings` 입력 폼에서 직접 입력합니다.
|
||||
|
||||
- 앱키/시크릿/계좌번호는 사용자 브라우저에서만 관리
|
||||
- 서버 API는 로그인 세션 + 요청 헤더로 전달된 키가 있어야 동작
|
||||
- `NEXT_PUBLIC_BASE_URL` (선택, 배포 도메인 사용 시 권장)
|
||||
|
||||
### 5-4. 로컬 실행
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
- 개발 서버: `http://localhost:3001`
|
||||
- Turbopack 적용: `package.json`의 `dev` 스크립트에 `--turbopack` 포함
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
### 5-5. 점검 명령
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
```bash
|
||||
npm run lint
|
||||
npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
## Learn More
|
||||
## 6) 종목 인덱스 동기화
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
`features/trade/data/korean-stocks.json`은 수동 편집용 파일이 아닙니다.
|
||||
KIS 마스터 파일(KOSPI/KOSDAQ)에서 자동 생성합니다.
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
```bash
|
||||
npm run sync:stocks
|
||||
```
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
검증만 하고 싶으면:
|
||||
|
||||
## Deploy on Vercel
|
||||
```bash
|
||||
npm run sync:stocks:check
|
||||
```
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
상세 문서: `docs/trade-stock-sync.md`
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
## 7) API 엔드포인트 요약
|
||||
|
||||
- 인증/연결
|
||||
- `POST /api/kis/validate`: API 키 검증
|
||||
- `POST /api/kis/revoke`: 토큰 폐기
|
||||
- `POST /api/kis/ws/approval`: 웹소켓 승인키 발급
|
||||
|
||||
- 국내주식
|
||||
- `GET /api/kis/domestic/search?q=...`: 종목 검색(로컬 인덱스)
|
||||
- `GET /api/kis/domestic/overview?symbol=...`: 종목 개요
|
||||
- `GET /api/kis/domestic/orderbook?symbol=...`: 호가
|
||||
- `GET /api/kis/domestic/chart?symbol=...&timeframe=...`: 차트
|
||||
- `POST /api/kis/domestic/order-cash`: 현금 주문
|
||||
|
||||
## 8) 프로젝트 구조
|
||||
|
||||
```text
|
||||
app/
|
||||
(home)/ 랜딩
|
||||
(auth)/ 로그인/회원가입/비밀번호 재설정
|
||||
(main)/ 로그인 후 화면(dashboard/trade/settings)
|
||||
api/kis/ KIS 연동 API 라우트
|
||||
features/
|
||||
auth/ 인증 UI/액션/상수
|
||||
settings/ KIS 키 설정 UI + 런타임 스토어
|
||||
trade/ 검색/차트/호가/주문/웹소켓
|
||||
lib/kis/ KIS REST/WS 공통 로직
|
||||
scripts/
|
||||
sync-korean-stocks.mjs
|
||||
utils/supabase/ 서버/클라이언트/미들웨어 클라이언트
|
||||
```
|
||||
|
||||
## 9) 트러블슈팅
|
||||
|
||||
- KIS 검증 실패
|
||||
- 입력한 앱키/시크릿, 실전/모의 모드가 맞는지 확인
|
||||
- KIS Open API 앱 권한과 IP 허용 설정 확인
|
||||
|
||||
- 실시간 체결/호가가 안 들어옴
|
||||
- `/settings`에서 검증 상태가 유지되는지 확인
|
||||
- 장 구간(장중/동시호가/시간외)에 따라 데이터가 달라질 수 있음
|
||||
- 브라우저 콘솔 디버그가 필요하면 `localStorage.KIS_WS_DEBUG = "1"` 설정
|
||||
|
||||
- 검색 결과가 기대와 다름
|
||||
- 검색은 KIS 실시간 검색 API가 아니라 로컬 인덱스(`korean-stocks.json`) 기반
|
||||
- 최신 종목 반영이 필요하면 `npm run sync:stocks` 실행
|
||||
|
||||
## 10) 운영 주의사항
|
||||
|
||||
- 현재 KIS 입력값과 검증 상태 일부는 브라우저 로컬 스토리지(localStorage, 브라우저 저장소)에 저장됩니다.
|
||||
- 실전 운영 시에는 민감정보 보관 정책(암호화, 만료, 서버 보관 방식)을 반드시 따로 설계하세요.
|
||||
- 주문은 계좌번호가 필요합니다. 계좌번호 입력/검증 UX는 운영 환경에 맞게 보완이 필요합니다.
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import Link from "next/link";
|
||||
import { AUTH_ROUTES } from "@/features/auth/constants";
|
||||
import { Mail } from "lucide-react";
|
||||
|
||||
/**
|
||||
* [비밀번호 찾기 페이지]
|
||||
@@ -31,10 +32,10 @@ export default async function ForgotPasswordPage({
|
||||
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
{message && <FormMessage message={message} />}
|
||||
|
||||
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
||||
<Card className="border-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/70">
|
||||
<CardHeader className="space-y-3 text-center">
|
||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
|
||||
<span className="text-sm font-semibold">MAIL</span>
|
||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
|
||||
<Mail className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||
비밀번호 재설정
|
||||
@@ -59,13 +60,13 @@ export default async function ForgotPasswordPage({
|
||||
placeholder="name@example.com"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="h-11 transition-all duration-200"
|
||||
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
formAction={requestPasswordReset}
|
||||
className="h-11 w-full bg-linear-to-r from-gray-900 to-black font-semibold text-white shadow-lg transition-all hover:from-black hover:to-gray-800 hover:shadow-xl dark:from-white dark:to-gray-100 dark:text-black dark:hover:from-gray-100 dark:hover:to-white"
|
||||
className="h-11 w-full bg-linear-to-r from-brand-500 to-brand-700 font-semibold text-white shadow-lg shadow-brand-500/20 transition-all hover:from-brand-600 hover:to-brand-800 hover:shadow-xl hover:shadow-brand-500/25"
|
||||
>
|
||||
재설정 링크 보내기
|
||||
</Button>
|
||||
@@ -74,7 +75,7 @@ export default async function ForgotPasswordPage({
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href={AUTH_ROUTES.LOGIN}
|
||||
className="text-sm font-medium text-gray-700 hover:text-black dark:text-gray-300 dark:hover:text-white"
|
||||
className="text-sm font-medium text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||
>
|
||||
로그인 페이지로 돌아가기
|
||||
</Link>
|
||||
|
||||
@@ -12,17 +12,18 @@ export default async function AuthLayout({
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-linear-to-br from-gray-50 via-white to-gray-100 dark:from-black dark:via-gray-950 dark:to-gray-900">
|
||||
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-linear-to-br from-brand-50 via-white to-brand-100/60 dark:from-brand-950 dark:via-gray-950 dark:to-brand-900/40">
|
||||
{/* ========== 헤더 (홈 이동용) ========== */}
|
||||
<Header user={user} />
|
||||
|
||||
{/* ========== 배경 그라디언트 레이어 ========== */}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,var(--tw-gradient-stops))] from-gray-200/30 via-gray-100/15 to-transparent dark:from-gray-800/30 dark:via-gray-900/20" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,var(--tw-gradient-stops))] from-gray-300/30 via-gray-200/15 to-transparent dark:from-gray-700/30 dark:via-gray-800/20" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,var(--tw-gradient-stops))] from-brand-200/40 via-brand-100/20 to-transparent dark:from-brand-800/25 dark:via-brand-900/15" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,var(--tw-gradient-stops))] from-brand-300/30 via-brand-200/15 to-transparent dark:from-brand-700/20 dark:via-brand-800/10" />
|
||||
|
||||
{/* ========== 애니메이션 블러 효과 ========== */}
|
||||
<div className="absolute left-1/4 top-1/4 h-64 w-64 animate-pulse rounded-full bg-gray-300/20 blur-3xl dark:bg-gray-700/20" />
|
||||
<div className="absolute bottom-1/4 right-1/4 h-64 w-64 animate-pulse rounded-full bg-gray-400/20 blur-3xl delay-700 dark:bg-gray-600/20" />
|
||||
<div className="absolute left-1/4 top-1/4 h-72 w-72 animate-pulse rounded-full bg-brand-300/25 blur-3xl dark:bg-brand-700/15" />
|
||||
<div className="absolute bottom-1/4 right-1/4 h-72 w-72 animate-pulse rounded-full bg-brand-400/20 blur-3xl delay-700 dark:bg-brand-600/15" />
|
||||
<div className="absolute right-1/3 top-1/3 h-48 w-48 animate-pulse rounded-full bg-brand-200/30 blur-2xl delay-1000 dark:bg-brand-800/20" />
|
||||
|
||||
{/* ========== 메인 콘텐츠 영역 (중앙 정렬) ========== */}
|
||||
<main className="z-10 flex w-full flex-1 flex-col items-center justify-center px-4 py-12">
|
||||
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import LoginForm from "@/features/auth/components/login-form";
|
||||
import { LogIn } from "lucide-react";
|
||||
|
||||
/**
|
||||
* [로그인 페이지 컴포넌트]
|
||||
*
|
||||
* Modern UI with glassmorphism effect (유리 형태 디자인)
|
||||
* - 투명 배경 + 블러 효과로 깊이감 표현
|
||||
* - 그라디언트 배경으로 생동감 추가
|
||||
* 브랜드 컬러 기반 글래스모피즘 카드 디자인
|
||||
* - 보라색 그라디언트 아이콘 배지
|
||||
* - shadcn/ui 컴포넌트로 일관된 디자인 시스템 유지
|
||||
*
|
||||
* @param searchParams - URL 쿼리 파라미터 (에러 메시지 전달용)
|
||||
@@ -23,36 +23,25 @@ export default async function LoginPage({
|
||||
}: {
|
||||
searchParams: Promise<{ message: string }>;
|
||||
}) {
|
||||
// URL에서 메시지 파라미터 추출 (로그인 실패 시 에러 메시지 표시)
|
||||
const { message } = await searchParams;
|
||||
|
||||
return (
|
||||
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
{/* 에러/성공 메시지 표시 영역 */}
|
||||
{/* URL 파라미터에 message가 있으면 표시됨 */}
|
||||
<FormMessage message={message} />
|
||||
|
||||
{/* ========== 로그인 카드 (Glassmorphism) ========== */}
|
||||
{/* bg-white/70: 70% 투명도의 흰색 배경 */}
|
||||
{/* backdrop-blur-xl: 배경 블러 효과 (유리 느낌) */}
|
||||
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
||||
{/* ========== 카드 헤더 영역 ========== */}
|
||||
<Card className="border-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/70">
|
||||
<CardHeader className="space-y-3 text-center">
|
||||
{/* 아이콘 배경: 그라디언트 원형 */}
|
||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
|
||||
<span className="text-4xl">👋</span>
|
||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
|
||||
<LogIn className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
{/* 페이지 제목 */}
|
||||
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||
환영합니다!
|
||||
</CardTitle>
|
||||
{/* 페이지 설명 */}
|
||||
<CardDescription className="text-base">
|
||||
서비스 이용을 위해 로그인해 주세요.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
{/* ========== 카드 콘텐츠 영역 (폼) ========== */}
|
||||
<CardContent>
|
||||
<LoginForm />
|
||||
</CardContent>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { KeyRound } from "lucide-react";
|
||||
|
||||
/**
|
||||
* [비밀번호 재설정 페이지]
|
||||
@@ -39,10 +40,10 @@ export default async function ResetPasswordPage({
|
||||
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
{message && <FormMessage message={message} />}
|
||||
|
||||
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
||||
<Card className="border-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/70">
|
||||
<CardHeader className="space-y-3 text-center">
|
||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
|
||||
<span className="text-sm font-semibold">PW</span>
|
||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
|
||||
<KeyRound className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||
비밀번호 재설정
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { UserPlus } from "lucide-react";
|
||||
|
||||
export default async function SignupPage({
|
||||
searchParams,
|
||||
@@ -19,13 +20,12 @@ export default async function SignupPage({
|
||||
|
||||
return (
|
||||
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
{/* 메시지 알림 */}
|
||||
<FormMessage message={message} />
|
||||
|
||||
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
|
||||
<Card className="border-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/70">
|
||||
<CardHeader className="space-y-3 text-center">
|
||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
|
||||
<span className="text-4xl">🚀</span>
|
||||
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
|
||||
<UserPlus className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||
회원가입
|
||||
@@ -35,16 +35,14 @@ export default async function SignupPage({
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
{/* ========== 폼 영역 ========== */}
|
||||
<CardContent className="space-y-6">
|
||||
<SignupForm />
|
||||
|
||||
{/* ========== 로그인 링크 ========== */}
|
||||
<p className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
이미 계정이 있으신가요?{" "}
|
||||
<Link
|
||||
href={AUTH_ROUTES.LOGIN}
|
||||
className="font-semibold text-gray-900 transition-colors hover:text-black dark:text-gray-100 dark:hover:text-white"
|
||||
className="font-semibold text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||
>
|
||||
로그인 하러 가기
|
||||
</Link>
|
||||
|
||||
@@ -1,224 +1,213 @@
|
||||
/**
|
||||
/**
|
||||
* @file app/(home)/page.tsx
|
||||
* @description 서비스 메인 랜딩 페이지
|
||||
* @remarks
|
||||
* - [레이어] Pages (Server Component)
|
||||
* - [역할] 비로그인 유저 유입, 브랜드 소개, 로그인/대시보드 유도
|
||||
* - [구성요소] Header, Hero Section (Spline 3D), Feature Section (Bento Grid)
|
||||
* - [데이터 흐름] Server Auth Check -> Client Component Props
|
||||
* @description 서비스 메인 랜딩 페이지(Server Component)
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { ArrowRight, Sparkles } from "lucide-react";
|
||||
import { Header } from "@/features/layout/components/header";
|
||||
import { AUTH_ROUTES } from "@/features/auth/constants";
|
||||
import { SplineScene } from "@/features/home/components/spline-scene";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ShaderBackground from "@/components/ui/shader-background";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { AnimatedBrandTone } from "@/components/ui/animated-brand-tone";
|
||||
|
||||
interface StartStep {
|
||||
step: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const START_STEPS: StartStep[] = [
|
||||
{
|
||||
step: "01",
|
||||
title: "1분이면 충분해요",
|
||||
description:
|
||||
"복잡한 서류나 방문 없이, 쓰던 계좌 그대로 안전하게 연결할 수 있어요.",
|
||||
},
|
||||
{
|
||||
step: "02",
|
||||
title: "내 스타일대로 골라보세요",
|
||||
description:
|
||||
"공격적인 투자부터 안정적인 관리까지, 나에게 딱 맞는 전략이 준비되어 있어요.",
|
||||
},
|
||||
{
|
||||
step: "03",
|
||||
title: "이제 일상을 즐기세요",
|
||||
description:
|
||||
"차트는 JOORIN-E가 하루 종일 보고 있을게요. 마음 편히 본업에 집중하세요.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 메인 페이지 컴포넌트 (비동기 서버 컴포넌트)
|
||||
* @returns Landing Page Elements
|
||||
* @see layout.tsx - RootLayout 내에서 렌더링
|
||||
* @see spline-scene.tsx - 3D 인터랙션
|
||||
* 홈 메인 랜딩 페이지
|
||||
* @returns 랜딩 UI
|
||||
*/
|
||||
export default async function HomePage() {
|
||||
// [Step 1] 서버 사이드 인증 상태 확인
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
const primaryCtaHref = user ? AUTH_ROUTES.DASHBOARD : AUTH_ROUTES.SIGNUP;
|
||||
const primaryCtaLabel = user ? "시작하기" : "지금 무료로 시작하기";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col overflow-x-hidden">
|
||||
<Header user={user} showDashboardLink={true} />
|
||||
<div className="flex min-h-screen flex-col overflow-x-hidden bg-black text-white selection:bg-brand-500/30">
|
||||
<Header user={user} showDashboardLink={true} blendWithBackground={true} />
|
||||
|
||||
<main className="flex-1 bg-background pt-16">
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute inset-0 -z-10 h-full w-full bg-white dark:bg-black bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-size-[14px_24px] mask-[radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)]" />
|
||||
<main className="relative isolate flex-1">
|
||||
{/* ========== BACKGROUND ========== */}
|
||||
<ShaderBackground opacity={0.6} className="-z-20" />
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 -z-10 bg-black/60"
|
||||
/>
|
||||
|
||||
<section className="container relative mx-auto max-w-7xl px-4 pt-12 md:pt-24 lg:pt-32">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
{/* Badge */}
|
||||
<div className="mb-6 inline-flex items-center rounded-full border border-brand-200/50 bg-brand-50/50 px-3 py-1 text-sm font-medium text-brand-600 backdrop-blur-md dark:border-brand-800/50 dark:bg-brand-900/50 dark:text-brand-300">
|
||||
<span className="mr-2 flex h-2 w-2 animate-pulse rounded-full bg-brand-500"></span>
|
||||
The Future of Trading
|
||||
</div>
|
||||
{/* ========== HERO SECTION ========== */}
|
||||
<section className="container mx-auto max-w-5xl px-4 pt-32 md:pt-48">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<span className="inline-flex animate-in fade-in-0 slide-in-from-top-2 items-center gap-2 rounded-full border border-brand-400/30 bg-white/5 px-4 py-1.5 text-xs font-medium tracking-wide text-brand-200 backdrop-blur-md duration-1000">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
자동 매매의 새로운 기준, JOORIN-E
|
||||
</span>
|
||||
|
||||
<h1 className="font-heading text-4xl font-black tracking-tight text-foreground sm:text-6xl md:text-7xl lg:text-8xl">
|
||||
투자의 미래를 <br className="hidden sm:block" />
|
||||
<span className="animate-gradient-x bg-linear-to-r from-brand-500 via-brand-600 to-brand-700 bg-clip-text text-transparent underline decoration-brand-500/30 decoration-4 underline-offset-8">
|
||||
자동화하세요
|
||||
<h1 className="mt-8 animate-in slide-in-from-bottom-4 text-5xl font-black tracking-tight text-white duration-1000 md:text-8xl">
|
||||
주식, 이제는
|
||||
<br />
|
||||
<span className="bg-linear-to-b from-brand-300 via-brand-200 to-brand-500 bg-clip-text text-transparent">
|
||||
마음 편하게 하세요.
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="mt-6 max-w-2xl text-lg text-muted-foreground sm:text-xl leading-relaxed break-keep">
|
||||
AutoTrade는 최첨단 알고리즘을 통해 24시간 암호화폐 시장을
|
||||
분석합니다.
|
||||
<p className="mt-8 max-w-2xl animate-in slide-in-from-bottom-4 text-sm leading-relaxed text-white/60 duration-1000 md:text-xl">
|
||||
어렵고 불안한 주식 투자, 혼자 고민하지 마세요.
|
||||
<br className="hidden md:block" />
|
||||
감정 없는 데이터 기반 투자로 안정적인 수익을 경험해보세요.
|
||||
검증된 원칙으로 24시간 당신의 자산을 지켜드릴게요.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-col items-center gap-4 sm:flex-row">
|
||||
{user ? (
|
||||
<div className="mt-12 flex animate-in slide-in-from-bottom-6 flex-col gap-4 duration-1000 sm:flex-row">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="h-14 rounded-full px-10 text-lg font-bold shadow-xl shadow-brand-500/20 transition-all hover:scale-105 hover:shadow-brand-500/40"
|
||||
className="group h-14 min-w-[200px] rounded-full bg-brand-500 px-10 text-lg font-bold text-white transition-all hover:scale-105 hover:bg-brand-400 active:scale-95"
|
||||
>
|
||||
<Link href={AUTH_ROUTES.DASHBOARD}>대시보드 바로가기</Link>
|
||||
<Link href={primaryCtaHref}>
|
||||
{primaryCtaLabel}
|
||||
<ArrowRight className="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="h-14 rounded-full px-10 text-lg font-bold shadow-xl shadow-brand-500/20 transition-all hover:scale-105 hover:shadow-brand-500/40"
|
||||
>
|
||||
<Link href={AUTH_ROUTES.LOGIN}>무료로 시작하기</Link>
|
||||
</Button>
|
||||
)}
|
||||
{!user && (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="h-14 rounded-full border-border bg-background/50 px-10 text-lg hover:bg-accent/50 backdrop-blur-sm"
|
||||
>
|
||||
<Link href={AUTH_ROUTES.LOGIN}>데모 체험하기</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== BRAND TONE SECTION (움직이는 글자) ========== */}
|
||||
<section className="container mx-auto max-w-5xl px-4 py-24 md:py-40">
|
||||
<AnimatedBrandTone />
|
||||
</section>
|
||||
|
||||
{/* ========== SIMPLE STEPS SECTION ========== */}
|
||||
<section className="container mx-auto max-w-5xl px-4 py-24">
|
||||
<div className="flex flex-col items-center gap-16 md:flex-row md:items-start">
|
||||
<div className="flex-1 text-center md:text-left">
|
||||
<h2 className="text-3xl font-black md:text-5xl">
|
||||
설계부터 실행까지
|
||||
<br />
|
||||
<span className="text-brand-300">단 3단계면 끝.</span>
|
||||
</h2>
|
||||
<p className="mt-6 text-sm leading-relaxed text-white/50 md:text-lg">
|
||||
복잡한 계산과 감시는 JOORIN-E가 대신할게요.
|
||||
<br />
|
||||
당신은 가벼운 마음으로 '시작' 버튼만 누르세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Spline Scene - Centered & Wide */}
|
||||
<div className="relative mt-16 w-full max-w-5xl">
|
||||
<div className="group relative aspect-video w-full overflow-hidden rounded-3xl border border-white/20 bg-linear-to-b from-white/10 to-transparent shadow-2xl backdrop-blur-2xl dark:border-white/10 dark:bg-black/20">
|
||||
{/* Glow Effect */}
|
||||
<div className="absolute -inset-1 rounded-3xl bg-linear-to-r from-brand-500 via-brand-600 to-brand-700 opacity-20 blur-2xl transition-opacity duration-500 group-hover:opacity-40" />
|
||||
<div className="flex-2 grid w-full gap-4 md:grid-cols-1">
|
||||
{START_STEPS.map((item) => (
|
||||
<div
|
||||
key={item.step}
|
||||
className="group flex items-center gap-6 rounded-2xl border border-white/5 bg-white/5 p-6 transition-all hover:bg-white/10"
|
||||
>
|
||||
<span className="text-3xl font-black text-brand-500/50 group-hover:text-brand-500">
|
||||
{item.step}
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-white/50">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SplineScene
|
||||
sceneUrl="https://prod.spline.design/6Wq1Q7YGyM-iab9i/scene.splinecode"
|
||||
className="relative z-10 h-full w-full rounded-2xl"
|
||||
/>
|
||||
{/* 보안 안심 문구 (사용자 요청 반영) */}
|
||||
<div className="mt-16 flex flex-col items-center justify-center text-center animate-in slide-in-from-bottom-6 duration-1000 delay-300">
|
||||
<div className="flex max-w-2xl flex-col items-center gap-4 rounded-2xl border border-brand-500/20 bg-brand-500/5 p-8 backdrop-blur-sm md:flex-row md:gap-8 md:text-left">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-brand-500/10 text-brand-400">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="lucide lucide-shield-check"
|
||||
>
|
||||
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-brand-100">
|
||||
내 계좌 정보, 서버에 저장되지 않나요?
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-brand-200/70">
|
||||
<strong className="text-brand-200">
|
||||
네, 절대 저장하지 않으니 안심하세요.
|
||||
</strong>
|
||||
<br />
|
||||
JOORIN-E는 여러분의 계좌 비밀번호와 API 키를 서버로 전송하지
|
||||
않습니다.
|
||||
<br className="hidden md:block" />
|
||||
모든 중요 정보는 여러분의 기기(브라우저)에만 암호화되어
|
||||
저장되며,
|
||||
<br className="hidden md:block" />
|
||||
매매 실행 시에만 증권사와 직접 통신하는 데 사용됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section - Bento Grid */}
|
||||
<section className="container mx-auto max-w-7xl px-4 py-24 sm:py-32">
|
||||
<div className="mb-16 text-center">
|
||||
<h2 className="font-heading text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">
|
||||
강력한 기능,{" "}
|
||||
<span className="text-brand-500">직관적인 경험</span>
|
||||
{/* ========== FINAL CTA SECTION ========== */}
|
||||
<section className="container mx-auto max-w-5xl px-4 py-32">
|
||||
<div className="relative overflow-hidden rounded-[2.5rem] border border-brand-500/20 bg-linear-to-b from-brand-500/10 to-transparent p-12 text-center md:p-24">
|
||||
<h2 className="text-3xl font-black md:text-6xl">
|
||||
더 이상 미루지 마세요.
|
||||
<br />
|
||||
지금 바로 경험해보세요.
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
성공적인 투자를 위해 필요한 모든 도구가 준비되어 있습니다.
|
||||
</p>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="h-16 rounded-full bg-white px-12 text-xl font-black text-black transition-all hover:scale-110 active:scale-95"
|
||||
>
|
||||
<Link href={primaryCtaHref}>{primaryCtaLabel}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 md:gap-8">
|
||||
{/* Feature 1 */}
|
||||
<div className="group relative col-span-1 overflow-hidden rounded-3xl border border-border/50 bg-background/50 p-8 shadow-sm transition-all hover:shadow-xl hover:-translate-y-1 dark:bg-zinc-900/50 md:col-span-2">
|
||||
<div className="relative z-10 flex flex-col justify-between h-full gap-6">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-brand-50 text-brand-600 dark:bg-brand-900/20 dark:text-brand-300">
|
||||
<svg
|
||||
className="w-8 h-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold mb-2">실시간 모니터링</h3>
|
||||
<p className="text-muted-foreground text-lg leading-relaxed">
|
||||
초당 수천 건의 트랜잭션을 실시간으로 분석합니다.
|
||||
<br />
|
||||
시장 변동성을 놓치지 않고 최적의 진입 시점을 포착하세요.
|
||||
<p className="mt-8 text-sm text-white/30">
|
||||
© 2026 POPUP STUDIO. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-0 right-0 h-64 w-64 translate-x-1/2 -translate-y-1/2 rounded-full bg-brand-500/10 blur-3xl transition-all duration-500 group-hover:bg-brand-500/20" />
|
||||
</div>
|
||||
|
||||
{/* Feature 2 (Tall) */}
|
||||
<div className="group relative col-span-1 row-span-2 overflow-hidden rounded-3xl border border-border/50 bg-background/50 p-8 shadow-sm transition-all hover:shadow-xl hover:-translate-y-1 dark:bg-zinc-900/50">
|
||||
<div className="relative z-10 flex flex-col h-full gap-6">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-brand-50 text-brand-600 dark:bg-brand-900/20 dark:text-brand-300">
|
||||
<svg
|
||||
className="w-8 h-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19.428 15.428a2 2 0 00-1.022-.547l-2.384-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold">알고리즘 트레이딩</h3>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
24시간 멈추지 않는 자동 매매 시스템입니다.
|
||||
</p>
|
||||
<div className="mt-auto space-y-4 pt-4">
|
||||
{[
|
||||
"추세 추종 전략",
|
||||
"변동성 돌파",
|
||||
"AI 예측 모델",
|
||||
"리스크 관리",
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="flex items-center gap-3 text-sm font-medium text-foreground/80"
|
||||
>
|
||||
<div className="h-2 w-2 rounded-full bg-brand-500" />
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 h-64 w-64 -translate-x-1/2 translate-y-1/2 rounded-full bg-brand-500/10 blur-3xl transition-all duration-500 group-hover:bg-brand-500/20" />
|
||||
</div>
|
||||
|
||||
{/* Feature 3 */}
|
||||
<div className="group relative col-span-1 overflow-hidden rounded-3xl border border-border/50 bg-background/50 p-8 shadow-sm transition-all hover:shadow-xl hover:-translate-y-1 dark:bg-zinc-900/50 md:col-span-2">
|
||||
<div className="relative z-10 flex flex-col justify-between h-full gap-6">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-brand-50 text-brand-600 dark:bg-brand-900/20 dark:text-brand-300">
|
||||
<svg
|
||||
className="w-8 h-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold mb-2">스마트 포트폴리오</h3>
|
||||
<p className="text-muted-foreground text-lg leading-relaxed">
|
||||
목표 수익률 달성 시 자동으로 이익을 실현하고, MDD를
|
||||
최소화하여
|
||||
<br />
|
||||
시장이 하락할 때도 당신의 자산을 안전하게 지킵니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute right-0 bottom-0 h-40 w-40 translate-x-1/3 translate-y-1/3 rounded-full bg-brand-500/10 blur-3xl transition-all duration-500 group-hover:bg-brand-500/20" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,115 +1,25 @@
|
||||
/**
|
||||
* @file app/(main)/dashboard/page.tsx
|
||||
* @description 사용자 대시보드 메인 페이지 (보호된 라우트)
|
||||
* @remarks
|
||||
* - [레이어] Pages (Server Component)
|
||||
* - [역할] 사용자 자산 현황, 최근 활동, 통계 요약을 보여줌
|
||||
* - [권한] 로그인한 사용자만 접근 가능 (Middleware에 의해 보호됨)
|
||||
* @description 로그인 사용자 전용 대시보드 페이지(Server Component)
|
||||
*/
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { DashboardContainer } from "@/features/dashboard/components/DashboardContainer";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Activity, CreditCard, DollarSign, Users } from "lucide-react";
|
||||
|
||||
/**
|
||||
* 대시보드 페이지 (비동기 서버 컴포넌트)
|
||||
* @returns Dashboard Grid Layout
|
||||
* 대시보드 페이지
|
||||
* @returns DashboardContainer UI
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 대시보드 상태 헤더/지수/보유종목 UI를 제공합니다.
|
||||
*/
|
||||
export default async function DashboardPage() {
|
||||
// [Step 1] 세션 확인 (Middleware가 1차 방어하지만, 데이터 접근 시 2차 확인)
|
||||
// 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다.
|
||||
const supabase = await createClient();
|
||||
await supabase.auth.getUser();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">대시보드</h2>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">총 수익</CardTitle>
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">$45,231.89</div>
|
||||
<p className="text-xs text-muted-foreground">지난달 대비 +20.1%</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">구독자</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">+2350</div>
|
||||
<p className="text-xs text-muted-foreground">지난달 대비 +180.1%</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">판매량</CardTitle>
|
||||
<CreditCard className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">+12,234</div>
|
||||
<p className="text-xs text-muted-foreground">지난달 대비 +19%</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">현재 활동 중</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">+573</div>
|
||||
<p className="text-xs text-muted-foreground">지난 시간 대비 +201</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-7">
|
||||
<Card className="col-span-4">
|
||||
<CardHeader>
|
||||
<CardTitle>개요</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pl-2">
|
||||
{/* Chart placeholder */}
|
||||
<div className="h-[200px] w-full bg-slate-100 dark:bg-slate-800 rounded-md flex items-center justify-center text-muted-foreground">
|
||||
차트 영역 (준비 중)
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>최근 활동</CardTitle>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
이번 달 265건의 거래가 있었습니다.
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center">
|
||||
<div className="ml-4 space-y-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
비트코인 매수
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">BTC/USDT</p>
|
||||
</div>
|
||||
<div className="ml-auto font-medium">+$1,999.00</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="ml-4 space-y-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
이더리움 매도
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">ETH/USDT</p>
|
||||
</div>
|
||||
<div className="ml-auto font-medium">+$39.00</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (!user) redirect("/login");
|
||||
|
||||
return <DashboardContainer />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Header } from "@/features/layout/components/header";
|
||||
import { Sidebar } from "@/features/layout/components/sidebar";
|
||||
import { MobileBottomNav, Sidebar } from "@/features/layout/components/sidebar";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
|
||||
export default async function MainLayout({
|
||||
@@ -13,12 +13,13 @@ export default async function MainLayout({
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-zinc-50 dark:bg-black">
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<Header user={user} />
|
||||
<div className="flex flex-1">
|
||||
<div className="flex flex-1 pt-16">
|
||||
<Sidebar />
|
||||
<main className="flex-1 w-full p-6 md:p-8 lg:p-10">{children}</main>
|
||||
<main className="min-w-0 flex-1 pb-20 md:pb-0">{children}</main>
|
||||
</div>
|
||||
<MobileBottomNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
26
app/(main)/settings/page.tsx
Normal file
26
app/(main)/settings/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @file app/(main)/settings/page.tsx
|
||||
* @description 로그인 사용자 전용 설정 페이지(Server Component)
|
||||
*/
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { SettingsContainer } from "@/features/settings/components/SettingsContainer";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
|
||||
/**
|
||||
* 설정 페이지
|
||||
* @returns SettingsContainer UI
|
||||
* @see features/settings/components/SettingsContainer.tsx KIS 인증 설정 UI를 제공합니다.
|
||||
*/
|
||||
export default async function SettingsPage() {
|
||||
// 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다.
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) redirect("/login");
|
||||
|
||||
return <SettingsContainer />;
|
||||
}
|
||||
|
||||
26
app/(main)/trade/page.tsx
Normal file
26
app/(main)/trade/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @file app/(main)/trade/page.tsx
|
||||
* @description 로그인 사용자 전용 트레이딩 페이지(Server Component)
|
||||
*/
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { TradeContainer } from "@/features/trade/components/TradeContainer";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
|
||||
/**
|
||||
* 트레이딩 페이지
|
||||
* @returns TradeContainer UI
|
||||
* @see features/trade/components/TradeContainer.tsx 종목 검색/차트/호가/주문 기능을 제공합니다.
|
||||
*/
|
||||
export default async function TradePage() {
|
||||
// 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다.
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) redirect("/login");
|
||||
|
||||
return <TradeContainer />;
|
||||
}
|
||||
|
||||
56
app/api/kis/_response.ts
Normal file
56
app/api/kis/_response.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { KisTradingEnv } from "@/features/trade/types/trade.types";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const KIS_API_ERROR_CODE = {
|
||||
AUTH_REQUIRED: "KIS_AUTH_REQUIRED",
|
||||
INVALID_REQUEST: "KIS_INVALID_REQUEST",
|
||||
CREDENTIAL_REQUIRED: "KIS_CREDENTIAL_REQUIRED",
|
||||
ACCOUNT_REQUIRED: "KIS_ACCOUNT_REQUIRED",
|
||||
UPSTREAM_FAILURE: "KIS_UPSTREAM_FAILURE",
|
||||
UNAUTHORIZED: "KIS_UNAUTHORIZED",
|
||||
} as const;
|
||||
|
||||
export type KisApiErrorCode =
|
||||
(typeof KIS_API_ERROR_CODE)[keyof typeof KIS_API_ERROR_CODE];
|
||||
|
||||
interface CreateKisApiErrorResponseOptions {
|
||||
status: number;
|
||||
code: KisApiErrorCode;
|
||||
message: string;
|
||||
tradingEnv?: KisTradingEnv;
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description KIS API 라우트용 표준 에러 응답을 생성합니다.
|
||||
* @remarks 클라이언트 하위호환을 위해 message/error 키를 동시에 제공합니다.
|
||||
* @see features/trade/apis/kis-stock.api.ts 종목 API 클라이언트는 error 우선 파싱
|
||||
* @see features/settings/apis/kis-auth.api.ts 인증 API 클라이언트는 message 우선 파싱
|
||||
*/
|
||||
export function createKisApiErrorResponse({
|
||||
status,
|
||||
code,
|
||||
message,
|
||||
tradingEnv,
|
||||
extra,
|
||||
}: CreateKisApiErrorResponseOptions) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
message,
|
||||
error: message,
|
||||
errorCode: code,
|
||||
...(tradingEnv ? { tradingEnv } : {}),
|
||||
...(extra ?? {}),
|
||||
},
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description unknown 에러 객체를 사용자 노출용 메시지로 정규화합니다.
|
||||
* @see app/api/kis/domestic/balance/route.ts 서버 예외를 공통 메시지로 변환
|
||||
*/
|
||||
export function toKisApiErrorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
18
app/api/kis/_session.ts
Normal file
18
app/api/kis/_session.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
|
||||
/**
|
||||
* @description KIS API 라우트 접근 전에 Supabase 로그인 세션을 검증합니다.
|
||||
* @returns 로그인 세션 존재 여부
|
||||
* @remarks UI 흐름: 클라이언트 요청 -> KIS API route -> hasKisApiSession -> (실패 시 401, 성공 시 KIS 호출)
|
||||
* @see app/api/kis/domestic/balance/route.ts 잔고 API 세션 가드
|
||||
* @see app/api/kis/validate/route.ts 인증 검증 API 세션 가드
|
||||
*/
|
||||
export async function hasKisApiSession() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
return Boolean(!error && user);
|
||||
}
|
||||
39
app/api/kis/domestic/_shared.ts
Normal file
39
app/api/kis/domestic/_shared.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { parseKisAccountParts } from "@/lib/kis/account";
|
||||
import {
|
||||
normalizeTradingEnv,
|
||||
type KisCredentialInput,
|
||||
} from "@/lib/kis/config";
|
||||
|
||||
/**
|
||||
* @description 요청 헤더에서 KIS 키를 읽어옵니다.
|
||||
* @param headers 요청 헤더
|
||||
* @returns KIS 인증 입력값
|
||||
* @see app/api/kis/domestic/balance/route.ts 대시보드 잔고 API 인증키 파싱
|
||||
* @see app/api/kis/domestic/indices/route.ts 대시보드 지수 API 인증키 파싱
|
||||
*/
|
||||
export function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput {
|
||||
const appKey = headers.get("x-kis-app-key")?.trim();
|
||||
const appSecret = headers.get("x-kis-app-secret")?.trim();
|
||||
const tradingEnv = normalizeTradingEnv(
|
||||
headers.get("x-kis-trading-env") ?? undefined,
|
||||
);
|
||||
|
||||
return {
|
||||
appKey,
|
||||
appSecret,
|
||||
tradingEnv,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 요청 헤더에서 계좌번호(8-2)를 읽어옵니다.
|
||||
* @param headers 요청 헤더
|
||||
* @returns 계좌번호 파트(8 + 2) 또는 null
|
||||
* @see app/api/kis/domestic/balance/route.ts 잔고 조회 시 필수 계좌정보 파싱
|
||||
*/
|
||||
export function readKisAccountParts(headers: Headers) {
|
||||
const headerAccountNo = headers.get("x-kis-account-no");
|
||||
const headerAccountProductCode = headers.get("x-kis-account-product-code");
|
||||
|
||||
return parseKisAccountParts(headerAccountNo, headerAccountProductCode);
|
||||
}
|
||||
85
app/api/kis/domestic/activity/route.ts
Normal file
85
app/api/kis/domestic/activity/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { DashboardActivityResponse } from "@/features/dashboard/types/dashboard.types";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import { getDomesticDashboardActivity } from "@/lib/kis/dashboard";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
import {
|
||||
readKisAccountParts,
|
||||
readKisCredentialsFromHeaders,
|
||||
} from "@/app/api/kis/domestic/_shared";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/domestic/activity/route.ts
|
||||
* @description 국내주식 주문내역/매매일지 조회 API
|
||||
*/
|
||||
|
||||
/**
|
||||
* 대시보드 하단(주문내역/매매일지) 조회 API
|
||||
* @returns 주문내역 목록 + 매매일지 목록/요약
|
||||
* @remarks UI 흐름: DashboardContainer -> useDashboardData -> /api/kis/domestic/activity -> ActivitySection 렌더링
|
||||
* @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 초기 로드/새로고침에서 호출합니다.
|
||||
* @see features/dashboard/components/ActivitySection.tsx 주문내역/매매일지 섹션 렌더링
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||
message: "KIS API 키 설정이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const account = readKisAccountParts(request.headers);
|
||||
if (!account) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
|
||||
message:
|
||||
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getDomesticDashboardActivity(account, credentials);
|
||||
const response: DashboardActivityResponse = {
|
||||
source: "kis",
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
orders: result.orders,
|
||||
tradeJournal: result.tradeJournal,
|
||||
journalSummary: result.journalSummary,
|
||||
warnings: result.warnings,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 500,
|
||||
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||
message: toKisApiErrorMessage(
|
||||
error,
|
||||
"주문내역/매매일지 조회 중 오류가 발생했습니다.",
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
78
app/api/kis/domestic/balance/route.ts
Normal file
78
app/api/kis/domestic/balance/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { DashboardBalanceResponse } from "@/features/dashboard/types/dashboard.types";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import { getDomesticDashboardBalance } from "@/lib/kis/dashboard";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
import {
|
||||
readKisAccountParts,
|
||||
readKisCredentialsFromHeaders,
|
||||
} from "@/app/api/kis/domestic/_shared";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/domestic/balance/route.ts
|
||||
* @description 국내주식 계좌 잔고/보유종목 조회 API
|
||||
*/
|
||||
|
||||
/**
|
||||
* 대시보드 잔고 조회 API
|
||||
* @returns 총자산/손익/보유종목 목록
|
||||
* @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 초기 로드/새로고침에서 호출합니다.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||
message: "KIS API 키 설정이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const account = readKisAccountParts(request.headers);
|
||||
if (!account) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
|
||||
message:
|
||||
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getDomesticDashboardBalance(account, credentials);
|
||||
const response: DashboardBalanceResponse = {
|
||||
source: "kis",
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
summary: result.summary,
|
||||
holdings: result.holdings,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 500,
|
||||
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||
message: toKisApiErrorMessage(error, "잔고 조회 중 오류가 발생했습니다."),
|
||||
});
|
||||
}
|
||||
}
|
||||
100
app/api/kis/domestic/chart/route.ts
Normal file
100
app/api/kis/domestic/chart/route.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type {
|
||||
DashboardChartTimeframe,
|
||||
DashboardStockChartResponse,
|
||||
} from "@/features/trade/types/trade.types";
|
||||
import { hasKisConfig } from "@/lib/kis/config";
|
||||
import { getDomesticChart } from "@/lib/kis/domestic";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
|
||||
|
||||
const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
|
||||
"1m",
|
||||
"30m",
|
||||
"1h",
|
||||
"1d",
|
||||
"1w",
|
||||
];
|
||||
|
||||
/**
|
||||
* @file app/api/kis/domestic/chart/route.ts
|
||||
* @description 국내주식 차트(분봉/일봉/주봉) 조회 API
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const symbol = (searchParams.get("symbol") ?? "").trim();
|
||||
const timeframe = (
|
||||
searchParams.get("timeframe") ?? "1d"
|
||||
).trim() as DashboardChartTimeframe;
|
||||
const cursor = (searchParams.get("cursor") ?? "").trim() || undefined;
|
||||
|
||||
if (!/^\d{6}$/.test(symbol)) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: "symbol은 6자리 숫자여야 합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!VALID_TIMEFRAMES.includes(timeframe)) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: "지원하지 않는 timeframe입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||
message:
|
||||
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 차트를 조회할 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const chart = await getDomesticChart(
|
||||
symbol,
|
||||
timeframe,
|
||||
credentials,
|
||||
cursor,
|
||||
);
|
||||
|
||||
const response: DashboardStockChartResponse = {
|
||||
symbol,
|
||||
timeframe,
|
||||
candles: chart.candles,
|
||||
nextCursor: chart.nextCursor,
|
||||
hasMore: chart.hasMore,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 500,
|
||||
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||
message: toKisApiErrorMessage(error, "KIS 차트 조회 중 오류가 발생했습니다."),
|
||||
});
|
||||
}
|
||||
}
|
||||
64
app/api/kis/domestic/indices/route.ts
Normal file
64
app/api/kis/domestic/indices/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { DashboardIndicesResponse } from "@/features/dashboard/types/dashboard.types";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import { getDomesticDashboardIndices } from "@/lib/kis/dashboard";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/domestic/indices/route.ts
|
||||
* @description 국내 주요 지수(KOSPI/KOSDAQ) 조회 API
|
||||
*/
|
||||
|
||||
/**
|
||||
* 대시보드 지수 조회 API
|
||||
* @returns 코스피/코스닥 지수 목록
|
||||
* @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 초기 로드/주기 갱신에서 호출합니다.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||
message: "KIS API 키 설정이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const items = await getDomesticDashboardIndices(credentials);
|
||||
const response: DashboardIndicesResponse = {
|
||||
source: "kis",
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
items,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 500,
|
||||
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||
message: toKisApiErrorMessage(error, "지수 조회 중 오류가 발생했습니다."),
|
||||
});
|
||||
}
|
||||
}
|
||||
168
app/api/kis/domestic/order-cash/route.ts
Normal file
168
app/api/kis/domestic/order-cash/route.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { executeOrderCash } from "@/lib/kis/trade";
|
||||
import {
|
||||
DashboardStockCashOrderResponse,
|
||||
} from "@/features/trade/types/trade.types";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import { parseKisAccountParts } from "@/lib/kis/account";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/domestic/order-cash/route.ts
|
||||
* @description 국내주식 현금 주문 API
|
||||
*/
|
||||
|
||||
const orderCashBodySchema = z
|
||||
.object({
|
||||
symbol: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^\d{6}$/, "종목코드는 6자리 숫자여야 합니다."),
|
||||
side: z.enum(["buy", "sell"], {
|
||||
message: "주문 구분(side)은 buy/sell만 허용됩니다.",
|
||||
}),
|
||||
orderType: z.enum(["limit", "market"], {
|
||||
message: "주문 유형(orderType)은 limit/market만 허용됩니다.",
|
||||
}),
|
||||
quantity: z.coerce
|
||||
.number()
|
||||
.int("주문수량은 정수여야 합니다.")
|
||||
.positive("주문수량은 1주 이상이어야 합니다."),
|
||||
price: z.coerce.number(),
|
||||
accountNo: z.string().trim().min(1, "계좌번호를 입력해 주세요."),
|
||||
accountProductCode: z.string().trim().optional(),
|
||||
})
|
||||
.superRefine((body, ctx) => {
|
||||
if (body.orderType === "limit" && body.price <= 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["price"],
|
||||
message: "지정가 주문은 주문가격이 0보다 커야 합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (body.orderType === "market" && body.price < 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["price"],
|
||||
message: "시장가 주문은 주문가격이 0 이상이어야 합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const accountParts = parseKisAccountParts(
|
||||
body.accountNo,
|
||||
body.accountProductCode,
|
||||
);
|
||||
if (!accountParts) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["accountNo"],
|
||||
message:
|
||||
"계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
|
||||
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||
message: "KIS API 키 설정이 필요합니다.",
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
let rawBody: unknown = {};
|
||||
try {
|
||||
rawBody = (await request.json()) as unknown;
|
||||
} catch {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: "요청 본문(JSON)을 읽을 수 없습니다.",
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = orderCashBodySchema.safeParse(rawBody);
|
||||
|
||||
if (!parsed.success) {
|
||||
const firstIssue = parsed.error.issues[0];
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: firstIssue?.message ?? "주문 요청 값이 올바르지 않습니다.",
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
const body = parsed.data;
|
||||
const accountParts = parseKisAccountParts(
|
||||
body.accountNo,
|
||||
body.accountProductCode,
|
||||
);
|
||||
|
||||
if (!accountParts) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
|
||||
message:
|
||||
"계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.",
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
const output = await executeOrderCash(
|
||||
{
|
||||
symbol: body.symbol,
|
||||
side: body.side,
|
||||
orderType: body.orderType,
|
||||
quantity: body.quantity,
|
||||
price: body.price,
|
||||
accountNo: accountParts.accountNo,
|
||||
accountProductCode: accountParts.accountProductCode,
|
||||
},
|
||||
credentials,
|
||||
);
|
||||
|
||||
const response: DashboardStockCashOrderResponse = {
|
||||
ok: true,
|
||||
tradingEnv,
|
||||
message: "주문이 전송되었습니다.",
|
||||
orderNo: output.ODNO,
|
||||
orderTime: output.ORD_TMD,
|
||||
orderOrgNo: output.KRX_FWDG_ORD_ORGNO,
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 500,
|
||||
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||
message: toKisApiErrorMessage(error, "주문 전송 중 오류가 발생했습니다."),
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
}
|
||||
163
app/api/kis/domestic/orderbook/route.ts
Normal file
163
app/api/kis/domestic/orderbook/route.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
getDomesticOrderBook,
|
||||
KisDomesticOrderBookOutput,
|
||||
} from "@/lib/kis/domestic";
|
||||
import { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import {
|
||||
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
|
||||
parseDomesticKisSession,
|
||||
} from "@/lib/kis/domestic-market-session";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/domestic/orderbook/route.ts
|
||||
* @description 국내주식 호가 조회 API
|
||||
*/
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const symbol = (searchParams.get("symbol") ?? "").trim();
|
||||
|
||||
if (!/^\d{6}$/.test(symbol)) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: "symbol은 6자리 숫자여야 합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||
message: "KIS API 키 설정이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionOverride = readSessionOverrideFromHeaders(request.headers);
|
||||
const raw = await getDomesticOrderBook(symbol, credentials, {
|
||||
sessionOverride,
|
||||
});
|
||||
|
||||
const levels = Array.from({ length: 10 }, (_, i) => {
|
||||
const idx = i + 1;
|
||||
return {
|
||||
askPrice: readOrderBookNumber(raw, `askp${idx}`, `ovtm_untp_askp${idx}`),
|
||||
bidPrice: readOrderBookNumber(raw, `bidp${idx}`, `ovtm_untp_bidp${idx}`),
|
||||
askSize: readOrderBookNumber(
|
||||
raw,
|
||||
`askp_rsqn${idx}`,
|
||||
`ovtm_untp_askp_rsqn${idx}`,
|
||||
),
|
||||
bidSize: readOrderBookNumber(raw, ...resolveBidSizeKeys(idx)),
|
||||
};
|
||||
});
|
||||
|
||||
const response: DashboardStockOrderBookResponse = {
|
||||
symbol,
|
||||
source: "kis",
|
||||
levels,
|
||||
totalAskSize: readOrderBookNumber(
|
||||
raw,
|
||||
"total_askp_rsqn",
|
||||
"ovtm_untp_total_askp_rsqn",
|
||||
"ovtm_total_askp_rsqn",
|
||||
),
|
||||
totalBidSize: readOrderBookNumber(
|
||||
raw,
|
||||
"total_bidp_rsqn",
|
||||
"ovtm_untp_total_bidp_rsqn",
|
||||
"ovtm_total_bidp_rsqn",
|
||||
),
|
||||
businessHour: readOrderBookString(raw, "bsop_hour", "ovtm_untp_last_hour"),
|
||||
hourClassCode: readOrderBookString(raw, "hour_cls_code"),
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 500,
|
||||
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||
message: toKisApiErrorMessage(error, "호가 조회 중 오류가 발생했습니다."),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function readSessionOverrideFromHeaders(headers: Headers) {
|
||||
if (process.env.NODE_ENV === "production") return null;
|
||||
const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER);
|
||||
return parseDomesticKisSession(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 호가 응답 필드를 대소문자 모두 허용해 숫자로 읽습니다.
|
||||
* @see app/api/kis/domestic/orderbook/route.ts GET에서 output/output1 키 차이 방어 로직으로 사용합니다.
|
||||
*/
|
||||
function readOrderBookNumber(raw: KisDomesticOrderBookOutput, ...keys: string[]) {
|
||||
const record = raw as Record<string, unknown>;
|
||||
const value = resolveOrderBookValue(record, keys) ?? "0";
|
||||
const normalized =
|
||||
typeof value === "string"
|
||||
? value.replaceAll(",", "").trim()
|
||||
: String(value ?? "0");
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 호가 응답 필드를 문자열로 읽습니다.
|
||||
* @see app/api/kis/domestic/orderbook/route.ts GET 응답 생성 시 businessHour/hourClassCode 추출
|
||||
*/
|
||||
function readOrderBookString(raw: KisDomesticOrderBookOutput, ...keys: string[]) {
|
||||
const record = raw as Record<string, unknown>;
|
||||
const value = resolveOrderBookValue(record, keys);
|
||||
if (value === undefined || value === null) return undefined;
|
||||
const text = String(value).trim();
|
||||
return text.length > 0 ? text : undefined;
|
||||
}
|
||||
|
||||
function resolveOrderBookValue(record: Record<string, unknown>, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
const direct = record[key];
|
||||
if (direct !== undefined && direct !== null) return direct;
|
||||
|
||||
const upper = record[key.toUpperCase()];
|
||||
if (upper !== undefined && upper !== null) return upper;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveBidSizeKeys(index: number) {
|
||||
if (index === 2) {
|
||||
return [`bidp_rsqn${index}`, `ovtm_untp_bidp_rsqn${index}`, "ovtm_untp_bidp_rsqn"];
|
||||
}
|
||||
|
||||
return [`bidp_rsqn${index}`, `ovtm_untp_bidp_rsqn${index}`];
|
||||
}
|
||||
98
app/api/kis/domestic/overview/route.ts
Normal file
98
app/api/kis/domestic/overview/route.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks";
|
||||
import type { DashboardStockOverviewResponse } from "@/features/trade/types/trade.types";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import { getDomesticOverview } from "@/lib/kis/domestic";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
import {
|
||||
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
|
||||
parseDomesticKisSession,
|
||||
} from "@/lib/kis/domestic-market-session";
|
||||
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/domestic/overview/route.ts
|
||||
* @description 국내주식 종목 상세(현재가 + 차트) API
|
||||
*/
|
||||
|
||||
/**
|
||||
* 국내주식 종목 상세 API
|
||||
* @param request query string의 symbol(6자리 종목코드) 사용
|
||||
* @returns 대시보드 상세 모델
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const symbol = (searchParams.get("symbol") ?? "").trim();
|
||||
|
||||
if (!/^\d{6}$/.test(symbol)) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: "symbol은 6자리 숫자여야 합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||
|
||||
if (!hasKisConfig(credentials)) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
|
||||
message:
|
||||
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 시세를 조회할 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const fallbackMeta = KOREAN_STOCK_INDEX.find((item) => item.symbol === symbol);
|
||||
|
||||
try {
|
||||
const sessionOverride = readSessionOverrideFromHeaders(request.headers);
|
||||
const overview = await getDomesticOverview(
|
||||
symbol,
|
||||
fallbackMeta,
|
||||
credentials,
|
||||
{ sessionOverride },
|
||||
);
|
||||
|
||||
const response: DashboardStockOverviewResponse = {
|
||||
stock: overview.stock,
|
||||
source: "kis",
|
||||
priceSource: overview.priceSource,
|
||||
marketPhase: overview.marketPhase,
|
||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 500,
|
||||
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
|
||||
message: toKisApiErrorMessage(error, "KIS 조회 중 오류가 발생했습니다."),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function readSessionOverrideFromHeaders(headers: Headers) {
|
||||
if (process.env.NODE_ENV === "production") return null;
|
||||
const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER);
|
||||
return parseDomesticKisSession(raw);
|
||||
}
|
||||
122
app/api/kis/domestic/search/route.ts
Normal file
122
app/api/kis/domestic/search/route.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks";
|
||||
import type {
|
||||
DashboardStockSearchItem,
|
||||
DashboardStockSearchResponse,
|
||||
KoreanStockIndexItem,
|
||||
} from "@/features/trade/types/trade.types";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
} from "@/app/api/kis/_response";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const SEARCH_LIMIT = 10;
|
||||
|
||||
/**
|
||||
* @file app/api/kis/domestic/search/route.ts
|
||||
* @description 국내주식 종목명/종목코드 검색 API
|
||||
* @remarks
|
||||
* - [레이어] API Route
|
||||
* - [사용자 행동] 대시보드 검색창 엔터/검색 버튼 클릭 시 호출
|
||||
* - [데이터 흐름] dashboard-main.tsx -> /api/kis/domestic/search -> KOREAN_STOCK_INDEX 필터/정렬 -> JSON 응답
|
||||
* - [연관 파일] features/trade/data/korean-stocks.ts, features/trade/components/dashboard-main.tsx
|
||||
* @author jihoon87.lee
|
||||
*/
|
||||
|
||||
/**
|
||||
* 국내주식 검색 API
|
||||
* @param request query string의 q(검색어) 사용
|
||||
* @returns 종목 검색 결과 목록
|
||||
* @see features/trade/components/dashboard-main.tsx 검색 폼에서 호출합니다.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// [Step 1] query string에서 검색어(q)를 읽고 공백을 제거합니다.
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = (searchParams.get("q") ?? "").trim();
|
||||
|
||||
// [Step 2] 검색어가 없으면 빈 목록을 즉시 반환해 불필요한 계산을 줄입니다.
|
||||
if (!query) {
|
||||
const response: DashboardStockSearchResponse = {
|
||||
query,
|
||||
items: [],
|
||||
total: 0,
|
||||
};
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
|
||||
const normalized = normalizeKeyword(query);
|
||||
|
||||
// [Step 3] 인덱스에서 코드/이름 포함 여부로 1차 필터링 후 점수를 붙입니다.
|
||||
const ranked = KOREAN_STOCK_INDEX.filter((item) => {
|
||||
const symbol = item.symbol;
|
||||
const name = normalizeKeyword(item.name);
|
||||
return symbol.includes(normalized) || name.includes(normalized);
|
||||
})
|
||||
.map((item) => ({
|
||||
item,
|
||||
score: getSearchScore(item, normalized),
|
||||
}))
|
||||
.filter((entry) => entry.score > 0)
|
||||
.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
if (a.item.market !== b.item.market) return a.item.market.localeCompare(b.item.market);
|
||||
return a.item.name.localeCompare(b.item.name, "ko");
|
||||
});
|
||||
|
||||
// [Step 4] UI에서 필요한 최소 필드만 남겨 SEARCH_LIMIT 만큼 반환합니다.
|
||||
const items: DashboardStockSearchItem[] = ranked.slice(0, SEARCH_LIMIT).map(({ item }) => ({
|
||||
symbol: item.symbol,
|
||||
name: item.name,
|
||||
market: item.market,
|
||||
}));
|
||||
|
||||
const response: DashboardStockSearchResponse = {
|
||||
query,
|
||||
items,
|
||||
total: ranked.length,
|
||||
};
|
||||
|
||||
// [Step 5] DashboardStockSearchResponse 형태로 응답합니다.
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색어 정규화(공백 제거 + 소문자)
|
||||
* @param value 원본 문자열
|
||||
* @returns 정규화 문자열
|
||||
* @see app/api/kis/domestic/search/route.ts 한글/영문 검색 비교 정확도를 높입니다.
|
||||
*/
|
||||
function normalizeKeyword(value: string) {
|
||||
return value.replaceAll(/\s+/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 결과 점수 계산
|
||||
* @param item 종목 인덱스 항목
|
||||
* @param normalizedQuery 정규화된 검색어
|
||||
* @returns 높은 값일수록 우선순위 상위
|
||||
* @see app/api/kis/domestic/search/route.ts 검색 결과 정렬 기준으로 사용합니다.
|
||||
*/
|
||||
function getSearchScore(item: KoreanStockIndexItem, normalizedQuery: string) {
|
||||
const normalizedName = normalizeKeyword(item.name);
|
||||
const normalizedSymbol = item.symbol.toLowerCase();
|
||||
|
||||
if (normalizedSymbol === normalizedQuery) return 120;
|
||||
if (normalizedName === normalizedQuery) return 110;
|
||||
if (normalizedSymbol.startsWith(normalizedQuery)) return 100;
|
||||
if (normalizedName.startsWith(normalizedQuery)) return 90;
|
||||
if (normalizedName.includes(normalizedQuery)) return 70;
|
||||
if (normalizedSymbol.includes(normalizedQuery)) return 60;
|
||||
|
||||
return 0;
|
||||
}
|
||||
65
app/api/kis/revoke/route.ts
Normal file
65
app/api/kis/revoke/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { DashboardKisRevokeResponse } from "@/features/trade/types/trade.types";
|
||||
import { normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import {
|
||||
parseKisCredentialRequest,
|
||||
validateKisCredentialInput,
|
||||
} from "@/lib/kis/request";
|
||||
import { revokeKisAccessToken } from "@/lib/kis/token";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/revoke/route.ts
|
||||
* @description 사용자 입력 KIS API 키로 액세스 토큰을 폐기합니다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @description KIS 액세스 토큰 폐기
|
||||
* @see features/settings/components/KisAuthForm.tsx
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const credentials = await parseKisCredentialRequest(request);
|
||||
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
|
||||
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
const invalidMessage = validateKisCredentialInput(credentials);
|
||||
if (invalidMessage) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: invalidMessage,
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const message = await revokeKisAccessToken(credentials);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
tradingEnv,
|
||||
message,
|
||||
} satisfies DashboardKisRevokeResponse);
|
||||
} catch (error) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.UNAUTHORIZED,
|
||||
message: toKisApiErrorMessage(error, "API 토큰 폐기 중 오류가 발생했습니다."),
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
}
|
||||
247
app/api/kis/validate-profile/route.ts
Normal file
247
app/api/kis/validate-profile/route.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import type { DashboardKisProfileValidateResponse } from "@/features/trade/types/trade.types";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import { parseKisAccountParts } from "@/lib/kis/account";
|
||||
import { kisGet } from "@/lib/kis/client";
|
||||
import { normalizeTradingEnv, type KisCredentialInput } from "@/lib/kis/config";
|
||||
import { validateKisCredentialInput } from "@/lib/kis/request";
|
||||
import { getKisAccessToken } from "@/lib/kis/token";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
|
||||
const kisProfileValidateBodySchema = z.object({
|
||||
appKey: z.string().trim().min(1, "앱 키를 입력해 주세요."),
|
||||
appSecret: z.string().trim().min(1, "앱 시크릿을 입력해 주세요."),
|
||||
tradingEnv: z.string().optional(),
|
||||
accountNo: z.string().trim().min(1, "계좌번호를 입력해 주세요."),
|
||||
});
|
||||
|
||||
interface BalanceValidationPreset {
|
||||
inqrDvsn: "01" | "02";
|
||||
prcsDvsn: "00" | "01";
|
||||
}
|
||||
|
||||
const BALANCE_VALIDATION_PRESETS: BalanceValidationPreset[] = [
|
||||
{
|
||||
// 명세 기본 요청값
|
||||
inqrDvsn: "01",
|
||||
prcsDvsn: "01",
|
||||
},
|
||||
{
|
||||
// 일부 계좌/환경 호환값
|
||||
inqrDvsn: "02",
|
||||
prcsDvsn: "00",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* @file app/api/kis/validate-profile/route.ts
|
||||
* @description 한국투자증권 계좌번호를 검증합니다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @description 앱키/앱시크릿키 + 계좌번호 유효성을 검증합니다.
|
||||
* @remarks UI 흐름: /settings -> KisProfileForm 확인 버튼 -> /api/kis/validate-profile -> store 반영 -> 대시보드 상태 확장
|
||||
* @see features/settings/components/KisProfileForm.tsx 계좌 확인 버튼에서 호출합니다.
|
||||
* @see features/settings/apis/kis-auth.api.ts validateKisProfile 클라이언트 API 함수
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const fallbackTradingEnv = normalizeTradingEnv(
|
||||
request.headers.get("x-kis-trading-env") ?? undefined,
|
||||
);
|
||||
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
tradingEnv: fallbackTradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
let rawBody: unknown = {};
|
||||
|
||||
try {
|
||||
rawBody = (await request.json()) as unknown;
|
||||
} catch {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: "요청 본문(JSON)을 읽을 수 없습니다.",
|
||||
tradingEnv: fallbackTradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
const parsedBody = kisProfileValidateBodySchema.safeParse(rawBody);
|
||||
if (!parsedBody.success) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message:
|
||||
parsedBody.error.issues[0]?.message ??
|
||||
"요청 본문 값이 올바르지 않습니다.",
|
||||
tradingEnv: fallbackTradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
const body = parsedBody.data;
|
||||
|
||||
const credentials: KisCredentialInput = {
|
||||
appKey: body.appKey.trim(),
|
||||
appSecret: body.appSecret.trim(),
|
||||
tradingEnv: normalizeTradingEnv(body.tradingEnv),
|
||||
};
|
||||
|
||||
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
|
||||
|
||||
const invalidCredentialMessage = validateKisCredentialInput(credentials);
|
||||
if (invalidCredentialMessage) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: invalidCredentialMessage,
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
const accountNoInput = body.accountNo.trim();
|
||||
|
||||
const accountParts = parseKisAccountParts(accountNoInput);
|
||||
if (!accountParts) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
|
||||
message:
|
||||
"계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.",
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// 1) 토큰 발급으로 앱키/시크릿 사전 검증
|
||||
try {
|
||||
await getKisAccessToken(credentials);
|
||||
} catch (error) {
|
||||
throw new Error(`앱키 검증 실패: ${toErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
// 2) 계좌 유효성 검증 (실제 계좌 조회 API)
|
||||
try {
|
||||
await validateAccountByBalanceApi(
|
||||
accountParts.accountNo,
|
||||
accountParts.accountProductCode,
|
||||
credentials,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(`계좌 검증 실패: ${toErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
const normalizedAccountNo = `${accountParts.accountNo}-${accountParts.accountProductCode}`;
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
tradingEnv,
|
||||
message: "계좌번호 검증이 완료되었습니다.",
|
||||
account: {
|
||||
normalizedAccountNo,
|
||||
},
|
||||
} satisfies DashboardKisProfileValidateResponse);
|
||||
} catch (error) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.UNAUTHORIZED,
|
||||
message: toKisApiErrorMessage(error, "계좌 검증 중 오류가 발생했습니다."),
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 계좌번호로 잔고 조회 API를 호출해 유효성을 확인합니다.
|
||||
* @param accountNo 계좌번호 앞 8자리
|
||||
* @param accountProductCode 계좌번호 뒤 2자리
|
||||
* @param credentials KIS 인증 정보
|
||||
* @see app/api/kis/validate-profile/route.ts POST
|
||||
*/
|
||||
async function validateAccountByBalanceApi(
|
||||
accountNo: string,
|
||||
accountProductCode: string,
|
||||
credentials: KisCredentialInput,
|
||||
) {
|
||||
const trId = normalizeTradingEnv(credentials.tradingEnv) === "real" ? "TTTC8434R" : "VTTC8434R";
|
||||
const attemptErrors: string[] = [];
|
||||
|
||||
for (const preset of BALANCE_VALIDATION_PRESETS) {
|
||||
try {
|
||||
const response = await kisGet<unknown>(
|
||||
"/uapi/domestic-stock/v1/trading/inquire-balance",
|
||||
trId,
|
||||
{
|
||||
CANO: accountNo,
|
||||
ACNT_PRDT_CD: accountProductCode,
|
||||
AFHR_FLPR_YN: "N",
|
||||
OFL_YN: "",
|
||||
INQR_DVSN: preset.inqrDvsn,
|
||||
UNPR_DVSN: "01",
|
||||
FUND_STTL_ICLD_YN: "N",
|
||||
FNCG_AMT_AUTO_RDPT_YN: "N",
|
||||
PRCS_DVSN: preset.prcsDvsn,
|
||||
CTX_AREA_FK100: "",
|
||||
CTX_AREA_NK100: "",
|
||||
},
|
||||
credentials,
|
||||
);
|
||||
|
||||
validateInquireBalanceResponse(response);
|
||||
return;
|
||||
} catch (error) {
|
||||
attemptErrors.push(
|
||||
`INQR_DVSN=${preset.inqrDvsn}, PRCS_DVSN=${preset.prcsDvsn}: ${toErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`계좌 확인 요청이 모두 실패했습니다. ${attemptErrors.join(" | ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 주식잔고조회 응답 구조를 최소 검증합니다.
|
||||
* @param response KIS 원본 응답
|
||||
* @see app/api/kis/validate-profile/route.ts validateAccountByBalanceApi
|
||||
*/
|
||||
function validateInquireBalanceResponse(
|
||||
response: {
|
||||
output1?: unknown;
|
||||
output2?: unknown;
|
||||
},
|
||||
) {
|
||||
const output1Ok =
|
||||
Array.isArray(response.output1) ||
|
||||
(response.output1 !== null && typeof response.output1 === "object");
|
||||
const output2Ok =
|
||||
Array.isArray(response.output2) ||
|
||||
(response.output2 !== null && typeof response.output2 === "object");
|
||||
|
||||
if (!output1Ok && !output2Ok) {
|
||||
throw new Error("응답에 output1/output2가 없습니다. 요청 파라미터를 확인해 주세요.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Error 객체를 사용자 표시용 문자열로 변환합니다.
|
||||
* @param error unknown 에러
|
||||
* @returns 메시지 문자열
|
||||
* @see app/api/kis/validate-profile/route.ts POST
|
||||
*/
|
||||
function toErrorMessage(error: unknown) {
|
||||
return error instanceof Error
|
||||
? error.message
|
||||
: "알 수 없는 오류가 발생했습니다.";
|
||||
}
|
||||
65
app/api/kis/validate/route.ts
Normal file
65
app/api/kis/validate/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { DashboardKisValidateResponse } from "@/features/trade/types/trade.types";
|
||||
import { normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import {
|
||||
parseKisCredentialRequest,
|
||||
validateKisCredentialInput,
|
||||
} from "@/lib/kis/request";
|
||||
import { getKisAccessToken } from "@/lib/kis/token";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/validate/route.ts
|
||||
* @description 사용자 입력 KIS API 키를 검증합니다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @description 액세스 토큰 발급 성공 여부로 API 키를 검증합니다.
|
||||
* @see features/settings/components/KisAuthForm.tsx
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const credentials = await parseKisCredentialRequest(request);
|
||||
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
|
||||
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
const invalidMessage = validateKisCredentialInput(credentials);
|
||||
if (invalidMessage) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: invalidMessage,
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await getKisAccessToken(credentials);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
tradingEnv,
|
||||
message: "API 키 검증이 완료되었습니다. (토큰 발급 성공)",
|
||||
} satisfies DashboardKisValidateResponse);
|
||||
} catch (error) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.UNAUTHORIZED,
|
||||
message: toKisApiErrorMessage(error, "API 키 검증 중 오류가 발생했습니다."),
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
}
|
||||
71
app/api/kis/ws/approval/route.ts
Normal file
71
app/api/kis/ws/approval/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { DashboardKisWsApprovalResponse } from "@/features/trade/types/trade.types";
|
||||
import { hasKisApiSession } from "@/app/api/kis/_session";
|
||||
import { getKisApprovalKey, resolveKisWebSocketUrl } from "@/lib/kis/approval";
|
||||
import { normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import {
|
||||
parseKisCredentialRequest,
|
||||
validateKisCredentialInput,
|
||||
} from "@/lib/kis/request";
|
||||
import {
|
||||
createKisApiErrorResponse,
|
||||
KIS_API_ERROR_CODE,
|
||||
toKisApiErrorMessage,
|
||||
} from "@/app/api/kis/_response";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/ws/approval/route.ts
|
||||
* @description KIS 웹소켓 승인키와 WS URL을 발급합니다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @description 실시간 웹소켓 연결 정보를 발급합니다.
|
||||
* @see features/trade/hooks/useKisTradeWebSocket.ts
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const credentials = await parseKisCredentialRequest(request);
|
||||
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
|
||||
|
||||
const hasSession = await hasKisApiSession();
|
||||
if (!hasSession) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
|
||||
message: "로그인이 필요합니다.",
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
const invalidMessage = validateKisCredentialInput(credentials);
|
||||
if (invalidMessage) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 400,
|
||||
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
|
||||
message: invalidMessage,
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const approvalKey = await getKisApprovalKey(credentials);
|
||||
const wsUrl = resolveKisWebSocketUrl(credentials);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
tradingEnv,
|
||||
approvalKey,
|
||||
wsUrl,
|
||||
message: "웹소켓 승인키 발급이 완료되었습니다.",
|
||||
} satisfies DashboardKisWsApprovalResponse);
|
||||
} catch (error) {
|
||||
return createKisApiErrorResponse({
|
||||
status: 401,
|
||||
code: KIS_API_ERROR_CODE.UNAUTHORIZED,
|
||||
message: toKisApiErrorMessage(
|
||||
error,
|
||||
"웹소켓 승인키 발급 중 오류가 발생했습니다.",
|
||||
),
|
||||
tradingEnv,
|
||||
});
|
||||
}
|
||||
}
|
||||
105
app/globals.css
105
app/globals.css
@@ -1,4 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "tailwindcss-animate";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@@ -38,16 +39,16 @@
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-brand-50: oklch(0.97 0.02 294);
|
||||
--color-brand-100: oklch(0.93 0.05 294);
|
||||
--color-brand-200: oklch(0.87 0.1 294);
|
||||
--color-brand-300: oklch(0.79 0.15 294);
|
||||
--color-brand-400: oklch(0.7 0.2 294);
|
||||
--color-brand-500: oklch(0.62 0.24 294);
|
||||
--color-brand-600: oklch(0.56 0.26 294);
|
||||
--color-brand-700: oklch(0.49 0.24 295);
|
||||
--color-brand-800: oklch(0.4 0.2 296);
|
||||
--color-brand-900: oklch(0.33 0.14 297);
|
||||
--color-brand-50: var(--brand-50);
|
||||
--color-brand-100: var(--brand-100);
|
||||
--color-brand-200: var(--brand-200);
|
||||
--color-brand-300: var(--brand-300);
|
||||
--color-brand-400: var(--brand-400);
|
||||
--color-brand-500: var(--brand-500);
|
||||
--color-brand-600: var(--brand-600);
|
||||
--color-brand-700: var(--brand-700);
|
||||
--color-brand-800: var(--brand-800);
|
||||
--color-brand-900: var(--brand-900);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
@@ -59,7 +60,8 @@
|
||||
--animate-gradient-x: gradient-x 15s ease infinite;
|
||||
|
||||
@keyframes gradient-x {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
background-size: 200% 200%;
|
||||
background-position: left center;
|
||||
}
|
||||
@@ -71,6 +73,41 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
/* BRAND PALETTE CONTROL
|
||||
* 이 블록만 수정하면 랜딩/대시보드의 보라 톤이 함께 바뀝니다.
|
||||
*/
|
||||
/* 초기 브랜드 보라값(원본 기준) */
|
||||
--brand-50: oklch(0.97 0.02 294);
|
||||
--brand-100: oklch(0.93 0.05 294);
|
||||
--brand-200: oklch(0.87 0.1 294);
|
||||
--brand-300: oklch(0.79 0.15 294);
|
||||
--brand-400: oklch(0.7 0.2 294);
|
||||
--brand-500: oklch(0.62 0.24 294);
|
||||
--brand-600: oklch(0.56 0.26 294);
|
||||
--brand-700: oklch(0.49 0.24 295);
|
||||
--brand-800: oklch(0.4 0.2 296);
|
||||
--brand-900: oklch(0.33 0.14 297);
|
||||
|
||||
/* 차트(canvas): 봉 하락색은 요청대로 파란색 유지 */
|
||||
--brand-chart-background-light: #ffffff;
|
||||
--brand-chart-background-dark: #17131e;
|
||||
--brand-chart-text-light: #6b21a8;
|
||||
--brand-chart-text-dark: #e9d5ff;
|
||||
--brand-chart-border-light: #e9d5ff;
|
||||
--brand-chart-border-dark: rgba(216, 180, 254, 0.3);
|
||||
--brand-chart-grid-light: #f3e8ff;
|
||||
--brand-chart-grid-dark: rgba(216, 180, 254, 0.14);
|
||||
--brand-chart-crosshair-light: #c084fc;
|
||||
--brand-chart-crosshair-dark: rgba(233, 213, 255, 0.75);
|
||||
|
||||
--brand-chart-background: #ffffff;
|
||||
--brand-chart-down: #2563eb;
|
||||
--brand-chart-volume-down: rgba(37, 99, 235, 0.45);
|
||||
--brand-chart-text: #6b21a8;
|
||||
--brand-chart-border: var(--brand-chart-border-light);
|
||||
--brand-chart-grid: var(--brand-chart-grid-light);
|
||||
--brand-chart-crosshair: var(--brand-chart-crosshair-light);
|
||||
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
@@ -78,7 +115,7 @@
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.56 0.26 294);
|
||||
--primary: var(--brand-600);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
@@ -89,7 +126,7 @@
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.62 0.24 294);
|
||||
--ring: var(--brand-500);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
@@ -97,7 +134,7 @@
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.56 0.26 294);
|
||||
--sidebar-primary: var(--brand-600);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
@@ -106,37 +143,45 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
/* 다크 모드 시인성 개선: 배경 대비는 유지하고, 카드/보더/보조 텍스트를 더 읽기 쉽게 조정 */
|
||||
--background: oklch(0.17 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card: oklch(0.235 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover: oklch(0.235 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.56 0.26 294);
|
||||
--primary: var(--brand-600);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary: oklch(0.285 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--muted: oklch(0.285 0 0);
|
||||
--muted-foreground: oklch(0.83 0 0);
|
||||
--accent: oklch(0.285 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.62 0.24 294);
|
||||
--border: oklch(1 0 0 / 18%);
|
||||
--input: oklch(1 0 0 / 22%);
|
||||
--ring: var(--brand-500);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar: oklch(0.235 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.56 0.26 294);
|
||||
--sidebar-primary: var(--brand-600);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent: oklch(0.285 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 18%);
|
||||
--sidebar-ring: oklch(0.78 0 0);
|
||||
|
||||
/* 다크 테마용 차트 배경/격자 대비 */
|
||||
--brand-chart-background: var(--brand-chart-background-dark);
|
||||
--brand-chart-text: var(--brand-chart-text-dark);
|
||||
--brand-chart-border: var(--brand-chart-border-dark);
|
||||
--brand-chart-grid: var(--brand-chart-grid-dark);
|
||||
--brand-chart-crosshair: var(--brand-chart-crosshair-dark);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -13,6 +13,8 @@ import { Geist, Geist_Mono, Outfit } from "next/font/google";
|
||||
import { QueryProvider } from "@/providers/query-provider";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { SessionManager } from "@/features/auth/components/session-manager";
|
||||
import { GlobalAlertModal } from "@/features/layout/components/GlobalAlertModal";
|
||||
import { Toaster } from "sonner";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -32,8 +34,9 @@ const outfit = Outfit({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AutoTrade",
|
||||
description: "Automated Crypto Trading Platform",
|
||||
title: "JOORIN-E (주린이) - 감이 아닌 전략으로 시작하는 자동매매",
|
||||
description:
|
||||
"주린이를 위한 자동매매 파트너 JOORIN-E. 손실 방어 규칙, 데이터 신호 분석, 실시간 자동 실행으로 초보의 첫 수익 루틴을 만듭니다.",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -60,7 +63,15 @@ export default function RootLayout({
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<SessionManager />
|
||||
<GlobalAlertModal />
|
||||
<QueryProvider>{children}</QueryProvider>
|
||||
<Toaster
|
||||
richColors
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
}}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
30
common-docs/api-reference/kis-error-code-reference.md
Normal file
30
common-docs/api-reference/kis-error-code-reference.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# KIS 오류코드 적용 기준 (2026-02-26)
|
||||
|
||||
## 1) 기준 소스
|
||||
- 공식 오류코드 페이지: `https://apiportal.koreainvestment.com/faq-error-code`
|
||||
- 확인 방식: 실제 브라우저 렌더링 후 테이블 추출
|
||||
- 코드 반영 위치: `lib/kis/error-codes.ts`
|
||||
|
||||
## 2) 코드 반영 목적
|
||||
- `msg_cd`만 보일 때 의미를 바로 알기 어렵기 때문에,
|
||||
코드와 문구를 같이 표시해 장애 원인 파악 속도를 높입니다.
|
||||
- 토큰 발급/폐기, REST 호출, 웹소켓 제어 오류 메시지의 형식을 통일합니다.
|
||||
|
||||
## 3) 적용된 모듈
|
||||
- `lib/kis/error-codes.ts`
|
||||
- 공식 FAQ 코드 문구 매핑
|
||||
- `getKisErrorGuide(msgCode)` 제공
|
||||
- `buildKisErrorDetail(...)` 제공
|
||||
- `lib/kis/client.ts`
|
||||
- REST 실패 메시지에 `msg_cd + 공식 문구` 반영
|
||||
- `lib/kis/token.ts`
|
||||
- 토큰 발급/폐기 실패 메시지에 `msg_cd + 공식 문구` 반영
|
||||
- `lib/kis/approval.ts`
|
||||
- 승인키 발급 실패 메시지에 `msg_cd + 공식 문구` 반영
|
||||
- `features/kis-realtime/stores/kisWebSocketStore.ts`
|
||||
- 실시간 제어 오류(`OPSP*`) 메시지에 공식 문구 반영
|
||||
|
||||
## 4) 운영 시 참고
|
||||
- 화면/로그에 `EGW00103`, `OPSP8996`처럼 코드가 보이면
|
||||
`lib/kis/error-codes.ts`에서 즉시 문구를 확인할 수 있습니다.
|
||||
- 신규 코드가 추가되면 공식 FAQ 기준으로 맵에 추가합니다.
|
||||
466
common-docs/api-reference/kis_api_reference.md
Normal file
466
common-docs/api-reference/kis_api_reference.md
Normal file
@@ -0,0 +1,466 @@
|
||||
# 한국투자증권 Open API 레퍼런스 가이드
|
||||
|
||||
> 이 문서는 Codex, Gemini, Claude 등 AI 어시스턴트가 한국투자증권(KIS) Open API를 기반으로 트레이딩 시스템을 개발하거나 UI/UX를 구성할 때 참고하기 위해 요약된 자료입니다.
|
||||
|
||||
## 📍 공식 사이트 및 주요 도구
|
||||
|
||||
- **공식 Open API 포털 (Main):** [https://apiportal.koreainvestment.com/apiservice-apiservice](https://apiportal.koreainvestment.com/apiservice-apiservice)
|
||||
- **공식 Github (코드 샘플 및 종목 정보):** [https://github.com/koreainvestment/open-trading-api](https://github.com/koreainvestment/open-trading-api)
|
||||
- **공식 챗봇 가이드 (GPTs):** [한국투자증권 Open API 서비스 챗봇](https://chatgpt.com/g/g-68b920ee7afc8191858d3dc05d429571-hangugtujajeunggweon-open-api-seobiseu-gpts)
|
||||
- **API 테스트베드:** [https://apiportal.koreainvestment.com/testbed-intro](https://apiportal.koreainvestment.com/testbed-intro)
|
||||
|
||||
---
|
||||
|
||||
## 📚 API 카테고리별 주요 문서 링크
|
||||
|
||||
한국투자증권 API 포털은 SPA(Single Page Application) 구조로, 각 API 상세 문서는 `https://apiportal.koreainvestment.com/apiservice-apiservice?{API_PATH}` 형태의 파라미터를 사용하여 접근할 수 있습니다.
|
||||
|
||||
### 1. 공통 및 인증 (Essential)
|
||||
|
||||
- **개요 (Summary):** [바로가기](https://apiportal.koreainvestment.com/apiservice-summary)
|
||||
- **종목정보파일 안내:** [바로가기](https://apiportal.koreainvestment.com/apiservice-category)
|
||||
- **OAuth인증 (접근토큰 발급/폐기):** [문서 링크](https://apiportal.koreainvestment.com/apiservice-apiservice?/oauth2/tokenP)
|
||||
- **실시간 (웹소켓) 접속키 발급:** [문서 링크](https://apiportal.koreainvestment.com/apiservice-apiservice?/oauth2/Approval)
|
||||
|
||||
### 2. 국내주식 (Domestic Stocks)
|
||||
|
||||
- **주문/계좌 (현금/신용주문, 잔고조회):** [주식주문(현금) 예시](https://apiportal.koreainvestment.com/apiservice-apiservice?/uapi/domestic-stock/v1/trading/order-cash)
|
||||
- **기본시세 (현재가, 호가, 체결, 일자별):** [주식현재가 시세 예시](https://apiportal.koreainvestment.com/apiservice-apiservice?/uapi/domestic-stock/v1/quotations/inquire-price)
|
||||
- **종목정보 (재무비율, 손익계산서, 대차대조표):** [상품기본조회 예시](https://apiportal.koreainvestment.com/apiservice-apiservice?/uapi/domestic-stock/v1/quotations/search-stock-info)
|
||||
- **시세/순위 분석 (거래량순위, 등락률, 관심종목):** [거래량순위 예시](https://apiportal.koreainvestment.com/apiservice-apiservice?/uapi/domestic-stock/v1/quotations/volume-rank)
|
||||
- **실시간 시세 (Websocket):** [실시간 체결가 (H0STCNT0)](https://apiportal.koreainvestment.com/apiservice-apiservice?/tryitout/H0STCNT0)
|
||||
|
||||
### 3. 해외주식 (Overseas Stocks)
|
||||
|
||||
- **주문/계좌 (미국, 일본, 중국, 홍콩, 베트남):** [해외주식 주문 예시](https://apiportal.koreainvestment.com/apiservice-apiservice?/uapi/overseas-stock/v1/trading/order)
|
||||
- **해외주식 시세 (현재가, 호가, 분봉):** [해외주식 현재가 예시](https://apiportal.koreainvestment.com/apiservice-apiservice?/uapi/overseas-price/v1/quotations/price)
|
||||
- **해외주식 실시간 시세 (Websocket):** [해외주식 실시간체결가 (HDFSCNT0)](https://apiportal.koreainvestment.com/apiservice-apiservice?/tryitout/HDFSCNT0)
|
||||
|
||||
### 4. 기타 금융 상품
|
||||
|
||||
- **국내선물옵션:** 주문, 기본시세, 실시간시세 지원
|
||||
- **해외선물옵션:** 해외선물 종목 상세 및 실시간 시세 지원
|
||||
- **장내채권:** 채권 매수/매도 주문 및 발행정보 시세 지원
|
||||
- **ELW 시세:** 기초자산별 종목 및 LP 매매추이 지원
|
||||
|
||||
---
|
||||
|
||||
## 💡 AI 어시스턴트를 위한 참고 팁
|
||||
|
||||
1. **엔드포인트 조합 규칙:** API 문서상에 표기된 `URL` (`/uapi/...`)을 포털 주소 뒤에 파라미터(`?`)로 붙이면 브라우저에서 해당 문서로 직접 이동이 가능합니다.
|
||||
2. **데이터 타입 주의:** `ORD_QTY`(주문수량), `ORD_UNPR`(주문단가) 등 숫자형 데이터도 **String 가공**이 필요한 경우가 많으므로 문서를 반드시 확인해야 합니다.
|
||||
3. **마스터 데이터:** 종목 코드 및 기본 종목 정보는 API 호출보다는 공지된 전체 종목 마스터 파일(zip)을 다운로드 및 파싱하여 사용하는 것을 KIS에서 권장합니다. 관련 파이썬/Node.js 파싱 코드는 공식 Github 링크를 참고하세요.
|
||||
|
||||
## 📋 KIS API Portal 전체 메뉴 구조 (Reference)
|
||||
|
||||
다음은 한국투자증권 Open API 포털의 전체 좌측 메뉴 구조와 각 API 엔드포인트 URL 리스트입니다. AI가 API 연동 코드를 작성할 때 엔드포인트 참조용으로 사용하세요.
|
||||
|
||||
### 개요
|
||||
|
||||
- 하위 메뉴 없음
|
||||
|
||||
### 종목정보파일
|
||||
|
||||
- 하위 메뉴 없음
|
||||
|
||||
### OAuth인증
|
||||
|
||||
- **접근토큰발급(P)**: `/oauth2/tokenP`
|
||||
- **접근토큰폐기(P)**: `/oauth2/revokeP`
|
||||
- **Hashkey**: `/uapi/hashkey`
|
||||
- **실시간 (웹소켓) 접속키 발급**: `/oauth2/Approval`
|
||||
|
||||
### [국내주식] 주문/계좌
|
||||
|
||||
- **주식주문(현금)**: `/uapi/domestic-stock/v1/trading/order-cash`
|
||||
- **주식주문(신용)**: `/uapi/domestic-stock/v1/trading/order-credit`
|
||||
- **주식주문(정정취소)**: `/uapi/domestic-stock/v1/trading/order-rvsecncl`
|
||||
- **주식정정취소가능주문조회**: `/uapi/domestic-stock/v1/trading/inquire-psbl-rvsecncl`
|
||||
- **주식일별주문체결조회**: `/uapi/domestic-stock/v1/trading/inquire-daily-ccld`
|
||||
- **주식잔고조회**: `/uapi/domestic-stock/v1/trading/inquire-balance`
|
||||
- **매수가능조회**: `/uapi/domestic-stock/v1/trading/inquire-psbl-order`
|
||||
- **매도가능수량조회**: `/uapi/domestic-stock/v1/trading/inquire-psbl-sell`
|
||||
- **신용매수가능조회**: `/uapi/domestic-stock/v1/trading/inquire-credit-psamount`
|
||||
- **주식예약주문**: `/uapi/domestic-stock/v1/trading/order-resv`
|
||||
- **주식예약주문정정취소**: `/uapi/domestic-stock/v1/trading/order-resv-rvsecncl`
|
||||
- **주식예약주문조회**: `/uapi/domestic-stock/v1/trading/order-resv-ccnl`
|
||||
- **퇴직연금 체결기준잔고**: `/uapi/domestic-stock/v1/trading/pension/inquire-present-balance`
|
||||
- **퇴직연금 미체결내역**: `/uapi/domestic-stock/v1/trading/pension/inquire-daily-ccld`
|
||||
- **퇴직연금 매수가능조회**: `/uapi/domestic-stock/v1/trading/pension/inquire-psbl-order`
|
||||
- **퇴직연금 예수금조회**: `/uapi/domestic-stock/v1/trading/pension/inquire-deposit`
|
||||
- **퇴직연금 잔고조회**: `/uapi/domestic-stock/v1/trading/pension/inquire-balance`
|
||||
- **주식잔고조회\_실현손익**: `/uapi/domestic-stock/v1/trading/inquire-balance-rlz-pl`
|
||||
- **투자계좌자산현황조회**: `/uapi/domestic-stock/v1/trading/inquire-account-balance`
|
||||
- **기간별손익일별합산조회**: `/uapi/domestic-stock/v1/trading/inquire-period-profit`
|
||||
- **기간별매매손익현황조회**: `/uapi/domestic-stock/v1/trading/inquire-period-trade-profit`
|
||||
- **주식통합증거금 현황**: `/uapi/domestic-stock/v1/trading/intgr-margin`
|
||||
- **기간별계좌권리현황조회**: `/uapi/domestic-stock/v1/trading/period-rights`
|
||||
|
||||
### [국내주식] 기본시세
|
||||
|
||||
- **주식현재가 시세**: `/uapi/domestic-stock/v1/quotations/inquire-price`
|
||||
- **주식현재가 시세2**: `/uapi/domestic-stock/v1/quotations/inquire-price-2`
|
||||
- **주식현재가 체결**: `/uapi/domestic-stock/v1/quotations/inquire-ccnl`
|
||||
- **주식현재가 일자별**: `/uapi/domestic-stock/v1/quotations/inquire-daily-price`
|
||||
- **주식현재가 호가/예상체결**: `/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn`
|
||||
- **주식현재가 투자자**: `/uapi/domestic-stock/v1/quotations/inquire-investor`
|
||||
- **주식현재가 회원사**: `/uapi/domestic-stock/v1/quotations/inquire-member`
|
||||
- **국내주식기간별시세(일/주/월/년)**: `/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice`
|
||||
- **주식당일분봉조회**: `/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice`
|
||||
- **주식일별분봉조회**: `/uapi/domestic-stock/v1/quotations/inquire-time-dailychartprice`
|
||||
- **주식현재가 당일시간대별체결**: `/uapi/domestic-stock/v1/quotations/inquire-time-itemconclusion`
|
||||
- **주식현재가 시간외일자별주가**: `/uapi/domestic-stock/v1/quotations/inquire-daily-overtimeprice`
|
||||
- **주식현재가 시간외시간별체결**: `/uapi/domestic-stock/v1/quotations/inquire-time-overtimeconclusion`
|
||||
- **국내주식 시간외현재가**: `/uapi/domestic-stock/v1/quotations/inquire-overtime-price`
|
||||
- **국내주식 시간외호가**: `/uapi/domestic-stock/v1/quotations/inquire-overtime-asking-price`
|
||||
- **국내주식 장마감 예상체결가**: `/uapi/domestic-stock/v1/quotations/exp-closing-price`
|
||||
- **ETF/ETN 현재가**: `/uapi/etfetn/v1/quotations/inquire-price`
|
||||
- **ETF 구성종목시세**: `/uapi/etfetn/v1/quotations/inquire-component-stock-price`
|
||||
- **NAV 비교추이(종목)**: `/uapi/etfetn/v1/quotations/nav-comparison-trend`
|
||||
- **NAV 비교추이(일)**: `/uapi/etfetn/v1/quotations/nav-comparison-daily-trend`
|
||||
- **NAV 비교추이(분)**: `/uapi/etfetn/v1/quotations/nav-comparison-time-trend`
|
||||
|
||||
### [국내주식] ELW 시세
|
||||
|
||||
- **ELW 현재가 시세**: `/uapi/domestic-stock/v1/quotations/inquire-elw-price`
|
||||
- **ELW 신규상장종목**: `/uapi/elw/v1/quotations/newly-listed`
|
||||
- **ELW 민감도 순위**: `/uapi/elw/v1/ranking/sensitivity`
|
||||
- **ELW 기초자산별 종목시세**: `/uapi/elw/v1/quotations/udrl-asset-price`
|
||||
- **ELW 종목검색**: `/uapi/elw/v1/quotations/cond-search`
|
||||
- **ELW 당일급변종목**: `/uapi/elw/v1/ranking/quick-change`
|
||||
- **ELW 기초자산 목록조회**: `/uapi/elw/v1/quotations/udrl-asset-list`
|
||||
- **ELW 비교대상종목조회**: `/uapi/elw/v1/quotations/compare-stocks`
|
||||
- **ELW LP매매추이**: `/uapi/elw/v1/quotations/lp-trade-trend`
|
||||
- **ELW 투자지표추이(체결)**: `/uapi/elw/v1/quotations/indicator-trend-ccnl`
|
||||
- **ELW 투자지표추이(분별)**: `/uapi/elw/v1/quotations/indicator-trend-minute`
|
||||
- **ELW 투자지표추이(일별)**: `/uapi/elw/v1/quotations/indicator-trend-daily`
|
||||
- **ELW 변동성 추이(틱)**: `/uapi/elw/v1/quotations/volatility-trend-tick`
|
||||
- **ELW 변동성추이(체결)**: `/uapi/elw/v1/quotations/volatility-trend-ccnl`
|
||||
- **ELW 변동성 추이(일별)**: `/uapi/elw/v1/quotations/volatility-trend-daily`
|
||||
- **ELW 민감도 추이(체결)**: `/uapi/elw/v1/quotations/sensitivity-trend-ccnl`
|
||||
- **ELW 변동성 추이(분별)**: `/uapi/elw/v1/quotations/volatility-trend-minute`
|
||||
- **ELW 민감도 추이(일별)**: `/uapi/elw/v1/quotations/sensitivity-trend-daily`
|
||||
- **ELW 만기예정/만기종목**: `/uapi/elw/v1/quotations/expiration-stocks`
|
||||
- **ELW 지표순위**: `/uapi/elw/v1/ranking/indicator`
|
||||
- **ELW 상승률순위**: `/uapi/elw/v1/ranking/updown-rate`
|
||||
- **ELW 거래량순위**: `/uapi/elw/v1/ranking/volume-rank`
|
||||
|
||||
### [국내주식] 업종/기타
|
||||
|
||||
- **국내업종 현재지수**: `/uapi/domestic-stock/v1/quotations/inquire-index-price`
|
||||
- **국내업종 일자별지수**: `/uapi/domestic-stock/v1/quotations/inquire-index-daily-price`
|
||||
- **국내업종 시간별지수(초)**: `/uapi/domestic-stock/v1/quotations/inquire-index-tickprice`
|
||||
- **국내업종 시간별지수(분)**: `/uapi/domestic-stock/v1/quotations/inquire-index-timeprice`
|
||||
- **업종 분봉조회**: `/uapi/domestic-stock/v1/quotations/inquire-time-indexchartprice`
|
||||
- **국내주식업종기간별시세(일/주/월/년)**: `/uapi/domestic-stock/v1/quotations/inquire-daily-indexchartprice`
|
||||
- **국내업종 구분별전체시세**: `/uapi/domestic-stock/v1/quotations/inquire-index-category-price`
|
||||
- **국내주식 예상체결지수 추이**: `/uapi/domestic-stock/v1/quotations/exp-index-trend`
|
||||
- **국내주식 예상체결 전체지수**: `/uapi/domestic-stock/v1/quotations/exp-total-index`
|
||||
- **변동성완화장치(VI) 현황**: `/uapi/domestic-stock/v1/quotations/inquire-vi-status`
|
||||
- **금리 종합(국내채권/금리)**: `/uapi/domestic-stock/v1/quotations/comp-interest`
|
||||
- **종합 시황/공시(제목)**: `/uapi/domestic-stock/v1/quotations/news-title`
|
||||
- **국내휴장일조회**: `/uapi/domestic-stock/v1/quotations/chk-holiday`
|
||||
- **국내선물 영업일조회**: `/uapi/domestic-stock/v1/quotations/market-time`
|
||||
|
||||
### [국내주식] 종목정보
|
||||
|
||||
- **상품기본조회**: `/uapi/domestic-stock/v1/quotations/search-info`
|
||||
- **주식기본조회**: `/uapi/domestic-stock/v1/quotations/search-stock-info`
|
||||
- **국내주식 대차대조표**: `/uapi/domestic-stock/v1/finance/balance-sheet`
|
||||
- **국내주식 손익계산서**: `/uapi/domestic-stock/v1/finance/income-statement`
|
||||
- **국내주식 재무비율**: `/uapi/domestic-stock/v1/finance/financial-ratio`
|
||||
- **국내주식 수익성비율**: `/uapi/domestic-stock/v1/finance/profit-ratio`
|
||||
- **국내주식 기타주요비율**: `/uapi/domestic-stock/v1/finance/other-major-ratios`
|
||||
- **국내주식 안정성비율**: `/uapi/domestic-stock/v1/finance/stability-ratio`
|
||||
- **국내주식 성장성비율**: `/uapi/domestic-stock/v1/finance/growth-ratio`
|
||||
- **국내주식 당사 신용가능종목**: `/uapi/domestic-stock/v1/quotations/credit-by-company`
|
||||
- **예탁원정보(배당일정)**: `/uapi/domestic-stock/v1/ksdinfo/dividend`
|
||||
- **예탁원정보(주식매수청구일정)**: `/uapi/domestic-stock/v1/ksdinfo/purreq`
|
||||
- **예탁원정보(합병/분할일정)**: `/uapi/domestic-stock/v1/ksdinfo/merger-split`
|
||||
- **예탁원정보(액면교체일정)**: `/uapi/domestic-stock/v1/ksdinfo/rev-split`
|
||||
- **예탁원정보(자본감소일정)**: `/uapi/domestic-stock/v1/ksdinfo/cap-dcrs`
|
||||
- **예탁원정보(상장정보일정)**: `/uapi/domestic-stock/v1/ksdinfo/list-info`
|
||||
- **예탁원정보(공모주청약일정)**: `/uapi/domestic-stock/v1/ksdinfo/pub-offer`
|
||||
- **예탁원정보(실권주일정)**: `/uapi/domestic-stock/v1/ksdinfo/forfeit`
|
||||
- **예탁원정보(의무예치일정)**: `/uapi/domestic-stock/v1/ksdinfo/mand-deposit`
|
||||
- **예탁원정보(유상증자일정)**: `/uapi/domestic-stock/v1/ksdinfo/paidin-capin`
|
||||
- **예탁원정보(무상증자일정)**: `/uapi/domestic-stock/v1/ksdinfo/bonus-issue`
|
||||
- **예탁원정보(주주총회일정)**: `/uapi/domestic-stock/v1/ksdinfo/sharehld-meet`
|
||||
- **국내주식 종목추정실적**: `/uapi/domestic-stock/v1/quotations/estimate-perform`
|
||||
- **당사 대주가능 종목**: `/uapi/domestic-stock/v1/quotations/lendable-by-company`
|
||||
- **국내주식 종목투자의견**: `/uapi/domestic-stock/v1/quotations/invest-opinion`
|
||||
- **국내주식 증권사별 투자의견**: `/uapi/domestic-stock/v1/quotations/invest-opbysec`
|
||||
|
||||
### [국내주식] 시세분석
|
||||
|
||||
- **종목조건검색 목록조회**: `/uapi/domestic-stock/v1/quotations/psearch-title`
|
||||
- **종목조건검색조회**: `/uapi/domestic-stock/v1/quotations/psearch-result`
|
||||
- **관심종목 그룹조회**: `/uapi/domestic-stock/v1/quotations/intstock-grouplist`
|
||||
- **관심종목(멀티종목) 시세조회**: `/uapi/domestic-stock/v1/quotations/intstock-multprice`
|
||||
- **관심종목 그룹별 종목조회**: `/uapi/domestic-stock/v1/quotations/intstock-stocklist-by-group`
|
||||
- **국내기관\_외국인 매매종목가집계**: `/uapi/domestic-stock/v1/quotations/foreign-institution-total`
|
||||
- **외국계 매매종목 가집계**: `/uapi/domestic-stock/v1/quotations/frgnmem-trade-estimate`
|
||||
- **종목별 투자자매매동향(일별)**: `/uapi/domestic-stock/v1/quotations/investor-trade-by-stock-daily`
|
||||
- **시장별 투자자매매동향(시세)**: `/uapi/domestic-stock/v1/quotations/inquire-investor-time-by-market`
|
||||
- **시장별 투자자매매동향(일별)**: `/uapi/domestic-stock/v1/quotations/inquire-investor-daily-by-market`
|
||||
- **종목별 외국계 순매수추이**: `/uapi/domestic-stock/v1/quotations/frgnmem-pchs-trend`
|
||||
- **회원사 실시간 매매동향(틱)**: `/uapi/domestic-stock/v1/quotations/frgnmem-trade-trend`
|
||||
- **주식현재가 회원사 종목매매동향**: `/uapi/domestic-stock/v1/quotations/inquire-member-daily`
|
||||
- **종목별 프로그램매매추이(체결)**: `/uapi/domestic-stock/v1/quotations/program-trade-by-stock`
|
||||
- **종목별 프로그램매매추이(일별)**: `/uapi/domestic-stock/v1/quotations/program-trade-by-stock-daily`
|
||||
- **종목별 외인기관 추정가집계**: `/uapi/domestic-stock/v1/quotations/investor-trend-estimate`
|
||||
- **종목별일별매수매도체결량**: `/uapi/domestic-stock/v1/quotations/inquire-daily-trade-volume`
|
||||
- **프로그램매매 종합현황(시간)**: `/uapi/domestic-stock/v1/quotations/comp-program-trade-today`
|
||||
- **프로그램매매 종합현황(일별)**: `/uapi/domestic-stock/v1/quotations/comp-program-trade-daily`
|
||||
- **프로그램매매 투자자매매동향(당일)**: `/uapi/domestic-stock/v1/quotations/investor-program-trade-today`
|
||||
- **국내주식 신용잔고 일별추이**: `/uapi/domestic-stock/v1/quotations/daily-credit-balance`
|
||||
- **국내주식 예상체결가 추이**: `/uapi/domestic-stock/v1/quotations/exp-price-trend`
|
||||
- **국내주식 공매도 일별추이**: `/uapi/domestic-stock/v1/quotations/daily-short-sale`
|
||||
- **국내주식 시간외예상체결등락률**: `/uapi/domestic-stock/v1/ranking/overtime-exp-trans-fluct`
|
||||
- **국내주식 체결금액별 매매비중**: `/uapi/domestic-stock/v1/quotations/tradprt-byamt`
|
||||
- **국내 증시자금 종합**: `/uapi/domestic-stock/v1/quotations/mktfunds`
|
||||
- **종목별 일별 대차거래추이**: `/uapi/domestic-stock/v1/quotations/daily-loan-trans`
|
||||
- **국내주식 상하한가 포착**: `/uapi/domestic-stock/v1/quotations/capture-uplowprice`
|
||||
- **국내주식 매물대/거래비중**: `/uapi/domestic-stock/v1/quotations/pbar-tratio`
|
||||
|
||||
### [국내주식] 순위분석
|
||||
|
||||
- **거래량순위**: `/uapi/domestic-stock/v1/quotations/volume-rank`
|
||||
- **국내주식 등락률 순위**: `/uapi/domestic-stock/v1/ranking/fluctuation`
|
||||
- **국내주식 호가잔량 순위**: `/uapi/domestic-stock/v1/ranking/quote-balance`
|
||||
- **국내주식 수익자산지표 순위**: `/uapi/domestic-stock/v1/ranking/profit-asset-index`
|
||||
- **국내주식 시가총액 상위**: `/uapi/domestic-stock/v1/ranking/market-cap`
|
||||
- **국내주식 재무비율 순위**: `/uapi/domestic-stock/v1/ranking/finance-ratio`
|
||||
- **국내주식 시간외잔량 순위**: `/uapi/domestic-stock/v1/ranking/after-hour-balance`
|
||||
- **국내주식 우선주/괴리율 상위**: `/uapi/domestic-stock/v1/ranking/prefer-disparate-ratio`
|
||||
- **국내주식 이격도 순위**: `/uapi/domestic-stock/v1/ranking/disparity`
|
||||
- **국내주식 시장가치 순위**: `/uapi/domestic-stock/v1/ranking/market-value`
|
||||
- **국내주식 체결강도 상위**: `/uapi/domestic-stock/v1/ranking/volume-power`
|
||||
- **국내주식 관심종목등록 상위**: `/uapi/domestic-stock/v1/ranking/top-interest-stock`
|
||||
- **국내주식 예상체결 상승/하락상위**: `/uapi/domestic-stock/v1/ranking/exp-trans-updown`
|
||||
- **국내주식 당사매매종목 상위**: `/uapi/domestic-stock/v1/ranking/traded-by-company`
|
||||
- **국내주식 신고/신저근접종목 상위**: `/uapi/domestic-stock/v1/ranking/near-new-highlow`
|
||||
- **국내주식 배당률 상위**: `/uapi/domestic-stock/v1/ranking/dividend-rate`
|
||||
- **국내주식 대량체결건수 상위**: `/uapi/domestic-stock/v1/ranking/bulk-trans-num`
|
||||
- **국내주식 신용잔고 상위**: `/uapi/domestic-stock/v1/ranking/credit-balance`
|
||||
- **국내주식 공매도 상위종목**: `/uapi/domestic-stock/v1/ranking/short-sale`
|
||||
- **국내주식 시간외등락율순위**: `/uapi/domestic-stock/v1/ranking/overtime-fluctuation`
|
||||
- **국내주식 시간외거래량순위**: `/uapi/domestic-stock/v1/ranking/overtime-volume`
|
||||
- **HTS조회상위20종목**: `/uapi/domestic-stock/v1/ranking/hts-top-view`
|
||||
|
||||
### [국내주식] 실시간시세
|
||||
|
||||
- **국내주식 실시간체결가 (KRX)**: `/tryitout/H0STCNT0`
|
||||
- **국내주식 실시간호가 (KRX)**: `/tryitout/H0STASP0`
|
||||
- **국내주식 실시간체결통보**: `/tryitout/H0STCNI0`
|
||||
- **국내주식 실시간예상체결 (KRX)**: `/tryitout/H0STANC0`
|
||||
- **국내주식 실시간회원사 (KRX)**: `/tryitout/H0STMBC0`
|
||||
- **국내주식 실시간프로그램매매 (KRX)**: `/tryitout/H0STPGM0`
|
||||
- **국내주식 장운영정보 (KRX)**: `/tryitout/H0STMKO0`
|
||||
- **국내주식 시간외 실시간호가 (KRX)**: `/tryitout/H0STOAA0`
|
||||
- **국내주식 시간외 실시간체결가 (KRX)**: `/tryitout/H0STOUP0`
|
||||
- **국내주식 시간외 실시간예상체결 (KRX)**: `/tryitout/H0STOAC0`
|
||||
- **국내지수 실시간체결**: `/tryitout/H0UPCNT0`
|
||||
- **국내지수 실시간예상체결**: `/tryitout/H0UPANC0`
|
||||
- **국내지수 실시간프로그램매매**: `/tryitout/H0UPPGM0`
|
||||
- **ELW 실시간호가**: `/tryitout/H0EWASP0`
|
||||
- **ELW 실시간체결가**: `/tryitout/H0EWCNT0`
|
||||
- **ELW 실시간예상체결**: `/tryitout/H0EWANC0`
|
||||
- **국내ETF NAV추이**: `/tryitout/H0STNAV0`
|
||||
- **국내주식 실시간체결가 (통합)**: `/tryitout/H0UNCNT0`
|
||||
- **국내주식 실시간호가 (통합)**: `/tryitout/H0UNASP0`
|
||||
- **국내주식 실시간예상체결 (통합)**: `/tryitout/H0UNANC0`
|
||||
- **국내주식 실시간회원사 (통합)**: `/tryitout/H0UNMBC0`
|
||||
- **국내주식 실시간프로그램매매 (통합)**: `/tryitout/H0UNPGM0`
|
||||
- **국내주식 장운영정보 (통합)**: `/tryitout/H0UNMKO0`
|
||||
- **국내주식 실시간체결가 (NXT)**: `/tryitout/H0NXCNT0`
|
||||
- **국내주식 실시간호가 (NXT)**: `/tryitout/H0NXASP0`
|
||||
- **국내주식 실시간예상체결 (NXT)**: `/tryitout/H0NXANC0`
|
||||
- **국내주식 실시간회원사 (NXT)**: `/tryitout/H0NXMBC0`
|
||||
- **국내주식 실시간프로그램매매 (NXT)**: `/tryitout/H0NXPGM0`
|
||||
- **국내주식 장운영정보 (NXT)**: `/tryitout/H0NXMKO0`
|
||||
|
||||
### [국내선물옵션] 주문/계좌
|
||||
|
||||
- **선물옵션 주문**: `/uapi/domestic-futureoption/v1/trading/order`
|
||||
- **선물옵션 정정취소주문**: `/uapi/domestic-futureoption/v1/trading/order-rvsecncl`
|
||||
- **선물옵션 주문체결내역조회**: `/uapi/domestic-futureoption/v1/trading/inquire-ccnl`
|
||||
- **선물옵션 잔고현황**: `/uapi/domestic-futureoption/v1/trading/inquire-balance`
|
||||
- **선물옵션 주문가능**: `/uapi/domestic-futureoption/v1/trading/inquire-psbl-order`
|
||||
- **(야간)선물옵션 주문체결 내역조회**: `/uapi/domestic-futureoption/v1/trading/inquire-ngt-ccnl`
|
||||
- **(야간)선물옵션 잔고현황**: `/uapi/domestic-futureoption/v1/trading/inquire-ngt-balance`
|
||||
- **(야간)선물옵션 주문가능 조회**: `/uapi/domestic-futureoption/v1/trading/inquire-psbl-ngt-order`
|
||||
- **(야간)선물옵션 증거금 상세**: `/uapi/domestic-futureoption/v1/trading/ngt-margin-detail`
|
||||
- **선물옵션 잔고정산손익내역**: `/uapi/domestic-futureoption/v1/trading/inquire-balance-settlement-pl`
|
||||
- **선물옵션 총자산현황**: `/uapi/domestic-futureoption/v1/trading/inquire-deposit`
|
||||
- **선물옵션 잔고평가손익내역**: `/uapi/domestic-futureoption/v1/trading/inquire-balance-valuation-pl`
|
||||
- **선물옵션 기준일체결내역**: `/uapi/domestic-futureoption/v1/trading/inquire-ccnl-bstime`
|
||||
- **선물옵션기간약정수수료일별**: `/uapi/domestic-futureoption/v1/trading/inquire-daily-amount-fee`
|
||||
|
||||
### [국내선물옵션] 기본시세
|
||||
|
||||
- **선물옵션 시세**: `/uapi/domestic-futureoption/v1/quotations/inquire-price`
|
||||
- **선물옵션 시세호가**: `/uapi/domestic-futureoption/v1/quotations/inquire-asking-price`
|
||||
- **선물옵션기간별시세(일/주/월/년)**: `/uapi/domestic-futureoption/v1/quotations/inquire-daily-fuopchartprice`
|
||||
- **선물옵션 분봉조회**: `/uapi/domestic-futureoption/v1/quotations/inquire-time-fuopchartprice`
|
||||
- **국내옵션전광판\_옵션월물리스트**: `/uapi/domestic-futureoption/v1/quotations/display-board-option-list`
|
||||
- **국내선물 기초자산 시세**: `/uapi/domestic-futureoption/v1/quotations/display-board-top`
|
||||
- **국내옵션전광판\_콜풋**: `/uapi/domestic-futureoption/v1/quotations/display-board-callput`
|
||||
- **국내옵션전광판\_선물**: `/uapi/domestic-futureoption/v1/quotations/display-board-futures`
|
||||
- **선물옵션 일중예상체결추이**: `/uapi/domestic-futureoption/v1/quotations/exp-price-trend`
|
||||
|
||||
### [국내선물옵션] 실시간시세
|
||||
|
||||
- **지수선물 실시간호가**: `/tryitout/H0IFASP0`
|
||||
- **지수선물 실시간체결가**: `/tryitout/H0IFCNT0`
|
||||
- **지수옵션 실시간호가**: `/tryitout/H0IOASP0`
|
||||
- **지수옵션 실시간체결가**: `/tryitout/H0IOCNT0`
|
||||
- **선물옵션 실시간체결통보**: `/tryitout/H0IFCNI0`
|
||||
- **상품선물 실시간호가**: `/tryitout/H0CFASP0`
|
||||
- **상품선물 실시간체결가**: `/tryitout/H0CFCNT0`
|
||||
- **주식선물 실시간호가**: `/tryitout/H0ZFASP0`
|
||||
- **주식선물 실시간체결가**: `/tryitout/H0ZFCNT0`
|
||||
- **주식선물 실시간예상체결**: `/tryitout/H0ZFANC0`
|
||||
- **주식옵션 실시간호가**: `/tryitout/H0ZOASP0`
|
||||
- **주식옵션 실시간체결가**: `/tryitout/H0ZOCNT0`
|
||||
- **주식옵션 실시간예상체결**: `/tryitout/H0ZOANC0`
|
||||
- **KRX야간옵션 실시간호가**: `/tryitout/H0EUASP0`
|
||||
- **KRX야간옵션 실시간체결가**: `/tryitout/H0EUCNT0`
|
||||
- **KRX야간옵션실시간예상체결**: `/tryitout/H0EUANC0`
|
||||
- **KRX야간옵션실시간체결통보**: `/tryitout/H0EUCNI0`
|
||||
- **KRX야간선물 실시간호가**: `/tryitout/H0MFASP0`
|
||||
- **KRX야간선물 실시간종목체결**: `/tryitout/H0MFCNT0`
|
||||
- **KRX야간선물 실시간체결통보**: `/tryitout/H0MFCNI0`
|
||||
|
||||
### [해외주식] 주문/계좌
|
||||
|
||||
- **해외주식 주문**: `/uapi/overseas-stock/v1/trading/order`
|
||||
- **해외주식 정정취소주문**: `/uapi/overseas-stock/v1/trading/order-rvsecncl`
|
||||
- **해외주식 예약주문접수**: `/uapi/overseas-stock/v1/trading/order-resv`
|
||||
- **해외주식 예약주문접수취소**: `/uapi/overseas-stock/v1/trading/order-resv-ccnl`
|
||||
- **해외주식 매수가능금액조회**: `/uapi/overseas-stock/v1/trading/inquire-psamount`
|
||||
- **해외주식 미체결내역**: `/uapi/overseas-stock/v1/trading/inquire-nccs`
|
||||
- **해외주식 잔고**: `/uapi/overseas-stock/v1/trading/inquire-balance`
|
||||
- **해외주식 주문체결내역**: `/uapi/overseas-stock/v1/trading/inquire-ccnl`
|
||||
- **해외주식 체결기준현재잔고**: `/uapi/overseas-stock/v1/trading/inquire-present-balance`
|
||||
- **해외주식 예약주문조회**: `/uapi/overseas-stock/v1/trading/order-resv-list`
|
||||
- **해외주식 결제기준잔고**: `/uapi/overseas-stock/v1/trading/inquire-paymt-stdr-balance`
|
||||
- **해외주식 일별거래내역**: `/uapi/overseas-stock/v1/trading/inquire-period-trans`
|
||||
- **해외주식 기간손익**: `/uapi/overseas-stock/v1/trading/inquire-period-profit`
|
||||
- **해외증거금 통화별조회**: `/uapi/overseas-stock/v1/trading/foreign-margin`
|
||||
- **해외주식 미국주간주문**: `/uapi/overseas-stock/v1/trading/daytime-order`
|
||||
- **해외주식 미국주간정정취소**: `/uapi/overseas-stock/v1/trading/daytime-order-rvsecncl`
|
||||
- **해외주식 지정가주문번호조회**: `/uapi/overseas-stock/v1/trading/algo-ordno`
|
||||
- **해외주식 지정가체결내역조회**: `/uapi/overseas-stock/v1/trading/inquire-algo-ccnl`
|
||||
|
||||
### [해외주식] 기본시세
|
||||
|
||||
- **해외주식 현재가상세**: `/uapi/overseas-price/v1/quotations/price-detail`
|
||||
- **해외주식 현재가 호가**: `/uapi/overseas-price/v1/quotations/inquire-asking-price`
|
||||
- **해외주식 현재체결가**: `/uapi/overseas-price/v1/quotations/price`
|
||||
- **해외주식 체결추이**: `/uapi/overseas-price/v1/quotations/inquire-ccnl`
|
||||
- **해외주식분봉조회**: `/uapi/overseas-price/v1/quotations/inquire-time-itemchartprice`
|
||||
- **해외지수분봉조회**: `/uapi/overseas-price/v1/quotations/inquire-time-indexchartprice`
|
||||
- **해외주식 기간별시세**: `/uapi/overseas-price/v1/quotations/dailyprice`
|
||||
- **해외주식 종목/지수/환율기간별시세(일/주/월/년)**: `/uapi/overseas-price/v1/quotations/inquire-daily-chartprice`
|
||||
- **해외주식조건검색**: `/uapi/overseas-price/v1/quotations/inquire-search`
|
||||
- **해외결제일자조회**: `/uapi/overseas-stock/v1/quotations/countries-holiday`
|
||||
- **해외주식 상품기본정보**: `/uapi/overseas-price/v1/quotations/search-info`
|
||||
- **해외주식 업종별시세**: `/uapi/overseas-price/v1/quotations/industry-theme`
|
||||
- **해외주식 업종별코드조회**: `/uapi/overseas-price/v1/quotations/industry-price`
|
||||
|
||||
### [해외주식] 시세분석
|
||||
|
||||
- **해외주식 가격급등락**: `/uapi/overseas-stock/v1/ranking/price-fluct`
|
||||
- **해외주식 거래량급증**: `/uapi/overseas-stock/v1/ranking/volume-surge`
|
||||
- **해외주식 매수체결강도상위**: `/uapi/overseas-stock/v1/ranking/volume-power`
|
||||
- **해외주식 상승율/하락율**: `/uapi/overseas-stock/v1/ranking/updown-rate`
|
||||
- **해외주식 신고/신저가**: `/uapi/overseas-stock/v1/ranking/new-highlow`
|
||||
- **해외주식 거래량순위**: `/uapi/overseas-stock/v1/ranking/trade-vol`
|
||||
- **해외주식 거래대금순위**: `/uapi/overseas-stock/v1/ranking/trade-pbmn`
|
||||
- **해외주식 거래증가율순위**: `/uapi/overseas-stock/v1/ranking/trade-growth`
|
||||
- **해외주식 거래회전율순위**: `/uapi/overseas-stock/v1/ranking/trade-turnover`
|
||||
- **해외주식 시가총액순위**: `/uapi/overseas-stock/v1/ranking/market-cap`
|
||||
- **해외주식 기간별권리조회**: `/uapi/overseas-price/v1/quotations/period-rights`
|
||||
- **해외뉴스종합(제목)**: `/uapi/overseas-price/v1/quotations/news-title`
|
||||
- **해외주식 권리종합**: `/uapi/overseas-price/v1/quotations/rights-by-ice`
|
||||
- **당사 해외주식담보대출 가능 종목**: `/uapi/overseas-price/v1/quotations/colable-by-company`
|
||||
- **해외속보(제목)**: `/uapi/overseas-price/v1/quotations/brknews-title`
|
||||
|
||||
### [해외주식] 실시간시세
|
||||
|
||||
- **해외주식 실시간호가**: `/tryitout/HDFSASP0`
|
||||
- **해외주식 지연호가(아시아)**: `/tryitout/HDFSASP1`
|
||||
- **해외주식 실시간지연체결가**: `/tryitout/HDFSCNT0`
|
||||
- **해외주식 실시간체결통보**: `/tryitout/H0GSCNI0`
|
||||
|
||||
### [해외선물옵션] 주문/계좌
|
||||
|
||||
- **해외선물옵션 주문**: `/uapi/overseas-futureoption/v1/trading/order`
|
||||
- **해외선물옵션 정정취소주문**: `/uapi/overseas-futureoption/v1/trading/order-rvsecncl`
|
||||
- **해외선물옵션 당일주문내역조회**: `/uapi/overseas-futureoption/v1/trading/inquire-ccld`
|
||||
- **해외선물옵션 미결제내역조회(잔고)**: `/uapi/overseas-futureoption/v1/trading/inquire-unpd`
|
||||
- **해외선물옵션 주문가능조회**: `/uapi/overseas-futureoption/v1/trading/inquire-psamount`
|
||||
- **해외선물옵션 기간계좌손익 일별**: `/uapi/overseas-futureoption/v1/trading/inquire-period-ccld`
|
||||
- **해외선물옵션 일별 체결내역**: `/uapi/overseas-futureoption/v1/trading/inquire-daily-ccld`
|
||||
- **해외선물옵션 예수금현황**: `/uapi/overseas-futureoption/v1/trading/inquire-deposit`
|
||||
- **해외선물옵션 일별 주문내역**: `/uapi/overseas-futureoption/v1/trading/inquire-daily-order`
|
||||
- **해외선물옵션 기간계좌거래내역**: `/uapi/overseas-futureoption/v1/trading/inquire-period-trans`
|
||||
- **해외선물옵션 증거금상세**: `/uapi/overseas-futureoption/v1/trading/margin-detail`
|
||||
|
||||
### [해외선물옵션] 기본시세
|
||||
|
||||
- **해외선물종목현재가**: `/uapi/overseas-futureoption/v1/quotations/inquire-price`
|
||||
- **해외선물종목상세**: `/uapi/overseas-futureoption/v1/quotations/stock-detail`
|
||||
- **해외선물 호가**: `/uapi/overseas-futureoption/v1/quotations/inquire-asking-price`
|
||||
- **해외선물 분봉조회**: `/uapi/overseas-futureoption/v1/quotations/inquire-time-futurechartprice`
|
||||
- **해외선물 체결추이(틱)**: `/uapi/overseas-futureoption/v1/quotations/tick-ccnl`
|
||||
- **해외선물 체결추이(주간)**: `/uapi/overseas-futureoption/v1/quotations/weekly-ccnl`
|
||||
- **해외선물 체결추이(일간)**: `/uapi/overseas-futureoption/v1/quotations/daily-ccnl`
|
||||
- **해외선물 체결추이(월간)**: `/uapi/overseas-futureoption/v1/quotations/monthly-ccnl`
|
||||
- **해외선물 상품기본정보**: `/uapi/overseas-futureoption/v1/quotations/search-contract-detail`
|
||||
- **해외선물 미결제추이**: `/uapi/overseas-futureoption/v1/quotations/investor-unpd-trend`
|
||||
- **해외옵션종목현재가**: `/uapi/overseas-futureoption/v1/quotations/opt-price`
|
||||
- **해외옵션종목상세**: `/uapi/overseas-futureoption/v1/quotations/opt-detail`
|
||||
- **해외옵션 호가**: `/uapi/overseas-futureoption/v1/quotations/opt-asking-price`
|
||||
- **해외옵션 분봉조회**: `/uapi/overseas-futureoption/v1/quotations/inquire-time-optchartprice`
|
||||
- **해외옵션 체결추이(틱)**: `/uapi/overseas-futureoption/v1/quotations/opt-tick-ccnl`
|
||||
- **해외옵션 체결추이(일간)**: `/uapi/overseas-futureoption/v1/quotations/opt-daily-ccnl`
|
||||
- **해외옵션 체결추이(주간)**: `/uapi/overseas-futureoption/v1/quotations/opt-weekly-ccnl`
|
||||
- **해외옵션 체결추이(월간)**: `/uapi/overseas-futureoption/v1/quotations/opt-monthly-ccnl`
|
||||
- **해외옵션 상품기본정보**: `/uapi/overseas-futureoption/v1/quotations/search-opt-detail`
|
||||
- **해외선물옵션 장운영시간**: `/uapi/overseas-futureoption/v1/quotations/market-time`
|
||||
|
||||
### [해외선물옵션]실시간시세
|
||||
|
||||
- **해외선물옵션 실시간체결가**: `/tryitout/HDFFF020`
|
||||
- **해외선물옵션 실시간호가**: `/tryitout/HDFFF010`
|
||||
- **해외선물옵션 실시간주문내역통보**: `/tryitout/HDFFF1C0`
|
||||
- **해외선물옵션 실시간체결내역통보**: `/tryitout/HDFFF2C0`
|
||||
|
||||
### [장내채권] 주문/계좌
|
||||
|
||||
- **장내채권 매수주문**: `/uapi/domestic-bond/v1/trading/buy`
|
||||
- **장내채권 매도주문**: `/uapi/domestic-bond/v1/trading/sell`
|
||||
- **장내채권 정정취소주문**: `/uapi/domestic-bond/v1/trading/order-rvsecncl`
|
||||
- **채권정정취소가능주문조회**: `/uapi/domestic-bond/v1/trading/inquire-psbl-rvsecncl`
|
||||
- **장내채권 주문체결내역**: `/uapi/domestic-bond/v1/trading/inquire-daily-ccld`
|
||||
- **장내채권 잔고조회**: `/uapi/domestic-bond/v1/trading/inquire-balance`
|
||||
- **장내채권 매수가능조회**: `/uapi/domestic-bond/v1/trading/inquire-psbl-order`
|
||||
|
||||
### [장내채권] 기본시세
|
||||
|
||||
- **장내채권현재가(호가)**: `/uapi/domestic-bond/v1/quotations/inquire-asking-price`
|
||||
- **장내채권현재가(시세)**: `/uapi/domestic-bond/v1/quotations/inquire-price`
|
||||
- **장내채권현재가(체결)**: `/uapi/domestic-bond/v1/quotations/inquire-ccnl`
|
||||
- **장내채권현재가(일별)**: `/uapi/domestic-bond/v1/quotations/inquire-daily-price`
|
||||
- **장내채권 기간별시세(일)**: `/uapi/domestic-bond/v1/quotations/inquire-daily-itemchartprice`
|
||||
- **장내채권 평균단가조회**: `/uapi/domestic-bond/v1/quotations/avg-unit`
|
||||
- **장내채권 발행정보**: `/uapi/domestic-bond/v1/quotations/issue-info`
|
||||
- **장내채권 기본조회**: `/uapi/domestic-bond/v1/quotations/search-bond-info`
|
||||
|
||||
### [장내채권] 실시간시세
|
||||
|
||||
- **일반채권 실시간체결가**: `/tryitout/H0BJCNT0`
|
||||
- **일반채권 실시간호가**: `/tryitout/H0BJASP0`
|
||||
- **채권지수 실시간체결가**: `/tryitout/H0BICNT0`
|
||||
BIN
common-docs/api-reference/openapi_all.xlsx
Normal file
BIN
common-docs/api-reference/openapi_all.xlsx
Normal file
Binary file not shown.
266
common-docs/features-autotrade-design.md
Normal file
266
common-docs/features-autotrade-design.md
Normal file
@@ -0,0 +1,266 @@
|
||||
# 브라우저 상주 자동매매 통합 계획서 v3.1 (AI/무저장 정책 반영)
|
||||
|
||||
## 요약
|
||||
1. 자동매매는 브라우저가 켜져 있을 때만 동작합니다.
|
||||
2. 백그라운드 탭(가려진 탭)에서는 동작을 허용합니다.
|
||||
3. 탭 종료, 브라우저 종료, 앱 종료, 외부 페이지 이탈 시 자동주문은 즉시 중지됩니다.
|
||||
4. 종료 직전 강한 경고를 보여주고 중지 이벤트를 서버에 기록합니다.
|
||||
5. 투자금/손실한도는 퍼센트와 금액을 동시에 받고 더 보수적인 값(더 작은 값)을 실적용합니다.
|
||||
6. 전략 선택은 프롬프트 입력, 검수 카탈로그, 온라인 실시간 수집을 모두 지원하며 복수선택 가능합니다.
|
||||
7. 실거래 우선, 장중 기본, 보수적 위험관리 기본값을 유지합니다.
|
||||
8. AI(인공지능)로 매수/매도 신호 후보를 만들고, 최종 주문은 규칙 엔진(고정 검증 로직)이 결정합니다.
|
||||
9. 한국투자증권 API 키/시크릿/계좌번호는 서버 DB에 저장하지 않습니다.
|
||||
10. KIS 민감정보는 브라우저 실행 세션 기준으로만 유지하고, 서버는 요청 처리 시에만 일시 사용합니다.
|
||||
|
||||
## 1) 기술 아키텍처
|
||||
1. 프론트엔드: Next.js 16 App Router + React 19 + TypeScript.
|
||||
2. 상태관리: Zustand 기반 `autotrade-engine-store` 신규.
|
||||
3. 실시간: 기존 KIS WebSocket 스토어 재사용, 자동매매 엔진 훅으로 연결.
|
||||
4. 서버 API: Next.js Route Handler(Node 런타임)로 전략/세션/로그/중지 API 제공.
|
||||
5. 데이터 저장: Supabase Postgres + RLS(행 단위 권한).
|
||||
6. 인증: Supabase Auth 세션 필수.
|
||||
7. 보안: KIS 민감정보는 서버 저장 금지, 요청 단위(한 번 호출)로만 처리.
|
||||
|
||||
## 2) 배포 구조
|
||||
1. 앱 배포: Vercel(기존 유지).
|
||||
2. DB/인증: Supabase(기존 유지).
|
||||
3. 자동매매 엔진: 브라우저 내부 실행(별도 워커 서버 없음).
|
||||
4. 서버 역할: 주문 위임, 상태 기록, 위험한도 검증, 감사로그 저장(민감정보 저장 제외).
|
||||
5. 만료 정리: Vercel Cron(1분 주기) 또는 DB 함수로 heartbeat 만료 세션 `stopped` 전환.
|
||||
6. 장애 로그: Vercel Logs + Supabase Logs + Sentry(권장) 연동.
|
||||
|
||||
## 3) 필수 환경변수
|
||||
1. `NEXT_PUBLIC_SUPABASE_URL`
|
||||
2. `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
||||
3. `SUPABASE_SERVICE_ROLE_KEY`
|
||||
4. `AUTOTRADE_HEARTBEAT_TTL_SEC` (기본 90)
|
||||
5. `AUTOTRADE_MAX_DAILY_ORDERS_DEFAULT` (기본 20)
|
||||
6. `AUTOTRADE_ONLINE_STRATEGY_ENABLED` (기본 true)
|
||||
7. `ONLINE_STRATEGY_PROVIDER_KEY` (온라인 수집용 키)
|
||||
8. `KIS_SERVER_STORAGE_DISABLED` (고정값 `true`, 서버 저장 차단 가드)
|
||||
|
||||
## 3-1) KIS 키/계좌 무저장 정책(추가)
|
||||
1. 저장 금지 대상: `appKey`, `appSecret`, `accountNo`, `accountProductCode`.
|
||||
2. 서버 DB(Supabase 포함)에는 위 값을 절대 저장하지 않습니다.
|
||||
3. 서버 로그에도 원문을 남기지 않고 마스킹(일부 가리기) 처리합니다.
|
||||
4. 자동매매 요청 시 민감정보는 헤더로 전달하고, 요청 처리 후 즉시 폐기합니다.
|
||||
5. 브라우저 보관은 `sessionStorage` 우선, `localStorage` 영구 저장은 자동매매 모드에서 금지합니다.
|
||||
6. UI 흐름: 설정 UI 입력 -> 메모리/세션 저장 -> API 호출 헤더 전달 -> 서버 즉시 사용 후 폐기.
|
||||
|
||||
## 4) 데이터 모델(Supabase)
|
||||
1. `auto_trade_strategies`
|
||||
2. 주요 컬럼: `user_id`, `name`, `strategy_source_type(prompt|catalog|online)`, `symbols[]`, `allocation_percent`, `allocation_amount`, `effective_allocation_amount`, `daily_loss_percent`, `daily_loss_amount`, `effective_daily_loss_limit`, `resolved_params(jsonb)`, `status`.
|
||||
3. `auto_trade_sessions`
|
||||
4. 주요 컬럼: `strategy_id`, `desired_state`, `runtime_state`, `leader_tab_id`, `last_heartbeat_at`, `started_at`, `ended_at`, `stop_reason`.
|
||||
5. `auto_trade_order_attempts`
|
||||
6. 주요 컬럼: `session_id`, `symbol`, `idempotency_key(unique)`, `request_payload`, `response_payload`, `status`, `blocked_reason`.
|
||||
7. `auto_trade_signal_logs`
|
||||
8. 주요 컬럼: `session_id`, `signal_payload`, `decision(execute|skip|block)`, `decision_reason`, `source_type`, `risk_grade`.
|
||||
9. `auto_trade_online_strategies`
|
||||
10. 주요 컬럼: `title`, `source_url`, `strategy_text`, `fetched_at`, `parser_score`, `risk_grade`, `is_approved`.
|
||||
11. `auto_trade_audit_logs`
|
||||
12. 주요 컬럼: `user_id`, `action`, `payload`, `created_at`.
|
||||
13. `kis_credentials*` 계열 테이블은 만들지 않습니다(무저장 정책).
|
||||
|
||||
## 5) API 설계
|
||||
1. `POST /api/autotrade/strategies/compile`
|
||||
2. 입력: 프롬프트/온라인 텍스트.
|
||||
3. 출력: 표준 규칙(JSON) + 검증결과.
|
||||
4. `POST /api/autotrade/strategies/validate`
|
||||
5. 출력: 실행 가능 여부, 차단 사유.
|
||||
6. `GET /api/autotrade/templates`
|
||||
7. 검수 카탈로그 전략 목록 제공.
|
||||
8. `POST /api/autotrade/strategies/discover`
|
||||
9. 온라인 실시간 수집 전략 목록 제공.
|
||||
10. `POST /api/autotrade/strategies`
|
||||
11. 전략 저장(배분/손실한도 실적용값 계산 포함).
|
||||
12. `POST /api/autotrade/sessions/start`
|
||||
13. 세션 시작 + 리스크 스냅샷 생성.
|
||||
14. `POST /api/autotrade/sessions/heartbeat`
|
||||
15. 리더 탭 생존신호 갱신.
|
||||
16. `POST /api/autotrade/sessions/stop`
|
||||
17. `reason`: `browser_exit|external_leave|manual|emergency|heartbeat_timeout`.
|
||||
18. `GET /api/autotrade/sessions/active`
|
||||
19. 현재 실행 세션/리더 정보 조회.
|
||||
20. `GET /api/autotrade/sessions/{id}/logs`
|
||||
21. 신호/주문/오류 로그 조회.
|
||||
22. 자동매매 관련 API(주문/세션/리스크)는 요청 헤더에 KIS 정보 포함이 필수입니다.
|
||||
23. 서버는 헤더 값 유효성만 검사하고 DB에는 저장하지 않습니다.
|
||||
24. 실패 응답/에러 로그에서도 민감정보는 마스킹합니다.
|
||||
|
||||
## 5-1) AI 자동매매 설계(추가)
|
||||
1. 핵심 원칙: AI는 "신호 후보 생성기", 최종 주문 판단은 "규칙 엔진"이 담당.
|
||||
2. 이유: AI 단독 주문은 일관성(항상 같은 판단)과 추적성이 약해 리스크가 큽니다.
|
||||
3. AI 입력 데이터:
|
||||
4. 실시간 체결/호가, 최근 변동성, 거래량, 전략 파라미터, 장 상태(정규장/시간외).
|
||||
5. AI 출력 데이터:
|
||||
6. `signal`(buy/sell/hold), `confidence`(신뢰도), `reason`(한 줄 근거), `ttlSec`(신호 유효시간).
|
||||
7. 실행 흐름:
|
||||
8. 사용자 전략 선택/프롬프트 입력 -> AI 해석 -> 규칙 JSON 변환 -> 리스크 검증 -> 주문 실행/차단.
|
||||
9. 온라인 유명 단타 기법 처리:
|
||||
10. 실시간 수집 -> 정규화(형식 맞추기) -> 위험등급 부여 -> 사용자 선택 -> 검증 통과 시 활성화.
|
||||
11. AI 장애 대응:
|
||||
12. AI 응답 지연/실패 시 신규 주문 중지 또는 보수 모드(`hold`) 강제.
|
||||
13. AI 드리프트(성능 저하) 대응:
|
||||
14. 최근 N건 성능 추적 후 기준 미달 전략 자동 일시정지.
|
||||
15. UI 흐름:
|
||||
16. 전략 화면 -> "AI 제안 받기" 클릭 -> 제안 전략 목록 표시 -> 사용자 선택/수정 -> 저장/시뮬레이션 -> 시작.
|
||||
17. 운영 기본값:
|
||||
18. `confidence`가 임계치(예: 0.65) 미만이면 주문 차단.
|
||||
19. `reason`이 비어 있으면 주문 차단(설명 없는 주문 금지).
|
||||
20. 동일 종목 반대 신호가 짧은 시간에 반복되면 쿨다운 연장.
|
||||
|
||||
## 5-2) 자동매매 설정 팝업 UX(사용자 요청 반영)
|
||||
1. 진입 흐름:
|
||||
2. 자동매매 버튼 클릭 -> 자동매매 설정 팝업 오픈 -> 설정 입력 -> "자동매매 시작" 클릭.
|
||||
3. 팝업 필수 입력:
|
||||
4. 전략 프롬프트(자유 입력)
|
||||
5. 유명 기법 선택(복수 선택): ORB(시가 범위 돌파), VWAP 되돌림, 거래량 돌파, 이동평균 교차, 갭 돌파.
|
||||
6. 투자금 설정: 퍼센트(%) + 금액(원) 동시 입력.
|
||||
7. 전략별 일일 손실한도: 퍼센트(%) + 금액(원) 동시 입력.
|
||||
8. 거래 대상: 종목 다중 선택(또는 관심종목 가져오기).
|
||||
9. 실행 전 검증:
|
||||
10. AI 해석 결과 미리보기(어떤 근거로 매수/매도할지 요약)
|
||||
11. 리스크 요약(실적용 투자금, 실적용 손실한도, 예상 최대 주문 수)
|
||||
12. 동의 체크(브라우저 종료/외부 이탈 시 즉시 중지)
|
||||
13. 버튼 정책:
|
||||
14. 필수값 누락 또는 검증 실패 시 시작 버튼 비활성화.
|
||||
15. 시작 성공 시 상단 고정 배너와 세션 상태 카드 즉시 표시.
|
||||
|
||||
## 5-3) AI API 선택 권장안(실행 가능한 추천)
|
||||
1. 결론:
|
||||
2. 1차는 OpenAI API를 기본으로 시작하고, 2차에서 Gemini/Claude를 붙일 수 있게 다중 제공자 어댑터(연결 레이어) 구조로 개발합니다.
|
||||
3. 추천 이유(요약):
|
||||
4. Structured Outputs(스키마 고정 출력) + Function Calling(함수 호출) 문서/생태계가 성숙해서 자동매매 검증 파이프라인 구성에 유리합니다.
|
||||
5. 비용/속도 최적화 모델 선택지가 넓어 PoC(개념검증) -> 운영 전환이 쉽습니다.
|
||||
6. 제공자별 특징:
|
||||
7. OpenAI: 엄격 모드(`strict`) 기반 함수 스키마 강제가 명확하고, `parallel_tool_calls=false`로 1회 1액션 제어가 쉽습니다.
|
||||
8. Gemini: 함수 호출 모드(`AUTO`/`ANY`/`NONE`/`VALIDATED`)가 명확하고 JSON 스키마 출력 지원이 좋아 대체 제공자로 적합합니다.
|
||||
9. Claude: `strict: true` 도구 호출과 구조화 출력이 강점이며, 보조/백업 제공자로 적합합니다.
|
||||
10. 운영 권장:
|
||||
11. 1차: OpenAI 단일 운영
|
||||
12. 2차: OpenAI 실패/지연 시 Gemini 폴백(대체 경로)
|
||||
13. 3차: Claude까지 확장하는 3중화(고가용성)
|
||||
|
||||
## 5-4) AI 판단 -> 주문 실행 파이프라인(실전형)
|
||||
1. Step 1. 입력 수집:
|
||||
2. 사용자 프롬프트 + 선택한 유명 기법 + 실시간 시세/호가 + 보유/가용자산 + 리스크 한도.
|
||||
3. Step 2. AI 해석:
|
||||
4. AI가 `signal`, `confidence`, `reason`, `ttlSec`, `proposed_order`를 JSON으로 반환.
|
||||
5. Step 3. 규칙 엔진 검증:
|
||||
6. 스키마 검증(형식), 정책 검증(리스크), 시장상태 검증(장중 여부), 중복주문 검증(idempotency).
|
||||
7. Step 4. 주문 결정:
|
||||
8. 검증 통과 -> KIS 주문 API 호출.
|
||||
9. 검증 실패 -> 주문 차단 + 사유 로그 기록.
|
||||
10. Step 5. 사후 평가:
|
||||
11. 체결/미체결 결과를 AI 평가 입력으로 재사용해 프롬프트/기법 가중치 조정.
|
||||
|
||||
## 5-5) AI 호출 프롬프트/출력 표준(권장 JSON)
|
||||
1. 시스템 프롬프트 핵심:
|
||||
2. "너는 주문 실행기가 아니라 신호 생성기다. 스키마에 맞는 JSON만 반환하고 설명문은 금지한다."
|
||||
3. 출력 스키마:
|
||||
4. `signal`: `buy|sell|hold`
|
||||
5. `confidence`: `0~1`
|
||||
6. `reason`: 짧은 한국어 근거
|
||||
7. `proposed_order`: `{symbol, side, orderType, price, quantity}`
|
||||
8. `risk_flags`: `string[]`
|
||||
9. `ttlSec`: 신호 만료 시간
|
||||
10. 차단 규칙:
|
||||
11. `confidence < threshold` 또는 `reason` 누락 또는 `risk_flags`에 차단 사유 포함 시 주문 금지.
|
||||
|
||||
## 5-6) 서버 무저장 정책과 AI 호출 결합 방식
|
||||
1. KIS 민감정보(`appKey`, `appSecret`, `accountNo`)는 AI API 호출 입력에 넣지 않습니다.
|
||||
2. AI에는 가격/지표/포지션 요약 같은 비식별 데이터(개인 식별이 어려운 데이터)만 전달합니다.
|
||||
3. 실제 주문 직전 단계에서만 브라우저 세션의 KIS 정보로 주문 API를 호출합니다.
|
||||
4. 서버는 주문 처리 중 헤더를 일시 사용 후 폐기하며 DB/로그 저장을 금지합니다.
|
||||
5. 에러 로그/감사로그에는 주문 사유와 결과만 남기고 민감값은 마스킹 처리합니다.
|
||||
|
||||
## 6) 브라우저 엔진 동작
|
||||
1. 엔진 상태: `IDLE`, `ARMED`, `RUNNING`, `STOPPING`, `STOPPED`, `ERROR`.
|
||||
2. 멀티탭 제어: `localStorage` lock + `BroadcastChannel` 동기화.
|
||||
3. 리더 탭만 주문 실행, 팔로워 탭은 조회 전용.
|
||||
4. 주문은 틱 이벤트(WebSocket 수신) 기반으로 처리해 백그라운드 타이머 지연 영향을 줄입니다.
|
||||
5. heartbeat 10초 주기 전송, TTL 90초 초과 시 서버 강제 종료.
|
||||
6. 새로고침 시 로컬 snapshot으로 이어서 실행.
|
||||
7. 브라우저 완전 종료 후 재진입 시 자동 재개 금지, `중지 상태`로 복구 후 사용자 재시작 필요.
|
||||
8. 백그라운드 탭에서도 WebSocket 이벤트 기반으로 신호 계산/주문은 유지합니다.
|
||||
|
||||
## 7) 강한 경고/즉시 중지 UX
|
||||
1. 실행 중 상단 빨간 경고 바 고정: "브라우저/탭 종료 또는 외부 이동 시 자동주문이 즉시 중지됩니다."
|
||||
2. 외부 링크 클릭 시 사전 모달 강제: "이동하면 자동매매가 중지됩니다. 계속할까요?"
|
||||
3. 탭 닫기/브라우저 종료는 `beforeunload` 기본 경고 사용.
|
||||
4. 종료 시퀀스: `STOPPING` 전환 -> 신규 주문 차단 -> `sendBeacon(stop)` -> lock 해제 -> `STOPPED`.
|
||||
5. 브라우저 보안 제한으로 `beforeunload` 커스텀 문구는 사용하지 않습니다(표준 경고만 가능).
|
||||
|
||||
## 8) 자산 배분/손실한도 입력 규칙
|
||||
1. 투자금 입력: `퍼센트(%)` + `금액(원)` 동시 입력.
|
||||
2. 실적용 투자금: `min(가용자산*퍼센트, 금액)`.
|
||||
3. 일일 손실한도 입력: `퍼센트(%)` + `금액(원)` 동시 입력.
|
||||
4. 실적용 손실한도: `min(전략투자금*퍼센트, 금액)`.
|
||||
5. UI에 실적용 값 실시간 계산 표시.
|
||||
6. 유효성 검증: 0보다 큰 값, 최대 퍼센트 상한, 가용자산 초과 금액 차단.
|
||||
7. UI에 "현재 가용자산 기준 실제 주문 가능 금액"을 즉시 표시합니다.
|
||||
|
||||
## 9) 전략 선택 체계(복수선택)
|
||||
1. 소스 탭 3개: `프롬프트`, `검수 카탈로그`, `온라인 실시간 수집`.
|
||||
2. 사용자는 소스별 전략을 여러 개 선택해 하나의 실행세트로 저장 가능.
|
||||
3. 프롬프트 전략: 자연어 입력 -> 컴파일 -> 검증 통과 시 활성화.
|
||||
4. 카탈로그 전략: 운영 검수 완료 버전만 제공.
|
||||
5. 온라인 전략: 실시간 수집 결과를 보여주되 검증 통과 전에는 실행 금지.
|
||||
6. 온라인/프롬프트 전략은 위험등급(`low|mid|high`) 자동 부여 후 실행 제한에 반영.
|
||||
|
||||
## 10) 보수적 위험관리 기본값
|
||||
1. 전략별 일일 손실한도 기본 2%.
|
||||
2. 전략별 일일 최대 주문 20건.
|
||||
3. 종목별 주문 쿨다운 60초.
|
||||
4. 단일 주문 상한: 전략 투자금의 25%.
|
||||
5. 데이터 지연 5초 초과 시 신규 주문 차단.
|
||||
6. 연속 실패 3회 시 자동 중지.
|
||||
7. lock 충돌 2회 이상 시 자동 중지.
|
||||
8. 비상정지 버튼은 언제나 최상단 고정 노출.
|
||||
|
||||
## 11) 구현 파일 범위
|
||||
1. `features/autotrade/components/*` (전략 선택, 배분 입력, 경고 배너, 실행 상태 패널)
|
||||
2. `features/autotrade/hooks/useAutotradeEngine.ts`
|
||||
3. `features/autotrade/stores/use-autotrade-engine-store.ts`
|
||||
4. `features/autotrade/types/autotrade.types.ts`
|
||||
5. `app/api/autotrade/**/route.ts`
|
||||
6. `lib/autotrade/*` (컴파일, 검증, 리스크 게이트, lock 유틸)
|
||||
7. 기존 `TradeContainer`/`OrderForm`에 자동매매 섹션 통합
|
||||
8. `features/settings/store/use-kis-runtime-store.ts` 자동매매 모드에서 민감정보 `persist` 제외
|
||||
9. `app/api/kis/*` 및 `app/api/autotrade/*` 민감정보 마스킹 유틸 공통 적용
|
||||
|
||||
## 12) 테스트 시나리오
|
||||
1. 멀티탭 3개에서 리더 1개만 주문하는지 확인.
|
||||
2. 백그라운드 탭에서 실시간 신호 기반 주문이 유지되는지 확인.
|
||||
3. 외부 링크 이탈 시 강한 경고 후 즉시 중지되는지 확인.
|
||||
4. 탭 종료/브라우저 종료에서 `sendBeacon` + TTL 강제종료가 동작하는지 확인.
|
||||
5. 퍼센트+금액 입력 시 실적용 값이 작은 값으로 계산되는지 확인.
|
||||
6. 전략별 일일 손실한도 초과 시 즉시 차단되는지 확인.
|
||||
7. 온라인 전략 검증 실패 시 실행이 막히는지 확인.
|
||||
8. 새로고침 후 동일 세션이 중복주문 없이 이어지는지 확인.
|
||||
9. 서버 DB/로그에 KIS 키/계좌 원문이 저장되지 않는지 확인.
|
||||
10. AI 응답 누락/지연 시 주문이 차단되는지 확인.
|
||||
11. AI `confidence` 임계치 미만에서 주문 차단되는지 확인.
|
||||
|
||||
## 13) 단계별 배포 계획
|
||||
1. 1주차: DB 마이그레이션 + API 골격 + 타입 정의.
|
||||
2. 2주차: 브라우저 엔진(lock/heartbeat/stop flow) + 기본 UI.
|
||||
3. 3주차: 전략 소스 3종(프롬프트/카탈로그/온라인) + 컴파일/검증.
|
||||
4. 4주차: 리스크 정책 완성 + 통합/E2E + 운영 모니터링.
|
||||
5. 롤아웃: 기능 플래그로 5% 사용자 -> 30% -> 전체 오픈.
|
||||
|
||||
## 14) 수용 기준
|
||||
1. 실행 중 종료 트리거 발생 시 신규 주문이 즉시 0건이어야 합니다.
|
||||
2. 멀티탭에서 중복 주문이 발생하지 않아야 합니다.
|
||||
3. 사용자는 전략별 투자금/손실한도를 퍼센트+금액으로 모두 설정할 수 있어야 합니다.
|
||||
4. 프롬프트/카탈로그/온라인 전략 복수선택 저장과 실행이 가능해야 합니다.
|
||||
5. 로그 화면에서 신호-판단-주문-중지 이유가 연결되어 추적 가능해야 합니다.
|
||||
|
||||
## 15) 명시적 가정/기본값
|
||||
1. "다른 페이지 이동"은 외부 도메인 이탈 기준입니다.
|
||||
2. 앱 내부 라우트 이동은 중지 트리거가 아닙니다.
|
||||
3. 브라우저가 완전히 종료되면 자동매매는 반드시 중지 상태로 종료됩니다.
|
||||
4. 브라우저 재진입 시 자동 재개는 하지 않고 사용자 재시작으로만 실행합니다.
|
||||
5. 온라인 전략은 "실시간 수집 가능"이지만 "검증 통과 후 실행"을 강제합니다.
|
||||
6. KIS API 키/계좌 정보는 서버 저장을 금지하고 요청 단위 처리만 허용합니다.
|
||||
42
common-docs/features/trade-stock-sync.md
Normal file
42
common-docs/features/trade-stock-sync.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Korean Stocks 동기화
|
||||
|
||||
`korean-stocks.json`은 수동 편집 파일이 아니라 자동 생성 파일입니다.
|
||||
직접 수정하지 말고 동기화 스크립트로 갱신하세요.
|
||||
|
||||
## 실행 명령
|
||||
|
||||
```bash
|
||||
npm run sync:stocks
|
||||
```
|
||||
|
||||
- KIS 최신 KOSPI/KOSDAQ 마스터 파일을 내려받아
|
||||
`features/trade/data/korean-stocks.json`을 다시 생성합니다.
|
||||
|
||||
```bash
|
||||
npm run sync:stocks:check
|
||||
```
|
||||
|
||||
- 현재 파일이 최신인지 검사합니다.
|
||||
- 갱신이 필요하면 종료 코드 `1`로 끝납니다.
|
||||
|
||||
```bash
|
||||
npm run sync:stocks -- --dry-run
|
||||
```
|
||||
|
||||
- 원격 파일 파싱/검증만 하고 저장은 하지 않습니다.
|
||||
|
||||
## 권장 운영 방법
|
||||
|
||||
1. 하루 1회(또는 배포 전) `npm run sync:stocks` 실행
|
||||
2. `npm run lint`, `npm run build`로 기본 검증
|
||||
3. 갱신된 `features/trade/data/korean-stocks.json` 커밋
|
||||
|
||||
## 참고
|
||||
|
||||
- 데이터 출처:
|
||||
- `https://new.real.download.dws.co.kr/common/master/kospi_code.mst.zip`
|
||||
- `https://new.real.download.dws.co.kr/common/master/kosdaq_code.mst.zip`
|
||||
- 비정상 데이터 저장을 막기 위해 최소 건수 검증(안전장치)을 넣었습니다.
|
||||
- 임시 파일 저장 후 교체(원자적 저장) 방식이라 중간 손상 위험을 줄입니다.
|
||||
- 공식 문서:
|
||||
- `https://apiportal.koreainvestment.com/apiservice-category`
|
||||
146
common-docs/ui/GLOBAL_ALERT_SYSTEM.md
Normal file
146
common-docs/ui/GLOBAL_ALERT_SYSTEM.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Global Alert System 사용 가이드
|
||||
|
||||
이 문서는 애플리케이션 전역에서 사용 가능한 `Global Alert System`의 설계 목적, 설치 방법, 그리고 사용법을 설명합니다.
|
||||
|
||||
## 1. 개요 (Overview)
|
||||
|
||||
Global Alert System은 Zustand 상태 관리 라이브러리와 Shadcn/ui의 `AlertDialog` 컴포넌트를 결합하여 만든 전역 알림 시스템입니다.
|
||||
복잡한 모달 로직을 매번 구현할 필요 없이, `useGlobalAlert` 훅 하나로 어디서든 일관된 UI의 알림 및 확인 창을 호출할 수 있습니다.
|
||||
|
||||
### 주요 특징
|
||||
|
||||
- **전역 상태 관리**: `useGlobalAlertStore`를 통해 모달의 상태를 중앙에서 관리합니다.
|
||||
- **간편한 Hook**: `useGlobalAlert` 훅을 통해 직관적인 API (`alert.success`, `alert.confirm` 등)를 제공합니다.
|
||||
- **다양한 타입 지원**: Success, Error, Warning, Info 등 상황에 맞는 스타일과 아이콘을 자동으로 적용합니다.
|
||||
- **비동기 지원**: 확인/취소 버튼 클릭 시 콜백 함수를 실행할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 설치 및 설정 (Setup)
|
||||
|
||||
이미 `app/layout.tsx`에 설정되어 있으므로, 개발자는 별도의 설정 없이 바로 사용할 수 있습니다.
|
||||
|
||||
### 파일 구조
|
||||
|
||||
```
|
||||
features/layout/
|
||||
├── components/
|
||||
│ └── GlobalAlertModal.tsx # 실제 렌더링되는 모달 컴포넌트
|
||||
├── hooks/
|
||||
│ └── use-global-alert.ts # 개발자가 사용하는 Custom Hook
|
||||
└── stores/
|
||||
└── use-global-alert-store.ts # Zustand Store
|
||||
```
|
||||
|
||||
### Layout 통합
|
||||
|
||||
`app/layout.tsx`에 `GlobalAlertModal`이 이미 등록되어 있습니다.
|
||||
|
||||
```tsx
|
||||
// app/layout.tsx
|
||||
import { GlobalAlertModal } from "@/features/layout/components/GlobalAlertModal";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<GlobalAlertModal /> {/* 전역 모달 등록 */}
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 사용법 (Usage)
|
||||
|
||||
### Hook 가져오기
|
||||
|
||||
```tsx
|
||||
import { useGlobalAlert } from "@/features/layout/hooks/use-global-alert";
|
||||
|
||||
const { alert } = useGlobalAlert();
|
||||
```
|
||||
|
||||
### 기본 알림 (Alert)
|
||||
|
||||
사용자에게 단순히 정보를 전달하고 확인 버튼만 있는 알림입니다.
|
||||
|
||||
```tsx
|
||||
// 1. 성공 알림
|
||||
alert.success("저장이 완료되었습니다.");
|
||||
|
||||
// 2. 에러 알림
|
||||
alert.error("데이터 불러오기에 실패했습니다.");
|
||||
|
||||
// 3. 경고 알림
|
||||
alert.warning("입력 값이 올바르지 않습니다.");
|
||||
|
||||
// 4. 정보 알림
|
||||
alert.info("새로운 버전이 업데이트되었습니다.");
|
||||
```
|
||||
|
||||
옵션을 추가하여 제목이나 버튼 텍스트를 변경할 수 있습니다.
|
||||
|
||||
```tsx
|
||||
alert.success("저장 완료", {
|
||||
title: "성공", // 기본값: 타입에 따른 제목 (예: "성공", "오류")
|
||||
confirmLabel: "닫기", // 기본값: "확인"
|
||||
});
|
||||
```
|
||||
|
||||
### 확인 대화상자 (Confirm)
|
||||
|
||||
사용자의 선택(확인/취소)을 요구하는 대화상자입니다.
|
||||
|
||||
```tsx
|
||||
alert.confirm("정말로 삭제하시겠습니까?", {
|
||||
type: "warning", // 기본값: warning (아이콘과 색상 변경됨)
|
||||
confirmLabel: "삭제",
|
||||
cancelLabel: "취소",
|
||||
onConfirm: () => {
|
||||
console.log("삭제 버튼 클릭됨");
|
||||
// 여기에 삭제 로직 추가
|
||||
},
|
||||
onCancel: () => {
|
||||
console.log("취소 버튼 클릭됨");
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API Reference
|
||||
|
||||
### `useGlobalAlert()`
|
||||
|
||||
Hook은 `alert` 객체와 `close` 함수를 반환합니다.
|
||||
|
||||
#### `alert` Methods
|
||||
|
||||
| 메서드 | 설명 | 파라미터 |
|
||||
| --------- | ----------------------- | ---------------------------------------------- |
|
||||
| `success` | 성공 알림 표시 | `(message: ReactNode, options?: AlertOptions)` |
|
||||
| `error` | 오류 알림 표시 | `(message: ReactNode, options?: AlertOptions)` |
|
||||
| `warning` | 경고 알림 표시 | `(message: ReactNode, options?: AlertOptions)` |
|
||||
| `info` | 정보 알림 표시 | `(message: ReactNode, options?: AlertOptions)` |
|
||||
| `confirm` | 확인/취소 대화상자 표시 | `(message: ReactNode, options?: AlertOptions)` |
|
||||
|
||||
#### `AlertOptions` Interface
|
||||
|
||||
```typescript
|
||||
interface AlertOptions {
|
||||
title?: ReactNode; // 모달 제목 (생략 시 타입에 맞는 기본 제목)
|
||||
confirmLabel?: string; // 확인 버튼 텍스트 (기본: "확인")
|
||||
cancelLabel?: string; // 취소 버튼 텍스트 (Confirm 모드에서 기본: "취소")
|
||||
onConfirm?: () => void; // 확인 버튼 클릭 시 실행될 콜백
|
||||
onCancel?: () => void; // 취소 버튼 클릭 시 실행될 콜백
|
||||
type?: AlertType; // 알림 타입 ("success" | "error" | "warning" | "info")
|
||||
}
|
||||
```
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* @file components/theme-toggle.tsx
|
||||
* @description 라이트/다크/시스템 테마 전환 토글 버튼
|
||||
* @description 라이트/다크 테마 즉시 전환 토글 버튼
|
||||
* @remarks
|
||||
* - [레이어] Components/UI
|
||||
* - [사용자 행동] 버튼 클릭 -> 드롭다운 메뉴 -> 테마 선택 -> 즉시 반영
|
||||
* - [사용자 행동] 버튼 클릭 -> 라이트/다크 즉시 전환
|
||||
* - [연관 파일] theme-provider.tsx (next-themes)
|
||||
*/
|
||||
|
||||
@@ -12,48 +12,53 @@
|
||||
import * as React from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface ThemeToggleProps {
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테마 토글 컴포넌트
|
||||
* @remarks next-themes의 useTheme 훅 사용
|
||||
* @returns Dropdown 메뉴 형태의 테마 선택기
|
||||
* @returns 단일 클릭으로 라이트/다크를 전환하는 버튼
|
||||
* @see features/layout/components/header.tsx Header 액션 영역 - 사용자 테마 전환 버튼
|
||||
*/
|
||||
export function ThemeToggle() {
|
||||
const { setTheme } = useTheme();
|
||||
export function ThemeToggle({ className, iconClassName }: ThemeToggleProps) {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
|
||||
const handleToggleTheme = React.useCallback(() => {
|
||||
// 시스템 테마 사용 중에도 현재 화면 기준으로 명확히 라이트/다크를 토글합니다.
|
||||
setTheme(resolvedTheme === "dark" ? "light" : "dark");
|
||||
}, [resolvedTheme, setTheme]);
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
{/* ========== 트리거 버튼 ========== */}
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
{/* 라이트 모드 아이콘 (회전 애니메이션) */}
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
{/* 다크 모드 아이콘 (회전 애니메이션) */}
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={className}
|
||||
onClick={handleToggleTheme}
|
||||
aria-label="테마 전환"
|
||||
>
|
||||
{/* ========== LIGHT ICON ========== */}
|
||||
<Sun
|
||||
className={cn(
|
||||
"h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0",
|
||||
iconClassName,
|
||||
)}
|
||||
/>
|
||||
{/* ========== DARK ICON ========== */}
|
||||
<Moon
|
||||
className={cn(
|
||||
"absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100",
|
||||
iconClassName,
|
||||
)}
|
||||
/>
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
{/* ========== 메뉴 컨텐츠 (우측 정렬) ========== */}
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
115
components/ui/animated-brand-tone.tsx
Normal file
115
components/ui/animated-brand-tone.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TONE_PHRASES = [
|
||||
{ q: "주식이 너무 어려워요...", a: "걱정하지 마. JOORIN-E가 다 해줄게." },
|
||||
{
|
||||
q: "내 돈, 정말 안전할까?",
|
||||
a: "안심해도 돼. 금융권 수준 보안키로 지키니까.",
|
||||
},
|
||||
{
|
||||
q: "손실 날까 봐 불안해요...",
|
||||
a: "걱정하지 마. 안전 장치가 24시간 작동해.",
|
||||
},
|
||||
{
|
||||
q: "복잡한 건 딱 질색인데..",
|
||||
a: "몰라도 돼. 클릭 몇 번이면 바로 시작이야.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* @description 프리미엄한 텍스트 리빌 효과를 제공하는 브랜드 톤 컴포넌트
|
||||
*/
|
||||
export function AnimatedBrandTone() {
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setIndex((prev) => (prev + 1) % TONE_PHRASES.length);
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[300px] flex-col items-center justify-center py-10 text-center md:min-h-[400px]">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
className="flex flex-col items-center w-full"
|
||||
>
|
||||
{/* 질문 (Q) */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-sm font-medium text-brand-300/60 md:text-lg"
|
||||
>
|
||||
“{TONE_PHRASES[index].q}”
|
||||
</motion.p>
|
||||
|
||||
{/* 답변 (A) - 타이핑 효과 */}
|
||||
<div className="mt-8 flex flex-col items-center gap-2">
|
||||
<h2 className="text-4xl font-extrabold tracking-wide text-white drop-shadow-lg md:text-6xl lg:text-7xl">
|
||||
<div className="inline-block break-keep whitespace-pre-wrap leading-tight">
|
||||
{TONE_PHRASES[index].a.split("").map((char, i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{
|
||||
duration: 0,
|
||||
delay: 0.5 + i * 0.1, // 글자당 0.1초 딜레이로 타이핑 효과
|
||||
}}
|
||||
className={cn(
|
||||
"inline-block",
|
||||
// 앞부분 강조 색상 로직은 단순화하거나 유지 (여기서는 전체 텍스트 톤 유지)
|
||||
i < 5 ? "text-brand-300" : "text-white",
|
||||
)}
|
||||
>
|
||||
{char === " " ? "\u00A0" : char}
|
||||
</motion.span>
|
||||
))}
|
||||
{/* 깜빡이는 커서 */}
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: [0, 1, 0] }}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
}}
|
||||
className="ml-1 inline-block h-[0.8em] w-1.5 bg-brand-400 align-middle shadow-[0_0_10px_rgba(45,212,191,0.5)]"
|
||||
/>
|
||||
</div>
|
||||
</h2>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 인디케이터 - 유휴 상태 표시 및 선택 기능 */}
|
||||
<div className="mt-16 flex gap-3">
|
||||
{TONE_PHRASES.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setIndex(i)}
|
||||
className={cn(
|
||||
"h-1.5 transition-all duration-500 rounded-full",
|
||||
i === index
|
||||
? "w-10 bg-brand-500 shadow-[0_0_20px_rgba(20,184,166,0.3)]"
|
||||
: "w-2 bg-white/10 hover:bg-white/20",
|
||||
)}
|
||||
aria-label={`Go to slide ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
components/ui/badge.tsx
Normal file
48
components/ui/badge.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
58
components/ui/scroll-area.tsx
Normal file
58
components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
|
||||
244
components/ui/shader-background.tsx
Normal file
244
components/ui/shader-background.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ShaderBackgroundProps {
|
||||
className?: string;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
const VS_SOURCE = `
|
||||
attribute vec4 aVertexPosition;
|
||||
void main() {
|
||||
gl_Position = aVertexPosition;
|
||||
}
|
||||
`;
|
||||
|
||||
const FS_SOURCE = `
|
||||
precision highp float;
|
||||
uniform vec2 iResolution;
|
||||
uniform float iTime;
|
||||
|
||||
const float overallSpeed = 0.2;
|
||||
const float gridSmoothWidth = 0.015;
|
||||
const float axisWidth = 0.05;
|
||||
const float majorLineWidth = 0.025;
|
||||
const float minorLineWidth = 0.0125;
|
||||
const float majorLineFrequency = 5.0;
|
||||
const float minorLineFrequency = 1.0;
|
||||
const vec4 gridColor = vec4(0.5);
|
||||
const float scale = 5.0;
|
||||
const vec4 lineColor = vec4(0.4, 0.2, 0.8, 1.0);
|
||||
const float minLineWidth = 0.01;
|
||||
const float maxLineWidth = 0.2;
|
||||
const float lineSpeed = 1.0 * overallSpeed;
|
||||
const float lineAmplitude = 1.0;
|
||||
const float lineFrequency = 0.2;
|
||||
const float warpSpeed = 0.2 * overallSpeed;
|
||||
const float warpFrequency = 0.5;
|
||||
const float warpAmplitude = 1.0;
|
||||
const float offsetFrequency = 0.5;
|
||||
const float offsetSpeed = 1.33 * overallSpeed;
|
||||
const float minOffsetSpread = 0.6;
|
||||
const float maxOffsetSpread = 2.0;
|
||||
const int linesPerGroup = 16;
|
||||
|
||||
#define drawCircle(pos, radius, coord) smoothstep(radius + gridSmoothWidth, radius, length(coord - (pos)))
|
||||
#define drawSmoothLine(pos, halfWidth, t) smoothstep(halfWidth, 0.0, abs(pos - (t)))
|
||||
#define drawCrispLine(pos, halfWidth, t) smoothstep(halfWidth + gridSmoothWidth, halfWidth, abs(pos - (t)))
|
||||
#define drawPeriodicLine(freq, width, t) drawCrispLine(freq / 2.0, width, abs(mod(t, freq) - (freq) / 2.0))
|
||||
|
||||
float drawGridLines(float axis) {
|
||||
return drawCrispLine(0.0, axisWidth, axis)
|
||||
+ drawPeriodicLine(majorLineFrequency, majorLineWidth, axis)
|
||||
+ drawPeriodicLine(minorLineFrequency, minorLineWidth, axis);
|
||||
}
|
||||
|
||||
float drawGrid(vec2 space) {
|
||||
return min(1.0, drawGridLines(space.x) + drawGridLines(space.y));
|
||||
}
|
||||
|
||||
float random(float t) {
|
||||
return (cos(t) + cos(t * 1.3 + 1.3) + cos(t * 1.4 + 1.4)) / 3.0;
|
||||
}
|
||||
|
||||
float getPlasmaY(float x, float horizontalFade, float offset) {
|
||||
return random(x * lineFrequency + iTime * lineSpeed) * horizontalFade * lineAmplitude + offset;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 fragCoord = gl_FragCoord.xy;
|
||||
vec4 fragColor;
|
||||
vec2 uv = fragCoord.xy / iResolution.xy;
|
||||
vec2 space = (fragCoord - iResolution.xy / 2.0) / iResolution.x * 2.0 * scale;
|
||||
|
||||
float horizontalFade = 1.0 - (cos(uv.x * 6.28) * 0.5 + 0.5);
|
||||
float verticalFade = 1.0 - (cos(uv.y * 6.28) * 0.5 + 0.5);
|
||||
|
||||
space.y += random(space.x * warpFrequency + iTime * warpSpeed) * warpAmplitude * (0.5 + horizontalFade);
|
||||
space.x += random(space.y * warpFrequency + iTime * warpSpeed + 2.0) * warpAmplitude * horizontalFade;
|
||||
|
||||
vec4 lines = vec4(0.0);
|
||||
vec4 bgColor1 = vec4(0.1, 0.1, 0.3, 1.0);
|
||||
vec4 bgColor2 = vec4(0.3, 0.1, 0.5, 1.0);
|
||||
|
||||
for(int l = 0; l < linesPerGroup; l++) {
|
||||
float normalizedLineIndex = float(l) / float(linesPerGroup);
|
||||
float offsetTime = iTime * offsetSpeed;
|
||||
float offsetPosition = float(l) + space.x * offsetFrequency;
|
||||
float rand = random(offsetPosition + offsetTime) * 0.5 + 0.5;
|
||||
float halfWidth = mix(minLineWidth, maxLineWidth, rand * horizontalFade) / 2.0;
|
||||
float offset = random(offsetPosition + offsetTime * (1.0 + normalizedLineIndex)) * mix(minOffsetSpread, maxOffsetSpread, horizontalFade);
|
||||
float linePosition = getPlasmaY(space.x, horizontalFade, offset);
|
||||
float line = drawSmoothLine(linePosition, halfWidth, space.y) / 2.0 + drawCrispLine(linePosition, halfWidth * 0.15, space.y);
|
||||
|
||||
float circleX = mod(float(l) + iTime * lineSpeed, 25.0) - 12.0;
|
||||
vec2 circlePosition = vec2(circleX, getPlasmaY(circleX, horizontalFade, offset));
|
||||
float circle = drawCircle(circlePosition, 0.01, space) * 4.0;
|
||||
|
||||
line = line + circle;
|
||||
lines += line * lineColor * rand;
|
||||
}
|
||||
|
||||
fragColor = mix(bgColor1, bgColor2, uv.x);
|
||||
fragColor *= verticalFade;
|
||||
fragColor.a = 1.0;
|
||||
fragColor += lines;
|
||||
|
||||
gl_FragColor = fragColor;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* @description Compile one shader source.
|
||||
* @see components/ui/shader-background.tsx ShaderBackground useEffect - WebGL init flow
|
||||
*/
|
||||
function loadShader(gl: WebGLRenderingContext, type: number, source: string) {
|
||||
const shader = gl.createShader(type);
|
||||
if (!shader) return null;
|
||||
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
console.error("Shader compile error:", gl.getShaderInfoLog(shader));
|
||||
gl.deleteShader(shader);
|
||||
return null;
|
||||
}
|
||||
|
||||
return shader;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Create and link WebGL shader program.
|
||||
* @see components/ui/shader-background.tsx ShaderBackground useEffect - shader program setup
|
||||
*/
|
||||
function initShaderProgram(gl: WebGLRenderingContext, vertexSource: string, fragmentSource: string) {
|
||||
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vertexSource);
|
||||
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
|
||||
if (!vertexShader || !fragmentShader) return null;
|
||||
|
||||
const shaderProgram = gl.createProgram();
|
||||
if (!shaderProgram) return null;
|
||||
|
||||
gl.attachShader(shaderProgram, vertexShader);
|
||||
gl.attachShader(shaderProgram, fragmentShader);
|
||||
gl.linkProgram(shaderProgram);
|
||||
|
||||
if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
|
||||
console.error("Shader program link error:", gl.getProgramInfoLog(shaderProgram));
|
||||
gl.deleteProgram(shaderProgram);
|
||||
return null;
|
||||
}
|
||||
|
||||
return shaderProgram;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Animated shader background canvas.
|
||||
* @param className Tailwind class for canvas.
|
||||
* @param opacity Canvas opacity.
|
||||
* @see https://21st.dev/community/components/thanh/shader-background/default
|
||||
*/
|
||||
const ShaderBackground = ({ className, opacity = 0.9 }: ShaderBackgroundProps) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const gl = canvas.getContext("webgl");
|
||||
if (!gl) {
|
||||
console.warn("WebGL not supported.");
|
||||
return;
|
||||
}
|
||||
|
||||
const shaderProgram = initShaderProgram(gl, VS_SOURCE, FS_SOURCE);
|
||||
if (!shaderProgram) return;
|
||||
|
||||
const positionBuffer = gl.createBuffer();
|
||||
if (!positionBuffer) return;
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
const positions = [-1.0, -1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0];
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
|
||||
|
||||
const vertexPosition = gl.getAttribLocation(shaderProgram, "aVertexPosition");
|
||||
const resolution = gl.getUniformLocation(shaderProgram, "iResolution");
|
||||
const time = gl.getUniformLocation(shaderProgram, "iTime");
|
||||
|
||||
const resizeCanvas = () => {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const nextWidth = Math.floor(window.innerWidth * dpr);
|
||||
const nextHeight = Math.floor(window.innerHeight * dpr);
|
||||
canvas.width = nextWidth;
|
||||
canvas.height = nextHeight;
|
||||
gl.viewport(0, 0, nextWidth, nextHeight);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", resizeCanvas);
|
||||
resizeCanvas();
|
||||
|
||||
const startTime = Date.now();
|
||||
let frameId = 0;
|
||||
|
||||
const render = () => {
|
||||
const currentTime = (Date.now() - startTime) / 1000;
|
||||
|
||||
gl.clearColor(0.0, 0.0, 0.0, 1.0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
gl.useProgram(shaderProgram);
|
||||
|
||||
if (resolution) gl.uniform2f(resolution, canvas.width, canvas.height);
|
||||
if (time) gl.uniform1f(time, currentTime);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
gl.vertexAttribPointer(vertexPosition, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.enableVertexAttribArray(vertexPosition);
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
|
||||
frameId = requestAnimationFrame(render);
|
||||
};
|
||||
|
||||
frameId = requestAnimationFrame(render);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frameId);
|
||||
window.removeEventListener("resize", resizeCanvas);
|
||||
gl.deleteBuffer(positionBuffer);
|
||||
gl.deleteProgram(shaderProgram);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden="true"
|
||||
className={cn("fixed inset-0 -z-10 h-full w-full", className)}
|
||||
style={{ opacity }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShaderBackground;
|
||||
13
components/ui/skeleton.tsx
Normal file
13
components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
91
components/ui/tabs.tsx
Normal file
91
components/ui/tabs.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
|
||||
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
32
doc-rule.md
32
doc-rule.md
@@ -1,32 +0,0 @@
|
||||
# Antigravity Rules
|
||||
|
||||
This document defines the coding and behavior rules for the Antigravity agent.
|
||||
|
||||
## General Rules
|
||||
|
||||
- **Language**: All output, explanations, and commit messages MUST be in **Korean (한국어)**.
|
||||
- **Tone**: Professional, helpful, and concise.
|
||||
|
||||
## Documentation Rules
|
||||
|
||||
### JSX Comments
|
||||
|
||||
- Mandatory use of section comments in JSX to delineate logical blocks.
|
||||
- Format: `{/* ========== SECTION NAME ========== */}`
|
||||
|
||||
### JSDoc Tags
|
||||
|
||||
- **@see**: Mandatory for function/component documentation. Must include calling file, function/event name, and purpose.
|
||||
- **@author**: Mandatory file-level tag. Use `@author jihoon87.lee`.
|
||||
|
||||
### Inline Comments
|
||||
|
||||
- High density of inline comments required for:
|
||||
- State definitions
|
||||
- Event handlers
|
||||
- Complex logic in JSX
|
||||
- Balance conciseness with clarity.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Follow Project-specific linting and formatting rules.
|
||||
@@ -26,7 +26,6 @@ import { InlineSpinner } from "@/components/ui/loading-spinner";
|
||||
*/
|
||||
export default function LoginForm() {
|
||||
// ========== 상태 관리 ==========
|
||||
// 서버와 클라이언트 초기 렌더링을 일치시키기 위해 초기값은 고정
|
||||
const [email, setEmail] = useState(() => {
|
||||
if (typeof window === "undefined") return "";
|
||||
return localStorage.getItem("auto-trade-saved-email") || "";
|
||||
@@ -37,11 +36,6 @@ export default function LoginForm() {
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// ========== 마운트 시 localStorage 데이터 복구 ==========
|
||||
// localStorage는 클라이언트 전용 외부 시스템이므로 useEffect에서 동기화하는 것이 올바른 패턴
|
||||
// React 공식 문서: "외부 시스템과 동기화"는 useEffect의 정확한 사용 사례
|
||||
// useState lazy initializer + window guard handles localStorage safely
|
||||
|
||||
// ========== 폼 제출 핸들러 ==========
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
@@ -83,7 +77,7 @@ export default function LoginForm() {
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="h-11 transition-all duration-200"
|
||||
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -102,7 +96,7 @@ export default function LoginForm() {
|
||||
minLength={8}
|
||||
pattern="^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])(?=.*[!@#$%^&*]).{8,}$"
|
||||
title="비밀번호는 최소 8자 이상, 대문자, 소문자, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다."
|
||||
className="h-11 transition-all duration-200"
|
||||
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -121,10 +115,9 @@ export default function LoginForm() {
|
||||
이메일 기억하기
|
||||
</Label>
|
||||
</div>
|
||||
{/* 비밀번호 찾기 링크 */}
|
||||
<Link
|
||||
href={AUTH_ROUTES.FORGOT_PASSWORD}
|
||||
className="text-sm font-medium text-gray-700 hover:text-black dark:text-gray-300 dark:hover:text-white"
|
||||
className="text-sm font-medium text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||
>
|
||||
비밀번호 찾기
|
||||
</Link>
|
||||
@@ -134,7 +127,7 @@ export default function LoginForm() {
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="h-11 w-full bg-linear-to-r from-gray-900 to-black font-semibold shadow-lg transition-all duration-200 hover:from-black hover:to-gray-800 hover:shadow-xl dark:from-white dark:to-gray-100 dark:text-black dark:hover:from-gray-100 dark:hover:to-white"
|
||||
className="h-11 w-full bg-linear-to-r from-brand-500 to-brand-700 font-semibold text-white shadow-lg shadow-brand-500/20 transition-all duration-200 hover:from-brand-600 hover:to-brand-800 hover:shadow-xl hover:shadow-brand-500/25"
|
||||
size="lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
@@ -148,11 +141,11 @@ export default function LoginForm() {
|
||||
</Button>
|
||||
|
||||
{/* ========== 회원가입 링크 ========== */}
|
||||
<p className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
계정이 없으신가요?{" "}
|
||||
<Link
|
||||
href={AUTH_ROUTES.SIGNUP}
|
||||
className="font-semibold text-gray-900 transition-colors hover:text-black dark:text-gray-100 dark:hover:text-white"
|
||||
className="font-semibold text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||
>
|
||||
회원가입 하기
|
||||
</Link>
|
||||
@@ -162,7 +155,7 @@ export default function LoginForm() {
|
||||
{/* ========== 소셜 로그인 구분선 ========== */}
|
||||
<div className="relative">
|
||||
<Separator className="my-6" />
|
||||
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-white px-3 text-xs font-medium text-gray-500 dark:bg-gray-900 dark:text-gray-400">
|
||||
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-white px-3 text-xs font-medium text-muted-foreground dark:bg-brand-950">
|
||||
또는 소셜 로그인
|
||||
</span>
|
||||
</div>
|
||||
@@ -174,7 +167,7 @@ export default function LoginForm() {
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="h-11 w-full border-gray-200 bg-white shadow-sm transition-all duration-200 hover:bg-gray-50 hover:shadow-md dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-750"
|
||||
className="h-11 w-full border-brand-200/50 bg-white shadow-sm transition-all duration-200 hover:bg-brand-50 hover:shadow-md dark:border-brand-800/50 dark:bg-brand-950/50 dark:hover:bg-brand-900/50"
|
||||
>
|
||||
<svg className="mr-2 h-5 w-5" viewBox="0 0 24 24">
|
||||
<path
|
||||
|
||||
@@ -79,9 +79,9 @@ export default function ResetPasswordForm() {
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
{...register("password")}
|
||||
className="h-11 transition-all duration-200"
|
||||
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
8자 이상, 대문자/소문자/숫자/특수문자를 각 1개 이상 포함해야 합니다.
|
||||
</p>
|
||||
{errors.password && (
|
||||
@@ -102,7 +102,7 @@ export default function ResetPasswordForm() {
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
{...register("confirmPassword")}
|
||||
className="h-11 transition-all duration-200"
|
||||
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
|
||||
/>
|
||||
{confirmPassword &&
|
||||
password !== confirmPassword &&
|
||||
@@ -114,7 +114,7 @@ export default function ResetPasswordForm() {
|
||||
{confirmPassword &&
|
||||
password === confirmPassword &&
|
||||
!errors.confirmPassword && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400">
|
||||
<p className="text-xs text-brand-600 dark:text-brand-400">
|
||||
비밀번호가 일치합니다.
|
||||
</p>
|
||||
)}
|
||||
@@ -128,7 +128,7 @@ export default function ResetPasswordForm() {
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="h-11 w-full bg-linear-to-r from-gray-900 to-black font-semibold text-white shadow-lg transition-all hover:from-black hover:to-gray-800 hover:shadow-xl dark:from-white dark:to-gray-100 dark:text-black dark:hover:from-gray-100 dark:hover:to-white"
|
||||
className="h-11 w-full bg-linear-to-r from-brand-500 to-brand-700 font-semibold text-white shadow-lg shadow-brand-500/20 transition-all hover:from-brand-600 hover:to-brand-800 hover:shadow-xl hover:shadow-brand-500/25"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
|
||||
@@ -29,6 +29,12 @@ import { SESSION_TIMEOUT_MS } from "@/features/auth/constants";
|
||||
// 설정: 경고 표시 시간 (타임아웃 1분 전) - 현재 미사용 (즉시 로그아웃)
|
||||
// const WARNING_MS = 60 * 1000;
|
||||
|
||||
const SESSION_RELATED_STORAGE_KEYS = [
|
||||
"session-storage",
|
||||
"auth-storage",
|
||||
"autotrade-kis-runtime-store",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 세션 관리자 컴포넌트
|
||||
* 사용자 활동을 감지하여 세션 연장 및 타임아웃 처리
|
||||
@@ -51,6 +57,19 @@ export function SessionManager() {
|
||||
|
||||
const { setLastActive } = useSessionStore();
|
||||
|
||||
/**
|
||||
* @description 세션 만료 로그아웃 시 세션 관련 로컬 스토리지를 정리합니다.
|
||||
* @see features/layout/components/user-menu.tsx 수동 로그아웃 경로에서도 동일한 키를 제거합니다.
|
||||
*/
|
||||
const clearSessionRelatedStorage = useCallback(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
for (const key of SESSION_RELATED_STORAGE_KEYS) {
|
||||
window.localStorage.removeItem(key);
|
||||
window.sessionStorage.removeItem(key);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 로그아웃 처리 핸들러
|
||||
* @see session-timer.tsx - 타임아웃 시 동일한 로직 필요 가능성 있음
|
||||
@@ -64,11 +83,12 @@ export function SessionManager() {
|
||||
|
||||
// [Step 3] 로컬 스토어 및 세션 정보 초기화
|
||||
useSessionStore.persist.clearStorage();
|
||||
clearSessionRelatedStorage();
|
||||
|
||||
// [Step 4] 로그인 페이지로 리다이렉트 및 메시지 표시
|
||||
router.push("/login?message=세션이 만료되었습니다. 다시 로그인해주세요.");
|
||||
router.refresh();
|
||||
}, [router]);
|
||||
}, [clearSessionRelatedStorage, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthPage) return;
|
||||
@@ -79,6 +99,10 @@ export function SessionManager() {
|
||||
if (showWarning) setShowWarning(false);
|
||||
};
|
||||
|
||||
// [Step 0] 인증 페이지에서 메인 페이지로 진입한 직후를 "활동"으로 간주해
|
||||
// 이전 세션 잔여 시간(예: 00:00)으로 즉시 로그아웃되는 현상을 방지합니다.
|
||||
updateLastActive();
|
||||
|
||||
// [Step 1] 사용자 활동 이벤트 감지 (마우스, 키보드, 스크롤, 터치)
|
||||
const events = ["mousedown", "keydown", "scroll", "touchstart"];
|
||||
const handleActivity = () => updateLastActive();
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { SESSION_TIMEOUT_MS } from "@/features/auth/constants";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* 세션 만료 타이머 컴포넌트
|
||||
@@ -21,7 +22,12 @@ import { SESSION_TIMEOUT_MS } from "@/features/auth/constants";
|
||||
* @remarks 1초마다 리렌더링 발생
|
||||
* @see header.tsx - 로그인 상태일 때 헤더에 표시
|
||||
*/
|
||||
export function SessionTimer() {
|
||||
interface SessionTimerProps {
|
||||
/** 셰이더 배경 위에서 가독성을 높이는 투명 모드 */
|
||||
blendWithBackground?: boolean;
|
||||
}
|
||||
|
||||
export function SessionTimer({ blendWithBackground = false }: SessionTimerProps) {
|
||||
const lastActive = useSessionStore((state) => state.lastActive);
|
||||
|
||||
// [State] 남은 시간 (밀리초)
|
||||
@@ -54,11 +60,14 @@ export function SessionTimer() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`text-sm font-medium tabular-nums px-3 py-1.5 rounded-md border bg-background/50 backdrop-blur-md hidden md:block transition-colors ${
|
||||
className={cn(
|
||||
"hidden rounded-full border px-3 py-1.5 text-sm font-medium tabular-nums backdrop-blur-md transition-colors md:block",
|
||||
isUrgent
|
||||
? "text-red-500 border-red-200 bg-red-50/50 dark:bg-red-900/20 dark:border-red-800"
|
||||
: "text-muted-foreground border-border/40"
|
||||
}`}
|
||||
? "border-red-200 bg-red-50/50 text-red-500 dark:border-red-800 dark:bg-red-900/20"
|
||||
: blendWithBackground
|
||||
? "border-white/30 bg-black/45 text-white shadow-sm shadow-black/40"
|
||||
: "border-border/40 bg-background/50 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{/* ========== 라벨 ========== */}
|
||||
<span className="mr-2">세션 만료</span>
|
||||
|
||||
@@ -84,7 +84,7 @@ export default function SignupForm() {
|
||||
autoComplete="email"
|
||||
disabled={isLoading}
|
||||
{...register("email")}
|
||||
className="h-11 transition-all duration-200"
|
||||
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400">
|
||||
@@ -105,9 +105,9 @@ export default function SignupForm() {
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
{...register("password")}
|
||||
className="h-11 transition-all duration-200"
|
||||
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
최소 8자 이상, 대문자, 소문자, 숫자, 특수문자 포함
|
||||
</p>
|
||||
{errors.password && (
|
||||
@@ -129,7 +129,7 @@ export default function SignupForm() {
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
{...register("confirmPassword")}
|
||||
className="h-11 transition-all duration-200"
|
||||
className="h-11 transition-all duration-200 focus-visible:ring-brand-500"
|
||||
/>
|
||||
{/* 비밀번호 불일치 시 실시간 피드백 */}
|
||||
{confirmPassword &&
|
||||
@@ -143,7 +143,7 @@ export default function SignupForm() {
|
||||
{confirmPassword &&
|
||||
password === confirmPassword &&
|
||||
!errors.confirmPassword && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400">
|
||||
<p className="text-xs text-brand-600 dark:text-brand-400">
|
||||
비밀번호가 일치합니다 ✓
|
||||
</p>
|
||||
)}
|
||||
@@ -159,7 +159,7 @@ export default function SignupForm() {
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="h-11 w-full bg-gradient-to-r from-gray-900 to-black font-semibold shadow-lg transition-all duration-200 hover:from-black hover:to-gray-800 hover:shadow-xl dark:from-white dark:to-gray-100 dark:text-black dark:hover:from-gray-100 dark:hover:to-white"
|
||||
className="h-11 w-full bg-linear-to-r from-brand-500 to-brand-700 font-semibold text-white shadow-lg shadow-brand-500/20 transition-all duration-200 hover:from-brand-600 hover:to-brand-800 hover:shadow-xl hover:shadow-brand-500/25"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
|
||||
94
features/dashboard/apis/dashboard.api.ts
Normal file
94
features/dashboard/apis/dashboard.api.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
||||
import {
|
||||
buildKisRequestHeaders,
|
||||
resolveKisApiErrorMessage,
|
||||
type KisApiErrorPayload,
|
||||
} from "@/features/settings/apis/kis-api-utils";
|
||||
import type {
|
||||
DashboardActivityResponse,
|
||||
DashboardBalanceResponse,
|
||||
DashboardIndicesResponse,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
/**
|
||||
* @file features/dashboard/apis/dashboard.api.ts
|
||||
* @description 대시보드 잔고/지수 API 클라이언트
|
||||
*/
|
||||
|
||||
/**
|
||||
* 계좌 잔고/보유종목을 조회합니다.
|
||||
* @param credentials KIS 인증 정보
|
||||
* @returns 잔고 응답
|
||||
* @see app/api/kis/domestic/balance/route.ts 서버 라우트
|
||||
*/
|
||||
export async function fetchDashboardBalance(
|
||||
credentials: KisRuntimeCredentials,
|
||||
): Promise<DashboardBalanceResponse> {
|
||||
const response = await fetch("/api/kis/domestic/balance", {
|
||||
method: "GET",
|
||||
headers: buildKisRequestHeaders(credentials, { includeAccountNo: true }),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as
|
||||
| DashboardBalanceResponse
|
||||
| KisApiErrorPayload;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(resolveKisApiErrorMessage(payload, "잔고 조회 중 오류가 발생했습니다."));
|
||||
}
|
||||
|
||||
return payload as DashboardBalanceResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시장 지수(KOSPI/KOSDAQ)를 조회합니다.
|
||||
* @param credentials KIS 인증 정보
|
||||
* @returns 지수 응답
|
||||
* @see app/api/kis/domestic/indices/route.ts 서버 라우트
|
||||
*/
|
||||
export async function fetchDashboardIndices(
|
||||
credentials: KisRuntimeCredentials,
|
||||
): Promise<DashboardIndicesResponse> {
|
||||
const response = await fetch("/api/kis/domestic/indices", {
|
||||
method: "GET",
|
||||
headers: buildKisRequestHeaders(credentials),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as
|
||||
| DashboardIndicesResponse
|
||||
| KisApiErrorPayload;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(resolveKisApiErrorMessage(payload, "지수 조회 중 오류가 발생했습니다."));
|
||||
}
|
||||
|
||||
return payload as DashboardIndicesResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* 주문내역/매매일지(활동 데이터)를 조회합니다.
|
||||
* @param credentials KIS 인증 정보
|
||||
* @returns 활동 데이터 응답
|
||||
* @see app/api/kis/domestic/activity/route.ts 서버 라우트
|
||||
*/
|
||||
export async function fetchDashboardActivity(
|
||||
credentials: KisRuntimeCredentials,
|
||||
): Promise<DashboardActivityResponse> {
|
||||
const response = await fetch("/api/kis/domestic/activity", {
|
||||
method: "GET",
|
||||
headers: buildKisRequestHeaders(credentials, { includeAccountNo: true }),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as
|
||||
| DashboardActivityResponse
|
||||
| KisApiErrorPayload;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(resolveKisApiErrorMessage(payload, "활동 데이터 조회 중 오류가 발생했습니다."));
|
||||
}
|
||||
|
||||
return payload as DashboardActivityResponse;
|
||||
}
|
||||
331
features/dashboard/components/ActivitySection.tsx
Normal file
331
features/dashboard/components/ActivitySection.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
import { AlertCircle, ClipboardList, FileText, RefreshCcw } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type {
|
||||
DashboardActivityResponse,
|
||||
DashboardTradeSide,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatPercent,
|
||||
getChangeToneClass,
|
||||
} from "@/features/dashboard/utils/dashboard-format";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ActivitySectionProps {
|
||||
activity: DashboardActivityResponse | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 대시보드 하단 주문내역/매매일지 섹션입니다.
|
||||
* @remarks UI 흐름: DashboardContainer -> ActivitySection -> tabs(주문내역/매매일지) -> 리스트 렌더링
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 하단 영역에서 호출합니다.
|
||||
* @see app/api/kis/domestic/activity/route.ts 주문내역/매매일지 데이터 소스
|
||||
*/
|
||||
export function ActivitySection({
|
||||
activity,
|
||||
isLoading,
|
||||
error,
|
||||
onRetry,
|
||||
}: ActivitySectionProps) {
|
||||
const orders = activity?.orders ?? [];
|
||||
const journalRows = activity?.tradeJournal ?? [];
|
||||
const summary = activity?.journalSummary;
|
||||
const warnings = activity?.warnings ?? [];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
{/* ========== TITLE ========== */}
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ClipboardList className="h-4 w-4 text-brand-600 dark:text-brand-400" />
|
||||
주문내역 · 매매일지
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
최근 주문 체결 내역과 실현손익 기록을 확인합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
{isLoading && !activity && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
주문내역/매매일지를 불러오는 중입니다.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50/60 p-2.5 dark:border-red-900/40 dark:bg-red-950/20">
|
||||
<p className="flex items-start gap-1.5 text-sm text-red-600 dark:text-red-400">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-red-700/80 dark:text-red-300/80">
|
||||
주문/매매일지 API는 장중 혼잡 시간에 간헐적 실패가 발생할 수 있습니다.
|
||||
</p>
|
||||
{onRetry ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
className="mt-2 border-red-300 text-red-700 hover:bg-red-100 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/40"
|
||||
>
|
||||
<RefreshCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||
주문/매매일지 다시 불러오기
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{warnings.map((warning) => (
|
||||
<Badge
|
||||
key={warning}
|
||||
variant="outline"
|
||||
className="border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-300"
|
||||
>
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{warning}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== TABS ========== */}
|
||||
<Tabs defaultValue="orders" className="gap-3">
|
||||
<TabsList className="w-full justify-start">
|
||||
<TabsTrigger value="orders">주문내역 {orders.length}건</TabsTrigger>
|
||||
<TabsTrigger value="journal">매매일지 {journalRows.length}건</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="orders">
|
||||
<div className="overflow-hidden rounded-xl border border-border/70">
|
||||
<div className="grid grid-cols-[100px_1fr_140px_120px_120px_90px] gap-2 bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>일시</span>
|
||||
<span>종목</span>
|
||||
<span>주문</span>
|
||||
<span>체결</span>
|
||||
<span>평균체결가</span>
|
||||
<span>상태</span>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[280px]">
|
||||
{orders.length === 0 ? (
|
||||
<p className="px-3 py-4 text-sm text-muted-foreground">
|
||||
표시할 주문내역이 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y divide-border/60">
|
||||
{orders.map((order) => (
|
||||
<div
|
||||
key={`${order.orderNo}-${order.orderDate}-${order.orderTime}`}
|
||||
className="grid grid-cols-[100px_1fr_140px_120px_120px_90px] items-center gap-2 px-3 py-2 text-sm"
|
||||
>
|
||||
{/* ========== ORDER DATETIME ========== */}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p>{order.orderDate}</p>
|
||||
<p>{order.orderTime}</p>
|
||||
</div>
|
||||
|
||||
{/* ========== STOCK INFO ========== */}
|
||||
<div>
|
||||
<p className="font-medium text-foreground">{order.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{order.symbol} · {getSideLabel(order.side)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ========== ORDER INFO ========== */}
|
||||
<div className="text-xs">
|
||||
<p>수량 {order.orderQuantity.toLocaleString("ko-KR")}주</p>
|
||||
<p className="text-muted-foreground">
|
||||
{order.orderTypeName} · {formatCurrency(order.orderPrice)}원
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ========== FILLED INFO ========== */}
|
||||
<div className="text-xs">
|
||||
<p>체결 {order.filledQuantity.toLocaleString("ko-KR")}주</p>
|
||||
<p className="text-muted-foreground">
|
||||
금액 {formatCurrency(order.filledAmount)}원
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ========== AVG PRICE ========== */}
|
||||
<div className="text-xs font-medium text-foreground">
|
||||
{formatCurrency(order.averageFilledPrice)}원
|
||||
</div>
|
||||
|
||||
{/* ========== STATUS ========== */}
|
||||
<div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[11px]",
|
||||
order.isCanceled
|
||||
? "border-slate-300 text-slate-600 dark:border-slate-700 dark:text-slate-300"
|
||||
: order.remainingQuantity > 0
|
||||
? "border-amber-300 text-amber-700 dark:border-amber-700 dark:text-amber-300"
|
||||
: "border-emerald-300 text-emerald-700 dark:border-emerald-700 dark:text-emerald-300",
|
||||
)}
|
||||
>
|
||||
{order.isCanceled
|
||||
? "취소"
|
||||
: order.remainingQuantity > 0
|
||||
? "미체결"
|
||||
: "체결완료"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="journal" className="space-y-3">
|
||||
{/* ========== JOURNAL SUMMARY ========== */}
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<SummaryMetric
|
||||
label="총 실현손익"
|
||||
value={summary ? `${formatCurrency(summary.totalRealizedProfit)}원` : "-"}
|
||||
toneClass={summary ? getChangeToneClass(summary.totalRealizedProfit) : undefined}
|
||||
/>
|
||||
<SummaryMetric
|
||||
label="총 수익률"
|
||||
value={summary ? formatPercent(summary.totalRealizedRate) : "-"}
|
||||
toneClass={summary ? getChangeToneClass(summary.totalRealizedProfit) : undefined}
|
||||
/>
|
||||
<SummaryMetric
|
||||
label="총 매수금액"
|
||||
value={summary ? `${formatCurrency(summary.totalBuyAmount)}원` : "-"}
|
||||
/>
|
||||
<SummaryMetric
|
||||
label="총 매도금액"
|
||||
value={summary ? `${formatCurrency(summary.totalSellAmount)}원` : "-"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-border/70">
|
||||
<div className="grid grid-cols-[100px_1fr_120px_130px_120px_110px] gap-2 bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>일자</span>
|
||||
<span>종목</span>
|
||||
<span>매매구분</span>
|
||||
<span>매수/매도금액</span>
|
||||
<span>실현손익(률)</span>
|
||||
<span>비용</span>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[280px]">
|
||||
{journalRows.length === 0 ? (
|
||||
<p className="px-3 py-4 text-sm text-muted-foreground">
|
||||
표시할 매매일지가 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y divide-border/60">
|
||||
{journalRows.map((row) => {
|
||||
const toneClass = getChangeToneClass(row.realizedProfit);
|
||||
return (
|
||||
<div
|
||||
key={`${row.tradeDate}-${row.symbol}-${row.realizedProfit}-${row.buyAmount}-${row.sellAmount}`}
|
||||
className="grid grid-cols-[100px_1fr_120px_130px_120px_110px] items-center gap-2 px-3 py-2 text-sm"
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">{row.tradeDate}</p>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">{row.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.symbol}</p>
|
||||
</div>
|
||||
<p className={cn("text-xs font-medium", getSideToneClass(row.side))}>
|
||||
{getSideLabel(row.side)}
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
매수 {formatCurrency(row.buyAmount)}원 / 매도 {formatCurrency(row.sellAmount)}원
|
||||
</p>
|
||||
<p className={cn("text-xs font-medium", toneClass)}>
|
||||
{formatCurrency(row.realizedProfit)}원 ({formatPercent(row.realizedRate)})
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
수수료 {formatCurrency(row.fee)}원
|
||||
<br />
|
||||
세금 {formatCurrency(row.tax)}원
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{!isLoading && !error && !activity && (
|
||||
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<FileText className="h-4 w-4" />
|
||||
활동 데이터가 없습니다.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface SummaryMetricProps {
|
||||
label: string;
|
||||
value: string;
|
||||
toneClass?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 매매일지 요약 지표 카드입니다.
|
||||
* @param label 지표명
|
||||
* @param value 지표값
|
||||
* @param toneClass 값 색상 클래스
|
||||
* @see features/dashboard/components/ActivitySection.tsx 매매일지 상단 요약 표시
|
||||
*/
|
||||
function SummaryMetric({ label, value, toneClass }: SummaryMetricProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border/70 bg-background/70 p-3">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className={cn("mt-1 text-sm font-semibold text-foreground", toneClass)}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 매수/매도 라벨 텍스트를 반환합니다.
|
||||
* @param side 매수/매도 구분값
|
||||
* @returns 라벨 문자열
|
||||
* @see features/dashboard/components/ActivitySection.tsx 주문내역/매매일지 표시
|
||||
*/
|
||||
function getSideLabel(side: DashboardTradeSide) {
|
||||
if (side === "buy") return "매수";
|
||||
if (side === "sell") return "매도";
|
||||
return "기타";
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 매수/매도 라벨 색상 클래스를 반환합니다.
|
||||
* @param side 매수/매도 구분값
|
||||
* @returns Tailwind 텍스트 클래스
|
||||
* @see features/dashboard/components/ActivitySection.tsx 매매구분 표시
|
||||
*/
|
||||
function getSideToneClass(side: DashboardTradeSide) {
|
||||
if (side === "buy") return "text-red-600 dark:text-red-400";
|
||||
if (side === "sell") return "text-blue-600 dark:text-blue-400";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
36
features/dashboard/components/DashboardAccessGate.tsx
Normal file
36
features/dashboard/components/DashboardAccessGate.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface DashboardAccessGateProps {
|
||||
canAccess: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description KIS 인증 여부에 따라 대시보드 접근 가이드를 렌더링합니다.
|
||||
* @param canAccess 대시보드 접근 가능 여부
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 인증되지 않은 경우 이 컴포넌트를 렌더링합니다.
|
||||
*/
|
||||
export function DashboardAccessGate({ canAccess }: DashboardAccessGateProps) {
|
||||
if (canAccess) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<section className="w-full max-w-xl rounded-2xl border border-brand-200 bg-background p-6 shadow-sm dark:border-brand-800/45 dark:bg-brand-900/18">
|
||||
{/* ========== UNVERIFIED NOTICE ========== */}
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
대시보드를 보려면 한국투자증권 연결이 필요합니다.
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
설정 페이지에서 앱키, 앱시크릿키, 계좌번호를 입력하고 연결을 완료해 주세요.
|
||||
</p>
|
||||
|
||||
{/* ========== ACTION ========== */}
|
||||
<div className="mt-4">
|
||||
<Button asChild className="bg-brand-600 hover:bg-brand-700">
|
||||
<Link href="/settings">설정 페이지로 이동</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
362
features/dashboard/components/DashboardContainer.tsx
Normal file
362
features/dashboard/components/DashboardContainer.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { ActivitySection } from "@/features/dashboard/components/ActivitySection";
|
||||
import { DashboardAccessGate } from "@/features/dashboard/components/DashboardAccessGate";
|
||||
import { DashboardSkeleton } from "@/features/dashboard/components/DashboardSkeleton";
|
||||
import { HoldingsList } from "@/features/dashboard/components/HoldingsList";
|
||||
import { MarketSummary } from "@/features/dashboard/components/MarketSummary";
|
||||
import { StatusHeader } from "@/features/dashboard/components/StatusHeader";
|
||||
import { StockDetailPreview } from "@/features/dashboard/components/StockDetailPreview";
|
||||
import { useDashboardData } from "@/features/dashboard/hooks/use-dashboard-data";
|
||||
import { useMarketRealtime } from "@/features/dashboard/hooks/use-market-realtime";
|
||||
import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore";
|
||||
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
|
||||
import { useHoldingsRealtime } from "@/features/dashboard/hooks/use-holdings-realtime";
|
||||
import type {
|
||||
DashboardBalanceSummary,
|
||||
DashboardHoldingItem,
|
||||
DashboardMarketIndexItem,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
import type { KisRealtimeStockTick } from "@/features/dashboard/utils/kis-stock-realtime.utils";
|
||||
|
||||
/**
|
||||
* @file DashboardContainer.tsx
|
||||
* @description 대시보드 메인 레이아웃 및 데이터 통합 관리 컴포넌트
|
||||
* @remarks
|
||||
* - [레이어] Components / Container
|
||||
* - [사용자 행동] 대시보드 진입 -> 전체 자산/시장 지수/보유 종목 확인 -> 특정 종목 선택 상세 확인
|
||||
* - [데이터 흐름] API(REST/WS) -> Hooks(useDashboardData, useMarketRealtime, useHoldingsRealtime) -> UI 병합 -> 하위 컴포넌트 전파
|
||||
* - [연관 파일] use-dashboard-data.ts, use-holdings-realtime.ts, StatusHeader.tsx, HoldingsList.tsx
|
||||
* @author jihoon87.lee
|
||||
*/
|
||||
export function DashboardContainer() {
|
||||
// [Store] KIS 런타임 설정 상태 (인증 여부, 접속 계좌, 웹소켓 정보 등)
|
||||
const {
|
||||
verifiedCredentials,
|
||||
isKisVerified,
|
||||
isKisProfileVerified,
|
||||
verifiedAccountNo,
|
||||
_hasHydrated,
|
||||
wsApprovalKey,
|
||||
wsUrl,
|
||||
} = useKisRuntimeStore(
|
||||
useShallow((state) => ({
|
||||
verifiedCredentials: state.verifiedCredentials,
|
||||
isKisVerified: state.isKisVerified,
|
||||
isKisProfileVerified: state.isKisProfileVerified,
|
||||
verifiedAccountNo: state.verifiedAccountNo,
|
||||
_hasHydrated: state._hasHydrated,
|
||||
wsApprovalKey: state.wsApprovalKey,
|
||||
wsUrl: state.wsUrl,
|
||||
})),
|
||||
);
|
||||
|
||||
// KIS 접근 가능 여부 판단
|
||||
const canAccess = isKisVerified && Boolean(verifiedCredentials);
|
||||
|
||||
// [Hooks] 기본적인 대시보드 데이터(잔고, 지수, 활동내역) 조회 및 선택 상태 관리
|
||||
// @see use-dashboard-data.ts - 초기 데이터 로딩 및 폴링 처리
|
||||
const {
|
||||
activity,
|
||||
balance,
|
||||
indices: initialIndices,
|
||||
selectedSymbol,
|
||||
setSelectedSymbol,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
activityError,
|
||||
balanceError,
|
||||
indicesError,
|
||||
lastUpdatedAt,
|
||||
refresh,
|
||||
} = useDashboardData(canAccess ? verifiedCredentials : null);
|
||||
|
||||
// [Hooks] 시장 지수(코스피/코스닥) 실시간 웹소켓 데이터 구독
|
||||
// @see use-market-realtime.ts - 웹소켓 연결 및 지수 파싱
|
||||
const { realtimeIndices, isConnected: isWsConnected } = useMarketRealtime(
|
||||
verifiedCredentials,
|
||||
isKisVerified,
|
||||
);
|
||||
|
||||
// [Hooks] 보유 종목 실시간 시세 웹소켓 데이터 구독
|
||||
// @see use-holdings-realtime.ts - 보유 종목 리스트 기반 시세 업데이트
|
||||
const { realtimeData: realtimeHoldings } = useHoldingsRealtime(
|
||||
balance?.holdings ?? [],
|
||||
);
|
||||
const reconnectWebSocket = useKisWebSocketStore((state) => state.reconnect);
|
||||
|
||||
// [Step 1] REST API로 가져온 기본 지수 정보와 실시간 웹소켓 시세 병합
|
||||
const indices = useMemo(() => {
|
||||
if (initialIndices.length === 0) {
|
||||
return buildRealtimeOnlyIndices(realtimeIndices);
|
||||
}
|
||||
|
||||
return initialIndices.map((item) => {
|
||||
const realtime = realtimeIndices[item.code];
|
||||
if (!realtime) return item;
|
||||
|
||||
return {
|
||||
...item,
|
||||
price: realtime.price,
|
||||
change: realtime.change,
|
||||
changeRate: realtime.changeRate,
|
||||
};
|
||||
});
|
||||
}, [initialIndices, realtimeIndices]);
|
||||
|
||||
// [Step 2] 초기 잔고 데이터와 실시간 보유 종목 시세를 병합하여 손익 재계산
|
||||
const mergedHoldings = useMemo(
|
||||
() => mergeHoldingsWithRealtime(balance?.holdings ?? [], realtimeHoldings),
|
||||
[balance?.holdings, realtimeHoldings],
|
||||
);
|
||||
|
||||
const isKisRestConnected = Boolean(
|
||||
(balance && !balanceError) ||
|
||||
(initialIndices.length > 0 && !indicesError) ||
|
||||
(activity && !activityError),
|
||||
);
|
||||
const hasRealtimeStreaming =
|
||||
Object.keys(realtimeIndices).length > 0 ||
|
||||
Object.keys(realtimeHoldings).length > 0;
|
||||
const isRealtimePending = Boolean(
|
||||
wsApprovalKey && wsUrl && isWsConnected && !hasRealtimeStreaming,
|
||||
);
|
||||
const effectiveIndicesError = indices.length === 0 ? indicesError : null;
|
||||
const indicesWarning =
|
||||
indices.length > 0 && indicesError
|
||||
? "지수 API 재요청 중입니다. 화면 값은 실시간/최근 성공 데이터입니다."
|
||||
: null;
|
||||
|
||||
/**
|
||||
* 대시보드 수동 새로고침 시 REST 조회 + 웹소켓 재연결을 함께 수행합니다.
|
||||
* @remarks UI 흐름: StatusHeader/각 카드 다시 불러오기 버튼 -> handleRefreshAll -> REST 재조회 + WS 완전 종료 후 재연결
|
||||
* @see features/dashboard/components/StatusHeader.tsx 상단 다시 불러오기 버튼
|
||||
* @see features/kis-realtime/stores/kisWebSocketStore.ts reconnect
|
||||
*/
|
||||
const handleRefreshAll = async () => {
|
||||
await Promise.allSettled([
|
||||
refresh(),
|
||||
reconnectWebSocket({ refreshApproval: false }),
|
||||
]);
|
||||
};
|
||||
|
||||
/**
|
||||
* 실시간 보유종목 데이터를 기반으로 전체 자산 요약을 계산합니다.
|
||||
* @returns 실시간 요약 데이터 (총자산, 손익, 평가금액 등)
|
||||
*/
|
||||
const mergedSummary = useMemo(
|
||||
() => buildRealtimeSummary(balance?.summary ?? null, mergedHoldings),
|
||||
[balance?.summary, mergedHoldings],
|
||||
);
|
||||
|
||||
// [Step 3] 실시간 병합 데이터에서 현재 선택된 종목 정보를 추출
|
||||
// @see StockDetailPreview.tsx - 선택된 종목의 상세 정보 표시
|
||||
const realtimeSelectedHolding = useMemo(() => {
|
||||
if (!selectedSymbol || mergedHoldings.length === 0) return null;
|
||||
return (
|
||||
mergedHoldings.find((item) => item.symbol === selectedSymbol) ?? null
|
||||
);
|
||||
}, [mergedHoldings, selectedSymbol]);
|
||||
|
||||
// 하이드레이션 이전에는 로딩 스피너 표시
|
||||
if (!_hasHydrated) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// KIS 인증이 되지 않은 경우 접근 제한 게이트 표시
|
||||
if (!canAccess) {
|
||||
return <DashboardAccessGate canAccess={canAccess} />;
|
||||
}
|
||||
|
||||
// 데이터 로딩 중이며 아직 데이터가 없는 경우 스켈레톤 표시
|
||||
if (isLoading && !balance && indices.length === 0) {
|
||||
return <DashboardSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mx-auto flex w-full max-w-7xl flex-col gap-4 p-4 md:p-6">
|
||||
{/* ========== 상단 상태 영역: 계좌 연결 정보 및 새로고침 ========== */}
|
||||
<StatusHeader
|
||||
summary={mergedSummary}
|
||||
isKisRestConnected={isKisRestConnected}
|
||||
isWebSocketReady={Boolean(wsApprovalKey && wsUrl && isWsConnected)}
|
||||
isRealtimePending={isRealtimePending}
|
||||
isProfileVerified={isKisProfileVerified}
|
||||
verifiedAccountNo={verifiedAccountNo}
|
||||
isRefreshing={isRefreshing}
|
||||
lastUpdatedAt={lastUpdatedAt}
|
||||
onRefresh={() => {
|
||||
void handleRefreshAll();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ========== 메인 그리드 구성 ========== */}
|
||||
<div className="grid gap-4 lg:grid-cols-[1.15fr_1fr]">
|
||||
{/* 왼쪽 섹션: 보유 종목 목록 리스트 */}
|
||||
<HoldingsList
|
||||
holdings={mergedHoldings}
|
||||
selectedSymbol={selectedSymbol}
|
||||
isLoading={isLoading}
|
||||
error={balanceError}
|
||||
onRetry={() => {
|
||||
void handleRefreshAll();
|
||||
}}
|
||||
onSelect={setSelectedSymbol}
|
||||
/>
|
||||
|
||||
{/* 오른쪽 섹션: 시장 지수 요약 및 선택 종목 상세 정보 */}
|
||||
<div className="grid gap-4">
|
||||
{/* 시장 지수 현황 (코스피/코스닥) */}
|
||||
<MarketSummary
|
||||
items={indices}
|
||||
isLoading={isLoading}
|
||||
error={effectiveIndicesError}
|
||||
warning={indicesWarning}
|
||||
isRealtimePending={isRealtimePending}
|
||||
onRetry={() => {
|
||||
void handleRefreshAll();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 선택된 종목의 실시간 상세 요약 정보 */}
|
||||
<StockDetailPreview
|
||||
holding={realtimeSelectedHolding}
|
||||
totalAmount={mergedSummary?.totalAmount ?? 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ========== 하단 섹션: 최근 매매/충전 활동 내역 ========== */}
|
||||
<ActivitySection
|
||||
activity={activity}
|
||||
isLoading={isLoading}
|
||||
error={activityError}
|
||||
onRetry={() => {
|
||||
void handleRefreshAll();
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description REST 지수 데이터가 없을 때 실시간 수신값만으로 코스피/코스닥 표시 데이터를 구성합니다.
|
||||
* @param realtimeIndices 실시간 지수 맵
|
||||
* @returns 화면 렌더링용 지수 배열
|
||||
* @remarks UI 흐름: DashboardContainer -> buildRealtimeOnlyIndices -> MarketSummary 렌더링
|
||||
* @see features/dashboard/hooks/use-market-realtime.ts 실시간 지수 수신 훅
|
||||
*/
|
||||
function buildRealtimeOnlyIndices(
|
||||
realtimeIndices: Record<string, { price: number; change: number; changeRate: number }>,
|
||||
) {
|
||||
const baseItems: DashboardMarketIndexItem[] = [
|
||||
{ market: "KOSPI", code: "0001", name: "코스피", price: 0, change: 0, changeRate: 0 },
|
||||
{ market: "KOSDAQ", code: "1001", name: "코스닥", price: 0, change: 0, changeRate: 0 },
|
||||
];
|
||||
|
||||
return baseItems
|
||||
.map((item) => {
|
||||
const realtime = realtimeIndices[item.code];
|
||||
if (!realtime) return null;
|
||||
return {
|
||||
...item,
|
||||
price: realtime.price,
|
||||
change: realtime.change,
|
||||
changeRate: realtime.changeRate,
|
||||
} satisfies DashboardMarketIndexItem;
|
||||
})
|
||||
.filter((item): item is DashboardMarketIndexItem => Boolean(item));
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 보유종목 리스트에 실시간 체결가를 병합해 현재가/평가금액/손익을 재계산합니다.
|
||||
* @param holdings REST 기준 보유종목
|
||||
* @param realtimeHoldings 종목별 실시간 체결 데이터
|
||||
* @returns 병합된 보유종목 리스트
|
||||
* @remarks UI 흐름: DashboardContainer -> mergeHoldingsWithRealtime -> HoldingsList/StockDetailPreview 반영
|
||||
* @see features/dashboard/hooks/use-holdings-realtime.ts 보유종목 실시간 체결 구독
|
||||
*/
|
||||
function mergeHoldingsWithRealtime(
|
||||
holdings: DashboardHoldingItem[],
|
||||
realtimeHoldings: Record<string, KisRealtimeStockTick>,
|
||||
) {
|
||||
if (holdings.length === 0 || Object.keys(realtimeHoldings).length === 0) {
|
||||
return holdings;
|
||||
}
|
||||
|
||||
return holdings.map((item) => {
|
||||
const tick = realtimeHoldings[item.symbol];
|
||||
if (!tick) return item;
|
||||
|
||||
const currentPrice = tick.currentPrice;
|
||||
const purchaseAmount = item.averagePrice * item.quantity;
|
||||
const evaluationAmount = currentPrice * item.quantity;
|
||||
const profitLoss = evaluationAmount - purchaseAmount;
|
||||
const profitRate = purchaseAmount > 0 ? (profitLoss / purchaseAmount) * 100 : 0;
|
||||
|
||||
return {
|
||||
...item,
|
||||
currentPrice,
|
||||
evaluationAmount,
|
||||
profitLoss,
|
||||
profitRate,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 실시간 보유종목 기준으로 대시보드 요약(총자산/손익)을 일관되게 재계산합니다.
|
||||
* @param summary REST API 요약 값
|
||||
* @param holdings 실시간 병합된 보유종목
|
||||
* @returns 재계산된 요약 값
|
||||
* @remarks UI 흐름: DashboardContainer -> buildRealtimeSummary -> StatusHeader 카드 반영
|
||||
* @see features/dashboard/components/StatusHeader.tsx 상단 요약 렌더링
|
||||
*/
|
||||
function buildRealtimeSummary(
|
||||
summary: DashboardBalanceSummary | null,
|
||||
holdings: DashboardHoldingItem[],
|
||||
) {
|
||||
if (!summary) return null;
|
||||
if (holdings.length === 0) return summary;
|
||||
|
||||
const evaluationAmount = holdings.reduce(
|
||||
(total, item) => total + item.evaluationAmount,
|
||||
0,
|
||||
);
|
||||
const purchaseAmount = holdings.reduce(
|
||||
(total, item) => total + item.averagePrice * item.quantity,
|
||||
0,
|
||||
);
|
||||
const totalProfitLoss = evaluationAmount - purchaseAmount;
|
||||
const totalProfitRate =
|
||||
purchaseAmount > 0 ? (totalProfitLoss / purchaseAmount) * 100 : 0;
|
||||
|
||||
const evaluationDelta = evaluationAmount - summary.evaluationAmount;
|
||||
const baseTotalAmount =
|
||||
summary.apiReportedNetAssetAmount > 0
|
||||
? summary.apiReportedNetAssetAmount
|
||||
: summary.totalAmount;
|
||||
|
||||
// 실시간은 "기준 순자산 + 평가금 증감분"으로만 반영합니다.
|
||||
const totalAmount = Math.max(baseTotalAmount + evaluationDelta, 0);
|
||||
const netAssetAmount = totalAmount;
|
||||
const cashBalance = Math.max(totalAmount - evaluationAmount, 0);
|
||||
|
||||
return {
|
||||
...summary,
|
||||
totalAmount,
|
||||
netAssetAmount,
|
||||
cashBalance,
|
||||
evaluationAmount,
|
||||
purchaseAmount,
|
||||
totalProfitLoss,
|
||||
totalProfitRate,
|
||||
} satisfies DashboardBalanceSummary;
|
||||
}
|
||||
59
features/dashboard/components/DashboardSkeleton.tsx
Normal file
59
features/dashboard/components/DashboardSkeleton.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* @description 대시보드 초기 로딩 스켈레톤 UI입니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx isLoading 상태에서 렌더링합니다.
|
||||
*/
|
||||
export function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 p-4 md:p-6">
|
||||
{/* ========== HEADER SKELETON ========== */}
|
||||
<Card className="border-brand-200 dark:border-brand-800/50">
|
||||
<CardContent className="grid gap-3 p-4 md:grid-cols-4">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ========== BODY SKELETON ========== */}
|
||||
<div className="grid gap-4 lg:grid-cols-[1.15fr_1fr]">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-5 w-28" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-14 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-5 w-40" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-14 w-full" />
|
||||
<Skeleton className="h-14 w-full" />
|
||||
<Skeleton className="h-14 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
228
features/dashboard/components/HoldingsList.tsx
Normal file
228
features/dashboard/components/HoldingsList.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* @file HoldingsList.tsx
|
||||
* @description 대시보드 좌측 영역의 보유 종목 리스트 컴포넌트
|
||||
* @remarks
|
||||
* - [레이어] Components / UI
|
||||
* - [사용자 행동] 종목 리스트 스크롤 -> 특정 종목 클릭(선택) -> 우측 상세 프레뷰 갱신
|
||||
* - [데이터 흐름] DashboardContainer(mergedHoldings) -> HoldingsList -> HoldingItemRow -> onSelect(Callback)
|
||||
* - [연관 파일] DashboardContainer.tsx, dashboard.types.ts, use-price-flash.ts
|
||||
* @author jihoon87.lee
|
||||
*/
|
||||
import { AlertCircle, Wallet2 } from "lucide-react";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatPercent,
|
||||
getChangeToneClass,
|
||||
} from "@/features/dashboard/utils/dashboard-format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash";
|
||||
|
||||
interface HoldingsListProps {
|
||||
/** 보유 종목 데이터 리스트 (실시간 시세 병합됨) */
|
||||
holdings: DashboardHoldingItem[];
|
||||
/** 현재 선택된 종목의 심볼 (없으면 null) */
|
||||
selectedSymbol: string | null;
|
||||
/** 데이터 로딩 상태 */
|
||||
isLoading: boolean;
|
||||
/** 에러 메시지 (없으면 null) */
|
||||
error: string | null;
|
||||
/** 섹션 재조회 핸들러 */
|
||||
onRetry?: () => void;
|
||||
/** 종목 선택 시 호출되는 핸들러 */
|
||||
onSelect: (symbol: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* [컴포넌트] 보유 종목 리스트
|
||||
* 사용자의 잔고 정보를 바탕으로 실시간 시세가 반영된 종목 카드 목록을 렌더링합니다.
|
||||
*
|
||||
* @param props HoldingsListProps
|
||||
* @see DashboardContainer.tsx - 좌측 메인 영역에서 실시간 병합 데이터를 전달받아 호출
|
||||
* @see DashboardContainer.tsx - setSelectedSymbol 핸들러를 onSelect로 전달
|
||||
*/
|
||||
export function HoldingsList({
|
||||
holdings,
|
||||
selectedSymbol,
|
||||
isLoading,
|
||||
error,
|
||||
onRetry,
|
||||
onSelect,
|
||||
}: HoldingsListProps) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
{/* ========== 카드 헤더: 타이틀 및 설명 ========== */}
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Wallet2 className="h-4 w-4 text-brand-600 dark:text-brand-400" />
|
||||
보유 종목
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
현재 보유 중인 종목을 선택하면 우측 상세가 갱신됩니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
{/* ========== 카드 본문: 상태별 메시지 및 리스트 ========== */}
|
||||
<CardContent>
|
||||
{/* 로딩 중 상태 (데이터가 아직 없는 경우) */}
|
||||
{isLoading && holdings.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
보유 종목을 불러오는 중입니다.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 에러 발생 상태 */}
|
||||
{error && (
|
||||
<div className="mb-2 rounded-md border border-red-200 bg-red-50/60 p-2.5 dark:border-red-900/40 dark:bg-red-950/20">
|
||||
<p className="flex items-start gap-1.5 text-sm text-red-600 dark:text-red-400">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-red-700/80 dark:text-red-300/80">
|
||||
한국투자증권 API가 일시적으로 불안정할 수 있습니다. 잠시 후 다시 시도해 주세요.
|
||||
</p>
|
||||
{onRetry ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
className="mt-2 border-red-300 text-red-700 hover:bg-red-100 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/40"
|
||||
>
|
||||
<RefreshCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||
보유종목 다시 불러오기
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 없음 상태 */}
|
||||
{!isLoading && holdings.length === 0 && !error && (
|
||||
<p className="text-sm text-muted-foreground">보유 종목이 없습니다.</p>
|
||||
)}
|
||||
|
||||
{/* 종목 리스트 렌더링 영역 */}
|
||||
{holdings.length > 0 && (
|
||||
<ScrollArea className="h-[420px] pr-3">
|
||||
<div className="space-y-2">
|
||||
{holdings.map((holding) => (
|
||||
<HoldingItemRow
|
||||
key={holding.symbol}
|
||||
holding={holding}
|
||||
isSelected={selectedSymbol === holding.symbol}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface HoldingItemRowProps {
|
||||
/** 개별 종목 정보 */
|
||||
holding: DashboardHoldingItem;
|
||||
/** 선택 여부 */
|
||||
isSelected: boolean;
|
||||
/** 클릭 핸들러 */
|
||||
onSelect: (symbol: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* [컴포넌트] 보유 종목 개별 행 (아이템)
|
||||
* 종목의 기본 정보와 실시간 시세, 현재 손익 상태를 표시합니다.
|
||||
*
|
||||
* @param props HoldingItemRowProps
|
||||
* @see HoldingsList.tsx - holdings.map 내에서 호출
|
||||
* @see use-price-flash.ts - 현재가 변경 감지 및 애니메이션 효과 트리거
|
||||
*/
|
||||
function HoldingItemRow({
|
||||
holding,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: HoldingItemRowProps) {
|
||||
// [State/Hook] 현재가 기반 가격 반짝임 효과 상태 관리
|
||||
// @see use-price-flash.ts - 현재가 변경 감지 시 symbol을 기준으로 이펙트 발생 여부 결정
|
||||
const flash = usePriceFlash(holding.currentPrice, holding.symbol);
|
||||
|
||||
// [UI] 손익 상태에 따른 텍스트 색상 클래스 결정 (상승: red, 하락: blue)
|
||||
const toneClass = getChangeToneClass(holding.profitLoss);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
// [Step 1] 종목 클릭 시 부모의 선택 핸들러 호출
|
||||
onClick={() => onSelect(holding.symbol)}
|
||||
className={cn(
|
||||
"w-full rounded-xl border px-3 py-3 text-left transition-all relative overflow-hidden",
|
||||
isSelected
|
||||
? "border-brand-400 bg-brand-50/60 ring-1 ring-brand-300 dark:border-brand-600 dark:bg-brand-900/20 dark:ring-brand-700"
|
||||
: "border-border/70 bg-background hover:border-brand-200 hover:bg-brand-50/30 dark:hover:border-brand-700 dark:hover:bg-brand-900/15",
|
||||
)}
|
||||
>
|
||||
{/* ========== 행 상단: 종목명, 심볼 및 현재가 정보 ========== */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
{/* 종목명 및 기본 정보 */}
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{holding.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{holding.symbol} · {holding.market} · {holding.quantity}주
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="relative inline-flex items-center justify-end gap-1">
|
||||
{/* 시세 변동 애니메이션 (Flash) 표시 영역 */}
|
||||
{flash && (
|
||||
<span
|
||||
key={flash.id}
|
||||
className={cn(
|
||||
"pointer-events-none absolute -left-12 top-0 whitespace-nowrap text-xs font-bold animate-in fade-in slide-in-from-bottom-1 fill-mode-forwards duration-300",
|
||||
flash.type === "up" ? "text-red-500" : "text-blue-500",
|
||||
)}
|
||||
>
|
||||
{flash.type === "up" ? "+" : ""}
|
||||
{flash.val.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
{/* 실시간 현재가 */}
|
||||
<p className="text-sm font-semibold text-foreground transition-colors duration-300">
|
||||
{formatCurrency(holding.currentPrice)}원
|
||||
</p>
|
||||
</div>
|
||||
{/* 실시간 수익률 */}
|
||||
<p className={cn("text-xs font-medium", toneClass)}>
|
||||
{formatPercent(holding.profitRate)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ========== 행 하단: 평단가, 평가액 및 실시간 손익 ========== */}
|
||||
<div className="mt-2 grid grid-cols-3 gap-1 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
평균 {formatCurrency(holding.averagePrice)}원
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
평가 {formatCurrency(holding.evaluationAmount)}원
|
||||
</span>
|
||||
<span className={cn("text-right font-medium", toneClass)}>
|
||||
손익 {formatCurrency(holding.profitLoss)}원
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
192
features/dashboard/components/MarketSummary.tsx
Normal file
192
features/dashboard/components/MarketSummary.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { BarChart3, TrendingDown, TrendingUp } from "lucide-react";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import type { DashboardMarketIndexItem } from "@/features/dashboard/types/dashboard.types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatSignedCurrency,
|
||||
formatSignedPercent,
|
||||
} from "@/features/dashboard/utils/dashboard-format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash";
|
||||
|
||||
interface MarketSummaryProps {
|
||||
items: DashboardMarketIndexItem[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
warning?: string | null;
|
||||
isRealtimePending?: boolean;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 코스피/코스닥 지수 요약 카드입니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 우측 상단 영역에서 호출합니다.
|
||||
*/
|
||||
export function MarketSummary({
|
||||
items,
|
||||
isLoading,
|
||||
error,
|
||||
warning = null,
|
||||
isRealtimePending = false,
|
||||
onRetry,
|
||||
}: MarketSummaryProps) {
|
||||
return (
|
||||
<Card className="overflow-hidden border-brand-200/80 bg-linear-to-br from-brand-50/50 to-background dark:border-brand-800/45 dark:from-brand-950/20 dark:to-background">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-base text-brand-700 dark:text-brand-300">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
시장 지수
|
||||
</CardTitle>
|
||||
</div>
|
||||
<CardDescription>실시간 코스피/코스닥 지수 현황입니다.</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
|
||||
{/* ========== LOADING STATE ========== */}
|
||||
{isLoading && items.length === 0 && (
|
||||
<div className="col-span-full py-4 text-center text-sm text-muted-foreground animate-pulse">
|
||||
지수 데이터를 불러오는 중입니다...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== REALTIME PENDING STATE ========== */}
|
||||
{isRealtimePending && items.length === 0 && !isLoading && !error && (
|
||||
<div className="col-span-full rounded-md bg-amber-50 p-3 text-sm text-amber-700 dark:bg-amber-950/25 dark:text-amber-400">
|
||||
실시간 시세 연결은 완료되었고 첫 지수 데이터를 기다리는 중입니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== ERROR/WARNING STATE ========== */}
|
||||
{error && (
|
||||
<div className="col-span-full rounded-md bg-red-50 p-3 text-sm text-red-600 dark:bg-red-950/30 dark:text-red-400">
|
||||
<p>지수 정보를 가져오는데 실패했습니다.</p>
|
||||
<p className="mt-1 text-xs opacity-80">
|
||||
{toCompactErrorMessage(error)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs opacity-80">
|
||||
토큰이 정상이어도 한국투자증권 API 점검/지연 시 일시적으로 실패할 수 있습니다.
|
||||
</p>
|
||||
{onRetry ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
className="mt-2 border-red-300 text-red-700 hover:bg-red-100 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/40"
|
||||
>
|
||||
<RefreshCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||
지수 다시 불러오기
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{!error && warning && (
|
||||
<div className="col-span-full rounded-md bg-amber-50 p-3 text-sm text-amber-700 dark:bg-amber-950/25 dark:text-amber-400">
|
||||
{warning}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== INDEX CARDS ========== */}
|
||||
{items.map((item) => (
|
||||
<IndexItem key={item.code} item={item} />
|
||||
))}
|
||||
|
||||
{!isLoading && items.length === 0 && !error && (
|
||||
<div className="col-span-full py-4 text-center text-sm text-muted-foreground">
|
||||
표시할 데이터가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 길고 복잡한 서버 오류를 대시보드 카드에 맞는 짧은 문구로 축약합니다.
|
||||
* @param error 원본 오류 문자열
|
||||
* @returns 화면 노출용 오류 메시지
|
||||
* @see features/dashboard/components/MarketSummary.tsx 지수 오류 배너 상세 문구
|
||||
*/
|
||||
function toCompactErrorMessage(error: string) {
|
||||
const normalized = error.replaceAll(/\s+/g, " ").trim();
|
||||
if (!normalized) return "잠시 후 다시 시도해 주세요.";
|
||||
if (normalized.length <= 120) return normalized;
|
||||
return `${normalized.slice(0, 120)}...`;
|
||||
}
|
||||
|
||||
function IndexItem({ item }: { item: DashboardMarketIndexItem }) {
|
||||
const isUp = item.change > 0;
|
||||
const isDown = item.change < 0;
|
||||
const toneClass = isUp
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: isDown
|
||||
? "text-blue-600 dark:text-blue-400"
|
||||
: "text-muted-foreground";
|
||||
|
||||
const bgClass = isUp
|
||||
? "bg-red-50/50 dark:bg-red-950/10 border-red-100 dark:border-red-900/30"
|
||||
: isDown
|
||||
? "bg-blue-50/50 dark:bg-blue-950/10 border-blue-100 dark:border-blue-900/30"
|
||||
: "bg-muted/50 border-border/50";
|
||||
|
||||
const flash = usePriceFlash(item.price, item.code);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col justify-between rounded-xl border p-4 transition-all hover:bg-background/80",
|
||||
bgClass,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{item.market}
|
||||
</span>
|
||||
{isUp ? (
|
||||
<TrendingUp className="h-4 w-4 text-red-500/70" />
|
||||
) : isDown ? (
|
||||
<TrendingDown className="h-4 w-4 text-blue-500/70" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-2xl font-bold tracking-tight relative w-fit">
|
||||
{formatCurrency(item.price)}
|
||||
|
||||
{/* Flash Indicator */}
|
||||
{flash && (
|
||||
<div
|
||||
key={flash.id} // Force re-render for animation restart using state ID
|
||||
className={cn(
|
||||
"absolute left-full top-1 ml-2 text-sm font-bold animate-out fade-out slide-out-to-top-2 duration-1000 fill-mode-forwards pointer-events-none whitespace-nowrap",
|
||||
flash.type === "up" ? "text-red-500" : "text-blue-500",
|
||||
)}
|
||||
>
|
||||
{flash.type === "up" ? "+" : ""}
|
||||
{flash.val.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 flex items-center gap-2 text-sm font-medium",
|
||||
toneClass,
|
||||
)}
|
||||
>
|
||||
<span>{formatSignedCurrency(item.change)}</span>
|
||||
<span className="rounded-md bg-background/50 px-1.5 py-0.5 text-xs shadow-sm">
|
||||
{formatSignedPercent(item.changeRate)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
219
features/dashboard/components/StatusHeader.tsx
Normal file
219
features/dashboard/components/StatusHeader.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import Link from "next/link";
|
||||
import { Activity, RefreshCcw, Settings2, Wifi } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import type { DashboardBalanceSummary } from "@/features/dashboard/types/dashboard.types";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatSignedCurrency,
|
||||
formatSignedPercent,
|
||||
getChangeToneClass,
|
||||
} from "@/features/dashboard/utils/dashboard-format";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface StatusHeaderProps {
|
||||
summary: DashboardBalanceSummary | null;
|
||||
isKisRestConnected: boolean;
|
||||
isWebSocketReady: boolean;
|
||||
isRealtimePending: boolean;
|
||||
isProfileVerified: boolean;
|
||||
verifiedAccountNo: string | null;
|
||||
isRefreshing: boolean;
|
||||
lastUpdatedAt: string | null;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 대시보드 상단 상태 헤더(총자산/손익/연결상태/빠른액션) 컴포넌트입니다.
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 대시보드 루트에서 상태 값을 전달받아 렌더링합니다.
|
||||
*/
|
||||
export function StatusHeader({
|
||||
summary,
|
||||
isKisRestConnected,
|
||||
isWebSocketReady,
|
||||
isRealtimePending,
|
||||
isProfileVerified,
|
||||
verifiedAccountNo,
|
||||
isRefreshing,
|
||||
lastUpdatedAt,
|
||||
onRefresh,
|
||||
}: StatusHeaderProps) {
|
||||
const toneClass = getChangeToneClass(summary?.totalProfitLoss ?? 0);
|
||||
const updatedLabel = lastUpdatedAt
|
||||
? new Date(lastUpdatedAt).toLocaleTimeString("ko-KR", {
|
||||
hour12: false,
|
||||
})
|
||||
: "--:--:--";
|
||||
const hasApiTotalAmount =
|
||||
Boolean(summary) && (summary?.apiReportedTotalAmount ?? 0) > 0;
|
||||
const hasApiNetAssetAmount =
|
||||
Boolean(summary) && (summary?.apiReportedNetAssetAmount ?? 0) > 0;
|
||||
const isApiTotalAmountDifferent =
|
||||
Boolean(summary) &&
|
||||
Math.abs(
|
||||
(summary?.apiReportedTotalAmount ?? 0) - (summary?.totalAmount ?? 0),
|
||||
) >= 1;
|
||||
const realtimeStatusLabel = isWebSocketReady
|
||||
? isRealtimePending
|
||||
? "수신 대기중"
|
||||
: "연결됨"
|
||||
: "미연결";
|
||||
|
||||
return (
|
||||
<Card className="relative overflow-hidden border-brand-200 shadow-sm dark:border-brand-800/50">
|
||||
{/* ========== BACKGROUND DECORATION ========== */}
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-20 bg-linear-to-r from-brand-100/70 via-brand-50/50 to-transparent dark:from-brand-900/35 dark:via-brand-900/20" />
|
||||
|
||||
<CardContent className="relative grid gap-3 p-4 md:grid-cols-[1fr_1fr_1fr_auto]">
|
||||
{/* ========== TOTAL ASSET ========== */}
|
||||
<div className="rounded-xl border border-border/70 bg-background/90 px-4 py-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">내 자산 (순자산 실시간)</p>
|
||||
<p className="mt-1 text-xl font-semibold tracking-tight">
|
||||
{summary ? `${formatCurrency(summary.totalAmount)}원` : "-"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
현금(예수금) {summary ? `${formatCurrency(summary.cashBalance)}원` : "-"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
주식 평가금{" "}
|
||||
{summary ? `${formatCurrency(summary.evaluationAmount)}원` : "-"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
총예수금(KIS){" "}
|
||||
{summary ? `${formatCurrency(summary.totalDepositAmount)}원` : "-"}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground/80">
|
||||
총예수금은 결제 대기 금액이 포함될 수 있어 체감 현금과 다를 수 있습니다.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
순자산(대출 반영){" "}
|
||||
{summary ? `${formatCurrency(summary.netAssetAmount)}원` : "-"}
|
||||
</p>
|
||||
{hasApiTotalAmount && isApiTotalAmountDifferent ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
KIS 집계 총자산 {formatCurrency(summary?.apiReportedTotalAmount ?? 0)}원
|
||||
</p>
|
||||
) : null}
|
||||
{hasApiNetAssetAmount ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
KIS 집계 순자산 {formatCurrency(summary?.apiReportedNetAssetAmount ?? 0)}원
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* ========== PROFIT/LOSS ========== */}
|
||||
<div className="rounded-xl border border-border/70 bg-background/90 px-4 py-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">현재 손익</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 text-xl font-semibold tracking-tight",
|
||||
toneClass,
|
||||
)}
|
||||
>
|
||||
{summary ? `${formatSignedCurrency(summary.totalProfitLoss)}원` : "-"}
|
||||
</p>
|
||||
<p className={cn("mt-1 text-xs font-medium", toneClass)}>
|
||||
{summary ? formatSignedPercent(summary.totalProfitRate) : "-"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
현재 평가금액{" "}
|
||||
{summary ? `${formatCurrency(summary.evaluationAmount)}원` : "-"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
총 매수금액{" "}
|
||||
{summary ? `${formatCurrency(summary.purchaseAmount)}원` : "-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ========== CONNECTION STATUS ========== */}
|
||||
<div className="rounded-xl border border-border/70 bg-background/90 px-4 py-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">연결 상태</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs font-medium">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-1",
|
||||
isKisRestConnected
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-red-500/10 text-red-600 dark:text-red-400",
|
||||
)}
|
||||
>
|
||||
<Wifi className="h-3.5 w-3.5" />
|
||||
서버 {isKisRestConnected ? "연결됨" : "연결 끊김"}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-1",
|
||||
isWebSocketReady
|
||||
? isRealtimePending
|
||||
? "bg-amber-500/10 text-amber-700 dark:text-amber-400"
|
||||
: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
실시간 시세 {realtimeStatusLabel}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-1",
|
||||
isProfileVerified
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
|
||||
)}
|
||||
>
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
계좌 인증 {isProfileVerified ? "완료" : "미완료"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
마지막 업데이트 {updatedLabel}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
계좌 {maskAccountNo(verifiedAccountNo)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
대출금 {summary ? `${formatCurrency(summary.loanAmount)}원` : "-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ========== QUICK ACTIONS ========== */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row md:flex-col md:items-stretch md:justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="w-full border-brand-200 text-brand-700 hover:bg-brand-50 dark:border-brand-700 dark:text-brand-300 dark:hover:bg-brand-900/30"
|
||||
>
|
||||
<RefreshCcw
|
||||
className={cn("h-4 w-4 mr-2", isRefreshing ? "animate-spin" : "")}
|
||||
/>
|
||||
지금 다시 불러오기
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
className="w-full bg-brand-600 text-white hover:bg-brand-700 dark:bg-brand-600 dark:text-white dark:hover:bg-brand-500"
|
||||
>
|
||||
<Link href="/settings">
|
||||
<Settings2 className="h-4 w-4 mr-2" />
|
||||
연결 설정
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 계좌번호를 마스킹해 표시합니다.
|
||||
* @param value 계좌번호(8-2)
|
||||
* @returns 마스킹 문자열
|
||||
* @see features/dashboard/components/StatusHeader.tsx 시스템 상태 영역 계좌 표시
|
||||
*/
|
||||
function maskAccountNo(value: string | null) {
|
||||
if (!value) return "-";
|
||||
const digits = value.replace(/\D/g, "");
|
||||
if (digits.length !== 10) return "********";
|
||||
return "********-**";
|
||||
}
|
||||
235
features/dashboard/components/StockDetailPreview.tsx
Normal file
235
features/dashboard/components/StockDetailPreview.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* @file StockDetailPreview.tsx
|
||||
* @description 대시보드 우측 영역의 선택 종목 상세 정보 및 실시간 시세 반영 컴포넌트
|
||||
* @remarks
|
||||
* - [레이어] Components / UI
|
||||
* - [사용자 행동] 종목 리스트에서 항목 선택 -> 상세 정보 조회 -> 실시간 시세 변동 확인
|
||||
* - [데이터 흐름] DashboardContainer(realtimeSelectedHolding) -> StockDetailPreview -> Metric(UI)
|
||||
* - [연관 파일] DashboardContainer.tsx, dashboard.types.ts, use-price-flash.ts
|
||||
* @author jihoon87.lee
|
||||
*/
|
||||
import { BarChartBig, ExternalLink, MousePointerClick } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash";
|
||||
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
|
||||
import { useTradeNavigationStore } from "@/features/trade/store/use-trade-navigation-store";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatPercent,
|
||||
getChangeToneClass,
|
||||
} from "@/features/dashboard/utils/dashboard-format";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface StockDetailPreviewProps {
|
||||
/** 선택된 종목 정보 (없으면 null) */
|
||||
holding: DashboardHoldingItem | null;
|
||||
/** 현재 총 자산 (비중 계산용) */
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* [컴포넌트] 선택 종목 상세 요약 카드
|
||||
* 대시보드에서 선택된 특정 종목의 매입가, 현재가, 수익률 등 상세 지표를 실시간으로 보여줍니다.
|
||||
*
|
||||
* @param props StockDetailPreviewProps
|
||||
* @see DashboardContainer.tsx - HoldingsList 선택 결과를 실시간 데이터로 전달받아 렌더링
|
||||
*/
|
||||
export function StockDetailPreview({
|
||||
holding,
|
||||
totalAmount,
|
||||
}: StockDetailPreviewProps) {
|
||||
const router = useRouter();
|
||||
const setPendingTarget = useTradeNavigationStore(
|
||||
(state) => state.setPendingTarget,
|
||||
);
|
||||
// [State/Hook] 실시간 가격 변동 애니메이션 상태 관리
|
||||
// @remarks 종목이 선택되지 않았을 때를 대비해 safe value(0)를 전달하며, 종목 변경 시 효과를 초기화하도록 symbol 전달
|
||||
const currentPrice = holding?.currentPrice ?? 0;
|
||||
const priceFlash = usePriceFlash(currentPrice, holding?.symbol);
|
||||
|
||||
// [Step 1] 종목이 선택되지 않은 경우 초기 안내 화면 렌더링
|
||||
if (!holding) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<BarChartBig className="h-4 w-4 text-brand-600 dark:text-brand-400" />
|
||||
선택 종목 정보
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
보유 종목을 선택하면 자세한 정보가 표시됩니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
왼쪽 보유 종목 리스트에서 종목을 선택해 주세요.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// [Step 2] 수익/손실 여부에 따른 UI 톤(색상) 결정
|
||||
const profitToneClass = getChangeToneClass(holding.profitLoss);
|
||||
|
||||
// [Step 3] 총 자산 대비 비중 계산
|
||||
const allocationRate =
|
||||
totalAmount > 0
|
||||
? Math.min((holding.evaluationAmount / totalAmount) * 100, 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{/* ========== 카드 헤더: 종목명 및 기본 정보 ========== */}
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<BarChartBig className="h-4 w-4 text-brand-600 dark:text-brand-400" />
|
||||
선택 종목 정보
|
||||
</CardTitle>
|
||||
<CardDescription className="flex items-center gap-1.5 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPendingTarget({
|
||||
symbol: holding.symbol,
|
||||
name: holding.name,
|
||||
market: holding.market,
|
||||
});
|
||||
router.push("/trade");
|
||||
}}
|
||||
className={cn(
|
||||
"group flex items-center gap-1.5 rounded-md border border-brand-200 bg-brand-50 px-2 py-0.5",
|
||||
"text-sm font-bold text-brand-700 transition-all cursor-pointer",
|
||||
"hover:border-brand-400 hover:bg-brand-100 hover:shadow-sm",
|
||||
"dark:border-brand-800/60 dark:bg-brand-900/40 dark:text-brand-400 dark:hover:border-brand-600 dark:hover:bg-brand-900/60",
|
||||
)}
|
||||
title={`${holding.name} 종목 상세 거래로 이동`}
|
||||
>
|
||||
<span className="truncate">{holding.name}</span>
|
||||
<span className="text-[10px] font-medium opacity-70">
|
||||
({holding.symbol})
|
||||
</span>
|
||||
<ExternalLink className="h-3 w-3 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
· {holding.market}
|
||||
</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* ========== 실시간 주요 지표 영역 (Grid) ========== */}
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<Metric
|
||||
label="보유 수량"
|
||||
value={`${holding.quantity.toLocaleString("ko-KR")}주`}
|
||||
/>
|
||||
<Metric
|
||||
label="매입 평균가"
|
||||
value={`${formatCurrency(holding.averagePrice)}원`}
|
||||
/>
|
||||
<Metric
|
||||
label="현재가"
|
||||
value={`${formatCurrency(holding.currentPrice)}원`}
|
||||
flash={priceFlash}
|
||||
/>
|
||||
<Metric
|
||||
label="수익률"
|
||||
value={formatPercent(holding.profitRate)}
|
||||
valueClassName={profitToneClass}
|
||||
/>
|
||||
<Metric
|
||||
label="현재 손익"
|
||||
value={`${formatCurrency(holding.profitLoss)}원`}
|
||||
valueClassName={profitToneClass}
|
||||
/>
|
||||
<Metric
|
||||
label="평가금액"
|
||||
value={`${formatCurrency(holding.evaluationAmount)}원`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ========== 자산 비중 그래프 영역 ========== */}
|
||||
<div className="rounded-xl border border-border/70 bg-background/70 p-3">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>총 자산 대비 비중</span>
|
||||
<span>{formatPercent(allocationRate)}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 rounded-full bg-muted">
|
||||
<div
|
||||
className="h-2 rounded-full bg-linear-to-r from-brand-500 to-brand-700"
|
||||
style={{ width: `${allocationRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ========== 추가 기능 예고 영역 (Placeholder) ========== */}
|
||||
<div className="rounded-xl border border-dashed border-border/80 bg-muted/30 p-3">
|
||||
<p className="flex items-center gap-1.5 text-sm font-medium text-foreground/80">
|
||||
<MousePointerClick className="h-4 w-4 text-brand-500" />
|
||||
빠른 주문(준비 중)
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
향후 이 영역에서 선택 종목의 빠른 매수/매도 기능을 제공합니다.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface MetricProps {
|
||||
/** 지표 레이블 */
|
||||
label: string;
|
||||
/** 표시될 값 */
|
||||
value: string;
|
||||
/** 값 텍스트 추가 스타일 */
|
||||
valueClassName?: string;
|
||||
/** 가격 변동 애니메이션 상태 */
|
||||
flash?: { type: "up" | "down"; val: number; id: number } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* [컴포넌트] 상세 카드용 개별 지표 아이템
|
||||
* 레이블과 값을 박스 형태로 렌더링하며, 필요한 경우 시세 변동 Flash 애니메이션을 처리합니다.
|
||||
*
|
||||
* @param props MetricProps
|
||||
* @see StockDetailPreview.tsx - 내부 그리드 영역에서 여러 개 호출
|
||||
*/
|
||||
function Metric({ label, value, valueClassName, flash }: MetricProps) {
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-xl border border-border/70 bg-background/70 p-3 transition-colors">
|
||||
{/* 시세 변동 시 나타나는 일시적인 수치 표시 (Flash) */}
|
||||
{flash && (
|
||||
<span
|
||||
key={flash.id}
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-2 top-2 text-xs font-bold animate-in fade-in slide-in-from-bottom-1 fill-mode-forwards duration-300",
|
||||
flash.type === "up" ? "text-red-500" : "text-blue-500",
|
||||
)}
|
||||
>
|
||||
{flash.type === "up" ? "+" : ""}
|
||||
{flash.val.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 지표 레이블 및 본체 값 */}
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 text-sm font-semibold text-foreground transition-colors",
|
||||
valueClassName,
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
198
features/dashboard/hooks/use-dashboard-data.ts
Normal file
198
features/dashboard/hooks/use-dashboard-data.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
||||
import {
|
||||
fetchDashboardActivity,
|
||||
fetchDashboardBalance,
|
||||
fetchDashboardIndices,
|
||||
} from "@/features/dashboard/apis/dashboard.api";
|
||||
import type {
|
||||
DashboardActivityResponse,
|
||||
DashboardBalanceResponse,
|
||||
DashboardIndicesResponse,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
|
||||
interface UseDashboardDataResult {
|
||||
activity: DashboardActivityResponse | null;
|
||||
balance: DashboardBalanceResponse | null;
|
||||
indices: DashboardIndicesResponse["items"];
|
||||
selectedSymbol: string | null;
|
||||
setSelectedSymbol: (symbol: string) => void;
|
||||
isLoading: boolean;
|
||||
isRefreshing: boolean;
|
||||
activityError: string | null;
|
||||
balanceError: string | null;
|
||||
indicesError: string | null;
|
||||
lastUpdatedAt: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const POLLING_INTERVAL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* @description 대시보드 잔고/지수 상태를 관리하는 훅입니다.
|
||||
* @param credentials KIS 인증 정보
|
||||
* @returns 대시보드 데이터/로딩/오류 상태
|
||||
* @remarks UI 흐름: 대시보드 진입 -> refresh("initial") -> balance/indices API 병렬 호출 -> 카드별 상태 반영
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 대시보드 루트 컨테이너
|
||||
* @see features/dashboard/apis/dashboard.api.ts 실제 API 호출 함수
|
||||
*/
|
||||
export function useDashboardData(
|
||||
credentials: KisRuntimeCredentials | null,
|
||||
): UseDashboardDataResult {
|
||||
const [activity, setActivity] = useState<DashboardActivityResponse | null>(null);
|
||||
const [balance, setBalance] = useState<DashboardBalanceResponse | null>(null);
|
||||
const [indices, setIndices] = useState<DashboardIndicesResponse["items"]>([]);
|
||||
const [selectedSymbol, setSelectedSymbolState] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [activityError, setActivityError] = useState<string | null>(null);
|
||||
const [balanceError, setBalanceError] = useState<string | null>(null);
|
||||
const [indicesError, setIndicesError] = useState<string | null>(null);
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<string | null>(null);
|
||||
|
||||
const requestSeqRef = useRef(0);
|
||||
|
||||
const hasAccountNo = Boolean(credentials?.accountNo?.trim());
|
||||
|
||||
/**
|
||||
* @description 잔고/지수 데이터를 병렬로 갱신합니다.
|
||||
* @param mode 초기 로드/수동 새로고침/주기 갱신 구분
|
||||
* @see features/dashboard/hooks/use-dashboard-data.ts useEffect 초기 호출/폴링/수동 새로고침
|
||||
*/
|
||||
const refreshInternal = useCallback(
|
||||
async (mode: "initial" | "manual" | "polling") => {
|
||||
if (!credentials) return;
|
||||
|
||||
const requestSeq = ++requestSeqRef.current;
|
||||
const isInitial = mode === "initial";
|
||||
|
||||
if (isInitial) {
|
||||
setIsLoading(true);
|
||||
} else {
|
||||
setIsRefreshing(true);
|
||||
}
|
||||
|
||||
const tasks: [
|
||||
Promise<DashboardBalanceResponse | null>,
|
||||
Promise<DashboardIndicesResponse>,
|
||||
Promise<DashboardActivityResponse | null>,
|
||||
] = [
|
||||
hasAccountNo
|
||||
? fetchDashboardBalance(credentials)
|
||||
: Promise.resolve(null),
|
||||
fetchDashboardIndices(credentials),
|
||||
hasAccountNo
|
||||
? fetchDashboardActivity(credentials)
|
||||
: Promise.resolve(null),
|
||||
];
|
||||
|
||||
const [balanceResult, indicesResult, activityResult] = await Promise.allSettled(tasks);
|
||||
if (requestSeq !== requestSeqRef.current) return;
|
||||
|
||||
let hasAnySuccess = false;
|
||||
|
||||
if (!hasAccountNo) {
|
||||
setBalance(null);
|
||||
setBalanceError(
|
||||
"계좌번호가 없어 잔고를 조회할 수 없습니다. 설정에서 계좌번호(8-2)를 입력해 주세요.",
|
||||
);
|
||||
setActivity(null);
|
||||
setActivityError(
|
||||
"계좌번호가 없어 주문내역/매매일지를 조회할 수 없습니다. 설정에서 계좌번호(8-2)를 입력해 주세요.",
|
||||
);
|
||||
setSelectedSymbolState(null);
|
||||
} else if (balanceResult.status === "fulfilled") {
|
||||
hasAnySuccess = true;
|
||||
setBalance(balanceResult.value);
|
||||
setBalanceError(null);
|
||||
|
||||
setSelectedSymbolState((prev) => {
|
||||
const nextHoldings = balanceResult.value?.holdings ?? [];
|
||||
if (nextHoldings.length === 0) return null;
|
||||
if (prev && nextHoldings.some((item) => item.symbol === prev)) {
|
||||
return prev;
|
||||
}
|
||||
return nextHoldings[0]?.symbol ?? null;
|
||||
});
|
||||
} else {
|
||||
setBalanceError(balanceResult.reason instanceof Error ? balanceResult.reason.message : "잔고 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (hasAccountNo && activityResult.status === "fulfilled") {
|
||||
hasAnySuccess = true;
|
||||
setActivity(activityResult.value);
|
||||
setActivityError(null);
|
||||
} else if (hasAccountNo && activityResult.status === "rejected") {
|
||||
setActivityError(activityResult.reason instanceof Error ? activityResult.reason.message : "주문내역/매매일지 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (indicesResult.status === "fulfilled") {
|
||||
hasAnySuccess = true;
|
||||
setIndices(indicesResult.value.items);
|
||||
setIndicesError(null);
|
||||
} else {
|
||||
setIndicesError(indicesResult.reason instanceof Error ? indicesResult.reason.message : "시장 지수 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (hasAnySuccess) {
|
||||
setLastUpdatedAt(new Date().toISOString());
|
||||
}
|
||||
|
||||
if (isInitial) {
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
},
|
||||
[credentials, hasAccountNo],
|
||||
);
|
||||
|
||||
/**
|
||||
* @description 대시보드 수동 새로고침 핸들러입니다.
|
||||
* @see features/dashboard/components/StatusHeader.tsx 새로고침 버튼 onClick
|
||||
*/
|
||||
const refresh = useCallback(async () => {
|
||||
await refreshInternal("manual");
|
||||
}, [refreshInternal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!credentials) return;
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
void refreshInternal("initial");
|
||||
}, 0);
|
||||
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [credentials, refreshInternal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!credentials) return;
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
void refreshInternal("polling");
|
||||
}, POLLING_INTERVAL_MS);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [credentials, refreshInternal]);
|
||||
|
||||
const setSelectedSymbol = useCallback((symbol: string) => {
|
||||
setSelectedSymbolState(symbol);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
activity,
|
||||
balance,
|
||||
indices,
|
||||
selectedSymbol,
|
||||
setSelectedSymbol,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
activityError,
|
||||
balanceError,
|
||||
indicesError,
|
||||
lastUpdatedAt,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
81
features/dashboard/hooks/use-holdings-realtime.ts
Normal file
81
features/dashboard/hooks/use-holdings-realtime.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
|
||||
import {
|
||||
type KisRealtimeStockTick,
|
||||
parseKisRealtimeStockTick,
|
||||
} from "@/features/dashboard/utils/kis-stock-realtime.utils";
|
||||
import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore";
|
||||
|
||||
const STOCK_REALTIME_TR_ID = "H0STCNT0";
|
||||
|
||||
/**
|
||||
* @description 보유 종목 목록에 대한 실시간 체결 데이터를 구독합니다.
|
||||
* @param holdings 보유 종목 목록
|
||||
* @returns 종목별 실시간 체결 데이터/연결 상태
|
||||
* @remarks UI 흐름: DashboardContainer -> useHoldingsRealtime -> HoldingsList/summary 실시간 반영
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 보유종목 실시간 병합
|
||||
*/
|
||||
export function useHoldingsRealtime(holdings: DashboardHoldingItem[]) {
|
||||
const [realtimeData, setRealtimeData] = useState<
|
||||
Record<string, KisRealtimeStockTick>
|
||||
>({});
|
||||
const subscribeRef = useRef(useKisWebSocketStore.getState().subscribe);
|
||||
const connectRef = useRef(useKisWebSocketStore.getState().connect);
|
||||
const { isConnected } = useKisWebSocketStore();
|
||||
|
||||
const uniqueSymbols = useMemo(
|
||||
() =>
|
||||
Array.from(new Set((holdings ?? []).map((item) => item.symbol))).sort(),
|
||||
[holdings],
|
||||
);
|
||||
const symbolKey = useMemo(() => uniqueSymbols.join(","), [uniqueSymbols]);
|
||||
|
||||
useEffect(() => {
|
||||
if (uniqueSymbols.length === 0) {
|
||||
const resetTimerId = window.setTimeout(() => {
|
||||
setRealtimeData({});
|
||||
}, 0);
|
||||
return () => window.clearTimeout(resetTimerId);
|
||||
}
|
||||
|
||||
connectRef.current();
|
||||
|
||||
const unsubs: (() => void)[] = [];
|
||||
|
||||
uniqueSymbols.forEach((symbol) => {
|
||||
const unsub = subscribeRef.current(
|
||||
STOCK_REALTIME_TR_ID,
|
||||
symbol,
|
||||
(data: string) => {
|
||||
const tick = parseKisRealtimeStockTick(data);
|
||||
if (tick) {
|
||||
setRealtimeData((prev) => {
|
||||
const prevTick = prev[tick.symbol];
|
||||
if (
|
||||
prevTick?.currentPrice === tick.currentPrice &&
|
||||
prevTick?.change === tick.change &&
|
||||
prevTick?.changeRate === tick.changeRate
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[tick.symbol]: tick,
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
unsubs.push(unsub);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubs.forEach((unsub) => unsub());
|
||||
};
|
||||
}, [symbolKey, uniqueSymbols]);
|
||||
|
||||
return { realtimeData, isConnected };
|
||||
}
|
||||
77
features/dashboard/hooks/use-market-realtime.ts
Normal file
77
features/dashboard/hooks/use-market-realtime.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
parseKisRealtimeIndexTick,
|
||||
type KisRealtimeIndexTick,
|
||||
} from "@/features/dashboard/utils/kis-index-realtime.utils";
|
||||
import { useKisWebSocket } from "@/features/kis-realtime/hooks/useKisWebSocket";
|
||||
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
||||
|
||||
const INDEX_TR_ID = "H0UPCNT0";
|
||||
const KOSPI_SYMBOL = "0001";
|
||||
const KOSDAQ_SYMBOL = "1001";
|
||||
|
||||
interface UseMarketRealtimeResult {
|
||||
realtimeIndices: Record<string, KisRealtimeIndexTick>;
|
||||
isConnected: boolean;
|
||||
hasReceivedTick: boolean;
|
||||
isPending: boolean;
|
||||
lastTickAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 코스피/코스닥 실시간 지수 웹소켓 구독 상태를 관리합니다.
|
||||
* @param credentials KIS 인증 정보(하위 호환 파라미터)
|
||||
* @param isVerified KIS 연결 인증 여부
|
||||
* @returns 실시간 지수 맵/연결 상태/수신 대기 상태
|
||||
* @remarks UI 흐름: DashboardContainer -> useMarketRealtime -> MarketSummary/StatusHeader 렌더링 반영
|
||||
* @see features/dashboard/components/DashboardContainer.tsx 지수 데이터 통합 및 상태 전달
|
||||
*/
|
||||
export function useMarketRealtime(
|
||||
_credentials: KisRuntimeCredentials | null, // 하위 호환성을 위해 남겨둠 (실제로는 스토어 사용)
|
||||
isVerified: boolean, // 하위 호환성을 위해 남겨둠
|
||||
): UseMarketRealtimeResult {
|
||||
const [realtimeIndices, setRealtimeIndices] = useState<
|
||||
Record<string, KisRealtimeIndexTick>
|
||||
>({});
|
||||
const [lastTickAt, setLastTickAt] = useState<string | null>(null);
|
||||
|
||||
const handleMessage = useCallback((data: string) => {
|
||||
const tick = parseKisRealtimeIndexTick(data);
|
||||
if (tick) {
|
||||
setLastTickAt(new Date().toISOString());
|
||||
setRealtimeIndices((prev) => ({
|
||||
...prev,
|
||||
[tick.symbol]: tick,
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// KOSPI 구독
|
||||
const { isConnected: isKospiConnected } = useKisWebSocket({
|
||||
symbol: KOSPI_SYMBOL,
|
||||
trId: INDEX_TR_ID,
|
||||
onMessage: handleMessage,
|
||||
enabled: isVerified,
|
||||
});
|
||||
|
||||
// KOSDAQ 구독
|
||||
const { isConnected: isKosdaqConnected } = useKisWebSocket({
|
||||
symbol: KOSDAQ_SYMBOL,
|
||||
trId: INDEX_TR_ID,
|
||||
onMessage: handleMessage,
|
||||
enabled: isVerified,
|
||||
});
|
||||
|
||||
const hasReceivedTick = Object.keys(realtimeIndices).length > 0;
|
||||
const isPending = isVerified && (isKospiConnected || isKosdaqConnected) && !hasReceivedTick;
|
||||
|
||||
return {
|
||||
realtimeIndices,
|
||||
isConnected: isKospiConnected || isKosdaqConnected,
|
||||
hasReceivedTick,
|
||||
isPending,
|
||||
lastTickAt,
|
||||
};
|
||||
}
|
||||
73
features/dashboard/hooks/use-price-flash.ts
Normal file
73
features/dashboard/hooks/use-price-flash.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const FLASH_DURATION_MS = 2_000;
|
||||
|
||||
/**
|
||||
* @description 가격 변동 시 일시 플래시(+/-) 값을 생성합니다.
|
||||
* @param currentPrice 현재가
|
||||
* @param key 종목 식별 키(종목 변경 시 상태 초기화)
|
||||
* @returns 플래시 값(up/down, 변화량) 또는 null
|
||||
* @remarks UI 흐름: 시세 변경 -> usePriceFlash -> 플래시 값 노출 -> 2초 후 자동 제거
|
||||
* @see features/dashboard/components/HoldingsList.tsx 보유종목 현재가 플래시
|
||||
* @see features/dashboard/components/StockDetailPreview.tsx 상세 카드 현재가 플래시
|
||||
*/
|
||||
export function usePriceFlash(currentPrice: number, key?: string) {
|
||||
const [flash, setFlash] = useState<{
|
||||
val: number;
|
||||
type: "up" | "down";
|
||||
id: number;
|
||||
} | null>(null);
|
||||
|
||||
const prevKeyRef = useRef<string | undefined>(key);
|
||||
const prevPriceRef = useRef<number>(currentPrice);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const keyChanged = prevKeyRef.current !== key;
|
||||
|
||||
if (keyChanged) {
|
||||
prevKeyRef.current = key;
|
||||
prevPriceRef.current = currentPrice;
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
const resetTimerId = window.setTimeout(() => {
|
||||
setFlash(null);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(resetTimerId);
|
||||
}
|
||||
|
||||
const prevPrice = prevPriceRef.current;
|
||||
const diff = currentPrice - prevPrice;
|
||||
prevPriceRef.current = currentPrice;
|
||||
|
||||
if (prevPrice === 0 || Math.abs(diff) === 0) return;
|
||||
|
||||
// 플래시가 보이는 동안에는 새 플래시를 덮어쓰지 않아 화면 잔상이 지속되지 않게 합니다.
|
||||
if (timerRef.current !== null) return;
|
||||
|
||||
setFlash({
|
||||
val: diff,
|
||||
type: diff > 0 ? "up" : "down",
|
||||
id: Date.now(),
|
||||
});
|
||||
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
setFlash(null);
|
||||
timerRef.current = null;
|
||||
}, FLASH_DURATION_MS);
|
||||
}, [currentPrice, key]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return flash;
|
||||
}
|
||||
141
features/dashboard/types/dashboard.types.ts
Normal file
141
features/dashboard/types/dashboard.types.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @file features/dashboard/types/dashboard.types.ts
|
||||
* @description 대시보드(잔고/지수/보유종목) 전용 타입 정의
|
||||
*/
|
||||
|
||||
import type { KisTradingEnv } from "@/features/trade/types/trade.types";
|
||||
|
||||
export type DashboardMarket = "KOSPI" | "KOSDAQ";
|
||||
|
||||
/**
|
||||
* 대시보드 잔고 요약
|
||||
*/
|
||||
export interface DashboardBalanceSummary {
|
||||
totalAmount: number;
|
||||
cashBalance: number;
|
||||
totalDepositAmount: number;
|
||||
totalProfitLoss: number;
|
||||
totalProfitRate: number;
|
||||
netAssetAmount: number;
|
||||
evaluationAmount: number;
|
||||
purchaseAmount: number;
|
||||
loanAmount: number;
|
||||
apiReportedTotalAmount: number;
|
||||
apiReportedNetAssetAmount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 보유 종목 항목
|
||||
*/
|
||||
export interface DashboardHoldingItem {
|
||||
symbol: string;
|
||||
name: string;
|
||||
market: DashboardMarket;
|
||||
quantity: number;
|
||||
averagePrice: number;
|
||||
currentPrice: number;
|
||||
evaluationAmount: number;
|
||||
profitLoss: number;
|
||||
profitRate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 주문/매매 공통 매수/매도 구분
|
||||
*/
|
||||
export type DashboardTradeSide = "buy" | "sell" | "unknown";
|
||||
|
||||
/**
|
||||
* 대시보드 주문내역 항목
|
||||
*/
|
||||
export interface DashboardOrderHistoryItem {
|
||||
orderDate: string;
|
||||
orderTime: string;
|
||||
orderNo: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
side: DashboardTradeSide;
|
||||
orderTypeName: string;
|
||||
orderPrice: number;
|
||||
orderQuantity: number;
|
||||
filledQuantity: number;
|
||||
filledAmount: number;
|
||||
averageFilledPrice: number;
|
||||
remainingQuantity: number;
|
||||
isCanceled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 매매일지 항목
|
||||
*/
|
||||
export interface DashboardTradeJournalItem {
|
||||
tradeDate: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
side: DashboardTradeSide;
|
||||
buyQuantity: number;
|
||||
buyAmount: number;
|
||||
sellQuantity: number;
|
||||
sellAmount: number;
|
||||
realizedProfit: number;
|
||||
realizedRate: number;
|
||||
fee: number;
|
||||
tax: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 매매일지 요약
|
||||
*/
|
||||
export interface DashboardTradeJournalSummary {
|
||||
totalRealizedProfit: number;
|
||||
totalRealizedRate: number;
|
||||
totalBuyAmount: number;
|
||||
totalSellAmount: number;
|
||||
totalFee: number;
|
||||
totalTax: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계좌 잔고 API 응답 모델
|
||||
*/
|
||||
export interface DashboardBalanceResponse {
|
||||
source: "kis";
|
||||
tradingEnv: KisTradingEnv;
|
||||
summary: DashboardBalanceSummary;
|
||||
holdings: DashboardHoldingItem[];
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시장 지수 항목
|
||||
*/
|
||||
export interface DashboardMarketIndexItem {
|
||||
market: DashboardMarket;
|
||||
code: string;
|
||||
name: string;
|
||||
price: number;
|
||||
change: number;
|
||||
changeRate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시장 지수 API 응답 모델
|
||||
*/
|
||||
export interface DashboardIndicesResponse {
|
||||
source: "kis";
|
||||
tradingEnv: KisTradingEnv;
|
||||
items: DashboardMarketIndexItem[];
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 주문내역/매매일지 API 응답 모델
|
||||
*/
|
||||
export interface DashboardActivityResponse {
|
||||
source: "kis";
|
||||
tradingEnv: KisTradingEnv;
|
||||
orders: DashboardOrderHistoryItem[];
|
||||
tradeJournal: DashboardTradeJournalItem[];
|
||||
journalSummary: DashboardTradeJournalSummary;
|
||||
warnings: string[];
|
||||
fetchedAt: string;
|
||||
}
|
||||
66
features/dashboard/utils/dashboard-format.ts
Normal file
66
features/dashboard/utils/dashboard-format.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @file features/dashboard/utils/dashboard-format.ts
|
||||
* @description 대시보드 숫자/색상 표현 유틸
|
||||
*/
|
||||
|
||||
const KRW_FORMATTER = new Intl.NumberFormat("ko-KR");
|
||||
const PERCENT_FORMATTER = new Intl.NumberFormat("ko-KR", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
/**
|
||||
* 원화 금액을 포맷합니다.
|
||||
* @param value 숫자 값
|
||||
* @returns 쉼표 포맷 문자열
|
||||
* @see features/dashboard/components/StatusHeader.tsx 자산/손익 금액 표시
|
||||
*/
|
||||
export function formatCurrency(value: number) {
|
||||
return KRW_FORMATTER.format(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 퍼센트 값을 포맷합니다.
|
||||
* @param value 숫자 값
|
||||
* @returns 소수점 2자리 퍼센트 문자열
|
||||
* @see features/dashboard/components/StatusHeader.tsx 수익률 표시
|
||||
*/
|
||||
export function formatPercent(value: number) {
|
||||
return `${PERCENT_FORMATTER.format(value)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 값의 부호를 포함한 금액 문자열을 만듭니다.
|
||||
* @param value 숫자 값
|
||||
* @returns + 또는 - 부호가 포함된 금액 문자열
|
||||
* @see features/dashboard/components/MarketSummary.tsx 전일 대비 수치 표시
|
||||
*/
|
||||
export function formatSignedCurrency(value: number) {
|
||||
if (value > 0) return `+${formatCurrency(value)}`;
|
||||
if (value < 0) return `-${formatCurrency(Math.abs(value))}`;
|
||||
return "0";
|
||||
}
|
||||
|
||||
/**
|
||||
* 값의 부호를 포함한 퍼센트 문자열을 만듭니다.
|
||||
* @param value 숫자 값
|
||||
* @returns + 또는 - 부호가 포함된 퍼센트 문자열
|
||||
* @see features/dashboard/components/MarketSummary.tsx 전일 대비율 표시
|
||||
*/
|
||||
export function formatSignedPercent(value: number) {
|
||||
if (value > 0) return `+${formatPercent(value)}`;
|
||||
if (value < 0) return `-${formatPercent(Math.abs(value))}`;
|
||||
return "0.00%";
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자 값의 상승/하락/보합 텍스트 색상을 반환합니다.
|
||||
* @param value 숫자 값
|
||||
* @returns Tailwind 텍스트 클래스
|
||||
* @see features/dashboard/components/HoldingsList.tsx 수익률/손익 색상 적용
|
||||
*/
|
||||
export function getChangeToneClass(value: number) {
|
||||
if (value > 0) return "text-red-600 dark:text-red-400";
|
||||
if (value < 0) return "text-blue-600 dark:text-blue-400";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
62
features/dashboard/utils/kis-index-realtime.utils.ts
Normal file
62
features/dashboard/utils/kis-index-realtime.utils.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export interface KisRealtimeIndexTick {
|
||||
symbol: string; // 업종코드 (0001: KOSPI, 1001: KOSDAQ)
|
||||
price: number; // 현재가
|
||||
change: number; // 전일대비
|
||||
changeRate: number; // 전일대비율
|
||||
sign: string; // 대비부호
|
||||
time: string; // 체결시간
|
||||
}
|
||||
|
||||
const INDEX_REALTIME_TR_ID = "H0UPCNT0";
|
||||
|
||||
const INDEX_FIELD_INDEX = {
|
||||
symbol: 0, // bstp_cls_code
|
||||
time: 1, // bsop_hour
|
||||
price: 2, // prpr_nmix
|
||||
sign: 3, // prdy_vrss_sign
|
||||
change: 4, // bstp_nmix_prdy_vrss
|
||||
accumulatedVolume: 5, // acml_vol
|
||||
accumulatedAmount: 6, // acml_tr_pbmn
|
||||
changeRate: 9, // prdy_ctrt
|
||||
} as const;
|
||||
|
||||
export function parseKisRealtimeIndexTick(
|
||||
raw: string,
|
||||
): KisRealtimeIndexTick | null {
|
||||
// Format: 0|H0UPCNT0|001|0001^123456^...
|
||||
if (!/^([01])\|/.test(raw)) return null;
|
||||
|
||||
const parts = raw.split("|");
|
||||
if (parts.length < 4) return null;
|
||||
|
||||
// Check TR ID
|
||||
if (parts[1] !== INDEX_REALTIME_TR_ID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const values = parts[3].split("^");
|
||||
if (values.length < 10) return null; // Ensure minimum fields exist
|
||||
|
||||
const symbol = values[INDEX_FIELD_INDEX.symbol];
|
||||
const price = parseFloat(values[INDEX_FIELD_INDEX.price]);
|
||||
const sign = values[INDEX_FIELD_INDEX.sign];
|
||||
const changeRaw = parseFloat(values[INDEX_FIELD_INDEX.change]);
|
||||
const changeRateRaw = parseFloat(values[INDEX_FIELD_INDEX.changeRate]);
|
||||
|
||||
// Adjust sign for negative values if necessary (usually API sends absolute values for change)
|
||||
const isNegative = sign === "5" || sign === "4"; // 5: 하락, 4: 하한
|
||||
|
||||
const change = isNegative ? -Math.abs(changeRaw) : Math.abs(changeRaw);
|
||||
const changeRate = isNegative
|
||||
? -Math.abs(changeRateRaw)
|
||||
: Math.abs(changeRateRaw);
|
||||
|
||||
return {
|
||||
symbol,
|
||||
time: values[INDEX_FIELD_INDEX.time],
|
||||
price,
|
||||
change,
|
||||
changeRate,
|
||||
sign,
|
||||
};
|
||||
}
|
||||
69
features/dashboard/utils/kis-stock-realtime.utils.ts
Normal file
69
features/dashboard/utils/kis-stock-realtime.utils.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
export interface KisRealtimeStockTick {
|
||||
symbol: string; // 종목코드
|
||||
time: string; // 체결시간
|
||||
currentPrice: number; // 현재가
|
||||
sign: string; // 전일대비부호 (1:상한, 2:상승, 3:보합, 4:하한, 5:하락)
|
||||
change: number; // 전일대비
|
||||
changeRate: number; // 전일대비율
|
||||
accumulatedVolume: number; // 누적거래량
|
||||
}
|
||||
|
||||
const STOCK_realtime_TR_ID = "H0STCNT0";
|
||||
|
||||
// H0STCNT0 Output format indices based on typical KIS Realtime API
|
||||
// Format: MKSC_SHRN_ISCD^STCK_CNTG_HOUR^STCK_PRPR^PRDY_VRSS_SIGN^PRDY_VRSS^PRDY_CTRT^...
|
||||
const STOCK_FIELD_INDEX = {
|
||||
symbol: 0, // MKSC_SHRN_ISCD
|
||||
time: 1, // STCK_CNTG_HOUR
|
||||
currentPrice: 2, // STCK_PRPR
|
||||
sign: 3, // PRDY_VRSS_SIGN
|
||||
change: 4, // PRDY_VRSS
|
||||
changeRate: 5, // PRDY_CTRT
|
||||
accumulatedVolume: 12, // ACML_VOL (Usually at index 12 or similar, need to be careful here)
|
||||
} as const;
|
||||
|
||||
export function parseKisRealtimeStockTick(
|
||||
raw: string,
|
||||
): KisRealtimeStockTick | null {
|
||||
// Format: 0|H0STCNT0|001|SYMBOL^TIME^PRICE^SIGN^CHANGE^...
|
||||
if (!/^([01])\|/.test(raw)) return null;
|
||||
|
||||
const parts = raw.split("|");
|
||||
if (parts.length < 4) return null;
|
||||
|
||||
// Check TR ID
|
||||
if (parts[1] !== STOCK_realtime_TR_ID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const values = parts[3].split("^");
|
||||
if (values.length < 6) return null; // Ensure minimum fields exist
|
||||
|
||||
const symbol = values[STOCK_FIELD_INDEX.symbol];
|
||||
const currentPrice = parseFloat(values[STOCK_FIELD_INDEX.currentPrice]);
|
||||
const sign = values[STOCK_FIELD_INDEX.sign];
|
||||
const changeRaw = parseFloat(values[STOCK_FIELD_INDEX.change]);
|
||||
const changeRateRaw = parseFloat(values[STOCK_FIELD_INDEX.changeRate]);
|
||||
|
||||
// Adjust sign for negative values if necessary
|
||||
const isNegative = sign === "5" || sign === "4"; // 5: 하락, 4: 하한
|
||||
|
||||
const change = isNegative ? -Math.abs(changeRaw) : Math.abs(changeRaw);
|
||||
const changeRate = isNegative
|
||||
? -Math.abs(changeRateRaw)
|
||||
: Math.abs(changeRateRaw);
|
||||
|
||||
// Validate numeric values
|
||||
if (isNaN(currentPrice)) return null;
|
||||
|
||||
return {
|
||||
symbol,
|
||||
time: values[STOCK_FIELD_INDEX.time],
|
||||
currentPrice,
|
||||
sign,
|
||||
change,
|
||||
changeRate,
|
||||
accumulatedVolume:
|
||||
parseFloat(values[STOCK_FIELD_INDEX.accumulatedVolume]) || 0,
|
||||
};
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Spline from "@splinetool/react-spline";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SplineSceneProps {
|
||||
sceneUrl: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SplineScene({ sceneUrl, className }: SplineSceneProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
return (
|
||||
<div className={cn("relative h-full w-full", className)}>
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-zinc-100 dark:bg-zinc-900">
|
||||
<div className="h-10 w-10 animate-spin rounded-full border-4 border-zinc-200 border-t-brand-500 dark:border-zinc-800" />
|
||||
</div>
|
||||
)}
|
||||
<Spline
|
||||
scene={sceneUrl}
|
||||
onLoad={() => setIsLoading(false)}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
features/kis-realtime/hooks/useKisWebSocket.ts
Normal file
53
features/kis-realtime/hooks/useKisWebSocket.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore";
|
||||
|
||||
/**
|
||||
* @file features/kis-realtime/hooks/useKisWebSocket.ts
|
||||
* @description KIS 실시간 데이터를 구독하기 위한 통합 훅입니다.
|
||||
* 컴포넌트 마운트/언마운트 시 자동으로 구독 및 해제를 처리합니다.
|
||||
*/
|
||||
|
||||
type RealtimeCallback = (data: string) => void;
|
||||
|
||||
interface UseKisWebSocketParams {
|
||||
symbol?: string; // 종목코드 (없으면 구독 안 함)
|
||||
trId?: string; // 거래 ID (예: H0STCNT0)
|
||||
onMessage?: RealtimeCallback; // 데이터 수신 콜백
|
||||
enabled?: boolean; // 구독 활성화 여부
|
||||
}
|
||||
|
||||
export function useKisWebSocket({
|
||||
symbol,
|
||||
trId,
|
||||
onMessage,
|
||||
enabled = true,
|
||||
}: UseKisWebSocketParams) {
|
||||
const subscribeRef = useRef(useKisWebSocketStore.getState().subscribe);
|
||||
const connectRef = useRef(useKisWebSocketStore.getState().connect);
|
||||
const { isConnected } = useKisWebSocketStore();
|
||||
const callbackRef = useRef(onMessage);
|
||||
|
||||
// 콜백 함수가 바뀌어도 재구독하지 않도록 ref 사용
|
||||
useEffect(() => {
|
||||
callbackRef.current = onMessage;
|
||||
}, [onMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !symbol || !trId) return;
|
||||
|
||||
// 연결 시도 (이미 연결 중이면 스토어에서 무시됨)
|
||||
connectRef.current();
|
||||
|
||||
// 구독 요청
|
||||
const unsubscribe = subscribeRef.current(trId, symbol, (data) => {
|
||||
callbackRef.current?.(data);
|
||||
});
|
||||
|
||||
// 언마운트 시 구독 해제
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [symbol, trId, enabled]);
|
||||
|
||||
return { isConnected };
|
||||
}
|
||||
617
features/kis-realtime/stores/kisWebSocketStore.ts
Normal file
617
features/kis-realtime/stores/kisWebSocketStore.ts
Normal file
@@ -0,0 +1,617 @@
|
||||
import { create } from "zustand";
|
||||
import { buildKisErrorDetail } from "@/lib/kis/error-codes";
|
||||
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
|
||||
import { buildKisRealtimeMessage } from "@/features/kis-realtime/utils/websocketUtils";
|
||||
|
||||
/**
|
||||
* @file features/kis-realtime/stores/kisWebSocketStore.ts
|
||||
* @description KIS 실시간 웹소켓 연결을 전역에서 하나로 관리하는 스토어입니다.
|
||||
* 중복 연결을 방지하고, 여러 컴포넌트에서 동일한 데이터를 구독할 때 효율적으로 처리합니다.
|
||||
*/
|
||||
|
||||
type RealtimeCallback = (data: string) => void;
|
||||
|
||||
interface KisWebSocketState {
|
||||
isConnected: boolean;
|
||||
error: string | null;
|
||||
|
||||
/**
|
||||
* 웹소켓 연결을 수립합니다.
|
||||
* 이미 연결되어 있거나 연결 중이면 무시합니다.
|
||||
*/
|
||||
connect: (options?: { forceApprovalRefresh?: boolean }) => Promise<void>;
|
||||
|
||||
/**
|
||||
* 웹소켓 연결을 강제로 재시작합니다.
|
||||
* 필요 시 승인키를 새로 발급받아 재연결합니다.
|
||||
*/
|
||||
reconnect: (options?: { refreshApproval?: boolean }) => Promise<void>;
|
||||
|
||||
/**
|
||||
* 웹소켓 연결을 종료합니다.
|
||||
* 모든 구독이 해제됩니다.
|
||||
*/
|
||||
disconnect: () => void;
|
||||
|
||||
/**
|
||||
* 특정 TR ID와 종목 코드로 실시간 데이터를 구독합니다.
|
||||
* @param trId 거래 ID (예: H0STCNT0)
|
||||
* @param symbol 종목 코드 (예: 005930)
|
||||
* @param callback 데이터 수신 시 실행할 콜백 함수
|
||||
* @returns 구독 해제 함수 (useEffect cleanup에서 호출하세요)
|
||||
*/
|
||||
subscribe: (
|
||||
trId: string,
|
||||
symbol: string,
|
||||
callback: RealtimeCallback,
|
||||
) => () => void;
|
||||
}
|
||||
|
||||
// 구독자 목록 관리 (Key: "TR_ID|SYMBOL", Value: Set<Callback>)
|
||||
// 스토어 외부 변수로 관리하여 불필요한 리렌더링을 방지합니다.
|
||||
const subscribers = new Map<string, Set<RealtimeCallback>>();
|
||||
const subscriberCounts = new Map<string, number>(); // 실제 소켓 구독 요청 여부 추적용
|
||||
|
||||
let socket: WebSocket | null = null;
|
||||
let isConnecting = false; // 연결 진행 중 상태 잠금
|
||||
let reconnectRetryTimer: number | undefined;
|
||||
let lastAppKeyConflictAt = 0;
|
||||
let reconnectAttempt = 0;
|
||||
let manualDisconnectRequested = false;
|
||||
|
||||
const MAX_AUTO_RECONNECT_ATTEMPTS = 8;
|
||||
const RECONNECT_BASE_DELAY_MS = 1_000;
|
||||
const RECONNECT_MAX_DELAY_MS = 30_000;
|
||||
const RECONNECT_JITTER_MS = 300;
|
||||
|
||||
function isKisWsDebugEnabled() {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.localStorage.getItem("KIS_WS_DEBUG") === "1";
|
||||
}
|
||||
|
||||
function wsDebugLog(...args: unknown[]) {
|
||||
if (!isKisWsDebugEnabled()) return;
|
||||
console.log(...args);
|
||||
}
|
||||
|
||||
function wsDebugWarn(...args: unknown[]) {
|
||||
if (!isKisWsDebugEnabled()) return;
|
||||
console.warn(...args);
|
||||
}
|
||||
|
||||
export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
||||
isConnected: false,
|
||||
error: null,
|
||||
|
||||
connect: async (options) => {
|
||||
const forceApprovalRefresh = options?.forceApprovalRefresh ?? false;
|
||||
manualDisconnectRequested = false;
|
||||
window.clearTimeout(reconnectRetryTimer);
|
||||
reconnectRetryTimer = undefined;
|
||||
const currentSocket = socket;
|
||||
|
||||
if (currentSocket?.readyState === WebSocket.CLOSING) {
|
||||
await waitForSocketClose(currentSocket);
|
||||
}
|
||||
|
||||
// 1. 이미 연결되어 있거나, 연결 시도 중이면 중복 실행 방지
|
||||
if (isSocketUnavailableForNewConnect(socket) || isConnecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isConnecting = true;
|
||||
const { getOrFetchWsConnection, clearWsConnectionCache } =
|
||||
useKisRuntimeStore.getState();
|
||||
if (forceApprovalRefresh) {
|
||||
clearWsConnectionCache();
|
||||
}
|
||||
const wsConnection = await getOrFetchWsConnection();
|
||||
|
||||
// 비동기 대기 중에 다른 연결이 성사되었는지 다시 확인
|
||||
if (isSocketOpenOrConnecting(socket)) {
|
||||
isConnecting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!wsConnection) {
|
||||
throw new Error("웹소켓 접속 키 발급에 실패했습니다.");
|
||||
}
|
||||
|
||||
// 소켓 생성
|
||||
// socket 변수에 할당하기 전에 로컬 변수로 제어하여 이벤트 핸들러 클로저 문제 방지
|
||||
const ws = new WebSocket(wsConnection.wsUrl);
|
||||
wsDebugLog("[KisWebSocket] Connecting to:", wsConnection.wsUrl);
|
||||
socket = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
isConnecting = false;
|
||||
// socket 변수가 다른 인스턴스로 바뀌었을 가능성은 낮지만(락 때문),
|
||||
// 안전을 위해 이벤트 발생 주체인 ws를 사용 또는 현재 socket 확인
|
||||
if (socket !== ws) return;
|
||||
|
||||
set({ isConnected: true, error: null });
|
||||
reconnectAttempt = 0;
|
||||
wsDebugLog("[KisWebSocket] Connected");
|
||||
|
||||
// 재연결 시 기존 구독 복구
|
||||
const approvalKey = wsConnection.approvalKey;
|
||||
if (approvalKey) {
|
||||
subscriberCounts.forEach((_, key) => {
|
||||
const [trId, symbol] = key.split("|");
|
||||
|
||||
// OPEN 상태일 때만 전송
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
sendSubscription(ws, approvalKey, trId, symbol, "1"); // 구독
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (socket === ws) {
|
||||
isConnecting = false;
|
||||
set({ isConnected: false });
|
||||
socket = null;
|
||||
|
||||
const hasSubscribers = hasActiveRealtimeSubscribers();
|
||||
const canAutoReconnect =
|
||||
!manualDisconnectRequested &&
|
||||
hasSubscribers &&
|
||||
reconnectAttempt < MAX_AUTO_RECONNECT_ATTEMPTS;
|
||||
|
||||
if (canAutoReconnect) {
|
||||
reconnectAttempt += 1;
|
||||
const delayMs = getReconnectDelayMs(reconnectAttempt);
|
||||
wsDebugWarn(
|
||||
`[KisWebSocket] Disconnected (code=${event.code}, reason=${event.reason || "none"}, wasClean=${event.wasClean}) -> reconnect in ${delayMs}ms (attempt ${reconnectAttempt}/${MAX_AUTO_RECONNECT_ATTEMPTS})`,
|
||||
);
|
||||
|
||||
window.clearTimeout(reconnectRetryTimer);
|
||||
reconnectRetryTimer = window.setTimeout(() => {
|
||||
const refreshApproval = reconnectAttempt % 3 === 0;
|
||||
void get().reconnect({ refreshApproval });
|
||||
}, delayMs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
hasSubscribers &&
|
||||
reconnectAttempt >= MAX_AUTO_RECONNECT_ATTEMPTS
|
||||
) {
|
||||
set({
|
||||
error:
|
||||
"실시간 연결이 반복 종료되어 자동 재연결을 중단했습니다. 새로고침 또는 수동 재연결을 시도해 주세요.",
|
||||
});
|
||||
}
|
||||
|
||||
reconnectAttempt = 0;
|
||||
wsDebugLog(
|
||||
`[KisWebSocket] Disconnected (code=${event.code}, reason=${event.reason || "none"})`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (event) => {
|
||||
if (socket === ws) {
|
||||
isConnecting = false;
|
||||
const errEvent = event as ErrorEvent;
|
||||
console.error("[KisWebSocket] Error", {
|
||||
type: event.type,
|
||||
message: errEvent?.message,
|
||||
url: ws.url,
|
||||
readyState: ws.readyState,
|
||||
});
|
||||
set({
|
||||
isConnected: false,
|
||||
error: "웹소켓 연결 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = event.data;
|
||||
if (typeof data !== "string") return;
|
||||
|
||||
// PINGPONG 응답 또는 제어 메시지 처리
|
||||
if (data.startsWith("{")) {
|
||||
const control = parseControlMessage(data);
|
||||
if (!control) return;
|
||||
|
||||
if (control.trId === "PINGPONG") {
|
||||
// KIS 샘플 구현과 동일하게 원문을 그대로 echo하여 연결 유지를 보조합니다.
|
||||
if (socket === ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (control.rtCd && control.rtCd !== "0") {
|
||||
const errorMessage = buildControlErrorMessage(control);
|
||||
set({
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
// KIS 제어 메시지: ALREADY IN USE appkey
|
||||
// 이전 세션이 닫히기 전에 재연결될 때 발생합니다.
|
||||
// KIS 서버가 세션을 정리하는 데 최소 10~30초가 필요하므로
|
||||
// 충분한 대기 후 재연결합니다.
|
||||
if (control.msgCd === "OPSP8996") {
|
||||
const now = Date.now();
|
||||
if (now - lastAppKeyConflictAt > 5_000) {
|
||||
lastAppKeyConflictAt = now;
|
||||
wsDebugWarn(
|
||||
"[KisWebSocket] ALREADY IN USE appkey - 현재 소켓을 닫고 30초 후 재연결합니다.",
|
||||
);
|
||||
// 현재 소켓을 즉시 닫아 서버 측 세션 정리 유도
|
||||
if (socket === ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.close(1000, "ALREADY IN USE - graceful close");
|
||||
}
|
||||
window.clearTimeout(reconnectRetryTimer);
|
||||
reconnectRetryTimer = window.setTimeout(() => {
|
||||
void get().reconnect({ refreshApproval: false });
|
||||
}, 30_000); // 30초 쿨다운
|
||||
}
|
||||
}
|
||||
|
||||
// 승인키가 유효하지 않을 때는 승인키 재발급 후 재연결합니다.
|
||||
if (control.msgCd === "OPSP0011") {
|
||||
window.clearTimeout(reconnectRetryTimer);
|
||||
reconnectRetryTimer = window.setTimeout(() => {
|
||||
void get().reconnect({ refreshApproval: true });
|
||||
}, 1_200);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (data[0] === "0" || data[0] === "1") {
|
||||
// 데이터 포맷: 0|TR_ID|KEY|...
|
||||
const parts = data.split("|");
|
||||
if (parts.length >= 4) {
|
||||
const trId = parts[1];
|
||||
const body = parts[3];
|
||||
const values = body.split("^");
|
||||
const symbol = values[0] ?? "";
|
||||
|
||||
// UI 흐름: 소켓 수신 -> TR/심볼 정규화 매칭 -> 해당 구독 콜백 실행 -> 훅 파서(parseKisRealtime*) -> 화면 반영
|
||||
dispatchRealtimeMessageToSubscribers(trId, symbol, data);
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
isConnecting = false;
|
||||
set({
|
||||
isConnected: false,
|
||||
error: err instanceof Error ? err.message : "연결 실패",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
reconnect: async (options) => {
|
||||
const refreshApproval = options?.refreshApproval ?? false;
|
||||
// disconnect()는 manualDisconnectRequested=true를 설정하므로 직접 호출 금지
|
||||
// 대신 소켓만 직접 닫습니다.
|
||||
manualDisconnectRequested = false;
|
||||
window.clearTimeout(reconnectRetryTimer);
|
||||
reconnectRetryTimer = undefined;
|
||||
const currentSocket = socket;
|
||||
if (
|
||||
currentSocket &&
|
||||
(currentSocket.readyState === WebSocket.OPEN ||
|
||||
currentSocket.readyState === WebSocket.CONNECTING)
|
||||
) {
|
||||
currentSocket.close();
|
||||
}
|
||||
if (currentSocket && currentSocket.readyState !== WebSocket.CLOSED) {
|
||||
await waitForSocketClose(currentSocket);
|
||||
}
|
||||
await get().connect({
|
||||
forceApprovalRefresh: refreshApproval,
|
||||
});
|
||||
},
|
||||
|
||||
disconnect: () => {
|
||||
manualDisconnectRequested = true;
|
||||
const currentSocket = socket;
|
||||
if (
|
||||
currentSocket &&
|
||||
(currentSocket.readyState === WebSocket.OPEN ||
|
||||
currentSocket.readyState === WebSocket.CONNECTING ||
|
||||
currentSocket.readyState === WebSocket.CLOSING)
|
||||
) {
|
||||
currentSocket.close();
|
||||
}
|
||||
if (
|
||||
currentSocket?.readyState === WebSocket.CLOSED &&
|
||||
socket === currentSocket
|
||||
) {
|
||||
socket = null;
|
||||
}
|
||||
set({ isConnected: false });
|
||||
window.clearTimeout(reconnectRetryTimer);
|
||||
reconnectRetryTimer = undefined;
|
||||
reconnectAttempt = 0;
|
||||
isConnecting = false;
|
||||
},
|
||||
|
||||
subscribe: (trId, symbol, callback) => {
|
||||
const key = `${trId}|${symbol}`;
|
||||
|
||||
// 1. 구독자 목록에 추가
|
||||
if (!subscribers.has(key)) {
|
||||
subscribers.set(key, new Set());
|
||||
}
|
||||
subscribers.get(key)!.add(callback);
|
||||
|
||||
// 2. 소켓 서버에 구독 요청 (첫 번째 구독자인 경우)
|
||||
const currentCount = subscriberCounts.get(key) || 0;
|
||||
if (currentCount === 0) {
|
||||
const { wsApprovalKey } = useKisRuntimeStore.getState();
|
||||
if (socket?.readyState === WebSocket.OPEN && wsApprovalKey) {
|
||||
sendSubscription(socket, wsApprovalKey, trId, symbol, "1"); // "1": 등록
|
||||
}
|
||||
}
|
||||
subscriberCounts.set(key, currentCount + 1);
|
||||
|
||||
// 3. 구독 해제 함수 반환
|
||||
return () => {
|
||||
const callbacks = subscribers.get(key);
|
||||
if (callbacks) {
|
||||
callbacks.delete(callback);
|
||||
if (callbacks.size === 0) {
|
||||
subscribers.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
const count = subscriberCounts.get(key) || 0;
|
||||
if (count > 0) {
|
||||
subscriberCounts.set(key, count - 1);
|
||||
if (count - 1 === 0) {
|
||||
// 마지막 구독자가 사라지면 소켓 구독 해제
|
||||
const { wsApprovalKey } = useKisRuntimeStore.getState();
|
||||
if (socket?.readyState === WebSocket.OPEN && wsApprovalKey) {
|
||||
sendSubscription(socket, wsApprovalKey, trId, symbol, "2"); // "2": 해제
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
// 헬퍼: 구독/해제 메시지 전송
|
||||
function sendSubscription(
|
||||
ws: WebSocket,
|
||||
appKey: string,
|
||||
trId: string,
|
||||
symbol: string,
|
||||
trType: "1" | "2",
|
||||
) {
|
||||
try {
|
||||
const msg = buildKisRealtimeMessage(appKey, symbol, trId, trType);
|
||||
ws.send(JSON.stringify(msg));
|
||||
wsDebugLog(
|
||||
`[KisWebSocket] ${trType === "1" ? "Sub" : "Unsub"} ${trId} ${symbol}`,
|
||||
);
|
||||
} catch (e) {
|
||||
wsDebugWarn("[KisWebSocket] Send error", e);
|
||||
}
|
||||
}
|
||||
|
||||
interface KisWsControlMessage {
|
||||
trId?: string;
|
||||
trKey?: string;
|
||||
rtCd?: string;
|
||||
msgCd?: string;
|
||||
msg1?: string;
|
||||
encrypt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 웹소켓 제어 메시지(JSON)를 파싱합니다.
|
||||
* @param rawData 원본 메시지 문자열
|
||||
* @returns 파싱된 제어 메시지 또는 null
|
||||
* @see features/kis-realtime/stores/kisWebSocketStore.ts ws.onmessage
|
||||
*/
|
||||
function parseControlMessage(rawData: string): KisWsControlMessage | null {
|
||||
try {
|
||||
const parsed = JSON.parse(rawData) as {
|
||||
header?: {
|
||||
tr_id?: string;
|
||||
tr_key?: string;
|
||||
encrypt?: string;
|
||||
};
|
||||
body?: {
|
||||
rt_cd?: string;
|
||||
msg_cd?: string;
|
||||
msg1?: string;
|
||||
};
|
||||
rt_cd?: string;
|
||||
msg_cd?: string;
|
||||
msg1?: string;
|
||||
};
|
||||
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
|
||||
return {
|
||||
trId: parsed.header?.tr_id,
|
||||
trKey: parsed.header?.tr_key,
|
||||
encrypt: parsed.header?.encrypt,
|
||||
rtCd: parsed.body?.rt_cd ?? parsed.rt_cd,
|
||||
msgCd: parsed.body?.msg_cd ?? parsed.msg_cd,
|
||||
msg1: parsed.body?.msg1 ?? parsed.msg1,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description KIS 웹소켓 제어 오류를 사용자용 짧은 문구로 변환합니다.
|
||||
* @param message KIS 제어 메시지
|
||||
* @returns 표시용 오류 문자열
|
||||
* @see features/kis-realtime/stores/kisWebSocketStore.ts ws.onmessage
|
||||
*/
|
||||
function buildControlErrorMessage(message: KisWsControlMessage) {
|
||||
if (message.msgCd === "OPSP8996") {
|
||||
return "실시간 연결이 다른 세션과 충돌해 재연결을 시도합니다.";
|
||||
}
|
||||
const detail = buildKisErrorDetail({
|
||||
message: message.msg1,
|
||||
msgCode: message.msgCd,
|
||||
});
|
||||
return detail
|
||||
? `실시간 제어 메시지 오류: ${detail}`
|
||||
: "실시간 제어 메시지 오류";
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 활성화된 웹소켓 구독이 존재하는지 반환합니다.
|
||||
* @returns 구독 중인 TR/심볼이 1개 이상이면 true
|
||||
* @see features/kis-realtime/stores/kisWebSocketStore.ts ws.onclose
|
||||
*/
|
||||
function hasActiveRealtimeSubscribers() {
|
||||
for (const count of subscriberCounts.values()) {
|
||||
if (count > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 자동 재연결 시도 횟수에 따라 지수 백오프 지연시간(ms)을 계산합니다.
|
||||
* @param attempt 1부터 시작하는 재연결 시도 횟수
|
||||
* @returns 지연시간(ms)
|
||||
* @see features/kis-realtime/stores/kisWebSocketStore.ts ws.onclose
|
||||
*/
|
||||
function getReconnectDelayMs(attempt: number) {
|
||||
const exponential = RECONNECT_BASE_DELAY_MS * 2 ** Math.max(0, attempt - 1);
|
||||
const clamped = Math.min(exponential, RECONNECT_MAX_DELAY_MS);
|
||||
const jitter = Math.floor(Math.random() * RECONNECT_JITTER_MS);
|
||||
return clamped + jitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 소켓이 OPEN 또는 CONNECTING 상태인지 검사합니다.
|
||||
* @param target 검사 대상 소켓
|
||||
* @returns 연결 유지/진행 상태면 true
|
||||
* @see features/kis-realtime/stores/kisWebSocketStore.ts connect
|
||||
*/
|
||||
function isSocketOpenOrConnecting(target: WebSocket | null) {
|
||||
if (!target) return false;
|
||||
return (
|
||||
target.readyState === WebSocket.OPEN ||
|
||||
target.readyState === WebSocket.CONNECTING
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 새 연결을 시작하면 안 되는 소켓 상태인지 검사합니다.
|
||||
* @param target 검사 대상 소켓
|
||||
* @returns OPEN/CONNECTING/CLOSING 중 하나면 true
|
||||
* @see features/kis-realtime/stores/kisWebSocketStore.ts connect
|
||||
*/
|
||||
function isSocketUnavailableForNewConnect(target: WebSocket | null) {
|
||||
if (!target) return false;
|
||||
return (
|
||||
target.readyState === WebSocket.OPEN ||
|
||||
target.readyState === WebSocket.CONNECTING ||
|
||||
target.readyState === WebSocket.CLOSING
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 특정 웹소켓 인스턴스가 완전히 닫힐 때까지 대기합니다.
|
||||
* @param target 대기할 웹소켓 인스턴스
|
||||
* @param timeoutMs 최대 대기 시간(ms)
|
||||
* @returns close/error/timeout 중 먼저 완료되면 resolve
|
||||
* @see features/kis-realtime/stores/kisWebSocketStore.ts connect/reconnect
|
||||
*/
|
||||
function waitForSocketClose(target: WebSocket, timeoutMs = 2_000) {
|
||||
if (target.readyState === WebSocket.CLOSED) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
const onClose = () => finish();
|
||||
const onError = () => finish();
|
||||
const timeoutId = window.setTimeout(() => finish(), timeoutMs);
|
||||
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
window.clearTimeout(timeoutId);
|
||||
target.removeEventListener("close", onClose);
|
||||
target.removeEventListener("error", onError);
|
||||
resolve();
|
||||
};
|
||||
|
||||
target.addEventListener("close", onClose);
|
||||
target.addEventListener("error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 실시간 데이터(TR/종목코드)와 등록된 구독자를 매칭해 콜백을 실행합니다.
|
||||
* 종목코드 접두(prefix) 차이(A005930/J005930 등)와 구독 심볼 형식 차이를 허용합니다.
|
||||
* @param trId 수신 TR ID
|
||||
* @param rawSymbol 수신 데이터의 원본 종목코드
|
||||
* @param payload 웹소켓 원문 메시지
|
||||
* @see features/trade/hooks/useTradeTickSubscription.ts 체결 구독 콜백
|
||||
* @see features/trade/hooks/useOrderbookSubscription.ts 호가 구독 콜백
|
||||
*/
|
||||
function dispatchRealtimeMessageToSubscribers(
|
||||
trId: string,
|
||||
rawSymbol: string,
|
||||
payload: string,
|
||||
) {
|
||||
const callbackSet = new Set<RealtimeCallback>();
|
||||
const normalizedIncomingSymbol = normalizeRealtimeSymbol(rawSymbol);
|
||||
|
||||
// 1) 정확히 일치하는 key 우선
|
||||
const exactKey = `${trId}|${rawSymbol}`;
|
||||
subscribers.get(exactKey)?.forEach((callback) => callbackSet.add(callback));
|
||||
|
||||
// 2) 숫자 6자리 기준(정규화)으로 일치하는 key 매칭
|
||||
subscribers.forEach((callbacks, key) => {
|
||||
const [subscribedTrId, subscribedSymbol = ""] = key.split("|");
|
||||
if (subscribedTrId !== trId) return;
|
||||
if (!normalizedIncomingSymbol) return;
|
||||
|
||||
const normalizedSubscribedSymbol =
|
||||
normalizeRealtimeSymbol(subscribedSymbol);
|
||||
if (!normalizedSubscribedSymbol) return;
|
||||
if (normalizedIncomingSymbol !== normalizedSubscribedSymbol) return;
|
||||
|
||||
callbacks.forEach((callback) => callbackSet.add(callback));
|
||||
});
|
||||
|
||||
// 3) 심볼 매칭이 실패한 경우에도 같은 TR 전체 콜백으로 안전 fallback
|
||||
if (callbackSet.size === 0) {
|
||||
subscribers.forEach((callbacks, key) => {
|
||||
const [subscribedTrId] = key.split("|");
|
||||
if (subscribedTrId !== trId) return;
|
||||
callbacks.forEach((callback) => callbackSet.add(callback));
|
||||
});
|
||||
}
|
||||
|
||||
callbackSet.forEach((callback) => callback(payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 실시간 종목코드를 비교 가능한 6자리 숫자 코드로 정규화합니다.
|
||||
* @param value 원본 종목코드 (예: 005930, A005930)
|
||||
* @returns 정규화된 6자리 코드. 파싱 불가 시 원본 trim 값 반환
|
||||
* @see features/kis-realtime/stores/kisWebSocketStore.ts dispatchRealtimeMessageToSubscribers
|
||||
*/
|
||||
function normalizeRealtimeSymbol(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "";
|
||||
|
||||
const digits = trimmed.replace(/\D/g, "");
|
||||
if (digits.length >= 6) {
|
||||
return digits.slice(-6);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
29
features/kis-realtime/utils/websocketUtils.ts
Normal file
29
features/kis-realtime/utils/websocketUtils.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @file features/kis-realtime/utils/websocketUtils.ts
|
||||
* @description KIS 웹소켓 메시지 생성 및 파싱 관련 유틸리티 함수 모음
|
||||
*/
|
||||
|
||||
/**
|
||||
* @description KIS 실시간 구독/해제 소켓 메시지를 생성합니다.
|
||||
*/
|
||||
export function buildKisRealtimeMessage(
|
||||
approvalKey: string,
|
||||
symbol: string,
|
||||
trId: string,
|
||||
trType: "1" | "2",
|
||||
) {
|
||||
return {
|
||||
header: {
|
||||
approval_key: approvalKey,
|
||||
custtype: "P",
|
||||
tr_type: trType,
|
||||
"content-type": "utf-8",
|
||||
},
|
||||
body: {
|
||||
input: {
|
||||
tr_id: trId,
|
||||
tr_key: symbol,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
110
features/layout/components/GlobalAlertModal.tsx
Normal file
110
features/layout/components/GlobalAlertModal.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Info } from "lucide-react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useGlobalAlertStore } from "@/features/layout/stores/use-global-alert-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function GlobalAlertModal() {
|
||||
const {
|
||||
isOpen,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
isSingleButton,
|
||||
closeAlert,
|
||||
} = useGlobalAlertStore();
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
closeAlert();
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm?.();
|
||||
closeAlert();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onCancel?.();
|
||||
closeAlert();
|
||||
};
|
||||
|
||||
const Icon = {
|
||||
success: CheckCircle2,
|
||||
error: AlertCircle,
|
||||
warning: AlertTriangle,
|
||||
info: Info,
|
||||
}[type];
|
||||
|
||||
const iconColor = {
|
||||
success: "text-emerald-500",
|
||||
error: "text-red-500",
|
||||
warning: "text-amber-500",
|
||||
info: "text-blue-500",
|
||||
}[type];
|
||||
|
||||
const bgColor = {
|
||||
success: "bg-emerald-50 dark:bg-emerald-950/20",
|
||||
error: "bg-red-50 dark:bg-red-950/20",
|
||||
warning: "bg-amber-50 dark:bg-amber-950/20",
|
||||
info: "bg-blue-50 dark:bg-blue-950/20",
|
||||
}[type];
|
||||
|
||||
return (
|
||||
<AlertDialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<AlertDialogContent className="sm:max-w-[400px]">
|
||||
<AlertDialogHeader>
|
||||
<div className="flex flex-col items-center gap-4 text-center sm:flex-row sm:text-left">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-12 w-12 shrink-0 items-center justify-center rounded-full",
|
||||
bgColor,
|
||||
iconColor,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-sm leading-relaxed">
|
||||
{message}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="mt-4 sm:justify-end">
|
||||
{!isSingleButton && (
|
||||
<AlertDialogCancel onClick={handleCancel} className="mt-2 sm:mt-0">
|
||||
{cancelLabel || "취소"}
|
||||
</AlertDialogCancel>
|
||||
)}
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirm}
|
||||
className={cn(
|
||||
type === "error" && "bg-red-600 hover:bg-red-700",
|
||||
type === "warning" && "bg-amber-600 hover:bg-amber-700",
|
||||
type === "success" && "bg-emerald-600 hover:bg-emerald-700",
|
||||
)}
|
||||
>
|
||||
{confirmLabel || "확인"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
108
features/layout/components/Logo.tsx
Normal file
108
features/layout/components/Logo.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface LogoProps {
|
||||
className?: string;
|
||||
variant?: "symbol" | "full";
|
||||
/** 배경과 섞이는 모드 (홈 화면 등). 로고가 흰색으로 표시됩니다. */
|
||||
blendWithBackground?: boolean;
|
||||
}
|
||||
|
||||
export function Logo({
|
||||
className,
|
||||
variant = "full",
|
||||
blendWithBackground = false,
|
||||
}: LogoProps) {
|
||||
// 색상 클래스 정의
|
||||
const mainColorClass = blendWithBackground
|
||||
? "fill-brand-500 stroke-brand-500" // 배경 혼합 모드에서도 심볼은 브랜드 컬러 유지
|
||||
: "fill-brand-600 stroke-brand-600 dark:fill-brand-500 dark:stroke-brand-500";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative flex items-center gap-2 select-none", className)}
|
||||
aria-label="JOORIN-E Logo"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
variant === "full" ? "h-10 w-10" : "h-full w-full",
|
||||
)}
|
||||
>
|
||||
<defs>
|
||||
{/* Mask for the cutout effect around the arrow */}
|
||||
<mask id="arrow-cutout">
|
||||
<rect width="100%" height="100%" fill="white" />
|
||||
<path
|
||||
d="M10 75 C 35 45, 55 85, 90 25"
|
||||
fill="none"
|
||||
stroke="black"
|
||||
strokeWidth="12"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Arrow Head Cutout */}
|
||||
<path
|
||||
d="M90 25 L 78 32 L 85 42 Z"
|
||||
fill="black"
|
||||
stroke="black"
|
||||
strokeWidth="6"
|
||||
strokeLinejoin="round"
|
||||
transform="rotate(-15 90 25)"
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
|
||||
{/* ========== BARS (Masked) ========== */}
|
||||
<g
|
||||
mask="url(#arrow-cutout)"
|
||||
className={
|
||||
blendWithBackground
|
||||
? "fill-brand-500" // 배경 혼합 모드에서도 브랜드 컬러 사용
|
||||
: "fill-brand-600 dark:fill-brand-500"
|
||||
}
|
||||
>
|
||||
{/* Bar 1 (Left, Short) */}
|
||||
<rect x="15" y="45" width="18" height="40" rx="4" />
|
||||
{/* Bar 2 (Middle, Medium) */}
|
||||
<rect x="41" y="30" width="18" height="55" rx="4" />
|
||||
{/* Bar 3 (Right, Tall) */}
|
||||
<rect x="67" y="10" width="18" height="75" rx="4" />
|
||||
</g>
|
||||
|
||||
{/* ========== ARROW (Foreground) ========== */}
|
||||
<g className={mainColorClass}>
|
||||
{/* Arrow Path */}
|
||||
<path
|
||||
d="M10 75 C 35 45, 55 85, 90 25"
|
||||
fill="none"
|
||||
strokeWidth="7"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Arrow Head */}
|
||||
<path
|
||||
d="M90 25 L 78 32 L 85 42 Z"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
transform="rotate(-15 90 25)"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* ========== TEXT (Optional) ========== */}
|
||||
{variant === "full" && (
|
||||
<span
|
||||
className={cn(
|
||||
"font-bold tracking-tight",
|
||||
blendWithBackground
|
||||
? "text-white opacity-95"
|
||||
: "text-brand-900 dark:text-brand-50",
|
||||
)}
|
||||
style={{ fontSize: "1.35rem", fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
JOORIN-E
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,6 @@
|
||||
/**
|
||||
/**
|
||||
* @file features/layout/components/header.tsx
|
||||
* @description 애플리케이션 최상단 헤더 컴포넌트 (네비게이션, 테마, 유저 메뉴)
|
||||
* @remarks
|
||||
* - [레이어] Components/UI/Layout
|
||||
* - [사용자 행동] 홈 이동, 테마 변경, 로그인/회원가입 이동, 대시보드 이동
|
||||
* - [데이터 흐름] User Prop -> UI Conditional Rendering
|
||||
* - [연관 파일] layout.tsx, session-timer.tsx, user-menu.tsx
|
||||
* @description 애플리케이션 상단 헤더 컴포넌트
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
@@ -14,75 +9,130 @@ import { AUTH_ROUTES } from "@/features/auth/constants";
|
||||
import { UserMenu } from "@/features/layout/components/user-menu";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { SessionTimer } from "@/features/auth/components/session-timer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Logo } from "@/features/layout/components/Logo";
|
||||
|
||||
interface HeaderProps {
|
||||
/** 현재 로그인한 사용자 정보 (없으면 null) */
|
||||
/** 현재 로그인 사용자 정보(null 가능) */
|
||||
user: User | null;
|
||||
/** 대시보드 링크 표시 여부 */
|
||||
/** 대시보드 링크 버튼 노출 여부 */
|
||||
showDashboardLink?: boolean;
|
||||
/** 홈 랜딩에서 배경과 자연스럽게 섞이는 헤더 모드 */
|
||||
blendWithBackground?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 헤더 컴포넌트
|
||||
* @param user Supabase User 객체
|
||||
* @param showDashboardLink 대시보드 바로가기 버튼 노출 여부
|
||||
* @param showDashboardLink 대시보드 버튼 노출 여부
|
||||
* @param blendWithBackground 홈 랜딩 전용 반투명 모드
|
||||
* @returns Header JSX
|
||||
* @see layout.tsx - RootLayout에서 데이터 주입하여 호출
|
||||
* @see app/(home)/page.tsx 홈 랜딩에서 blendWithBackground=true로 호출
|
||||
*/
|
||||
export function Header({ user, showDashboardLink = false }: HeaderProps) {
|
||||
export function Header({
|
||||
user,
|
||||
showDashboardLink = false,
|
||||
blendWithBackground = false,
|
||||
}: HeaderProps) {
|
||||
return (
|
||||
<header className="fixed top-0 z-40 w-full border-b border-border/40 bg-background/80 backdrop-blur-xl supports-backdrop-filter:bg-background/60">
|
||||
<div className="flex h-16 w-full items-center justify-between px-4 md:px-6">
|
||||
{/* ========== 좌측: 로고 영역 ========== */}
|
||||
<Link href={AUTH_ROUTES.HOME} className="flex items-center gap-2 group">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary/10 transition-transform duration-200 group-hover:scale-110">
|
||||
<div className="h-5 w-5 rounded-lg bg-linear-to-br from-brand-500 to-brand-700" />
|
||||
</div>
|
||||
<span className="text-xl font-bold tracking-tight text-foreground transition-colors group-hover:text-primary">
|
||||
AutoTrade
|
||||
</span>
|
||||
<header
|
||||
className={cn(
|
||||
"fixed inset-x-0 top-0 z-50 w-full",
|
||||
blendWithBackground
|
||||
? "text-white"
|
||||
: "border-b border-border/40 bg-background/80 backdrop-blur-xl supports-backdrop-filter:bg-background/60",
|
||||
)}
|
||||
>
|
||||
{blendWithBackground && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-linear-to-b from-black/70 via-black/35 to-transparent"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 flex h-16 w-full items-center justify-between px-4 md:px-6",
|
||||
blendWithBackground
|
||||
? "bg-black/30 backdrop-blur-xl supports-backdrop-filter:bg-black/20"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{/* ========== LEFT: LOGO SECTION ========== */}
|
||||
{/* ========== LEFT: LOGO SECTION ========== */}
|
||||
<Link href={AUTH_ROUTES.HOME} className="group flex items-center gap-2">
|
||||
<Logo
|
||||
variant="full"
|
||||
className="h-10 text-xl transition-transform duration-200 group-hover:scale-105"
|
||||
blendWithBackground={blendWithBackground}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* ========== 우측: 액션 버튼 영역 ========== */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 테마 토글 */}
|
||||
<ThemeToggle />
|
||||
{/* ========== RIGHT: ACTION SECTION ========== */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 sm:gap-3",
|
||||
blendWithBackground ? "text-white" : "",
|
||||
)}
|
||||
>
|
||||
<ThemeToggle
|
||||
className={cn(
|
||||
blendWithBackground
|
||||
? "rounded-full border border-white/40 bg-black/50 text-white! backdrop-blur-md hover:bg-black/65 focus-visible:ring-white/80"
|
||||
: "",
|
||||
)}
|
||||
iconClassName={blendWithBackground ? "text-white!" : undefined}
|
||||
/>
|
||||
|
||||
{user ? (
|
||||
// [Case 1] 로그인 상태
|
||||
<>
|
||||
{/* 세션 타임아웃 타이머 */}
|
||||
<SessionTimer />
|
||||
<SessionTimer blendWithBackground={blendWithBackground} />
|
||||
|
||||
{showDashboardLink && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hidden sm:inline-flex"
|
||||
className={cn(
|
||||
"hidden font-medium sm:inline-flex",
|
||||
blendWithBackground
|
||||
? "rounded-full border border-white/40 bg-black/50 text-white! backdrop-blur-md [text-shadow:0_1px_8px_rgba(0,0,0,0.45)] hover:bg-black/65 hover:text-white!"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<Link href={AUTH_ROUTES.DASHBOARD}>대시보드</Link>
|
||||
<Link href={AUTH_ROUTES.DASHBOARD}>시작하기</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 사용자 드롭다운 메뉴 */}
|
||||
<UserMenu user={user} />
|
||||
<UserMenu user={user} blendWithBackground={blendWithBackground} />
|
||||
</>
|
||||
) : (
|
||||
// [Case 2] 비로그인 상태
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hidden sm:inline-flex"
|
||||
className={cn(
|
||||
"hidden sm:inline-flex",
|
||||
blendWithBackground
|
||||
? "rounded-full border border-white/40 bg-black/50 text-white! backdrop-blur-md hover:bg-black/65 hover:text-white!"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<Link href={AUTH_ROUTES.LOGIN}>로그인</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" className="rounded-full px-6">
|
||||
<Link href={AUTH_ROUTES.SIGNUP}>시작하기</Link>
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
className={cn(
|
||||
"rounded-full px-6",
|
||||
blendWithBackground
|
||||
? "bg-brand-500/90 text-white shadow-lg shadow-brand-700/40 hover:bg-brand-400"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<Link href={AUTH_ROUTES.SIGNUP}>회원가입</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,51 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BarChart2, Home, Settings, User, Wallet } from "lucide-react";
|
||||
import {
|
||||
BarChart2,
|
||||
ChevronLeft,
|
||||
Home,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { MenuItem } from "../types";
|
||||
|
||||
const MENU_ITEMS: MenuItem[] = [
|
||||
{
|
||||
title: "대시보드",
|
||||
href: "/",
|
||||
href: "/dashboard",
|
||||
icon: Home,
|
||||
variant: "default",
|
||||
matchExact: true,
|
||||
showInBottomNav: true,
|
||||
},
|
||||
{
|
||||
title: "자동매매",
|
||||
href: "/trade",
|
||||
icon: BarChart2,
|
||||
variant: "ghost",
|
||||
},
|
||||
{
|
||||
title: "자산현황",
|
||||
href: "/assets",
|
||||
icon: Wallet,
|
||||
variant: "ghost",
|
||||
},
|
||||
{
|
||||
title: "프로필",
|
||||
href: "/profile",
|
||||
icon: User,
|
||||
variant: "ghost",
|
||||
badge: "LIVE",
|
||||
showInBottomNav: true,
|
||||
},
|
||||
{
|
||||
title: "설정",
|
||||
href: "/settings",
|
||||
icon: Settings,
|
||||
variant: "ghost",
|
||||
showInBottomNav: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* @description 메인 좌측 사이드바(데스크탑): 기본 축소 상태에서 hover/focus 시 확장됩니다.
|
||||
* @see features/layout/components/sidebar.tsx MENU_ITEMS 한 곳에서 메뉴/배지/모바일 탭 구성을 함께 관리합니다.
|
||||
*/
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<aside className="fixed left-0 top-14 z-30 hidden h-[calc(100vh-3.5rem)] w-full shrink-0 overflow-y-auto border-r border-zinc-200 bg-white py-6 pl-2 pr-6 dark:border-zinc-800 dark:bg-black md:sticky md:block md:w-64 lg:w-72">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<aside
|
||||
className={cn(
|
||||
"relative hidden h-[calc(100vh-4rem)] shrink-0 overflow-x-visible overflow-y-auto border-r border-brand-100 bg-white px-2 py-5 transition-[width] duration-200 dark:border-brand-900/40 dark:bg-background md:sticky md:top-16 md:block",
|
||||
isExpanded ? "md:w-64" : "md:w-[74px]",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded((prev) => !prev)}
|
||||
aria-label={isExpanded ? "Collapse sidebar" : "Expand sidebar"}
|
||||
className={cn(
|
||||
"absolute -right-3 top-20 z-50 hidden h-8 w-8 items-center justify-center rounded-full",
|
||||
"border border-zinc-200/50 bg-white/80 shadow-lg backdrop-blur-md transition-all duration-300",
|
||||
"hover:scale-110 hover:bg-white active:scale-95",
|
||||
"dark:border-zinc-800/50 dark:bg-zinc-900/80 dark:hover:bg-zinc-900",
|
||||
"md:flex",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft
|
||||
className={cn(
|
||||
"h-4 w-4 text-zinc-600 transition-transform duration-300 dark:text-zinc-300",
|
||||
isExpanded ? "rotate-0" : "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div className="h-1.5" />
|
||||
{/* ========== SIDEBAR ITEMS ========== */}
|
||||
<div className="flex flex-col space-y-1.5">
|
||||
{MENU_ITEMS.map((item) => {
|
||||
const isActive = item.matchExact
|
||||
? pathname === item.href
|
||||
@@ -55,22 +85,53 @@ export function Sidebar() {
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
title={item.title}
|
||||
className={cn(
|
||||
"group flex items-center rounded-md px-3 py-2 text-sm font-medium hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 transition-colors",
|
||||
"group/item relative flex items-center rounded-xl px-3 py-2.5 text-sm transition-colors",
|
||||
"hover:bg-brand-50 hover:text-brand-800 dark:hover:bg-brand-900/30 dark:hover:text-brand-100",
|
||||
isActive
|
||||
? "bg-zinc-100 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50"
|
||||
: "text-zinc-500 dark:text-zinc-400",
|
||||
? "bg-brand-100 text-brand-800 shadow-sm dark:bg-brand-900/40 dark:text-brand-100"
|
||||
: "text-muted-foreground dark:text-brand-200/80",
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
{/* ========== ACTIVE BAR ========== */}
|
||||
<span
|
||||
className={cn(
|
||||
"mr-3 h-5 w-5 shrink-0 transition-colors",
|
||||
isActive
|
||||
? "text-zinc-900 dark:text-zinc-50"
|
||||
: "text-zinc-400 group-hover:text-zinc-900 dark:text-zinc-500 dark:group-hover:text-zinc-50",
|
||||
"absolute left-0 top-1/2 h-5 -translate-y-1/2 rounded-r-full transition-all",
|
||||
isActive ? "w-1.5 bg-brand-500" : "w-0",
|
||||
)}
|
||||
/>
|
||||
{item.title}
|
||||
|
||||
{/* ========== ICON + DOT BADGE ========== */}
|
||||
<item.icon
|
||||
className={cn(
|
||||
"h-5 w-5 shrink-0 transition-colors",
|
||||
isActive
|
||||
? "text-brand-700 dark:text-brand-200"
|
||||
: "text-zinc-400 group-hover/item:text-brand-700 dark:text-brand-300/70 dark:group-hover/item:text-brand-200",
|
||||
)}
|
||||
/>
|
||||
|
||||
{item.badge && !isExpanded && (
|
||||
<span className="absolute left-7 top-2 h-2 w-2 rounded-full bg-brand-500" />
|
||||
)}
|
||||
|
||||
{/* ========== LABEL (EXPAND ON TOGGLE) ========== */}
|
||||
<span
|
||||
className={cn(
|
||||
"ml-3 flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap transition-all duration-200",
|
||||
isExpanded
|
||||
? "max-w-[180px] opacity-100"
|
||||
: "max-w-0 opacity-0",
|
||||
)}
|
||||
>
|
||||
<span className="truncate font-medium">{item.title}</span>
|
||||
{item.badge && (
|
||||
<span className="shrink-0 rounded-full bg-brand-100 px-1.5 py-0.5 text-[10px] font-semibold text-brand-700">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
@@ -78,3 +139,58 @@ export function Sidebar() {
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 모바일 하단 빠른 탭 네비게이션.
|
||||
* @see features/layout/components/sidebar.tsx Sidebar와 같은 MENU_ITEMS를 공유해 중복 정의를 줄입니다.
|
||||
*/
|
||||
export function MobileBottomNav() {
|
||||
const pathname = usePathname();
|
||||
const bottomItems = MENU_ITEMS.filter(
|
||||
(item) => item.showInBottomNav !== false,
|
||||
);
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="모바일 빠른 메뉴"
|
||||
className="fixed inset-x-0 bottom-0 z-40 border-t border-brand-100 bg-white/95 backdrop-blur-sm supports-backdrop-filter:bg-white/80 dark:border-brand-900/40 dark:bg-background/95 dark:supports-backdrop-filter:bg-background/80 md:hidden"
|
||||
>
|
||||
{/* ========== BOTTOM NAV ITEMS ========== */}
|
||||
<div
|
||||
className={cn(
|
||||
"grid",
|
||||
bottomItems.length === 4 ? "grid-cols-4" : "grid-cols-5",
|
||||
)}
|
||||
>
|
||||
{bottomItems.map((item) => {
|
||||
const isActive = item.matchExact
|
||||
? pathname === item.href
|
||||
: pathname.startsWith(item.href);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={`bottom-${item.href}`}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex min-h-16 flex-col items-center justify-center gap-1.5 text-[11px] font-medium transition-colors",
|
||||
isActive
|
||||
? "text-brand-700"
|
||||
: "text-muted-foreground hover:text-brand-700 dark:text-brand-200/80 dark:hover:text-brand-200",
|
||||
)}
|
||||
>
|
||||
<span className="relative">
|
||||
<item.icon
|
||||
className={cn("h-4 w-4", isActive && "text-brand-600")}
|
||||
/>
|
||||
{item.badge && (
|
||||
<span className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-brand-500" />
|
||||
)}
|
||||
</span>
|
||||
<span className="leading-none">{item.title}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
/**
|
||||
/**
|
||||
* @file features/layout/components/user-menu.tsx
|
||||
* @description 사용자 프로필 드롭다운 메뉴 컴포넌트
|
||||
* @remarks
|
||||
* - [레이어] Components/UI
|
||||
* - [사용자 행동] Avatar 클릭 -> 드롭다운 오픈 -> 프로필/설정 이동 또는 로그아웃
|
||||
* - [연관 파일] header.tsx, features/auth/actions.ts (로그아웃)
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { User } from "@supabase/supabase-js";
|
||||
import { LogOut, Settings } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signout } from "@/features/auth/actions";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
@@ -19,61 +18,94 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { User } from "@supabase/supabase-js";
|
||||
import { LogOut, Settings, User as UserIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SESSION_RELATED_STORAGE_KEYS = [
|
||||
"session-storage",
|
||||
"auth-storage",
|
||||
"autotrade-kis-runtime-store",
|
||||
] as const;
|
||||
|
||||
interface UserMenuProps {
|
||||
/** Supabase User 객체 */
|
||||
user: User | null;
|
||||
/** 홈 랜딩의 shader 배경 위에서 대비를 높이는 모드 */
|
||||
blendWithBackground?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 메뉴/프로필 컴포넌트 (로그인 시 헤더 노출)
|
||||
* 사용자 메뉴/프로필 컴포넌트
|
||||
* @param user 로그인한 사용자 정보
|
||||
* @returns Avatar 버튼 및 드롭다운 메뉴
|
||||
* @param blendWithBackground shader 배경 위 가독성 모드
|
||||
* @returns Avatar 버튼 + 드롭다운 메뉴
|
||||
* @see features/layout/components/header.tsx 헤더 우측 액션 영역에서 호출
|
||||
*/
|
||||
export function UserMenu({ user }: UserMenuProps) {
|
||||
export function UserMenu({ user, blendWithBackground = false }: UserMenuProps) {
|
||||
const router = useRouter();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
/**
|
||||
* @description 로그아웃 제출 직전에 세션 관련 로컬 스토리지를 정리합니다.
|
||||
* @see features/auth/actions.ts signout - 서버 세션 종료를 담당합니다.
|
||||
*/
|
||||
const clearSessionRelatedStorage = () => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
for (const key of SESSION_RELATED_STORAGE_KEYS) {
|
||||
window.localStorage.removeItem(key);
|
||||
window.sessionStorage.removeItem(key);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-2 outline-none">
|
||||
<Avatar className="h-8 w-8 transition-opacity hover:opacity-80">
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-full outline-none transition-colors",
|
||||
blendWithBackground
|
||||
? "ring-1 ring-white/30 hover:bg-black/30 focus-visible:ring-2 focus-visible:ring-white/70"
|
||||
: "",
|
||||
)}
|
||||
aria-label="사용자 메뉴 열기"
|
||||
>
|
||||
<Avatar className="h-8 w-8 transition-opacity hover:opacity-90">
|
||||
<AvatarImage src={user.user_metadata?.avatar_url} />
|
||||
<AvatarFallback className="bg-linear-to-br from-brand-500 to-brand-700 text-white text-xs font-bold">
|
||||
<AvatarFallback
|
||||
className={cn(
|
||||
"text-xs font-bold text-white",
|
||||
blendWithBackground
|
||||
? "bg-brand-500/90 [text-shadow:0_1px_8px_rgba(0,0,0,0.45)]"
|
||||
: "bg-linear-to-br from-brand-500 to-brand-700",
|
||||
)}
|
||||
>
|
||||
{user.email?.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{user.user_metadata?.full_name ||
|
||||
user.user_metadata?.name ||
|
||||
"사용자"}
|
||||
</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
{user.email}
|
||||
{user.user_metadata?.full_name || user.user_metadata?.name || "사용자"}
|
||||
</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => router.push("/profile")}>
|
||||
<UserIcon className="mr-2 h-4 w-4" />
|
||||
<span>프로필</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>설정</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<form action={signout}>
|
||||
|
||||
<form action={signout} onSubmit={clearSessionRelatedStorage}>
|
||||
<DropdownMenuItem asChild>
|
||||
<button className="w-full text-red-600 dark:text-red-400">
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
|
||||
84
features/layout/hooks/use-global-alert.ts
Normal file
84
features/layout/hooks/use-global-alert.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { ReactNode } from "react";
|
||||
import {
|
||||
AlertType,
|
||||
useGlobalAlertStore,
|
||||
} from "@/features/layout/stores/use-global-alert-store";
|
||||
|
||||
interface AlertOptions {
|
||||
title?: ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
onConfirm?: () => void;
|
||||
onCancel?: () => void;
|
||||
type?: AlertType;
|
||||
}
|
||||
|
||||
export function useGlobalAlert() {
|
||||
const openAlert = useGlobalAlertStore((state) => state.openAlert);
|
||||
const closeAlert = useGlobalAlertStore((state) => state.closeAlert);
|
||||
|
||||
const show = (
|
||||
message: ReactNode,
|
||||
type: AlertType = "info",
|
||||
options?: AlertOptions,
|
||||
) => {
|
||||
openAlert({
|
||||
message,
|
||||
type,
|
||||
title: options?.title || getDefaultTitle(type),
|
||||
confirmLabel: options?.confirmLabel || "확인",
|
||||
cancelLabel: options?.cancelLabel,
|
||||
onConfirm: options?.onConfirm,
|
||||
onCancel: options?.onCancel,
|
||||
isSingleButton: true,
|
||||
});
|
||||
};
|
||||
|
||||
const confirm = (
|
||||
message: ReactNode,
|
||||
type: AlertType = "warning",
|
||||
options?: AlertOptions,
|
||||
) => {
|
||||
openAlert({
|
||||
message,
|
||||
type,
|
||||
title: options?.title || "확인",
|
||||
confirmLabel: options?.confirmLabel || "확인",
|
||||
cancelLabel: options?.cancelLabel || "취소",
|
||||
onConfirm: options?.onConfirm,
|
||||
onCancel: options?.onCancel,
|
||||
isSingleButton: false,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
alert: {
|
||||
success: (message: ReactNode, options?: AlertOptions) =>
|
||||
show(message, "success", options),
|
||||
warning: (message: ReactNode, options?: AlertOptions) =>
|
||||
show(message, "warning", options),
|
||||
error: (message: ReactNode, options?: AlertOptions) =>
|
||||
show(message, "error", options),
|
||||
info: (message: ReactNode, options?: AlertOptions) =>
|
||||
show(message, "info", options),
|
||||
confirm: (message: ReactNode, options?: AlertOptions) =>
|
||||
confirm(message, options?.type || "warning", options),
|
||||
},
|
||||
close: closeAlert,
|
||||
};
|
||||
}
|
||||
|
||||
function getDefaultTitle(type: AlertType) {
|
||||
switch (type) {
|
||||
case "success":
|
||||
return "성공";
|
||||
case "error":
|
||||
return "오류";
|
||||
case "warning":
|
||||
return "주의";
|
||||
case "info":
|
||||
return "알림";
|
||||
default:
|
||||
return "알림";
|
||||
}
|
||||
}
|
||||
43
features/layout/stores/use-global-alert-store.ts
Normal file
43
features/layout/stores/use-global-alert-store.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ReactNode } from "react";
|
||||
import { create } from "zustand";
|
||||
|
||||
export type AlertType = "success" | "warning" | "error" | "info";
|
||||
|
||||
export interface AlertState {
|
||||
isOpen: boolean;
|
||||
type: AlertType;
|
||||
title: ReactNode;
|
||||
message: ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
onConfirm?: () => void;
|
||||
onCancel?: () => void;
|
||||
// 단일 버튼 모드 여부 (Confirm 모달이 아닌 단순 Alert)
|
||||
isSingleButton?: boolean;
|
||||
}
|
||||
|
||||
interface AlertActions {
|
||||
openAlert: (params: Omit<AlertState, "isOpen">) => void;
|
||||
closeAlert: () => void;
|
||||
}
|
||||
|
||||
const initialState: AlertState = {
|
||||
isOpen: false,
|
||||
type: "info",
|
||||
title: "",
|
||||
message: "",
|
||||
confirmLabel: "확인",
|
||||
cancelLabel: "취소",
|
||||
isSingleButton: true,
|
||||
};
|
||||
|
||||
export const useGlobalAlertStore = create<AlertState & AlertActions>((set) => ({
|
||||
...initialState,
|
||||
openAlert: (params) =>
|
||||
set({
|
||||
...initialState, // 초기화 후 설정
|
||||
...params,
|
||||
isOpen: true,
|
||||
}),
|
||||
closeAlert: () => set({ isOpen: false }),
|
||||
}));
|
||||
@@ -6,4 +6,6 @@ export interface MenuItem {
|
||||
icon: LucideIcon;
|
||||
variant: "default" | "ghost";
|
||||
matchExact?: boolean;
|
||||
badge?: string;
|
||||
showInBottomNav?: boolean;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user