22 Commits

Author SHA1 Message Date
406af7408a 스킬 정리 및 리팩토링 2026-02-26 09:05:17 +09:00
4c52d6d82f 한국투자증권 전체 api 문서 2026-02-25 10:02:06 +09:00
076f27a12a 호가창 매수,매도 색상 변경 2026-02-25 10:01:58 +09:00
f875e338eb codex rule 업데이트 2026-02-25 10:01:49 +09:00
a16af8ad7d 트레이딩창 UI 배치 및 UX 수정 및 기획서 추가 2026-02-24 15:48:02 +09:00
19ebb1c6ea 실시간 웹소켓 리팩토링 2026-02-23 17:09:16 +09:00
276ef09d89 차트 수정 2026-02-13 16:41:10 +09:00
b73867c65d [공통컴포넌트] alert 제작 2026-02-13 16:12:08 +09:00
7c194d7452 보안 점검 및 대시보드 문구 수정 2026-02-13 15:44:41 +09:00
1ac907cd27 대시보드 실시간 기능 추가 2026-02-13 12:56:56 +09:00
12feeb2775 대시보드 추가기능 + 계좌인증 2026-02-12 17:16:41 +09:00
434a814246 대시보드 구현 2026-02-12 14:20:07 +09:00
8f1d75b4d5 정리 2026-02-12 10:24:03 +09:00
3cea3e66d0 임시커밋 2026-02-11 16:31:28 +09:00
f650d51f68 디자인 변경 2026-02-11 15:27:03 +09:00
95291e6922 테마 적용 2026-02-11 14:06:06 +09:00
def87bd47a git ignore 추가 2026-02-11 11:19:44 +09:00
89bad1d141 차트 수정 2026-02-11 11:18:15 +09:00
e5a518b211 레이아웃 변경 대시보드 2026-02-10 17:29:57 +09:00
ca01f33d71 대시보드 중간 커밋 2026-02-10 17:16:49 +09:00
851a2acd69 대시보드 2026-02-06 17:50:35 +09:00
35916430b7 Merge branch 'features/version1' into dev 2026-02-06 14:48:35 +09:00
170 changed files with 41069 additions and 1613 deletions

View File

@@ -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 활용
- 변경 사항은 반드시 로컬에서 검증 후 완료 보고
- 에러 발생 시 근본 원인 파악 및 해결

View File

@@ -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는 파일명 + 함수명 + 역할**: 전체 경로 불필요
# 지금부터 작업
내가 주는 코드를 위 규칙에 맞게 "주석만" 보강하라.

View File

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

View File

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

View 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. 계획 대비 완료체크]
- 완료/부분 완료/미완료
- 최종 판정: 배포 가능/보완 필요
```

View 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

View 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는 문서 근거 없이 단정하지 않는다.
- 구현 단계에서 성능에 큰 악영향이 보이면 즉시 메모(기록)하고 다음 단계에서 정리한다.

View 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

View 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. ...
[최종 판정]
- 배포 가능/보완 필요
```

View File

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

View 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-..-..: ...
```
## 규칙
- 계획 승인 전에 실제 구현 코드를 대량 작성하지 않는다.
- 파일 삭제는 반드시 필요성/대체 경로를 확인한 뒤 진행한다.
- 동작 변경과 리팩토링을 섞지 않는다.

View 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

View 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 개선을 했으면 왜 개선했는지 한 줄 근거를 남긴다.

View 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

View 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 결과
- 생략/실패 사유 및 대체 검증

View 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

View 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

View File

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

@@ -0,0 +1,6 @@
{
"tools": {
"approvalMode": "auto_edit",
"allowed": ["run_shell_command"]
}
}

6
.gitignore vendored
View File

@@ -119,8 +119,14 @@ storybook-static/
*.local
.cache/
node_modules
.tmp/
# ========================================
# Custom
# ========================================
.playwright-mcp/
# ========================================
# Documentation (문서)
# ========================================
docs/

View File

@@ -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`
- 단순 설명/문서 요약/잡담 요청에는 파이프라인 스킬을 강제하지 않는다.

View File

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

@@ -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는 운영 환경에 맞게 보완이 필요합니다.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 />
&apos;&apos; .
</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>

View File

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

View File

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

View 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
View 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
View 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
View 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);
}

View 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);
}

View 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,
"주문내역/매매일지 조회 중 오류가 발생했습니다.",
),
});
}
}

View 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, "잔고 조회 중 오류가 발생했습니다."),
});
}
}

View 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 차트 조회 중 오류가 발생했습니다."),
});
}
}

View 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, "지수 조회 중 오류가 발생했습니다."),
});
}
}

View 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,
});
}
}

View 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}`];
}

View 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);
}

View 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;
}

View 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,
});
}
}

View 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
: "알 수 없는 오류가 발생했습니다.";
}

View 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,
});
}
}

View 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,
});
}
}

View File

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

View File

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

View 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 기준으로 맵에 추가합니다.

View 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`

Binary file not shown.

View 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 키/계좌 정보는 서버 저장을 금지하고 요청 단위 처리만 허용합니다.

View 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`

View 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")
}
```

View File

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

View 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"
>
&ldquo;{TONE_PHRASES[index].q}&rdquo;
</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
View 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 }

View 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 }

View File

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

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

View 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
View 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 }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;
}

View 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";
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 "********-**";
}

View 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>
);
}

View 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,
};
}

View 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 };
}

View 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,
};
}

View 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;
}

View 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;
}

View 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";
}

View 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,
};
}

View 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,
};
}

View File

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

View 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 };
}

View 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;
}

View 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,
},
},
};
}

View 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>
);
}

View 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>
);
}

View File

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

View File

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

View File

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

View 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 "알림";
}
}

View 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 }),
}));

View File

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